This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Deploy to GitHub Pages
on:
release:
types: [published]
concurrency:
group: "pages"
cancel-in-progress: false
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Delete old artifacts
uses: geekyeggo/delete-artifact@v5
with:
name: github-pages
failOnError: false
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+6
View File
@@ -0,0 +1,6 @@
.claude/settings.local.json
node_modules/
package.json
package-lock.json
tuning/screenshots/
tuning/results/*.json
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found — World Orogen</title>
<meta name="robots" content="noindex">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #030308; color: #fff;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; text-align: center; padding: 20px;
}
.card { max-width: 480px; }
h1 { font-size: 72px; margin-bottom: 8px; }
h2 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #ccd; }
p { color: #889; margin-bottom: 24px; line-height: 1.6; }
a {
display: inline-block; padding: 12px 28px;
background: rgba(80, 140, 255, 0.15); color: #7ab;
border: 1px solid rgba(80, 140, 255, 0.3); border-radius: 8px;
text-decoration: none; font-weight: 500; transition: background 0.2s;
}
a:hover { background: rgba(80, 140, 255, 0.25); }
</style>
</head>
<body>
<div class="card">
<h1>404</h1>
<h2>This planet doesn't exist yet</h2>
<p>The page you're looking for wasn't found. Head back to World Orogen and generate a new world instead.</p>
<a href="/">Build a New World</a>
</div>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
# CLAUDE.md
## Project Overview
World Orogen — a browser-based procedural planet generator using Three.js and ES modules with no build step.
**World Orogen is concept art for planets, not a geophysical simulator.** Every feature should prioritize making the output *look* more believable or helping users iterate faster. Never slow down generation to chase physical accuracy — if a simpler approximation looks just as good, use it. However, the scientific grounding is what makes the output convincing: tectonic models inspired by real geology, pressure-driven wind patterns, and Köppen classification aren't optional polish — they're the reason the output passes the glance test. Preserve and extend this scientific foundation whenever it serves the visuals. The tool's job is to be the fastest path from a blank page to a world worth building on.
## Guiding Principles
All three tenets should be considered simultaneously. When they conflict, break ties in this order:
1. **Artistic appeal** — The output should look visually interesting and compelling, informed by real science but not constrained by it. Aesthetics come first.
2. **Ease of use and efficiency** — The interface should be approachable and intuitive. Generation should be fast. Don't sacrifice usability for realism.
3. **Scientific plausibility** — Terrain, tectonics, and geology should be grounded in real planetary science. Results don't need to be physically accurate simulations, but they should be believable.
## What Users Love (Protect These)
User feedback consistently highlights these as World Orogen's core strengths. Any change should preserve or enhance them — never degrade them as a side effect.
1. **Climate simulation depth** — The climate view (wind, ocean currents, precipitation, Köppen) is the single most-cited differentiator. Users call it "the only map generator with this level of detail" and say it's what sets Orogen apart from Azgaar and every other tool. Never simplify or remove climate layers. When adding features, consider whether they can leverage the climate system (e.g. rivers fed by precipitation, settlements placed by climate).
2. **Instant, in-browser, zero-friction** — No install, no account, no build step. Users love that they can open a URL and have a planet in seconds. Never add mandatory sign-up, downloads, or server dependencies. Keep generation fast — if a feature risks slowing generation significantly, make it optional or deferred (like the existing on-demand climate above 300K).
3. **Interactive plate editing** — Users say "haven't seen this functionality anywhere else." The Ctrl-click multi-select → Rebuild workflow is a key differentiator. Don't break this interaction pattern. Extend it (e.g. plate direction editing) rather than replacing it.
4. **True globe with proper wrapping** — Users who came from Azgaar specifically cite the globe as a reason they switched. The globe-first experience, equirectangular map as secondary view, and seamless wrapping matter. Don't make the map view primary or break globe rendering.
5. **Free and open source** — Repeatedly praised. No paywalls, no feature-gating, no "pro" tier. This is a trust signal that drives adoption and contributions.
6. **Works on mobile** — Users are surprised it runs well on phones. Maintain the responsive bottom-sheet layout, touch targets, and pinch-to-zoom. Don't add features that only work on desktop without a mobile equivalent.
7. **Terrain aesthetics** — "Fractal-looking mountains," realistic erosion, organic coastlines. The visual quality of the terrain itself gets specific praise. Protect the artistic output of the erosion and terrain post-processing pipeline.
When proposing a new feature or change, ask: "Does this preserve all seven strengths above?" If it trades one for another, flag the tradeoff explicitly.
## Key Rules
After any code change, check whether README.md needs updating. The README documents all UI controls, features, algorithms, and project structure. If a change adds, removes, or modifies any of the following, update the README to match:
- Sliders, dropdowns, toggles, or other UI controls (names, ranges, defaults)
- User interactions (keyboard shortcuts, mouse actions, edit behaviors)
- Generation pipeline steps or algorithms
- Visual features (rendering, overlays, debug layers)
- Project file structure (new files, renamed files, removed files)
- External dependencies
After any code change, check whether the tutorial modal content (in `index.html`, inside `#tutorialOverlay`) needs updating. The tutorial steps describe the app's features and interactions. If a change adds, removes, or modifies any of the following, update the relevant tutorial step to match:
- Core workflow (how to generate a planet, what controls to use)
- Interactive features (navigation, editing, keyboard/mouse actions)
- What the tool does or its key selling points
After any code change that adds significant user-facing features, ask the developer if they would like to update the What's New modal (in `index.html`, inside `#whatsNewOverlay`). The modal is version-gated by the `VERSION` constant in `initWhatsNew()` in `js/main.js` — bumping this string will show the modal again to returning users on their next visit.
After any code change that affects the UI, ensure it works on mobile. The app uses a responsive bottom-sheet layout on screens ≤ 768px (`styles.css` media queries) and has touch-specific behavior throughout. If a change adds, removes, or modifies any of the following, verify and update the mobile experience:
- New buttons or controls — must have ≥ 44px touch targets on mobile (see `@media (max-width: 768px)` in `styles.css`)
- New interactions — must have touch equivalents; desktop uses Ctrl-click for plate editing, mobile uses `state.editMode` toggle (`js/edit-mode.js`); desktop uses scroll-to-zoom, mobile uses pinch (`js/scene.js`)
- Tooltips — must reposition above their trigger on mobile, not to the right (overflow off-screen)
- New overlays or modals — must be usable within the bottom-sheet layout and not be hidden behind it
- Performance-sensitive features — consider lower thresholds on touch devices (detail warnings, export limits); check `state.isTouchDevice` in `js/state.js`
- Info/hint text — update both desktop text (in `index.html`) and the mobile-specific text set in `js/main.js` (search for `state.isTouchDevice`)
After any code change to simulation or climate code, ensure **scale invariance** — the result must look equivalent regardless of the Detail slider (numRegions from 2K to 2.5M). The key rule: never use raw cell-hop counts or neighbor-displacement magnitudes without scaling by resolution. Specifically:
- **Smoothing passes** must target a physical distance: `Math.max(minPasses, Math.round(targetKm / avgEdgeKm))` where `avgEdgeKm = (π × 6371) / √numRegions`. Never write a bare `smooth(mesh, field, 5)`.
- **Multipliers on neighbor-displacement quantities** (e.g. wind convergence, which sums `wind · displacement`) must normalize by `avgEdgeRad = π / √numRegions` since displacement magnitudes shrink at higher resolution.
- **BFS hop thresholds** must be expressed as `Math.round(targetKm / avgEdgeKm)`, not as fixed integers.
- **Thresholds in physical units** (degrees latitude, km altitude, °C, mm precipitation) are inherently scale-invariant and do NOT need scaling — e.g. "28° from ITCZ" or "heightKm > 1.5" are fine at any resolution.
- When in doubt, ask: "if I double numRegions, does this value change meaning?" If yes, it needs scaling.
After any code change that adds, removes, or modifies features, check whether the SEO and AISEO files need updating. The project has several files that describe the app to search engines and AI models. These must stay accurate — outdated claims are worse than no claims. If a change adds, removes, or modifies any of the following, update the relevant files:
- **`index.html` `<head>` meta tags** — The `<title>`, `description`, `og:description`, `twitter:description`, and `keywords` meta tags describe what the app does. Update if core capabilities change (e.g. new simulation type, new export format, new interaction mode).
- **`index.html` JSON-LD structured data** — The `<script type="application/ld+json">` block contains a `WebApplication` schema with a `featureList` array. Add or remove entries when major features are added or removed.
- **`index.html` hidden `<main>` block** — The visually hidden semantic HTML block (right after `<body>`) describes the app for crawlers. Update its feature list, use cases, or description when the app's capabilities change meaningfully.
- **`llms.txt`** — A plain-text file at the project root that describes the tool for AI assistants. Update its feature list, "who it's for" section, or technical details when capabilities change. Keep it concise and factual.
- **`sitemap.xml`** — Update the `<lastmod>` date when deploying significant changes.
Files that rarely need updating: `robots.txt` (only if adding pages or restricting crawlers), `CNAME` (only if domain changes), `preview.png` (only if the app's visual appearance changes dramatically).
After any code change that adds, removes, or modifies slider controls, update the planet code encoding in `js/planet-code.js` to match. The planet code packs the seed and all slider values into a compact base36 string using mixed-radix integer packing. If a slider's range, step, or count changes, or if a new slider is added, update:
- The `SLIDERS` array (min, step, count for each slider)
- The `RADICES` array (the count values in right-to-left order)
- The `encodePlanetCode` and `decodePlanetCode` functions (packing/unpacking order)
- The corresponding slider wiring in `js/main.js` (the `map` objects in the `generate-done` handler, `applyCode`, and hash-loading code)
## Painted-map import (js/painted.js)
The import page has a second source: a painting whose colours are legend *classes* — uplift rates and
erodibilities, never heights — solved into terrain by a Braun-Willett stream-power solve on the sphere mesh.
The legend JSON schema is shared with the Salty terrain generator (`Tools/Terrain`, `terrain plan`), so keep
it compatible: read new keys optionally, never rename existing ones. Rules that follow from the physics:
- **Paint the uplift, never the height.** Nothing on this path may hand the solve a painted surface; the
painting decides where the land rises and how fast, and the rivers, divides and valleys are the solve's.
- **Thresholds are quantiles of the planet.** The massif fabric and the rock field are cut by rank over every
region on the globe (`rankField`), never per class or per landmass, so an island gets all of a massif or
none of it the way a real island would.
- **The coast moves before the solve.** Coast roughening is noise on the signed distance from the painted
waterline, applied to the land mask, not a warp of the finished elevation.
- **Relief is a scale, not a solve parameter.** For n = 1 the steady state is linear in U/K, so the Peak
Height slider rescales the solved field afterwards; do not add a clamp inside the loop that would break
that linearity without a reason written down.
- Every noise field is sampled at the region's 3D position on the unit sphere, so the seam and the poles
need no special handling; keep it that way.
- The class, uplift, erodibility, drainage, slope and basin layers are debug layers coloured by
`js/painted-layers.js`, which planet-mesh.js uses for the globe, the map and the exports alike; a new
layer is added there once and nowhere else.
- **The class table prints the typical angle, not only the divide angle** (`js/painted-report.js`, ported from
the Go tool's plan report). The divide is the steepest ground a rate can make and almost none of a map is
divide; the median is about a third of it in tangent. An author who reads the divide as the landscape sets
every rate two or three times too hot, so the typical angle is the column and the divide is the tooltip.
These functions are checked against `terrain plan`'s `plan.json` and must stay exact.
- **The overlay is a texture, never a region colour** (`js/painted-overlay.js`, `painted-overlay-view.js`). It
is painted at the template's resolution, where a road is a few pixels wide and a region here is tens of
kilometres across, so voting it onto the mesh would lose every thin stroke. Marks *are* voted onto regions
for one thing only: `coast_jitter`, the single mark property any pass reads. Blank on an overlay is alpha,
never a reserved colour, and an opaque pixel matching no mark is dropped and counted rather than snapped to
the nearest - the class legend's rule inverted, because most of an overlay is nothing.
- **Planet.json outranks a legend's own `planet` block.** It is the manifest the two-hour bake reads; a legend's
copy is a convenience for when it is absent.
- **The studio link is read-only by construction.** `terrain studio` sets its CORS header on GET and HEAD alone
and answers no preflight, so this page can read the painting and the legends and can never paint, save, plan
or bake. Do not ask for that to be widened.
## Unreal landscape export (js/unreal-export.js, unreal-render.js, unreal-ui.js)
A third export, beside the map PNGs: a window of the planet rendered straight into the tile set Unreal
Engine's landscape importer takes - per-tile 16-bit heights at 255*N+1 vertices, an 8-bit weightmap per
paint layer, and the `Region.json` that describes the grid - written to a folder through the File System
Access API. It exists because the map exports cannot be imported: one is a picture, and the other is a flat
equirectangular PNG with **no scale on it**, so the consumer had to invent metres-per-pixel. Rules:
- **The scale is an input and it is recorded.** `planet_circumference_km` is what turns the window's degrees
into ground. It is asked for because it cannot be derived from a sphere mesh, and the manifest writes back
the metres-per-pixel, the window in degrees and the projection, so nothing downstream guesses again.
- **Sample the window once, then cut it.** Every tile is resampled out of one float raster by its *global*
vertex position, which is what makes a shared column bit-identical between neighbours. Never render a tile
under its own camera: one 16-bit step is centimetres of crack along a seam, and a rasteriser gives no
guarantee that two frustums agree on a shared edge. The seam test is the acceptance gate.
- **Tiles carry a one-vertex margin while the layers are derived.** The layers read slope; a one-sided
difference at a tile edge is not what the neighbour computes there, and without the margin every boundary
is a one-vertex line of different paint.
- **Heights ride `heightmapColor`'s -5..6 km ramp, read as floats.** Do not write kilometres straight into
the vertex colour attribute to save the conversion: negative values then depend on three.js colour
management staying out of the way, and the float target already resolves a millimetre over that ramp.
- **Never `<input type="number">` for a decimal.** It formats and parses in the browser's locale, so on a
comma-decimal machine `0.17` displays as "0,17" and `.value` comes back empty - the setting silently
becomes NaN and the export writes a tile set of nothing. Text plus `inputmode="decimal"`, parsed here.
- **The panel's job is the number not yet typed.** Every field re-plans on the keystroke and the readout says
what it bought, including what the flat reading costs at the window's edges. This is the same service
`terrain plan` does for a legend: the expensive step must never be how you find out a number was wrong.
- **This does not make ground finer.** The mesh resolves a couple of hundred metres and no sampling invents
what is not there. The export fixes the *shape* the ground arrives in, not its detail; the detail is the
Salty terrain generator's `terrain tiles`.
- **An existing `Region.json` is never replaced**, only written beside as `Region.generated.json`. A
hand-written manifest is mostly the reasoning behind its numbers, and the same rule already governs
`terrain studio`, which saves by patching a legend's *text* so its commentary survives. Tiles are data and
are overwritten; a manifest is an argument and is not.
+1
View File
@@ -0,0 +1 @@
orogen.studio
+209
View File
@@ -0,0 +1,209 @@
# Heightmap Realism: Holistic Gap Analysis & Implementation Plan
## Context
This plan evaluates the *combined output* of the entire elevation pipeline — base distance fields + tectonic uplift/suppression + stress propagation + noise + interior uplift + ocean profiles + coastal roughening + island arcs + hotspots — to identify where the net elevation at canonical planetary positions diverges from reality. Each gap is assessed against what all layers together already produce, not what any single layer does in isolation.
All implementations must scale with region count. The codebase normalizes via `scaleFactor = Math.sqrt(numRegions / 10000)`. BFS distances, band widths, and pass counts use this factor. All new features must follow the same pattern so geological proportions hold from 2k to 640k regions.
---
## Implementation Lessons Learned
### Lesson 1: BFS Seed Selectivity Is Critical
When computing influence fields via BFS, the choice of seed cells determines everything. In Phase 1 we initially seeded from ALL land cells with any propagated stress (`r_stress > 0.01`). Because stress propagates ~12 hops from every plate boundary, this blanketed ~100% of land cells — the "tectonic activity" map was red everywhere.
**Fix**: Switched to `dist_mountain` (already computed from `stress_mountain_r` — only mountain-building convergent boundary cells with sf < 0.55). This means only major collisions drive the influence field. Plates with no convergent collisions on their edges correctly get zero tectonic activity (cratons).
**Rule for future features**: Always consider what fraction of the planet your seed set covers. If seeds + their propagation zone covers >50% of the target surface, the field won't differentiate anything. Use the most selective seed set that captures the geological phenomenon.
### Lesson 2: Plate Size vs Feature Size at 10k Regions
At 10k regions with 20 plates, each plate is ~500 cells with diameter ~22 cells. Features that require "deep interior far from all boundaries" only manifest clearly when plates are large enough to have such interiors. At low region counts or high plate counts, plates are too small for interior differentiation.
**Implication**: Features should degrade gracefully — at small plates they simplify or disappear rather than creating artifacts. The `tectonicReach` clamp (`max(6, ...)`) handles this, but future features must consider the same constraint.
### Lesson 3: dist_mountain Is a Versatile Signal
`dist_mountain` (BFS from `stress_mountain_r`, blocked by `ocean_r`) encodes "distance from the nearest mountain-building collision through land." It's already computed, inherently scales, and is finite only on plates reachable from major convergent boundaries. It's the right signal for tectonic-modulated interior uplift and should be leveraged for future features (plateau enhancement, back-arc identification) rather than computing new BFS fields where possible.
### Lesson 4: Foreland Basins Need Base Elevation Asymmetry
Phase 1's interior uplift fix reduced the uniform +0.14 and increased the foreland dip from -0.03 to -0.06. But the harmonic-mean base elevation still contributes ~+0.16 at the foreland position, and `dist_mountain`-based tectonic activity is high there (close to mountains). The foreland dip alone cannot overcome base + tectonic-modulated interior. True foreland depressions require base elevation asymmetry — lowering the base on the subducting side so there's room for a basin.
### Lesson 5: r_subductFactor Propagation Range Is Limited
`r_subductFactor` is only propagated as far as stress reaches (~5 hops on subducting side due to aggressive decay, ~12 hops on overriding side). Beyond propagation range, sf = default 0.5. This means sf cannot be used to distinguish overriding vs subducting sides at distances beyond stress propagation. Features that need side-awareness at longer range must use other signals (e.g., `dist_mountain` is finite only on the overriding side of continent-continent collisions where sf < 0.55).
### Lesson 6: Stacking Effects Compound — Start at 60% Strength
Phase 2's asymmetry and plateau effects were initially implemented at full planned strength (asymmetry multiplier 1.2, sf suppression 0.50, plateau noise floor 0.15, plateau uplift 0.04). When combined with the existing sf suppression, differential stress decay, and Phase 1's tectonic-aware interior, the visual effect was too aggressive — mountains looked unnaturally skewed and plateaus too flat.
**Fix**: Toned all parameters to roughly 60% of planned values (asymmetry 0.8, suppression 0.42, noise floor 0.30, uplift 0.025). This produced a convincing in-between that enhances the existing pipeline without dominating it.
**Rule for future features**: When adding new effects that stack with existing mechanisms, start at 50-60% of the theoretically "correct" value and tune from there. The pipeline is multiplicative — each layer compounds on previous ones. Paper-napkin math that considers layers in isolation will overestimate the needed strength.
### Lesson 7: Plateau Detection Via sf < 0.45 Works Within Stress Range
The `isPlateauZone` flag uses `sf < 0.45` (overriding side) AND `dMtn > plateauStart` AND `dMtn finite`. Since sf is propagated ~12 hops on the overriding side (Lesson 5), this correctly identifies plateau regions within the stress influence zone. Beyond that, sf reverts to 0.5 and the cell is no longer flagged as a plateau — it falls back to Phase 1's `tectonicActivity`-based interior uplift, which provides a smooth transition. The two systems complement each other: sf-based plateau zone for structured flat character near collisions, tectonicActivity-based interior for gradual elevation decline farther out.
### Lesson 8: Ocean Floor Depth Interacts With Multiple Positive-Elevation Layers
Phase 3 attempted to implement passive vs active continental margins by differentiating shelf/slope/abyss profiles. Passive margins were made shallower (-0.01 to -0.04 shelf) and wider (8 cells vs 3 cells). However, even after multiple rounds of deepening, false land kept appearing in the oceans.
**Root cause**: The ocean floor elevation is set early in the pipeline, but multiple subsequent layers add positive elevation — coastal roughening noise, island scattering, hotspot volcanism, and coastal domain warping. The original fixed profile (-0.02 to -0.08 shelf, -0.08 to -0.33 slope) was specifically tuned to survive these additions. Making shelves shallower broke that balance everywhere at once.
**Lesson**: Ocean floor changes cannot be made in isolation from the coastal roughening, island scattering, and hotspot systems. The ocean and coastal layers form a tightly coupled system. Any ocean floor rework needs to be holistic — adjusting depths, noise amplitudes, and island thresholds together as a coordinated change. This is why all ocean work has been moved to a dedicated phase.
**Reverted**: Passive/active margin profiles reverted to original fixed breakpoints. The coast-boundary BFS was hoisted before the main loop (structural improvement, no behavioral change). The `coastConvergent` flag infrastructure remains available for future use.
---
## Phase 1: Tectonic-Aware Interior — COMPLETED
### What was implemented
1. **Tectonic-modulated interior uplift**: Replaced uniform `+0.14` with `0.06 + tectonicActivity * 0.16`. Uses `dist_mountain` with quadratic decay over `TECTONIC_REACH_BASE=20 * scaleFactor` cells. Range: +0.06 (quiet craton) to +0.22 (collision-backed plateau).
2. **Noise amplitude scaling**: `noiseScale = 0.25 + 0.75 * min(1, stressNorm * 4)`. Quiet interiors get 25% noise (visibly flat), collision zones get full roughness.
3. **Foreland dip increase**: Zone widened from `stressNorm < 0.05` to `< 0.10`, max depression increased from `-0.03` to `-0.06` with linear falloff.
4. **Debug layer**: "Tectonic Activity" added showing the `tectonicActivity` field.
---
## Phase 2: Mountain Asymmetry + Plateau Enhancement — COMPLETED
### What was implemented
**Rank 4 — Mountain Asymmetry (toned to 60% strength per Lesson 6):**
1. **Base elevation asymmetry**: `dist_mountain` multiplied by `1.0 + (sf - 0.5) * 0.8` before feeding into harmonic-mean formula. Range: 0.6 (overriding, compressed) to 1.4 (subducting, inflated). This shifts the distance-field ridge peak toward the subducting side.
2. **SF suppression amplified**: Increased from `0.35` to `0.42` (was planned at 0.50). Subducting-side elevation gets up to 42% suppression.
**Rank 5 — Plateau Enhancement (toned to 60% strength per Lesson 6):**
3. **`tectonicActivity` moved early**: Computed before the noise section so it can drive plateau noise suppression.
4. **Plateau zone detection**: `isPlateauZone = sf < 0.45 && dMtn finite && dMtn > plateauStart` where `plateauStart = max(2, round(3 * scaleFactor))`.
5. **Plateau noise suppression**: In plateau zones, noise additionally multiplied by `max(0.30, 1 - tectonicActivity * 0.60)`. Creates flat-topped character without making plateaus completely featureless.
6. **Plateau uplift boost**: `+0.025 * tectonicActivity * (1 - sf)` for plateau cells with tectonicActivity > 0.1. Tracked in interior debug layer.
### Updated canonical positions (post Phase 2)
- **Position A** (mountain front, overriding): Base now higher due to compressed dist_mountain (asymmetry 0.6x). Net ~0.90-1.05. Slightly higher peaks on overriding side. ✓
- **Position B** (5 cells behind mountain, overriding): Plateau boost + noise suppression. Net ~0.55-0.58. Flat elevated plateau. ✓
- **Position C** (5 cells in front, subducting): Base now lower due to inflated dist_mountain (asymmetry 1.4x) + stronger sf suppression. Net ~0.38-0.42. Asymmetry vs B is now ~25-30%. ✓
- **Position D** (foreland, stress edge): Base lowered ~15% on subducting side. Net ~0.20-0.22. Still not a true basin but notably lower. The mountain→foreland contrast is now ~4:1.
- **Position E** (deep interior): Unchanged from Phase 1 (sf=0.5 → asymmetry=1.0). Net ~0.12-0.15.
### Remaining gap status update
**Gap 3 (Foreland Basins)**: Improved. The base asymmetry lowers the subducting-side base by ~15%. Combined with the -0.06 foreland dip, the foreland is now visibly lower than surrounding terrain. Not yet a deep basin (~0.20 vs mountain ~0.90) but the contrast is significant.
**Gap 5 (Mountain Asymmetry)**: ADDRESSED. Asymmetry is now ~25-30% between overriding and subducting sides, up from ~10% pre-Phase-1 and ~15% post-Phase-1. Visible in the base debug layer as a shifted ridge peak.
---
## Phase 3: Rift Valley Structure — COMPLETED
### What was implemented
**Rift valleys (Rank 3, at 60% strength per Lesson 6):**
1. **Rift BFS**: Pre-computed BFS from divergent continent-continent boundary cells (`btype === 2 && !r_hasOcean`) through same-plate land cells, max `RIFT_HALF_WIDTH_BASE=4 * scaleFactor` cells.
2. **Structured graben profile** replacing the old flat `-0.12` depression:
- **Axis** (rd=0): -0.15 depression + volcanic ridged noise (amplitude 0.04)
- **Floor** (rd=1 to `round(1.5*sf)`): -0.12 with decreasing volcanic texture
- **Shoulders** (`floorEnd` to `round(2.5*sf)`): +0.03 modest uplift flanking the graben
- **Fadeout** (beyond shoulders): smoothstep to ambient (guarded against division by zero when `riftHalfWidth == shoulderEnd` at low resolution)
3. **Graceful degradation**: At 2k regions (sf=0.45): axis + 1 floor cell + 1 shoulder cell, no fadeout zone. At 100k+ (sf=3.16): full 13-cell-wide structure with graben, floor, shoulders, and smooth transition.
**Coast-boundary BFS hoisted**: Moved from inside the coastal roughening block to before the main elevation loop. Structural cleanup — same logic, same data, just available earlier.
### Gap status update
**Gap 1 (Passive vs Active Margins)**: Covered by Ocean Rework (see `OCEAN_REWORK_PLAN.md`).
**~~Gap 4 (Rift Valleys)~~**: ADDRESSED by Phase 3. Structured graben with axis depression (-0.15), volcanic floor texture, and flanking shoulders (+0.03). With Phase 1's reduced interior uplift (+0.06 for quiet areas), the rift axis should produce actual depressions.
---
## Part 2: Remaining Gaps
### ~~Gap 1: Passive vs. Active Margins Are Identical~~
**Status**: Covered by Ocean Rework (`OCEAN_REWORK_PLAN.md`).
### ~~Gap 2: Continental Interiors Are Uniformly Elevated and Rough~~
**Status**: ADDRESSED by Phase 1.
### Gap 3: Foreland Basins Still Elevated
**Status**: Significantly improved by Phases 1+2. Base asymmetry + foreland dip + tectonic-aware interior create a visible low zone at the stress edge on the subducting side. Not yet a deep basin but the profile is qualitatively correct: mountain → steep drop → low foreland → gradual rise to interior.
### ~~Gap 4: Rift Valleys Are Not Valleys~~
**Status**: ADDRESSED by Phase 3. Structured graben profile with axis, floor, shoulders, and fadeout.
### ~~Gap 5: Mountain Asymmetry Is Too Subtle~~
**Status**: ADDRESSED by Phase 2. ~25-30% asymmetry, visible in base and normal views.
### ~~Gap 6: Ocean Fracture Zones~~
**Status**: Covered by Ocean Rework (`OCEAN_REWORK_PLAN.md`).
### Gap 7: Back-Arc Basins — unchanged
---
## Part 3: Remaining Implementation Plan (Land-focused)
Ocean work (margins, fracture zones, ridges, coastal roughening differentiation) is in `OCEAN_REWORK_PLAN.md`.
### Rank 7: Back-Arc Basins
**Why**: No existing layer produces depression behind volcanic arcs. Primarily affects land/coast.
**Scaling**: Basin distance scales with `scaleFactor`.
**Approach**: Identify overriding-plate cells 5-12 cells behind convergent ocean-continent boundaries. Apply smoothstep depression `-0.03 * stressNorm` (per Lesson 6). Cells below 0 appear as marginal seas.
---
### Rank 8: Hypsometric Distribution Correction
**Why**: Light post-processing to ensure bimodal elevation histogram.
**Scaling**: Resolution-independent (operates on values).
**Approach**: Separate histograms for ocean/land, gentle quantile remapping, light blend factor (0.25).
- **Lesson 6 applies**: Use a very light blend (0.15-0.20) to avoid washing out the structural improvements from Phases 1-3.
---
### Rank 9: Simplified Fluvial Erosion
**Why**: Highest cost, highest potential. Adds drainage valleys.
**Scaling**: Flow accumulation on mesh neighbors is inherently scale-independent. Erosion depth absolute.
**Approach**: Topological sort by elevation, steepest-descent flow routing, `elev -= EROSION_RATE * log(1 + flow)`.
- **Lesson 6 applies**: Start with EROSION_RATE = 0.004 (half of planned 0.008). The Phase 1 noise suppression already creates smooth interiors — erosion on top of that might create overly deep valleys in quiet areas.
---
## Recommended Implementation Phases
**Phase 1** — COMPLETED
- Tectonic-aware interior differentiation (Rank 2).
- Gaps addressed: #2 (uniform interiors), partial #3 (foreland), partial #5 (asymmetry).
**Phase 2** — COMPLETED
- Mountain asymmetry (Rank 4) + plateau enhancement (Rank 5), toned to 60% strength.
- Gaps addressed: #5 (asymmetry), further progress on #3 (foreland).
**Phase 3** — COMPLETED
- Rift valley structure (Rank 3). Passive margins attempted but reverted (Lesson 8).
- Gaps addressed: #4 (rift valleys).
**Phase 4** (Land refinements): Ranks 7 + 8 + 9
- Back-arc basins + hypsometric correction + simplified fluvial erosion.
- These primarily affect land elevation values.
**Ocean Rework** — See `OCEAN_REWORK_PLAN.md`
- Covers margins, ridges, fracture zones, and coastal roughening differentiation as a coordinated system.
## Verification
After each phase:
- Generate 10+ planets at 10k regions with default settings
- Test at 2k, 10k, 50k, 200k regions to verify scaling invariance
- Use debug layers to confirm new component contributes correctly
- Verify combined elevation at canonical positions matches expected values
- Check `performance.now()` stays under 300ms at 10k regions
- Verify no NaN/Infinity in output
- Visual checks per phase:
- Phase 1: Flat quiet interiors, rough collision zones, elevated plateaus ✓
- Phase 2: Asymmetric mountain profiles, visible foreland contrast, flat-topped plateaus ✓
- Phase 3: Rift valleys with shoulders ✓ (passive margins deferred)
- Phase 4: Bimodal elevation histogram, drainage valleys at high resolution
- Phase 5: Wide passive shelves vs narrow active shelves, fracture zone lines, back-arc depressions
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+158
View File
@@ -0,0 +1,158 @@
# Ocean Topography Rework — Ground-Up Plan
## Context
Phase 3 attempted to implement passive vs active continental margins by changing the ocean floor depth profile. This failed because the ocean floor, coastal roughening, island scattering, and hotspot systems are tightly coupled — shallower shelf depths caused widespread false land (Lesson 8 in HEIGHTMAP_REALISM_PLAN.md).
This plan rethinks ocean plate topography from the ground up. The key insight: **coastline character should come from the coastal roughening system, not from the base depth profile**. The depth profile's job is to provide a stable, deep floor. Everything above that floor — coastline shape, islands, shelf character — should be controlled by the layers that add positive elevation on top.
All implementations must scale with region count via `scaleFactor = Math.sqrt(numRegions / 10000)`.
### Priorities
1. **Interesting, geographically plausible coastlines** — varied shapes, different character at different tectonic settings
2. **Interesting ocean landforms** — islands, arcs, seamounts forming where tectonically appropriate
3. **Realistic ocean floor** — margin differentiation, ridges, fracture zones
### File: `js/elevation.js`
---
## Ocean Depth Budget Analysis
Every ocean cell starts at `oceanBase` (negative), then multiple layers add positive elevation. The depth must survive these additions to stay underwater — unless the addition is *intentional* land (islands, arcs, hotspots).
**Current ocean base profile** (dist_coast `dc`):
| Zone | Distance | Depth |
|------|----------|-------|
| Shelf | dc < 5 | -0.02 to -0.08 |
| Slope | 5 ≤ dc < 12 | -0.08 to -0.33 |
| Abyss | dc ≥ 12 | ~-0.35 |
**Positive layers that can affect ocean cells:**
| Layer | Max positive contribution | Reach | Intentional land? |
|-------|-------------------------|-------|-------------------|
| Coastal fractal noise (L1) | ±0.12 × falloff × stressAmp (up to ~0.5) | 8 cells | No |
| Domain warping (L3) | ±0.2 | 5 cells | No |
| Island scattering (L2) | +0.36 | 4 cells | **Yes** |
| Island arcs | +0.55 | 5 cells | **Yes** |
| Hotspots | +0.9 (ocean boosted 1.8x) | sigma×5 | **Yes** |
| Ocean noise | ±0.03 | Global | No |
**The problem**: At dc=1, the shelf depth is only -0.032. Coastal L1 noise alone (±0.12 near coast) can push this positive. The shelf is too shallow to survive non-island coastal roughening.
**The fix**: Deepen the shelf so only intentional mechanisms (island scatter, arcs, hotspots) create above-water features. Differentiate margin types through WIDTH (spatial extent of shelf), not DEPTH (how shallow it is).
---
## Implementation Steps
### Step 1: Deepen Ocean Baseline + Margin-Aware Width
**Goal**: A deeper, more resilient base profile that differentiates margin types through *width* (how far the shelf extends), not through *depth* (how shallow the shelf is).
**Changes**: Replace the fixed ocean floor breakpoints in the `else` (ocean) branch of the main elevation loop.
**New profile**:
- `SHELF_NEAR = -0.04` (coast edge, was -0.02)
- `SHELF_FAR = -0.10` (shelf break, was -0.08)
- `SLOPE_FAR = -0.33` (base of slope, unchanged)
- `ABYSS = -0.35` (deep ocean, unchanged)
**Width differentiation** (via hoisted `coastConvergent` flag):
- Active margins: shelf end = `max(2, round(3 * sf))`, slope end = `max(5, round(8 * sf))`
- Passive margins: shelf end = `max(4, round(7 * sf))`, slope end = `max(10, round(16 * sf))`
- Both margin types use the SAME depth endpoints — avoids the false-land problem
**Why this works where the previous attempt failed**: The previous attempt made passive margins *shallower*. This plan makes everything *uniformly deeper* while widening the passive shelf *spatially*. Visual difference comes from shelf width, not depth.
**QA**: Generate at 10k. Compare ocean debug layer before/after. No new false land should appear. Passive coasts should have wider light-blue shelf bands. Active coasts should have narrower bands.
---
### Step 2: Mid-Ocean Ridge Enhancement
**Goal**: Wider, more prominent ridges instead of single-cell-wide uplift.
Currently, only cells with `btype === 2 && r_bothOcean[r]` get ridge uplift (+0.06 to +0.18). This is a 1-cell-wide feature — invisible at most zoom levels.
**New approach**: Pre-compute `ridgeDist` via BFS from ocean divergent boundary cells, propagating through ocean cells only, max `round(4 * sf)` cells. Replace the single-cell ridge block with distance-based ridge uplift using quadratic falloff:
- At boundary (rd=0): full uplift `(0.12 * ridgedNoise + 0.06)`
- At rd=2: 25% uplift
- At rd=4: 0%
**Scaling**: At 2k: 2-cell-wide ridge. At 10k: 4-cell. At 200k: 18-cell.
**QA**: Generate at 10k. Ocean debug layer should show visible ridge bands at divergent ocean-ocean boundaries. Ridges should be wider than before but not dominant. No land should be created (ridge uplift peaks at ~+0.18, ocean base is -0.35 at those distances).
---
### Step 3: Oceanic Fracture Zones
**Goal**: Transform ocean-ocean boundaries create visible linear depressions.
Pre-compute `fractureDist` via BFS from transform ocean boundaries (`btype === 3 && r_bothOcean[r]`), propagating through ocean cells only, max `round(3 * sf)` cells.
Apply subtle depression: `-0.03 * (1 - d/maxDist)` fading linearly.
Where fracture zones intersect the widened ridge (Step 2), the ridge uplift is naturally reduced by the fracture depression, creating the characteristic offset/staircase pattern.
**Scaling**: At 2k: 2-cell-wide line. At 10k: 3-cell. At 200k: 13-cell.
**QA**: Generate at 10k. Look at ocean debug layer for linear depressions at transform boundaries. Where they cross mid-ocean ridges, the ridge should appear offset/interrupted.
---
### Step 4: Margin-Aware Coastal Roughening
**Goal**: Different coastline character at active vs passive margins. This is where the visual coastline interest comes from.
**Layer 1 (Coastal fractal noise)**: Differentiate frequency and amplitude.
- Passive coasts: freq 12 (was 18), amp 0.08 (was 0.12) — broad bays, gentle peninsulas
- Active coasts: keep current freq 18, amp 0.12 — rugged, fjord-like
- Both still modulated by stress
**Layer 2 (Island scattering)**: Wider range and easier threshold at passive margins.
- Passive: range 6 cells (was 4), threshold 0.20 (was 0.25) — barrier islands, archipelagos
- Active: range 3 cells, threshold 0.30 — fewer islands, only where stress concentrates
- Subduction suppression stays unchanged
**Layer 3 (Domain warping)**: Wider warp zone at passive margins.
- Passive: falloff multiplier 1.2 (warp dies slower, broader coastal irregularity)
- Active: falloff multiplier 1.5 (warp concentrated near coast)
**QA**: Generate several planets at 10k. Compare coastline character: passive coasts should have broader, gentler features with more offshore islands. Active coasts should remain rugged. Toggle the Coastal debug layer to verify the contribution patterns differ.
---
### Step 5: Debug Layer
Add a "Margins" debug layer showing margin type classification for ocean cells:
- Active margin cells: one color
- Passive margin cells: another color
- Ridge zone: highlighted
- Fracture zone: highlighted
Add the option to `index.html` debug layer dropdown.
**QA**: Generate at 10k. Verify that convergent coastlines show as active, non-convergent show as passive, and the classification makes geological sense.
---
## Scaling Verification Table
| Feature | 2k (sf=0.45) | 10k (sf=1.0) | 50k (sf=2.24) | 200k (sf=4.47) |
|---------|-------------|-------------|--------------|----------------|
| Active shelf | 2 cells | 3 cells | 7 cells | 13 cells |
| Passive shelf | 4 cells | 7 cells | 16 cells | 31 cells |
| Active slope end | 5 cells | 8 cells | 18 cells | 36 cells |
| Passive slope end | 10 cells | 16 cells | 36 cells | 72 cells |
| Ridge width | 2 cells | 4 cells | 9 cells | 18 cells |
| Fracture width | 2 cells | 3 cells | 7 cells | 13 cells |
| Passive island range | 6 cells | 6 cells | 13 cells | 27 cells |
---
## Lessons Applied
- **Lesson 1 (seed selectivity)**: Ridge seeds = only `btype===2 && r_bothOcean`. Fracture seeds = only `btype===3 && r_bothOcean`. Highly selective.
- **Lesson 6 (start at 60%)**: Fracture depression at -0.03 (conservative). Ridge widening modest (4 cells). Coastal differentiation moderate (freq 12 vs 18, not 8 vs 18).
- **Lesson 8 (ocean depth coupling)**: Depths are uniformly DEEPER not shallower. Width is the differentiator, not depth.
+392
View File
@@ -0,0 +1,392 @@
# World Orogen
A browser-based procedural planet generator that creates realistic terrestrial planets with tectonic plate simulation, elevation modeling, and interactive editing. Uses native ES modules with no build step required.
[![Live Site](https://img.shields.io/badge/Try_it-orogen.studio-brightgreen)](https://orogen.studio/) ![Three.js](https://img.shields.io/badge/Three.js-0.160.0-blue) ![No Build](https://img.shields.io/badge/build-none-green)
## Philosophy
World Orogen is concept art for planets. It's built for the moment early in a project when you need a world that *looks* real — believable tectonics, organic coastlines, climate patterns that feel right — but you don't need a geophysical simulation to get there. The science isn't decoration: plate collision models inspired by real tectonics, pressure-driven wind patterns, and Köppen classification are what make the output feel convincing at a glance. If a climate scientist squints and finds inaccuracies, that's fine — but the scientific foundation is what earns the first glance. Plausibility, not precision.
The core value is creative velocity. Generate a planet in seconds, tweak plates and terrain until it matches your vision, then export high-resolution maps into whatever comes next — Gaea, Wonderdraft, Photoshop, a game engine, a novel outline. Orogen is designed to be the fastest path from a blank page to a world worth building on — the first tool in your worldbuilding pipeline, not the last.
## Guiding Principles
1. **Artistic appeal** — Visually interesting, scientifically informed output. Aesthetics come first.
2. **Ease of use and efficiency** — Approachable interface, fast generation. Don't sacrifice usability for realism.
3. **Scientific plausibility** — Grounded in real planetary science. Believable, not necessarily physically accurate.
All three are considered together; ties are broken in the order above.
## Features
- **Fibonacci sphere meshing** with Voronoi cell tessellation via Delaunay triangulation
- **Tectonic plate simulation** — farthest-point seed placement with top-3 jitter, round-robin flood fill with directional growth bias, growth-rate governor, compactness penalty to prevent spindly shapes, multi-pass boundary smoothing, and fragment reconnection
- **Ocean/land assignment** — farthest-point continent seeding, round-robin growth with separation guarantees, trapped sea absorption, targeting ~30% land coverage
- **Collision detection** — convergent, divergent, and transform boundary classification with density-based subduction modeling; dual-layer super plate system groups same-type plates into ~20 tectonic units for broad orogenic belts blended 50/50 with fine-grained individual plate orogeny
- **Elevation generation** — three distance fields (mountain/ocean/coastline) combined via harmonic-mean formula, stress-driven uplift, asymmetric mountain profiles, continental shelf/slope/abyss profiles, foreland basins, plateau formation, and rift valleys with graben profiles
- **Ocean floor features** — mid-ocean ridges at divergent boundaries, deep trenches at subduction zones, fracture zones at transform boundaries, back-arc basins behind subduction zones
- **Island arcs** — volcanic island chains at ocean-ocean convergent boundaries with ridged noise shaping
- **Hotspot volcanism** — dual-component mantle plume model (broad thermal swell + volcanic peak) with drift-trail island chains, domain-warped shape distortion, drift-direction elongation, summit calderas on active domes, radial rift-zone ridges, age-dependent volcanic texture, and per-hotspot variation in strength/decay/spacing
- **Terrain post-processing** — noise-based domain warping (FBM simplex noise with greedy mesh walk) to deform the elevation field for organic coastlines and mountain ridges, independently controllable bilateral smoothing to blend harsh BFS distance-field boundaries, glacial erosion that carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes via latitude-driven ice flow with drainage accumulation, priority-flood pit resolution with canyon carving (Barnes et al. algorithm that ensures every land cell drains to the ocean, carving dramatic canyons through mountain saddle points rather than filling basins), iterative implicit stream power hydraulic erosion (Braun-Willett style) that carves self-reinforcing river valleys with automatic sediment deposition in flat receivers, thermal erosion that softens ridges via talus-angle material transport, ridge sharpening that accentuates mountain ridgelines, and always-on soil creep (Laplacian diffusion) that rounds off hillslopes
- **Coastal roughening** — fractal noise with active/passive margin differentiation, domain warping for bays/headlands, and offshore island scattering
- **3D globe rendering** with atmosphere rim shader, translucent water sphere, terrain displacement, and starfield
- **Equirectangular map projection** with antimeridian wrapping
- **Interactive editing** — Ctrl-click plates to mark them for reshaping (multi-select with visual tinting), then click Rebuild to apply all changes at once. Ctrl-click again to undo a pending selection. Press Escape to cancel all pending edits
- **Seasonal wind simulation** — pressure-driven wind patterns with a longitude-varying ITCZ that tracks the thermal equator (~5° over ocean, up to 15-20° over continents), Gaussian pressure bands (subtropical highs, subpolar lows, polar highs), land/sea thermal contrast for monsoon-like pressure reversals, elevation barometric effects, and Coriolis-deflected geostrophic wind with natural cross-equatorial flow reversal. Computed for both summer and winter seasons.
- **Ocean surface currents** — rule-based geographic gyre simulation driven by wind belts (trade winds, westerlies, polar easterlies) with a longitude-varying ITCZ equatorial countercurrent. Continental shelves are classified as western or eastern boundaries via coast-normal BFS, producing subtropical gyres (CW in NH, CCW in SH) with western boundary intensification (Gulf Stream, Kuroshio effect) and weaker eastern boundary return flow. Detects circumpolar channels for unobstructed eastward currents (Antarctic Circumpolar Current). Currents are colored by heat transport: red = warm poleward flow, blue = cold equatorward flow, black = zonal (neutral). Computed for both summer and winter seasons.
- **Precipitation** — blended dual-model approach: a complex moisture advection simulation is combined 50-50 with a fast heuristic zonal model. The advection model simulates wind-driven moisture transport from coasts with six mechanisms: ITCZ convective uplift, frontal convergence, orographic rain/shadow, lee cyclogenesis, polar-front precipitation, and subtropical high suppression. The heuristic model provides smooth latitude-based patterns (ITCZ wet belt, subtropical dry belt, mid-latitude recovery, polar dryness) modulated by continentality and orographic effects. Blending the two reduces splotchiness while preserving terrain-informed detail and strengthening subtropical desert formation (~20–35°). Visualized on a brown (dry) → green (moderate) → blue (wet) color ramp. Computed for both summer and winter seasons.
- **Map type switcher** — first-class Terrain / Satellite / Climate / Heightmap tabs with color legends for each view
- **On-demand climate** — optional deferred climate computation; skip climate during generation for faster terrain iteration, compute it on demand when needed
- **Detailed visualization** — twenty-six selectable inspection layers organized by category (Geology, Atmosphere, Ocean, Climate, Elevation) for viewing each component in isolation. Wind/pressure layers show directional wind arrows, ocean current layers show current arrows colored by heat transport, on both globe and map views. Precipitation layers use a brown→green→blue ramp showing dry to wet regions.
- **Heightmap import** — bring your own equirectangular B&W heightmap (Earth, Mars, hand-drawn maps) onto a 3D globe. Black pixels become ocean, brighter pixels become higher land. The import page (`/import`) runs full climate simulation (wind, precipitation, temperature, K&ouml;ppen) on your imported terrain, with optional terrain sculpting (smoothing, erosion, ridge sharpening). Supported formats: PNG, JPEG, WebP.
- **Painted map import** — paint a flat map where every colour is a *class* (a rate of rock uplift and an erodibility, never a height) and a legend JSON says what the colours mean; the import page's **Painted Map** source solves the stream-power equation dh/dt = U − K·A^m·S on the sphere mesh (Braun-Willett implicit scheme, priority-flood drainage, hillslope diffusion) until the land is in balance with its uplift, so rivers, divides and valley hierarchy come out of the physics. Classes carry massif blocks (a plain with hill masses standing out of it, cut at a quantile of the whole planet), coastal-plain ramps, a planet-wide rock field that multiplies erodibility, and a regional swell; the drawn coastline is roughened with fractal noise before the solve. Six extra inspect layers and export types: class map, uplift rate, erodibility, drainage (rivers), slope and drainage basins. The legend format is shared with the Salty terrain generator's `terrain plan` / `terrain bake`.
- **Map export** — download high-resolution equirectangular PNGs (color terrain, satellite biome, climate/Köppen, B&W heightmap, land-only heightmap, or B&W land mask) at configurable widths up to 65536px with tiled rendering. **Export All** downloads Satellite, Climate, Heightmap, and Land Mask in one click, auto-computing climate if needed.
- **Unreal landscape export** — render a window of the planet straight into the tile set Unreal Engine's landscape importer wants: a 16-bit greyscale height per tile at 255·N+1 vertices (one Landscape actor each), an 8-bit weightmap per paint layer beside it, and a `Region.json` describing the grid. Unlike the map exports, this one carries a **scale**: you give the planet's circumference and the export records metres-per-pixel, the window in degrees, and the projection, so nothing downstream has to guess. Files are written into a folder you pick (Chrome/Edge, File System Access API). See [Unreal landscape export](#unreal-landscape-export) below.
## Quick Start
Serve the project with any local HTTP server (required for ES modules):
```bash
# Python
python3 -m http.server 8000
# Or Node.js
npx serve .
```
Then open **http://localhost:8000** in your browser. No dependencies to install, no build step.
Click **Build New World** to create a new random planet. The button changes color and label based on what you've adjusted:
- **Build New World** (blue) — generates a fresh planet with a new random seed
- **Rebuild** (amber) — re-renders the current planet at a new detail/roughness level without changing continent shapes
- **Regenerate** (red) — creates new tectonic plates when the Plates or Continents slider has changed
### Navigation
A top navigation bar connects the two pages:
- **Generate** (`/`) — procedural planet generation with tectonic plates, erosion, and climate
- **Import** (`/import`) — two sources, switched at the top of the panel:
- **Heightmap** — import your own equirectangular B&W heightmap, view it on a 3D globe, and run climate simulation. Black (0) = ocean, brighter = higher elevation. Supports PNG, JPEG, and WebP.
- **Painted Map** — import a painting whose colours are legend classes plus the legend JSON (a built-in legend and a demo painting are included), edit the classes' uplift rates, depths and erodibilities in the table, and click **Solve Terrain**. The report says how many pixels matched no class and whether the left and right edges agree (they are the same meridian). Optionally add **Planet.json** (the Salty generator's manifest), an **Overlay** annotation layer, or load all of them from a running `terrain studio`.
### Painted Map controls
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Peak Height | 0.5 – 6 km | 4.5 km | The 99.5th percentile of the solved land is put at this height. The shape is the solve's; this is only the scale (for n = 1 the steady state is linear in uplift over erodibility) |
| Ocean Depth | 0.25 – 5 km | 4 km | Depth of the deepest sea class; shallower classes (shelf, surf) scale with it, and every shore ramps down over a couple of cells |
| Coast Detail | 0 – 1 | 0.35 | Fractal noise added to the signed distance from the painted waterline before the solve, up to three cells of shift. Islets keep at least a third of their width |
| Uplift Variation | 0 – 0.6 | 0.30 | A regional swell over the painted rate, so a lowland painted in one colour has basins and rises of its own |
| Solve Steps | 50 – 600 | 200 | How long the stream-power solve runs. About 12 s at 204K regions |
| Painted world circumference | km | 100 | Distances in the legend (coastal plains, massif and rock sizes) are in the painted world's kilometres and are scaled to the Earth-sized globe by the ratio of circumferences |
| Massif size | km | 7 | Block size of the planet's upland fabric — how big the hill masses standing out of a plain are |
| Rock province size | km | 8 | Province size of the planet's rock field, which multiplies each class's erodibility |
| Seed | integer | 7945 | Re-rolls what the painting does not fix: the massifs, the rock provinces, the swell and the coastline detail. **Re-roll** picks a new one |
### The class table's two angles
Each land class shows the **typical** hillslope its uplift rate makes on the Salty generator's 8 m geology grid, and what that ground reads as — plain, rolling, hill country, mountain, alpine. Hovering a value gives the **divide** angle, the steepest ground the rate can make, and the P90.
Read the typical column. Steady state is `S = U/(K·A^m)` and `A` is smallest at the top of a catchment, so the divide angle is the steepest place in a world and almost none of a map is divide; the median comes out at about a third of it in tangent. Reading the divide angle as the landscape is how a legend gets set two or three times too hot. A class whose divide is past the angle of repose is marked **clamped** — there the repose clamp shapes the ground rather than the rivers, and raising the rate makes the summits higher without making the ground steeper. The note under the table gives the rate at which that begins.
The angles come from the bake's constants, which **Planet.json** carries: cell size, `K`, `m` and the angle of repose. Without it the defaults are the shipped planet's (8 m, 5e-5, 0.5, 35°).
### Planet.json
The Salty generator's own manifest (`RawContent/World/Planet.json`). Loading it brings the planet block — circumference, massif and rock province sizes, uplift variation, seed — and the pipeline constants the angles above are about, so none of it has to be retyped. It outranks a legend's own `planet` block, because it is the file the two-hour bake actually reads.
### Overlay
A second painting the same size as the template and registered to it, whose colours are **marks** rather than classes: forests, settlements, roads, and stretches of coast to leave alone. It answers a different question from the class template — every colour there is geology, and there is no uplift rate for a town — so it is a separate sheet with a legend of its own.
Blank is decided by **alpha**, never by a colour: an unpainted pixel is transparent, so no colour is spent on emptiness and an export with a white matte behind it does not turn the world into whatever mark white is nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted, which is the only way a colour the legend forgot ever shows.
Exactly one mark property changes anything: **`coast_jitter`** scales how far the coast roughening may move the shore inside the mark. `0` pins a hand-drawn coastline exactly as painted while the rest of the world is still roughened; above 1 chews it harder, which is what makes a fjord coast. Everything else is inert — two solves with and without a forest are the same terrain.
The sheet is drawn as a **texture**, not voted onto the mesh, because a road is a few pixels wide and a region here covers tens of kilometres. The **Overlay Sheet** toggle drapes it over whatever layer is shown, on the globe and on the map; the **Overlay** inspect layer and export type draw it over a dimmed class map, and hovering a marked region names the mark.
The overlay legend JSON has a `marks` array; each mark has a `name`, an `rgb` triple, optionally `kind: "path"` with `width_m`, `coast_jitter`, `min_area_px` and a `note`. The file-level `match_distance` and `min_area_px` are the defaults.
### From terrain studio
`terrain studio` (the Salty generator's painting tool) serves the painting it is holding, both legends and Planet.json read-only across origins. Enter its address and **Load from studio** brings the whole planet in one step, exactly as its next `terrain plan` would read it — including strokes made since the last save, because the studio holds the painting in memory. It needs a studio built on or after 2026-09-20. The studio only ever shares reads: nothing on this page can paint, save, plan or bake.
The legend JSON has a `classes` array; each class has a `name`, an `rgb` triple and either `"sea": true` with `depth_m` or `uplift_mm_yr` and `k_mult`, optionally `massif: { floor_mm_yr, fraction }`, `coastal_plain_km` / `coastal_floor_mm_yr`, `lithology_mix`, `stroke: true` (an outline colour that dissolves into its neighbours, or becomes `edge_class` where it touches a pole) and `derived: true` (a class that is never painted). An optional `planet` block carries `circumference_km`, `massif_wavelength_km`, `lithology_wavelength_km`, `uplift_variation` and `lithology.k_multipliers`. See `assets/painted-legend.json`. **Download legend JSON** writes the table's edits back into the loaded file with every other key intact.
### Sharing Planets
Every generated planet produces a **planet code** (shown below the Build button) that encodes the random seed, all slider values, and any plate edits. An unedited planet is 21 characters; plate edits (applied via Rebuild) extend the code to include the toggled plates. Older codes (13–18 characters) from previous versions are still supported — missing sliders default to their current default values. To share a planet:
- **Copy** the code with the copy button and send it to someone
- **Load** a code by pasting it into the planet code field and clicking Load (or pressing Enter). The Load button turns blue when a new code is ready to apply.
- **URL sharing** — the code is also stored in the URL hash (e.g. `#a7f3kq9xp2b`), so you can share the full URL directly. Opening a URL with a valid hash auto-loads that planet, including any plate edits.
## Controls
### Shape Your World
Core world parameters that control the planet's structure (changing these requires a full rebuild):
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Detail | 5,000 – 2,560,000 | 204,000 | Number of Voronoi cells on the sphere. Only affects rendering resolution — continent shapes are stable across detail levels (generated on a fixed ~20K reference grid) |
| Irregularity | 0 – 1 | 0.75 | Randomization of Fibonacci point positions |
| Plates | 4 – 120 | 80 | Number of tectonic plates |
| Continents | 1 – 10 | 4 | Target number of separate landmasses |
| Roughness | 0 – 0.5 | 0.40 | Fractal noise magnitude for terrain roughness |
| Continent Size Variety | 0 – 1 | 0.35 | How much continent sizes vary — 0 keeps continents similar in size, 1 allows a mix of large and small landmasses |
| Land Coverage | 0 – 1 | 0.3 | Percentage of the planet covered by land. Low values create ocean worlds, high values create desert worlds. Above 40% coverage, precipitation is progressively dampened to simulate reduced oceanic moisture |
### Terrain Sculpting
Post-processing passes that refine the terrain (collapsed by default — the defaults produce good results). These do not require a full rebuild; adjusting any slider lights up the **Reapply** button at the bottom of this section — click it to reapply only the sculpting passes on the current planet.
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Terrain Warp | 0 – 1 | 0.75 | Domain warping — deforms the elevation field using noise to produce organic, squiggly coastlines and mountain ridges |
| Smoothing | 0 – 1 | 0.10 | Blends harsh terrain boundaries from tectonic generation |
| Glacial Erosion | 0 – 1 | 0.50 | Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes via latitude-driven ice flow |
| Hydraulic Erosion | 0 – 1 | 0.50 | Iterative stream-power erosion — resolves endorheic basins via priority-flood canyon carving, then carves river valleys and dendritic drainage networks, with automatic sediment deposition in flat receivers |
| Thermal Erosion | 0 – 1 | 0.10 | Slope-driven material transport — softens ridges and creates natural talus slopes |
| Ridge Sharpening | 0 – 1 | 0.50 | Accentuates mountain ridgelines — pushes peaks further above their surroundings for more dramatic terrain |
### Climate
Global climate offsets that adjust temperature and precipitation without a full rebuild. Changing these triggers a fast climate-only recompute.
| Control | Range | Default | Description |
|---------|-------|---------|-------------|
| Temperature | -15 – 15 | 0 | Global temperature offset in °C — positive makes the planet warmer, negative colder. Climate zones shift accordingly |
| Precipitation | -1 – 1 | 0 | Global precipitation scale — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution |
### Auto Climate
Climate simulation (wind, ocean currents, precipitation, temperature, Köppen classification) runs automatically during generation when detail is ≤ 300K regions. Above 300K, climate is skipped for faster terrain iteration and computed on demand when switching to a climate-dependent view.
### Visual Options
- **Map Type** — segmented Terrain / Satellite / Climate / Heightmap tabs for quick switching between the four most common visualizations. Each tab shows a color legend:
- **Terrain** — elevation color ramp from deep ocean through sea level to mountain peaks
- **Satellite** — realistic biome colors based on Köppen climate classification and elevation (lush green rainforests, tan deserts, white ice caps, dark taiga, gray tundra), with ocean using the standard terrain palette. High elevations blend toward snow white based on climate-aware snow lines.
- **Climate** — Köppen-Geiger classification with color swatches for all 30 climate types
- **Heightmap** — black-to-white gradient on a fixed absolute scale (-5 km ocean floor to 6 km peaks), so the same physical height always maps to the same shade
- **View** dropdown — switch between Globe and Map (equirectangular projection)
- **Center Longitude** slider (map mode only) — shifts the map projection's central meridian to any longitude from 180°W to 180°E, scrolling the equirectangular projection so the chosen longitude is centered. Exports are unaffected (always centered on 0°).
- **Wireframe** — toggle switch to show Voronoi cell edges as a wireframe overlay
- **Show Plates** — toggle switch to color regions by plate (green shades = land, blue shades = ocean); also draws black super plate boundary lines showing tectonic super-groups
- **Auto-Rotate** — toggle switch to spin the globe continuously
- **Grid Lines** — toggle switch for latitude/longitude grid overlay on both globe and map views
- **Grid Spacing** — choose the interval between grid lines: 30°, 15°, 10°, 5°, or 2.5°
### Inspect Dropdown
The **Inspect** dropdown (in Visual Options, below the map tabs) selects a detailed visualization layer. On the import page a **Painted Map** group — Class Map, Uplift Rate, Erodibility, Drainage (Rivers), Slope, Drainage Basins — is enabled once a painted map is solved; the same six are export types, and **Export All** includes them. Options are organized into groups:
- **Main views** (ungrouped at top) — Terrain, Satellite, Köppen Climate, Land Heightmap
- **Geology** — Base, Tectonic, Noise, Interior, Coastal, Ocean Floor, Hotspot, Tectonic Activity, Margins, Back-Arc, Fold Ridge, Orogenic Power, Erosion Delta (blue = eroded, red = deposited)
- **Atmosphere** — Pressure Summer/Winter (blue = low, red = high), Wind Speed Summer/Winter (with directional arrows on both globe and map)
- **Ocean** — Currents Summer/Winter (red = warm poleward, blue = cold equatorward, black = zonal; with directional current arrows)
- **Climate** — Precipitation Summer/Winter (brown = dry, green = moderate, blue = wet), Rain Shadow Summer/Winter (diverging blue = windward orographic boost, gray = neutral, red-brown = leeward rain shadow; leeward effects are seeded at downslope faces scaled by mountain height, then propagated ~1500 km downwind to show extended shadow zones like the foehn drying effect), Temperature Summer/Winter (purple-blue = cold, white = 0 C, green-yellow = warm, red = hot; fixed -45 to +45 C range), Continentality (blue = ocean, green = coast, yellow = moderate interior, orange/red = deep continental interior)
- **Elevation** — Full Heightmap (full-range B&W)
### Export
Click **Export Map** (below Visual Options) to open the export modal:
- **Type** — Color Map (terrain colors), Satellite (biome colors from Köppen classification), Climate (Köppen classification colors), Heightmap (B&W full range on fixed -5 to 6 km absolute scale), Land Heightmap (B&W on fixed 0 to 6 km absolute scale, ocean is black), or Land Mask (pure B&W — white = land, black = ocean). Satellite and Climate options are disabled when climate hasn't been computed.
- **Width** slider — 1024 to 65536 pixels (height is always width/2 for equirectangular). Large exports use tiled rendering to handle GPU texture limits.
- **Export** — downloads the selected type as an equirectangular PNG with no grid overlay
- **Export All** — downloads four maps (Satellite, Climate, Land Heightmap, Land Mask) sequentially. If climate hasn't been computed yet, it runs automatically before exporting.
- A progress overlay shows rendering and PNG encoding status during export
- **Unreal Landscape…** — opens the landscape tile exporter described below
### Unreal landscape export
The map exports answer "draw the whole planet at width W". Unreal asks a different question, and the
**Unreal Landscape…** button in the export modal answers it: *what is the ground, in metres, over this
rectangle of the planet, at the sample spacing the game uses?*
It writes one `<Level>_x<N>_y<N>_Height.png` (16-bit greyscale, `tiles.vertices` square) and three 8-bit
weightmaps per tile, plus `Region.json`, into a folder you choose. Every field re-plans as you type, and the
readout under them says what that setting bought.
| Field | What it decides |
| --- | --- |
| Level | The tiles are named after its last segment |
| Planet circumference | **The scale.** A sphere carries no metres; this is what turns the window's degrees into ground, and it decides how much land a window can hold |
| Centre longitude / latitude | Where the window sits. Keep it near the equator |
| Tiles across / down, Vertices a tile | The grid. `255·N+1` vertices gives each tile N×N components of 255 quads — the component count is what costs, not the vertex count |
| Quad size | Metres between vertices, in centimetres. 200 is a 2 m quad |
| Elevation floor / ceiling | What 0 and 65535 mean. Too narrow clips (reported); too wide only costs height precision (also reported) |
| Sea scale | Multiplies everything below sea level, so a whole-planet abyss does not force an elevation range that costs the land its precision. Land is untouched |
| Sample spacing | How finely the planet is rendered before the tiles are cut from it. The mesh resolves a couple of hundred metres, so anything under ~25 m is already lossless |
Three things about it are worth knowing.
**The window is sampled once, then cut.** The planet is rendered into a single float raster over the window
and every tile is resampled out of that raster *by its global vertex position*, so a column two neighbours
share is computed from the same source coordinates twice and comes out bit-identical. Nothing blends or
stitches. Tiles carry a one-vertex margin while the paint layers are derived, because the layers read slope
and a one-sided difference at a tile's edge is not what the neighbour computes there.
**A small planet cannot hold a large flat window.** The projection is equirectangular, cosine-corrected at
the centre latitude, which splits the east-west error between the two edges instead of leaving it all on
one. The readout prints that cost, and the panel says so plainly when the window is too big for the sphere:
936 km² on a 100 km-circumference planet is 29% of the entire globe, and reads as a window 110° on a side
stretched 74.7% at its edge. That is arithmetic, not a bug — raise the circumference or use fewer rows.
**It does not invent detail.** The sphere mesh resolves a couple of hundred metres, so below that the ground
is smooth no matter how finely it is sampled. What this export fixes is that the ground arrives in the shape
Unreal wants with its scale attached; it does not make the ground finer.
An existing `Region.json` is **never replaced** — a hand-written one is mostly commentary explaining why each
number is what it is, and a generated file would throw that away. When one is already in the folder the new
manifest is written as `Region.generated.json` instead and the status line says so; rename it over the old
one when you have read the difference. The tiles themselves are always overwritten.
Requires the File System Access API (Chrome or Edge on desktop); the panel says so if the browser lacks it.
### Sidebar & Loading
The control panel can be collapsed and expanded with the **«** toggle button in the sidebar header. On small screens (≤ 768px) the sidebar becomes a bottom sheet with a drag handle — starts collapsed, showing only the handle and header. Drag up or tap the handle to expand. A fullscreen overlay with spinner, title, and progress bar appears during every generation — fully opaque on initial load, semi-transparent on subsequent builds so the previous planet is dimmed behind it. Stage labels (shaping, plates, oceans, mountains, painting) update as the pipeline progresses.
### Tutorial & Help
A five-step tutorial modal introduces the tool on first visit (auto-shown via `localStorage`). It covers planet generation, slider controls, interactive editing, visualization, saving/sharing via planet codes, and map export. A **?** help button in the top-right corner reopens the tutorial at any time. The modal can be dismissed with the close button, backdrop click, Escape key, or the "Get Started" button on the final step.
A **What's New** modal is shown once per release to returning users (those who have already dismissed the tutorial). It highlights new features, changes, and a heads-up that saved planet codes may produce different-looking worlds due to terrain/climate reworks. The modal uses a versioned `localStorage` flag (`wo-whatsnew-seen`) — bump the `VERSION` constant in `initWhatsNew()` to trigger it again on the next release.
### Interaction
Navigation hints are shown in the sidebar panel and as a contextual tooltip when hovering the planet.
| Action | Desktop | Mobile |
|--------|---------|--------|
| Rotate globe / pan map | Drag | Drag (one finger) |
| Zoom | Scroll wheel | Pinch with two fingers |
| Highlight plate + info card | Hover | — |
| Mark plate for reshaping | Ctrl-click a plate (multi-select) | Tap the edit button (pencil), then tap plates |
| Undo pending plate | Ctrl-click the same plate again | Tap the same plate again |
| Apply pending edits | Click the Rebuild button | Tap the Rebuild button |
| Cancel all pending edits | Press Escape | — |
Hovering over a region shows an info card with plate type, elevation, coordinates, and (when climate has been computed) temperature, precipitation, and K&ouml;ppen classification. Pending plates show a colored tint (green = ocean→land, blue = land→ocean) and hover text indicates "(pending)".
### Mobile Support
World Orogen is fully usable on phones and tablets:
- **Bottom-sheet sidebar** — on screens 768px or narrower, the sidebar becomes a bottom sheet with a drag handle. Drag or tap the handle to expand/collapse. The globe stays visible above.
- **Pinch-to-zoom** — two-finger pinch zooms the globe and map, using the same smooth lerp as desktop scroll-zoom.
- **View switcher** — a dropdown in the top-right lets you switch between Terrain, Satellite, Climate, and Heightmap views without opening the bottom sheet.
- **Edit-mode toggle** — a floating pencil button (bottom-right) activates plate editing. Tap it to toggle edit mode (glows green when active), then tap plates to mark them. Tap the Rebuild button to apply all changes at once.
- **Touch-friendly targets** — buttons, checkboxes, and sliders are enlarged for comfortable finger input.
- **Performance** — detail warning thresholds are lowered on touch devices (orange at 200K, red at 500K). Export widths above 8192px are disabled on mobile.
- **Tooltips** reposition above their trigger instead of to the right, so they stay on screen.
- **Orientation** changes are handled automatically.
## How It Works
### Pipeline
1. **Fibonacci spiral** distributes N points evenly on a unit sphere with optional jitter
2. **Stereographic projection** maps the sphere points to 2D
3. **Delaunator** computes Delaunay triangulation in projected space
4. **Pole closure** connects convex hull edges to a pole point, creating a watertight mesh
5. **Coarse plate generation** on a fixed ~20,000-region reference mesh (resolution-independent), via farthest-point seed placement (with top-3 jitter for variety), round-robin flood fill with per-plate growth rates, directional bias coupled inversely to growth rate, growth-rate governor, and compactness penalty
6. **Ocean/land assignment** on the coarse mesh using farthest-point continent seeding with area budgeting
7. **Plate projection** maps coarse plate assignments onto the high-res mesh via nearest-neighbor adjacency walk, then smooths boundaries with resolution-scaled majority-vote passes
8. **Collision detection** simulates plate drift to classify convergent/divergent/transform boundaries
9. **Stress propagation** diffuses collision stress inward through continental plates via frontier BFS
10. **Elevation assignment** combines distance fields, stress-driven uplift, ocean floor profiles, rift valleys, back-arc basins, hotspot volcanism, island arcs, coastal roughening, and multi-layered noise
11. **Terrain post-processing** applies domain warping (controlled by Terrain Warp slider) using FBM simplex noise to deform the elevation field for organic coastlines and mountain ridges via greedy mesh walk, then bilateral smoothing (controlled by Smoothing slider) to blend BFS banding artefacts, glacial erosion (controlled by Glacial Erosion slider) carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes, priority-flood pit resolution carves canyons through mountain saddle points to ensure all land drains to the ocean, iterative implicit stream power hydraulic erosion with sediment deposition (controlled by Hydraulic Erosion slider) carves self-reinforcing river valleys, thermal erosion (controlled by Thermal Erosion slider) softens ridges via talus-angle material transport, ridge sharpening (controlled by Ridge Sharpening slider) accentuates mountain ridgelines, and always-on soil creep gently rounds off hillslopes
12. **Wind simulation** computes a longitude-varying ITCZ by scanning for the thermal maximum at each longitude (accounting for land/sea heating differential and elevation lapse rate), builds pressure fields from Gaussian zonal bands centered on the ITCZ plus land/sea thermal modifiers and elevation barometric effects, then derives wind vectors from pressure gradients with latitude-dependent Coriolis deflection and surface friction. Computed for both NH summer and winter.
13. **Ocean currents** uses a rule-based geographic approach: classifies ocean cells by wind belt (trades, westerlies, polar easterlies) to set base zonal flow, runs three BFS passes from coastal seeds to compute distance to western and eastern coastlines (classified by coast-normal direction), deflects currents poleward near western boundaries (warm, intensified ×2) and equatorward near eastern boundaries (cold, weaker ×0.8), detects circumpolar channels at ±60° latitude for unobstructed eastward flow, smooths with 5 Laplacian passes, and classifies heat transport by meridional flow direction. Computed for both seasons.
14. **Precipitation** uses a blended dual-model approach. The complex model computes moisture advection from coasts using iterative upwind propagation driven by wind vectors, with depletion based on distance and elevation gain, plus six mechanisms: ITCZ convective uplift, frontal convergence at subpolar lows, orographic rain/rain shadow, lee cyclogenesis, polar front diffuse precipitation, and seasonal subtropical high suppression (shifts poleward in local summer to create Mediterranean dry-summer patterns). A heuristic zonal model computes smooth precipitation from ITCZ distance (with aggressive subtropical drying at 15–30°), seasonal hemisphere boost with Mediterranean subtropical suppression (up to 55% summer reduction at 25-42° latitude), continental dryness, and orographic rain shadow. The two models are blended 50-50 then normalized via 95th-percentile scaling. Computed for both seasons.
15. **Temperature** computes per-cell surface temperature using the ITCZ as the thermal equator (28°C peak, warmest latitude band), with poleward cooling following a power-law curve (exponent 1.2, 13° tropical plateau, 52°C range). Modulated by seasonal hemisphere offset with latitude-dependent seasonal amplitude boost (up to ±12°C peaking at 55-75° latitude), continentality-scaled maritime factor (coast 0.50× to deep interior 1.20× seasonal swing), moisture-dependent elevation lapse rate (4.5 C/km in wet regions to 9.3 C/km in dry regions, interpolated by precipitation), ocean current warmth (16-pass diffusion onto coastal land, ±20°C effect with 0.95 continentality gate), and precipitation/cloud cover moderation. Normalized to a fixed -45 to +45 C range. Computed for both seasons.
16. **Rendering** builds a Voronoi cell mesh with per-vertex colors and terrain displacement
### Key Algorithms
- **Seeded PRNG** — Park-Miller LCG for deterministic generation
- **3D Simplex noise** — with fBm and ridged fBm variants for terrain detail
- **Harmonic-mean distance blending** — `(1/a - 1/b) / (1/a + 1/b + 1/c)` for smooth elevation transitions
- **Domain warping** — noise-driven coordinate offsets for organic coastlines
- **Density-based subduction** — tanh mapping of density differences with undulation noise
- **BFS distance fields** — randomized frontier expansion from boundary seeds, used for elevation, coast distance, rift width, ridge profiles, and back-arc basins
- **Gaussian dome uplift** — hotspot volcanism modeled as dual-component Gaussians (thermal swell + volcanic peak) with domain-warped shape distortion, anisotropic drift elongation, summit calderas, radial rift ridges, and age-dependent texture blending
## Project Structure
```
index.html Main page — HTML markup + import map + structured data
import.html Import page — heightmap upload + climate visualization
styles.css All CSS (shared by both pages)
robots.txt Search engine crawler directives
sitemap.xml Sitemap for search engine indexing
site.webmanifest Web app manifest (metadata + theming)
llms.txt AI/LLM-readable site description (AISEO)
humans.txt Project credits
CNAME Custom domain config (orogen.studio)
404.html Custom 404 page
preview.png Social preview image (og:image / Twitter card)
js/
main.js Generator entry point — UI wiring, animation loop
import-main.js Import page entry point — file upload, import dispatch, painted-map legend table
painted.js Painted-map import — legend parsing, pixel classification, region voting, stroke dissolution, coast roughening, uplift field, Braun-Willett stream-power solve
painted-layers.js Colours and legends for the painted layers (class, uplift, erodibility, drainage, slope, basins, overlay)
painted-report.js What a legend's numbers make before a solve: the divide and typical hillslope angles per class
painted-overlay.js The annotation layer — mark legend parsing, pixel classification, region voting, coast_jitter per region
painted-overlay-view.js The overlay sheet as a texture on the globe, the map and the exports
state.js Shared mutable application state
generate.js Worker dispatcher — posts jobs, handles results
planet-worker.js Web Worker — runs geology pipeline off main thread
planet-code.js Planet code encode/decode (seed + sliders → base36)
rng.js Seeded PRNG (Park-Miller LCG)
simplex-noise.js 3D Simplex noise with fBm and ridged fBm
color-map.js Elevation → RGB colour mapping + satellite biome colors
sphere-mesh.js Fibonacci sphere, Delaunay, SphereMesh dual-mesh
plates.js Tectonic plate generation (farthest-point seeding, round-robin flood fill, compactness constraints)
coarse-plates.js Resolution-independent plate pipeline — coarse reference grid, projection, boundary smoothing
super-plates.js Groups same-type plates into ~20 super plates for broad orogenic belts
ocean-land.js Ocean/land assignment with continent seeding
elevation.js Collisions, stress propagation, distance fields, elevation
terrain-post.js Domain warping, bilateral smoothing, glacial/hydraulic/thermal erosion, ridge sharpening, soil creep
climate-util.js Shared climate utilities — smoothing, ITCZ lookup, percentile selection
wind.js Seasonal wind simulation — pressure fields, ITCZ tracking, Coriolis wind
ocean.js Ocean surface currents — rule-based wind-belt gyres, coast BFS, circumpolar detection
precipitation.js Precipitation simulation — moisture advection, ITCZ/frontal/orographic effects, blended with heuristic
heuristic-precip.js Heuristic zonal precipitation model — smooth latitude/continentality/orographic patterns
temperature.js Temperature simulation — ITCZ thermal equator, lapse rate, continentality, ocean currents
scene.js Three.js scene, cameras, controls, lights
planet-mesh.js Voronoi mesh, map projection, hover highlight
edit-mode.js Ctrl-click plate multi-select + hover info
detail-scale.js Non-linear (power-curve) detail slider mapping
png-write.js 8-bit and 16-bit greyscale PNG encoders (a canvas gives neither)
unreal-render.js Renders a lon/lat window of the planet into a float raster of kilometres
unreal-export.js Cuts that raster into Unreal landscape tiles, derives the weightmaps, writes Region.json
unreal-ui.js The Unreal Landscape panel, built in JS so both pages share one copy
```
## Dependencies
Loaded via CDN import maps (no installation needed):
- [Three.js](https://threejs.org/) v0.160.0 — 3D rendering
- [Delaunator](https://github.com/mapbox/delaunator) v5.0.1 — 2D Delaunay triangulation
## License
This project is licensed under the GNU General Public License v3.0 — see [LICENSE](LICENSE) for details.
## Acknowledgments
Inspired by [Red Blob Games' planet generation](https://www.redblobgames.com/x/1843-planet-generation/) — Fibonacci sphere meshing, dual-mesh traversal, and distance-field elevation approach.
Additional inspiration and reference from:
- [Worldbuilding Pasta](https://worldbuildingpasta.blogspot.com/) — worldbuilding science and climate reference
- [Artifexian](https://www.youtube.com/@Artifexian) — worldbuilding tutorials and planetary science inspiration
- [Madeline James](https://www.youtube.com/@MadelineJamesWorldbuilds) ([website](https://www.madelinejameswrites.com/)) — worldbuilding methodology and climate design reference
- [Fractal Philosophy](https://www.youtube.com/watch?v=7xL0udlhnqI) — procedural terrain generation inspiration
+111
View File
@@ -0,0 +1,111 @@
# World Buildr — V1 Product Review
## What's Good (Strengths)
### Technical Foundation is Impressive
- The geology pipeline is genuinely sophisticated — tectonic plates, collision detection, stress propagation, distance fields, island arcs, hotspot volcanism, rift valleys, back-arc basins. This isn't a noise-on-a-sphere generator; it's a real tectonic simulation. That's the differentiator and it's strong.
- Deterministic planet codes with URL sharing is a killer feature for virality. Compact 11-char codes that fully reproduce a planet (including manual edits) is smart product thinking.
- Zero build step, no install, CDN-loaded deps. The lowest possible friction to get it running.
### UI is Clean and Focused
- The sidebar panel is well-organized with collapsible sections. Slider hints ("Coarse / Fine", "Supercontinent / Archipelago") are excellent — they tell users what the slider *means*, not just what it *does*.
- The `?` tooltip system on each slider is unobtrusive but available.
- The stale indicator (button turns orange "Rebuild" when sliders change) communicates state without words.
- Hover-to-highlight-plate with contextual info is discoverable and satisfying.
- The tutorial is lightweight (4 steps) and dismissable. Correct approach for a tool like this.
### Artistic Appeal is Solid
- Atmosphere rim shader, translucent water sphere, starfield — the globe looks like a planet, not a texture demo. The color ramp produces believable earth tones with good contrast between ocean/land/mountain/snow.
---
## What Needs Work for Market-Ready V1
### 1. Performance & Perceived Speed (High Priority)
**Generation blocks the main thread.** The `setTimeout(..., 16)` in `generate.js` lets the button state repaint, but the actual work is synchronous — at 200K+ cells, the browser locks for multiple seconds. Users will think the app is frozen.
- **Move generation to a Web Worker.** This is the single biggest UX improvement possible. It unblocks the UI, lets you show a progress bar, and prevents the browser's "page unresponsive" warning at high detail levels.
- At minimum, add a visible progress indicator (spinner, progress bar, or pulsing animation on the button) beyond just the text changing to "Building...".
### 2. Mobile & Responsive (High Priority)
- The UI panel is absolutely positioned at `top: 16px; left: 16px` with a fixed `min-width: 270px`. On mobile screens this will cover most of the viewport. There's no way to collapse or dismiss it.
- No `@media` queries anywhere in CSS. No touch gesture handling. Ctrl-click is impossible on mobile.
- **For V1:** At minimum, make the sidebar collapsible/toggleable on small screens. Consider touch-to-select as the mobile equivalent of Ctrl-click.
### 3. First Impression & Empty State (High Priority)
- When the page loads, it immediately starts generating a planet. That's fine — but there's no loading state visible before JS loads and executes. On slower connections, users see a black screen.
- **Add a lightweight loading indicator in pure HTML/CSS** (no JS dependency) that gets replaced when the app initializes.
### 4. Export & Practical Utility (Medium-High Priority)
Right now users can look at planets and share codes. But what can they *do* with what they've made? For a tool going to market, you need at least one export path:
- **Image export** — "Save as PNG" for the current view (globe or map). This is trivial with `renderer.domElement.toDataURL()` and immediately makes the tool useful for worldbuilding, RPGs, wallpapers.
- **Heightmap export** — a grayscale equirectangular PNG of the elevation data. This makes the tool usable in Unity, Unreal, Blender, and other 3D tools. This is the bridge from "cool demo" to "useful tool."
- **Consider STL/OBJ export** for 3D printing enthusiasts (lower priority but high wow-factor).
### 5. Color Map & Biome Richness (Medium Priority)
The current color map (`color-map.js`) is a single elevation-to-color function with 8 linear interpolation bands. It works, but:
- No latitude-based variation — polar regions look the same as the equator. Adding even a simple latitude tint (white toward poles, warmer at equator) would dramatically increase visual appeal.
- No biome differentiation — deserts, forests, tundra, ice caps are all absent. Even a simple noise-modulated biome layer on top of the elevation coloring would make planets feel more alive and give users something to discover as they rotate.
- The ocean coloring is uniform depth-based blue. Real oceans have color variation from coastal shallows (teal/cyan) to deep abyssal (near-black). The data is already there in `dist_coast`.
### 6. Accessibility & Discoverability (Medium Priority)
- **Ctrl-click is not discoverable.** It's mentioned in the tutorial and in small text at the bottom, but there's no visual affordance. Users who dismiss the tutorial will never find this feature. Consider a mode toggle button ("Edit Plates" on/off) that makes regular clicks toggle plates.
- **Keyboard shortcuts are absent.** Space to generate, R to toggle rotation, W for wireframe, etc. — these are cheap to add and power users will expect them.
- **No undo for plate edits.** Ctrl-click is destructive (triggers a full recompute). A simple undo stack (even just "undo last edit") would make editing feel safe.
### 7. Branding & Polish (Medium Priority)
- **Favicon** is `data:,` (empty). Add a real favicon — even a simple colored globe emoji rendered to a canvas.
- **No Open Graph / social meta tags.** When someone shares a planet URL on Twitter/Discord/Slack, it will show nothing. Add `og:title`, `og:description`, `og:image` (a static preview image) at minimum.
- **The title bar just says "World Buildr."** Consider dynamically updating it: "World Buildr — #a7f3kq9xp2b" when a planet is loaded, so browser tabs are identifiable.
- **No 404/error handling for bad hash codes.** If someone visits a URL with a corrupted hash, the error is silent. Show a brief toast message.
### 8. Code Architecture for Future Growth (Low-Medium Priority)
- **`elevation.js` is 970 lines** doing collision detection, stress propagation, distance fields, rift BFS, ridge BFS, fracture BFS, back-arc BFS, coastal roughening, island arcs, hotspot volcanism, and final elevation assembly — all in a single function. This will become unmaintainable. Even a basic extraction of each geological feature into its own function/file would help.
- **`buildDriftArrows` has an early `return`** on line 373 of `planet-mesh.js` — the entire function is dead code after it. Either remove it or finish it.
### 9. Map View Quality (Low-Medium Priority)
- The equirectangular map projection works but has visible triangle seams near the poles and antimeridian. For a market product, these artifacts reduce confidence in quality.
- Map view has no grid lines, labels, or legend. Even a simple lat/lon grid overlay would make it feel like a proper map.
### 10. Documentation & Landing (Low Priority for MVP, High for Marketing)
- The README is thorough for developers but there's no landing page, no screenshots, no GIF/video showing the tool in action. For a product going to market, the first thing someone sees should be a compelling visual, not a markdown file.
- Consider a simple landing section or splash that shows off the best-looking generated planet before asking users to interact.
---
## Priority Summary
| Priority | Item | Effort | Status |
|----------|------|--------|--------|
| **Must Have** | Web Worker for generation (no UI freeze) | Medium | |
| **Must Have** | Mobile-responsive sidebar (collapsible) | Low-Medium | Done |
| **Must Have** | Loading state before JS initializes | Low | Done |
| **Must Have** | Image export (PNG screenshot) | Low | |
| **Should Have** | Heightmap export (grayscale PNG) | Medium | |
| **Should Have** | Latitude-based color variation / basic biomes | Medium | |
| **Should Have** | OG/social meta tags for link previews | Low | |
| **Should Have** | Real favicon | Low | |
| **Should Have** | Edit mode toggle (not just Ctrl-click) | Low | |
| **Should Have** | Undo for plate edits | Low-Medium | |
| **Nice to Have** | Keyboard shortcuts | Low | |
| **Nice to Have** | Elevation.js refactor | Medium | |
| **Nice to Have** | Map view polish (grid lines, pole fixes) | Medium | Done |
| **Nice to Have** | Landing page / hero visual | Medium | |
---
## Bottom Line
The core of this product is genuinely strong — the tectonic simulation, the planet codes, and the clean UI put it well ahead of most procedural planet generators. What's missing for V1-to-market is mostly in the **"last mile" category**: making the output *usable* beyond just looking at it (exports), making it *work everywhere* (mobile), and making it *feel* polished (favicon, social previews, loading states, no UI freezes). The geology engine is the hard part, and that's already done. The rest is packaging.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a5d6e2da8117625318159574c907594337ea9cb9d18e35476af2e23f0edbb22
size 327162
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c422294c85f5327e5aeed6c50582864cf994aff84694391f561eeb556a56b05d
size 273264
+38
View File
@@ -0,0 +1,38 @@
{
"_comment": "The built-in legend for a painted map: what each colour means, in rock uplift (mm/yr) and erodibility, never height. It is the same schema Tools/Terrain reads (RawContent/World/Templates/Map3.legend.json in the Salty repo, with its commentary trimmed), so a painting and legend made for one tool load in the other. The optional planet block carries the numbers Planet.json holds there.",
"image": "painted-demo.png",
"warn_distance": 60,
"planet": {
"circumference_km": 100,
"massif_wavelength_km": 7,
"lithology_wavelength_km": 8,
"uplift_variation": 0.30,
"lithology": { "types": 3, "k_multipliers": [0.6, 1.0, 1.8] }
},
"classes": [
{ "name": "ocean", "rgb": [ 91, 175, 185], "sea": true, "depth_m": 512 },
{ "name": "deep", "rgb": [ 65, 165, 180], "sea": true, "depth_m": 512 },
{ "name": "shelf", "rgb": [153, 204, 221], "sea": true, "depth_m": 120 },
{ "name": "surf", "rgb": [221, 238, 238], "sea": true, "depth_m": 20 },
{ "name": "ice", "derived": true, "rgb": [250, 250, 250], "uplift_mm_yr": 0.05, "k_mult": 1.0,
"snow": true, "lithology_mix": 0 },
{ "name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
"coastal_plain_km": 1.0, "coastal_floor_mm_yr": 0.012 },
{ "name": "highland", "rgb": [ 68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.045, "fraction": 0.30 },
"coastal_plain_km": 4.0, "coastal_floor_mm_yr": 0.03 },
{ "name": "desert", "rgb": [238, 221, 153], "uplift_mm_yr": 0.10, "k_mult": 0.5,
"massif": { "floor_mm_yr": 0.015, "fraction": 0.14 },
"coastal_plain_km": 1.5, "coastal_floor_mm_yr": 0.015 },
{ "name": "crater", "rgb": [124, 117, 111], "uplift_mm_yr": 0.15, "k_mult": 1.5,
"lithology_mix": 0 },
{ "name": "stroke", "rgb": [238, 238, 238], "stroke": true, "edge_class": "ice" }
]
}
+17
View File
@@ -0,0 +1,17 @@
/* TEAM */
Project: World Orogen
Site: https://orogen.studio
Contact: https://github.com/raguilar011095/planet_heightmap_generation
/* THANKS */
Red Blob Games — Fibonacci sphere meshing and planet generation inspiration
Three.js — 3D rendering
Delaunator — Delaunay triangulation
Worldbuilding Pasta (https://worldbuildingpasta.blogspot.com/) — worldbuilding science and climate reference
Artifexian (https://www.youtube.com/@Artifexian) — worldbuilding tutorials and planetary science inspiration
Madeline James (https://www.youtube.com/@MadelineJamesWorldbuilds, https://www.madelinejameswrites.com/) — worldbuilding methodology and climate design reference
Fractal Philosophy (https://www.youtube.com/watch?v=7xL0udlhnqI) — procedural terrain generation inspiration
/* SITE */
Standards: HTML5, ES Modules, WebGL
Software: Three.js 0.160.0, Delaunator 5.0.1
+410
View File
@@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World Orogen — Import Heightmap or Painted Map</title>
<meta name="description" content="Import your own equirectangular heightmap, or a painted map whose colours are uplift rates that stream-power erosion turns into terrain with real rivers, onto a 3D globe with automatic climate simulation — wind, precipitation, temperature, and K&ouml;ppen classification. Annotate it with an overlay of forests, settlements, roads and coastlines. Free, in your browser.">
<meta name="keywords" content="heightmap import, painted map, uplift, stream power erosion, equirectangular projection, climate simulation, world generator, terrain viewer, planet builder, map overlay, annotation layer, hillslope angle, Three.js, worldbuilding tool">
<meta name="author" content="World Orogen">
<meta name="theme-color" content="#0a0e17">
<link rel="canonical" href="https://orogen.studio/import">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://orogen.studio/import">
<meta property="og:title" content="World Orogen — Import Heightmap">
<meta property="og:description" content="Import your own equirectangular heightmap onto a 3D globe with automatic climate simulation. Free, in your browser.">
<meta property="og:image" content="https://orogen.studio/preview.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="World Orogen">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="World Orogen — Import Heightmap">
<meta name="twitter:description" content="Import your own equirectangular heightmap onto a 3D globe with automatic climate simulation. Free, in your browser.">
<meta name="twitter:image" content="https://orogen.studio/preview.png">
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="manifest" href="site.webmanifest">
<link rel="author" href="humans.txt">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='50' y='50' dominant-baseline='central' text-anchor='middle' font-size='75'>🌍</text></svg>">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav id="topNav">
<a href="/" class="nav-tab">Generate</a>
<a href="/import" class="nav-tab active">Import</a>
</nav>
<div id="buildOverlay" class="build-overlay hidden">
<div class="build-overlay-inner">
<div class="build-overlay-spinner"></div>
<div class="build-overlay-title" id="buildOverlayTitle">Importing heightmap</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="buildBarFill"></div>
</div>
<div class="progress-label" id="buildBarLabel"></div>
</div>
</div>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="sheetHandle" class="sheet-handle"><span></span></div>
<div id="sidebarHeader">
<div>
<h2>World Orogen</h2>
<div class="sub">Import a heightmap or a painted map</div>
</div>
<button id="sidebarToggle" title="Collapse panel">&#x00AB;</button>
</div>
<div id="sidebarContent">
<details class="section" open>
<summary>Import</summary>
<div class="section-body">
<div class="map-tabs source-tabs" id="sourceTabs">
<button class="map-tab active" data-source="heightmap">Heightmap</button>
<button class="map-tab" data-source="painted">Painted Map</button>
</div>
<div class="src-heightmap">
<div class="import-upload-area">
<label class="import-file-label" for="heightmapFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose Image
</label>
<input type="file" id="heightmapFile" accept="image/png,image/jpeg,image/webp" style="display:none">
<div id="importFileName" class="import-file-name"></div>
</div>
<canvas id="importPreview" class="import-preview" style="display:none"></canvas>
<div id="importDims" class="import-dims" style="display:none"></div>
<div class="import-hint">Black (0) = ocean. Brighter = higher elevation. Use a 2:1 equirectangular image.</div>
<div id="importExpect" class="import-expect" style="display:none">Your heightmap will be projected onto a 3D sphere with full climate simulation — wind patterns, precipitation, temperature, and biome classification — computed automatically.</div>
</div>
<div class="src-painted">
<div class="import-upload-area">
<label class="import-file-label" for="paintFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Choose Painting
</label>
<input type="file" id="paintFile" accept="image/png,image/jpeg,image/webp" style="display:none">
<div id="paintFileName" class="import-file-name"></div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="legendFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
Choose Legend
</label>
<input type="file" id="legendFile" accept="application/json,.json" style="display:none">
<div id="legendFileName" class="import-file-name">Built-in legend</div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="planetFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Choose Planet.json
</label>
<input type="file" id="planetFile" accept="application/json,.json" style="display:none">
<div id="planetFileName" class="import-file-name">Defaults</div>
</div>
<canvas id="paintPreview" class="import-preview" style="display:none"></canvas>
<div id="paintDims" class="import-dims" style="display:none"></div>
<div class="import-hint">Every colour is a <em>class</em> — a rate of rock uplift and an erodibility, never a height — and the legend JSON says what the colours mean. The land is then eroded with stream power until it is in balance with its uplift, so the rivers, divides and valleys are the physics' answer to your painting. 2:1 equirectangular. <a href="#" id="paintDemoLink">Load the demo painting</a>.</div>
<div id="paintReport" class="import-report" style="display:none"></div>
<div id="paintLegendWrap" style="display:none">
<table class="paint-legend" id="paintLegend">
<thead><tr><th></th><th>Class</th><th>Painted</th><th>Uplift <span class="unit">mm/yr</span> / depth <span class="unit">m</span></th><th>k</th><th>Slope <span class="tip" data-tip="The typical (median) hillslope this rate makes on the bake's geology grid, and what it reads as. Hover a value for the divide angle, the steepest ground the rate can make: almost none of a map is divide, so read this column, not that one">?</span></th></tr></thead>
<tbody></tbody>
</table>
<div id="legendNote" class="import-hint legend-note"></div>
<button id="legendDownload" class="btn-ghost btn-small" title="Save the legend with your edits, in the format Tools/Terrain reads">Download legend JSON</button>
</div>
<div class="cg">
<label>Peak Height <span class="tip" data-tip="How high the land stands: the 99.5th percentile of the solved terrain is put at this height. The shape — where the rivers run, how far a coast is from its divide — is the solve's; this is only the scale">?</span> <span class="v" id="vPk">4.5 km</span></label>
<input type="range" id="sPk" min="0.5" max="6" value="4.5" step="0.25">
<div class="slider-hint"><span>Hills</span><span>Alps</span></div>
</div>
<div class="cg">
<label>Ocean Depth <span class="tip" data-tip="The depth of the deepest sea class; shallower classes (shelf, surf) scale with it, and every shore ramps down over a couple of cells">?</span> <span class="v" id="vOd">4.0 km</span></label>
<input type="range" id="sOd" min="0.25" max="5" value="4" step="0.25">
<div class="slider-hint"><span>Shallow</span><span>Abyssal</span></div>
</div>
<div class="cg">
<label>Coast Detail <span class="tip" data-tip="A drawn shore is a smooth curve and a real one is fractal. Noise is added to the distance from the painted waterline before anything is solved, so bays and headlands appear where the brush was straight. Islets keep at least a third of their width">?</span> <span class="v" id="vCd">0.35</span></label>
<input type="range" id="sCd" min="0" max="1" value="0.35" step="0.05">
<div class="slider-hint"><span>As drawn</span><span>Ragged</span></div>
</div>
<div class="cg">
<label>Uplift Variation <span class="tip" data-tip="A regional swell over the painted rate, so a lowland painted in one colour has basins and rises of its own rather than one flat rate across a continent">?</span> <span class="v" id="vUv">0.30</span></label>
<input type="range" id="sUv" min="0" max="0.6" value="0.3" step="0.05">
<div class="slider-hint"><span>Uniform</span><span>Rolling</span></div>
</div>
<div class="cg">
<label>Solve Steps <span class="tip" data-tip="How long the stream-power solve runs. It converges within a few hundred steps at any detail; more steps cost time and change little">?</span> <span class="v" id="vSt">200</span></label>
<input type="range" id="sSt" min="50" max="600" value="200" step="10">
<div class="slider-hint"><span>Quick</span><span>Settled</span></div>
</div>
<details class="subsection">
<summary>Painted planet</summary>
<div class="subsection-body">
<div class="import-hint">The globe is Earth-sized. Distances in the legend — coastal plains, massif and rock sizes — are in the painted world's kilometres and are scaled up by the ratio of the two circumferences.</div>
<div class="num-row"><label for="nCirc">Painted world circumference, km</label><input type="number" id="nCirc" value="100" min="1" step="1"></div>
<div class="num-row"><label for="nMassif">Massif size, km <span class="tip" data-tip="The size of the blocks in the planet's upland fabric. A class with a massif block is a plain with hill masses standing out of it; this is how big they are">?</span></label><input type="number" id="nMassif" value="7" min="0" step="0.5"></div>
<div class="num-row"><label for="nLith">Rock province size, km <span class="tip" data-tip="The size of the provinces in the planet's rock field, which multiplies each class's erodibility so a range is made of several rocks rather than one">?</span></label><input type="number" id="nLith" value="8" min="0" step="0.5"></div>
<div class="num-row"><label for="nSeed">Seed <span class="tip" data-tip="A painting is a composition; the seed re-rolls what it does not fix — the massifs, the rock provinces, the swell and the coastline detail">?</span></label><input type="number" id="nSeed" value="7945" min="0" step="1"><button id="paintReroll" class="btn-ghost btn-small" title="New seed">Re-roll</button></div>
</div>
</details>
<details class="subsection" id="overlaySection">
<summary>Overlay</summary>
<div class="subsection-body">
<div class="import-hint">A second painting the same size as the template and registered to it, whose colours are <em>marks</em> rather than classes: forests, settlements, roads, and stretches of coast to leave alone. Blank is transparent. Nothing on it changes a height except <code>coast_jitter</code>, which pins (0) or roughens (above 1) the shore inside the mark.</div>
<div class="import-upload-area">
<label class="import-file-label" for="overlayFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l5-5 4 4 3-3 6 6"/><circle cx="16" cy="8" r="1.5"/></svg>
Choose Overlay
</label>
<input type="file" id="overlayFile" accept="image/png,image/webp" style="display:none">
<div id="overlayFileName" class="import-file-name"></div>
</div>
<div class="import-upload-area">
<label class="import-file-label" for="overlayLegendFile">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
Choose Overlay Legend
</label>
<input type="file" id="overlayLegendFile" accept="application/json,.json" style="display:none">
<div id="overlayLegendFileName" class="import-file-name"></div>
</div>
<div id="overlayReport" class="import-report" style="display:none"></div>
</div>
</details>
<details class="subsection" id="studioSection">
<summary>From terrain studio</summary>
<div class="subsection-body">
<div class="import-hint">Load the painting, both legends and Planet.json straight from a running <code>terrain studio</code> (Tools/Terrain), exactly as its next plan would read them.</div>
<div class="num-row"><label for="studioUrl">Studio address</label><input type="text" id="studioUrl" value="http://127.0.0.1:8099" spellcheck="false"></div>
<button id="studioLoad" class="btn-ghost btn-small">Load from studio</button>
<div id="studioNote" class="import-report" style="display:none"></div>
</div>
</details>
</div>
<div class="cg">
<label>Detail <span class="tip" data-tip="Resolution of the sphere mesh — more detail means finer coastlines but takes longer to process">?</span> <span class="v" id="vN">204,000</span></label>
<input type="range" id="sN" min="0" max="1000" step="1" value="600">
<div class="slider-hint"><span>Coarse</span><span>Fine</span></div>
<div class="detail-warn" id="detailWarn"></div>
</div>
<button id="importBtn" class="import-btn" disabled>Import</button>
<div class="import-hint" style="margin-top:8px">Your image stays on your device — nothing is uploaded. Imported worlds can't be shared via planet codes.</div>
</div>
</details>
<details class="section">
<summary>Terrain Sculpting <span class="tip" data-tip="Post-processing passes to refine the imported terrain. All default to 0 (no effect). Adjust sliders and click Reapply.">?</span></summary>
<div class="section-body">
<div class="cg">
<label>Terrain Warp <span class="tip" data-tip="Deforms the elevation field using noise for more organic coastlines and mountain ridges">?</span> <span class="v" id="vTw">0.00</span></label>
<input type="range" id="sTw" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Smoothing <span class="tip" data-tip="Blends harsh terrain edges and pixelation from the source image">?</span> <span class="v" id="vS">0.00</span></label>
<input type="range" id="sS" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Glacial Erosion <span class="tip" data-tip="Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes">?</span> <span class="v" id="vGl">0.00</span></label>
<input type="range" id="sGl" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Ice Age</span></div>
</div>
<div class="cg">
<label>Hydraulic Erosion <span class="tip" data-tip="Iterative stream-power erosion — carves river valleys and dendritic drainage networks">?</span> <span class="v" id="vHEr">0.00</span></label>
<input type="range" id="sHEr" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Deep</span></div>
</div>
<div class="cg">
<label>Thermal Erosion <span class="tip" data-tip="Slope-driven material transport — softens ridges and creates natural talus slopes">?</span> <span class="v" id="vTEr">0.00</span></label>
<input type="range" id="sTEr" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Ridge Sharpening <span class="tip" data-tip="Accentuates mountain ridgelines — pushes peaks further above their surroundings">?</span> <span class="v" id="vRs">0.00</span></label>
<input type="range" id="sRs" min="0" max="1" value="0" step="0.05">
<div class="slider-hint"><span>None</span><span>Jagged</span></div>
</div>
<button id="reapplyBtn" title="Reapply terrain sculpting" disabled><span class="reapply-icon">&#x21bb;</span> Reapply</button>
</div>
</details>
<details class="section">
<summary>Climate <span class="tip" data-tip="Global temperature and precipitation offsets — changes apply on slider release, recomputing only precipitation, temperature, and climate zones">?</span></summary>
<div class="section-body">
<div class="climate-hint">Changes apply on release — only climate zones are recomputed.</div>
<div class="cg">
<label>Temperature <span class="tip" data-tip="Shift global temperature — positive makes the planet warmer, negative makes it colder. Climate zones shift accordingly.">?</span> <span class="v" id="vTmp">&plusmn;0&deg;C</span></label>
<input type="range" id="sTmp" min="-15" max="15" value="0" step="1">
<div class="slider-hint"><span>Colder</span><span>Warmer</span></div>
</div>
<div class="cg">
<label>Precipitation <span class="tip" data-tip="Scale global precipitation — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution.">?</span> <span class="v" id="vPrc">&plusmn;0%</span></label>
<input type="range" id="sPrc" min="-1" max="1" value="0" step="0.1">
<div class="slider-hint"><span>Drier</span><span>Wetter</span></div>
</div>
</div>
</details>
<details class="section" open>
<summary>Visual Options</summary>
<div class="section-body">
<div class="cg">
<label>View</label>
<select id="viewMode">
<option value="globe">Globe</option>
<option value="map">Map</option>
</select>
</div>
<div class="cg" id="mapCenterLonGroup" style="display:none">
<label>Center Longitude <span class="v" id="vMapCenterLon">0&deg;</span></label>
<input type="range" id="sMapCenterLon" min="-180" max="180" value="0" step="5">
</div>
<div class="map-tabs" id="mapTabs">
<button class="map-tab active" data-layer="">Terrain</button>
<button class="map-tab" data-layer="biome">Satellite</button>
<button class="map-tab" data-layer="koppen">Climate</button>
<button class="map-tab" data-layer="landheightmap">Heightmap</button>
</div>
<div class="cg">
<label>Inspect</label>
<select id="debugLayer">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">K&ouml;ppen Climate</option>
<option value="landheightmap">Land Heightmap</option>
<optgroup label="Atmosphere">
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
</optgroup>
<optgroup label="Ocean">
<option value="oceanCurrentSummer">Currents (Summer)</option>
<option value="oceanCurrentWinter">Currents (Winter)</option>
</optgroup>
<optgroup label="Climate">
<option value="precipSummer">Precipitation (Summer)</option>
<option value="precipWinter">Precipitation (Winter)</option>
<option value="rainShadowSummer">Rain Shadow (Summer)</option>
<option value="rainShadowWinter">Rain Shadow (Winter)</option>
<option value="tempSummer">Temperature (Summer)</option>
<option value="tempWinter">Temperature (Winter)</option>
<option value="continentality">Continentality</option>
</optgroup>
<optgroup label="Elevation">
<option value="heightmap">Full Heightmap</option>
<option value="erosionDelta">Erosion Delta</option>
</optgroup>
<optgroup label="Painted Map" id="paintedInspectGroup">
<option value="paintClass" disabled>Class Map</option>
<option value="paintUplift" disabled>Uplift Rate</option>
<option value="paintK" disabled>Erodibility</option>
<option value="flow" disabled>Drainage (Rivers)</option>
<option value="slope" disabled>Slope</option>
<option value="basins" disabled>Drainage Basins</option>
<option value="paintOverlay" disabled>Overlay</option>
</optgroup>
</select>
</div>
<div id="vizLegend" class="viz-legend"></div>
<div class="tg">
<label class="toggle-label"><input type="checkbox" id="chkWire"><span class="toggle-track"><span class="toggle-thumb"></span></span>Wireframe</label>
<label class="toggle-label"><input type="checkbox" id="chkRotate"><span class="toggle-track"><span class="toggle-thumb"></span></span>Auto-Rotate</label>
<label class="toggle-label"><input type="checkbox" id="chkGrid" checked><span class="toggle-track"><span class="toggle-thumb"></span></span>Grid Lines</label>
<label class="toggle-label src-painted" title="Drape the overlay sheet over whatever layer is shown"><input type="checkbox" id="chkOverlay" disabled><span class="toggle-track"><span class="toggle-thumb"></span></span>Overlay Sheet</label>
</div>
<div class="cg" id="gridSpacingGroup">
<label>Grid Spacing</label>
<select id="gridSpacing">
<option value="30">30&deg;</option>
<option value="15" selected>15&deg;</option>
<option value="10">10&deg;</option>
<option value="5">5&deg;</option>
<option value="2.5">2.5&deg;</option>
</select>
</div>
<details id="statsDetails" class="stats-toggle">
<summary>Stats</summary>
<div id="stats"></div>
</details>
</div>
</details>
<button id="exportBtn" class="export-btn">Export Map</button>
<a id="repoLink" href="https://github.com/raguilar011095/planet_heightmap_generation" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.64 7.64 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</div>
</div>
<select id="mobileViewSwitch" class="mobile-view-switch">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate</option>
<option value="landheightmap">Heightmap</option>
</select>
<div id="exportOverlay" class="hidden">
<div id="exportCard">
<button id="exportClose">&times;</button>
<h3>Export Map</h3>
<div class="cg">
<label>Type</label>
<select id="exportType">
<option value="color">Color Map</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate (K&ouml;ppen)</option>
<option value="heightmap">Heightmap (B&amp;W)</option>
<option value="landheightmap">Land Heightmap (B&amp;W)</option>
<option value="landmask">Land Mask (B&amp;W)</option>
<optgroup label="Painted Map" id="paintedExportGroup">
<option value="paintclass" disabled>Class Map</option>
<option value="uplift" disabled>Uplift Rate</option>
<option value="erodibility" disabled>Erodibility</option>
<option value="flow" disabled>Drainage (Rivers)</option>
<option value="slope" disabled>Slope</option>
<option value="basins" disabled>Drainage Basins</option>
<option value="overlay" disabled>Overlay</option>
</optgroup>
</select>
</div>
<div class="cg">
<label>Width <span class="v" id="exportDims">4096 &times; 2048</span></label>
<select id="exportWidth">
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="4096" selected>4096</option>
<option value="8192">8192</option>
<option value="16384">16384</option>
<option value="32768">32768</option>
<option value="65536">65536</option>
</select>
</div>
<div class="export-actions">
<button id="exportCancel" class="btn-ghost">Cancel</button>
<button id="exportGo" class="btn-primary">Export</button>
<button id="exportAllGo" class="btn-primary">Export All</button>
</div>
</div>
</div>
<!-- Hidden elements for generate.js compatibility (reads these by ID) -->
<input type="checkbox" id="chkPlates" style="display:none">
<input type="hidden" id="sLc" value="0.3">
<div id="topInfo">Import an equirectangular heightmap to visualize it on a globe</div>
<div id="hoverInfo"></div>
<div id="info">Import an equirectangular B&amp;W heightmap to get started</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"delaunator": "https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm"
}
}
</script>
<script type="module" src="js/import-main.js"></script>
</body>
</html>
+530
View File
@@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World Orogen — Procedural Planet Generator</title>
<meta name="description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser. Export heightmaps for worldbuilding, games, and tabletop RPGs.">
<meta name="keywords" content="procedural planet generator, world generator, heightmap generator, tectonic simulation, worldbuilding tool, fantasy map maker, terrain generator, planet builder, procedural generation, Three.js, tabletop RPG map, D&D world map, game dev heightmap">
<meta name="author" content="World Orogen">
<meta name="theme-color" content="#0a0e17">
<link rel="canonical" href="https://orogen.studio/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://orogen.studio/">
<meta property="og:title" content="World Orogen — Procedural Planet Generator">
<meta property="og:description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser. Export heightmaps for worldbuilding, games, and tabletop RPGs.">
<meta property="og:image" content="https://orogen.studio/preview.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="World Orogen">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="World Orogen — Procedural Planet Generator">
<meta name="twitter:description" content="Generate realistic procedural planets with tectonic plates, erosion, climate simulation, and volcanic islands — free, in your browser.">
<meta name="twitter:image" content="https://orogen.studio/preview.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "World Orogen",
"url": "https://orogen.studio/",
"description": "A browser-based procedural planet generator that creates realistic terrestrial planets with tectonic plate simulation, erosion, climate modeling, and interactive editing. Export heightmaps, satellite views, and climate maps for worldbuilding, game development, and tabletop RPGs.",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any (browser-based)",
"browserRequirements": "Requires a modern browser with WebGL support",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"Tectonic plate simulation with collision detection",
"Glacial, hydraulic, and thermal erosion",
"Climate simulation with wind, precipitation, and ocean currents",
"Hotspot volcanism with island chains",
"Interactive plate editing",
"Equirectangular heightmap import with automatic climate simulation",
"Equirectangular map export up to 65536px",
"Unreal Engine landscape tile export with recorded scale and projection",
"Multiple visualization modes (terrain, satellite, climate, heightmap)",
"Shareable planet codes"
],
"screenshot": "https://orogen.studio/preview.png",
"softwareVersion": "1.0",
"creator": {
"@type": "Organization",
"name": "World Orogen"
}
}
</script>
<!-- FAQ Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is World Orogen?",
"acceptedAnswer": {
"@type": "Answer",
"text": "World Orogen is a free, browser-based procedural planet generator. It creates realistic terrestrial planets with tectonic plate simulation, multiple erosion types, climate modeling, and volcanic features. No download or account required — it runs entirely in your browser."
}
},
{
"@type": "Question",
"name": "Can I use the exported maps in my own projects?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. World Orogen exports high-resolution equirectangular maps (up to 65,536px wide) in multiple formats: color terrain, satellite biome, Köppen climate, heightmap, and land mask. These can be used in game engines like Unity or Unreal, tabletop RPG campaigns, worldbuilding projects, or any creative work."
}
},
{
"@type": "Question",
"name": "What browsers does World Orogen support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "World Orogen works in any modern browser with WebGL support, including Chrome, Firefox, Safari, and Edge. It works on both desktop and mobile devices."
}
},
{
"@type": "Question",
"name": "How do I share a planet with someone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Every generated planet has a unique planet code shown below the Build button. Copy the code or share the URL directly — anyone can paste the code or open the link to recreate your exact planet, including any plates you've edited."
}
}
]
}
</script>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="manifest" href="site.webmanifest">
<link rel="author" href="humans.txt">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text x='50' y='50' dominant-baseline='central' text-anchor='middle' font-size='75'>🌍</text></svg>">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav id="topNav">
<a href="/" class="nav-tab active">Generate</a>
<a href="/import" class="nav-tab">Import</a>
</nav>
<!-- Semantic content for search engines and AI crawlers (visually hidden) -->
<main aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap">
<h1>World Orogen — Procedural Planet Generator</h1>
<p>World Orogen is a free, browser-based procedural planet generator. Build realistic terrestrial planets shaped by tectonic plates, erosion, and volcanism — no download or account required.</p>
<h2>How It Works</h2>
<p>Start by adjusting sliders for detail level, number of tectonic plates, continents, and terrain roughness. Click "Build New World" to generate a unique planet with realistic continental shapes, mountain ranges, ocean trenches, volcanic island chains, and hotspot volcanism. Every planet is procedurally generated and completely unique.</p>
<h2>Key Features</h2>
<ul>
<li>Tectonic plate simulation with convergent, divergent, and transform boundaries</li>
<li>Multiple erosion types: glacial (fjords, U-shaped valleys), hydraulic (river valleys), and thermal (talus slopes)</li>
<li>Climate simulation with seasonal wind patterns, ocean currents, precipitation, and K&ouml;ppen climate classification</li>
<li>Hotspot volcanism with drift-trail island chains (like Hawaii)</li>
<li>Interactive plate editing — select multiple plates for batch land/ocean toggling with visual preview before rebuild</li>
<li>Heightmap import — bring your own equirectangular B&amp;W heightmap (Earth, Mars, hand-drawn) and run full climate simulation on it</li>
<li>Multiple visualization modes: terrain, satellite biome, climate, and heightmap</li>
<li>26 detailed inspection layers for geology, atmosphere, ocean, and climate</li>
<li>High-resolution equirectangular map export up to 65,536px wide</li>
<li>Shareable planet codes — copy a code to share your exact planet with others</li>
</ul>
<h2>Use Cases</h2>
<p>World Orogen is used by worldbuilders, game developers, tabletop RPG players, fantasy authors, and anyone who needs realistic terrain. Export heightmaps for use in game engines like Unity or Unreal, or download satellite-style maps for your D&amp;D campaign setting. The climate simulation produces realistic biome distributions for believable fantasy worlds.</p>
<h2>Share Your World</h2>
<p>Every planet has a unique planet code. Share the URL (e.g. orogen.studio/#CODE) to let anyone recreate your exact world — including any tectonic plates you've edited by hand.</p>
</main>
<div id="buildOverlay" class="build-overlay initial">
<div class="build-overlay-inner">
<div class="build-overlay-spinner"></div>
<div class="build-overlay-title">World Orogen</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="buildBarFill"></div>
</div>
<div class="progress-label" id="buildBarLabel"></div>
</div>
</div>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="sheetHandle" class="sheet-handle"><span></span></div>
<div id="sidebarHeader">
<div>
<h2>World Orogen</h2>
<div class="sub">Build worlds shaped by tectonic forces</div>
</div>
<button id="sidebarToggle" title="Collapse panel">&#x00AB;</button>
</div>
<div id="sidebarContent">
<details class="section" open>
<summary>Shape Your World</summary>
<div class="section-body">
<div class="cg">
<label>Detail <span class="tip" data-tip="Resolution of the sphere — more detail means finer coastlines and terrain, but takes longer to generate">?</span> <span class="v" id="vN">204,000</span></label>
<input type="range" id="sN" min="0" max="1000" step="1" value="600">
<div class="slider-hint"><span>Coarse</span><span>Fine</span></div>
<div class="detail-warn" id="detailWarn"></div>
</div>
<div class="cg">
<label>Irregularity <span class="tip" data-tip="How randomly the cell points are scattered — 0 gives a uniform grid, 1 gives fully organic, irregular shapes">?</span> <span class="v" id="vJ">0.75</span></label>
<input type="range" id="sJ" min="0" max="1" value="0.75" step="0.05">
<div class="slider-hint"><span>Uniform</span><span>Scattered</span></div>
</div>
<div class="cg">
<label>Plates <span class="tip" data-tip="Number of tectonic plates — more plates means more boundaries where mountains, trenches, and coastlines form">?</span> <span class="v" id="vP">80</span></label>
<input type="range" id="sP" min="4" max="120" value="80" step="1">
<div class="slider-hint"><span>Few</span><span>Many</span></div>
</div>
<div class="cg">
<label>Continents <span class="tip" data-tip="Target number of landmasses — 1 creates a supercontinent, higher values scatter land into multiple continents and archipelagos">?</span> <span class="v" id="vCn">4</span></label>
<input type="range" id="sCn" min="1" max="10" value="4" step="1">
<div class="slider-hint"><span>Supercontinent</span><span>Archipelago</span></div>
</div>
<div class="cg">
<label>Roughness <span class="tip" data-tip="Terrain roughness — higher values add more fractal detail to mountains and coastlines">?</span> <span class="v" id="vNs">0.40</span></label>
<input type="range" id="sNs" min="0" max="0.5" value="0.40" step="0.01">
<div class="slider-hint"><span>Smooth</span><span>Rugged</span></div>
</div>
<div class="cg">
<label>Continent Size Variety <span class="tip" data-tip="How much continent sizes vary — at 0 all continents are similar in size, at 1 you get a mix of large and small landmasses">?</span> <span class="v" id="vCsv">0.35</span></label>
<input type="range" id="sCsv" min="0" max="1" value="0.35" step="0.05">
<div class="slider-hint"><span>Equal</span><span>Varied</span></div>
</div>
<div class="cg">
<label>Land Coverage <span class="tip" data-tip="Percentage of the planet covered by land — low values create ocean worlds, high values create desert worlds with reduced precipitation">?</span> <span class="v" id="vLc">30%</span></label>
<input type="range" id="sLc" min="0" max="1" value="0.3" step="0.01">
<div class="slider-hint"><span>Ocean World</span><span>Desert World</span></div>
</div>
</div>
</details>
<button id="generate">Build New World</button>
<div id="seedRow">
<input type="text" id="seedCode" placeholder="Planet code">
<button id="copyBtn" title="Copy code">&#x2398;</button>
<button id="loadBtn" title="Load planet from code">Load</button>
</div>
<div id="seedError">Invalid planet code</div>
<details class="section">
<summary>Terrain Sculpting <span class="tip" data-tip="Erosion and smoothing passes that refine the raw terrain. Adjust sliders and click Reapply — no full rebuild needed.">?</span></summary>
<div class="section-body">
<div class="cg">
<label>Terrain Warp <span class="tip" data-tip="Deforms the elevation field on the sphere using noise, producing more organic, squiggly coastlines and mountain ridges">?</span> <span class="v" id="vTw">0.75</span></label>
<input type="range" id="sTw" min="0" max="1" value="0.75" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Smoothing <span class="tip" data-tip="Blends harsh terrain boundaries from tectonic generation — smooths banded ridges and abrupt transitions">?</span> <span class="v" id="vS">0.10</span></label>
<input type="range" id="sS" min="0" max="1" value="0.10" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Glacial Erosion <span class="tip" data-tip="Ice-age sculpting — carves fjords, U-shaped valleys, and lake basins at high latitudes and altitudes">?</span> <span class="v" id="vGl">0.50</span></label>
<input type="range" id="sGl" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Ice Age</span></div>
</div>
<div class="cg">
<label>Hydraulic Erosion <span class="tip" data-tip="Iterative stream-power erosion — carves river valleys and dendritic drainage networks">?</span> <span class="v" id="vHEr">0.50</span></label>
<input type="range" id="sHEr" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Deep</span></div>
</div>
<div class="cg">
<label>Thermal Erosion <span class="tip" data-tip="Slope-driven material transport — softens ridges and creates natural talus slopes">?</span> <span class="v" id="vTEr">0.10</span></label>
<input type="range" id="sTEr" min="0" max="1" value="0.10" step="0.05">
<div class="slider-hint"><span>None</span><span>Heavy</span></div>
</div>
<div class="cg">
<label>Ridge Sharpening <span class="tip" data-tip="Accentuates mountain ridgelines — pushes peaks further above their surroundings for more dramatic terrain">?</span> <span class="v" id="vRs">0.50</span></label>
<input type="range" id="sRs" min="0" max="1" value="0.50" step="0.05">
<div class="slider-hint"><span>None</span><span>Jagged</span></div>
</div>
<button id="reapplyBtn" title="Reapply terrain sculpting" disabled><span class="reapply-icon">&#x21bb;</span> Reapply</button>
</div>
</details>
<details class="section">
<summary>Climate <span class="tip" data-tip="Global temperature and precipitation offsets — changes apply on slider release, recomputing only precipitation, temperature, and climate zones">?</span></summary>
<div class="section-body">
<div class="climate-hint">Changes apply on release — only climate zones are recomputed.</div>
<div class="cg">
<label>Temperature <span class="tip" data-tip="Shift global temperature — positive makes the planet warmer, negative makes it colder. Climate zones shift accordingly.">?</span> <span class="v" id="vTmp">±0°C</span></label>
<input type="range" id="sTmp" min="-15" max="15" value="0" step="1">
<div class="slider-hint"><span>Colder</span><span>Warmer</span></div>
</div>
<div class="cg">
<label>Precipitation <span class="tip" data-tip="Scale global precipitation — positive makes the planet wetter, negative drier. Affects desert and rainforest distribution.">?</span> <span class="v" id="vPrc">±0%</span></label>
<input type="range" id="sPrc" min="-1" max="1" value="0" step="0.1">
<div class="slider-hint"><span>Drier</span><span>Wetter</span></div>
</div>
</div>
</details>
<details class="section" open>
<summary>Visual Options</summary>
<div class="section-body">
<div class="cg">
<label>View</label>
<select id="viewMode">
<option value="globe">Globe</option>
<option value="map">Map</option>
</select>
</div>
<div class="cg" id="mapCenterLonGroup" style="display:none">
<label>Center Longitude <span class="v" id="vMapCenterLon">0°</span></label>
<input type="range" id="sMapCenterLon" min="-180" max="180" value="0" step="5">
</div>
<div class="map-tabs" id="mapTabs">
<button class="map-tab active" data-layer="">Terrain</button>
<button class="map-tab" data-layer="biome">Satellite</button>
<button class="map-tab" data-layer="koppen">Climate</button>
<button class="map-tab" data-layer="landheightmap">Heightmap</button>
</div>
<div class="cg">
<label>Inspect</label>
<select id="debugLayer">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">K&ouml;ppen Climate</option>
<option value="landheightmap">Land Heightmap</option>
<optgroup label="Geology">
<option value="base">Base</option>
<option value="tectonic">Tectonic</option>
<option value="noise">Noise</option>
<option value="interior">Interior</option>
<option value="coastal">Coastal</option>
<option value="ocean">Ocean Floor</option>
<option value="hotspot">Hotspot</option>
<option value="tecActivity">Tectonic Activity</option>
<option value="margins">Margins</option>
<option value="backArc">Back-Arc</option>
<option value="foldRidge">Fold Ridge</option>
<option value="orogenicPower">Orogenic Power</option>
<option value="erosionDelta">Erosion Delta</option>
</optgroup>
<optgroup label="Atmosphere">
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
</optgroup>
<optgroup label="Ocean">
<option value="oceanCurrentSummer">Currents (Summer)</option>
<option value="oceanCurrentWinter">Currents (Winter)</option>
</optgroup>
<optgroup label="Climate">
<option value="precipSummer">Precipitation (Summer)</option>
<option value="precipWinter">Precipitation (Winter)</option>
<option value="rainShadowSummer">Rain Shadow (Summer)</option>
<option value="rainShadowWinter">Rain Shadow (Winter)</option>
<option value="tempSummer">Temperature (Summer)</option>
<option value="tempWinter">Temperature (Winter)</option>
<option value="continentality">Continentality</option>
</optgroup>
<optgroup label="Elevation">
<option value="heightmap">Full Heightmap</option>
</optgroup>
</select>
</div>
<div id="vizLegend" class="viz-legend"></div>
<div class="tg">
<label class="toggle-label"><input type="checkbox" id="chkWire"><span class="toggle-track"><span class="toggle-thumb"></span></span>Wireframe</label>
<label class="toggle-label"><input type="checkbox" id="chkPlates"><span class="toggle-track"><span class="toggle-thumb"></span></span>Show Plates</label>
<label class="toggle-label"><input type="checkbox" id="chkRotate"><span class="toggle-track"><span class="toggle-thumb"></span></span>Auto-Rotate</label>
<label class="toggle-label"><input type="checkbox" id="chkGrid" checked><span class="toggle-track"><span class="toggle-thumb"></span></span>Grid Lines</label>
</div>
<div class="cg" id="gridSpacingGroup">
<label>Grid Spacing</label>
<select id="gridSpacing">
<option value="30">30°</option>
<option value="15" selected>15°</option>
<option value="10">10°</option>
<option value="5">5°</option>
<option value="2.5">2.5°</option>
</select>
</div>
<details id="statsDetails" class="stats-toggle">
<summary>Stats</summary>
<div id="stats"></div>
</details>
</div>
</details>
<button id="exportBtn" class="export-btn">Export Map</button>
<a id="repoLink" href="https://github.com/raguilar011095/planet_heightmap_generation" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.64 7.64 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</div>
</div>
<select id="mobileViewSwitch" class="mobile-view-switch">
<option value="">Terrain</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate</option>
<option value="landheightmap">Heightmap</option>
</select>
<button id="helpBtn" title="Tutorial">?</button>
<div id="tutorialOverlay" class="hidden">
<div id="tutorialCard">
<button id="tutorialClose">&times;</button>
<div class="tutorial-step" data-step="0">
<h3>Welcome to World Orogen</h3>
<p>Procedural planets inspired by real tectonics, erosion, and climate.</p>
<p>Every world is one of a kind — with believable continents, mountains, ocean trenches, and volcanic islands. Build one in seconds, tweak it until it's yours.</p>
</div>
<div class="tutorial-step" data-step="1">
<h3>Shape Your World</h3>
<p>Use the <strong>Shape Your World</strong> sliders to control detail, plates, continents, and roughness, then hit <strong>Build New World</strong>. The <strong>Detail</strong> slider only changes rendering resolution &mdash; continent shapes stay the same, so you can iterate quickly at low detail and crank it up when the planet looks good. Expand <strong>Terrain Sculpting</strong> to fine-tune erosion and smoothing &mdash; these can be reapplied instantly with the <strong>&#x21bb;</strong> button.</p>
</div>
<div class="tutorial-step" data-step="2">
<h3>Explore &amp; Edit</h3>
<p><strong>Drag</strong> to rotate the globe. <strong>Scroll</strong> to zoom in and out. <strong>Ctrl-click</strong> plates to mark them for reshaping &mdash; select multiple, then hit <strong>Rebuild</strong> to apply all at once. Ctrl-click again to undo a pending selection. <strong>Hover</strong> any region to see elevation, coordinates, temperature, precipitation, and climate classification.</p>
</div>
<div class="tutorial-step" data-step="3">
<h3>Visualize Your World</h3>
<p>Switch between <strong>Terrain</strong>, <strong>Satellite</strong>, <strong>Climate</strong>, and <strong>Heightmap</strong> views using the tabs under Visual Options. Use the <strong>Inspect</strong> dropdown for detailed layers like pressure, wind, and precipitation.</p>
<p>At high detail, climate is skipped automatically for faster generation &mdash; it computes on demand when you switch to a climate view.</p>
</div>
<div class="tutorial-step" data-step="4">
<h3>Save &amp; Share</h3>
<p>Every planet gets a unique <strong>planet code</strong> shown below the Build button &mdash; including any plates you've edited. <strong>Copy</strong> it to share with others, or <strong>paste</strong> a code and click <strong>Load</strong> to recreate someone else's world. You can also share the URL directly.</p>
<p>Use <strong>Export Map</strong> to download high-resolution equirectangular images &mdash; terrain, satellite, climate, heightmaps, or land masks. <strong>Export All</strong> downloads satellite, climate, heightmap, and land mask in one click.</p>
</div>
<div class="tutorial-dots">
<span class="dot active" data-dot="0"></span>
<span class="dot" data-dot="1"></span>
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
<span class="dot" data-dot="4"></span>
</div>
<div class="tutorial-nav">
<button id="tutorialBack" class="btn-ghost" disabled>Back</button>
<button id="tutorialNext" class="btn-primary">Next</button>
</div>
</div>
</div>
<div id="exportOverlay" class="hidden">
<div id="exportCard">
<button id="exportClose">&times;</button>
<h3>Export Map</h3>
<div class="cg">
<label>Type</label>
<select id="exportType">
<option value="color">Color Map</option>
<option value="biome">Satellite</option>
<option value="koppen">Climate (K&ouml;ppen)</option>
<option value="heightmap">Heightmap (B&amp;W)</option>
<option value="landheightmap">Land Heightmap (B&amp;W)</option>
<option value="landmask">Land Mask (B&amp;W)</option>
</select>
</div>
<div class="cg">
<label>Width <span class="v" id="exportDims">4096 &times; 2048</span></label>
<select id="exportWidth">
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="4096" selected>4096</option>
<option value="8192">8192</option>
<option value="16384">16384</option>
<option value="32768">32768</option>
<option value="65536">65536</option>
</select>
</div>
<div class="export-actions">
<button id="exportCancel" class="btn-ghost">Cancel</button>
<button id="exportGo" class="btn-primary">Export</button>
<button id="exportAllGo" class="btn-primary">Export All</button>
</div>
</div>
</div>
<div id="surveyOverlay" class="hidden">
<div id="surveyCard">
<button id="surveyClose">&times;</button>
<h3>Thanks for exploring!</h3>
<p>You've spent some real time with World Orogen &mdash; that means a lot. If you have a minute, I'd love to hear what you think.</p>
<div class="survey-actions">
<button id="surveyDismiss" class="btn-ghost">Maybe later</button>
<a id="surveyLink" href="https://docs.google.com/forms/d/e/1FAIpQLScFSryT8Uom4jMkpb-YQnyjHMSWqZmDDT3bSOSabHovsjKL7A/viewform?usp=dialog" target="_blank" rel="noopener" class="btn-primary">Take the survey</a>
</div>
</div>
</div>
<div id="whatsNewOverlay" class="hidden">
<div id="whatsNewCard">
<button id="whatsNewClose">&times;</button>
<div class="whatsnew-step" data-step="0">
<h3>What's New in World Orogen</h3>
<p>A lot has changed since your last visit. This update brings features many of you asked for &mdash; plus major improvements under the hood.</p>
<p class="whatsnew-warn"><strong>Heads up:</strong> Saved planet codes will still load, but your worlds may look different. The terrain, erosion, and climate systems have all been reworked, so elevations, coastlines, and biome placement will shift.</p>
</div>
<div class="whatsnew-step" data-step="1">
<h3>New Controls</h3>
<ul class="whatsnew-list">
<li><strong>Land Coverage</strong> &mdash; Control how much of your planet is land vs. ocean. Want a water world with scattered islands? A Pangaea with inland seas? Now you can dial it in.</li>
<li><strong>Continent Size Variety</strong> &mdash; Go from uniform landmasses to a mix of sprawling continents and smaller islands.</li>
<li><strong>Temperature &amp; Precipitation</strong> &mdash; Shift global climate warmer or colder, wetter or drier. No more getting stuck with a generic climate.</li>
</ul>
</div>
<div class="whatsnew-step" data-step="2">
<h3>Heightmap Import</h3>
<p>You can now <strong>bring your own heightmap</strong> &mdash; upload an equirectangular image and Orogen runs the full climate simulation on it. Wind, currents, precipitation, K&ouml;ppen classification, all of it.</p>
<p>Use it to see how your hand-drawn world's climate would actually play out, or import Earth and Mars for reference. Or paint a map where each colour is an uplift rate and let stream-power erosion carve the terrain and its rivers. Find both under the <strong>Import</strong> tab.</p>
</div>
<div class="whatsnew-step" data-step="3">
<h3>Plate Editing &amp; Climate Fixes</h3>
<ul class="whatsnew-list">
<li><strong>Multi-select plates</strong> &mdash; Mark several plates at once, then reshape them all in a single rebuild. No more click-wait-repeat.</li>
<li><strong>Southern hemisphere climates fixed</strong> &mdash; Mediterranean and continental climates now appear properly in both hemispheres.</li>
<li><strong>Better terrain at every detail level</strong> &mdash; Mountains, erosion, and coastlines now scale consistently whether you're at 5K or 2.5M regions.</li>
</ul>
</div>
<div class="tutorial-dots">
<span class="dot active" data-dot="0"></span>
<span class="dot" data-dot="1"></span>
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
</div>
<div class="tutorial-nav">
<button id="whatsNewBack" class="btn-ghost" disabled>Back</button>
<button id="whatsNewNext" class="btn-primary">Next</button>
</div>
</div>
</div>
<div id="topInfo">Drag to rotate &middot; Scroll to zoom &middot; Ctrl-click to reshape continents</div>
<button id="editToggle" class="edit-toggle" title="Toggle plate edit mode">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 2.5l3 3L6 17H3v-3L14.5 2.5z"/>
</svg>
</button>
<button id="refreshFab" class="refresh-fab" title="Generate new planet">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 10a7 7 0 1 1-2-5"/>
<polyline points="15 2 17 5 14 6"/>
</svg>
</button>
<div id="hoverInfo"></div>
<button id="rebuildFab" class="rebuild-fab" style="display:none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="2 8 6 12 14 4"/>
</svg>
<span>Rebuild (0)</span>
</button>
<div id="info">Drag to rotate &middot; Scroll to zoom &middot; Ctrl-click to reshape continents</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"delaunator": "https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm"
}
}
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
// Shared climate utilities: smoothing, ITCZ lookup, and percentile selection.
// ── Laplacian smoothing ──────────────────────────────────────────────────────
export function smoothField(mesh, field, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const tmp = new Float32Array(numRegions);
let src = field, dst = tmp;
for (let pass = 0; pass < passes; pass++) {
for (let r = 0; r < numRegions; r++) {
let sum = src[r];
let count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
sum += src[adjList[ni]];
count++;
}
dst[r] = sum / count;
}
const swap = src; src = dst; dst = swap;
}
// If result ended up in tmp, copy back to field
if (src !== field) field.set(src);
}
// ── ITCZ latitude lookup (linear interpolation with wrapping) ────────────────
export function makeItczLookup(itczLons, itczLats) {
const n = itczLons.length;
const step = (2 * Math.PI) / n;
const lonStart = -Math.PI + step * 0.5;
return function (lon) {
let fi = (lon - lonStart) / step;
fi = ((fi % n) + n) % n;
const i0 = Math.floor(fi);
const i1 = (i0 + 1) % n;
const frac = fi - i0;
return itczLats[i0] * (1 - frac) + itczLats[i1] * frac;
};
}
// ── Floyd-Rivest selection (O(N) expected percentile) ────────────────────────
function floydRivest(arr, left, right, k) {
while (right > left) {
if (right - left > 600) {
const n = right - left + 1;
const i = k - left + 1;
const z = Math.log(n);
const s = 0.5 * Math.exp(2 * z / 3);
const sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (i - n / 2 < 0 ? -1 : 1);
const newLeft = Math.max(left, Math.floor(k - i * s / n + sd));
const newRight = Math.min(right, Math.floor(k + (n - i) * s / n + sd));
floydRivest(arr, newLeft, newRight, k);
}
const t = arr[k];
if (t !== t) return; // NaN pivot — cannot partition, bail out
let i = left;
let j = right;
arr[k] = arr[left];
arr[left] = t;
if (arr[right] > t) {
arr[left] = arr[right];
arr[right] = t;
}
while (i < j) {
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
i++;
j--;
while (arr[i] < t) i++;
while (arr[j] > t) j--;
}
if (arr[left] === t) {
const tmp = arr[left];
arr[left] = arr[j];
arr[j] = tmp;
} else {
j++;
const tmp = arr[j];
arr[j] = arr[right];
arr[right] = tmp;
}
if (j <= k) left = j + 1;
if (k <= j) right = j - 1;
}
}
/**
* Compute the p-th percentile of a numeric array in O(N) expected time.
* Returns the value at index floor(n * p) of the sorted order.
* Makes a copy so the input is not mutated. Returns 1 if the result is 0.
*/
export function percentile(arr, p) {
const n = arr.length;
if (n === 0) return 1;
const work = new Float32Array(arr);
const k = Math.floor(n * p);
floydRivest(work, 0, n - 1, k);
return work[k] || 1;
}
+120
View File
@@ -0,0 +1,120 @@
// Coarse reference grid for resolution-independent plate boundaries.
// Generates plates on a fixed ~20K-region mesh, then projects onto any
// high-res mesh with FBM noise perturbation for fractal boundaries.
import { makeRng } from './rng.js';
import { buildSphere } from './sphere-mesh.js';
import { SimplexNoise } from './simplex-noise.js';
import { generatePlates } from './plates.js';
import { assignOceanLand } from './ocean-land.js';
import {
N_COARSE, COARSE_JITTER, COARSE_PERTURB_BASE, COARSE_PERTURB_LOW_T,
COARSE_FBM_BASE_FREQ, COARSE_FBM_OCTAVES, COARSE_FBM_DECAY, COARSE_FBM_FREQ_MULT,
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
} from './terrain-config.js';
/**
* Generate plates and ocean/land on a fixed coarse reference mesh.
* Uses isolated RNG so it doesn't affect the main mesh's random stream.
* Jitter is fixed so plate shapes don't change when the user adjusts irregularity.
*/
export function generateCoarsePlates(seed, numPlates, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
const coarseRng = makeRng(seed + 137);
const { mesh: coarseMesh, r_xyz: coarse_xyz } = buildSphere(N_COARSE, COARSE_JITTER, coarseRng);
const { r_plate: coarse_r_plate, plateSeeds: coarsePlateSeeds, plateVec: coarsePlateVec } =
generatePlates(coarseMesh, coarse_xyz, numPlates, seed);
const coarsePlateIsOcean = assignOceanLand(
coarseMesh, coarse_r_plate, coarsePlateSeeds, coarse_xyz, seed, numContinents, continentSizeVariety, landCoverage
);
return {
coarseMesh,
coarse_xyz,
coarse_r_plate,
coarsePlateSeeds,
coarsePlateVec,
coarsePlateIsOcean,
};
}
/**
* Project coarse plate assignments onto a high-res mesh via nearest-neighbor
* with FBM noise perturbation for fractal plate boundaries.
*
* Each hi-res point is shifted by multi-octave simplex noise before the
* nearest-neighbor lookup, which wobbles the plate boundary by ~2 coarse
* cell widths with fractal detail at multiple scales.
*
* Uses adjacency-walk on the coarse mesh with warm-starting for O(1)
* amortized cost per region.
*/
export function projectCoarsePlates(mesh, r_xyz, coarseMesh, coarse_xyz, coarse_r_plate, seed, numPlates) {
const N = mesh.numRegions;
const r_plate = new Int32Array(N);
const { adjOffset: cOff, adjList: cAdj } = coarseMesh;
// FBM noise for fractal boundary perturbation
const noise = new SimplexNoise(seed + 999);
const coarseEdgeRad = Math.PI / Math.sqrt(coarseMesh.numRegions);
const lowPlateT = numPlates != null ? Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE)) : 0;
const perturbAmp = coarseEdgeRad * (COARSE_PERTURB_BASE + COARSE_PERTURB_LOW_T * lowPlateT); // 1.5 → 2.5 coarse cells
const BASE_FREQ = COARSE_FBM_BASE_FREQ; // ~8 features per sphere diameter → ~16 around equator
const NC = coarseMesh.numRegions;
const MAX_WALK = Math.ceil(Math.sqrt(NC)); // safety cap for greedy walk
let cur = 0; // current best coarse region — warm-started across iterations
for (let r = 0; r < N; r++) {
const ox = r_xyz[3 * r], oy = r_xyz[3 * r + 1], oz = r_xyz[3 * r + 2];
// FBM perturbation: shift lookup point for fractal boundaries
let dx = 0, dy = 0, dz = 0;
let amp = perturbAmp, freq = BASE_FREQ;
for (let oct = 0; oct < COARSE_FBM_OCTAVES; oct++) {
dx += noise.noise3D(ox * freq, oy * freq, oz * freq) * amp;
dy += noise.noise3D(ox * freq + 100, oy * freq + 100, oz * freq + 100) * amp;
dz += noise.noise3D(ox * freq + 200, oy * freq + 200, oz * freq + 200) * amp;
amp *= COARSE_FBM_DECAY;
freq *= COARSE_FBM_FREQ_MULT;
}
// Project perturbed point back onto unit sphere
let px = ox + dx, py = oy + dy, pz = oz + dz;
const len = Math.sqrt(px * px + py * py + pz * pz) || 1;
px /= len; py /= len; pz /= len;
// Greedy walk: find nearest coarse region to the perturbed point
let bestDot = px * coarse_xyz[3 * cur] + py * coarse_xyz[3 * cur + 1] + pz * coarse_xyz[3 * cur + 2];
let improved = true;
let steps = 0;
while (improved && steps < MAX_WALK) {
improved = false;
steps++;
for (let i = cOff[cur], iEnd = cOff[cur + 1]; i < iEnd; i++) {
const nb = cAdj[i];
const d = px * coarse_xyz[3 * nb] + py * coarse_xyz[3 * nb + 1] + pz * coarse_xyz[3 * nb + 2];
if (d > bestDot) {
bestDot = d;
cur = nb;
improved = true;
}
}
}
// Fallback: if greedy walk hit the step limit, brute-force search
if (steps >= MAX_WALK) {
for (let c = 0; c < NC; c++) {
const d = px * coarse_xyz[3 * c] + py * coarse_xyz[3 * c + 1] + pz * coarse_xyz[3 * c + 2];
if (d > bestDot) { bestDot = d; cur = c; }
}
}
r_plate[r] = coarse_r_plate[cur];
}
return r_plate;
}
+125
View File
@@ -0,0 +1,125 @@
// Elevation → RGB colour mapping.
// Convert raw mesh elevation (nonlinear, 0-~1 for land) to physical height
// in kilometres. Hybrid S-curve: quartic start gives extensive flatlands,
// steepest rise around t≈0.75, derivative→0 at top so peaks compress.
// Ocean (elev < 0) is mapped with a linear scale (~5 km at -0.5).
export function elevToHeightKm(elev) {
if (elev <= 0) return elev * 10; // ocean: -0.5 → -5 km
const t = Math.min(elev, 1);
const t2 = t * t;
return 6 * t2 * t2 * (5 - 4 * t); // 0→0, 0.25→0.09, 0.5→1.13, 0.75→3.80, 1.0→6
}
// Biome base colors indexed by Köppen class ID (satellite-view palette).
// 0=Ocean delegated, 1-30 = land biomes.
const BIOME_COLORS = [
null, // 0 Ocean — handled separately
[0.05, 0.30, 0.05], // 1 Af Tropical rainforest — deep emerald
[0.08, 0.33, 0.07], // 2 Am Tropical monsoon — dense green
[0.42, 0.50, 0.18], // 3 Aw Tropical savanna — yellow-green
[0.82, 0.72, 0.50], // 4 BWh Hot desert — sandy tan
[0.60, 0.55, 0.48], // 5 BWk Cold desert — gray-brown
[0.72, 0.62, 0.30], // 6 BSh Hot steppe — dry gold
[0.55, 0.52, 0.32], // 7 BSk Cold steppe — muted olive-tan
[0.18, 0.42, 0.12], // 8 Cfa Humid subtropical — mid green
[0.12, 0.38, 0.10], // 9 Cfb Oceanic — rich green
[0.10, 0.28, 0.10], // 10 Cfc Subpolar oceanic — dark muted green
[0.45, 0.48, 0.22], // 11 Csa Hot-summer Mediterranean — khaki-green
[0.40, 0.45, 0.20], // 12 Csb Warm-summer Mediterranean — chaparral
[0.35, 0.40, 0.20], // 13 Csc Cold-summer Mediterranean — darker khaki
[0.20, 0.44, 0.14], // 14 Cwa Humid subtropical monsoon — mid green
[0.15, 0.40, 0.12], // 15 Cwb Subtropical highland — green
[0.12, 0.32, 0.10], // 16 Cwc Cold subtropical highland — dark green
[0.12, 0.36, 0.08], // 17 Dfa Hot-summer continental — forest green
[0.10, 0.32, 0.08], // 18 Dfb Warm-summer continental — forest green
[0.06, 0.22, 0.08], // 19 Dfc Subarctic — dark spruce green
[0.05, 0.18, 0.07], // 20 Dfd Extremely cold subarctic — very dark
[0.38, 0.38, 0.18], // 21 Dsa Hot-summer continental dry — olive-brown
[0.35, 0.35, 0.17], // 22 Dsb Warm-summer continental dry — olive-brown
[0.08, 0.22, 0.08], // 23 Dsc Subarctic dry summer — dark green
[0.06, 0.18, 0.07], // 24 Dsd Extremely cold subarctic dry — very dark
[0.14, 0.36, 0.10], // 25 Dwa Hot-summer continental monsoon — forest green
[0.12, 0.32, 0.09], // 26 Dwb Warm-summer continental monsoon
[0.07, 0.22, 0.08], // 27 Dwc Subarctic monsoon — dark spruce
[0.05, 0.18, 0.07], // 28 Dwd Extremely cold subarctic monsoon
[0.35, 0.32, 0.22], // 29 ET Tundra — earthy brown (sparse moss/lichen on rock)
[0.78, 0.80, 0.84], // 30 EF Ice cap — blue-tinted white
];
// Rocky/alpine mountain color for high-elevation blending.
const ROCK_COLOR = [0.42, 0.38, 0.32];
// Altitude thresholds (km) by Köppen group:
// [alpine line, snow line]
// Alpine line: vegetation gives way to rocky alpine terrain.
// Snow line: permanent snow begins.
function altitudeThresholds(classId) {
if (classId <= 0) return [0, 0]; // Ocean
if (classId <= 3) return [3.5, 5.5]; // Tropical (A)
if (classId <= 7) return [3.0, 5.0]; // Arid (B)
if (classId <= 16) return [2.0, 3.5]; // Temperate (C)
if (classId <= 18 || classId === 21 || classId === 22 ||
classId === 25 || classId === 26) return [1.5, 3.0]; // Continental humid (D*a, D*b)
if (classId <= 28) return [0.8, 2.0]; // Subarctic (D*c, D*d)
if (classId === 29) return [0.4, 1.5]; // Tundra (ET) — rocky higher up, snow only at peaks
return [0, 0.5]; // Ice cap (EF)
}
// Satellite-view biome color: realistic land colors based on Köppen class
// and elevation, with ocean delegated to the standard ocean palette.
export function biomeColor(koppenId, elevation) {
// Ocean
if (koppenId === 0 || elevation <= 0) return elevationToColor(elevation);
const base = BIOME_COLORS[koppenId] || [0.30, 0.50, 0.20];
const hKm = elevToHeightKm(elevation);
const [alpineLine, snowLine] = altitudeThresholds(koppenId);
let r = base[0], g = base[1], b = base[2];
// Low-elevation subtle darkening for depth (0-200m)
if (hKm < 0.2) {
const dark = 0.93 + 0.07 * (hKm / 0.2);
r *= dark; g *= dark; b *= dark;
}
// Mid-elevation: gentle darkening to show terrain relief (200m to alpine line)
if (alpineLine > 0 && hKm > 0.2 && hKm < alpineLine) {
const t = (hKm - 0.2) / (alpineLine - 0.2);
const darken = 1.0 - t * 0.15; // up to 15% darker at alpine line
r *= darken; g *= darken; b *= darken;
}
// Alpine zone: blend toward rocky brown-gray above the tree/vegetation line
if (alpineLine > 0 && hKm > alpineLine) {
const rockZone = snowLine > alpineLine ? snowLine - alpineLine : 2.0;
const rockT = Math.min(1, (hKm - alpineLine) / rockZone);
const s = rockT * rockT; // ease-in for gradual transition
r = r + (ROCK_COLOR[0] - r) * s;
g = g + (ROCK_COLOR[1] - g) * s;
b = b + (ROCK_COLOR[2] - b) * s;
}
// Snow zone: blend toward white above the snow line
if (snowLine > 0 && hKm > snowLine) {
const snowT = Math.min(1, (hKm - snowLine) / 2.5);
const s = snowT * snowT; // ease-in for gradual snow buildup
r = r + (0.92 - r) * s;
g = g + (0.93 - g) * s;
b = b + (0.96 - b) * s;
}
return [r, g, b];
}
export function elevationToColor(e) {
if (e < -0.50) return [0.04, 0.06, 0.30];
if (e < -0.10) { const t=(e+0.50)/0.40; return [0.04+t*0.07,0.06+t*0.14,0.30+t*0.18]; }
if (e < 0.00) { const t=(e+0.10)/0.10; return [0.11+t*0.19,0.20+t*0.22,0.48+t*0.12]; }
if (e < 0.03) { const t=e/0.03; return [0.72+t*0.08,0.68-t*0.02,0.46-t*0.10]; }
if (e < 0.25) { const t=(e-0.03)/0.22; return [0.20-t*0.06,0.54-t*0.12,0.12+t*0.08]; }
if (e < 0.50) { const t=(e-0.25)/0.25; return [0.14+t*0.30,0.42-t*0.14,0.20-t*0.06]; }
if (e < 0.75) { const t=(e-0.50)/0.25; return [0.44+t*0.16,0.28+t*0.12,0.14+t*0.18]; }
{ const t=Math.min(1,(e-0.75)/0.20); return [0.60+t*0.35,0.40+t*0.50,0.32+t*0.60]; }
}
+14
View File
@@ -0,0 +1,14 @@
// Non-linear detail slider mapping (power curve, p=5).
// Slider position 0–1000 maps to detail 2,000–2,560,000.
// Gives generous control in the normal range; the old max (640K) sits at ~76%.
const MIN = 5000, MAX = 2560000, RANGE = MAX - MIN, STEPS = 1000, P = 5;
export function detailFromSlider(pos) {
const t = pos / STEPS;
return Math.round((MIN + RANGE * Math.pow(t, P)) / 1000) * 1000;
}
export function sliderFromDetail(n) {
return Math.round(STEPS * Math.pow(Math.max(0, n - MIN) / RANGE, 1 / P));
}
+271
View File
@@ -0,0 +1,271 @@
// Plate interaction: hover info + ctrl-click to toggle land/sea.
// Uses analytical ray-sphere intersection instead of Three.js mesh raycasting
// for O(N) dot-product lookups rather than O(N) triangle intersection tests.
import * as THREE from 'three';
import { canvas, camera, mapCamera } from './scene.js';
import { state } from './state.js';
import { updateHoverHighlight, updateMapHoverHighlight, updatePendingHighlight, updateMapPendingHighlight } from './planet-mesh.js';
import { KOPPEN_CLASSES } from './koppen.js';
import { elevToHeightKm } from './color-map.js';
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const _inverseMatrix = new THREE.Matrix4();
const _localRay = new THREE.Ray();
/** Find nearest region to a unit-sphere direction (max dot product). */
function findNearestRegion(nx, ny, nz) {
const { mesh, r_xyz, r_plate } = state.curData;
const N = mesh.numRegions;
let bestDot = -2, bestR = -1;
for (let r = 0; r < N; r++) {
const dot = nx * r_xyz[3 * r] + ny * r_xyz[3 * r + 1] + nz * r_xyz[3 * r + 2];
if (dot > bestDot) { bestDot = dot; bestR = r; }
}
if (bestR < 0) return null;
return { region: bestR, plate: r_plate[bestR] };
}
/** Globe view: analytical ray-sphere intersection → nearest region.
* ~50-100x faster than Three.js mesh raycasting at high detail. */
function getHitInfoGlobe(event) {
if (!state.planetMesh) return null;
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Transform ray into planet's local space (handles auto-rotation)
_inverseMatrix.copy(state.planetMesh.matrixWorld).invert();
_localRay.copy(raycaster.ray).applyMatrix4(_inverseMatrix);
const ox = _localRay.origin.x, oy = _localRay.origin.y, oz = _localRay.origin.z;
const dx = _localRay.direction.x, dy = _localRay.direction.y, dz = _localRay.direction.z;
// Ray-sphere: |O + tD|² = R² (a=1 since direction is normalised)
const R = 1.08; // slightly above max elevation displacement
const b = 2 * (ox * dx + oy * dy + oz * dz);
const c = ox * ox + oy * oy + oz * oz - R * R;
const disc = b * b - 4 * c;
if (disc < 0) return null;
const t = (-b - Math.sqrt(disc)) * 0.5;
if (t < 0) return null;
// Hit point → normalise to unit direction
const hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz;
const len = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1;
return findNearestRegion(hx / len, hy / len, hz / len);
}
/** Map view: unproject mouse → map plane → inverse equirect → nearest region. */
function getHitInfoMap(event) {
if (!state.mapMesh) return null;
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Intersect ray with z=0 plane to get world coords on the map
raycaster.setFromCamera(mouse, mapCamera);
const o = raycaster.ray.origin, d = raycaster.ray.direction;
if (Math.abs(d.z) < 1e-10) return null;
const t = -o.z / d.z;
const wx = o.x + t * d.x;
const wy = o.y + t * d.y;
// Inverse equirectangular: map coords → lon/lat → unit sphere xyz
const PI = Math.PI;
const sx = 2 / PI;
let lon = wx / sx + (state.mapCenterLon || 0);
const lat = wy / sx;
if (lat < -PI / 2 || lat > PI / 2) return null;
// Wrap lon back to [-PI, PI]
if (lon > PI) lon -= 2 * PI;
else if (lon < -PI) lon += 2 * PI;
const cosLat = Math.cos(lat);
return findNearestRegion(
cosLat * Math.sin(lon),
Math.sin(lat),
cosLat * Math.cos(lon)
);
}
function getHitInfo(event) {
if (!state.curData) return null;
return state.mapMode ? getHitInfoMap(event) : getHitInfoGlobe(event);
}
/** Build multi-line hover HTML for a region. */
function buildHoverHTML(region, plate) {
const d = state.curData;
const isOcean = d.plateIsOcean.has(plate);
const isPending = state.pendingToggles.has(plate);
const dot = `<span style="color:${isOcean ? '#4af' : '#6b3'}">●</span>`;
const action = state.isTouchDevice ? 'Tap' : 'Ctrl-click';
const lines = [];
// Line 1: plate type + edit hint
if (isPending) {
const target = isOcean ? 'Land' : 'Ocean';
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'} → ${target}</b> <span style="color:#fa0">(pending)</span> · ${action} to undo`);
} else {
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'}</b> plate · ${action} to ${isOcean ? 'raise land' : 'flood'}`);
}
// Elevation
const elev = d.r_elevation[region];
const elevKm = elevToHeightKm(elev).toFixed(1);
lines.push(`<span class="hi-label">Elev</span> ${elevKm} km`);
// Lat/Lon from r_xyz
const x = d.r_xyz[3 * region];
const y = d.r_xyz[3 * region + 1];
const z = d.r_xyz[3 * region + 2];
const lat = Math.asin(Math.max(-1, Math.min(1, y))) * (180 / Math.PI);
const lon = Math.atan2(x, z) * (180 / Math.PI);
const latStr = Math.abs(lat).toFixed(1) + '°' + (lat >= 0 ? 'N' : 'S');
const lonStr = Math.abs(lon).toFixed(1) + '°' + (lon >= 0 ? 'E' : 'W');
lines.push(`<span class="hi-label">Coord</span> ${latStr}, ${lonStr}`);
// Climate data (only if computed)
if (state.climateComputed && d.r_temperature_summer) {
const tS = -45 + Math.max(0, Math.min(1, d.r_temperature_summer[region])) * 90;
const tW = -45 + Math.max(0, Math.min(1, d.r_temperature_winter[region])) * 90;
if (elev <= 0) {
// Ocean: show as SST
lines.push(`<span class="hi-label">SST</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
} else {
lines.push(`<span class="hi-label">Temp</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
// Precipitation (land only)
if (d.r_precip_summer) {
const pS = (Math.max(0, Math.min(1, d.r_precip_summer[region])) * 1000).toFixed(0);
const pW = (Math.max(0, Math.min(1, d.r_precip_winter[region])) * 1000).toFixed(0);
lines.push(`<span class="hi-label">Precip</span> ${pS} / ${pW} mm`);
}
// Köppen (land only)
if (d.debugLayers && d.debugLayers.koppen) {
const kIdx = d.debugLayers.koppen[region];
const kc = KOPPEN_CLASSES[kIdx];
if (kc && kc.code !== 'Ocean') {
const [r, g, b] = kc.color;
const hex = '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
lines.push(`<span class="hi-label">Clima</span> <span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${hex};vertical-align:middle;margin-right:4px"></span>${kc.code} — ${kc.name}`);
}
}
}
}
return lines.join('<br>');
}
/** Set up hover and ctrl-click event listeners. */
export function setupEditMode() {
let downInfo = null;
let orbiting = false;
let lastHoverTime = 0;
const HOVER_INTERVAL = 50; // ms — cap hover lookups
canvas.addEventListener('pointerdown', (e) => {
if (!state.curData) return;
const isEditTap = (e.button === 0 && e.ctrlKey) ||
(e.button === 0 && state.isTouchDevice && state.editMode);
if (isEditTap) {
// Ctrl-click or mobile edit-mode tap: plate editing
const hit = getHitInfo(e);
if (!hit) return;
downInfo = { x: e.clientX, y: e.clientY, plate: hit.plate };
} else if (e.button === 0 || e.button === 2) {
// Regular click/right-click: orbit or pan — skip hover raycasts
orbiting = true;
}
});
canvas.addEventListener('pointerup', (e) => {
orbiting = false;
if (!downInfo || !state.curData || e.button !== 0) { downInfo = null; return; }
const dx = e.clientX - downInfo.x;
const dy = e.clientY - downInfo.y;
if (dx * dx + dy * dy < 36) {
const pid = downInfo.plate;
// Toggle pending: add if absent, remove if present (undo)
if (state.pendingToggles.has(pid)) {
state.pendingToggles.delete(pid);
} else {
state.pendingToggles.add(pid);
}
// Remove hover highlight first so pending tint applies to base colors.
// Hover saves its backup from pre-pending colors; if we don't strip it,
// the hover restore in updateHoverHighlight wipes out the pending tint.
const savedHover = state.hoveredPlate;
state.hoveredPlate = -1;
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
state.hoveredPlate = savedHover;
// Apply pending tint to the now-clean base colors
updatePendingHighlight();
updateMapPendingHighlight();
// Re-apply hover on top of pending-tinted colors
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
// Update hover text to reflect pending state
const hoverEl = document.getElementById('hoverInfo');
if (state.hoveredRegion >= 0 && state.curData) {
hoverEl.innerHTML = buildHoverHTML(state.hoveredRegion, state.hoveredPlate);
}
// Notify main.js to show/hide rebuild button
document.dispatchEvent(new CustomEvent('pending-edits-changed'));
}
downInfo = null;
});
canvas.addEventListener('pointermove', (e) => {
if (!state.curData) {
if (state.hoveredPlate >= 0 || state.hoveredRegion >= 0) {
state.hoveredPlate = -1;
state.hoveredRegion = -1;
document.getElementById('hoverInfo').style.display = 'none';
}
return;
}
// Skip while orbiting/panning — no hover lookup during drag
if (orbiting) return;
// Throttle hover updates
const now = performance.now();
if (now - lastHoverTime < HOVER_INTERVAL) return;
lastHoverTime = now;
const hit = getHitInfo(e);
const newRegion = hit ? hit.region : -1;
// Only highlight the plate when in edit mode (Ctrl held or mobile edit toggle)
const inEditMode = e.ctrlKey || (state.isTouchDevice && state.editMode);
const newPlate = (hit && inEditMode) ? hit.plate : -1;
// Update plate highlight only when plate changes
if (newPlate !== state.hoveredPlate) {
state.hoveredPlate = newPlate;
if (state.mapMode) updateMapHoverHighlight();
else updateHoverHighlight();
}
// Update info text when region changes
if (newRegion !== state.hoveredRegion) {
state.hoveredRegion = newRegion;
state.hoveredPlate = (hit && inEditMode) ? hit.plate : -1;
const hoverEl = document.getElementById('hoverInfo');
if (newRegion >= 0) {
hoverEl.innerHTML = buildHoverHTML(newRegion, hit.plate);
hoverEl.style.display = 'block';
} else {
hoverEl.style.display = 'none';
}
}
});
}
File diff suppressed because it is too large Load Diff
+991
View File
@@ -0,0 +1,991 @@
// Planet generation — dispatches work to a Web Worker, falls back to
// synchronous main-thread generation if module workers aren't supported.
import Delaunator from 'delaunator';
import { setDelaunator, SphereMesh } from './sphere-mesh.js';
import { computePlateColors, buildMesh } from './planet-mesh.js';
import { state } from './state.js';
import { detailFromSlider } from './detail-scale.js';
import { computeOceanCurrents } from './ocean.js';
import { computePrecipitation } from './precipitation.js';
import { computeTemperature } from './temperature.js';
import { classifyKoppen } from './koppen.js';
// Main thread still needs Delaunator for SphereMesh reconstruction
setDelaunator(Delaunator);
// Read all slider values from the DOM into a params object
function readSliders() {
return {
N: detailFromSlider(+document.getElementById('sN').value),
P: +document.getElementById('sP').value,
jitter: +document.getElementById('sJ').value,
nMag: +document.getElementById('sNs').value,
numContinents: +document.getElementById('sCn').value,
terrainWarp: +document.getElementById('sTw').value,
smoothing: +document.getElementById('sS').value,
hydraulicErosion: +document.getElementById('sHEr').value,
thermalErosion: +document.getElementById('sTEr').value,
ridgeSharpening: +document.getElementById('sRs').value,
glacialErosion: +document.getElementById('sGl').value,
continentSizeVariety: +document.getElementById('sCsv').value,
temperatureOffset: +document.getElementById('sTmp').value,
precipitationOffset: +document.getElementById('sPrc').value,
landCoverage: +document.getElementById('sLc').value,
};
}
// Read sliders with optional chaining (for import page where some sliders may not exist)
function readSlidersOptional() {
return {
N: detailFromSlider(+document.getElementById('sN').value),
jitter: +(document.getElementById('sJ')?.value ?? 0.75),
terrainWarp: +(document.getElementById('sTw')?.value ?? 0),
smoothing: +(document.getElementById('sS')?.value ?? 0),
hydraulicErosion: +(document.getElementById('sHEr')?.value ?? 0),
thermalErosion: +(document.getElementById('sTEr')?.value ?? 0),
ridgeSharpening: +(document.getElementById('sRs')?.value ?? 0),
glacialErosion: +(document.getElementById('sGl')?.value ?? 0),
};
}
// --- Worker setup ---
let worker = null;
let workerSupported = true;
try {
worker = new Worker(new URL('./planet-worker.js', import.meta.url), { type: 'module' });
} catch (e) {
console.warn('[World Orogen] Module workers not supported, falling back to main thread:', e);
workerSupported = false;
}
// Active callback state
let _onProgress = null;
let _onDone = null;
let _t0 = 0;
function resetUI() {
const btn = document.getElementById('generate');
btn.disabled = false;
btn.textContent = 'Build New World';
btn.classList.remove('generating', 'stale');
}
function fail(err) {
console.error('[World Orogen] Generation failed:', err);
resetUI();
if (_onProgress) _onProgress(0, '');
}
// Reconstruct SphereMesh from transferred data
function reconstructMesh(triangles, halfedges, numRegions) {
return new SphereMesh(triangles, halfedges, numRegions);
}
// Build minimal wind-result-like object for computeOceanCurrents fallback.
// Derives geographic data (lat, sinLat, isLand, tangent frames) from r_xyz/r_elevation
// and wraps the wind vectors the worker already sent.
function buildWindResultForOcean(mesh, r_xyz, r_elevation,
r_wind_east_summer, r_wind_north_summer, r_wind_east_winter, r_wind_north_winter,
itczLons, itczLatsSummer, itczLatsWinter) {
const n = mesh.numRegions;
const r_lat = new Float32Array(n);
const r_lon = new Float32Array(n);
const r_sinLat = new Float32Array(n);
const r_isLand = new Uint8Array(n);
const r_eastX = new Float32Array(n), r_eastY = new Float32Array(n), r_eastZ = new Float32Array(n);
const r_northX = new Float32Array(n), r_northY = new Float32Array(n), r_northZ = new Float32Array(n);
for (let r = 0; r < n; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
r_sinLat[r] = y;
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
r_lon[r] = Math.atan2(x, z);
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
// East = cross(up, position) normalized
let ex = z, ey = 0, ez = -x;
const elen = Math.sqrt(ex * ex + ez * ez);
if (elen > 1e-10) { ex /= elen; ez /= elen; }
else { ex = 1; ez = 0; } // poles
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
// North = cross(position, east) normalized
let nx = y * ez - z * ey;
let ny = z * ex - x * ez;
let nz = x * ey - y * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
r_northX[r] = nx / nlen; r_northY[r] = ny / nlen; r_northZ[r] = nz / nlen;
}
// BFS coast distance through land (needed by precipitation fallback)
const { adjOffset, adjList } = mesh;
const r_coastDistLand = new Int32Array(n);
r_coastDistLand.fill(-1);
const bfsQueue = [];
for (let r = 0; r < n; r++) {
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
if (!r_isLand[adjList[ni]]) {
r_coastDistLand[r] = 0;
bfsQueue.push(r);
break;
}
}
}
let bfsHead = 0;
while (bfsHead < bfsQueue.length) {
const r = bfsQueue[bfsHead++];
const d = r_coastDistLand[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb] && r_coastDistLand[nb] === -1) {
r_coastDistLand[nb] = d;
bfsQueue.push(nb);
}
}
}
// Compute wind speed from components (prevents TypeError if accessed)
const r_wind_speed_summer = new Float32Array(n);
const r_wind_speed_winter = new Float32Array(n);
for (let r = 0; r < n; r++) {
const se = r_wind_east_summer[r], sn = r_wind_north_summer[r];
r_wind_speed_summer[r] = Math.sqrt(se * se + sn * sn);
const we = r_wind_east_winter[r], wn = r_wind_north_winter[r];
r_wind_speed_winter[r] = Math.sqrt(we * we + wn * wn);
}
// Zero-filled pressure deviation (neutral: no pressure-driven effects in fallback)
const r_pressure_summer = new Float32Array(n);
const r_pressure_winter = new Float32Array(n);
return {
r_lat, r_lon, r_sinLat, r_isLand,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ,
r_coastDistLand,
r_wind_east_summer, r_wind_north_summer,
r_wind_east_winter, r_wind_north_winter,
r_wind_speed_summer, r_wind_speed_winter,
r_pressure_summer, r_pressure_winter,
itczLons, itczLatsSummer, itczLatsWinter
};
}
if (worker) {
worker.onmessage = (e) => {
const msg = e.data;
switch (msg.type) {
case 'progress':
if (_onProgress) _onProgress(msg.pct, msg.label);
break;
case 'done': {
const tMainStart = performance.now();
const tReconStart = performance.now();
const mesh = reconstructMesh(msg.triangles, msg.halfedges, msg.numRegions);
const tRecon = performance.now() - tReconStart;
const tColorsStart = performance.now();
computePlateColors(new Set(msg.plateSeeds), new Set(msg.plateIsOcean));
const tColors = performance.now() - tColorsStart;
state.climateComputed = !msg.skipClimate;
const tStateStart = performance.now();
state.curData = {
mesh,
r_xyz: msg.r_xyz,
t_xyz: msg.t_xyz,
r_plate: msg.r_plate,
plateSeeds: new Set(msg.plateSeeds),
plateVec: msg.plateVec,
plateIsOcean: new Set(msg.plateIsOcean),
originalPlateIsOcean: new Set(msg.originalPlateIsOcean),
plateDensity: msg.plateDensity,
plateDensityLand: msg.plateDensityLand,
plateDensityOcean: msg.plateDensityOcean,
prePostElev: msg.prePostElev,
r_elevation: msg.r_elevation,
t_elevation: msg.t_elevation,
mountain_r: new Set(msg.mountain_r),
coastline_r: new Set(msg.coastline_r),
ocean_r: new Set(msg.ocean_r),
r_stress: msg.r_stress,
r_wind_east_summer: msg.r_wind_east_summer,
r_wind_north_summer: msg.r_wind_north_summer,
r_wind_east_winter: msg.r_wind_east_winter,
r_wind_north_winter: msg.r_wind_north_winter,
itczLons: msg.itczLons,
itczLatsSummer: msg.itczLatsSummer,
itczLatsWinter: msg.itczLatsWinter,
r_ocean_current_east_summer: msg.r_ocean_current_east_summer,
r_ocean_current_north_summer: msg.r_ocean_current_north_summer,
r_ocean_current_east_winter: msg.r_ocean_current_east_winter,
r_ocean_current_north_winter: msg.r_ocean_current_north_winter,
r_ocean_speed_summer: msg.r_ocean_speed_summer,
r_ocean_speed_winter: msg.r_ocean_speed_winter,
r_ocean_warmth_summer: msg.r_ocean_warmth_summer,
r_ocean_warmth_winter: msg.r_ocean_warmth_winter,
r_precip_summer: msg.r_precip_summer,
r_precip_winter: msg.r_precip_winter,
r_temperature_summer: msg.r_temperature_summer,
r_temperature_winter: msg.r_temperature_winter,
seed: msg.seed,
nMag: msg.nMag,
debugLayers: msg.debugLayers,
terrainMetrics: msg.terrainMetrics || null,
painted: msg.painted || null
};
if (msg.terrainMetrics) window.__terrainMetrics = msg.terrainMetrics;
const tState = performance.now() - tStateStart;
// Main-thread fallbacks — only run when climate was requested but partially missing
// (e.g. older cached worker). Skip entirely when skipClimate was set.
if (!msg.skipClimate) {
let tOceanFallback = 0;
const d = state.curData;
let windResult = null;
if (msg.r_wind_east_summer && (!d.r_ocean_speed_summer || !d.r_precip_summer || !d.r_temperature_summer)) {
windResult = buildWindResultForOcean(mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_ocean_speed_summer && windResult) {
console.log('[generate.js] Ocean data missing from worker — computing on main thread');
const t0Ocean = performance.now();
const oceanResult = computeOceanCurrents(mesh, d.r_xyz, d.r_elevation, windResult);
d.r_ocean_current_east_summer = oceanResult.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = oceanResult.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = oceanResult.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = oceanResult.r_ocean_current_north_winter;
d.r_ocean_speed_summer = oceanResult.r_ocean_speed_summer;
d.r_ocean_speed_winter = oceanResult.r_ocean_speed_winter;
d.r_ocean_warmth_summer = oceanResult.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = oceanResult.r_ocean_warmth_winter;
tOceanFallback = performance.now() - t0Ocean;
console.log(`[generate.js] Ocean currents computed on main thread in ${tOceanFallback.toFixed(0)} ms`);
}
if (!d.r_precip_summer && windResult) {
console.log('[generate.js] Precipitation data missing from worker — computing on main thread');
const t0Precip = performance.now();
const precipResult = computePrecipitation(mesh, d.r_xyz, d.r_elevation, windResult, d);
d.r_precip_summer = precipResult.r_precip_summer;
d.r_precip_winter = precipResult.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = precipResult.r_precip_summer;
d.debugLayers.precipWinter = precipResult.r_precip_winter;
d.debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
}
console.log(`[generate.js] Precipitation computed on main thread in ${(performance.now() - t0Precip).toFixed(0)} ms`);
}
if (!d.r_temperature_summer && windResult) {
console.log('[generate.js] Temperature data missing from worker — computing on main thread');
const t0Temp = performance.now();
const tempResult = computeTemperature(mesh, d.r_xyz, d.r_elevation, windResult, d, d);
d.r_temperature_summer = tempResult.r_temperature_summer;
d.r_temperature_winter = tempResult.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tempResult.r_temperature_summer;
d.debugLayers.tempWinter = tempResult.r_temperature_winter;
}
console.log(`[generate.js] Temperature computed on main thread in ${(performance.now() - t0Temp).toFixed(0)} ms`);
}
if (state.curData.debugLayers && !state.curData.debugLayers.koppen &&
state.curData.r_temperature_summer && state.curData.r_precip_summer) {
const d = state.curData;
d.debugLayers.koppen = classifyKoppen(mesh, d.r_elevation,
{ r_temperature_summer: d.r_temperature_summer, r_temperature_winter: d.r_temperature_winter },
{ r_precip_summer: d.r_precip_summer, r_precip_winter: d.r_precip_winter });
}
}
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const tTotal = performance.now() - _t0;
// Diagnostics
{
let landCount = 0, nanCount = 0;
const plateIsOcean = state.curData.plateIsOcean;
const r_plate = state.curData.r_plate;
const r_elevation = state.curData.r_elevation;
for (let r = 0; r < mesh.numRegions; r++) {
if (!plateIsOcean.has(r_plate[r])) landCount++;
if (isNaN(r_elevation[r])) nanCount++;
}
const landPct = (100 * landCount / mesh.numRegions).toFixed(1);
if (nanCount > 0) console.error(`[World Orogen] WARNING: ${nanCount} NaN elevation values detected!`);
if (landCount / mesh.numRegions < 0.10) console.warn(`[World Orogen] WARNING: Only ${landPct}% land (${landCount} regions). Ocean/land growth may have stalled.`);
}
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
console.log(`%c[World Orogen] Generation complete`, 'color:#6cf;font-weight:bold');
if (msg._params) {
console.log(` Params: N=${msg._params.N.toLocaleString()} P=${msg._params.P} jitter=${msg._params.jitter} noise=${msg._params.nMag} continents=${msg._params.numContinents} seed=${msg._params.seed}`);
console.log(` Sculpting: warp=${msg._params.terrainWarp} smooth=${msg._params.smoothing} glacial=${msg._params.glacialErosion} hydraulic=${msg._params.hydraulicErosion} thermal=${msg._params.thermalErosion} ridge=${msg._params.ridgeSharpening}`);
}
console.log(` Regions: ${mesh.numRegions.toLocaleString()} Triangles: ${mesh.numTriangles.toLocaleString()} Sides: ${mesh.numSides.toLocaleString()}`);
// Worker pipeline stages
if (msg._pipelineTiming) {
console.groupCollapsed(' %cWorker pipeline stages', 'color:#8cf');
console.table(msg._pipelineTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Elevation sub-stages
if (msg._timing) {
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Post-processing sub-stages
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
// Summary
const tWorker = msg._workerTotal || 0;
const tTransfer = tTotal - tWorker - tMainTotal;
console.log(
` %cSummary:%c Worker: ${f(tWorker)} ms | Transfer: ${f(tTransfer)} ms | Main thread: ${f(tMainTotal)} ms (reconstruct=${f(tRecon)}, colors=${f(tColors)}, state=${f(tState)}, buildMesh=${f(tBuild)}) | TOTAL: ${f(tTotal)} ms`,
'color:#ff6;font-weight:bold', ''
);
const ms = tTotal.toFixed(0);
document.getElementById('stats').innerHTML =
`Regions: ${mesh.numRegions.toLocaleString()}<br>` +
`Triangles: ${mesh.numTriangles.toLocaleString()}<br>` +
`Generated in ${ms} ms<br>` +
`<span style="color:#445;font-size:10px">worker ${tWorker.toFixed(0)} · render ${tBuild.toFixed(0)}</span>`;
if (_onProgress) _onProgress(100, 'Done');
resetUI();
document.getElementById('generate').dispatchEvent(new CustomEvent('generate-done'));
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'reapplyDone': {
const tMainStart = performance.now();
state.climateComputed = !msg.skipClimate;
const d = state.curData;
d.r_elevation = msg.r_elevation;
d.t_elevation = msg.t_elevation;
d.debugLayers.erosionDelta = msg.erosionDelta;
if (msg.r_wind_east_summer) {
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
}
if (msg.itczLons) {
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
}
if (msg.r_ocean_current_east_summer) {
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
}
// Fallback: compute ocean currents on main thread if worker didn't
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
}
if (msg.r_precip_summer) {
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
}
if (msg.r_temperature_summer) {
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
}
if (msg.windDebugLayers) {
Object.assign(d.debugLayers, msg.windDebugLayers);
}
// Fallback: compute precip/temp on main thread if climate was
// requested but data is missing (e.g. partial worker result)
if (!msg.skipClimate && d.r_wind_east_summer) {
let wr = null;
if (!d.r_precip_summer || !d.r_temperature_summer) {
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_precip_summer && wr) {
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
d.r_precip_summer = pr.r_precip_summer;
d.r_precip_winter = pr.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = pr.r_precip_summer;
d.debugLayers.precipWinter = pr.r_precip_winter;
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
}
}
if (!d.r_temperature_summer && wr) {
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
d.r_temperature_summer = tr.r_temperature_summer;
d.r_temperature_winter = tr.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tr.r_temperature_summer;
d.debugLayers.tempWinter = tr.r_temperature_winter;
}
}
}
// Clear stale climate data when climate was skipped so rendering
// doesn't show mismatched terrain/climate from a previous run
if (msg.skipClimate) {
d.r_precip_summer = null;
d.r_precip_winter = null;
d.r_temperature_summer = null;
d.r_temperature_winter = null;
if (d.debugLayers) {
d.debugLayers.koppen = null;
d.debugLayers.tempSummer = null;
d.debugLayers.tempWinter = null;
d.debugLayers.precipSummer = null;
d.debugLayers.precipWinter = null;
}
}
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const rt = msg._reapplyTiming || {};
console.log(`%c[World Orogen] Reapply complete`, 'color:#8f8;font-weight:bold');
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
console.log(
` %cSummary:%c Worker: ${f(rt.workerTotal || 0)} ms (clone=${f(rt.clone || 0)}, postProcess=${f(rt.postProcessing || 0)}, triElev=${f(rt.triangleElevations || 0)}) | Main: ${f(tMainTotal)} ms (buildMesh=${f(tBuild)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'editDone': {
const tMainStart = performance.now();
state.climateComputed = !msg.skipClimate;
const d = state.curData;
d.prePostElev = msg.prePostElev;
d.r_elevation = msg.r_elevation;
d.t_elevation = msg.t_elevation;
d.mountain_r = new Set(msg.mountain_r);
d.coastline_r = new Set(msg.coastline_r);
d.ocean_r = new Set(msg.ocean_r);
d.r_stress = msg.r_stress;
if (msg.r_wind_east_summer) {
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
}
if (msg.itczLons) {
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
}
if (msg.r_ocean_current_east_summer) {
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
}
// Fallback: compute ocean currents on main thread if worker didn't
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
}
if (msg.r_precip_summer) {
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
}
if (msg.r_temperature_summer) {
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
}
d.debugLayers = msg.debugLayers;
// Fallback: compute precip/temp on main thread if climate was
// requested but data is missing (e.g. partial worker result)
if (!msg.skipClimate && d.r_wind_east_summer) {
let wr = null;
if (!d.r_precip_summer || !d.r_temperature_summer) {
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
d.r_wind_east_summer, d.r_wind_north_summer,
d.r_wind_east_winter, d.r_wind_north_winter,
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
}
if (!d.r_precip_summer && wr) {
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
d.r_precip_summer = pr.r_precip_summer;
d.r_precip_winter = pr.r_precip_winter;
if (d.debugLayers) {
d.debugLayers.precipSummer = pr.r_precip_summer;
d.debugLayers.precipWinter = pr.r_precip_winter;
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
}
}
if (!d.r_temperature_summer && wr) {
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
d.r_temperature_summer = tr.r_temperature_summer;
d.r_temperature_winter = tr.r_temperature_winter;
if (d.debugLayers) {
d.debugLayers.tempSummer = tr.r_temperature_summer;
d.debugLayers.tempWinter = tr.r_temperature_winter;
}
}
}
// Clear stale climate data when climate was skipped
if (msg.skipClimate) {
d.r_precip_summer = null;
d.r_precip_winter = null;
d.r_temperature_summer = null;
d.r_temperature_winter = null;
if (d.debugLayers) {
d.debugLayers.koppen = null;
d.debugLayers.tempSummer = null;
d.debugLayers.tempWinter = null;
d.debugLayers.precipSummer = null;
d.debugLayers.precipWinter = null;
}
}
const tColorsStart = performance.now();
computePlateColors(d.plateSeeds, d.plateIsOcean);
const tColors = performance.now() - tColorsStart;
const tBuildStart = performance.now();
buildMesh();
const tBuild = performance.now() - tBuildStart;
const tMainTotal = performance.now() - tMainStart;
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const et = msg._editTiming || {};
console.log(`%c[World Orogen] Edit recompute complete`, 'color:#fc8;font-weight:bold');
if (msg._timing) {
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
if (msg._postTiming && msg._postTiming.length > 0) {
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
console.groupEnd();
}
console.log(
` %cSummary:%c Worker: ${f(et.workerTotal || 0)} ms (elevation=${f(et.elevation || 0)}, postProcess=${f(et.postProcessing || 0)}, triElev=${f(et.triangleElevations || 0)}, retain=${f(et.retainState || 0)}) | Main: ${f(tMainTotal)} ms (colors=${f(tColors)}, buildMesh=${f(tBuild)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'climateDone': {
const d = state.curData;
if (d) {
// Copy all climate arrays
d.r_wind_east_summer = msg.r_wind_east_summer;
d.r_wind_north_summer = msg.r_wind_north_summer;
d.r_wind_east_winter = msg.r_wind_east_winter;
d.r_wind_north_winter = msg.r_wind_north_winter;
d.itczLons = msg.itczLons;
d.itczLatsSummer = msg.itczLatsSummer;
d.itczLatsWinter = msg.itczLatsWinter;
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
d.r_precip_summer = msg.r_precip_summer;
d.r_precip_winter = msg.r_precip_winter;
d.r_temperature_summer = msg.r_temperature_summer;
d.r_temperature_winter = msg.r_temperature_winter;
// Merge climate debug layers
if (msg.climateDebugLayers && d.debugLayers) {
Object.assign(d.debugLayers, msg.climateDebugLayers);
}
}
state.climateComputed = true;
buildMesh();
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
const ct = msg._climateTiming || {};
console.log(`%c[World Orogen] Climate computed on demand`, 'color:#f8a;font-weight:bold');
console.log(
` %cSummary:%c Worker: ${f(ct.workerTotal || 0)} ms (wind=${f(ct.wind || 0)}, ocean=${f(ct.ocean || 0)}, precip=${f(ct.precipitation || 0)}, temp=${f(ct.temperature || 0)}, koppen=${f(ct.koppen || 0)})`,
'color:#ff6;font-weight:bold', ''
);
if (_onProgress) _onProgress(100, 'Done');
if (_onDone) { _onDone(); _onDone = null; }
break;
}
case 'error':
fail(msg.message);
if (_onDone) { _onDone(); _onDone = null; }
break;
}
};
worker.onerror = (e) => {
fail(e.message || 'Worker crashed');
if (_onDone) { _onDone(); _onDone = null; }
};
}
// --- Synchronous fallback (imported lazily to avoid loading when worker works) ---
let _fallbackModules = null;
async function loadFallback() {
if (_fallbackModules) return _fallbackModules;
const [rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates] = await Promise.all([
import('./rng.js'),
import('./simplex-noise.js'),
import('./sphere-mesh.js'),
import('./plates.js'),
import('./ocean-land.js'),
import('./elevation.js'),
import('./terrain-post.js'),
import('./wind.js'),
import('./ocean.js'),
import('./precipitation.js'),
import('./temperature.js'),
import('./coarse-plates.js')
]);
_fallbackModules = { rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates };
return _fallbackModules;
}
function generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate) {
// Dynamic import already resolved — run synchronously via rAF stages
const m = _fallbackModules;
const btn = document.getElementById('generate');
const { N, P, jitter, nMag, numContinents, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
const progress = onProgress || (() => {});
const ctx = {};
const stages = [
{ pct: 0, label: 'Shaping the world\u2026', work() {
ctx.seed = overrideSeed ?? Math.floor(Math.random() * 16777216);
ctx.rng = m.rng.makeRng(ctx.seed);
const { mesh, r_xyz } = m.sphere.buildSphere(N, jitter, ctx.rng);
ctx.mesh = mesh; ctx.r_xyz = r_xyz;
ctx.t_xyz = m.sphere.generateTriangleCenters(mesh, r_xyz);
}},
{ pct: 10, label: 'Generating coarse plates\u2026', work() {
const { coarseMesh, coarse_xyz, coarse_r_plate, coarsePlateSeeds, coarsePlateVec, coarsePlateIsOcean } =
m.coarsePlates.generateCoarsePlates(ctx.seed, P, numContinents, continentSizeVariety, landCoverage);
ctx.coarseMesh = coarseMesh; ctx.coarse_xyz = coarse_xyz;
ctx.coarse_r_plate = coarse_r_plate;
ctx.plateSeeds = coarsePlateSeeds; ctx.plateVec = coarsePlateVec;
ctx.coarsePlateIsOcean = coarsePlateIsOcean;
}},
{ pct: 18, label: 'Projecting plates\u2026', work() {
ctx.r_plate = m.coarsePlates.projectCoarsePlates(ctx.mesh, ctx.r_xyz, ctx.coarseMesh, ctx.coarse_xyz, ctx.coarse_r_plate, ctx.seed, P);
m.plates.smoothAndReconnectPlates(ctx.mesh, ctx.r_plate, ctx.plateSeeds, 3);
}},
{ pct: 25, label: 'Carving oceans\u2026', work() {
const plateIsOcean = ctx.coarsePlateIsOcean;
ctx.originalPlateIsOcean = new Set(plateIsOcean);
if (toggledIndices.length > 0) {
const seedArr = Array.from(ctx.plateSeeds);
for (const i of toggledIndices) {
if (i < seedArr.length) {
const r = seedArr[i];
if (plateIsOcean.has(r)) plateIsOcean.delete(r);
else plateIsOcean.add(r);
}
}
}
computePlateColors(ctx.plateSeeds, plateIsOcean);
const plateDensity = {}, plateDensityLand = {}, plateDensityOcean = {};
for (const r of ctx.plateSeeds) {
const drng = m.rng.makeRng(r + 777);
plateDensityOcean[r] = 3.0 + drng() * 0.5;
plateDensityLand[r] = 2.4 + drng() * 0.5;
plateDensity[r] = plateIsOcean.has(r) ? plateDensityOcean[r] : plateDensityLand[r];
}
ctx.plateIsOcean = plateIsOcean; ctx.plateDensity = plateDensity;
ctx.plateDensityLand = plateDensityLand; ctx.plateDensityOcean = plateDensityOcean;
ctx.noise = new m.simplex.SimplexNoise(ctx.seed);
}},
{ pct: 35, label: 'Raising mountains\u2026', work() {
const { r_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers, _timing } =
m.elev.assignElevation(ctx.mesh, ctx.r_xyz, ctx.plateIsOcean, ctx.r_plate, ctx.plateVec, ctx.plateSeeds, ctx.noise, nMag, ctx.seed, 5, ctx.plateDensity);
ctx.r_elevation = r_elevation; ctx.mountain_r = mountain_r; ctx.coastline_r = coastline_r;
ctx.ocean_r = ocean_r; ctx.r_stress = r_stress; ctx.debugLayers = debugLayers;
ctx.prePostElev = new Float32Array(r_elevation);
if (terrainWarp > 0) m.post.warpTerrain(ctx.mesh, r_elevation, ctx.r_xyz, ctx.seed, terrainWarp, debugLayers.hotspot);
const r_isOcean = new Uint8Array(ctx.mesh.numRegions);
for (let r = 0; r < ctx.mesh.numRegions; r++) { if (r_elevation[r] <= 0) r_isOcean[r] = 1; }
const preErosion = new Float32Array(r_elevation);
if (smoothing > 0) m.post.smoothElevation(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + smoothing * 4), 0.2 + smoothing * 0.5);
if (glacialErosion > 0 || hydraulicErosion > 0 || thermalErosion > 0)
m.post.erodeComposite(ctx.mesh, r_elevation, ctx.r_xyz, r_isOcean, Math.round(hydraulicErosion * 20), hydraulicErosion * 0.0006, 0.5, 1.0, Math.round(thermalErosion * 10), 1.2 - thermalErosion * 0.4, thermalErosion * 0.15, Math.round(glacialErosion * 10), glacialErosion);
if (ridgeSharpening > 0) m.post.sharpenRidges(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + ridgeSharpening * 3), ridgeSharpening * 0.08);
m.post.applySoilCreep(ctx.mesh, r_elevation, r_isOcean, 3, 0.1125);
const dl_erosionDelta = new Float32Array(ctx.mesh.numRegions);
for (let r = 0; r < ctx.mesh.numRegions; r++) dl_erosionDelta[r] = r_elevation[r] - preErosion[r];
debugLayers.erosionDelta = dl_erosionDelta;
if (!skipClimate) {
const windResult = m.wind.computeWind(ctx.mesh, ctx.r_xyz, r_elevation, ctx.plateIsOcean, ctx.r_plate, ctx.noise);
debugLayers.pressureSummer = windResult.r_pressure_summer;
debugLayers.pressureWinter = windResult.r_pressure_winter;
debugLayers.windSpeedSummer = windResult.r_wind_speed_summer;
debugLayers.windSpeedWinter = windResult.r_wind_speed_winter;
ctx.windResult = windResult;
const oceanResult = m.oceanCurrents.computeOceanCurrents(ctx.mesh, ctx.r_xyz, r_elevation, windResult);
ctx.oceanResult = oceanResult;
const precipResult = m.precip.computePrecipitation(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipitationOffset, landCoverage);
ctx.precipResult = precipResult;
debugLayers.precipSummer = precipResult.r_precip_summer;
debugLayers.precipWinter = precipResult.r_precip_winter;
debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
const tempResult = m.temp.computeTemperature(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset);
ctx.tempResult = tempResult;
debugLayers.tempSummer = tempResult.r_temperature_summer;
debugLayers.tempWinter = tempResult.r_temperature_winter;
debugLayers.koppen = classifyKoppen(ctx.mesh, r_elevation, tempResult, precipResult);
}
const t_elevation = new Float32Array(ctx.mesh.numTriangles);
for (let t = 0; t < ctx.mesh.numTriangles; t++) {
const s0 = 3 * t;
const a = ctx.mesh.s_begin_r(s0), b = ctx.mesh.s_begin_r(s0+1), c = ctx.mesh.s_begin_r(s0+2);
t_elevation[t] = (r_elevation[a] + r_elevation[b] + r_elevation[c]) / 3;
}
ctx.t_elevation = t_elevation;
}},
{ pct: 85, label: 'Painting the surface\u2026', work() {
state.curData = {
mesh: ctx.mesh, r_xyz: ctx.r_xyz, t_xyz: ctx.t_xyz,
r_plate: ctx.r_plate, plateSeeds: ctx.plateSeeds, plateVec: ctx.plateVec,
plateIsOcean: ctx.plateIsOcean, originalPlateIsOcean: ctx.originalPlateIsOcean,
plateDensity: ctx.plateDensity, plateDensityLand: ctx.plateDensityLand,
plateDensityOcean: ctx.plateDensityOcean, prePostElev: ctx.prePostElev,
r_elevation: ctx.r_elevation, t_elevation: ctx.t_elevation,
mountain_r: ctx.mountain_r, coastline_r: ctx.coastline_r, ocean_r: ctx.ocean_r,
r_stress: ctx.r_stress, noise: ctx.noise, seed: ctx.seed, debugLayers: ctx.debugLayers,
r_wind_east_summer: ctx.windResult ? ctx.windResult.r_wind_east_summer : null,
r_wind_north_summer: ctx.windResult ? ctx.windResult.r_wind_north_summer : null,
r_wind_east_winter: ctx.windResult ? ctx.windResult.r_wind_east_winter : null,
r_wind_north_winter: ctx.windResult ? ctx.windResult.r_wind_north_winter : null,
itczLons: ctx.windResult ? ctx.windResult.itczLons : null,
itczLatsSummer: ctx.windResult ? ctx.windResult.itczLatsSummer : null,
itczLatsWinter: ctx.windResult ? ctx.windResult.itczLatsWinter : null,
r_ocean_current_east_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_summer : null,
r_ocean_current_north_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_summer : null,
r_ocean_current_east_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_winter : null,
r_ocean_current_north_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_winter : null,
r_ocean_speed_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_summer : null,
r_ocean_speed_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_winter : null,
r_ocean_warmth_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_summer : null,
r_ocean_warmth_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_winter : null,
r_precip_summer: ctx.precipResult ? ctx.precipResult.r_precip_summer : null,
r_precip_winter: ctx.precipResult ? ctx.precipResult.r_precip_winter : null,
r_temperature_summer: ctx.tempResult ? ctx.tempResult.r_temperature_summer : null,
r_temperature_winter: ctx.tempResult ? ctx.tempResult.r_temperature_winter : null
};
state.climateComputed = !skipClimate;
buildMesh();
progress(100, 'Done');
resetUI();
btn.dispatchEvent(new CustomEvent('generate-done'));
}}
];
function runStage(idx) {
if (idx >= stages.length) return;
const s = stages[idx];
try { progress(s.pct, s.label); } catch (e) { fail(e); return; }
requestAnimationFrame(() => setTimeout(() => {
try { s.work(); runStage(idx + 1); } catch (e) { fail(e); }
}, 0));
}
setTimeout(() => runStage(0), 0);
}
// --- Public API ---
export function generate(overrideSeed, toggledIndices = [], onProgress, skipClimate = false) {
const btn = document.getElementById('generate');
btn.disabled = true;
btn.textContent = 'Building\u2026';
btn.classList.add('generating');
_onProgress = onProgress || (() => {});
_t0 = performance.now();
if (!worker) {
// Fallback: load modules then run synchronously
loadFallback().then(() => generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate));
return;
}
const s = readSliders();
worker.postMessage({
cmd: 'generate',
...s,
seed: overrideSeed,
toggledIndices,
skipClimate
});
}
export function reapplyViaWorker(onDone, skipClimate = false) {
if (!worker || !state.curData) return;
_onProgress = (pct, label) => {
// Progress updates during reapply (used by build overlay if shown)
};
_onDone = onDone || null;
_t0 = performance.now();
const s = readSlidersOptional();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
worker.postMessage({
cmd: 'reapply',
...s, temperatureOffset, precipitationOffset, landCoverage,
skipClimate
});
}
export function editRecomputeViaWorker(onDone, skipClimate = false) {
if (!worker || !state.curData) return;
const d = state.curData;
_onProgress = () => {};
_onDone = onDone || null;
_t0 = performance.now();
const { nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
worker.postMessage({
cmd: 'editRecompute',
plateIsOcean: Array.from(d.plateIsOcean),
plateDensity: d.plateDensity,
nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening,
temperatureOffset, precipitationOffset, landCoverage,
skipClimate
});
}
export function computeClimateViaWorker(onProgress, onDone) {
if (!worker || !state.curData) return;
_onProgress = onProgress || (() => {});
_onDone = onDone || null;
_t0 = performance.now();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
worker.postMessage({
cmd: 'computeClimate',
temperatureOffset, precipitationOffset, landCoverage
});
}
/**
* Import a painted class map: `classRaster` is one legend index per pixel (from
* painted.js classifyImage on the main thread), `legend` the raw legend JSON object, and
* `paintedParams` the planet block plus the solve controls (peak height, ocean depth, steps,
* coast detail, uplift variation, seed).
*/
export function importPainted(classRaster, imageWidth, imageHeight, legend, paintedParams, onProgress, skipClimate = false, overlay = null) {
if (!worker) return;
_onProgress = onProgress || (() => {});
_t0 = performance.now();
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
worker.postMessage({
cmd: 'importPainted',
N, jitter,
classRaster, imageWidth, imageHeight,
legend, painted: paintedParams,
overlay,
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
temperatureOffset, precipitationOffset,
skipClimate
}, [classRaster.buffer, ...(overlay && overlay.marks ? [overlay.marks.buffer] : [])]);
}
export function importHeightmap(grayscale, imageWidth, imageHeight, onProgress, skipClimate = false) {
if (!worker) return;
_onProgress = onProgress || (() => {});
_t0 = performance.now();
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
worker.postMessage({
cmd: 'importHeightmap',
N, jitter,
grayscale, imageWidth, imageHeight,
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
skipClimate
}, [grayscale.buffer]);
}
+269
View File
@@ -0,0 +1,269 @@
// Heuristic precipitation model: smooth zonal patterns blended with the
// complex advection model to reduce splotchiness and strengthen deserts.
// Computes precipitation from four multiplicative factors: zonal base curve
// (distance from ITCZ), seasonal modifier, continental dryness, and
// orographic rain shadow.
import { smoothstep } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { smoothField, makeItczLookup } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Zonal base curve ────────────────────────────────────────────────────────
// Returns a value in [0.03, 1.0] based on distance from the ITCZ in degrees.
function zonalBase(distDeg) {
if (distDeg < 5) {
// ITCZ core: 1.0
return 1.0;
} else if (distDeg < 10) {
// Outer ITCZ / trades: 1.0 → 0.35 (faster falloff)
return 1.0 - 0.65 * smoothstep(5, 10, distDeg);
} else if (distDeg < 33) {
// Subtropical highs (desert factory): 0.35 → 0.02
// Very aggressive minimum — core of the desert belt.
return 0.35 - 0.33 * smoothstep(10, 28, distDeg);
} else if (distDeg < 55) {
// Mid-lat westerlies recovery: 0.02 → 0.5
return 0.02 + 0.48 * smoothstep(33, 55, distDeg);
} else if (distDeg < 70) {
// Subpolar: 0.5 → 0.3
return 0.5 - 0.2 * smoothstep(55, 70, distDeg);
} else {
// Polar: 0.3 → 0.1
return 0.3 - 0.2 * smoothstep(70, 90, distDeg);
}
}
// ── Heuristic zonal wind ────────────────────────────────────────────────────
// Idealized wind direction based on latitude relative to the ITCZ.
// Returns local east/north components (positive east = blowing eastward,
// positive north = blowing poleward in NH).
//
// Zonal wind belts (Earth-like):
// ITCZ (0-5°): light/convergent
// Trades (5-30°): strong easterlies, deflected equatorward by Coriolis
// Subtropical (25-35°): weak/variable (transition)
// Westerlies (35-60°): west→east, deflected poleward
// Polar easterlies (60-90°): east→west, deflected equatorward
function heuristicWind(distFromItczDeg, isNorthOfItcz) {
// Sign for hemisphere: +1 if north of ITCZ, -1 if south
const hemiSign = isNorthOfItcz ? 1 : -1;
let we, wn;
if (distFromItczDeg < 5) {
// ITCZ: light convergent winds — slight equatorward component
we = 0;
wn = -hemiSign * 0.1;
} else if (distFromItczDeg < 30) {
// Trade winds: easterlies (blowing westward) with equatorward component
// Strength ramps up from ITCZ edge, peaks ~15-20°, fades toward subtropics
const tradeStrength = smoothstep(5, 15, distFromItczDeg)
* (1 - smoothstep(25, 32, distFromItczDeg));
we = -tradeStrength * 0.8; // strong westward
wn = -hemiSign * tradeStrength * 0.3; // equatorward (toward ITCZ)
} else if (distFromItczDeg < 60) {
// Westerlies: blowing eastward with poleward component
const westStrength = smoothstep(30, 40, distFromItczDeg)
* (1 - smoothstep(55, 65, distFromItczDeg));
we = westStrength * 0.9; // strong eastward
wn = hemiSign * westStrength * 0.25; // poleward
} else {
// Polar easterlies: blowing westward with equatorward component
const polarStrength = smoothstep(60, 70, distFromItczDeg);
we = -polarStrength * 0.4; // moderate westward
wn = -hemiSign * polarStrength * 0.15; // equatorward
}
return { we, wn };
}
// ── Heuristic wind field for a full season ──────────────────────────────────
// Computes idealized zonal wind E/N arrays for all regions.
export function computeHeuristicWindField(numRegions, r_lat, r_lon, itczLookup) {
const hWindE = new Float32Array(numRegions);
const hWindN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const itczLat = itczLookup(r_lon[r]) * 0.3; // dampened ITCZ, same as precip
const signedDist = lat - itczLat;
const distDeg = Math.abs(signedDist) / DEG;
const northOfItcz = signedDist > 0;
const { we, wn } = heuristicWind(distDeg, northOfItcz);
hWindE[r] = we;
hWindN[r] = wn;
}
return { hWindE, hWindN };
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute heuristic precipitation for both seasons.
* Returns raw (un-normalized) Float32Arrays.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz
* @param {Float32Array} r_elevation
* @param {object} windResult - output from computeWind()
* @param {Float32Array} r_elevGradE - pre-computed east elevation gradient
* @param {Float32Array} r_elevGradN - pre-computed north elevation gradient
* @param {Int32Array} r_coastDistLand - BFS hop distance from coast through land
* @returns {{ r_precip_summer, r_precip_winter }}
*/
export function computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand) {
const numRegions = mesh.numRegions;
const { r_lat, r_lon, r_isLand, r_continentality } = windResult;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
// Precompute west-coast proximity: positive = west coast, negative = east coast.
// Coastal land cells check which side ocean is on relative to the local east
// direction, then the signal is smoothed ~300 km inland through land only.
const { r_eastX, r_eastY, r_eastZ } = windResult;
const { adjOffset, adjList } = mesh;
const r_westCoast = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r] || r_coastDistLand[r] !== 0) continue;
let oceanDotEast = 0;
let count = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb]) {
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
oceanDotEast += dx * r_eastX[r] + dy * r_eastY[r] + dz * r_eastZ[r];
count++;
}
}
if (count > 0) {
// Negative dot = ocean is to the west = west coast
r_westCoast[r] = oceanDotEast < 0 ? 1 : -1;
}
}
// Smooth through land only (~300 km) so the signal bleeds inland
const wcPasses = Math.max(2, Math.round(300 / avgEdgeKm));
const wcTmp = new Float32Array(numRegions);
for (let pass = 0; pass < wcPasses; pass++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) { wcTmp[r] = 0; continue; }
let sum = r_westCoast[r], count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb]) { sum += r_westCoast[nb]; count++; }
}
wcTmp[r] = sum / count;
}
r_westCoast.set(wcTmp);
}
const result = {};
const seasons = [
{ name: 'summer', shift: 5 },
{ name: 'winter', shift: -5 }
];
for (const { name } of seasons) {
const isSummer = name === 'summer';
const itczLookup = makeItczLookup(windResult.itczLons,
isSummer ? windResult.itczLatsSummer : windResult.itczLatsWinter);
const precip = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
// ── A. Zonal base curve (distance from ITCZ) ──
// Dampen ITCZ shift: use only 30% of the complex model's ITCZ
// displacement so the zonal bands stay close to the geographic
// equator. The full ITCZ swing (up to 15-20°) would drag the
// subtropical desert belt too far, drying the true equator and
// wetting the mid-latitudes in the shifted season.
const itczLat = itczLookup(lon) * 0.3;
const signedDist = lat - itczLat;
const distFromItczDeg = Math.abs(signedDist) / DEG;
const isNorthOfItcz = signedDist > 0;
const zonal = zonalBase(distFromItczDeg);
// ── B. Seasonal modifier + Mediterranean subtropical suppression ──
const absLatDeg = Math.abs(lat) / DEG;
const inSummerHemi = isSummer ? (lat >= 0) : (lat < 0);
let seasonMod = inSummerHemi ? 1.1 : 0.9;
// Mediterranean suppression: subtropical highs expand poleward in
// local summer, strongly suppressing rainfall at 25-42° latitude.
// In local winter the highs retreat equatorward and westerlies
// bring rain to these latitudes. This seasonal contrast is the
// primary driver of Mediterranean (Cs) climates.
// Stronger on west coasts (subtropical highs sit over eastern ocean
// basins, drying the adjacent western continental margins) and
// weaker on east coasts (onshore tropical moisture counters drying).
if (inSummerHemi && absLatDeg > 22 && absLatDeg < 45) {
const medSuppress = smoothstep(22, 30, absLatDeg)
* (1 - smoothstep(38, 45, absLatDeg));
const wc = r_westCoast[r]; // +1 west coast, -1 east coast, 0 inland
const strength = 0.15 + wc * 0.20; // 0.35 west coast, 0.15 inland, ~0 east coast
seasonMod *= (1 - medSuppress * Math.max(0, strength));
}
// ── C. Continental dryness ──
let contMod = 1.0;
const cont = (r_isLand[r] && r_continentality) ? r_continentality[r] : 0;
if (cont > 0) {
contMod = 1.0 - cont * cont * 0.65;
}
// ── D. Orographic rain shadow (using heuristic zonal wind) ──
let oroMod = 1.0;
if (r_isLand[r] && r_elevation[r] > 0) {
const { we, wn } = heuristicWind(distFromItczDeg, isNorthOfItcz);
// Wind dot elevation gradient: positive = windward, negative = leeward
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
if (windDotGrad > 0) {
// Windward: up to +60% boost
const uplift = Math.min(1, windDotGrad * 15);
oroMod = 1.0 + uplift * 0.6;
} else {
// Leeward: up to -70% suppression, scaled by mountain height
const heightKm = elevToHeightKm(Math.max(0, r_elevation[r]));
const heightScale = Math.min(1, heightKm / 3); // 3km+ = full shadow
const shadow = Math.min(1, -windDotGrad * 18);
oroMod = Math.max(0.3, 1.0 - shadow * 0.7 * heightScale);
}
}
// ── E. Hard distance-from-coast cutoff ──
// Fixed 2000-3000km cutoff regardless of latitude.
let distMod = 1.0;
if (r_isLand[r] && r_coastDistLand[r] > 0) {
const distKm = r_coastDistLand[r] * avgEdgeKm;
if (distKm > 2000) {
distMod = Math.max(0.03, 1 - smoothstep(2000, 3000, distKm));
}
}
// ── Final ──
precip[r] = Math.max(0.05, zonal * seasonMod * contMod * oroMod * distMod);
}
// Light smoothing ~100km
const smoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, precip, smoothPasses);
result[`r_precip_${name}`] = precip;
}
return result;
}
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
// Köppen climate classification using the "worldbuilding pasta" band-based
// methodology. Two-season (summer/winter) data is used as a proxy for
// warmest/coldest month values.
//
// Approach:
// Step 1 – Temperature bands (tropical → temperate → continental → tundra → ice cap)
// Step 2 – Arid zones (B) dry in both seasons → desert core + steppe fringe
// Step 3 – Precipitation subtypes within each band (A / C / D details)
//
// IMPORTANT: The simulation labels "summer" and "winter" are NH-centric
// (NH summer = June-Aug, NH winter = Dec-Feb). For each cell we determine
// the LOCAL warm/cold season from temperature and use that to assign the
// correct precipitation pattern (s/w/f). Without this, Mediterranean (Cs)
// and monsoon (Cw/Dw) climates are hemisphere-flipped.
import { smoothstep } from './wind.js';
/**
* Köppen class definitions: ID → { code, name, color [r,g,b] 0-1 }.
*/
export const KOPPEN_CLASSES = [
{ code: 'Ocean', name: 'Ocean', color: [0.29, 0.44, 0.65] }, // #4a6fa5
{ code: 'Af', name: 'Tropical rainforest', color: [0.00, 0.00, 1.00] }, // #0000FF
{ code: 'Am', name: 'Tropical monsoon', color: [0.00, 0.47, 1.00] }, // #0077FF
{ code: 'Aw', name: 'Tropical savanna', color: [0.27, 0.67, 0.98] }, // #46AAFA
{ code: 'BWh', name: 'Hot desert', color: [1.00, 0.00, 0.00] }, // #FF0000
{ code: 'BWk', name: 'Cold desert', color: [1.00, 0.59, 0.59] }, // #FF9696
{ code: 'BSh', name: 'Hot steppe', color: [0.96, 0.65, 0.00] }, // #F5A500
{ code: 'BSk', name: 'Cold steppe', color: [1.00, 0.86, 0.39] }, // #FFDB63
{ code: 'Cfa', name: 'Humid subtropical', color: [0.78, 1.00, 0.31] }, // #C8FF50
{ code: 'Cfb', name: 'Oceanic', color: [0.39, 1.00, 0.31] }, // #64FF50
{ code: 'Cfc', name: 'Subpolar oceanic', color: [0.20, 0.78, 0.00] }, // #32C800
{ code: 'Csa', name: 'Hot-summer Mediterranean', color: [1.00, 1.00, 0.00] }, // #FFFF00
{ code: 'Csb', name: 'Warm-summer Mediterranean', color: [0.78, 0.78, 0.00] }, // #C8C800
{ code: 'Csc', name: 'Cold-summer Mediterranean', color: [0.59, 0.59, 0.00] }, // #969600
{ code: 'Cwa', name: 'Humid subtropical (monsoon)', color: [0.59, 1.00, 0.59] }, // #96FF96
{ code: 'Cwb', name: 'Subtropical highland', color: [0.39, 0.78, 0.39] }, // #63C764
{ code: 'Cwc', name: 'Cold subtropical highland', color: [0.20, 0.59, 0.20] }, // #329633
{ code: 'Dfa', name: 'Hot-summer continental', color: [0.00, 1.00, 1.00] }, // #00FFFF
{ code: 'Dfb', name: 'Warm-summer continental', color: [0.22, 0.78, 1.00] }, // #37C8FF
{ code: 'Dfc', name: 'Subarctic', color: [0.00, 0.49, 0.49] }, // #007D7D
{ code: 'Dfd', name: 'Extremely cold subarctic', color: [0.00, 0.27, 0.37] }, // #00465F
{ code: 'Dsa', name: 'Hot-summer continental (dry summer)', color: [0.90, 0.50, 1.00] }, // #E680FF
{ code: 'Dsb', name: 'Warm-summer continental (dry summer)', color: [0.70, 0.35, 0.85] }, // #B359D9
{ code: 'Dsc', name: 'Subarctic (dry summer)', color: [0.50, 0.20, 0.65] }, // #8033A6
{ code: 'Dsd', name: 'Extremely cold subarctic (dry summer)', color: [0.35, 0.10, 0.45] }, // #591A73
{ code: 'Dwa', name: 'Hot-summer continental (monsoon)', color: [0.67, 0.69, 1.00] }, // #ABB1FF
{ code: 'Dwb', name: 'Warm-summer continental (monsoon)', color: [0.43, 0.47, 0.78] }, // #6E77C8
{ code: 'Dwc', name: 'Subarctic (monsoon)', color: [0.29, 0.31, 0.78] }, // #4A50C8
{ code: 'Dwd', name: 'Extremely cold subarctic (monsoon)', color: [0.20, 0.00, 0.53] }, // #320087
{ code: 'ET', name: 'Tundra', color: [0.70, 0.70, 0.70] }, // #B2B2B2
{ code: 'EF', name: 'Ice cap', color: [0.41, 0.41, 0.41] }, // #686868
];
// Lookup table: KOPPEN_CLASSES code → ID (built once at import time)
const CODE_TO_ID = {};
KOPPEN_CLASSES.forEach((c, i) => { CODE_TO_ID[c.code] = i; });
/**
* Classify each region into a Köppen climate type using the worldbuilding-
* pasta band-based methodology.
*
* @param {object} mesh - SphereMesh
* @param {Float32Array} r_elevation - per-region elevation (<=0 = ocean)
* @param {object} tempResult - { r_temperature_summer, r_temperature_winter } (0-1 → -45..+45 C)
* @param {object} precipResult - { r_precip_summer, r_precip_winter } (0-1 p95-normalized)
* @returns {Uint8Array} r_koppen - per-region class ID (index into KOPPEN_CLASSES)
*/
export function classifyKoppen(mesh, r_elevation, tempResult, precipResult) {
const n = mesh.numRegions;
const r_koppen = new Uint8Array(n);
const tSummer = tempResult.r_temperature_summer;
const tWinter = tempResult.r_temperature_winter;
const pSummer = precipResult.r_precip_summer;
const pWinter = precipResult.r_precip_winter;
for (let r = 0; r < n; r++) {
// ── Ocean ──
if (r_elevation[r] <= 0) {
r_koppen[r] = 0;
continue;
}
// ── Convert normalised values to physical units ──
// Ts/Tw are NH summer/winter proxies — NOT necessarily local warm/cold
const Ts = -45 + Math.max(0, Math.min(1, tSummer[r])) * 90;
const Tw = -45 + Math.max(0, Math.min(1, tWinter[r])) * 90;
const Thot = Math.max(Ts, Tw); // warmest month proxy (°C)
const Tcold = Math.min(Ts, Tw); // coldest month proxy (°C)
const Tann = (Ts + Tw) / 2;
// "Shoulder-month" temperature: approximate the temp 2 months before
// peak summer. With only 2 seasons we interpolate 2/6 of the way from
// peak toward cold. Used for the humid-continental / subarctic split
// and for the tempLetter 'b' criterion (4+ months >= 10°C).
const Tshoulder = Thot - (Thot - Tcold) * (2 / 6);
// ── Hemisphere-aware local seasons ──
// Determine which simulation season is this cell's LOCAL warm season.
// NH cells: sim summer = local summer. SH cells: sim winter = local summer.
const localSummerIsSim = Ts >= Tw;
// Precipitation: each season value ∈ [0,1] represents ~6 months.
// Scale to approximate mm for that half-year.
const Ps = Math.max(0, pSummer[r]) * 1000; // NH summer half-year mm
const Pw = Math.max(0, pWinter[r]) * 1000; // NH winter half-year mm
const Pann = Ps + Pw; // annual mm
// Local summer/winter precipitation (hemisphere-corrected)
const PsummerLocal = localSummerIsSim ? Ps : Pw;
const PwinterLocal = localSummerIsSim ? Pw : Ps;
const PsMonthLocal = PsummerLocal / 6; // avg monthly precip in local summer
const PwMonthLocal = PwinterLocal / 6; // avg monthly precip in local winter
// Estimate driest individual month from the 6-month average.
// A 6-month dry-season average of 40mm might contain months ranging from
// 10mm to 70mm. The stronger the seasonal contrast (wet vs dry half-year),
// the more peaked the distribution within each half-year, so the driest
// month is further below the half-year average.
// Factor: at equal seasons (ratio=1) → driest ≈ 0.7× average
// at strong monsoon (ratio=5+) → driest ≈ 0.35× average
const seasonRatio = Math.max(PsMonthLocal, PwMonthLocal) / (Math.min(PsMonthLocal, PwMonthLocal) || 1);
const driestFraction = 0.60 - 0.35 * smoothstep(1, 4, seasonRatio);
const Pdry = Math.min(PsMonthLocal, PwMonthLocal) * driestFraction;
// ================================================================
// STEP 1 – TEMPERATURE BANDS
// ================================================================
// Band codes: 'A' tropical, 'C' temperate, 'D' continental,
// 'ET' tundra, 'EF' ice cap
// Sub-bands for temperate: 'hotSummer' (>=22°C) vs 'coolSummer'
// Sub-bands for continental: 'humidCont' (Tshoulder>=10) vs 'subarctic'
let band;
let tempSubBand = ''; // 'hotSummer'|'coolSummer' for C; 'humidCont'|'subarctic' for D
if (Thot < 0) {
// Ice cap: warmest month < 0°C
band = 'EF';
} else if (Thot < 10) {
// Tundra: warmest month 0-10°C
band = 'ET';
} else if (Tcold >= 18) {
// Tropical: coldest month >= 18°C
band = 'A';
} else if (Tcold >= 0) {
// Temperate: coldest month 0-18°C AND warmest >= 10°C
band = 'C';
tempSubBand = Thot >= 22 ? 'hotSummer' : 'coolSummer';
} else {
// Continental: coldest month < 0°C AND warmest >= 10°C
band = 'D';
tempSubBand = Tshoulder >= 10 ? 'humidCont' : 'subarctic';
}
// ── Short-circuit polar types ──
if (band === 'EF') { r_koppen[r] = CODE_TO_ID['EF']; continue; }
if (band === 'ET') { r_koppen[r] = CODE_TO_ID['ET']; continue; }
// ================================================================
// STEP 2 – ARID ZONES (B)
// ================================================================
// The blog approach: areas "dry in both seasons" become desert by
// default, with steppe as a transition on the edges.
//
// We use the standard Köppen aridity threshold (which encodes the
// idea of evapotranspiration exceeding precipitation) to decide B,
// then split desert vs steppe.
//
// h/k is determined by mean annual temperature (standard Köppen):
// Tann >= 18°C → hot (h)
// Tann < 18°C → cold (k)
//
// summerFrac uses LOCAL warm-season precipitation (hemisphere-corrected)
// because the threshold encodes evapotranspiration which peaks in
// the warm season regardless of hemisphere.
let Pthresh;
const summerFrac = Pann > 0 ? PsummerLocal / Pann : 0.5;
if (summerFrac >= 0.7) {
Pthresh = 20 * Tann + 280;
} else if (summerFrac <= 0.3) {
Pthresh = 20 * Tann;
} else {
Pthresh = 20 * Tann + 140;
}
Pthresh = Math.max(0, Pthresh);
if (Pann < Pthresh) {
const isHot = Tann >= 18; // standard Köppen: h if mean annual temp >= 18°C
if (Pann < Pthresh * 0.5) {
// Desert
r_koppen[r] = isHot ? CODE_TO_ID['BWh'] : CODE_TO_ID['BWk'];
} else {
// Steppe (transition fringe)
r_koppen[r] = isHot ? CODE_TO_ID['BSh'] : CODE_TO_ID['BSk'];
}
continue;
}
// ================================================================
// STEP 3 – PRECIPITATION SUBTYPES WITHIN EACH BAND
// ================================================================
// ── Determine s / w / f precipitation pattern ──
// All comparisons use LOCAL summer/winter so the pattern is correct
// in both hemispheres.
// Our "monthly" values are 6-month averages, not individual months —
// this smooths the driest/wettest month contrast, so thresholds are
// relaxed vs. standard Köppen (which uses actual monthly extremes).
// s = dry local summer: summer month < 50mm AND < 1/2 winter month
// w = dry local winter: winter month < 1/4 summer month
// (relaxed from standard 1/10 because 6-month averages compress contrast)
// f = no dry season
let precipPattern;
const localSummerDrier = PsummerLocal < PwinterLocal;
if (localSummerDrier && PsMonthLocal < 50 && PsMonthLocal < PwMonthLocal / 2) {
precipPattern = 's';
} else if (!localSummerDrier && PwMonthLocal < PsMonthLocal / 3) {
precipPattern = 'w';
} else {
precipPattern = 'f';
}
// ── Determine temperature sub-letter (a / b / c / d) ──
// a: warmest month >= 22°C
// b: warmest < 22°C but 4+ months >= 10°C (proxy: Tshoulder >= 10°C)
// c: fewer than 4 months >= 10°C, coldest >= −38°C
// d: coldest < −38°C (extreme continental, only for D)
let tempLetter;
if (Thot >= 22) {
tempLetter = 'a';
} else if (Tshoulder >= 10) {
tempLetter = 'b';
} else if (Tcold >= -38) {
tempLetter = 'c';
} else {
tempLetter = 'd';
}
// ── Band A: Tropical ──
if (band === 'A') {
// Blog approach:
// very wet both seasons → Af (tropical rainforest)
// wet both seasons → Am (tropical monsoon)
// wet one season, dry other → Aw (tropical savanna)
//
// Translated with thresholds:
// Af: driest month >= 60 mm
// Am: Pann >= 25*(100 - Pdry) (i.e. enough total rain to sustain forest
// despite a short dry spell)
// Aw: everything else
if (Pdry >= 60) {
r_koppen[r] = CODE_TO_ID['Af'];
} else if (Pann >= 25 * (100 - Pdry)) {
r_koppen[r] = CODE_TO_ID['Am'];
} else {
r_koppen[r] = CODE_TO_ID['Aw'];
}
continue;
}
// ── Band C: Temperate ──
if (band === 'C') {
// Blog approach:
// dry local summer → Mediterranean (Cs)
// remaining hot-summer → humid subtropical (Cfa / Cwa)
// remaining cool-summer → oceanic (Cfb / Cwb / Cfc / Cwc)
const code = 'C' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
r_koppen[r] = CODE_TO_ID['Cfb'];
}
continue;
}
// ── Band D: Continental ──
if (band === 'D') {
// Blog approach:
// humid continental (Tshoulder >= 10°C) = Dfa/Dfb/Dsa/Dsb/Dwa/Dwb
// subarctic (Tshoulder < 10°C) = Dfc/Dfd/Dsc/Dsd/Dwc/Dwd
//
// Ds zones appear near Mediterranean regions; Dw zones appear
// near regions with strong monsoon effect (far ITCZ excursion).
const code = 'D' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
const fallback = 'Df' + tempLetter;
r_koppen[r] = CODE_TO_ID[fallback] || CODE_TO_ID['Dfc'];
}
continue;
}
}
return r_koppen;
}
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// Ocean / land assignment.
// Targets ~30% land by surface area. numContinents controls how many
// separate landmasses to create. Small trapped interior seas are absorbed.
import { makeRng } from './rng.js';
export function assignOceanLand(mesh, r_plate, plateSeeds, r_xyz, seed, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
const rng = makeRng(seed + 42);
const numRegions = mesh.numRegions;
const plateIds = Array.from(plateSeeds);
const numPlates = plateIds.length;
const { adjOffset, adjList } = mesh;
// 1. Plate areas and centroids
const plateArea = {};
const plateCentroid = {};
for (const pid of plateIds) {
plateArea[pid] = 0;
plateCentroid[pid] = [0, 0, 0];
}
for (let r = 0; r < numRegions; r++) {
const p = r_plate[r];
if (!plateCentroid[p]) { plateArea[p] = 0; plateCentroid[p] = [0, 0, 0]; }
plateArea[p]++;
plateCentroid[p][0] += r_xyz[3*r];
plateCentroid[p][1] += r_xyz[3*r+1];
plateCentroid[p][2] += r_xyz[3*r+2];
}
for (const pid of plateIds) {
const a = plateArea[pid] || 1;
plateCentroid[pid][0] /= a;
plateCentroid[pid][1] /= a;
plateCentroid[pid][2] /= a;
}
// 2. Plate adjacency graph + perimeter
const plateAdj = {};
const platePerim = {};
for (const pid of plateIds) { plateAdj[pid] = new Set(); platePerim[pid] = 0; }
for (let r = 0; r < numRegions; r++) {
const myPlate = r_plate[r];
let isBoundary = false;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nbPlate = r_plate[adjList[ni]];
if (myPlate !== nbPlate) {
plateAdj[myPlate].add(nbPlate);
isBoundary = true;
}
}
if (isBoundary) platePerim[myPlate]++;
}
// Plate compactness
const plateCompact = {};
let maxCompact = 0;
for (const pid of plateIds) {
const c = Math.sqrt(plateArea[pid] || 1) / (platePerim[pid] || 1);
plateCompact[pid] = c;
if (c > maxCompact) maxCompact = c;
}
if (maxCompact > 0) {
for (const pid of plateIds) plateCompact[pid] /= maxCompact;
}
const targetLandArea = landCoverage * numRegions;
// 3. Pick continent seeds via farthest-point sampling
const effectiveNum = Math.min(numContinents, numPlates);
const continentSeeds = [];
const chosen = new Set();
const first = plateIds[Math.floor(rng() * numPlates)];
continentSeeds.push(first);
chosen.add(first);
for (let s = 1; s < effectiveNum; s++) {
const candidates = [];
for (const pid of plateIds) {
if (chosen.has(pid)) continue;
const cx = plateCentroid[pid];
let minDist = Infinity;
for (const existing of continentSeeds) {
const ex = plateCentroid[existing];
const dx = cx[0]-ex[0], dy = cx[1]-ex[1], dz = cx[2]-ex[2];
const d = dx*dx + dy*dy + dz*dz;
if (d < minDist) minDist = d;
}
const rawAreaFactor = Math.sqrt(numRegions / numPlates) / Math.sqrt(plateArea[pid] || 1);
const areaFactor = 1 + (rawAreaFactor - 1) * (1 - continentSizeVariety * 0.5);
const compact = 0.3 + 0.7 * plateCompact[pid];
candidates.push({ pid, score: minDist * areaFactor * compact });
}
if (candidates.length === 0) break;
candidates.sort((a, b) => b.score - a.score);
const topK = Math.min(candidates.length, 3);
const pick = candidates[Math.floor(rng() * topK)];
continentSeeds.push(pick.pid);
chosen.add(pick.pid);
}
// If seeds alone exceed the land budget, trim the largest seeds
let seedArea = 0;
for (const pid of continentSeeds) seedArea += plateArea[pid];
while (continentSeeds.length > 1 && seedArea > targetLandArea) {
let maxIdx = 0;
for (let i = 1; i < continentSeeds.length; i++) {
if (plateArea[continentSeeds[i]] > plateArea[continentSeeds[maxIdx]]) maxIdx = i;
}
seedArea -= plateArea[continentSeeds[maxIdx]];
chosen.delete(continentSeeds[maxIdx]);
continentSeeds.splice(maxIdx, 1);
}
// 4. Initialize continent assignment
const plateContinent = {};
for (let c = 0; c < continentSeeds.length; c++) {
plateContinent[continentSeeds[c]] = c;
}
let landArea = seedArea;
// 5. Round-robin growth with per-continent targets
const growTarget = targetLandArea * 0.9;
const numC = continentSeeds.length;
// Per-continent growth targets: at variety=0 all equal, at variety=1 highly skewed
const continentTarget = new Float64Array(numC);
const continentArea = new Float64Array(numC);
for (let c = 0; c < numC; c++) {
continentArea[c] = plateArea[continentSeeds[c]];
}
if (continentSizeVariety > 0 && numC > 1) {
const weights = [];
for (let c = 0; c < numC; c++) {
// Log-normal-ish: at variety=1, weights span ~0.3x to ~3.5x (12:1 ratio)
const logWeight = (rng() - 0.5) * continentSizeVariety * 2.5;
weights.push(Math.exp(logWeight));
}
const totalWeight = weights.reduce((a, b) => a + b, 0);
for (let c = 0; c < numC; c++) {
continentTarget[c] = growTarget * weights[c] / totalWeight;
}
} else {
const equal = growTarget / Math.max(numC, 1);
for (let c = 0; c < numC; c++) continentTarget[c] = equal;
}
let progress = true;
while (progress && landArea < growTarget) {
progress = false;
for (let c = 0; c < numC && landArea < growTarget; c++) {
// Skip continents that have reached their individual target
if (continentArea[c] >= continentTarget[c]) continue;
const candidates = [];
for (const pid of plateIds) {
if (plateContinent[pid] !== undefined) continue;
let touchesSelf = false, touchesOther = false;
let sameCount = 0;
for (const adj of plateAdj[pid]) {
const ac = plateContinent[adj];
if (ac === c) { touchesSelf = true; sameCount++; }
else if (ac !== undefined) { touchesOther = true; break; }
}
if (touchesSelf && !touchesOther) {
candidates.push({ pid, score: sameCount + plateCompact[pid] * 3 + rng() * 0.5 });
}
}
if (candidates.length === 0) continue;
candidates.sort((a, b) => b.score - a.score);
const topK = Math.min(candidates.length, 3);
const pick = candidates[Math.floor(rng() * topK)];
plateContinent[pick.pid] = c;
continentArea[c] += plateArea[pick.pid];
landArea += plateArea[pick.pid];
progress = true;
}
}
// 6. Absorb trapped interior seas
const oceanComponents = [];
const visited = new Set();
for (const pid of plateIds) {
if (plateContinent[pid] !== undefined || visited.has(pid)) continue;
const component = [pid];
visited.add(pid);
for (let qi = 0; qi < component.length; qi++) {
for (const adj of plateAdj[component[qi]]) {
if (plateContinent[adj] === undefined && !visited.has(adj)) {
visited.add(adj);
component.push(adj);
}
}
}
oceanComponents.push(component);
}
let mainIdx = 0;
for (let i = 1; i < oceanComponents.length; i++) {
let areaI = 0, areaM = 0;
for (const p of oceanComponents[i]) areaI += plateArea[p];
for (const p of oceanComponents[mainIdx]) areaM += plateArea[p];
if (areaI > areaM) mainIdx = i;
}
const absorbCap = targetLandArea * 1.1;
for (let i = 0; i < oceanComponents.length; i++) {
if (i === mainIdx) continue;
const component = oceanComponents[i];
const bordering = new Set();
for (const op of component) {
for (const adj of plateAdj[op]) {
if (plateContinent[adj] !== undefined) bordering.add(plateContinent[adj]);
}
if (bordering.size > 1) break;
}
if (bordering.size === 1) {
let compArea = 0;
for (const op of component) compArea += plateArea[op];
if (landArea + compArea <= absorbCap) {
const c = bordering.values().next().value;
for (const op of component) plateContinent[op] = c;
landArea += compArea;
}
}
}
// 7. Build plateIsOcean set
const plateIsOcean = new Set();
for (const pid of plateIds) {
if (plateContinent[pid] === undefined) plateIsOcean.add(pid);
}
return plateIsOcean;
}
+389
View File
@@ -0,0 +1,389 @@
// Ocean current simulation: rule-based geographic approach with wind-belt-driven gyres.
// Wind belts drive zonal currents; continental shelves deflect them into gyres.
// Warmth is classified geographically: western coasts = warm, eastern coasts = cold.
console.log('[ocean.js] Module loaded');
import { smoothstep } from './wind.js';
import { makeItczLookup, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Coast distance & classification via BFS ─────────────────────────────────
function computeCoastFields(mesh, r_xyz, r_isOcean,
r_eastX, r_eastY, r_eastZ) {
const { adjOffset, adjList, numRegions } = mesh;
const westSeeds = [];
const eastSeeds = [];
const allCoastSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
let landDirX = 0, landDirY = 0, landDirZ = 0;
let hasLandNeighbor = false;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isOcean[nb]) {
hasLandNeighbor = true;
landDirX += r_xyz[3 * nb] - r_xyz[3 * r];
landDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
landDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
}
}
if (!hasLandNeighbor) continue;
allCoastSeeds.push(r);
// Project land direction into tangent frame east component
const normalE = landDirX * r_eastX[r] + landDirY * r_eastY[r] + landDirZ * r_eastZ[r];
// normalE < -0.2 → land is to the west → western coast seed
// normalE > +0.2 → land is to the east → eastern coast seed
if (normalE < -0.2) {
westSeeds.push(r);
} else if (normalE > 0.2) {
eastSeeds.push(r);
} else {
if (normalE <= 0) westSeeds.push(r);
else eastSeeds.push(r);
}
}
// BFS: compute hop distance from seed set through ocean cells.
// Reuses a single queue array (capacity allocated once) across all three passes.
const bfsQueue = new Int32Array(numRegions);
function bfsDistance(seeds) {
const dist = new Int32Array(numRegions);
dist.fill(-1);
let qLen = 0;
for (const s of seeds) {
dist[s] = 0;
bfsQueue[qLen++] = s;
}
let head = 0;
while (head < qLen) {
const r = bfsQueue[head++];
const d = dist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isOcean[nb] && dist[nb] === -1) {
dist[nb] = d;
bfsQueue[qLen++] = nb;
}
}
}
return dist;
}
const r_coastDist = bfsDistance(allCoastSeeds);
const r_westCoastDist = bfsDistance(westSeeds);
const r_eastCoastDist = bfsDistance(eastSeeds);
return { r_coastDist, r_westCoastDist, r_eastCoastDist };
}
// ── Circumpolar channel detection ───────────────────────────────────────────
function hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, targetLat, bandWidth) {
const NUM_BINS = 72;
const binHasOcean = new Uint8Array(NUM_BINS);
const latMin = targetLat - bandWidth;
const latMax = targetLat + bandWidth;
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
const lat = r_lat[r];
if (lat < latMin || lat > latMax) continue;
let bin = Math.floor(((r_lon[r] + Math.PI) / (2 * Math.PI)) * NUM_BINS);
bin = ((bin % NUM_BINS) + NUM_BINS) % NUM_BINS;
binHasOcean[bin] = 1;
}
for (let i = 0; i < NUM_BINS; i++) {
if (!binHasOcean[i]) return false;
}
return true;
}
// ── Geographic heat classification ──────────────────────────────────────────
// Warmth is determined by coast type and wind cell. The prevailing wind
// direction determines which side of a basin accumulates warm water:
// Hadley cell (trades westward): western=warm, eastern=cold
// Ferrel cell (westerlies eastward): western=cold, eastern=warm (flipped)
// Polar cell (easterlies westward): western=warm, eastern=cold (flipped back)
function classifyWarmth(r_isOcean, r_lat, numRegions,
r_westCoastDist, r_eastCoastDist, fadeRange, seasonalShiftDeg) {
const r_warmth = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
// Shifted latitude for cell boundaries (matches wind band shift)
const bandLatDeg = Math.abs(r_lat[r] / DEG - seasonalShiftDeg);
// Wind cell sign: trades/polar push water west (western=warm → +1),
// westerlies push water east (western=cold → -1)
let cellSign;
if (bandLatDeg < 28) {
cellSign = 1;
} else if (bandLatDeg < 35) {
cellSign = 1 - 2 * smoothstep(28, 35, bandLatDeg);
} else if (bandLatDeg < 55) {
cellSign = -1;
} else if (bandLatDeg < 65) {
cellSign = -1 + 2 * smoothstep(55, 65, bandLatDeg);
} else {
cellSign = 1;
}
const wDist = r_westCoastDist[r];
const eDist = r_eastCoastDist[r];
let warm = 0;
if (wDist >= 0 && wDist < fadeRange) {
const t = 1 - wDist / fadeRange;
warm += cellSign * t * t;
}
if (eDist >= 0 && eDist < fadeRange) {
const t = 1 - eDist / fadeRange;
warm -= cellSign * t * t;
}
r_warmth[r] = Math.max(-1, Math.min(1, warm));
}
return r_warmth;
}
// ── Laplacian smoothing (ocean only) ────────────────────────────────────────
function smoothOcean(mesh, field, r_isOcean, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const tmp = new Float32Array(numRegions);
for (let pass = 0; pass < passes; pass++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) { tmp[r] = field[r]; continue; }
let sum = field[r], count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isOcean[nb]) {
sum += field[nb];
count++;
}
}
tmp[r] = sum / count;
}
field.set(tmp);
}
}
// ── Main entry point ────────────────────────────────────────────────────────
/**
* Compute ocean surface currents using rule-based geographic approach.
* Wind belts drive zonal currents, continental shelves deflect them into
* gyres. Warmth is classified geographically by coast type.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind() (includes lat, lon, sinLat, isLand, tangent frames, ITCZ arrays)
* @returns {object} current vectors, warmth, and speed arrays for both seasons
*/
export function computeOceanCurrents(mesh, r_xyz, r_elevation, windResult) {
console.log('[ocean.js] computeOceanCurrents called, numRegions:', mesh.numRegions);
const numRegions = mesh.numRegions;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const timing = [];
const { r_lat, r_sinLat, r_isLand,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ } = windResult;
// Ocean mask
const r_isOcean = new Uint8Array(numRegions);
for (let r = 0; r < numRegions; r++) r_isOcean[r] = r_isLand[r] ? 0 : 1;
// Step 0: Setup — r_lon and ITCZ lookups
let t0 = performance.now();
let r_lon = windResult.r_lon;
if (!r_lon) {
r_lon = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_lon[r] = Math.atan2(r_xyz[3 * r], r_xyz[3 * r + 2]);
}
}
const itczLookupSummer = makeItczLookup(windResult.itczLons, windResult.itczLatsSummer);
const itczLookupWinter = makeItczLookup(windResult.itczLons, windResult.itczLatsWinter);
timing.push({ stage: 'Ocean: setup (ITCZ lookup + lon)', ms: performance.now() - t0 });
// Step 1: Coast distance & classification (shared between seasons)
t0 = performance.now();
const { r_coastDist, r_westCoastDist, r_eastCoastDist } =
computeCoastFields(mesh, r_xyz, r_isOcean,
r_eastX, r_eastY, r_eastZ);
timing.push({ stage: 'Ocean: coast BFS (3 passes)', ms: performance.now() - t0 });
// Step 2: Circumpolar channel detection
t0 = performance.now();
const circumpolarNH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, 60 * DEG, 5 * DEG);
const circumpolarSH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, -60 * DEG, 5 * DEG);
console.log(`[ocean.js] Circumpolar: NH=${circumpolarNH}, SH=${circumpolarSH}`);
timing.push({ stage: 'Ocean: circumpolar detection', ms: performance.now() - t0 });
// Coast influence threshold
const coastThreshold = Math.max(5, Math.round(Math.sqrt(numRegions) * 0.035));
// Warmth fade range — extends beyond coast deflection zone
const warmthRange = coastThreshold * 2;
const result = {};
const seasons = [
{ name: 'summer', itczLookup: itczLookupSummer },
{ name: 'winter', itczLookup: itczLookupWinter }
];
for (const { name, itczLookup } of seasons) {
// Seasonal shift: wind cells migrate ~5° toward summer hemisphere
const seasonalShiftDeg = name === 'summer' ? 5 : -5;
// Steps 3–4: Wind band classification + current vectors
t0 = performance.now();
const currentE = new Float32Array(numRegions);
const currentN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) continue;
const lat = r_lat[r];
const absLatDeg = Math.abs(lat) / DEG;
const lon = r_lon[r];
const hemisphereSign = lat >= 0 ? 1 : -1;
// Shifted latitude for wind band boundaries (cells migrate with season)
const bandLatDeg = Math.abs(lat / DEG - seasonalShiftDeg);
// ITCZ latitude at this longitude
const itczLat = itczLookup(lon);
const distFromItcz = Math.abs(lat - itczLat) / DEG;
// Step 3: Base zonal flow from wind band (using shifted boundaries)
let baseE;
if (distFromItcz < 3) {
// ITCZ zone: eastward countercurrent at center, blends to westward at edges
baseE = 1 - 2 * smoothstep(0, 3, distFromItcz);
} else if (bandLatDeg < 30) {
// Trade winds: westward
baseE = -1;
} else if (bandLatDeg < 35) {
// Subtropical transition: blend trades → westerlies
baseE = -1 + 2 * smoothstep(30, 35, bandLatDeg);
} else if (bandLatDeg < 58) {
// Ferrel cell / westerlies: eastward
baseE = 1;
} else if (bandLatDeg < 65) {
// Subpolar transition: blend westerlies → polar easterlies
baseE = 1 - 1.5 * smoothstep(58, 65, bandLatDeg);
} else {
// Polar easterlies: weak westward
baseE = -0.5;
}
currentE[r] = baseE;
currentN[r] = 0;
// Step 4: Coast deflection
const wDist = r_westCoastDist[r];
const eDist = r_eastCoastDist[r];
// Near western coast: strong poleward deflection (warm current)
if (wDist >= 0 && wDist < coastThreshold) {
const t = 1 - wDist / coastThreshold;
const strength = t * t * 2.0; // western intensification ×2
currentN[r] += hemisphereSign * strength; // poleward
currentE[r] *= (1 - t * t * 0.7);
}
// Near eastern coast: moderate equatorward deflection (cold current)
if (eDist >= 0 && eDist < coastThreshold) {
const t = 1 - eDist / coastThreshold;
const strength = t * t * 0.8; // eastern weaker ×0.8
currentN[r] -= hemisphereSign * strength; // equatorward
currentE[r] *= (1 - t * t * 0.5);
}
// Circumpolar override (55–75° with open channel)
const isCircumpolar = (lat > 0 && circumpolarNH) || (lat < 0 && circumpolarSH);
if (isCircumpolar && absLatDeg >= 55 && absLatDeg <= 75) {
const cStrength = 1 - Math.abs(absLatDeg - 65) / 10;
currentE[r] = currentE[r] * (1 - cStrength) + 1.5 * cStrength;
currentN[r] *= (1 - cStrength * 0.8);
}
}
timing.push({ stage: `Ocean: wind bands + vectors (${name})`, ms: performance.now() - t0 });
// Step 5: Smooth ~125 km (scale-invariant)
t0 = performance.now();
const oceanSmoothPasses = Math.max(2, Math.round(125 / avgEdgeKm));
smoothOcean(mesh, currentE, r_isOcean, oceanSmoothPasses);
smoothOcean(mesh, currentN, r_isOcean, oceanSmoothPasses);
// Zero out land
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) { currentE[r] = 0; currentN[r] = 0; }
}
timing.push({ stage: `Ocean: smoothing (${name})`, ms: performance.now() - t0 });
// Step 6: Geographic warmth classification (coast type, not flow direction)
// Smoothed heavily to blend out jagged coastline noise and dilute
// small island contributions (few coast cells → weak signal after smoothing).
t0 = performance.now();
const r_warmth = classifyWarmth(r_isOcean, r_lat, numRegions,
r_westCoastDist, r_eastCoastDist, warmthRange, seasonalShiftDeg);
const warmthSmoothPasses = Math.max(3, Math.round(900 / avgEdgeKm));
smoothOcean(mesh, r_warmth, r_isOcean, warmthSmoothPasses);
// Step 7: Normalize speed (95th percentile)
// Use speed-squared to avoid sqrt in the hot loop; sqrt is monotonic
// so percentile on squared values gives the same ranking.
const r_speed = new Float32Array(numRegions);
const oceanSpeedsSq = new Float32Array(numRegions);
let oceanCount = 0;
for (let r = 0; r < numRegions; r++) {
const spdSq = currentE[r] * currentE[r] + currentN[r] * currentN[r];
r_speed[r] = spdSq;
if (r_isOcean[r] && spdSq > 0) oceanSpeedsSq[oceanCount++] = spdSq;
}
const p95Sq = percentile(oceanSpeedsSq.subarray(0, oceanCount), 0.95);
// Now convert to linear 0-1: speed/p95 = sqrt(spdSq)/sqrt(p95Sq) = sqrt(spdSq/p95Sq)
const invP95Sq = 1 / p95Sq;
for (let r = 0; r < numRegions; r++) {
r_speed[r] = Math.min(1, Math.sqrt(r_speed[r] * invP95Sq));
}
console.log(`[Ocean ${name}] coastThreshold=${coastThreshold}, warmthRange=${warmthRange}, p95Sq=${p95Sq.toExponential(3)}, oceanCells=${oceanCount}`);
timing.push({ stage: `Ocean: warmth + normalize (${name})`, ms: performance.now() - t0 });
result[`r_ocean_current_east_${name}`] = currentE;
result[`r_ocean_current_north_${name}`] = currentN;
result[`r_ocean_speed_${name}`] = r_speed;
result[`r_ocean_warmth_${name}`] = r_warmth;
}
result._oceanTiming = timing;
return result;
}
+176
View File
@@ -0,0 +1,176 @@
// Colours and legends for the painted-map layers: the class map, the uplift rate, the
// erodibility, the drainage (log area), the slope and the drainage basins.
//
// Shared by planet-mesh.js (globe, map and export colouring) and import-main.js (the sidebar
// legend), so the picture on the globe and the one in the exported PNG are the same picture.
export const PAINTED_LAYERS = new Set(['paintClass', 'paintUplift', 'paintK', 'flow', 'slope', 'basins', 'paintOverlay']);
// Export type → debug layer it draws.
export const PAINTED_EXPORT_TYPES = {
paintclass: 'paintClass',
uplift: 'paintUplift',
erodibility: 'paintK',
flow: 'flow',
slope: 'slope',
basins: 'basins',
overlay: 'paintOverlay',
};
export const PAINTED_EXPORT_LABELS = {
paintclass: 'Class Map',
uplift: 'Uplift Rate',
erodibility: 'Erodibility',
flow: 'Drainage',
slope: 'Slope',
basins: 'Basins',
overlay: 'Overlay',
};
const SEA_DARK = [0.05, 0.07, 0.12];
const SEA_GREY = [0.16, 0.18, 0.24];
function lerp3(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function ramp(stops, t) {
if (t <= 0) return stops[0];
if (t >= 1) return stops[stops.length - 1];
const x = t * (stops.length - 1);
const i = Math.floor(x);
return lerp3(stops[i], stops[Math.min(stops.length - 1, i + 1)], x - i);
}
// Uplift: dark violet → wine → orange → pale yellow. Sea is dark.
const UPLIFT_STOPS = [[0.09, 0.04, 0.20], [0.42, 0.08, 0.36], [0.78, 0.25, 0.22], [0.98, 0.62, 0.12], [1.00, 0.95, 0.65]];
// Erodibility: hard rock blue → neutral grey → soft rock orange.
const K_STOPS = [[0.20, 0.35, 0.80], [0.55, 0.57, 0.62], [0.95, 0.55, 0.15]];
// Drainage: dry ground olive → light blue → white at the trunk rivers.
const FLOW_STOPS = [[0.26, 0.28, 0.16], [0.30, 0.40, 0.30], [0.35, 0.62, 0.85], [0.75, 0.90, 1.00], [1.00, 1.00, 1.00]];
// Slope: white → yellow → red → near-black.
const SLOPE_STOPS = [[0.96, 0.96, 0.94], [0.98, 0.85, 0.30], [0.90, 0.30, 0.10], [0.25, 0.05, 0.05]];
function basinColor(id) {
// Golden-ratio hue per basin, moderate saturation so neighbours differ without shouting.
const hue = ((id * 0.6180339887) % 1 + 1) % 1;
const s = 0.55, l = 0.55;
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
t = ((t % 1) + 1) % 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(hue + 1 / 3), f(hue), f(hue - 1 / 3)];
}
/**
* Prepare a colour function for one painted layer. Returns null when the data is missing.
* The returned object has `color(regionIndex)` and the numbers the sidebar legend prints.
*/
export function preparePaintedLayer(layer, arr, curData) {
if (!arr) return null;
const painted = curData && curData.painted;
const N = arr.length;
switch (layer) {
case 'paintClass': {
const cls = painted && painted.legend && painted.legend.classes;
if (!cls) return null;
const table = cls.map(c => [c.rgb[0] / 255, c.rgb[1] / 255, c.rgb[2] / 255]);
return { color: r => table[arr[r]] || SEA_GREY, classes: cls };
}
case 'paintUplift': {
let max = 0;
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
const inv = max > 0 ? 1 / max : 0;
return { color: r => arr[r] < 0 ? SEA_DARK : ramp(UPLIFT_STOPS, arr[r] * inv), max };
}
case 'paintK': {
let lo = Infinity, hi = -Infinity;
for (let r = 0; r < N; r++) { const v = arr[r]; if (v < 0) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
if (!(hi > lo)) { lo = 0.5; hi = 1.5; }
// Centre the ramp on k = 1 so "harder than average" and "softer" read as colours.
const span = Math.max(hi - 1, 1 - lo, 1e-6);
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(K_STOPS, 0.5 + (arr[r] - 1) / (2 * span)), lo, hi };
}
case 'flow': {
let max = 0;
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
const inv = max > 0 ? 1 / max : 0;
return { color: r => { const v = arr[r]; if (v < 0) return SEA_DARK; const t = v * inv; return ramp(FLOW_STOPS, t * t); }, max };
}
case 'slope': {
const cap = 30;
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(SLOPE_STOPS, Math.min(1, arr[r] / cap)), cap };
}
case 'basins':
return { color: r => arr[r] < 0 ? SEA_DARK : basinColor(arr[r]) };
case 'paintOverlay': {
// The class map halved towards black, so a full-strength mark drawn over it cannot be mistaken
// for the ground - the Go tool's map_overlay.png, drawn the same way. The sheet itself is a
// texture (painted-overlay-view.js), not a region colour.
const cls = painted && painted.legend && painted.legend.classes;
if (!cls) return null;
const table = cls.map(c => [c.rgb[0] / 510, c.rgb[1] / 510, c.rgb[2] / 510]);
return { color: r => table[arr[r]] || SEA_DARK, classes: cls };
}
default:
return null;
}
}
function css(c) { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; }
function gradientHTML(stops, labels) {
const pcts = stops.map((_, i) => Math.round(i / (stops.length - 1) * 100));
const grad = stops.map((c, i) => `${css(c)} ${pcts[i]}%`).join(', ');
return `<div class="legend-gradient" style="background:linear-gradient(to right,${grad})"></div>` +
`<div class="legend-labels">${labels.map(l => `<span>${l}</span>`).join('')}</div>`;
}
/** Sidebar legend HTML for a painted layer, or '' when the layer has no data. `overlay` is state.overlay. */
export function paintedLegendHTML(layer, curData, overlay) {
const d = curData;
if (!d || !d.debugLayers) return '';
const prep = preparePaintedLayer(layer, d.debugLayers[layer], d);
if (!prep) return '';
switch (layer) {
case 'paintClass': {
let html = '<div class="legend-classes">';
for (const c of prep.classes) {
if (c.derived && !c.used) continue;
const what = c.sea ? `${c.depthM} m deep` : `${c.upliftMmYr} mm/yr`;
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${c.rgb.join(',')})"></span>${c.name} <span class="legend-class-num">${what}</span></div>`;
}
return html + '</div>';
}
case 'paintUplift':
return gradientHTML(UPLIFT_STOPS, ['0', 'Uplift, mm/yr', prep.max.toFixed(2)]);
case 'paintK':
return gradientHTML(K_STOPS, ['Hard rock', 'k = 1', 'Soft rock']);
case 'flow':
return gradientHTML(FLOW_STOPS, ['Hillslope', 'Drainage area', 'Trunk river']);
case 'slope':
return gradientHTML(SLOPE_STOPS, ['0°', 'Slope', `${prep.cap}°+`]);
case 'basins':
return '<div class="legend-labels"><span>One colour per drainage basin, by river mouth</span></div>';
case 'paintOverlay': {
if (!overlay || !overlay.legend) return '<div class="legend-labels"><span>No overlay loaded</span></div>';
const rep = overlay.report;
let html = '<div class="legend-classes">';
for (const m of overlay.legend.marks) {
const share = rep ? 100 * rep.counts[m.index] / rep.total : 0;
let what = share > 0 ? share.toFixed(2) + ' %' : 'none';
if (m.coastJitter !== null) what += m.coastJitter === 0 ? ', coast pinned' : `, coast \u00d7${m.coastJitter}`;
else if (m.kind === 'path') what += ', path';
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${m.rgb.join(',')})"></span>${m.name} <span class="legend-class-num">${what}</span></div>`;
}
return html + '</div><div class="legend-labels"><span>Marks over the dimmed class map</span></div>';
}
default:
return '';
}
}
+204
View File
@@ -0,0 +1,204 @@
// Showing the overlay: the sheet as a texture draped over the globe and laid over the map.
//
// A mark is not voted onto the mesh for display, deliberately. The sheet is painted at the template's
// resolution - a road is eight pixels wide, a village a few hundred - and a region on this mesh covers
// roughly eight pixels each way, so a per-region colour would turn every road into a chain of blobs and lose
// the thin strokes entirely. A texture keeps every stroke exactly as painted, which is what an author wants
// to check: does the road follow the valley the solve made, is the town on the coast it was drawn against.
//
// On the globe the texture rides on the planet mesh's own triangles, with longitude and latitude as UVs, so
// it follows the relief and is never a shell floating over the mountains. On the map it is one flat quad
// over the map mesh, shifted with the centre-longitude slider through the texture offset rather than by
// moving the quad, so the seam wraps for free. The class map underneath is dimmed by the Overlay layer
// (painted-layers.js) for the same reason the Go tool's map_overlay.png halves the class colours: a
// full-strength mark on top of it cannot be mistaken for the ground.
import * as THREE from 'three';
import { scene } from './scene.js';
import { state } from './state.js';
// The largest sheet uploaded to the GPU. A 7738-wide template is 120 MB as RGBA; halved it is 30 and every
// stroke survives, because a block keeps the most common mark in it rather than the top-left pixel.
const SHEET_MAX_W = 4096;
const MAP_CLIP_PLANES = [
new THREE.Plane(new THREE.Vector3(1, 0, 0), 2),
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 2),
];
/**
* Draw the mark raster as an RGBA canvas no wider than maxW: each mark in its legend colour, blank
* transparent. Downsampling is by block vote over the non-blank marks, so a stroke thinner than the block
* still shows.
*/
export function buildSheetCanvas(overlay, maxW = SHEET_MAX_W) {
const { raster, w, h, legend } = overlay;
const f = Math.max(1, Math.ceil(w / maxW));
const ow = Math.ceil(w / f), oh = Math.ceil(h / f);
const cvs = document.createElement('canvas');
cvs.width = ow; cvs.height = oh;
const ctx = cvs.getContext('2d');
const img = ctx.createImageData(ow, oh);
const px = img.data;
const marks = legend.marks;
const counts = new Int32Array(marks.length + 1);
for (let oy = 0; oy < oh; oy++) {
const y0 = oy * f, y1 = Math.min(h, y0 + f);
for (let ox = 0; ox < ow; ox++) {
let best = 0;
if (f === 1) {
best = raster[y0 * w + ox];
} else {
const x0 = ox * f, x1 = Math.min(w, x0 + f);
counts.fill(0);
for (let y = y0; y < y1; y++) {
const row = y * w;
for (let x = x0; x < x1; x++) { const m = raster[row + x]; if (m) counts[m]++; }
}
let bc = 0;
for (let m = 1; m < counts.length; m++) if (counts[m] > bc) { bc = counts[m]; best = m; }
}
if (!best) continue;
const rgb = marks[best - 1].rgb;
const o = (oy * ow + ox) * 4;
px[o] = rgb[0]; px[o + 1] = rgb[1]; px[o + 2] = rgb[2]; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return cvs;
}
function makeTexture(cvs) {
const tex = new THREE.CanvasTexture(cvs);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function disposeOverlayMeshes() {
for (const key of ['overlayGlobeMesh', 'overlayMapMesh']) {
const m = state[key];
if (!m) continue;
scene.remove(m);
m.geometry.dispose();
if (m.material.map && m.material.map !== (state.overlay && state.overlay.texture)) m.material.map.dispose();
m.material.dispose();
state[key] = null;
}
}
/**
* Install a classified overlay ({ legend, raster, w, h, report, name }) as the current sheet, or null to
* remove it. Builds the texture once; the meshes follow the terrain meshes and are rebuilt with them.
*/
export function setOverlaySheet(overlay) {
disposeOverlayMeshes();
if (state.overlay && state.overlay.texture) state.overlay.texture.dispose();
if (!overlay) { state.overlay = null; return; }
overlay.sheet = buildSheetCanvas(overlay);
overlay.texture = makeTexture(overlay.sheet);
state.overlay = overlay;
updateOverlayMeshes();
}
/** Whether the sheet should be on screen: the toggle, or the Overlay layer, which always shows it. */
export function overlayWanted() {
return !!(state.overlay && state.overlay.texture) && (!!state.overlayVisible || state.debugLayer === 'paintOverlay');
}
/** Show or hide the sheet meshes for the current view without rebuilding them. */
export function setOverlayVisible() {
const on = overlayWanted();
if (state.overlayGlobeMesh) state.overlayGlobeMesh.visible = on && !state.mapMode;
if (state.overlayMapMesh) state.overlayMapMesh.visible = on && state.mapMode;
}
/** Follow the centre-longitude slider while it is being dragged: the map mesh moves, the sheet's UVs shift. */
export function syncOverlayMapCenter() {
const m = state.overlayMapMesh;
if (!m || !m.material.map) return;
m.material.map.offset.x = (state.mapCenterLon || 0) / (2 * Math.PI);
}
/**
* Rebuild the sheet meshes over whatever terrain meshes exist now. Called at the end of buildMesh and
* buildMapMesh in planet-mesh.js, so a rebuilt globe never keeps a stale sheet.
*/
export function updateOverlayMeshes() {
disposeOverlayMeshes();
const ov = state.overlay;
if (!ov || !ov.texture) return;
if (state.planetMesh) {
// The planet mesh's own triangles, unindexed, so each vertex is one triangle's and a triangle across
// the seam can have its U unwrapped past 1 without touching its neighbours.
const pos = state.planetMesh.geometry.getAttribute('position');
const n = pos.count;
const uv = new Float32Array(n * 2);
for (let i = 0; i < n; i += 3) {
let umin = 2, umax = -1;
for (let k = 0; k < 3; k++) {
const x = pos.getX(i + k), y = pos.getY(i + k), z = pos.getZ(i + k);
const len = Math.hypot(x, y, z) || 1;
const lon = Math.atan2(x, z);
const lat = Math.asin(Math.max(-1, Math.min(1, y / len)));
const u = (lon / Math.PI + 1) * 0.5;
uv[(i + k) * 2] = u;
uv[(i + k) * 2 + 1] = 0.5 + lat / Math.PI;
if (u < umin) umin = u;
if (u > umax) umax = u;
}
if (umax - umin > 0.5) {
for (let k = 0; k < 3; k++) { const j = (i + k) * 2; if (uv[j] < 0.5) uv[j] += 1; }
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', pos);
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
const mat = new THREE.MeshBasicMaterial({
map: ov.texture, transparent: true, depthWrite: false,
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = 2;
scene.add(mesh);
state.overlayGlobeMesh = mesh;
}
if (state.mapMesh) {
const geo = new THREE.PlaneGeometry(4, 2);
// Its own texture object over the same canvas, because the offset is per texture and the globe's must
// stay at zero.
const tex = makeTexture(ov.sheet);
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
clippingPlanes: MAP_CLIP_PLANES,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.z = 0.0035;
mesh.renderOrder = 2;
scene.add(mesh);
state.overlayMapMesh = mesh;
syncOverlayMapCenter();
}
setOverlayVisible();
}
/**
* Composite the sheet over an exported map canvas of width x height, 1:1 where the sheet is at least that
* wide and by nearest upscaling where it is not, so marks stay crisp.
*/
export function compositeOverlaySheet(ctx, width, height) {
const ov = state.overlay;
if (!ov || !ov.raster) return;
const sheet = width >= ov.w ? buildSheetCanvas(ov, ov.w) : buildSheetCanvas(ov, width);
const prev = ctx.imageSmoothingEnabled;
ctx.imageSmoothingEnabled = sheet.width === width;
ctx.drawImage(sheet, 0, 0, width, height);
ctx.imageSmoothingEnabled = prev;
sheet.width = 0; sheet.height = 0;
}
+230
View File
@@ -0,0 +1,230 @@
// The overlay: the second painting beside a class template, ported from Tools/Terrain internal/overlay.
//
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
// the wrong place to say "a forest grows here", "this is the village", "a road follows this valley" or "leave
// this stretch of coast exactly as I drew it". So there is a second image, the same size as the template and
// registered to it, whose colours are *marks* rather than classes, with a legend of its own. Two rules are
// the whole design, and both are kept here exactly as the Go tool has them:
//
// - **A mark that no pass reads still travels.** Forests, settlements and roads change no height anywhere;
// they are shown over the terrain and reported, and that is all.
// - **A mark that a pass does read changes one number.** `coast_jitter` scales how far the waterline
// roughening may move the shore inside the mark: 0 pins a hand-drawn coastline exactly as painted, above
// 1 chews it harder than the rest of the world. It is the only mark property any pass reads.
//
// Blank is decided by alpha, never by a colour: an unpainted pixel is transparent, so no colour is spent on
// emptiness and an export with a white matte behind it does not turn the world into whatever mark white is
// nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted - the rule
// the class legend has inverted, because there every pixel must become something and here most of the sheet
// is nothing.
//
// Pure computation, no DOM: the classifier runs on the main thread so the report is on screen before a
// solve, and the region sampling runs inside planet-worker.js.
export const OVERLAY_DEFAULT_MATCH_DISTANCE = 40;
export const OVERLAY_DEFAULT_MIN_AREA_PX = 24;
export const KIND_AREA = 'area';
export const KIND_PATH = 'path';
/** Parse an overlay legend JSON object (the same schema Tools/Terrain reads). Throws with a readable message. */
export function parseOverlayLegend(obj) {
if (!obj || typeof obj !== 'object') throw new Error('The overlay legend is not a JSON object');
if (!Array.isArray(obj.marks)) throw new Error('The overlay legend has no "marks" array');
if (obj.marks.length > 254) throw new Error(`The overlay has ${obj.marks.length} marks; the raster holds 254 plus blank`);
const seenName = new Map(), seenRGB = new Map();
const marks = obj.marks.map((m, i) => {
if (!m || typeof m !== 'object') throw new Error(`Mark ${i} is not an object`);
if (typeof m.name !== 'string' || !m.name) throw new Error(`Mark ${i} has no name`);
if (seenName.has(m.name)) throw new Error(`Marks ${seenName.get(m.name)} and ${i} are both named "${m.name}"`);
seenName.set(m.name, i);
if (!Array.isArray(m.rgb) || m.rgb.length !== 3) throw new Error(`Mark "${m.name}" has no rgb`);
const rgb = m.rgb.map(v => {
const n = Math.round(+v);
if (!(n >= 0 && n <= 255)) throw new Error(`Mark "${m.name}": rgb ${v} is outside 0..255`);
return n;
});
const key = rgb.join(',');
if (seenRGB.has(key)) throw new Error(`Marks "${seenRGB.get(key)}" and "${m.name}" share the colour ${key}; nothing could tell them apart`);
seenRGB.set(key, m.name);
let kind = m.kind || KIND_AREA;
if (kind !== KIND_AREA && kind !== KIND_PATH) throw new Error(`Mark "${m.name}": kind "${kind}" is neither "area" nor "path"`);
let coastJitter = null;
if (m.coast_jitter !== undefined && m.coast_jitter !== null) {
coastJitter = +m.coast_jitter;
if (!(coastJitter >= 0)) throw new Error(`Mark "${m.name}": coast_jitter is ${m.coast_jitter}; it is a multiplier on how far the waterline may move, so it is never negative`);
}
const widthM = +m.width_m || 0;
if (widthM < 0) throw new Error(`Mark "${m.name}": width_m is ${m.width_m}`);
return {
index: i + 1, // raster index; 0 is blank
name: m.name, rgb, kind, coastJitter, widthM,
minAreaPx: +m.min_area_px > 0 ? +m.min_area_px : 0,
note: typeof m.note === 'string' ? m.note : '',
};
});
return {
image: typeof obj.image === 'string' ? obj.image : '',
matchDistance: +obj.match_distance > 0 ? +obj.match_distance : OVERLAY_DEFAULT_MATCH_DISTANCE,
minAreaPx: +obj.min_area_px > 0 ? +obj.min_area_px : OVERLAY_DEFAULT_MIN_AREA_PX,
marks,
source: obj,
};
}
/** Whether any mark asks anything of the coast, which is the only reason a solve has to know about the sheet. */
export function overlayTouchesCoast(legend) {
return legend.marks.some(m => m.coastJitter !== null);
}
/**
* Assign every pixel of an RGBA sheet to a mark, or to blank (0). Same rule as the Go classifier: alpha below
* half is unpainted; otherwise the nearest mark wins if it is within the tolerance, and a colour further than
* that from everything is dropped and counted as `far`.
*/
export function classifyOverlay(rgba, w, h, legend) {
const marks = legend.marks;
const n = marks.length;
const pr = new Int32Array(n), pg = new Int32Array(n), pb = new Int32Array(n);
for (let k = 0; k < n; k++) { pr[k] = marks[k].rgb[0]; pg[k] = marks[k].rgb[1]; pb[k] = marks[k].rgb[2]; }
const tol2 = legend.matchDistance * legend.matchDistance;
const total = w * h;
const out = new Uint8Array(total);
const counts = new Int32Array(n + 1);
let blank = 0, far = 0, maxD2 = -1, maxAt = [-1, -1];
// A flat stroke repeats its colour millions of times, so the last answer is cached: one compare before
// any distance is computed. Exact, because the cache is keyed on the full 24-bit colour.
let lastKey = -1, lastBest = 0, lastD2 = 0;
for (let p = 0, o = 0; p < total; p++, o += 4) {
if (rgba[o + 3] < 128) { blank++; counts[0]++; continue; }
const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2];
const key = (r << 16) | (g << 8) | b;
if (key !== lastKey) {
let best = -1, bestD = 1 << 30;
for (let k = 0; k < n; k++) {
const dr = r - pr[k], dg = g - pg[k], db = b - pb[k];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = k; }
}
lastKey = key; lastBest = best; lastD2 = bestD;
}
if (lastBest < 0 || lastD2 > tol2) {
blank++; counts[0]++; far++;
if (lastD2 > maxD2) { maxD2 = lastD2; maxAt = [p % w, (p / w) | 0]; }
continue;
}
out[p] = lastBest + 1;
counts[lastBest + 1]++;
}
return {
marks: out, w, h, counts, total, blank, far,
maxDist: maxD2 >= 0 ? Math.sqrt(maxD2) : 0, maxAt,
};
}
/** The classifier's report as one line, the way `terrain plan` prints it. */
export function overlayReportText(rep) {
if (!rep || rep.total === 0) return 'no overlay';
const painted = rep.total - rep.blank;
let s = `${painted.toLocaleString()} px painted of ${(rep.total / 1e6).toFixed(1)} MP (${(100 * painted / rep.total).toFixed(1)} %)`;
if (rep.far > 0) s += `; ${rep.far.toLocaleString()} px match no mark and were dropped (worst ${rep.maxDist.toFixed(0)} at ${rep.maxAt[0]}, ${rep.maxAt[1]})`;
return s + '.';
}
const clamp1 = v => Math.max(-1, Math.min(1, v));
/**
* Vote the mark raster onto the mesh. Unlike a class, a mark is sparse - a stroke along a coast is a few
* pixels wide - so a plain majority would hand almost every region to blank. A region takes the most common
* non-blank mark under it when marks cover at least a third of its footprint, else 0.
*/
export function sampleMarksToMesh(mesh, r_xyz, markRaster, w, h, numMarks) {
const N = mesh.numRegions;
const r_mark = new Uint8Array(N);
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const pxPerRadX = w / (2 * Math.PI);
const pxPerRadY = h / Math.PI;
const counts = new Int32Array(numMarks + 1);
const MAXS = 7;
for (let r = 0; r < N; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
const lat = Math.asin(clamp1(y));
const lon = Math.atan2(x, z);
const cx = (lon / Math.PI + 1) * 0.5 * w;
const cy = (0.5 - lat / Math.PI) * h;
const cosLat = Math.max(Math.cos(lat), 1e-3);
let hx = 0.5 * spacing * pxPerRadX / cosLat;
if (hx > w / 2) hx = w / 2;
const hy = 0.5 * spacing * pxPerRadY;
const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx)));
const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy)));
counts.fill(0);
let marked = 0;
for (let j = 0; j < ny; j++) {
const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny;
let py = Math.floor(sy);
if (py < 0) py = 0; else if (py >= h) py = h - 1;
const row = py * w;
for (let i = 0; i < nx; i++) {
const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx;
let px = Math.floor(sx);
px = ((px % w) + w) % w;
const m = markRaster[row + px];
if (m) { counts[m]++; marked++; }
}
}
if (marked * 3 < nx * ny) continue;
let best = 0;
for (let m = 1; m <= numMarks; m++) if (counts[m] > counts[best]) best = m;
r_mark[r] = best;
}
return r_mark;
}
/**
* The coast-jitter multiplier per region, from the marks under it. `markJitter[i]` is the multiplier mark i
* asks for, or null when it says nothing; regions with no such mark get 1.
*
* Painting either side of the waterline is enough: the Go pass reads the mark on the far side of the shore
* too, so here a set factor spreads two hops over the mesh, and where two spread factors meet the smaller
* wins, because pinning is the deliberate act. Returns the factors and how many regions each way.
*/
export function jitterPerRegion(mesh, r_mark, markJitter, hops = 2) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const f = new Float32Array(N).fill(-1); // -1 is "unset"
let queue = [];
for (let r = 0; r < N; r++) {
const m = r_mark[r];
if (!m) continue;
const j = markJitter[m];
if (j === null || j === undefined) continue;
f[r] = j;
queue.push(r);
}
for (let hop = 0; hop < hops && queue.length; hop++) {
const next = [];
for (const c of queue) {
const fc = f[c];
for (let k = adjOffset[c], kEnd = adjOffset[c + 1]; k < kEnd; k++) {
const nb = adjList[k];
if (f[nb] < 0) { f[nb] = fc; next.push(nb); }
else if (fc < f[nb] && r_mark[nb] === 0) f[nb] = fc;
}
}
queue = next;
}
let pinned = 0, marked = 0;
for (let r = 0; r < N; r++) {
if (f[r] < 0) { f[r] = 1; continue; }
marked++;
if (f[r] === 0) pinned++;
}
return { r_jitter: f, pinned, marked };
}
+84
View File
@@ -0,0 +1,84 @@
// What a legend's numbers make, before anything is solved: the two angles per class that `terrain plan`
// prints, ported from Tools/Terrain internal/planet/plan.go so the class table here says the same thing.
//
// Steady state for the stream-power law is S = U/(K·A^m). With channels allowed down to a single cell, A at a
// drainage divide is one cell squared, so for n = 1 the uplift rate alone fixes the hillslope angle at the
// divide - the most useful number in the whole legend, because it decides whether the ground is shaped by
// rivers or by landsliding. But almost none of a map is divide: slope falls away downstream, and the median
// over a class comes out at about a third of the divide angle in tangent. That ratio was measured on the Go
// tool's 8 m grid over a factor of twenty in rate (0.34, 0.33, 0.33, 0.32), and it is the reason there are
// two columns. An author who reads the divide angle as the landscape sets every rate two or three times too
// hot.
//
// Pure functions of the legend and the bake's constants; no mesh, no DOM. The constants are the geology
// grid's (cell size, K, m, angle of repose), which is what the numbers are *about*: the ground the bake makes,
// not the globe here, whose relief is a scale.
export const SOLVE_DEFAULTS = { k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35 };
const RAD = Math.PI / 180;
/** Hillslope angle at a divide, in degrees, for a rate in mm/yr and an erodibility multiplier on K. */
export function divideAngleDeg(rateMmYr, kMult, solve) {
const s = solve || SOLVE_DEFAULTS;
const k = (s.k || SOLVE_DEFAULTS.k) * (kMult > 0 ? kMult : 1);
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
if (k <= 0 || cellM <= 0) return 0;
const slope = (rateMmYr / 1000) / (k * Math.pow(cellM * cellM, m));
return Math.atan(slope) / RAD;
}
// Fractions of the *tangent*, not of the angle, because the law is about slope.
const TYPICAL_MEDIAN_FRAC = 0.33;
const TYPICAL_P90_FRAC = 0.45;
/** The median and 90th-percentile slope over a class whose divide angle is `deg`. */
export function typicalFromDivide(deg) {
const t = Math.tan(deg * RAD);
return { median: Math.atan(t * TYPICAL_MEDIAN_FRAC) / RAD, p90: Math.atan(t * TYPICAL_P90_FRAC) / RAD };
}
/** The ground a median slope reads as. Boundaries are angles, not rates, deliberately. */
export function readsAs(deg) {
if (deg < 3) return 'plain';
if (deg < 8) return 'rolling';
if (deg < 16) return 'hill country';
if (deg < 28) return 'mountain';
return 'alpine';
}
/** The rate, in mm/yr, at which a divide at k = 1 reaches the angle of repose; above it the clamp shapes the ground. */
export function clampCeilingMmYr(solve) {
const s = solve || SOLVE_DEFAULTS;
const k = s.k || SOLVE_DEFAULTS.k;
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
return Math.tan(talus * RAD) * k * Math.pow(cellM * cellM, m) * 1000;
}
/**
* Every angle the plan prints for one parsed class (see painted.js parseLegend): the divide, the typical
* median and P90, what it reads as, whether the divide is past the angle of repose, and the same for the
* massif floor when the class has one. Null for a class that is not land.
*/
export function classAngles(c, solve) {
if (!c || c.sea || c.stroke || c.derived) return null;
const s = solve || SOLVE_DEFAULTS;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
const divide = divideAngleDeg(c.upliftMmYr, c.kMult, s);
const typ = typicalFromDivide(divide);
const out = {
divide, median: typ.median, p90: typ.p90,
readsAs: readsAs(typ.median),
clamped: divide >= talus,
floor: null,
};
if (c.massif && c.massif.fraction > 0) {
const fd = divideAngleDeg(c.massif.floorMmYr, c.kMult, s);
const ft = typicalFromDivide(fd);
out.floor = { rateMmYr: c.massif.floorMmYr, divide: fd, median: ft.median, readsAs: readsAs(ft.median), fraction: c.massif.fraction };
}
return out;
}
+924
View File
@@ -0,0 +1,924 @@
// Painted-map import — the uplift-painting workflow from the Salty terrain generator
// (Tools/Terrain, `terrain plan` / `terrain bake`) on World Orogen's sphere mesh.
//
// The author paints a flat equirectangular map where every colour is a *class*: a rate of rock
// uplift and an erodibility, never a height. A legend JSON beside the painting says what the
// colours mean. This module turns the two into a planet:
//
// 1. classify — every pixel goes to its nearest legend colour (nearest, never "unmatched",
// so a JPEG halo or a stray pixel lands on something sensible; the report
// says how many were far from everything).
// 2. vote — every Voronoi region takes the majority class of the pixels under it.
// 3. strokes — the white outline an artist draws round every island dissolves into
// whichever real class is nearest; a white blob touching a pole is the ice cap.
// 4. coast — the drawn shoreline is roughened by adding noise to the signed distance
// from it, because a drawn coast is a smooth curve and a real one is fractal.
// 5. uplift — class rate × the planet's upland fabric (a massif is where one fabric,
// cut at a quantile of the *planet*, stands high) × a coastal-plain ramp ×
// a regional swell; erodibility = class k × the planet's rock field.
// 6. solve — dh/dt = U − K·A^m·S, integrated implicitly up the drainage stack
// (Braun & Willett 2013) with the ocean fixed at sea level, until the land is
// in balance with its uplift. Rivers, divides and the valley hierarchy come out
// of the physics; the painting decides only where the land rises and how fast.
//
// Paint the uplift, never the height: a solve handed a painted surface erodes it into something
// else within a few hundred steps and throws the drainage network away.
//
// Everything here is pure computation with no DOM, so it runs inside planet-worker.js. The
// legend parser and the pixel classifier also run on the main thread, to show the match report
// before anything is solved.
import { SimplexNoise } from './simplex-noise.js';
import { elevToHeightKm } from './color-map.js';
export const DEFAULT_WARN_DISTANCE = 60;
export const DEFAULT_COASTAL_FLOOR_MM_YR = 0.02;
export const MAX_MASSIF_FRACTION = 0.6;
export const EARTH_CIRCUMFERENCE_KM = 40030;
export const EARTH_RADIUS_KM = 6371;
// Defaults for the planet block, matching RawContent/World/Planet.json in the Salty repo.
export const PLANET_DEFAULTS = {
circumferenceKm: 100,
massifWavelengthKm: 7,
lithologyWavelengthKm: 8,
lithology: [0.6, 1.0, 1.8],
variation: 0.30,
coastDetail: 0.35,
steps: 200,
peakKm: 4.5,
oceanDepthKm: 4.0,
seed: 7945,
// The bake's geology grid - Planet.json's pipeline block - which the class table's angles are about
// (painted-report.js). Not used by the solve here, whose relief is a scale.
k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35,
};
// ─── Legend ───────────────────────────────────────────────────────
/**
* Parse a legend JSON object (the same schema Tools/Terrain reads) into a normalised legend.
* Throws with a readable message when the legend cannot make a world.
*/
export function parseLegend(obj) {
if (!obj || typeof obj !== 'object') throw new Error('The legend is not a JSON object');
if (!Array.isArray(obj.classes) || obj.classes.length === 0) throw new Error('The legend has no "classes" array');
if (obj.classes.length > 255) throw new Error('The legend has more than 255 classes');
const classes = obj.classes.map((c, i) => {
if (!c || typeof c !== 'object') throw new Error(`Class ${i} is not an object`);
if (typeof c.name !== 'string' || !c.name) throw new Error(`Class ${i} has no name`);
let rgb = null;
if (Array.isArray(c.rgb) && c.rgb.length === 3) {
rgb = c.rgb.map(v => Math.max(0, Math.min(255, Math.round(+v || 0))));
}
if (!rgb && !c.derived) throw new Error(`Class "${c.name}" has no rgb`);
const sea = !!c.sea;
let massif = null;
if (c.massif && +c.massif.fraction > 0 && !sea) {
massif = {
floorMmYr: Math.max(0, +c.massif.floor_mm_yr || 0),
fraction: Math.min(MAX_MASSIF_FRACTION, +c.massif.fraction),
};
}
return {
index: i,
name: c.name,
rgb: rgb || [0, 0, 0],
sea,
depthM: sea ? Math.max(0, +c.depth_m || 0) : 0,
upliftMmYr: sea ? 0 : Math.max(0, +c.uplift_mm_yr || 0),
kMult: (+c.k_mult > 0) ? +c.k_mult : 1,
stroke: !!c.stroke,
derived: !!c.derived,
snow: !!c.snow,
edgeClass: typeof c.edge_class === 'string' ? c.edge_class : '',
edgeIndex: -1,
massif,
coastalPlainKm: Math.max(0, +c.coastal_plain_km || 0),
coastalFloorMmYr: Math.max(0, +c.coastal_floor_mm_yr || 0),
lithologyMix: (c.lithology_mix === undefined || c.lithology_mix === null) ? 1 : Math.max(0, Math.min(1, +c.lithology_mix)),
raw: c,
};
});
for (const c of classes) {
if (!c.edgeClass) continue;
const j = classes.findIndex(o => o.name === c.edgeClass);
if (j < 0) throw new Error(`Class "${c.name}" names edge_class "${c.edgeClass}", which is not in the legend`);
c.edgeIndex = j;
}
const paintable = classes.filter(c => !c.derived);
if (!paintable.some(c => c.sea)) throw new Error('The legend has no sea class');
if (!classes.some(c => !c.sea && !c.stroke)) throw new Error('The legend has no land class');
const planet = readPlanetBlock(obj);
return {
classes,
warnDistance: +obj.warn_distance > 0 ? +obj.warn_distance : DEFAULT_WARN_DISTANCE,
planet,
source: obj,
};
}
/**
* The "planet" block: the numbers Planet.json carries in the Go tool. Read from a legend that carries an
* optional copy of it, or from Planet.json itself, which has the same keys plus the pipeline block the
* class table's angles need. Missing keys keep `base`, which is PLANET_DEFAULTS for a legend.
*/
function readPlanetBlock(obj, base = PLANET_DEFAULTS) {
const p = obj.planet || {};
const pipe = obj.pipeline || {};
const lith = pipe.lithology || p.lithology || null;
const kmults = lith && Array.isArray(lith.k_multipliers) && lith.k_multipliers.length >= 2
? lith.k_multipliers.map(v => Math.max(0.05, +v || 1)) : (base.lithology || PLANET_DEFAULTS.lithology).slice();
const num = (v, d) => (v !== undefined && v !== null && isFinite(+v)) ? +v : d;
const fluvial = pipe.fluvial || {};
const thermal = pipe.thermal || {};
// The geology cell is the detail quad times the geology factor, as manifest.go derives it.
const quadM = num(obj.quad_cm, 0) / 100;
const geologyFactor = num(pipe.geology_factor, 0);
return {
circumferenceKm: Math.max(1, num(p.circumference_km, base.circumferenceKm)),
massifWavelengthKm: Math.max(0, num(p.massif_wavelength_km, base.massifWavelengthKm)),
lithologyWavelengthKm: Math.max(0, num(p.lithology_wavelength_km, base.lithologyWavelengthKm)),
lithology: kmults,
variation: Math.max(0, Math.min(0.6, num(p.uplift_variation, base.variation))),
seed: num(obj.source && obj.source.seed, num(p.seed, base.seed)),
k: Math.max(0, num(fluvial.k, base.k)),
m: num(fluvial.m, base.m),
cellM: quadM > 0 && geologyFactor > 0 ? quadM * geologyFactor : num(p.cell_m, base.cellM),
talusDeg: num(thermal.talus_deg, base.talusDeg),
overlayLegend: typeof p.overlay_legend === 'string' ? p.overlay_legend : (base.overlayLegend || ''),
overlay: typeof p.overlay === 'string' ? p.overlay : (base.overlay || ''),
};
}
/**
* Read Planet.json - the Go tool's own manifest - onto a parsed legend, so its planet block, its lithology
* multipliers, its seed and the geology grid's constants travel without being retyped. Keys it lacks keep
* what the legend had. Returns the merged block.
*/
export function applyPlanetManifest(legend, manifest) {
if (!manifest || typeof manifest !== 'object') throw new Error('Planet.json is not a JSON object');
if (!manifest.planet || typeof manifest.planet !== 'object') throw new Error('Planet.json has no "planet" block; the painted path reads a painted planet');
legend.planet = readPlanetBlock(manifest, legend.planet);
return legend.planet;
}
/** The class's plain-at-the-waterline rate, never above the class rate. */
export function plainFloorMmYr(c) {
let floor = c.coastalFloorMmYr > 0 ? c.coastalFloorMmYr : DEFAULT_COASTAL_FLOOR_MM_YR;
if (floor > c.upliftMmYr) floor = c.upliftMmYr;
return floor;
}
/**
* Write a legend back out as JSON text, keeping every key of the loaded file and only replacing
* the numbers the table edits, so the file still reads in Tools/Terrain with its commentary intact.
*/
export function serializeLegend(legend) {
const out = JSON.parse(JSON.stringify(legend.source));
for (let i = 0; i < legend.classes.length; i++) {
const c = legend.classes[i];
const raw = out.classes[i];
if (!raw) continue;
if (c.sea) raw.depth_m = c.depthM;
else {
raw.uplift_mm_yr = c.upliftMmYr;
raw.k_mult = c.kMult;
if (c.massif && raw.massif) {
raw.massif.floor_mm_yr = c.massif.floorMmYr;
raw.massif.fraction = c.massif.fraction;
}
}
}
return JSON.stringify(out, null, 2);
}
// ─── Pixel classification ─────────────────────────────────────────
/**
* Assign every pixel of an RGBA image to its nearest paintable class.
* Returns the class raster plus a match report.
*/
export function classifyImage(rgba, w, h, legend) {
const cls = legend.classes;
const n = cls.length;
const paint = [];
for (let i = 0; i < n; i++) if (!cls[i].derived) paint.push(i);
const np = paint.length;
const pr = new Int32Array(np), pg = new Int32Array(np), pb = new Int32Array(np);
for (let k = 0; k < np; k++) {
const c = cls[paint[k]];
pr[k] = c.rgb[0]; pg[k] = c.rgb[1]; pb[k] = c.rgb[2];
}
// Colours are quantised to six bits a channel and classified once per cell. A legend's
// classes sit tens of units apart (warn distance 60), so the ±2 of the cell is nothing.
const cacheCls = new Uint8Array(1 << 18).fill(255);
const cacheD2 = new Float32Array(1 << 18);
const total = w * h;
const out = new Uint8Array(total);
const counts = new Int32Array(n);
const warn2 = legend.warnDistance * legend.warnDistance;
let far = 0, maxD2 = 0, maxAt = 0;
for (let p = 0, o = 0; p < total; p++, o += 4) {
const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2];
const key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2);
let c = cacheCls[key];
if (c === 255) {
const cr = (r & ~3) + 2, cg = (g & ~3) + 2, cb = (b & ~3) + 2;
let best = 0, bestD = Infinity;
for (let k = 0; k < np; k++) {
const dr = cr - pr[k], dg = cg - pg[k], db = cb - pb[k];
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = k; }
}
c = paint[best];
cacheCls[key] = c;
cacheD2[key] = bestD;
}
out[p] = c;
counts[c]++;
const d2 = cacheD2[key];
if (d2 > warn2) far++;
if (d2 > maxD2) { maxD2 = d2; maxAt = p; }
}
// The wrap: the left and right columns are the same meridian.
let wrapDiffer = 0, wrapLandSea = 0;
for (let y = 0; y < h; y++) {
const a = out[y * w], b = out[y * w + w - 1];
if (a !== b) {
wrapDiffer++;
if (cls[a].sea !== cls[b].sea) wrapLandSea++;
}
}
return {
classes: out,
counts,
total,
far,
maxDist: Math.sqrt(maxD2),
maxAt: [maxAt % w, (maxAt / w) | 0],
wrapRows: h,
wrapDiffer,
wrapLandSea,
};
}
// ─── Region sampling ──────────────────────────────────────────────
const clamp1 = v => Math.max(-1, Math.min(1, v));
/**
* Vote the class raster onto the mesh: every region takes the majority class of a box of
* pixels the size of its own footprint, so a thin stroke or a JPEG halo never decides a cell.
* Returns the class per region and the latitude per region (reused by the stroke pass).
*/
export function sampleClassesToMesh(mesh, r_xyz, classRaster, w, h, numClasses) {
const N = mesh.numRegions;
const r_class = new Uint8Array(N);
const r_lat = new Float32Array(N);
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); // radians between neighbours
const pxPerRadX = w / (2 * Math.PI);
const pxPerRadY = h / Math.PI;
const counts = new Int32Array(numClasses);
const MAXS = 7;
for (let r = 0; r < N; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
const lat = Math.asin(clamp1(y));
const lon = Math.atan2(x, z);
r_lat[r] = lat;
const cx = (lon / Math.PI + 1) * 0.5 * w;
const cy = (0.5 - lat / Math.PI) * h;
const cosLat = Math.max(Math.cos(lat), 1e-3);
let hx = 0.5 * spacing * pxPerRadX / cosLat;
if (hx > w / 2) hx = w / 2;
const hy = 0.5 * spacing * pxPerRadY;
const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx)));
const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy)));
counts.fill(0);
for (let j = 0; j < ny; j++) {
const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny;
let py = Math.floor(sy);
if (py < 0) py = 0; else if (py >= h) py = h - 1;
const row = py * w;
for (let i = 0; i < nx; i++) {
const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx;
let px = Math.floor(sx);
px = ((px % w) + w) % w;
counts[classRaster[row + px]]++;
}
}
let best = 0;
for (let c = 1; c < numClasses; c++) if (counts[c] > counts[best]) best = c;
r_class[r] = best;
}
return { r_class, r_lat };
}
/**
* Resolve stroke classes on the mesh. A stroke component touching a pole becomes its
* edge_class (the ice cap painted in the same white as the outlines); everything else
* dissolves into the nearest non-stroke class by BFS.
*/
export function resolveStrokes(mesh, r_class, r_lat, legend) {
const cls = legend.classes;
const N = mesh.numRegions;
const isStroke = new Uint8Array(cls.length);
let any = false;
for (let i = 0; i < cls.length; i++) if (cls[i].stroke) { isStroke[i] = 1; any = true; }
if (!any) return { edgeAssigned: 0, dissolved: 0 };
const { adjOffset, adjList } = mesh;
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const poleLat = Math.PI / 2 - 2.5 * spacing;
const queue = new Int32Array(N);
const visited = new Uint8Array(N);
let edgeAssigned = 0, dissolved = 0;
// Connected components of each stroke class; the ones at a pole become the edge class.
for (let r = 0; r < N; r++) {
if (visited[r] || !isStroke[r_class[r]]) continue;
const ci = r_class[r];
const edge = cls[ci].edgeIndex;
let head = 0, tail = 0, touches = false;
queue[tail++] = r; visited[r] = 1;
while (head < tail) {
const c = queue[head++];
if (Math.abs(r_lat[c]) > poleLat) touches = true;
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!visited[nb] && r_class[nb] === ci) { visited[nb] = 1; queue[tail++] = nb; }
}
}
if (touches && edge >= 0) {
for (let k = 0; k < tail; k++) r_class[queue[k]] = edge;
edgeAssigned += tail;
}
}
// Dissolve what is left into the nearest real class.
const assigned = new Uint8Array(N);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
if (!isStroke[r_class[r]]) { assigned[r] = 1; queue[tail++] = r; }
}
while (head < tail) {
const c = queue[head++];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!assigned[nb]) { assigned[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; dissolved++; }
}
}
return { edgeAssigned, dissolved };
}
// ─── Coast ────────────────────────────────────────────────────────
/**
* Hop distance from the coast: for land, to the nearest sea region; for sea, to the nearest
* land region. 1 means adjacent. 0 means the planet has no coast at all.
*/
export function coastDistance(mesh, r_land) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const hop = new Int32Array(N);
const queue = new Int32Array(N);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
const land = r_land[r];
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (r_land[adjList[j]] !== land) { hop[r] = 1; queue[tail++] = r; break; }
}
}
while (head < tail) {
const c = queue[head++];
const land = r_land[c];
const d = hop[c] + 1;
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (hop[nb] === 0 && r_land[nb] === land) { hop[nb] = d; queue[tail++] = nb; }
}
}
return hop;
}
/** Connected components of land regions; returns per-region component id and each component's max coast hop. */
function landComponents(mesh, r_land, hop) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const comp = new Int32Array(N).fill(-1);
const compMax = [];
const queue = new Int32Array(N);
for (let r = 0; r < N; r++) {
if (!r_land[r] || comp[r] >= 0) continue;
const id = compMax.length;
let head = 0, tail = 0, mx = 0;
queue[tail++] = r; comp[r] = id;
while (head < tail) {
const c = queue[head++];
if (hop[c] > mx) mx = hop[c];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_land[nb] && comp[nb] < 0) { comp[nb] = id; queue[tail++] = nb; }
}
}
compMax.push(mx);
}
return { comp, compMax };
}
/**
* Roughen the drawn coastline by adding fractal noise to the signed hop distance from it.
* `amount` 0..1 is up to three cells of shift; an islet may lose at most two thirds of its
* width so an archipelago does not vanish. Returns a new land mask.
*/
export function roughenCoast(mesh, r_xyz, r_land, amount, seed, r_jitter = null) {
const N = mesh.numRegions;
const out = new Uint8Array(r_land);
if (amount <= 0) return { r_land: out, flipped: 0 };
const hop = coastDistance(mesh, r_land);
const amp = amount * 3;
// The overlay's coast_jitter marks scale the reach per region (painted-overlay.js): 0 pins the shore as
// painted, above 1 chews it harder. The early-out below has to use the largest reach any of them asks.
let ampMax = amp;
if (r_jitter) {
let jm = 1;
for (let r = 0; r < N; r++) if (r_jitter[r] > jm) jm = r_jitter[r];
ampMax = amp * jm;
}
const { comp, compMax } = landComponents(mesh, r_land, hop);
const noise = new SimplexNoise(seed * 31 + 17);
// Bays about eight cells wide, with four octaves below that.
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
const F = 1 / (8 * spacing);
let flipped = 0;
for (let r = 0; r < N; r++) {
if (hop[r] === 0) continue;
const d = hop[r] - 0.5;
if (d > ampMax + 1) continue;
let a = r_jitter ? amp * r_jitter[r] : amp;
if (a <= 0 || d > a + 1) continue;
if (r_land[r]) {
const cap = 0.66 * compMax[comp[r]];
if (cap < a) a = cap;
if (a < 0.5) continue;
}
const n = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, 4, 0.5);
const s = (r_land[r] ? d : -d) + 2 * n * a;
const land = s > 0 ? 1 : 0;
if (land !== r_land[r]) { out[r] = land; flipped++; }
}
return { r_land: out, flipped };
}
/**
* After the coast moves, a region can be land wearing a sea class or the other way round.
* Give each such region the class of the nearest region that agrees with its new type.
*/
export function reconcileClasses(mesh, r_class, r_land, legend) {
const cls = legend.classes;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const queue = new Int32Array(N);
const done = new Uint8Array(N);
let changed = 0;
for (const wantLand of [1, 0]) {
done.fill(0);
let head = 0, tail = 0;
for (let r = 0; r < N; r++) {
const consistent = (cls[r_class[r]].sea ? 0 : 1) === r_land[r];
if (consistent) { done[r] = 1; if (r_land[r] === wantLand) queue[tail++] = r; }
else if (r_land[r] !== wantLand) done[r] = 1; // the other pass's problem
}
while (head < tail) {
const c = queue[head++];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!done[nb]) { done[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; changed++; }
}
}
// A region nothing reached (an all-sea planet turned to land somewhere) takes the first
// class of the wanted type.
for (let r = 0; r < N; r++) {
if (done[r]) continue;
const idx = cls.findIndex(c => !c.derived && !c.stroke && (c.sea ? 0 : 1) === wantLand);
if (idx >= 0) { r_class[r] = idx; changed++; }
done[r] = 1;
}
}
return changed;
}
// ─── Uplift field ─────────────────────────────────────────────────
/** Exact rank of every value in 0..1 over the whole array: the share of the planet standing below it. */
export function rankField(values) {
const N = values.length;
const idx = new Uint32Array(N);
for (let i = 0; i < N; i++) idx[i] = i;
idx.sort((a, b) => values[a] - values[b]);
const rank = new Float32Array(N);
const denom = Math.max(1, N - 1);
for (let i = 0; i < N; i++) rank[idx[i]] = i / denom;
return rank;
}
function massifShape(rank, fraction) {
const lo = 1 - 1.5 * fraction;
const hi = 1 - 0.5 * fraction;
const t = (rank - lo) / (hi - lo);
if (t <= 0) return 0;
if (t >= 1) return 1;
return t * t * (3 - 2 * t);
}
/** The class rate where the fabric is high, the floor where it is low, cut in rank so `fraction` means what it says. */
export function massifRate(floorMmYr, rateMmYr, rank, fraction) {
return floorMmYr + (rateMmYr - floorMmYr) * massifShape(rank, fraction);
}
function sampleFbm(noise, r_xyz, N, F, octaves, gain) {
const out = new Float32Array(N);
for (let r = 0; r < N; r++) {
out[r] = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, octaves, gain);
}
return out;
}
/**
* Build the uplift-rate field (mm/yr per region) and the erodibility multiplier from the
* classes and the legend. Distances in the legend are in the painted planet's kilometres and
* are scaled to Orogen's Earth-sized globe by 40030 / circumferenceKm.
*/
export function buildUpliftField(mesh, r_xyz, r_class, r_land, legend, opts) {
const cls = legend.classes;
const N = mesh.numRegions;
const circ = Math.max(1, opts.circumferenceKm);
const scale = EARTH_CIRCUMFERENCE_KM / circ;
const avgEdgeKm = Math.PI * EARTH_RADIUS_KM / Math.sqrt(N);
const seed = opts.seed | 0;
const hop = coastDistance(mesh, r_land);
// A noise wavelength of λ painted kilometres is F = circ / (2π λ) noise units per radian.
const freqFor = km => circ / (2 * Math.PI * Math.max(1e-6, km));
let rank = null;
if (opts.massifWavelengthKm > 0 && cls.some(c => c.massif)) {
const fab = sampleFbm(new SimplexNoise(seed * 7 + 1), r_xyz, N, freqFor(opts.massifWavelengthKm), 5, 0.45);
rank = rankField(fab);
}
let rock = null;
const kmults = opts.lithology || [];
if (opts.lithologyWavelengthKm > 0 && kmults.length >= 2 && cls.some(c => c.lithologyMix > 0)) {
const fab = sampleFbm(new SimplexNoise(seed * 7 + 2), r_xyz, N, freqFor(opts.lithologyWavelengthKm), 4, 0.5);
const rr = rankField(fab);
rock = new Float32Array(N);
const types = kmults.length;
for (let r = 0; r < N; r++) rock[r] = kmults[Math.min(types - 1, Math.floor(rr[r] * types))];
}
let swell = null;
const variation = Math.max(0, opts.variation || 0);
if (variation > 0) swell = sampleFbm(new SimplexNoise(seed * 7 + 3), r_xyz, N, freqFor(25), 4, 0.5);
const r_rate = new Float32Array(N);
const r_k = new Float32Array(N);
let rateMax = 0, landCount = 0;
for (let r = 0; r < N; r++) {
const c = cls[r_class[r]];
let k = c.kMult;
if (rock && c.lithologyMix > 0) k *= 1 + c.lithologyMix * (rock[r] - 1);
r_k[r] = k;
if (!r_land[r]) continue;
landCount++;
let rate = c.upliftMmYr;
if (rank && c.massif) rate = massifRate(c.massif.floorMmYr, rate, rank[r], c.massif.fraction);
if (c.coastalPlainKm > 0) {
const plainKm = c.coastalPlainKm * scale;
const shoreKm = Math.max(0, hop[r] - 0.5) * avgEdgeKm;
let t = Math.min(1, shoreKm / plainKm);
t = t * t * (3 - 2 * t);
const floor = plainFloorMmYr(c);
if (rate > floor) rate = floor + (rate - floor) * t;
}
if (swell) {
let sw = 0.5 + swell[r] * 0.8;
if (sw < 0) sw = 0; else if (sw > 1) sw = 1;
rate *= 1 + variation * (2 * sw - 1);
}
r_rate[r] = rate;
if (rate > rateMax) rateMax = rate;
}
return { r_rate, r_k, rateMax, avgEdgeKm, scale, hop, landCount, massifRank: rank };
}
// ─── The solve ────────────────────────────────────────────────────
function hash01(a, b) {
let x = (Math.imul(a, 374761393) + Math.imul(b, 668265263)) | 0;
x = Math.imul(x ^ (x >>> 13), 1274126177);
x ^= x >>> 16;
return (x >>> 0) / 4294967296;
}
/** Binary min-heap over region indices with a copied key, sized once. */
class RegionHeap {
constructor(capacity) {
this.keys = new Float64Array(capacity);
this.items = new Int32Array(capacity);
this.size = 0;
}
clear() { this.size = 0; }
push(item, key) {
let i = this.size++;
const keys = this.keys, items = this.items;
while (i > 0) {
const p = (i - 1) >> 1;
if (keys[p] <= key) break;
keys[i] = keys[p]; items[i] = items[p];
i = p;
}
keys[i] = key; items[i] = item;
}
pop() {
const keys = this.keys, items = this.items;
const top = items[0];
const n = --this.size;
if (n > 0) {
const key = keys[n], item = items[n];
let i = 0;
while (true) {
let l = 2 * i + 1;
if (l >= n) break;
const r = l + 1;
if (r < n && keys[r] < keys[l]) l = r;
if (keys[l] >= key) break;
keys[i] = keys[l]; items[i] = items[l];
i = l;
}
keys[i] = key; items[i] = item;
}
return top;
}
}
/**
* Solve the stream-power equation on the mesh from an uplift field.
*
* Units are dimensionless: U is the rate as a fraction of the largest class rate, K is the
* erodibility multiplier, A is drainage area in cells and lengths are in mean edges, so a
* divide one cell from the sea at full rate stands about one unit high. For n = 1 the steady
* state is linear in U/K, so the relief is set afterwards by a single scale (see toElevation)
* and the shape of the land — where the rivers run, how the valleys nest, how far a coast is
* from its divide — is what the solve decides.
*
* Each step: priority-flood so every land cell has a downhill path to the ocean, steepest
* receivers, a donor stack, drainage area down the stack, the implicit update up it, and a
* touch of hillslope diffusion so the divides are rounded rather than needles.
*/
export function solveUplift(mesh, neighborDist, r_land, r_rate, r_k, rateMax, params, onProgress) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const steps = Math.max(1, params.steps | 0);
const dt = 1;
const m = params.m ?? 0.5;
const alpha = params.diffusion ?? 0.04;
const seed = params.seed | 0;
const eps = 1e-4;
let sumL = 0;
for (let i = 0; i < adjList.length; i++) sumL += neighborDist[i];
const meanEdge = sumL / Math.max(1, adjList.length);
const invMean = 1 / meanEdge;
const U = new Float32Array(N);
const h = new Float64Array(N);
for (let r = 0; r < N; r++) {
if (!r_land[r]) continue;
U[r] = rateMax > 0 ? r_rate[r] / rateMax : 0;
// A little initial relief, scaled by the rate, only so the first routing has something
// to bite on. The solve produces the relief; starting from ridges means tearing them down.
h[r] = 0.05 * U[r] * (0.75 + 0.5 * hash01(r, seed)) + 1e-3 * hash01(r, seed + 1);
}
const receiver = new Int32Array(N);
const recvLen = new Float32Array(N);
const donorOff = new Int32Array(N + 1);
const donorList = new Int32Array(N);
const cursor = new Int32Array(N);
const stack = new Int32Array(N);
const area = new Float32Array(N);
const closed = new Uint8Array(N);
const tmp = new Float64Array(N);
const heap = new RegionHeap(N);
function flood(step) {
closed.fill(0);
heap.clear();
for (let r = 0; r < N; r++) {
if (!r_land[r]) { closed[r] = 1; heap.push(r, h[r]); }
}
while (heap.size > 0) {
const c = heap.pop();
const hc = h[c];
for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (closed[nb]) continue;
closed[nb] = 1;
// The epsilon is scattered per cell and per step, so a filled flat has no
// gradient the router could read as the flood's own traversal order.
if (h[nb] <= hc) h[nb] = hc + eps * (0.5 + hash01(nb, step * 7919 + 13));
heap.push(nb, h[nb]);
}
}
}
function receivers() {
for (let r = 0; r < N; r++) {
if (!r_land[r]) { receiver[r] = r; recvLen[r] = meanEdge; continue; }
const hr = h[r];
let best = -1, bestS = 0, bestJ = -1;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = hr - h[nb];
if (drop <= 0) continue;
const s = drop / neighborDist[j];
if (s > bestS) { bestS = s; best = nb; bestJ = j; }
}
if (best < 0) { receiver[r] = r; recvLen[r] = meanEdge; }
else { receiver[r] = best; recvLen[r] = neighborDist[bestJ]; }
}
}
function buildStack() {
donorOff.fill(0);
for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorOff[r + 1]++; }
for (let i = 0; i < N; i++) donorOff[i + 1] += donorOff[i];
cursor.set(donorOff.subarray(0, N));
for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorList[cursor[r]++] = i; }
let tail = 0;
for (let i = 0; i < N; i++) if (receiver[i] === i) stack[tail++] = i;
for (let read = 0; read < tail; read++) {
const c = stack[read];
for (let d = donorOff[c], dEnd = donorOff[c + 1]; d < dEnd; d++) stack[tail++] = donorList[d];
}
return tail;
}
function accumulate(len) {
area.fill(1);
for (let k = len - 1; k >= 0; k--) {
const i = stack[k];
const r = receiver[i];
if (r !== i) area[r] += area[i];
}
}
function update(len) {
for (let k = 0; k < len; k++) {
const i = stack[k];
if (!r_land[i]) continue;
const r = receiver[i];
if (r === i) { h[i] += dt * U[i]; continue; }
const L = recvLen[i] * invMean;
const f = r_k[i] * dt * Math.pow(area[i], m) / L;
const hr = h[r];
let next = (h[i] + dt * U[i] + f * hr) / (1 + f);
if (next < hr) next = hr;
h[i] = next;
}
}
function diffuse() {
if (alpha <= 0) return;
for (let r = 0; r < N; r++) {
if (!r_land[r]) continue;
let sum = 0, cnt = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) { sum += h[adjList[j]]; cnt++; }
tmp[r] = cnt > 0 ? h[r] + alpha * (sum / cnt - h[r]) : h[r];
}
for (let r = 0; r < N; r++) if (r_land[r]) h[r] = tmp[r];
}
const t0 = (typeof performance !== 'undefined') ? performance.now() : Date.now();
let stackLen = 0;
const report = Math.max(1, Math.floor(steps / 20));
for (let step = 0; step < steps; step++) {
flood(step);
receivers();
stackLen = buildStack();
accumulate(stackLen);
update(stackLen);
diffuse();
if (onProgress && (step % report === 0 || step === steps - 1)) onProgress(step + 1, steps);
}
// One last fill and routing, so area and receiver describe the surface that is returned.
flood(steps);
receivers();
stackLen = buildStack();
accumulate(stackLen);
const basin = new Int32Array(N);
for (let k = 0; k < stackLen; k++) {
const i = stack[k];
const r = receiver[i];
basin[i] = r === i ? i : basin[r];
}
const t1 = (typeof performance !== 'undefined') ? performance.now() : Date.now();
return { h, area, receiver, recvLen, basin, meanEdge, solveMs: t1 - t0 };
}
// ─── Scaling to Orogen's elevation ────────────────────────────────
// Inverse of elevToHeightKm on land: a table over 0..6 km, built once.
let _invTable = null;
const INV_BINS = 6000;
function invHeightKm(km) {
if (!_invTable) {
const table = new Float32Array(INV_BINS + 1);
let t = 0;
const dtStep = 1 / 65536;
for (let b = 0; b <= INV_BINS; b++) {
const target = 6 * b / INV_BINS;
while (t < 1 && elevToHeightKm(t) < target) t += dtStep;
table[b] = Math.min(1, t);
}
_invTable = table;
}
if (km <= 0) return 0;
if (km >= 6) return 1;
const x = km / 6 * INV_BINS;
const b = Math.floor(x);
const f = x - b;
return _invTable[b] * (1 - f) + _invTable[Math.min(INV_BINS, b + 1)] * f;
}
/**
* Turn the solved units into Orogen's elevation field, plus the derived layers.
* Land is scaled so its 99.5th percentile stands at peakKm; sea takes the class depth,
* scaled so the deepest class sits at oceanDepthKm, with a short ramp down from the shore.
*/
export function toElevation(mesh, solved, r_land, r_class, legend, hop, opts) {
const cls = legend.classes;
const N = mesh.numRegions;
const { h, area, receiver, recvLen, basin, meanEdge } = solved;
const avgEdgeKm = opts.avgEdgeKm;
const landVals = [];
for (let r = 0; r < N; r++) if (r_land[r]) landVals.push(h[r]);
landVals.sort((a, b) => a - b);
const p995 = landVals.length ? landVals[Math.min(landVals.length - 1, Math.floor(landVals.length * 0.995))] : 0;
const kmScale = p995 > 0 ? opts.peakKm / p995 : 0;
let deepest = 0;
for (const c of cls) if (c.sea && c.depthM > deepest) deepest = c.depthM;
const depthScale = deepest > 0 ? (opts.oceanDepthKm * 1000) / deepest : 0;
const r_elevation = new Float32Array(N);
const slopeDeg = new Float32Array(N);
const flowLog = new Float32Array(N);
const basinOut = new Int32Array(N);
let maxKm = 0;
for (let r = 0; r < N; r++) {
if (r_land[r]) {
let km = h[r] * kmScale;
if (km > 6) km = 6;
if (km > maxKm) maxKm = km;
let e = invHeightKm(km);
if (e < 0.002) e = 0.002;
r_elevation[r] = e;
const rec = receiver[r];
if (rec !== r) {
const dz = (h[r] - h[rec]) * kmScale;
const dx = recvLen[r] / meanEdge * avgEdgeKm;
slopeDeg[r] = Math.atan2(Math.max(0, dz), Math.max(1e-6, dx)) * 180 / Math.PI;
}
flowLog[r] = Math.log10(Math.max(1, area[r]));
basinOut[r] = basin[r];
} else {
const c = cls[r_class[r]];
let depthKm = c.depthM / 1000 * depthScale;
const f = Math.min(1, Math.max(0, (hop[r] - 0.5) / 2));
depthKm *= f;
if (depthKm < 0.005) depthKm = 0.005;
let e = -depthKm / 10;
if (e < -0.5) e = -0.5;
r_elevation[r] = e;
slopeDeg[r] = -1;
flowLog[r] = -1;
basinOut[r] = -1;
}
}
return { r_elevation, slopeDeg, flowLog, basin: basinOut, kmScale, p995, maxKm };
}
+275
View File
@@ -0,0 +1,275 @@
// Planet code encode/decode — packs seed + slider values into a compact base36 string.
// Pure functions, no DOM access.
// Slider quantization tables
const SLIDERS = [
{ min: 5000, step: 1000, count: 2556 }, // Detail (N)
{ min: 0, step: 0.05, count: 21 }, // Irregularity (jitter)
{ min: 4, step: 1, count: 117 }, // Plates (P)
{ min: 1, step: 1, count: 10 }, // Continents
{ min: 0, step: 0.01, count: 51 }, // Roughness
{ min: 0, step: 0.05, count: 21 }, // Smoothing
{ min: 0, step: 0.05, count: 21 }, // Glacial Erosion
{ min: 0, step: 0.05, count: 21 }, // Hydraulic Erosion
{ min: 0, step: 0.05, count: 21 }, // Thermal Erosion
{ min: 0, step: 0.05, count: 21 }, // Ridge Sharpening
{ min: 0, step: 0.05, count: 21 }, // Soil Creep
{ min: 0, step: 0.05, count: 21 }, // Terrain Warp
{ min: 0, step: 0.05, count: 21 }, // 12: Continent Size Variety
{ min: -15, step: 1, count: 31 }, // 13: Temperature
{ min: -1, step: 0.1, count: 21 }, // 14: Precipitation
{ min: 0, step: 0.01, count: 101 }, // 15: Land Coverage
];
// Mixed-radix bases (right-to-left): lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, scIdx, rsIdx, teIdx, heIdx, glIdx, smIdx, nsIdx, cnIdx, pIdx, jIdx, nIdx, seed
const RADICES = [101, 21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
const SEED_MAX = 16777216; // 2^24
const BASE_LEN = 22; // base code length (no toggles)
const PREV5_LEN = 21; // previous 21-char codes (before land coverage)
const PREV4_LEN = 18; // previous 18-char codes (before continent variety/temp/precip)
const PREV3_LEN = 17; // previous 17-char codes (before terrain warp)
const PREV2_LEN = 16; // previous 16-char codes (before glacial erosion)
const PREV_LEN = 14; // previous 14-char codes (before ridge/creep)
const LEGACY_LEN = 13; // legacy 13-char codes (single erosion slider)
const IDX_CHARS = 2; // base36 chars per plate index (max index 119 = "3b")
// Legacy radices for decoding old 13-char codes (single erosion slider)
const LEGACY_RADICES = [21, 21, 51, 10, 117, 21, 2559];
// Previous-gen radices for decoding 14-char codes (two erosion sliders, no ridge/creep)
const PREV_RADICES = [21, 21, 21, 51, 10, 117, 21, 2559];
// Previous2-gen radices for decoding 16-char codes (no glacial erosion)
const PREV2_RADICES = [21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
// Previous3-gen radices for decoding 17-char codes (no terrain warp)
const PREV3_RADICES = [21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
// Previous5-gen radices for decoding 21-char codes (before land coverage)
const PREV5_RADICES = [21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
// Previous4-gen radices for decoding 18-char codes (before continent variety/temp/precip)
const PREV4_RADICES = [21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
function toIndex(value, slider) {
return Math.round((value - slider.min) / slider.step);
}
function fromIndex(idx, slider) {
// Round to step precision to avoid floating-point drift
const raw = slider.min + idx * slider.step;
const decimals = slider.step < 1 ? String(slider.step).split('.')[1].length : 0;
return decimals > 0 ? parseFloat(raw.toFixed(decimals)) : raw;
}
/** Parse a base36 string into a BigInt (char-by-char for full precision). */
function parseBase36(str) {
return [...str].reduce((acc, ch) => {
const d = parseInt(ch, 36);
if (isNaN(d)) throw new Error('bad char');
return acc * 36n + BigInt(d);
}, 0n);
}
// Decode format configs: one entry per code length.
// fields: [fieldName, SLIDERS_index] in LSB-first extraction order.
// defaults: field values not encoded in this format.
const DECODE_FORMATS = {
[LEGACY_LEN]: {
radices: LEGACY_RADICES,
fields: [
['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, thermalErosion: 0.1,
ridgeSharpening: 0.35, soilCreep: 0.05, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV_LEN]: {
radices: PREV_RADICES,
fields: [
['thermalErosion', 8], ['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, ridgeSharpening: 0.35,
soilCreep: 0.05, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV2_LEN]: {
radices: PREV2_RADICES,
fields: [
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, glacialErosion: 0, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV3_LEN]: {
radices: PREV3_RADICES,
fields: [
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
['glacialErosion', 6], ['smoothing', 5], ['roughness', 4],
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { terrainWarp: 0.5, continentSizeVariety: 0,
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV4_LEN]: {
radices: PREV4_RADICES,
fields: [
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { continentSizeVariety: 0, temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
},
[PREV5_LEN]: {
radices: PREV5_RADICES,
fields: [
['precipitationOffset', 14], ['temperatureOffset', 13], ['continentSizeVariety', 12],
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: { landCoverage: 0.3 }
},
[BASE_LEN]: {
radices: RADICES,
fields: [
['landCoverage', 15], ['precipitationOffset', 14], ['temperatureOffset', 13],
['continentSizeVariety', 12], ['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
],
defaults: {}
},
};
/** Generic mixed-radix decode: extract fields LSB-first, validate, convert, apply defaults. */
function decodeFormat(packed, config, toggleStr) {
const { radices, fields, defaults } = config;
const result = {};
for (let i = 0; i < radices.length; i++) {
const [name, si] = fields[i];
const idx = Number(packed % BigInt(radices[i]));
packed = packed / BigInt(radices[i]);
if (idx >= SLIDERS[si].count) return null;
result[name] = fromIndex(idx, SLIDERS[si]);
}
result.seed = Number(packed);
if (result.seed < 0 || result.seed >= SEED_MAX) return null;
Object.assign(result, defaults);
const toggledIndices = [];
if (toggleStr) {
for (let i = 0; i < toggleStr.length; i += IDX_CHARS) {
const idx = parseInt(toggleStr.slice(i, i + IDX_CHARS), 36);
if (isNaN(idx) || idx >= result.P) return null;
toggledIndices.push(idx);
}
}
result.toggledIndices = toggledIndices;
return result;
}
/**
* Encode planet parameters into a base36 planet code.
* @param {number} seed - Integer seed 0–16777215
* @param {number} N - Detail (5000–2560000, step 1000)
* @param {number} jitter - Irregularity (0–1, step 0.05)
* @param {number} P - Plates (4–120, step 1)
* @param {number} numContinents - Continents (1–10, step 1)
* @param {number} roughness - Roughness (0–0.5, step 0.01)
* @param {number} terrainWarp - Terrain Warp (0–1, step 0.05)
* @param {number} smoothing - Smoothing (0–1, step 0.05)
* @param {number} glacialErosion - Glacial Erosion (0–1, step 0.05)
* @param {number} hydraulicErosion - Hydraulic Erosion (0–1, step 0.05)
* @param {number} thermalErosion - Thermal Erosion (0–1, step 0.05)
* @param {number} ridgeSharpening - Ridge Sharpening (0–1, step 0.05)
* @param {number} soilCreep - Soil Creep (0–1, step 0.05)
* @param {number} continentSizeVariety - Continent Size Variety (0–1, step 0.05)
* @param {number} temperatureOffset - Temperature offset (-15–15, step 1)
* @param {number} precipitationOffset - Precipitation offset (-1–1, step 0.1)
* @param {number} landCoverage - Land Coverage (0–1, step 0.05)
* @param {number[]} [toggledIndices=[]] - Sorted array of toggled plate indices
* @returns {string} base36 code (22 chars without edits, 22 + '-' + 2*k with k edits)
*/
export function encodePlanetCode(seed, N, jitter, P, numContinents, roughness, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, soilCreep, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage, toggledIndices = []) {
const nIdx = toIndex(N, SLIDERS[0]);
const jIdx = toIndex(jitter, SLIDERS[1]);
const pIdx = toIndex(P, SLIDERS[2]);
const cnIdx = toIndex(numContinents, SLIDERS[3]);
const nsIdx = toIndex(roughness, SLIDERS[4]);
const smIdx = toIndex(smoothing, SLIDERS[5]);
const glIdx = toIndex(glacialErosion, SLIDERS[6]);
const heIdx = toIndex(hydraulicErosion, SLIDERS[7]);
const teIdx = toIndex(thermalErosion, SLIDERS[8]);
const rsIdx = toIndex(ridgeSharpening, SLIDERS[9]);
const scIdx = toIndex(soilCreep, SLIDERS[10]);
const twIdx = toIndex(terrainWarp, SLIDERS[11]);
const csvIdx = toIndex(continentSizeVariety, SLIDERS[12]);
const tmpIdx = toIndex(temperatureOffset, SLIDERS[13]);
const prcIdx = toIndex(precipitationOffset, SLIDERS[14]);
const lcIdx = toIndex(landCoverage, SLIDERS[15]);
// Mixed-radix packing (least-significant first: lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, ...)
let packed = BigInt(seed);
packed = packed * BigInt(RADICES[15]) + BigInt(nIdx); // * 2556
packed = packed * BigInt(RADICES[14]) + BigInt(jIdx); // * 21
packed = packed * BigInt(RADICES[13]) + BigInt(pIdx); // * 117
packed = packed * BigInt(RADICES[12]) + BigInt(cnIdx); // * 10
packed = packed * BigInt(RADICES[11]) + BigInt(nsIdx); // * 51
packed = packed * BigInt(RADICES[10]) + BigInt(smIdx); // * 21
packed = packed * BigInt(RADICES[9]) + BigInt(glIdx); // * 21
packed = packed * BigInt(RADICES[8]) + BigInt(heIdx); // * 21
packed = packed * BigInt(RADICES[7]) + BigInt(teIdx); // * 21
packed = packed * BigInt(RADICES[6]) + BigInt(rsIdx); // * 21
packed = packed * BigInt(RADICES[5]) + BigInt(scIdx); // * 21
packed = packed * BigInt(RADICES[4]) + BigInt(twIdx); // * 21
packed = packed * BigInt(RADICES[3]) + BigInt(csvIdx); // * 21
packed = packed * BigInt(RADICES[2]) + BigInt(tmpIdx); // * 31
packed = packed * BigInt(RADICES[1]) + BigInt(prcIdx); // * 21
packed = packed * BigInt(RADICES[0]) + BigInt(lcIdx); // * 21
let code = packed.toString(36).padStart(BASE_LEN, '0');
// Append toggled plate indices: "-" + 2-char base36 per index
if (toggledIndices.length > 0) {
code += '-' + toggledIndices
.map(i => i.toString(36).padStart(IDX_CHARS, '0'))
.join('');
}
return code;
}
/**
* Decode a base36 planet code back into planet parameters.
* Supports 22-char (current), 21-char (prev5), 18-char (prev4), 17-char (prev3), 16-char (prev2), 14-char (previous-gen), and 13-char (legacy) codes.
* @param {string} code - base36 code (13, 14, 16, 17, 18, 21, or 22 chars, optionally followed by "-" + toggle indices)
* @returns {{ seed: number, N: number, jitter: number, P: number, numContinents: number, roughness: number, terrainWarp: number, smoothing: number, glacialErosion: number, hydraulicErosion: number, thermalErosion: number, ridgeSharpening: number, soilCreep: number, continentSizeVariety: number, temperatureOffset: number, precipitationOffset: number, landCoverage: number, toggledIndices: number[] } | null}
*/
export function decodePlanetCode(code) {
if (typeof code !== 'string') return null;
code = code.trim().toLowerCase();
// Split base code from optional toggle suffix
const dashIdx = code.indexOf('-');
const base = dashIdx === -1 ? code : code.slice(0, dashIdx);
const toggleStr = dashIdx === -1 ? '' : code.slice(dashIdx + 1);
const config = DECODE_FORMATS[base.length];
if (!config) return null;
if (!/^[0-9a-z]+$/.test(base)) return null;
if (toggleStr && !/^[0-9a-z]+$/.test(toggleStr)) return null;
if (toggleStr && toggleStr.length % IDX_CHARS !== 0) return null;
let packed;
try {
packed = parseBase36(base);
} catch {
return null;
}
return decodeFormat(packed, config, toggleStr);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+362
View File
@@ -0,0 +1,362 @@
// Plate generation — round-robin weighted fill with directional bias.
// Each plate gets a random growth rate and preferred direction.
import { makeRng, makeRandInt } from './rng.js';
import {
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
PLATE_RATE_MIN_BASE, PLATE_RATE_MIN_LOW_T,
PLATE_RATE_RANGE_BASE, PLATE_RATE_RANGE_LOW_T,
PLATE_DIR_BASE_BASE, PLATE_DIR_BASE_LOW_T,
PLATE_DIR_SCALE_BASE, PLATE_DIR_SCALE_LOW_T,
PLATE_DIR_STRENGTH_CAP,
PLATE_COMPACT_BASE, PLATE_COMPACT_LOW_T,
PLATE_AREA_GOVERNOR_BASE, PLATE_AREA_GOVERNOR_LOW_T,
PLATE_COMPACT_THRESHOLD_MULT, PLATE_COMPACT_PENALTY_MULT,
PLATE_OMEGA_MIN, PLATE_OMEGA_RANGE,
PLATE_SMOOTH_BASE, PLATE_SMOOTH_LOW_T,
PLATE_SMOOTH_FIRST_THRESH, PLATE_SMOOTH_LATER_THRESH,
} from './terrain-config.js';
export function generatePlates(mesh, r_xyz, numPlates, seed) {
const { numRegions } = mesh;
const r_plate = new Int32Array(numRegions).fill(-1);
const rng = makeRng(seed + 0.5);
const randInt = makeRandInt(seed);
// Farthest-point seed distribution with top-3 jitter
const plateSeeds = new Set();
const isSeed = new Uint8Array(numRegions);
const minDistToSeed = new Float32Array(numRegions).fill(Infinity);
const firstSeed = randInt(numRegions);
plateSeeds.add(firstSeed);
isSeed[firstSeed] = 1;
const fsx = r_xyz[3*firstSeed], fsy = r_xyz[3*firstSeed+1], fsz = r_xyz[3*firstSeed+2];
for (let r = 0; r < numRegions; r++) {
minDistToSeed[r] = 1 - (r_xyz[3*r]*fsx + r_xyz[3*r+1]*fsy + r_xyz[3*r+2]*fsz);
}
minDistToSeed[firstSeed] = 0;
while (plateSeeds.size < numPlates && plateSeeds.size < numRegions) {
// Find top-3 farthest regions (flat vars, no object allocation)
let t0r = -1, t0d = -1, t1r = -1, t1d = -1, t2r = -1, t2d = -1;
for (let r = 0; r < numRegions; r++) {
if (isSeed[r]) continue;
const d = minDistToSeed[r];
if (d > t2d) {
if (d > t0d) {
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = d;
} else if (d > t1d) {
t2r = t1r; t2d = t1d; t1r = r; t1d = d;
} else {
t2r = r; t2d = d;
}
}
}
let validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
if (!validCount) break;
const pick = randInt(validCount);
const newSeed = pick === 0 ? t0r : pick === 1 ? t1r : t2r;
plateSeeds.add(newSeed);
isSeed[newSeed] = 1;
const nsx = r_xyz[3*newSeed], nsy = r_xyz[3*newSeed+1], nsz = r_xyz[3*newSeed+2];
// Fused pass: update minDistToSeed from new seed AND find top-3 for next iteration
if (plateSeeds.size < numPlates) {
t0r = -1; t0d = -1; t1r = -1; t1d = -1; t2r = -1; t2d = -1;
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
if (isSeed[r]) continue;
const md = minDistToSeed[r];
if (md > t2d) {
if (md > t0d) {
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = md;
} else if (md > t1d) {
t2r = t1r; t2d = t1d; t1r = r; t1d = md;
} else {
t2r = r; t2d = md;
}
}
}
// Next iteration can skip the search pass — top-3 is already computed
validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
if (!validCount) break;
const pick2 = randInt(validCount);
const newSeed2 = pick2 === 0 ? t0r : pick2 === 1 ? t1r : t2r;
plateSeeds.add(newSeed2);
isSeed[newSeed2] = 1;
const ns2x = r_xyz[3*newSeed2], ns2y = r_xyz[3*newSeed2+1], ns2z = r_xyz[3*newSeed2+2];
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*ns2x + r_xyz[3*r+1]*ns2y + r_xyz[3*r+2]*ns2z);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
}
} else {
// Last seed — just update distances (needed for distance field, but loop will exit)
for (let r = 0; r < numRegions; r++) {
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
}
}
}
// Interpolation factor: more cragginess at low plate counts
const lowPlateT = Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE));
// Per-plate growth properties
const plateGrowthRate = {};
const plateGrowthDir = {};
const plateDirStrength = {};
const rateMin = PLATE_RATE_MIN_BASE - PLATE_RATE_MIN_LOW_T * lowPlateT; // 0.7 → 0.3
const rateRange = PLATE_RATE_RANGE_BASE + PLATE_RATE_RANGE_LOW_T * lowPlateT; // 2.3 → 4.7
const dirBase = PLATE_DIR_BASE_BASE + PLATE_DIR_BASE_LOW_T * lowPlateT; // 0.15 → 0.4
const dirScale = PLATE_DIR_SCALE_BASE + PLATE_DIR_SCALE_LOW_T * lowPlateT; // 0.25 → 0.5
for (const center of plateSeeds) {
plateGrowthRate[center] = rateMin + rng() * rng() * rateRange;
const px = r_xyz[3*center], py = r_xyz[3*center+1], pz = r_xyz[3*center+2];
const pLen = Math.sqrt(px*px + py*py + pz*pz) || 1;
const nx = px/pLen, ny = py/pLen, nz = pz/pLen;
const rx = rng()-0.5, ry = rng()-0.5, rz = rng()-0.5;
const d = rx*nx + ry*ny + rz*nz;
let tx = rx - d*nx, ty = ry - d*ny, tz = rz - d*nz;
const tLen = Math.sqrt(tx*tx + ty*ty + tz*tz) || 1;
plateGrowthDir[center] = [tx/tLen, ty/tLen, tz/tLen];
plateDirStrength[center] = Math.min(PLATE_DIR_STRENGTH_CAP, rng() * (dirBase + dirScale / plateGrowthRate[center]));
}
// Per-plate frontiers — round-robin ensures every plate advances
const plateIds = Array.from(plateSeeds);
const frontiers = new Map();
const plateAreaCount = {};
for (const pid of plateIds) {
r_plate[pid] = pid;
frontiers.set(pid, [pid]);
plateAreaCount[pid] = 1;
}
const { adjOffset, adjList } = mesh;
let remaining = numRegions - plateIds.length;
const COMPACT_WEIGHT = PLATE_COMPACT_BASE - PLATE_COMPACT_LOW_T * lowPlateT; // 0.3 → 0.08
const expectedArea = Math.max(1, (numRegions - plateIds.length) / numPlates);
const areaGovernorMult = PLATE_AREA_GOVERNOR_BASE + PLATE_AREA_GOVERNOR_LOW_T * lowPlateT; // 2.0 → 4.0
const invNumRegions = 1 / numRegions;
while (remaining > 0) {
let anyProgress = false;
for (const pid of plateIds) {
const frontier = frontiers.get(pid);
if (frontier.length === 0) continue;
const rate = plateGrowthRate[pid];
const dir = plateGrowthDir[pid];
const d0 = dir[0], d1 = dir[1], d2 = dir[2];
const dirStr = plateDirStrength[pid];
const dirStrHalf = dirStr * 0.5;
let steps = Math.max(1, Math.ceil(rate * (0.5 + rng())));
// Governor: halve steps for plates exceeding threshold
if (plateAreaCount[pid] > expectedArea * areaGovernorMult) {
steps = Math.max(1, Math.ceil(steps * 0.5));
}
// Compactness: expected chord distance for a circular plate of current area
const expectedChordDist = Math.sqrt((plateAreaCount[pid] || 1) * invNumRegions / Math.PI) * 2;
const compactThreshold = expectedChordDist * PLATE_COMPACT_THRESHOLD_MULT;
// Precompute seed coordinates
const sx = r_xyz[3*pid], sy = r_xyz[3*pid+1], sz = r_xyz[3*pid+2];
for (let s = 0; s < steps && frontier.length > 0; s++) {
let bestIdx = 0, bestScore = -Infinity;
const samples = Math.min(frontier.length, 3 + Math.floor(dirStr * 5));
for (let i = 0; i < samples; i++) {
const idx = randInt(frontier.length);
const cell = frontier[idx];
const ci = 3*cell;
const dx = r_xyz[ci] - sx, dy = r_xyz[ci+1] - sy, dz = r_xyz[ci+2] - sz;
const dLenSq = dx*dx + dy*dy + dz*dz;
const dLen = Math.sqrt(dLenSq) || 1;
const alignment = (dx*d0 + dy*d1 + dz*d2) / dLen;
// Compactness: seedDist = dLenSq/2 for unit-sphere points
const excess = Math.max(0, dLenSq * 0.5 - compactThreshold);
const compactPenalty = excess * (COMPACT_WEIGHT * PLATE_COMPACT_PENALTY_MULT);
const score = alignment * dirStr + rng() * (1 - dirStrHalf) - compactPenalty;
if (score > bestScore) { bestScore = score; bestIdx = idx; }
}
const current = frontier[bestIdx];
frontier[bestIdx] = frontier[frontier.length - 1];
frontier.pop();
for (let j = adjOffset[current], jEnd = adjOffset[current + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_plate[nb] === -1) {
r_plate[nb] = pid;
frontier.push(nb);
plateAreaCount[pid]++;
remaining--;
anyProgress = true;
}
}
}
}
if (!anyProgress) break;
}
// Cleanup: assign orphaned regions to nearest claimed neighbor
let orphans = true;
while (orphans) {
orphans = false;
for (let r = 0; r < numRegions; r++) {
if (r_plate[r] === -1) {
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_plate[nb] !== -1) {
r_plate[r] = r_plate[nb];
orphans = true;
break;
}
}
}
}
}
smoothAndReconnectPlates(mesh, r_plate, plateSeeds, Math.round(PLATE_SMOOTH_BASE - PLATE_SMOOTH_LOW_T * lowPlateT));
// Assign an Euler pole + angular velocity per plate
const plateVec = {};
for (const center of plateSeeds) {
// Random Euler pole uniformly distributed on the sphere
const theta = rng() * 2 * Math.PI;
const cosP = 2 * rng() - 1;
const sinP = Math.sqrt(1 - cosP * cosP);
const pole = [sinP * Math.cos(theta), sinP * Math.sin(theta), cosP];
// Angular velocity: magnitude 0.5–2.0, random sign
const omega = (PLATE_OMEGA_MIN + rng() * PLATE_OMEGA_RANGE) * (rng() < 0.5 ? -1 : 1);
plateVec[center] = { pole, omega };
}
return { r_plate, plateSeeds, plateVec };
}
/**
* Smooth plate boundaries via majority-vote, then reconnect severed plates.
* @param {SphereMesh} mesh
* @param {Int32Array} r_plate — mutated in place
* @param {Set|Array} plateSeeds — seed region IDs (used for connectivity roots & protection)
* @param {number} numPasses — number of majority-vote smoothing passes
*/
export function smoothAndReconnectPlates(mesh, r_plate, plateSeeds, numPasses) {
const { numRegions, adjOffset, adjList } = mesh;
const plateIds = Array.from(plateSeeds);
// Build seed lookup for protection during smoothing.
// Protects plate seed regions from being reassigned by majority-vote.
// After coarse→hi-res projection the seed IDs are coarse-mesh indices
// that won't satisfy r_plate[pid] === pid on the hi-res mesh, so the
// array stays all-zeros and protection is effectively skipped — this is
// intentional since projected boundaries don't need seed anchoring.
const isSeed = new Uint8Array(numRegions);
for (const pid of plateIds) {
if (pid < numRegions && r_plate[pid] === pid) isSeed[pid] = 1;
}
// Smooth boundaries: majority-vote removes thin tendrils
let maxDeg = 0;
for (let r = 0; r < numRegions; r++) {
const deg = adjOffset[r + 1] - adjOffset[r];
if (deg > maxDeg) maxDeg = deg;
}
const cntPlates = new Int32Array(maxDeg);
const cntValues = new Uint8Array(maxDeg);
for (let pass = 0; pass < numPasses; pass++) {
const threshold = pass === 0 ? PLATE_SMOOTH_FIRST_THRESH : PLATE_SMOOTH_LATER_THRESH;
for (let r = 0; r < numRegions; r++) {
const rStart = adjOffset[r], rEnd = adjOffset[r + 1];
const deg = rEnd - rStart;
let nDistinct = 0;
for (let j = rStart; j < rEnd; j++) {
const p = r_plate[adjList[j]];
let found = false;
for (let k = 0; k < nDistinct; k++) {
if (cntPlates[k] === p) { cntValues[k]++; found = true; break; }
}
if (!found) { cntPlates[nDistinct] = p; cntValues[nDistinct] = 1; nDistinct++; }
}
let bestPlate = r_plate[r], bestCount = 0;
for (let k = 0; k < nDistinct; k++) {
if (cntValues[k] > bestCount) { bestCount = cntValues[k]; bestPlate = cntPlates[k]; }
}
if (bestCount > deg * threshold && !isSeed[r]) {
r_plate[r] = bestPlate;
}
}
}
// Reconnect: smoothing or projection may create disconnected plate fragments.
// For each plate, keep the LARGEST connected component and mark the rest
// for reassignment. This is stable across resolutions (unlike first-found).
{
const visited = new Uint8Array(numRegions);
// Per-plate: track the largest component's BFS list
const bestComponent = {}; // pid → [region indices]
for (let r = 0; r < numRegions; r++) {
if (visited[r]) continue;
const pid = r_plate[r];
const bfs = [r];
visited[r] = 1;
for (let qi = 0; qi < bfs.length; qi++) {
for (let ni = adjOffset[bfs[qi]], niEnd = adjOffset[bfs[qi] + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (!visited[nb] && r_plate[nb] === pid) {
visited[nb] = 1;
bfs.push(nb);
}
}
}
if (!bestComponent[pid] || bfs.length > bestComponent[pid].length) {
bestComponent[pid] = bfs;
}
}
// Mark regions in the largest component per plate
const inMain = new Uint8Array(numRegions);
for (const pid of Object.keys(bestComponent)) {
for (const r of bestComponent[pid]) inMain[r] = 1;
}
// Reassign orphaned regions (not in their plate's largest component)
// via BFS from the main-component boundary
const queue = [];
for (let r = 0; r < numRegions; r++) {
if (!inMain[r]) {
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
if (inMain[adjList[ni]]) {
r_plate[r] = r_plate[adjList[ni]];
inMain[r] = 1;
queue.push(r);
break;
}
}
}
}
for (let qi = 0; qi < queue.length; qi++) {
const r = queue[qi];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (!inMain[nb]) {
r_plate[nb] = r_plate[r];
inMain[nb] = 1;
queue.push(nb);
}
}
}
}
}
+92
View File
@@ -0,0 +1,92 @@
// Greyscale PNG encoding, 8-bit and 16-bit.
//
// Both are here because the Unreal landscape export needs both - a 16-bit height and an 8-bit weightmap
// per paint layer per tile - and because a canvas cannot produce either. toBlob writes 8-bit RGBA and
// Unreal's landscape importer wants single-channel, so the weightmaps would have to be un-RGBA'd on the
// way in; the heights have no 16-bit canvas path at all. Writing the chunks directly is less code than
// working around either.
//
// Compression is CompressionStream('deflate'), which is the zlib wrapper PNG asks for, not the raw
// DEFLATE that 'deflate-raw' would give. Filter 0 (None) on every scanline: the rows here are either
// smooth height ramps or near-flat weight fields, and Paeth would cost a pass over the image to save a
// few per cent of a file that is written once and read once.
const _crc32Table = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) crc = _crc32Table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function chunk(type, data) {
const out = new Uint8Array(4 + 4 + data.length + 4);
const dv = new DataView(out.buffer);
dv.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
async function assemble(width, height, bitDepth, raw) {
const ihdr = new Uint8Array(13);
const iv = new DataView(ihdr.buffer);
iv.setUint32(0, width);
iv.setUint32(4, height);
ihdr[8] = bitDepth;
ihdr[9] = 0; // colour type 0: greyscale
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(raw);
writer.close();
const body = new Uint8Array(await new Response(cs.readable).arrayBuffer());
const parts = [
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr),
chunk('IDAT', body),
chunk('IEND', new Uint8Array(0)),
];
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const part of parts) { png.set(part, at); at += part.length; }
return new Blob([png], { type: 'image/png' });
}
/** 16-bit greyscale PNG from a Uint16Array of width * height, row-major. Big-endian, as PNG requires. */
export async function encodeGray16(width, height, data) {
const rowLen = 1 + width * 2;
const raw = new Uint8Array(height * rowLen);
for (let y = 0; y < height; y++) {
const off = y * rowLen;
raw[off] = 0;
for (let x = 0; x < width; x++) {
const v = data[y * width + x];
raw[off + 1 + x * 2] = (v >> 8) & 0xFF;
raw[off + 2 + x * 2] = v & 0xFF;
}
}
return assemble(width, height, 16, raw);
}
/** 8-bit greyscale PNG from a Uint8Array of width * height, row-major. */
export async function encodeGray8(width, height, data) {
const rowLen = 1 + width;
const raw = new Uint8Array(height * rowLen);
for (let y = 0; y < height; y++) {
const off = y * rowLen;
raw[off] = 0;
raw.set(data.subarray(y * width, (y + 1) * width), off + 1);
}
return assemble(width, height, 8, raw);
}
+686
View File
@@ -0,0 +1,686 @@
// Precipitation simulation: moisture advection driven by wind, ocean warmth,
// orographic effects, ITCZ uplift, frontal convergence, and polar fronts.
// Computes per-region precipitation for summer and winter seasons.
import { smoothstep } from './wind.js';
import { computeGradients } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { computeHeuristicPrecipitation, computeHeuristicWindField } from './heuristic-precip.js';
import { smoothField, makeItczLookup, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Wind convergence ─────────────────────────────────────────────────────────
// Compute per-region convergence of the wind field. Negative divergence means
// winds are piling into a region (frontal zone / ITCZ-like uplift). We measure
// this as net inward flux: for each neighbor pair, how much does the neighbor's
// wind point toward us vs. our wind point toward the neighbor?
function computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ) {
const { adjOffset, adjList, numRegions } = mesh;
const convergence = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
// Wind at r in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
let conv = 0;
let count = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from r to nb
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
// inFlux - outFlux = -(nw·d) - (w·d) = -((nw + w)·d)
conv -= (r_wind3dX[nb] + wdx) * dx
+ (r_wind3dY[nb] + wdy) * dy
+ (r_wind3dZ[nb] + wdz) * dz;
count++;
}
// Normalize by neighbor count; positive = converging, negative = diverging
convergence[r] = count > 0 ? conv / count : 0;
}
return convergence;
}
// ── Upwind moisture advection ────────────────────────────────────────────────
// For each land cell, accumulate moisture from upwind neighbors.
// Moisture originates at coast cells proportional to ocean warmth and
// depletes with distance and elevation gain.
function advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm) {
const { adjOffset, adjList, numRegions } = mesh;
const moisture = new Float32Array(numRegions);
// Initialize moisture: coastal land cells from adjacent ocean warmth,
// ocean cells from their own warmth
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) {
// Ocean cells: base moisture proportional to warmth
const warmth = r_oceanWarmth ? r_oceanWarmth[r] : 0;
moisture[r] = 0.4 + 0.35 * Math.max(0, warmth);
continue;
}
if (r_coastDistLand[r] !== 0) continue; // not a coast cell
// Coastal land cell — check for onshore wind
let warmthSum = 0;
let oceanCount = 0;
let oceanDirX = 0, oceanDirY = 0, oceanDirZ = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb]) {
oceanCount++;
if (r_oceanWarmth) warmthSum += r_oceanWarmth[nb];
oceanDirX += r_xyz[3 * nb] - r_xyz[3 * r];
oceanDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
oceanDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
}
}
if (oceanCount === 0) continue;
const avgWarmth = warmthSum / oceanCount;
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Onshore = wind blows FROM ocean toward land = wind dot (ocean→region) < 0
const windDotOcean = wdx * oceanDirX + wdy * oceanDirY + wdz * oceanDirZ;
const onshore = windDotOcean < 0 ? 1.0 : 0.25;
// Base moisture: warm currents provide more, cold currents less
const warmthFactor = 0.5 + 0.5 * Math.max(-0.8, Math.min(1, avgWarmth));
moisture[r] = onshore * warmthFactor;
}
// Base friction: ~78% moisture survives the full maxHops
// distance over flat terrain. Per-hop retention = 0.78^(1/maxHops).
const depletionBase = 1 - Math.pow(0.78, 1 / maxHops);
// Iterative downwind propagation (ping-pong double-buffering)
let src = moisture;
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < maxHops; iter++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) { dst[r] = src[r]; continue; }
const we = r_windE[r], wn = r_windN[r];
if (we * we + wn * wn < 1e-6) { dst[r] = src[r]; continue; }
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Find upwind neighbors (those where wind at neighbor points toward us)
// Track weighted-average upwind elevation for gradient-based depletion
let upwindMoisture = 0;
let upwindWeight = 0;
let upwindHeightSum = 0;
const heightHere = r_heightKm[r];
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from nb to r
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Alignment: how much does wind at nb point toward r?
const dot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (dot > 0) {
upwindMoisture += src[nb] * dot;
upwindHeightSum += r_heightKm[nb] * dot;
upwindWeight += dot;
}
}
if (upwindWeight > 0) {
const incoming = upwindMoisture / upwindWeight;
const upwindHeight = upwindHeightSum / upwindWeight;
// Depletion depends on physical height GAIN (km) from upwind.
const heightGain = Math.max(0, heightHere - upwindHeight);
// Height gain per hop (km) shrinks at higher resolution.
// Multiply by maxHops to get total rise over the advection
// distance. A ~1 km total rise dumps significant moisture,
// ~2 km near-total.
const normalizedGain = heightGain * maxHops;
const elevDepletion = Math.min(0.8, normalizedGain * 0.55);
const depletion = depletionBase + elevDepletion;
const carried = incoming * Math.max(0, 1 - depletion);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
// Swap buffers
const swap = src;
src = dst;
dst = swap;
}
return src;
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute seasonal precipitation fields.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind()
* @param {object} oceanResult - output from computeOceanCurrents()
* @returns {{ r_precip_summer, r_precip_winter }} normalized 0–1 arrays
*/
export function computePrecipitation(mesh, r_xyz, r_elevation, windResult, oceanResult, precipitationOffset = 0, landCoverage = 0.3) {
console.log('[precipitation.js] computePrecipitation called, numRegions:', mesh.numRegions);
const numRegions = mesh.numRegions;
const timing = [];
const { r_lat, r_lon, r_isLand, r_continentality,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ } = windResult;
// Scale-dependent hop count: ~2000 km reach.
// Average edge length ≈ π / sqrt(numRegions) radians ≈ (π * 6371) / sqrt(N) km
// hops ≈ 2000 / edgeLengthKm
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const avgEdgeRad = Math.PI / Math.sqrt(numRegions);
const maxHops = Math.max(8, Math.min(20, Math.round(2000 / avgEdgeKm)));
// Coast distance through land — reuse BFS already computed by wind.js
const r_coastDistLand = windResult.r_coastDistLand;
// Elevation gradient for orographic detection (shared).
// Use a smoothed copy of elevation so local noise/crags don't fragment
// the large-scale windward/leeward signal at high resolutions.
// Target ~200 km smoothing radius — enough to average out terrain noise
// while preserving the broad mountain-range slope.
let t0 = performance.now();
const elevSmoothPasses = Math.max(2, Math.round(200 / avgEdgeKm));
const r_elevSmoothed = new Float32Array(r_elevation);
smoothField(mesh, r_elevSmoothed, elevSmoothPasses);
// Blend smoothed with actual: keeps broad slope signal but retains some local detail
for (let r = 0; r < numRegions; r++) {
r_elevSmoothed[r] = r_elevSmoothed[r] * 0.6 + r_elevation[r] * 0.4;
}
const r_elevGradE = new Float32Array(numRegions);
const r_elevGradN = new Float32Array(numRegions);
computeGradients(mesh, r_xyz, r_elevSmoothed,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_elevGradE, r_elevGradN);
timing.push({ stage: 'Precip: elevation gradients (smoothed)', ms: performance.now() - t0 });
// Pre-compute height in km for advection and mechanisms (elevation is constant across seasons)
const r_heightKm = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_heightKm[r] = elevToHeightKm(Math.max(0, r_elevation[r]));
}
const result = {};
const seasons = [
{ name: 'summer', shift: 5 },
{ name: 'winter', shift: -5 }
];
for (const { name, shift } of seasons) {
t0 = performance.now();
const r_windE_raw = windResult[`r_wind_east_${name}`];
const r_windN_raw = windResult[`r_wind_north_${name}`];
const r_windSpeed = windResult[`r_wind_speed_${name}`];
const r_pressure = windResult[`r_pressure_${name}`];
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
const itczLookup = makeItczLookup(windResult.itczLons,
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
// ── Blend complex wind with heuristic zonal wind (50-50) ──
// Smooths out noisy pressure-derived wind patterns, strengthens
// zonal consistency for advection and orographic effects.
const { hWindE, hWindN } = computeHeuristicWindField(
numRegions, r_lat, r_lon, itczLookup);
const r_windE = new Float32Array(numRegions);
const r_windN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_windE[r] = 0.5 * r_windE_raw[r] + 0.5 * hWindE[r];
r_windN[r] = 0.5 * r_windN_raw[r] + 0.5 * hWindN[r];
}
// Pre-compute 3D wind vectors for convergence and advection
const r_wind3dX = new Float32Array(numRegions);
const r_wind3dY = new Float32Array(numRegions);
const r_wind3dZ = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const we = r_windE[r], wn = r_windN[r];
r_wind3dX[r] = we * r_eastX[r] + wn * r_northX[r];
r_wind3dY[r] = we * r_eastY[r] + wn * r_northY[r];
r_wind3dZ[r] = we * r_eastZ[r] + wn * r_northZ[r];
}
// ── Step 1a: Wind convergence field ──
// Compute raw convergence then smooth heavily — real fronts are
// messy, mobile bands, not sharp lines. The smoothing spreads the
// signal over a wide area representing the zone where frontal
// weather systems wander over a season.
const r_convergence = computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ);
// Smooth ~400 km worth of hops so frontal zones are broad bands
const convSmoothPasses = Math.max(3, Math.round(400 / avgEdgeKm));
smoothField(mesh, r_convergence, convSmoothPasses);
// ── Step 1b: Moisture advection from coasts ──
const moisture = advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm);
const tAdvect = performance.now() - t0;
// ── Step 2: Apply precipitation mechanisms ──
t0 = performance.now();
const precip = new Float32Array(numRegions);
const rainShadow = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
const absLatDeg = Math.abs(lat) / DEG;
const elev = r_elevation[r];
const isLand = r_isLand[r];
let p = moisture[r];
// (a) ITCZ uplift: boost moisture within ±15° of ITCZ
const itczLat = itczLookup(lon);
const distFromItcz = Math.abs(lat - itczLat) / DEG;
const cont = (isLand && r_continentality) ? r_continentality[r] : 0;
if (distFromItcz < 15) {
const itczStrength = smoothstep(15, 0, distFromItcz);
// Core ITCZ (within 5°): strong uplift and convective rain
const coreBoost = distFromItcz < 5 ? 1.5 : 1.0;
p = p * (1 + itczStrength * coreBoost) + itczStrength * 0.3;
}
// (b) Frontal precipitation: actual wind convergence
// Where winds collide (convergence > 0) air is forced upward,
// creating turbulence and wringing out whatever moisture is present.
// This naturally finds frontal zones, ITCZ-like convergence,
// and any other place where air masses meet.
const conv = r_convergence[r];
if (conv > 0) {
// Scale convergence: gentle convergence gives mild boost,
// strong convergence (opposing air masses) gives large boost.
// Only amplifies existing moisture — dry converging air
// doesn't produce rain.
// Raw convergence ∝ avgEdgeRad (neighbor displacements shrink
// at higher resolution), so normalize to make scale-invariant.
const convStrength = Math.min(1, (conv / avgEdgeRad) * 0.055);
p = p * (1 + convStrength * 1.2) + convStrength * moisture[r] * 0.4;
}
// (c) Orographic effects (land only)
// The advection step already handles gradient-based moisture loss
// per hop. This step adds the *local* precipitation boost on windward
// slopes (forced uplift squeezes out extra rain at that cell) and a
// moderate leeward shadow for any remaining moisture.
if (isLand && elev > 0) {
const we = r_windE[r], wn = r_windN[r];
// Windward uplift: wind dot elevation gradient
// Positive = wind blows upslope (windward), negative = downslope (leeward)
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
if (windDotGrad > 0) {
// Windward: orographic enhancement — the steeper the slope
// the wind is pushing up, the more rain wrung out.
// gradient strength matters more than absolute height.
const uplift = Math.min(1, windDotGrad * 15);
p += uplift * 1.0;
} else {
// Leeward: rain shadow. The advection step already depleted
// moisture crossing the ridge; this is the *extra* suppression
// from descending/warming air (foehn drying) on the lee side.
const shadow = Math.min(1, -windDotGrad * 18);
p *= Math.max(0.02, 1 - shadow * 0.95);
}
}
// (d) Pressure-driven suppression/enhancement (hybrid)
// Start with a gentle latitude-band expectation for subtropical
// highs, then let the actual pressure field shift it — so the
// effect tracks real geography without being too aggressive.
const pDev = r_pressure[r]; // deviation from 1013 hPa
// Seasonal subtropical suppression: the subtropical high shifts
// poleward in local summer (creating Mediterranean dry summers)
// and retreats equatorward in local winter (allowing westerly rain).
const inLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
const subtropCenter = inLocalSummer ? 30 : 24;
const subtropWidth = inLocalSummer ? 16 : 12;
let subtropPeak = inLocalSummer ? 0.50 : 0.30;
// East-coast monsoon relief: reduce summer drying where
// poleward winds bring tropical moisture onshore. On Earth
// this produces humid subtropical (Cfa) on east coasts
// while west coasts keep Mediterranean (Cs) dry summers.
if (isLand && inLocalSummer) {
const polewardWind = lat >= 0 ? r_windN[r] : -r_windN[r];
if (polewardWind > 0) {
const coastDist = r_coastDistLand[r] >= 0 ? r_coastDistLand[r] : maxHops;
const coastProximity = 1 - smoothstep(0, maxHops * 0.4, coastDist);
const monsoonRelief = smoothstep(0, 0.15, polewardWind) * coastProximity;
subtropPeak *= (1 - monsoonRelief * 0.7);
}
}
const subtropDist = Math.abs(absLatDeg - subtropCenter);
const latBandSuppression = subtropDist < subtropWidth
? smoothstep(subtropWidth, 0, subtropDist) * subtropPeak : 0;
// Pressure modifier: high pressure adds suppression, low reduces it
// Kept gentle — pressure nudges the baseline, doesn't overwhelm it.
let pressureMod = 0;
if (pDev > 0) {
pressureMod = smoothstep(0, 12, pDev) * 0.25; // extra suppression
} else {
pressureMod = -smoothstep(0, 15, -pDev) * 0.2; // relief / enhancement
}
const totalSuppression = Math.max(0, latBandSuppression + pressureMod);
if (totalSuppression > 0) {
p *= Math.max(0.05, 1 - totalSuppression);
} else {
// Net enhancement from low pressure outside subtropical belt
p *= (1 - totalSuppression); // totalSuppression is negative here
}
// (e) Polar front: diffuse precipitation at high latitudes
// The polar front is broad and pushes moisture deep inland —
// the blog cites ~2000 km downwind, ~1500 km crosswind from
// any coast, including coasts with offshore winds.
// It always brings *some* precipitation from its own cyclonic
// activity, even deep inland, plus a stronger coastal component.
if (absLatDeg > 40) {
const polarStrength = smoothstep(40, 70, absLatDeg);
const coastDist = r_coastDistLand[r] < 0 ? maxHops : r_coastDistLand[r];
const inlandFade = 1 - smoothstep(0, maxHops, coastDist);
// Base: always present regardless of coast distance
const polarBase = polarStrength * 0.10;
// Coastal enhancement: fades inland
const polarCoastal = polarStrength * 0.20 * inlandFade;
// Mostly enhances existing moisture, but adds some regardless
p += polarBase + polarCoastal;
p *= (1 + polarStrength * 0.15); // gentle multiplicative boost
}
// (f) Continental interior dryness
// Now that continentality is BFS-based (0 at coast, 0.5 at ~1000km,
// 1.0 at ~2000km), we can use it directly. Squared curve keeps
// near-coast areas gentle while ramping for deep interiors.
if (isLand && cont > 0) {
const dryness = cont * cont * 0.55;
p *= Math.max(0.03, 1 - dryness);
}
// (g) Lee cyclogenesis: localized wet zone on leeward side of high mountains
// when ocean is nearby downwind (~200 km)
const heightKm = r_heightKm[r];
if (isLand && heightKm > 1.5) {
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
// ~200 km in hops (scale-invariant)
const leeCoastHops = Math.max(2, Math.round(200 / avgEdgeKm));
if (windDotGrad < -0.01 && r_coastDistLand[r] >= 0 && r_coastDistLand[r] < leeCoastHops) {
p += 0.15 * Math.min(1, heightKm / 5);
}
}
// Ocean cells: precipitation over ocean (for visual completeness)
if (!isLand) {
// ITCZ and frontal zones already contribute above.
// Add baseline ocean precipitation, suppressed under high pressure
const highPressureFade = pDev > 0 ? smoothstep(0, 12, pDev) : 0;
const oceanBase = 0.15 * (1 - highPressureFade);
p = Math.max(p, oceanBase);
}
// (h) Hard distance-from-coast moisture cutoff
// Beyond ~2000 km from any coast, moisture drops off steeply.
// By 3000 km almost nothing remains.
if (isLand && r_coastDistLand[r] > 0) {
const distKm = r_coastDistLand[r] * avgEdgeKm;
if (distKm > 2000) {
const fade = 1 - smoothstep(2000, 3000, distKm);
p *= Math.max(0.03, fade);
}
}
const precipMult = 1 + precipitationOffset * 0.5;
let finalPrecip = p * precipMult;
if (landCoverage > 0.4) {
const t = (landCoverage - 0.4) / 0.6;
finalPrecip *= 1 - t * t * 0.98;
}
precip[r] = Math.max(0, finalPrecip);
}
const tMechanisms = performance.now() - t0;
// ── Step 2b: Rain shadow diagnostic — local source + bidirectional propagation ──
// Seed leeward slopes with negative shadow strength and windward slopes
// with positive orographic rain. Then propagate each in the correct
// direction: shadow travels DOWNWIND (foehn drying), windward rain
// extends UPWIND (rising air condenses approaching the mountains).
{
const { adjOffset, adjList } = mesh;
// Seed: local orographic effect at each cell
// Only significant terrain (≥0.8 km) seeds shadows — small hills
// shouldn't cast continent-scale rain shadows.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r] || r_elevation[r] <= 0) continue;
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
const heightKm = r_heightKm[r];
if (heightKm < 0.8) continue; // skip low terrain
const heightScale = Math.min(1, (heightKm - 0.5) / 2.5);
if (windDotGrad > 0) {
rainShadow[r] = Math.min(1, windDotGrad * 20) * heightScale;
} else if (windDotGrad < 0) {
rainShadow[r] = -Math.min(1, -windDotGrad * 18) * heightScale;
}
}
// Pre-compute wind-aligned neighbor lists once — avoids
// redundant dot-product calculations inside every propagation
// iteration. Two sets: "upwind" (nb's wind points toward r,
// for shadow propagation) and "downwind" (r's wind points
// toward nb, for windward propagation).
const maxNbTotal = adjList.length;
const upNb = new Int32Array(maxNbTotal);
const upWt = new Float32Array(maxNbTotal);
const upOff = new Int32Array(numRegions + 1);
const dnNb = new Int32Array(maxNbTotal);
const dnWt = new Float32Array(maxNbTotal);
const dnOff = new Int32Array(numRegions + 1);
let upCount = 0, dnCount = 0;
for (let r = 0; r < numRegions; r++) {
upOff[r] = upCount;
dnOff[r] = dnCount;
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Upwind: wind at nb points toward r
const upDot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (upDot > 0) { upNb[upCount] = nb; upWt[upCount] = upDot; upCount++; }
// Downwind: wind at r points toward nb (direction is -dx,-dy,-dz)
const dnDot = -(r_wind3dX[r] * dx + r_wind3dY[r] * dy + r_wind3dZ[r] * dz);
if (dnDot > 0) { dnNb[dnCount] = nb; dnWt[dnCount] = dnDot; dnCount++; }
}
}
upOff[numRegions] = upCount;
dnOff[numRegions] = dnCount;
// --- Pass 1: Propagate shadow DOWNWIND (~2500 km, 15% survives) ---
const shadowHops = Math.max(8, Math.round(2500 / avgEdgeKm));
const shadowDecay = 1 - Math.pow(0.15, 1 / shadowHops);
const shadowField = new Float32Array(rainShadow);
// Reusable ping-pong buffers for both shadow and windward passes
let src = new Float32Array(shadowField);
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < shadowHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let upVal = 0, upW = 0;
const uEnd = upOff[r + 1];
for (let ui = upOff[r]; ui < uEnd; ui++) {
const val = src[upNb[ui]];
if (val < 0) { upVal += val * upWt[ui]; upW += upWt[ui]; }
}
if (upW > 0) {
const carried = (upVal / upW) * (1 - shadowDecay);
dst[r] = Math.min(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] < shadowField[r]) shadowField[r] = src[r];
}
// --- Pass 2: Propagate windward rain UPWIND (~1500 km, 25% survives) ---
const windwardHops = Math.max(6, Math.round(1500 / avgEdgeKm));
const windwardDecay = 1 - Math.pow(0.25, 1 / windwardHops);
const windwardField = new Float32Array(rainShadow);
// Reuse ping-pong buffers from shadow pass
src.set(windwardField);
dst.fill(0);
for (let iter = 0; iter < windwardHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let dnVal = 0, dnW = 0;
const dEnd = dnOff[r + 1];
for (let di = dnOff[r]; di < dEnd; di++) {
const val = src[dnNb[di]];
if (val > 0) { dnVal += val * dnWt[di]; dnW += dnWt[di]; }
}
if (dnW > 0) {
const carried = (dnVal / dnW) * (1 - windwardDecay);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] > windwardField[r]) windwardField[r] = src[r];
}
// Merge: shadow dominates if present, otherwise take windward
for (let r = 0; r < numRegions; r++) {
rainShadow[r] = shadowField[r] < 0 ? shadowField[r] : windwardField[r];
}
}
// Smooth ~150 km so the zones read clearly
const rsSmoothPasses = Math.max(2, Math.round(150 / avgEdgeKm));
smoothField(mesh, rainShadow, rsSmoothPasses);
// ── Step 2c: Apply propagated rain shadow to actual precipitation ──
// The local orographic effect in (c) only touches the mountain slopes
// themselves. This step extends the shadow hundreds of km downwind and
// boosts windward rain upwind, using the propagated field from 2b.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) continue;
const rs = rainShadow[r];
if (rs < -0.01) {
// Shadow zone: precipitation suppression behind mountains
const strength = Math.min(1, -rs * 2.25);
precip[r] *= Math.max(0.02, 1 - strength * 0.92);
} else if (rs > 0.01) {
// Windward zone: strong orographic precipitation enhancement
precip[r] += rs * 1.2;
}
}
// ── Step 3: Smooth (normalization deferred to blending step) ──
t0 = performance.now();
// Light smoothing ~100 km to blend cell-to-cell noise
const precipSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, precip, precipSmoothPasses);
const tSmooth = performance.now() - t0;
timing.push({ stage: `Precip: advection (${name})`, ms: tAdvect });
timing.push({ stage: `Precip: mechanisms (${name})`, ms: tMechanisms });
timing.push({ stage: `Precip: smooth (${name})`, ms: tSmooth });
result[`r_precip_${name}`] = precip;
result[`r_rainshadow_${name}`] = rainShadow;
}
// ── Step 4: Blend with heuristic model and normalize ──
t0 = performance.now();
const heuristic = computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand);
for (const seasonName of ['summer', 'winter']) {
const complex = result[`r_precip_${seasonName}`];
const heur = heuristic[`r_precip_${seasonName}`];
const blended = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
blended[r] = 0.5 * complex[r] + 0.5 * heur[r];
}
// 95th-percentile normalization on blended result
const maxPrecip = percentile(blended, 0.95);
for (let r = 0; r < numRegions; r++) {
blended[r] = Math.min(1, blended[r] / maxPrecip);
}
// Continental interior cap: interior regions can't exceed steppe-level
// precipitation. At cont=1.0, cap is 0.20 per season (≈ 200mm
// half-year → 400mm annual — solidly in steppe territory). Fades in
// from cont 0.5 so the transition is gradual. Other factors (desert
// factory, rain shadows, distance cutoff) can still push lower.
const r_continentality = windResult.r_continentality;
if (r_continentality) {
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] && r_continentality[r] > 0.5) {
const t = smoothstep(0.5, 1.0, r_continentality[r]);
const cap = 1.0 - t * 0.80; // 1.0 at cont=0.5, 0.20 at cont=1.0
blended[r] = Math.min(blended[r], cap);
}
}
}
result[`r_precip_${seasonName}`] = blended;
}
timing.push({ stage: 'Precip: heuristic blend+normalize', ms: performance.now() - t0 });
result._precipTiming = timing;
return result;
}
+11
View File
@@ -0,0 +1,11 @@
// Seeded RNG — deterministic pseudo-random number generators.
export function makeRng(seed) {
let s = (Math.abs(Math.floor(seed * 9301 + 49297)) % 2147483646) + 1;
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
export function makeRandInt(seed) {
const r = makeRng(seed);
return (n) => Math.floor(r() * n);
}
+175
View File
@@ -0,0 +1,175 @@
// Three.js scene setup: renderer, cameras, controls, lights, atmosphere, water, stars.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export const canvas = document.getElementById('canvas');
export const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
export const scene = new THREE.Scene();
scene.background = new THREE.Color(0x030308);
export const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 200);
camera.position.set(0, 0.4, 2.8);
export const ctrl = new OrbitControls(camera, canvas);
ctrl.enableDamping = true; ctrl.dampingFactor = 0.06;
ctrl.enablePan = false;
ctrl.minDistance = 1.4; ctrl.maxDistance = 8;
ctrl.enableZoom = false; // disable built-in zoom; custom handler below
// Smooth zoom: wheel sets a target distance, each frame lerps toward it
let _zoomTarget = camera.position.distanceTo(ctrl.target);
const ZOOM_STEP = 0.92; // multiplier per tick (lower = faster zoom)
const ZOOM_SMOOTH = 0.12; // lerp speed per frame (higher = snappier)
canvas.addEventListener('wheel', (e) => {
if (!ctrl.enabled) return;
e.preventDefault();
const dir = Math.sign(e.deltaY);
_zoomTarget *= dir > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
}, { passive: false });
// Pinch-to-zoom for globe (touch)
let _pinchDist = 0;
canvas.addEventListener('touchstart', (e) => {
if (!ctrl.enabled || e.touches.length !== 2) { _pinchDist = 0; return; }
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_pinchDist = Math.sqrt(dx * dx + dy * dy);
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!ctrl.enabled || e.touches.length !== 2 || _pinchDist === 0) return;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
const ratio = _pinchDist / dist;
_zoomTarget *= ratio;
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
_pinchDist = dist;
}, { passive: true });
canvas.addEventListener('touchend', () => { _pinchDist = 0; }, { passive: true });
export function tickZoom() {
const v = new THREE.Vector3().subVectors(camera.position, ctrl.target);
const cur = v.length();
const next = THREE.MathUtils.lerp(cur, _zoomTarget, ZOOM_SMOOTH);
if (Math.abs(next - cur) < 0.0001) return;
v.setLength(next);
camera.position.copy(ctrl.target).add(v);
}
scene.add(new THREE.AmbientLight(0xaabbcc, 3.5));
export const sun = new THREE.DirectionalLight(0xfff8ee, 1.5);
sun.position.set(5, 3, 4);
scene.add(sun);
// Stars
export let starsMesh;
{ const g=new THREE.BufferGeometry(),p=[];
for(let i=0;i<3000;i++){const th=Math.random()*Math.PI*2,ph=Math.acos(2*Math.random()-1),r=40+Math.random()*30;
p.push(r*Math.sin(ph)*Math.cos(th),r*Math.sin(ph)*Math.sin(th),r*Math.cos(ph));}
g.setAttribute('position',new THREE.Float32BufferAttribute(p,3));
starsMesh = new THREE.Points(g,new THREE.PointsMaterial({color:0xffffff,size:0.08}));
scene.add(starsMesh); }
// Atmosphere
const atmosMat = new THREE.ShaderMaterial({
uniforms:{c:{value:new THREE.Color(0.35,0.6,1.0)}},
vertexShader:`varying vec3 vN,vP;void main(){vN=normalize(normalMatrix*normal);vP=(modelViewMatrix*vec4(position,1)).xyz;gl_Position=projectionMatrix*vec4(vP,1);}`,
fragmentShader:`uniform vec3 c;varying vec3 vN,vP;void main(){float r=1.0-max(0.0,dot(normalize(-vP),vN));gl_FragColor=vec4(c,pow(r,3.5)*0.55);}`,
transparent:true,side:THREE.FrontSide,depthWrite:false
});
export const atmosMesh = new THREE.Mesh(new THREE.SphereGeometry(1.12,64,64), atmosMat);
scene.add(atmosMesh);
// Water sphere
const waterMat = new THREE.MeshPhongMaterial({
color:0x0c3a6e, transparent:true, opacity:0.55,
shininess:120, specular:0x4488bb, depthWrite:false
});
export const waterMesh = new THREE.Mesh(new THREE.SphereGeometry(1.0,80,80), waterMat);
scene.add(waterMesh);
// Equirectangular map camera & controls
export const mapCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
mapCamera.position.set(0, 0, 5);
mapCamera.lookAt(0, 0, 0);
export function updateMapCameraFrustum() {
const aspect = innerWidth / innerHeight;
const mapAspect = 2;
let halfW, halfH;
if (aspect > mapAspect) {
halfH = 1.15;
halfW = halfH * aspect;
} else {
halfW = 2.3;
halfH = halfW / aspect;
}
mapCamera.left = -halfW; mapCamera.right = halfW;
mapCamera.top = halfH; mapCamera.bottom = -halfH;
mapCamera.updateProjectionMatrix();
}
updateMapCameraFrustum();
export const mapCtrl = new OrbitControls(mapCamera, canvas);
mapCtrl.enableRotate = false;
mapCtrl.enableDamping = true;
mapCtrl.dampingFactor = 0.09;
mapCtrl.panSpeed = 1.4;
mapCtrl.screenSpacePanning = true;
mapCtrl.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
mapCtrl.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN };
mapCtrl.minZoom = 0.5;
mapCtrl.maxZoom = 20;
mapCtrl.enableZoom = false; // custom handler below
mapCtrl.enabled = false;
// Smooth zoom for map view (orthographic)
let _mapZoomTarget = mapCamera.zoom;
const MAP_ZOOM_STEP = 0.92;
const MAP_ZOOM_SMOOTH = 0.12;
canvas.addEventListener('wheel', (e) => {
if (!mapCtrl.enabled) return;
e.preventDefault();
const dir = Math.sign(e.deltaY);
_mapZoomTarget *= dir < 0 ? 1 / MAP_ZOOM_STEP : MAP_ZOOM_STEP;
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
}, { passive: false });
// Pinch-to-zoom for map (touch)
let _mapPinchDist = 0;
canvas.addEventListener('touchstart', (e) => {
if (!mapCtrl.enabled || e.touches.length !== 2) { _mapPinchDist = 0; return; }
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_mapPinchDist = Math.sqrt(dx * dx + dy * dy);
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!mapCtrl.enabled || e.touches.length !== 2 || _mapPinchDist === 0) return;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
const ratio = dist / _mapPinchDist;
_mapZoomTarget *= ratio;
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
_mapPinchDist = dist;
}, { passive: true });
canvas.addEventListener('touchend', () => { _mapPinchDist = 0; }, { passive: true });
export function tickMapZoom() {
const cur = mapCamera.zoom;
const next = THREE.MathUtils.lerp(cur, _mapZoomTarget, MAP_ZOOM_SMOOTH);
if (Math.abs(next - cur) < 0.0001) return;
mapCamera.zoom = next;
mapCamera.updateProjectionMatrix();
}
+54
View File
@@ -0,0 +1,54 @@
// Simplex Noise 3D with fBm and ridged fBm variants.
import { makeRng } from './rng.js';
export class SimplexNoise {
constructor(seed = 0) {
this.G = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]];
const rng = makeRng(seed);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) { const j = Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
this.perm = new Uint8Array(512);
this.pm12 = new Uint8Array(512);
for (let i = 0; i < 512; i++) { this.perm[i] = p[i&255]; this.pm12[i] = this.perm[i]%12; }
}
noise3D(x,y,z) {
const F=1/3,H=1/6,s=(x+y+z)*F;
const i=Math.floor(x+s),j=Math.floor(y+s),k=Math.floor(z+s);
const t=(i+j+k)*H,x0=x-i+t,y0=y-j+t,z0=z-k+t;
let i1,j1,k1,i2,j2,k2;
if(x0>=y0){if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0;}else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1;}else{i1=0;j1=0;k1=1;i2=1;j2=0;k2=1;}}
else{if(y0<z0){i1=0;j1=0;k1=1;i2=0;j2=1;k2=1;}else if(x0<z0){i1=0;j1=1;k1=0;i2=0;j2=1;k2=1;}else{i1=0;j1=1;k1=0;i2=1;j2=1;k2=0;}}
const x1=x0-i1+H,y1=y0-j1+H,z1=z0-k1+H,x2=x0-i2+2*H,y2=y0-j2+2*H,z2=z0-k2+2*H,x3=x0-1+3*H,y3=y0-1+3*H,z3=z0-1+3*H;
const ii=i&255,jj=j&255,kk=k&255,{perm:P,pm12:M,G:g}=this;
let n0=0,n1=0,n2=0,n3=0;
let a=0.6-x0*x0-y0*y0-z0*z0;if(a>0){a*=a;const v=g[M[ii+P[jj+P[kk]]]];n0=a*a*(v[0]*x0+v[1]*y0+v[2]*z0);}
let b=0.6-x1*x1-y1*y1-z1*z1;if(b>0){b*=b;const v=g[M[ii+i1+P[jj+j1+P[kk+k1]]]];n1=b*b*(v[0]*x1+v[1]*y1+v[2]*z1);}
let c=0.6-x2*x2-y2*y2-z2*z2;if(c>0){c*=c;const v=g[M[ii+i2+P[jj+j2+P[kk+k2]]]];n2=c*c*(v[0]*x2+v[1]*y2+v[2]*z2);}
let d=0.6-x3*x3-y3*y3-z3*z3;if(d>0){d*=d;const v=g[M[ii+1+P[jj+1+P[kk+1]]]];n3=d*d*(v[0]*x3+v[1]*y3+v[2]*z3);}
return 32*(n0+n1+n2+n3);
}
fbm(x,y,z,octaves=5,persistence=2/3) {
let sum=0,max=0,amp=1;
for(let o=0;o<octaves;o++){const f=1<<o;sum+=amp*this.noise3D(x*f,y*f,z*f);max+=amp;amp*=persistence;}
return sum/max;
}
ridgedFbm(x, y, z, octaves = 6, lacunarity = 2.0, gain = 0.5, offset = 1.0) {
let sum = 0, freq = 1, amp = 1, prev = 1, maxVal = 0;
for (let o = 0; o < octaves; o++) {
let n = this.noise3D(x * freq, y * freq, z * freq);
n = offset - Math.abs(n);
n = n * n;
sum += n * amp * prev;
maxVal += amp;
prev = Math.min(n, 1);
freq *= lacunarity;
amp *= gain;
}
return sum / maxVal;
}
}
+219
View File
@@ -0,0 +1,219 @@
// Sphere mesh construction: Fibonacci sphere → Delaunay → close pole → SphereMesh.
// Adapted from Red Blob Games sphere-mesh.js.
let _Delaunator = null;
export function setDelaunator(D) { _Delaunator = D; }
// Fibonacci sphere with jitter — evenly-distributed points using the
// Fibonacci spiral. Jitter randomises positions for more organic Voronoi cells.
export function generateFibonacciSphere(N, jitter, rng) {
const r_xyz = new Float32Array(3 * N);
const s = 3.6 / Math.sqrt(N);
const dlong = Math.PI * (3 - Math.sqrt(5));
const dz = 2.0 / N;
for (let k = 0, lng = 0, z = 1 - dz / 2; k < N; k++, z -= dz) {
const r = Math.sqrt(1 - z * z);
let latDeg = Math.asin(z) * 180 / Math.PI;
let lonDeg = lng * 180 / Math.PI;
if (jitter > 0) {
const jLat = (rng() - rng());
const jLon = (rng() - rng());
const nextZ = Math.max(-1, z - dz * 2 * Math.PI * r / s);
latDeg += jitter * jLat * (latDeg - Math.asin(nextZ) * 180 / Math.PI);
lonDeg += jitter * jLon * (s / r * 180 / Math.PI);
}
const latR = latDeg * Math.PI / 180;
const lonR = lonDeg * Math.PI / 180;
r_xyz[3*k] = Math.cos(latR) * Math.cos(lonR);
r_xyz[3*k+1] = Math.cos(latR) * Math.sin(lonR);
r_xyz[3*k+2] = Math.sin(latR);
lng += dlong;
}
return r_xyz;
}
// Stereographic projection (for Delaunay on a sphere).
// Projects every point from the "north pole" (0,0,1) onto a plane.
export function stereographicProjection(r_xyz, N) {
const flat = new Float64Array(2 * N);
for (let i = 0; i < N; i++) {
const z = r_xyz[3*i+2];
// Clamp denominator to prevent Infinity when a jittered point lands
// on or near the projection pole (z ≈ 1). The exact projected position
// doesn't matter for near-pole points — addPoleToMesh corrects connectivity.
const denom = Math.max(1e-12, 1 - z);
flat[2*i] = r_xyz[3*i] / denom;
flat[2*i+1] = r_xyz[3*i+1] / denom;
}
return flat;
}
// Add pole back into mesh — close the mesh by connecting hull edges to the pole.
export function addPoleToMesh(poleId, triangles, halfedges) {
const numSides = triangles.length;
const next = s => (s % 3 === 2) ? s - 2 : s + 1;
let numUnpaired = 0, firstUnpaired = -1;
const pointToSide = [];
for (let s = 0; s < numSides; s++) {
if (halfedges[s] === -1) {
numUnpaired++;
pointToSide[triangles[s]] = s;
firstUnpaired = s;
}
}
const nt = new Int32Array(numSides + 3 * numUnpaired);
const nh = new Int32Array(numSides + 3 * numUnpaired);
nt.set(triangles);
nh.set(halfedges);
for (let i = 0, s = firstUnpaired;
i < numUnpaired;
i++, s = pointToSide[nt[next(s)]]) {
const ns = numSides + 3 * i;
nh[s] = ns;
nh[ns] = s;
nt[ns] = nt[next(s)];
nt[ns + 1] = nt[s];
nt[ns + 2] = poleId;
const k = numSides + (3 * i + 4) % (3 * numUnpaired);
nh[ns + 2] = k;
nh[k] = ns + 2;
}
return { triangles: nt, halfedges: nh };
}
// Lightweight dual-mesh helper wrapping Delaunator output.
// Regions = Voronoi cells, Triangles = Delaunay triangles, Sides = half-edges.
export class SphereMesh {
constructor(triangles, halfedges, numRegions) {
this.triangles = triangles;
this.halfedges = halfedges;
this.numRegions = numRegions;
this.numSides = triangles.length;
this.numTriangles = (triangles.length / 3) | 0;
this._r_s = new Int32Array(numRegions).fill(-1);
for (let s = 0; s < this.numSides; s++) {
const r = triangles[s];
if (this._r_s[r] === -1) this._r_s[r] = s;
}
// Pre-compute flat adjacency lists for r_circulate_r and r_circulate_t.
// Replaces per-call half-edge traversal with cache-friendly array reads.
const adjCount = new Int32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const s0 = this._r_s[r];
if (s0 === -1) continue;
let s = s0;
do {
adjCount[r]++;
s = this._next(this.halfedges[s]);
} while (s !== s0);
}
this._adjOffset = new Int32Array(numRegions + 1);
for (let r = 0; r < numRegions; r++) {
this._adjOffset[r + 1] = this._adjOffset[r] + adjCount[r];
}
const totalAdj = this._adjOffset[numRegions];
this._adjList = new Int32Array(totalAdj); // neighbor regions
this._adjTriList = new Int32Array(totalAdj); // neighbor triangles
for (let r = 0; r < numRegions; r++) {
const s0 = this._r_s[r];
if (s0 === -1) continue;
let s = s0;
let idx = this._adjOffset[r];
do {
this._adjList[idx] = this.s_end_r(s);
this._adjTriList[idx] = this.s_inner_t(s);
idx++;
s = this._next(this.halfedges[s]);
} while (s !== s0);
}
// Public aliases for direct adjacency iteration (avoids r_circulate_r copy overhead)
this.adjOffset = this._adjOffset;
this.adjList = this._adjList;
}
_next(s) { return (s % 3 === 2) ? s - 2 : s + 1; }
s_begin_r(s){ return this.triangles[s]; }
s_end_r(s) { return this.triangles[this._next(s)]; }
s_inner_t(s){ return (s / 3) | 0; }
s_outer_t(s){ return (this.halfedges[s] / 3) | 0; }
r_circulate_r(out, r) {
const start = this._adjOffset[r];
const end = this._adjOffset[r + 1];
const len = end - start;
out.length = len;
for (let i = 0; i < len; i++) out[i] = this._adjList[start + i];
return out;
}
r_circulate_t(out, r) {
const start = this._adjOffset[r];
const end = this._adjOffset[r + 1];
const len = end - start;
out.length = len;
for (let i = 0; i < len; i++) out[i] = this._adjTriList[start + i];
return out;
}
}
// Build sphere — Fibonacci points → Delaunay → close pole.
export function buildSphere(N, jitter, rng) {
const r_xyz = generateFibonacciSphere(N, jitter, rng);
const flat = stereographicProjection(r_xyz, N);
const delaunay = new _Delaunator(flat);
const poleXYZ = new Float32Array(3 * (N + 1));
poleXYZ.set(r_xyz);
poleXYZ[3*N] = 0; poleXYZ[3*N+1] = 0; poleXYZ[3*N+2] = 1;
const closed = addPoleToMesh(N, delaunay.triangles, delaunay.halfedges);
const mesh = new SphereMesh(closed.triangles, closed.halfedges, N + 1);
return { mesh, r_xyz: poleXYZ };
}
// Pre-compute Euclidean distance between each region and its neighbors.
// Indexed by the same adjacency slot as adjList: neighborDist[i] is the
// distance from region r to adjList[i] where adjOffset[r] <= i < adjOffset[r+1].
export function computeNeighborDist(mesh, r_xyz) {
const { adjOffset, adjList } = mesh;
const neighborDist = new Float32Array(adjList.length);
for (let r = 0; r < mesh.numRegions; r++) {
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
for (let i = adjOffset[r]; i < adjOffset[r+1]; i++) {
const nb = adjList[i];
const dx = x - r_xyz[3*nb], dy = y - r_xyz[3*nb+1], dz = z - r_xyz[3*nb+2];
neighborDist[i] = Math.sqrt(dx*dx + dy*dy + dz*dz);
}
}
return neighborDist;
}
// Triangle centres (= Voronoi vertices on the sphere).
export function generateTriangleCenters(mesh, r_xyz) {
const { numTriangles } = mesh;
const t_xyz = new Float32Array(3 * numTriangles);
for (let t = 0; t < numTriangles; t++) {
const s0 = 3 * t;
const a = mesh.s_begin_r(s0),
b = mesh.s_begin_r(s0 + 1),
c = mesh.s_begin_r(s0 + 2);
t_xyz[3*t] = (r_xyz[3*a] + r_xyz[3*b] + r_xyz[3*c]) / 3;
t_xyz[3*t+1] = (r_xyz[3*a+1]+r_xyz[3*b+1]+r_xyz[3*c+1]) / 3;
t_xyz[3*t+2] = (r_xyz[3*a+2]+r_xyz[3*b+2]+r_xyz[3*c+2]) / 3;
}
return t_xyz;
}
+41
View File
@@ -0,0 +1,41 @@
// Shared mutable application state.
// All modules import this same object, so mutations are visible everywhere.
export const state = {
planetMesh: null,
wireMesh: null,
arrowGroup: null,
windArrowGroup: null,
curData: null,
plateColors: {},
_hoverBackup: null,
hoveredPlate: -1,
hoveredRegion: -1,
hoveredKoppen: -1,
_koppenHoverBackup: null,
_mapKoppenHoverBackup: null,
mapMesh: null,
mapFaceToSide: null,
_mapHoverBackup: null,
mapGridMesh: null,
globeGridMesh: null,
gridEnabled: true,
gridSpacing: 15,
mapMode: false,
mapCenterLon: 0,
dragStart: null,
debugLayer: '',
isTouchDevice: ('ontouchstart' in window) || (navigator.maxTouchPoints > 0),
editMode: false,
oceanCurrentArrowGroup: null,
climateComputed: false,
pendingToggles: new Set(),
_pendingBackup: null,
_mapPendingBackup: null,
importedHeightmap: false,
// The painted map's overlay sheet (painted-overlay-view.js): the classified marks and their texture,
// the meshes that show it on the globe and the map, and whether the toggle asks for it.
overlay: null,
overlayVisible: false,
overlayGlobeMesh: null,
overlayMapMesh: null,
};
+273
View File
@@ -0,0 +1,273 @@
// Super plates: groups connected same-type plates into ~20 larger tectonic
// units that move cohesively, producing broad orogenic belts while preserving
// fine-grained detail from individual plate interactions.
/**
* Build super plate assignments from individual plates.
*
* @param {Object} mesh Sphere mesh (adjOffset, adjList, numRegions)
* @param {Int32Array} r_plate Region → plate seed ID
* @param {Set} plateSeeds Set of all plate seed IDs
* @param {Object} plateVec plate seed → { pole: [x,y,z], omega }
* @param {Set} plateIsOcean Set of ocean plate seed IDs
* @param {Object} plateDensity plate seed → density value
* @returns {{ r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates }}
*/
export function buildSuperPlates(mesh, r_plate, plateSeeds, plateVec, plateIsOcean, plateDensity) {
const { numRegions, adjOffset, adjList } = mesh;
const numPlates = plateSeeds.size;
// 1. Count regions per plate (plate areas)
const plateArea = {};
for (const pid of plateSeeds) plateArea[pid] = 0;
for (let r = 0; r < numRegions; r++) {
plateArea[r_plate[r]]++;
}
// 2. Build plate adjacency graph
// plateNeighbors: pid → Set of neighbor plate IDs
const plateNeighbors = {};
for (const pid of plateSeeds) plateNeighbors[pid] = new Set();
for (let r = 0; r < numRegions; r++) {
const myPlate = r_plate[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nbPlate = r_plate[adjList[ni]];
if (nbPlate !== myPlate) {
plateNeighbors[myPlate].add(nbPlate);
}
}
}
// 3. Connected components of same-type plates (BFS on plate graph)
const plateVisited = new Set();
const components = []; // each: array of plate seed IDs
for (const pid of plateSeeds) {
if (plateVisited.has(pid)) continue;
const isOcean = plateIsOcean.has(pid);
const comp = [];
const queue = [pid];
plateVisited.add(pid);
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
comp.push(cur);
for (const nb of plateNeighbors[cur]) {
if (!plateVisited.has(nb) && plateIsOcean.has(nb) === isOcean) {
plateVisited.add(nb);
queue.push(nb);
}
}
}
components.push(comp);
}
// 4. Split large components to reach target count
const target = Math.max(2, Math.min(20, Math.round(numPlates / 4)));
const totalPlates = numPlates;
// plateToSuperPlate: plate seed → super plate ID
const plateToSuperPlate = {};
let nextSuperPlate = 0;
for (const comp of components) {
const k = Math.max(1, Math.round(target * comp.length / totalPlates));
if (k <= 1) {
// Entire component is one super plate
const spId = nextSuperPlate++;
for (const pid of comp) plateToSuperPlate[pid] = spId;
} else {
// Farthest-point seeding on plate graph using area-weighted
// distances, then multi-source Dijkstra assignment.
// Edge cost = sqrt(area of destination plate), so traversing a
// large plate costs more than a small one → more equal-area splits.
const compSet = new Set(comp);
const localAdj = {};
for (const pid of comp) {
localAdj[pid] = [];
for (const nb of plateNeighbors[pid]) {
if (compSet.has(nb)) localAdj[pid].push(nb);
}
}
// Edge weight: sqrt of destination plate area (linear proxy)
const edgeWeight = {};
for (const pid of comp) {
edgeWeight[pid] = Math.sqrt(plateArea[pid] || 1);
}
// Dijkstra from source set — updates dist in-place
const dist = {};
const dijkstraFrom = (startPids) => {
for (const pid of comp) dist[pid] = Infinity;
const visited = new Set();
for (const s of startPids) dist[s] = 0;
for (let iter = 0; iter < comp.length; iter++) {
// Find unvisited node with smallest dist
let cur = -1, minD = Infinity;
for (const pid of comp) {
if (!visited.has(pid) && dist[pid] < minD) {
minD = dist[pid]; cur = pid;
}
}
if (cur === -1) break;
visited.add(cur);
for (const nb of localAdj[cur]) {
const nd = dist[cur] + edgeWeight[nb];
if (nd < dist[nb]) dist[nb] = nd;
}
}
};
// Farthest-point seeding: pick k seeds maximizing minimum weighted distance
const seeds = [comp[0]];
dijkstraFrom([comp[0]]);
for (let si = 1; si < k; si++) {
let farthest = comp[0], maxDist = -1;
for (const pid of comp) {
if (dist[pid] > maxDist) {
maxDist = dist[pid];
farthest = pid;
}
}
seeds.push(farthest);
dijkstraFrom(seeds);
}
// Multi-source Dijkstra from seeds to assign plates to nearest seed
const assignment = {};
for (const pid of comp) assignment[pid] = -1;
const d = {};
for (const pid of comp) d[pid] = Infinity;
const visited = new Set();
for (let si = 0; si < seeds.length; si++) {
const spId = nextSuperPlate + si;
assignment[seeds[si]] = spId;
d[seeds[si]] = 0;
}
for (let iter = 0; iter < comp.length; iter++) {
let cur = -1, minD = Infinity;
for (const pid of comp) {
if (!visited.has(pid) && d[pid] < minD) {
minD = d[pid]; cur = pid;
}
}
if (cur === -1) break;
visited.add(cur);
for (const nb of localAdj[cur]) {
const nd = d[cur] + edgeWeight[nb];
if (nd < d[nb]) {
d[nb] = nd;
assignment[nb] = assignment[cur];
}
}
}
for (const pid of comp) {
plateToSuperPlate[pid] = assignment[pid];
}
nextSuperPlate += seeds.length;
}
}
const numSuperPlates = nextSuperPlate;
// 5. Build r_superPlate: region → super plate ID
const r_superPlate = new Int32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_superPlate[r] = plateToSuperPlate[r_plate[r]];
}
// 6. Compute super plate Euler poles (area-weighted)
// L = sum(area_i * omega_i * pole_i) — resultant angular momentum vector
// omega_avg = sum(area_i * |omega_i|) / sum(area_i) — restores magnitude
const spLx = new Float64Array(numSuperPlates);
const spLy = new Float64Array(numSuperPlates);
const spLz = new Float64Array(numSuperPlates);
const spOmegaSum = new Float64Array(numSuperPlates);
const spAreaSum = new Float64Array(numSuperPlates);
const spLargestPlate = new Array(numSuperPlates).fill(null); // { pid, area } for fallback
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const pv = plateVec[pid];
if (!pv || !pv.pole) continue; // skip synthetic/zero-velocity plates
const area = plateArea[pid];
const omega = pv.omega;
const px = pv.pole[0], py = pv.pole[1], pz = pv.pole[2];
spLx[spId] += area * omega * px;
spLy[spId] += area * omega * py;
spLz[spId] += area * omega * pz;
spOmegaSum[spId] += area * Math.abs(omega);
spAreaSum[spId] += area;
if (!spLargestPlate[spId] || area > spLargestPlate[spId].area) {
spLargestPlate[spId] = { pid, area };
}
}
const superPlateVec = {};
for (let sp = 0; sp < numSuperPlates; sp++) {
const lx = spLx[sp], ly = spLy[sp], lz = spLz[sp];
const lLen = Math.sqrt(lx * lx + ly * ly + lz * lz);
const totalArea = spAreaSum[sp];
if (lLen < 1e-8 || totalArea < 1) {
// Fallback: use largest constituent plate's pole
const largest = spLargestPlate[sp];
if (largest) {
const pv = plateVec[largest.pid];
if (pv && pv.pole) {
superPlateVec[sp] = { pole: [pv.pole[0], pv.pole[1], pv.pole[2]], omega: pv.omega };
continue;
}
}
superPlateVec[sp] = { pole: [0, 1, 0], omega: 0 };
continue;
}
const pole = [lx / lLen, ly / lLen, lz / lLen];
const omega = spOmegaSum[sp] / totalArea;
// Preserve sign from resultant direction
superPlateVec[sp] = { pole, omega };
}
// 7. Super plate ocean/land type: majority area of constituent plates
const superPlateIsOcean = new Set();
const spOceanArea = new Float64Array(numSuperPlates);
const spTotalArea = new Float64Array(numSuperPlates);
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const area = plateArea[pid];
spTotalArea[spId] += area;
if (plateIsOcean.has(pid)) spOceanArea[spId] += area;
}
for (let sp = 0; sp < numSuperPlates; sp++) {
if (spOceanArea[sp] > spTotalArea[sp] * 0.5) {
superPlateIsOcean.add(sp);
}
}
// 8. Super plate density: area-weighted average
const superPlateDensity = {};
const spDensitySum = new Float64Array(numSuperPlates);
const spDensityArea = new Float64Array(numSuperPlates);
for (const pid of plateSeeds) {
const spId = plateToSuperPlate[pid];
const area = plateArea[pid];
const density = plateDensity[pid];
if (density !== undefined) {
spDensitySum[spId] += area * density;
spDensityArea[spId] += area;
}
}
for (let sp = 0; sp < numSuperPlates; sp++) {
superPlateDensity[sp] = spDensityArea[sp] > 0
? spDensitySum[sp] / spDensityArea[sp]
: 2.7; // fallback average crust density
}
return { r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates };
}
+239
View File
@@ -0,0 +1,239 @@
// Temperature simulation: computes per-region surface temperature for summer
// and winter seasons based on ITCZ position, continentality, moisture-dependent
// elevation lapse rate (dry adiabatic 9.3 C/km to moist adiabatic 4.5 C/km),
// ocean current warmth, and precipitation/cloud cover moderation.
// Returns normalized 0-1 values mapped to a fixed -45 to +45 C range.
import { smoothstep } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { smoothField, makeItczLookup } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Diffuse ocean warmth onto nearby coastal land ───────────────────────────
// Uses plate-based continentality so that warmth spreads freely across
// shallow continental-shelf ocean and penetrates further inland. Ocean cells
// on continental plates (shallow seas) inherit warmth from nearby oceanic-
// plate cells first, then the warmth diffuses onto land.
function diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, r_plateContinentality, passes) {
const { adjOffset, adjList, numRegions } = mesh;
const coastal = new Float32Array(numRegions);
// Seed: all ocean cells contribute their warmth directly.
// Continental-shelf ocean cells may have weak/no current warmth;
// they'll pick up values from nearby oceanic-plate neighbors via diffusion.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) {
coastal[r] = r_oceanWarmth ? r_oceanWarmth[r] : 0;
}
}
const tmp = new Float32Array(numRegions);
for (let pass = 0; pass < passes; pass++) {
tmp.set(coastal);
for (let r = 0; r < numRegions; r++) {
// Skip deep-interior continental cells (plate-based)
if (r_plateContinentality && r_plateContinentality[r] >= 0.95) continue;
// Ocean cells also participate in diffusion so continental-shelf
// cells inherit warmth from nearby open-ocean neighbors
let sum = coastal[r];
let count = 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
sum += coastal[adjList[ni]];
count++;
}
tmp[r] = sum / count;
}
coastal.set(tmp);
}
return coastal;
}
// ── Main entry point ────────────────────────────────────────────────────────
/**
* Compute seasonal temperature fields.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind()
* @param {object} oceanResult - output from computeOceanCurrents()
* @param {object} precipResult - output from computePrecipitation()
* @returns {{ r_temperature_summer, r_temperature_winter, _tempTiming }}
*/
export function computeTemperature(mesh, r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset = 0) {
const numRegions = mesh.numRegions;
const timing = [];
const { r_lat, r_lon, r_isLand, r_continentality, r_plateContinentality } = windResult;
// Minimal smoothing: 1 pass just to blend cell-to-cell noise
const smoothPasses = 1;
const T_MIN = -45;
const T_MAX = 45;
const T_RANGE = T_MAX - T_MIN;
const result = {};
// Pre-compute constants shared across seasons
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const oceanWarmthPasses = Math.max(4, Math.round(1400 / avgEdgeKm));
const plateCont = r_plateContinentality || r_continentality;
const seasons = ['summer', 'winter'];
for (const name of seasons) {
const t0 = performance.now();
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
const r_oceanSpeed = oceanResult[`r_ocean_speed_${name}`];
const r_precip = precipResult[`r_precip_${name}`];
const itczLookup = makeItczLookup(windResult.itczLons,
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
// Pre-compute diffused ocean warmth for coastal land influence
// Use plate-based continentality for diffusion so warmth crosses
// continental shelves and reaches further inland
const coastalWarmth = diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, plateCont, oceanWarmthPasses);
const temp = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
const latDeg = lat / DEG;
const isLand = r_isLand[r];
const elev = r_elevation[r];
const cont = r_continentality ? r_continentality[r] : 0;
const pCont = r_plateContinentality ? r_plateContinentality[r] : cont;
// ── 1. Base temperature from thermal equator (ITCZ) ──
// Two curves blended by absolute latitude:
// - T_itcz: based on distance from the actual (land-warped) ITCZ
// - T_flat: based on distance from a fixed ITCZ at ±5° (ocean default)
// Near the tropics the real ITCZ matters; at high latitudes the
// ITCZ position is irrelevant and a stable zonal baseline takes over.
const tropicalHW = 13; // flat plateau half-width (degrees)
const maxDist = 90 - tropicalHW;
// Actual ITCZ curve
const itczLat = itczLookup(lon);
const distItcz = Math.abs(lat - itczLat) / DEG;
const tItcz = Math.max(0, distItcz - tropicalHW) / maxDist;
const T_itcz = 28 - 47 * Math.pow(tItcz, 1.4);
// Flat reference curve (ITCZ at 5° in summer hemisphere)
const flatItczLat = (name === 'summer' ? 5 : -5) * DEG;
const distFlat = Math.abs(lat - flatItczLat) / DEG;
const tFlat = Math.max(0, distFlat - tropicalHW) / maxDist;
const T_flat = 28 - 47 * Math.pow(tFlat, 1.4);
// Blend: ITCZ curve dominates tropics, flat curve dominates poles
const absLatDeg = Math.abs(lat) / DEG;
const blend = smoothstep(45, 90, absLatDeg);
let T = T_itcz * (1 - blend) + T_flat * blend;
// ── 2. Elevation lapse rate ──
// Moisture-dependent: dry air cools at ~9.8 C/km (dry adiabatic),
// saturated air at ~5 C/km (moist adiabatic) due to latent heat
// release. Use precipitation as a moisture proxy to interpolate.
const moisture = r_precip ? r_precip[r] : 0.5;
const lapse = 4.5 + 4.8 * (1 - moisture); // 4.5 C/km (wet) to 9.3 C/km (dry)
if (isLand && elev > 0) {
T -= lapse * elevToHeightKm(elev);
}
// ── 5. Ocean current temperature influence ──
if (!isLand && r_oceanWarmth && r_oceanSpeed) {
// Direct ocean effect: warm/cold currents shift SST
const warmth = r_oceanWarmth[r];
const speed = r_oceanSpeed[r];
T += warmth * Math.min(1, speed * 2) * 16;
} else if (isLand) {
// Coastal land: diffused ocean warmth fades with plate-based
// continentality so the effect reaches further inland and
// crosses continental shelves naturally
const cw = coastalWarmth[r];
if (Math.abs(cw) > 0.001) {
T += cw * (1 - smoothstep(0, 0.95, pCont)) * 20;
}
}
// ── 6. Precipitation / cloud cover moderation ──
if (r_precip) {
const p = r_precip[r];
if (p > 0.5) {
// High precip → clouds → moderate toward latitude baseline
const mod = smoothstep(0.5, 1.0, p) * 0.15;
// Pull toward 0 (moderate extremes)
T *= (1 - mod);
} else if (p < 0.3) {
// Low precip → clear skies → amplify extremes
const amp = smoothstep(0.3, 0.0, p) * 0.15;
T *= (1 + amp);
}
}
// ── 7. Maritime / continental moderation ──
// Ocean has high thermal inertia: coasts and small islands have
// smaller seasonal temperature swings (moderate climate), while
// continental interiors get more extreme summers and winters.
// Compute an annual-mean baseline (ITCZ at equator, no seasonal
// shift) and scale the seasonal deviation by continentality.
{
const distAnn = Math.abs(lat) / DEG; // distance from equator
const tAnn = Math.max(0, distAnn - tropicalHW) / maxDist;
const T_annual = 28 - 47 * Math.pow(tAnn, 1.4); // match new curve
// Apply same moisture-dependent lapse to annual baseline
const T_ann_adj = isLand && elev > 0
? T_annual - lapse * elevToHeightKm(elev)
: T_annual;
const deviation = T - T_ann_adj;
// Latitude-dependent seasonal boost: ITCZ shift alone gives ~5-6°C
// swing; real planets have 15-25°C from direct solar heating.
// Peaks at 55-75° latitude, zero at equator and poles.
const seasonalBoost = 12 * smoothstep(10, 55, distAnn)
* (1 - smoothstep(75, 90, distAnn));
const isLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
const seasonSign = isLocalSummer ? 1 : -1;
const boostedDeviation = deviation + seasonSign * seasonalBoost;
// Maritime: coast damps swing to 50%, deep interior amplifies to 120%
const maritimeFactor = 0.50 + cont * 0.70;
T = T_ann_adj + boostedDeviation * maritimeFactor;
}
T += temperatureOffset;
temp[r] = T;
}
const tCompute = performance.now() - t0;
// ── 7. Laplacian smoothing ──
const tSmooth0 = performance.now();
smoothField(mesh, temp, smoothPasses);
const tSmooth = performance.now() - tSmooth0;
// ── 8. Normalize to 0-1 using fixed range ──
const tNorm0 = performance.now();
for (let r = 0; r < numRegions; r++) {
temp[r] = Math.max(0, Math.min(1, (temp[r] - T_MIN) / T_RANGE));
}
const tNorm = performance.now() - tNorm0;
timing.push({ stage: `Temp: compute (${name})`, ms: tCompute });
timing.push({ stage: `Temp: smooth (${name})`, ms: tSmooth });
timing.push({ stage: `Temp: normalize (${name})`, ms: tNorm });
result[`r_temperature_${name}`] = temp;
}
result._tempTiming = timing;
return result;
}
+357
View File
@@ -0,0 +1,357 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const RIDGE_HEIGHT_VAR_BASE = 0.6;
export const RIDGE_HEIGHT_VAR_SCALE = 0.6;
export const RIDGE_HEIGHT_VAR_FREQ = 2.5;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.35;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.22;
export const ISLAND_PEAK_FLOOR = 0.04;
export const ISLAND_SUBDUCT_MAX = 0.3;
export const MAX_OCEAN_ARC_ELEV = 0.20;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.025;
export const GLACIAL_CONVERGENCE_BONUS = 0.015;
export const GLACIAL_DEPOSIT_AMOUNT = 0.007;
export const GLACIAL_FJORD_CARVE = 0.020;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
+839
View File
@@ -0,0 +1,839 @@
// Terrain quality metrics — computes a numeric scorecard from generation
// output for automated tuning evaluation. Runs inside the web worker
// after generation completes.
//
// Each metric function receives a context object with mesh, arrays, and
// debug layers, and returns a plain object of named scores.
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
/** Average edge length in radians for the current mesh resolution. */
function avgEdgeRad(numRegions) {
return Math.PI / Math.sqrt(numRegions);
}
/** Convert a BFS hop‐distance to approximate km (Earth radius). */
function hopsToKm(hops, numRegions) {
return hops * avgEdgeRad(numRegions) * 6371;
}
/** Percentile of a Float32Array (0–1). Mutates a copy. */
function percentile(arr, p) {
const sorted = Float32Array.from(arr).sort();
const idx = Math.min(Math.floor(p * sorted.length), sorted.length - 1);
return sorted[idx];
}
/** Flood-fill connected components on a boolean mask using mesh adjacency. */
function connectedComponents(mesh, mask) {
const N = mesh.numRegions;
const label = new Int32Array(N).fill(-1);
const components = []; // array of { id, cells: Set }
let nextId = 0;
const queue = [];
for (let r = 0; r < N; r++) {
if (!mask[r] || label[r] >= 0) continue;
const id = nextId++;
const cells = new Set();
label[r] = id;
cells.add(r);
queue.length = 0;
queue.push(r);
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (mask[nb] && label[nb] < 0) {
label[nb] = id;
cells.add(nb);
queue.push(nb);
}
}
}
components.push({ id, cells });
}
return { label, components };
}
/** BFS distance (in hops) from a seed set, with optional barrier mask. */
function bfsDistance(mesh, seeds, barrier) {
const N = mesh.numRegions;
const dist = new Int32Array(N).fill(-1);
const queue = [];
let head = 0;
for (const r of seeds) {
if (barrier && barrier[r]) continue;
dist[r] = 0;
queue.push(r);
}
while (head < queue.length) {
const cur = queue[head++];
const d1 = dist[cur] + 1;
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (dist[nb] >= 0) continue;
if (barrier && barrier[nb]) continue;
dist[nb] = d1;
queue.push(nb);
}
}
return dist;
}
// ────────────────────────────────────────────────────────────────────
// Tier 1 — Artistic Interest
// ────────────────────────────────────────────────────────────────────
/**
* Continental Silhouette Variety
* Measures variance of convex-hull-solidity across continents.
* (Approximated: since we're on a sphere mesh, we use the ratio of
* actual cell count to the BFS-bounding-box area as a proxy for solidity.)
*/
function continentSilhouette(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const isLand = new Uint8Array(N);
for (let r = 0; r < N; r++) if (r_elevation[r] > 0) isLand[r] = 1;
const { components } = connectedComponents(mesh, isLand);
// Filter to continents (>0.5% of land cells)
const totalLand = components.reduce((s, c) => s + c.cells.size, 0);
const minSize = Math.max(10, totalLand * 0.005);
const continents = components.filter(c => c.cells.size >= minSize);
const islands = components.filter(c => c.cells.size < minSize);
// Approximate solidity: area / (pi * (max_bfs_radius)^2)
// We compute max BFS radius from centroid of each continent
const solidities = [];
for (const cont of continents) {
const cellArr = Array.from(cont.cells);
// Find approximate centroid (cell with min max-distance to others via BFS from random sample)
const sample = cellArr[Math.floor(cellArr.length / 2)];
const distFromSample = bfsDistance(mesh, [sample], null);
let maxDist = 0;
for (const r of cellArr) {
if (distFromSample[r] > maxDist) maxDist = distFromSample[r];
}
// Solidity proxy: cellCount / (pi * maxDist^2)
const circleArea = Math.PI * maxDist * maxDist;
const solidity = circleArea > 0 ? Math.min(1, cont.cells.size / circleArea) : 1;
solidities.push(solidity);
}
const mean = solidities.length > 0
? solidities.reduce((a, b) => a + b, 0) / solidities.length : 0;
const variance = solidities.length > 1
? solidities.reduce((s, v) => s + (v - mean) ** 2, 0) / solidities.length : 0;
return {
continent_count: continents.length,
island_count_total: islands.length,
island_cells_total: islands.reduce((s, c) => s + c.cells.size, 0),
continent_solidity_mean: +mean.toFixed(4),
continent_solidity_variance: +variance.toFixed(4),
// Store components for reuse by other metrics
_continents: continents,
_islands: islands,
_isLand: isLand,
};
}
/**
* Elevation Drama
* Relief headroom (p95-p50 of land), plus check that peaks are clustered.
*/
function elevationDrama(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const landElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] > 0) landElev.push(r_elevation[r]);
}
if (landElev.length < 10) {
return { relief_headroom: 0, peak_clustering: 0 };
}
const arr = new Float32Array(landElev);
const p50 = percentile(arr, 0.50);
const p95 = percentile(arr, 0.95);
const relief = p95 - p50;
// Peak clustering: fraction of top-5% cells that have a top-5% neighbor
const threshold = p95;
const isPeak = new Uint8Array(N);
let peakCount = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] >= threshold) { isPeak[r] = 1; peakCount++; }
}
let clustered = 0;
for (let r = 0; r < N; r++) {
if (!isPeak[r]) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (isPeak[mesh.adjList[i]]) { clustered++; break; }
}
}
return {
relief_headroom: +relief.toFixed(4),
peak_clustering: peakCount > 0 ? +(clustered / peakCount).toFixed(4) : 0,
};
}
/**
* Coast Complexity
* Dimensionless roughness: coastline_cell_count / sqrt(land_cell_count).
*/
function coastComplexity(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
let landCount = 0;
let coastCount = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
landCount++;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { coastCount++; break; }
}
}
const index = landCount > 0 ? coastCount / Math.sqrt(landCount) : 0;
return {
coast_complexity_index: +index.toFixed(4),
coastline_cells: coastCount,
land_cells: landCount,
};
}
/**
* Ocean Floor Texture
* Standard deviation of ocean elevations + trench presence.
*/
function oceanFloorTexture(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
const oceanElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) oceanElev.push(r_elevation[r]);
}
if (oceanElev.length < 10) {
return { ocean_elev_stddev: 0, trench_fraction: 0 };
}
const arr = new Float32Array(oceanElev);
const mean = oceanElev.reduce((a, b) => a + b, 0) / oceanElev.length;
const variance = oceanElev.reduce((s, v) => s + (v - mean) ** 2, 0) / oceanElev.length;
const stddev = Math.sqrt(variance);
// Trench fraction: cells below p2 (expect distinct spike)
const p02 = percentile(arr, 0.02);
const p05 = percentile(arr, 0.05);
const trenchGap = p05 - p02; // distance between p2 and p5 — large = distinct trench tail
return {
ocean_elev_stddev: +stddev.toFixed(5),
ocean_trench_gap: +trenchGap.toFixed(5),
};
}
/**
* Flat Land on Ocean Plates
* Land cells assigned to ocean plates that lack volcanic/tectonic relief.
* These should be mountainous/volcanic, not flat plains.
*/
function flatOceanPlateLand(ctx) {
const { mesh, r_elevation, r_plate, plateIsOcean, debugLayers } = ctx;
const N = mesh.numRegions;
const oceanPlateSet = new Set(plateIsOcean);
let oceanPlateLandCount = 0;
let flatOceanPlateLandCount = 0;
const FLAT_THRESHOLD = 0.21; // below ~50m (quartic elev mapping) — barely above sea level
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
if (!oceanPlateSet.has(r_plate[r])) continue;
// This is land on an ocean plate
oceanPlateLandCount++;
if (r_elevation[r] < FLAT_THRESHOLD) {
flatOceanPlateLandCount++;
}
}
return {
ocean_plate_land_cells: oceanPlateLandCount,
flat_ocean_plate_land_cells: flatOceanPlateLandCount,
flat_ocean_plate_land_fraction: oceanPlateLandCount > 0
? +(flatOceanPlateLandCount / oceanPlateLandCount).toFixed(4) : 0,
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 2 — Scientific Plausibility
// ────────────────────────────────────────────────────────────────────
/**
* Bimodal Hypsometry
* Fit two-Gaussian model to elevation histogram. Measure trough depth
* and mode positions.
*/
function bimodalHypsometry(ctx) {
const { r_elevation } = ctx;
const N = r_elevation.length;
const BINS = 200;
const minE = -0.5, maxE = 0.8;
const binW = (maxE - minE) / BINS;
const hist = new Float64Array(BINS);
for (let r = 0; r < N; r++) {
const b = Math.floor((r_elevation[r] - minE) / binW);
if (b >= 0 && b < BINS) hist[b]++;
}
// Normalize
const total = hist.reduce((a, b) => a + b, 0);
for (let i = 0; i < BINS; i++) hist[i] /= total;
// Find two peaks: one below sea level (ocean), one above (land)
const seaBin = Math.floor((0 - minE) / binW);
let oceanPeak = 0, oceanPeakVal = 0;
for (let i = 0; i < seaBin; i++) {
if (hist[i] > oceanPeakVal) { oceanPeakVal = hist[i]; oceanPeak = i; }
}
let landPeak = seaBin, landPeakVal = 0;
for (let i = seaBin; i < BINS; i++) {
if (hist[i] > landPeakVal) { landPeakVal = hist[i]; landPeak = i; }
}
// Trough: minimum between the two peaks
let troughVal = Infinity;
for (let i = oceanPeak; i <= landPeak; i++) {
if (hist[i] < troughVal) troughVal = hist[i];
}
const peakAvg = (oceanPeakVal + landPeakVal) / 2;
const troughDepth = peakAvg > 0 ? 1 - troughVal / peakAvg : 0;
return {
ocean_mode_elev: +(minE + (oceanPeak + 0.5) * binW).toFixed(4),
land_mode_elev: +(minE + (landPeak + 0.5) * binW).toFixed(4),
hypsometry_trough_depth: +troughDepth.toFixed(4),
};
}
/**
* Mountain–Boundary Spatial Correlation
* Top 5% land cells should cluster near actual plate boundaries
* (cells where r_plate differs from a neighbor), not the propagated
* stress field which extends far inland.
*/
function mountainBoundaryCorrelation(ctx) {
const { mesh, r_elevation, r_plate } = ctx;
const N = mesh.numRegions;
// Find actual plate boundary cells (where r_plate differs from a neighbor)
const boundaryCells = [];
for (let r = 0; r < N; r++) {
const pid = r_plate[r];
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_plate[mesh.adjList[i]] !== pid) {
boundaryCells.push(r);
break;
}
}
}
if (boundaryCells.length === 0) {
return { mountain_boundary_ratio: 1.0 };
}
const distToBoundary = bfsDistance(mesh, boundaryCells, null);
// Land cells only
const landDists = [];
const mountainDists = [];
const landElev = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
landElev.push(r_elevation[r]);
}
const p95 = percentile(new Float32Array(landElev), 0.95);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0 || distToBoundary[r] < 0) continue;
landDists.push(distToBoundary[r]);
if (r_elevation[r] >= p95) mountainDists.push(distToBoundary[r]);
}
const medianLand = landDists.length > 0
? percentile(new Float32Array(landDists), 0.5) : 0;
const medianMountain = mountainDists.length > 0
? percentile(new Float32Array(mountainDists), 0.5) : 0;
const ratio = medianLand > 0 ? medianMountain / medianLand : 1;
return {
mountain_boundary_ratio: +ratio.toFixed(4),
mountain_boundary_median_hops: +medianMountain.toFixed(1),
all_land_boundary_median_hops: +medianLand.toFixed(1),
};
}
/**
* Orogenic Power vs Elevation Correlation
* The tectonic signal should survive post-processing.
*/
function orogenicCorrelation(ctx) {
const { r_elevation, debugLayers } = ctx;
if (!debugLayers || !debugLayers.orogenicPower) {
return { orogenic_elev_correlation: null };
}
const op = debugLayers.orogenicPower;
const N = r_elevation.length;
// Pearson correlation on land cells
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const x = op[r], y = r_elevation[r];
sumX += x; sumY += y; sumXY += x * y;
sumX2 += x * x; sumY2 += y * y;
n++;
}
if (n < 10) return { orogenic_elev_correlation: 0 };
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
return {
orogenic_elev_correlation: +corr.toFixed(4),
};
}
/**
* Erosion–Slope Coherence
* Hydraulic erosion should preferentially hit high-slope cells.
*/
function erosionSlopeCoherence(ctx) {
const { mesh, r_elevation, r_xyz, debugLayers } = ctx;
if (!debugLayers || !debugLayers.erosionDelta) {
return { erosion_slope_correlation: null };
}
const delta = debugLayers.erosionDelta;
const N = mesh.numRegions;
// Compute slope per land cell (max elevation difference to neighbors)
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
let maxSlope = 0;
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
const dh = Math.abs(r_elevation[r] - r_elevation[nb]);
if (dh > maxSlope) maxSlope = dh;
}
// Erosion delta should be negative (erosion) where slope is high
const x = maxSlope;
const y = -delta[r]; // positive = more erosion
sumX += x; sumY += y; sumXY += x * y;
sumX2 += x * x; sumY2 += y * y;
n++;
}
if (n < 10) return { erosion_slope_correlation: 0 };
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
return {
erosion_slope_correlation: +corr.toFixed(4),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 1+ — Island Metrics
// ────────────────────────────────────────────────────────────────────
/**
* Island analysis: count, size distribution, arc association, elevation profile.
*/
function islandMetrics(ctx, silhouetteResult) {
const { mesh, r_elevation, r_stress, r_plate, plateIsOcean } = ctx;
const N = mesh.numRegions;
const islands = silhouetteResult._islands;
const oceanPlateSet = new Set(plateIsOcean);
if (!islands || islands.length === 0) {
return {
island_count: 0,
island_size_max: 0,
island_mean_elevation: 0,
island_arc_association: 0,
};
}
// Size distribution
const sizes = islands.map(c => c.cells.size).sort((a, b) => b - a);
// Distance to high-stress cells (proxy for convergent boundaries)
const stressCells = [];
for (let r = 0; r < N; r++) {
if (r_stress[r] > 0.2) stressCells.push(r);
}
const distToStress = stressCells.length > 0
? bfsDistance(mesh, stressCells, null) : null;
// Per-island analysis
let arcAssocCount = 0;
let totalMeanElev = 0;
const ARC_DIST_THRESHOLD = Math.round(800 / hopsToKm(1, N)); // ~800km in hops
for (const island of islands) {
// Mean elevation
let elevSum = 0;
let minDistToStress = Infinity;
for (const r of island.cells) {
elevSum += r_elevation[r];
if (distToStress && distToStress[r] >= 0 && distToStress[r] < minDistToStress) {
minDistToStress = distToStress[r];
}
}
totalMeanElev += elevSum / island.cells.size;
if (minDistToStress <= ARC_DIST_THRESHOLD) arcAssocCount++;
}
return {
island_count: islands.length,
island_size_max: sizes[0],
island_size_median: sizes[Math.floor(sizes.length / 2)],
island_mean_elevation: +(totalMeanElev / islands.length).toFixed(4),
island_arc_association: +(arcAssocCount / islands.length).toFixed(4),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 1+ — Coastal Lowland & Shelf Metrics
// ────────────────────────────────────────────────────────────────────
/**
* Near-sea-level land fraction and elevation band distribution.
*/
function coastalLowlandIndex(ctx) {
const { r_elevation } = ctx;
const N = r_elevation.length;
// Elevation bands in normalized units (roughly: 0.01 ≈ 80m)
// 0-50m ≈ 0-0.00625, 50-200m ≈ 0.00625-0.025, 200-500m ≈ 0.025-0.0625, 500m+ ≈ 0.0625+
// But the exact scale depends on the planet's max elevation.
// Use relative bands: bottom 5%, 5-20%, 20-50%, 50%+ of land elevation range.
const landElevs = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] > 0) landElevs.push(r_elevation[r]);
}
if (landElevs.length < 10) {
return { lowland_fraction: 0, midland_fraction: 0, highland_fraction: 0 };
}
const sorted = new Float32Array(landElevs).sort();
const p10 = sorted[Math.floor(sorted.length * 0.10)];
const p30 = sorted[Math.floor(sorted.length * 0.30)];
// Thresholds from elevToHeightKm() quartic mapping:
// 50m (0.05km) → elev 0.21, 200m → 0.31, 500m → 0.40
let band0_50 = 0, band50_200 = 0, band200_500 = 0, band500plus = 0;
for (const e of landElevs) {
if (e < 0.21) band0_50++;
else if (e < 0.31) band50_200++;
else if (e < 0.40) band200_500++;
else band500plus++;
}
const total = landElevs.length;
return {
land_band_0_50m_frac: +(band0_50 / total).toFixed(4),
land_band_50_200m_frac: +(band50_200 / total).toFixed(4),
land_band_200_500m_frac: +(band200_500 / total).toFixed(4),
land_band_500m_plus_frac: +(band500plus / total).toFixed(4),
coastal_lowland_fraction: +((band0_50 + band50_200) / total).toFixed(4),
};
}
/**
* Shelf Width — distance from coast to -200m depth.
* Separately for active vs passive margins (using stress as proxy).
*/
function shelfWidth(ctx) {
const { mesh, r_elevation, r_stress } = ctx;
const N = mesh.numRegions;
// Find coastline cells (land adjacent to ocean)
const coastCells = [];
const coastIsActive = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) continue;
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
let isCoast = false;
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { isCoast = true; break; }
}
if (isCoast) {
coastCells.push(r);
// Active margin: near high stress
coastIsActive.push(r_stress[r] > 0.15);
}
}
// For each coast cell, walk outward into ocean measuring:
// 1) Distance to shelf break (elevation crossing below p25 of ocean depth)
// 2) First-ocean-cell elevation (diagnostic)
//
// We use a relative shelf break threshold based on actual ocean elevation
// distribution rather than a fixed value, since the elevation scale varies.
const oceanElevs = [];
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) oceanElevs.push(r_elevation[r]);
}
// Shelf break = 25th percentile of ocean depth (shallow quarter = shelf)
const SHELF_BREAK_DEPTH = oceanElevs.length > 0
? percentile(new Float32Array(oceanElevs), 0.25) : -0.1;
const activeWidths = [];
const passiveWidths = [];
let firstOceanElevSum = 0;
let firstOceanCount = 0;
// Sample coastline to keep computation bounded (every 3rd coast cell)
for (let ci = 0; ci < coastCells.length; ci += 3) {
const start = coastCells[ci];
const visited = new Set();
visited.add(start);
let frontier = [start];
let dist = 0;
let found = false;
const MAX_DIST = 80;
while (frontier.length > 0 && dist < MAX_DIST) {
dist++;
const next = [];
for (const cur of frontier) {
const off0 = mesh.adjOffset[cur];
const off1 = mesh.adjOffset[cur + 1];
for (let i = off0; i < off1; i++) {
const nb = mesh.adjList[i];
if (visited.has(nb)) continue;
visited.add(nb);
if (r_elevation[nb] > 0) continue; // stay in ocean
if (dist === 1) {
firstOceanElevSum += r_elevation[nb];
firstOceanCount++;
}
if (r_elevation[nb] <= SHELF_BREAK_DEPTH) {
if (coastIsActive[ci]) activeWidths.push(dist);
else passiveWidths.push(dist);
found = true;
break;
}
next.push(nb);
}
if (found) break;
}
if (found) break;
frontier = next;
}
}
const medianActive = activeWidths.length > 0
? percentile(new Float32Array(activeWidths), 0.5) : 0;
const medianPassive = passiveWidths.length > 0
? percentile(new Float32Array(passiveWidths), 0.5) : 0;
return {
shelf_break_threshold: +SHELF_BREAK_DEPTH.toFixed(4),
shelf_width_active_hops: +medianActive.toFixed(1),
shelf_width_passive_hops: +medianPassive.toFixed(1),
shelf_width_active_km: +hopsToKm(medianActive, N).toFixed(0),
shelf_width_passive_km: +hopsToKm(medianPassive, N).toFixed(0),
shelf_passive_wider_than_active: medianPassive > medianActive,
shelf_measurements_active: activeWidths.length,
shelf_measurements_passive: passiveWidths.length,
shelf_first_ocean_cell_mean_elev: firstOceanCount > 0
? +(firstOceanElevSum / firstOceanCount).toFixed(5) : 0,
};
}
/**
* Continental Interior Elevation Gradient
* How steeply land rises from coastline inland.
*/
function interiorGradient(ctx) {
const { mesh, r_elevation } = ctx;
const N = mesh.numRegions;
// Find coastal land cells
const coastSeeds = [];
const isOcean = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0) { isOcean[r] = 1; continue; }
const off0 = mesh.adjOffset[r];
const off1 = mesh.adjOffset[r + 1];
for (let i = off0; i < off1; i++) {
if (r_elevation[mesh.adjList[i]] <= 0) { coastSeeds.push(r); break; }
}
}
// BFS distance from coast (land only)
const distFromCoast = bfsDistance(mesh, coastSeeds, isOcean);
// Bin by distance, compute mean elevation at each distance band
const MAX_BAND = 30; // ~30 hops inland
const bandElev = new Float64Array(MAX_BAND);
const bandCount = new Int32Array(MAX_BAND);
for (let r = 0; r < N; r++) {
if (r_elevation[r] <= 0 || distFromCoast[r] < 0) continue;
const band = Math.min(distFromCoast[r], MAX_BAND - 1);
bandElev[band] += r_elevation[r];
bandCount[band]++;
}
// Compute gradient from band 0 to band 5 (first ~5 hops = near-coast)
const nearCoastElev = bandCount[0] > 0 ? bandElev[0] / bandCount[0] : 0;
let midBand = 5;
while (midBand > 1 && bandCount[midBand] === 0) midBand--;
const midElev = bandCount[midBand] > 0 ? bandElev[midBand] / bandCount[midBand] : 0;
const nearCoastGradient = midBand > 0 ? (midElev - nearCoastElev) / midBand : 0;
// Gradient per km
const hopKm = hopsToKm(1, N);
const gradientPerKm = hopKm > 0 ? nearCoastGradient / hopKm : 0;
return {
near_coast_mean_elev: +nearCoastElev.toFixed(5),
interior_gradient_per_hop: +nearCoastGradient.toFixed(5),
interior_gradient_per_km: +gradientPerKm.toFixed(6),
};
}
// ────────────────────────────────────────────────────────────────────
// Tier 3 — Layer Coherence
// ────────────────────────────────────────────────────────────────────
/**
* Hotspot Contribution Distinctiveness
* Kurtosis of hotspot layer — should be high (sparse, intense).
*/
function hotspotDistinctiveness(ctx) {
const { debugLayers } = ctx;
if (!debugLayers || !debugLayers.hotspot) {
return { hotspot_kurtosis: null };
}
const hs = debugLayers.hotspot;
const N = hs.length;
let sum = 0, n = 0;
for (let i = 0; i < N; i++) {
if (hs[i] !== 0) { sum += hs[i]; n++; }
}
if (n < 10) return { hotspot_kurtosis: 0, hotspot_active_fraction: 0 };
const mean = sum / n;
let m2 = 0, m4 = 0;
for (let i = 0; i < N; i++) {
if (hs[i] === 0) continue;
const d = hs[i] - mean;
m2 += d * d;
m4 += d * d * d * d;
}
m2 /= n; m4 /= n;
const kurtosis = m2 > 0 ? m4 / (m2 * m2) - 3 : 0; // excess kurtosis
return {
hotspot_kurtosis: +kurtosis.toFixed(2),
hotspot_active_fraction: +(n / N).toFixed(4),
};
}
/**
* Back-Arc and Fold Ridge Presence
* Verify these features have nonzero signal where expected.
*/
function backArcFoldPresence(ctx) {
const { debugLayers } = ctx;
const result = {};
if (debugLayers && debugLayers.backArc) {
const ba = debugLayers.backArc;
let nonzero = 0, sum = 0;
for (let i = 0; i < ba.length; i++) {
if (ba[i] !== 0) { nonzero++; sum += Math.abs(ba[i]); }
}
result.back_arc_active_cells = nonzero;
result.back_arc_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
}
if (debugLayers && debugLayers.foldRidge) {
const fr = debugLayers.foldRidge;
let nonzero = 0, sum = 0;
for (let i = 0; i < fr.length; i++) {
if (fr[i] !== 0) { nonzero++; sum += Math.abs(fr[i]); }
}
result.fold_ridge_active_cells = nonzero;
result.fold_ridge_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
}
return result;
}
// ────────────────────────────────────────────────────────────────────
// Main entry point
// ────────────────────────────────────────────────────────────────────
/**
* Compute all terrain quality metrics.
*
* @param {Object} ctx — context with:
* mesh, r_xyz, r_elevation, r_plate, plateIsOcean (Array or Set),
* r_stress, debugLayers, prePostElev (optional)
* @returns {Object} flat scorecard of named metrics
*/
export function computeTerrainMetrics(ctx) {
// Normalize plateIsOcean to an iterable of seed region IDs
if (ctx.plateIsOcean instanceof Set) {
ctx.plateIsOcean = Array.from(ctx.plateIsOcean);
}
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
const silhouette = continentSilhouette(ctx);
const drama = elevationDrama(ctx);
const coast = coastComplexity(ctx);
const oceanFloor = oceanFloorTexture(ctx);
const flatOcean = flatOceanPlateLand(ctx);
const hyps = bimodalHypsometry(ctx);
const mtnBoundary = mountainBoundaryCorrelation(ctx);
const orogenic = orogenicCorrelation(ctx);
const erosion = erosionSlopeCoherence(ctx);
const islands = islandMetrics(ctx, silhouette);
const lowland = coastalLowlandIndex(ctx);
const shelf = shelfWidth(ctx);
const gradient = interiorGradient(ctx);
const hotspot = hotspotDistinctiveness(ctx);
const backArcFold = backArcFoldPresence(ctx);
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
// Flatten into single scorecard, dropping internal fields
const scorecard = {};
for (const partial of [silhouette, drama, coast, oceanFloor, flatOcean, hyps,
mtnBoundary, orogenic, erosion, islands, lowland,
shelf, gradient, hotspot, backArcFold]) {
for (const [k, v] of Object.entries(partial)) {
if (!k.startsWith('_')) scorecard[k] = v;
}
}
scorecard._metrics_ms = +elapsed.toFixed(1);
return scorecard;
}
+840
View File
@@ -0,0 +1,840 @@
// Terrain post-processing: domain warping, bilateral smoothing, and
// flow-based erosion. Runs after elevation assignment to deform terrain
// for organic shapes, soften harsh boundaries, and carve natural
// drainage patterns.
import { SimplexNoise } from './simplex-noise.js';
import {
FLOOD_NOISE_AMP, FLOOD_CARVE_RADIUS_FRAC,
WARP_FREQ, WARP_OCTAVES, WARP_MAX_AMP_MULT,
WARP_BIAS_BASE, WARP_BIAS_STRENGTH_SCALE, WARP_HOTSPOT_DAMPEN,
SMOOTH_EDGE_SENSITIVITY,
GLACIAL_LAT_DIVISOR, GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH,
GLACIAL_ELEV_FACTOR_SCALE, GLACIAL_ELEV_FACTOR_LAT_BASE, GLACIAL_ELEV_FACTOR_LAT_SCALE,
GLACIAL_CARVE_RATE, GLACIAL_CONVERGENCE_BONUS, GLACIAL_DEPOSIT_AMOUNT,
GLACIAL_FJORD_CARVE, GLACIAL_FLOW_THRESHOLD, GLACIAL_FJORD_THRESHOLD,
GLACIAL_WIDENING_FRAC, GLACIAL_TERMINUS_RATIO, GLACIAL_FJORD_ICE_MIN,
GLACIAL_POST_SMOOTH, GLACIAL_MID_FLOOD_FRAC, GLACIAL_MID_FLOOD_CARVE,
GLACIAL_INITIAL_CARVE,
HYDRAULIC_DEPOSIT_FRAC, HYDRAULIC_SLOPE_SENSITIVITY,
THERMAL_TRANSFER_FRAC,
RIDGE_SHARPEN_CAP, VALLEY_DEEPEN_FACTOR, VALLEY_FLOOR_FRAC, VALLEY_FLOOR_MIN,
} from './terrain-config.js';
/**
* Inline binary min-heap keyed on an external Float32Array of priorities.
* Each cell is pushed/popped exactly once — no decrease-key needed.
*/
class MinHeap {
constructor(keyArray) {
this._key = keyArray;
this._data = [];
}
get size() { return this._data.length; }
push(cell) {
this._data.push(cell);
let i = this._data.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this._key[this._data[i]] >= this._key[this._data[parent]]) break;
const tmp = this._data[i]; this._data[i] = this._data[parent]; this._data[parent] = tmp;
i = parent;
}
}
pop() {
const top = this._data[0];
const last = this._data.pop();
if (this._data.length > 0) {
this._data[0] = last;
let i = 0;
const n = this._data.length;
while (true) {
let smallest = i;
const l = 2 * i + 1, r = 2 * i + 2;
if (l < n && this._key[this._data[l]] < this._key[this._data[smallest]]) smallest = l;
if (r < n && this._key[this._data[r]] < this._key[this._data[smallest]]) smallest = r;
if (smallest === i) break;
const tmp = this._data[i]; this._data[i] = this._data[smallest]; this._data[smallest] = tmp;
i = smallest;
}
}
return top;
}
}
/**
* Priority-flood pit resolution with canyon carving.
* Ensures every land cell has a monotonically descending drainage path to
* the ocean, favoring carving through spill points over filling pit floors.
*
* Pass 1: Standard Barnes et al. priority-flood fill from ocean-adjacent
* land cells inward → surface[], drainTo[]
* Pass 2: Redistribute fill deficit as carving along spill paths
* Pass 3: Enforce monotonic drainage with epsilon gradient
*/
function priorityFloodCarve(mesh, r_elevation, r_isOcean, carveStrength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const EPS = 1e-7;
// --- Identify the main ocean body via BFS ---
// Find connected ocean components and mark only the largest as "open ocean"
const oceanLabel = new Int32Array(N).fill(-1);
const componentSizes = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] || oceanLabel[r] >= 0) continue;
const label = componentSizes.length;
let size = 0;
const queue = [r];
oceanLabel[r] = label;
while (queue.length > 0) {
const cur = queue.pop();
size++;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (r_isOcean[nb] && oceanLabel[nb] < 0) {
oceanLabel[nb] = label;
queue.push(nb);
}
}
}
componentSizes.push(size);
}
let mainOceanLabel = 0;
for (let i = 1; i < componentSizes.length; i++) {
if (componentSizes[i] > componentSizes[mainOceanLabel]) mainOceanLabel = i;
}
const isOpenOcean = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] && oceanLabel[r] === mainOceanLabel) isOpenOcean[r] = 1;
}
// --- Deterministic hash for noise perturbation (meander paths) ---
// Small noise on priority keys makes the flood front irregular,
// producing winding drainage paths instead of straight lines
const NOISE_AMP = FLOOD_NOISE_AMP; // amplitude relative to typical elevation range
function cellNoise(r) {
let h = (r * 2654435761) >>> 0; // Knuth multiplicative hash
h = ((h >>> 16) ^ h) * 0x45d9f3b >>> 0;
h = ((h >>> 16) ^ h) >>> 0;
return (h / 0xffffffff) * NOISE_AMP;
}
const surface = new Float32Array(r_elevation);
const drainTo = new Int32Array(N).fill(-1);
const visited = new Uint8Array(N);
// Priority key array — elevation + small noise for meandering
const key = new Float32Array(N);
for (let r = 0; r < N; r++) key[r] = r_elevation[r] + cellNoise(r);
const heap = new MinHeap(key);
// Seed: land cells adjacent to the main open ocean (not inland seas)
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) { visited[r] = 1; continue; }
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (isOpenOcean[adjList[i]]) {
visited[r] = 1;
drainTo[r] = adjList[i]; // drains to open ocean neighbor
heap.push(r);
break;
}
}
}
// Pass 1: priority-flood fill (noise-perturbed for winding paths)
while (heap.size > 0) {
const r = heap.pop();
const surfR = surface[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (visited[nb]) continue;
visited[nb] = 1;
drainTo[nb] = r;
if (r_elevation[nb] < surfR + EPS) {
// Pit detected — fill to current surface + epsilon
surface[nb] = surfR + EPS;
key[nb] = surface[nb] + cellNoise(nb);
}
// else: neighbor drains naturally, surface[nb] already = r_elevation[nb]
heap.push(nb);
}
}
// Pass 2: carve-bias redistribution
// For each filled cell, trace path back to ocean, find the peak (spill point),
// and redistribute deficit as carving near the peak
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const deficit = surface[r] - r_elevation[r];
if (deficit <= EPS) continue;
// Trace drainTo path toward ocean, collect path and find peak
const path = [];
let peakIdx = -1;
let peakElev = -Infinity;
let cur = r;
while (cur >= 0 && !r_isOcean[cur]) {
path.push(cur);
if (r_elevation[cur] > peakElev) {
peakElev = r_elevation[cur];
peakIdx = path.length - 1;
}
cur = drainTo[cur];
}
if (peakIdx < 0 || path.length === 0) continue;
// Carve: lower cells near the peak using a triangle kernel
const carveAmount = deficit * carveStrength;
const radius = Math.max(3, Math.ceil(path.length * FLOOD_CARVE_RADIUS_FRAC));
const startIdx = Math.max(0, peakIdx - radius);
const endIdx = Math.min(path.length - 1, peakIdx + radius);
let kernelSum = 0;
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
kernelSum += 1 - dist / (radius + 1);
}
if (kernelSum > 0) {
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
const weight = (1 - dist / (radius + 1)) / kernelSum;
r_elevation[path[k]] -= carveAmount * weight;
if (r_elevation[path[k]] < 0) r_elevation[path[k]] = 0;
}
}
// Fill: raise the pit floor by the remaining fraction
const fillAmount = deficit * (1 - carveStrength);
r_elevation[r] += fillAmount;
}
// Pass 3: enforce monotonic drainage along drainTo paths
// Process cells in order of ascending surface (re-sort by surface)
const order = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) order.push(r);
}
order.sort((a, b) => surface[a] - surface[b]);
for (let i = 0; i < order.length; i++) {
const r = order[i];
const target = drainTo[r];
if (target < 0) continue;
const targetElev = r_isOcean[target] ? 0 : r_elevation[target];
if (r_elevation[r] <= targetElev) {
r_elevation[r] = targetElev + EPS;
}
}
}
/**
* Domain warping — displaces each region's elevation lookup by FBM simplex
* noise in the tangent plane, producing organic, squiggly coastlines and
* mountain ridges. Scale-invariant: noise is evaluated in 3D coordinate
* space and amplitude is in radians (physical distance on the sphere).
*
* For each region:
* 1. Compute a tangent-plane frame (east/north) at its position on the unit sphere
* 2. Use FBM simplex noise (4 octaves, frequency 6) to generate two
* displacement values in the tangent plane
* 3. Displace the region's 3D position along the tangent frame by the noise
* offsets, then re-project onto the unit sphere
* 4. Walk the mesh graph (greedy nearest-neighbor) from the original region
* toward the displaced point to find the closest region
* 5. Copy that source region's elevation to the output
*/
export function warpTerrain(mesh, r_elevation, r_xyz, seed, strength, r_hotspot) {
if (strength <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const noise = new SimplexNoise(seed + 9999);
const freq = WARP_FREQ;
const octaves = WARP_OCTAVES;
const maxAmp = WARP_MAX_AMP_MULT * strength; // radians (~760 km at Earth scale when strength=1)
const out = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
// Tangent frame: east = normalize(cross(up, pos)), north = cross(pos, east)
let ex = -pz, ey = 0, ez = px; // cross([0,1,0], pos) = [-pz, 0, px]
const elen = Math.sqrt(ex * ex + ez * ez);
if (elen > 1e-10) { ex /= elen; ez /= elen; }
else { ex = 1; ez = 0; } // poles
const nx = py * ez;
const ny = pz * ex - px * ez;
const nz = -py * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
const nnx = nx / nlen, nny = ny / nlen, nnz = nz / nlen;
// FBM noise → two displacement values
const d1 = noise.fbm(px * freq, py * freq, pz * freq, octaves) * maxAmp;
const d2 = noise.fbm(px * freq + 31.7, py * freq + 47.3, pz * freq + 19.1, octaves) * maxAmp;
// Displace position along tangent frame and re-project onto unit sphere
let wx = px + ex * d1 + nnx * d2;
let wy = py + ey * d1 + nny * d2;
let wz = pz + ez * d1 + nnz * d2;
const wlen = Math.sqrt(wx * wx + wy * wy + wz * wz) || 1;
wx /= wlen; wy /= wlen; wz /= wlen;
// Greedy mesh walk from r toward the displaced point
let cur = r;
let bestDot = wx * px + wy * py + wz * pz;
for (;;) {
let moved = false;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
const dot = wx * r_xyz[3 * nb] + wy * r_xyz[3 * nb + 1] + wz * r_xyz[3 * nb + 2];
if (dot > bestDot) {
bestDot = dot;
cur = nb;
moved = true;
}
}
if (!moved) break;
}
out[r] = r_elevation[cur];
}
// Weighted max: pick whichever is larger, biased by strength
// At strength≈0 → 75% original, at strength=1 → 75% warped
// Dampen near hotspots so volcanic peaks keep their sculpted shape
const warpBias = WARP_BIAS_BASE + WARP_BIAS_STRENGTH_SCALE * strength;
for (let r = 0; r < N; r++) {
const orig = r_elevation[r];
const warped = out[r];
let bias = warpBias;
if (r_hotspot) {
const hotFrac = Math.min(1, Math.abs(r_hotspot[r]) / (Math.abs(orig) || 1));
bias *= 1 - WARP_HOTSPOT_DAMPEN * hotFrac;
}
if (warped > orig) {
r_elevation[r] = orig + (warped - orig) * bias;
} else {
r_elevation[r] = warped + (orig - warped) * (1 - bias);
}
}
}
/**
* Bilateral-weighted Laplacian smoothing.
* Neighbors with similar elevation receive more weight, preserving ridges
* and trenches while blending the banded artefacts from BFS distance fields.
* Coastline cells (land adjacent to ocean) are locked to prevent drift.
*/
export function smoothElevation(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const tmp = new Float32Array(N);
const { adjOffset, adjList } = mesh;
// Pre-compute coastline lock: land cells adjacent to at least one ocean cell
const locked = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { locked[r] = 1; break; }
}
}
for (let iter = 0; iter < iterations; iter++) {
for (let r = 0; r < N; r++) {
if (locked[r]) { tmp[r] = r_elevation[r]; continue; }
const h = r_elevation[r];
let wSum = 0, hSum = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nh = r_elevation[adjList[i]];
const diff = Math.abs(nh - h);
const w = 1 / (1 + diff * SMOOTH_EDGE_SENSITIVITY);
wSum += w;
hSum += nh * w;
}
if (wSum > 0) {
const avg = hSum / wSum;
tmp[r] = h + (avg - h) * strength;
} else {
tmp[r] = h;
}
}
// Copy back
for (let r = 0; r < N; r++) r_elevation[r] = tmp[r];
}
}
/**
* Combined iterative erosion — interleaves hydraulic (stream power) and
* thermal (talus-angle) passes so they interact each iteration.
*
* Hydraulic: Braun-Willett implicit stream power. Rebuilds drainage graph
* each iteration so carved valleys attract more flow.
*
* Thermal: Slope-driven material transport. Redistributes material from
* steep slopes to lower neighbors using a simultaneous delta buffer.
*
* Each iteration runs one hydraulic step then one thermal step (if their
* respective iteration counts haven't been exhausted).
*/
export function erodeComposite(mesh, r_elevation, r_xyz, r_isOcean,
hIters, K, m, dt,
tIters, talusSlope, kThermal,
gIters, glacialStrength,
neighborDist)
{
gIters = gIters || 0;
glacialStrength = glacialStrength || 0;
const totalIters = Math.max(hIters, tIters, gIters);
if (totalIters <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Collect land cell indices
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
if (landCount === 0) return;
// Shared buffers
const drainTarget = new Int32Array(N);
const cellDist = new Float32Array(N);
const flow = new Float32Array(N);
const delta = new Float32Array(N);
// Priority-flood pit resolution: ensure every land cell drains to ocean
// before hydraulic erosion begins. Carves canyons through spill points.
if (hIters > 0) {
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_INITIAL_CARVE);
}
// ---- Glacial precomputation (once — index is position-based) ----
let glacIdx = null;
let iceTarget = null;
let iceFlow = null;
let numIceUpstream = null;
if (gIters > 0 && glacialStrength > 0) {
function smoothstep(x, edge0, edge1) {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
glacIdx = new Float32Array(N);
// At strength=1 glaciation starts at ~50° latitude; at 0.5 it starts at ~70°
const thresholdLat = Math.PI / 2 - glacialStrength * Math.PI / GLACIAL_LAT_DIVISOR;
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const y = r_xyz[3 * r + 1];
const polarDist = Math.abs(Math.asin(Math.max(-1, Math.min(1, y))));
const latFactor = smoothstep(polarDist, thresholdLat, Math.PI / 2);
const elevFactor = smoothstep(r_elevation[r], GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH);
const latScale = smoothstep(polarDist, Math.PI / 8, Math.PI / 3);
glacIdx[r] = Math.max(latFactor, elevFactor * GLACIAL_ELEV_FACTOR_SCALE * (GLACIAL_ELEV_FACTOR_LAT_BASE + GLACIAL_ELEV_FACTOR_LAT_SCALE * latScale)) * glacialStrength;
}
iceTarget = new Int32Array(N);
iceFlow = new Float32Array(N);
numIceUpstream = new Uint8Array(N);
}
// Per-iteration glacial rates (scaled so total effect ≈ same regardless of iter count)
const gScale = gIters > 0 ? 1.0 / gIters : 0;
const gCarveRate = GLACIAL_CARVE_RATE * gScale;
const gConvergenceBonus = GLACIAL_CONVERGENCE_BONUS * gScale;
const gDepositAmount = GLACIAL_DEPOSIT_AMOUNT * gScale;
const gFjordCarve = GLACIAL_FJORD_CARVE * gScale;
const gFlowThreshold = GLACIAL_FLOW_THRESHOLD;
const gFjordThreshold = GLACIAL_FJORD_THRESHOLD;
// Mid-loop drainage fix: at 75% of iterations, run a carve-biased
// priority-flood to cut outlets through basins created by glaciation.
const midFloodIter = Math.round(totalIters * GLACIAL_MID_FLOOD_FRAC);
let midFloodDone = false;
// Pre-allocate thermal erosion buffers (max neighbor degree)
let maxDeg = 0;
for (let r = 0; r < N; r++) {
const deg = adjOffset[r + 1] - adjOffset[r];
if (deg > maxDeg) maxDeg = deg;
}
const excNb = new Int32Array(maxDeg);
const excVal = new Float32Array(maxDeg);
const excAdjIdx = new Int32Array(maxDeg);
const excSlope = new Float32Array(maxDeg);
for (let iter = 0; iter < totalIters; iter++) {
if (!midFloodDone && iter >= midFloodIter) {
midFloodDone = true;
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_MID_FLOOD_CARVE);
}
// Sort land cells by descending elevation — needed by glacial ice flow
// and hydraulic flow accumulation. If glacial runs this iteration and
// hydraulic follows, glacial modifies elevations so we re-sort before hydraulic.
const glacialThisIter = iter < gIters && glacIdx;
const hydraulicThisIter = iter < hIters;
if (glacialThisIter || hydraulicThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// ---- Glacial step ----
if (glacialThisIter) {
// Rebuild ice drainage from current elevations
iceTarget.fill(-1);
numIceUpstream.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (glacIdx[r] <= 0) continue;
const h = r_elevation[r];
let bestNb = -1, bestDrop = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) { bestDrop = drop; bestNb = nb; }
}
if (bestNb >= 0) iceTarget[r] = bestNb;
}
// Accumulate ice flow downstream
for (let r = 0; r < N; r++) iceFlow[r] = glacIdx[r];
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = iceTarget[r];
if (target >= 0 && iceFlow[r] > 0) {
iceFlow[target] += iceFlow[r];
numIceUpstream[target]++;
}
}
// Carving: deepening + widening + over-deepening
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const deepening = gCarveRate * Math.pow(iceFlow[r], 0.6) * glacialStrength;
r_elevation[r] -= deepening;
// Valley widening for U-shape
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const d = neighborDist[j] || 1e-6;
const slope = Math.abs(r_elevation[r] - r_elevation[nb]) / d;
r_elevation[nb] -= deepening * GLACIAL_WIDENING_FRAC * Math.max(0, 1 - slope);
}
// Over-deepening at convergence zones
if (numIceUpstream[r] >= 2) {
r_elevation[r] -= gConvergenceBonus * Math.pow(iceFlow[r], 0.4);
}
}
// Moraine deposition at glacier termini
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const target = iceTarget[r];
if (target < 0 || r_isOcean[target]) continue;
if (glacIdx[target] < glacIdx[r] * GLACIAL_TERMINUS_RATIO) {
r_elevation[target] += gDepositAmount * Math.pow(iceFlow[r], 0.3);
}
}
// Fjord enhancement on coastal glaciated cells
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
if (glacIdx[r] <= GLACIAL_FJORD_ICE_MIN || iceFlow[r] <= gFjordThreshold) continue;
let isCoastal = false;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (r_isOcean[adjList[j]]) { isCoastal = true; break; }
}
if (isCoastal) {
r_elevation[r] -= gFjordCarve * Math.pow(iceFlow[r], 0.5);
if (r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// Clamp: land stays land
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// ---- Hydraulic step ----
if (hydraulicThisIter) {
// Re-sort if glacial step modified elevations this iteration
if (glacialThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// Build drainage graph (steepest descent)
drainTarget.fill(-1);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let bestNb = -1, bestDrop = -Infinity, bestJ = -1;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) {
bestDrop = drop;
bestNb = nb;
bestJ = j;
}
}
// Pit handling: drain to least-steep-ascent neighbor
if (bestDrop <= 0) {
let minAscent = Infinity;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const ascent = r_elevation[nb] - h;
if (ascent < minAscent) {
minAscent = ascent;
bestNb = nb;
bestJ = j;
}
}
}
if (bestNb >= 0) {
drainTarget[r] = bestNb;
cellDist[r] = neighborDist[bestJ] || 1e-6;
}
}
// Flow accumulation (already sorted descending at top of iteration)
flow.fill(0);
for (let i = 0; i < landCount; i++) flow[landCells[i]] = 1;
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = drainTarget[r];
if (target >= 0) flow[target] += flow[r];
}
// Implicit stream power solve (ascending elevation order) + sediment deposition
for (let i = landCount - 1; i >= 0; i--) {
const r = landCells[i];
const target = drainTarget[r];
if (target < 0 || cellDist[r] <= 0) continue;
const factor = K * Math.pow(flow[r], m) * dt / cellDist[r];
const h_receiver = Math.max(r_elevation[target], 0);
let h_new = (r_elevation[r] + factor * h_receiver) / (1 + factor);
if (h_new < h_receiver) h_new = h_receiver;
if (h_new < 0) h_new = 0;
// Sediment deposition: deposit fraction of eroded material at receiver
const eroded = r_elevation[r] - h_new;
if (eroded > 0 && !r_isOcean[target]) {
const drainOfTarget = drainTarget[target];
let receiverSlope = 0;
if (drainOfTarget >= 0 && cellDist[target] > 0) {
receiverSlope = Math.abs(r_elevation[target] - r_elevation[drainOfTarget]) / cellDist[target];
}
const depositFrac = HYDRAULIC_DEPOSIT_FRAC / (1 + receiverSlope * HYDRAULIC_SLOPE_SENSITIVITY);
const deposit = eroded * depositFrac;
r_elevation[target] += deposit;
if (r_elevation[target] > h_new) r_elevation[target] = h_new;
}
r_elevation[r] = h_new;
}
}
// ---- Thermal step ----
if (iter < tIters) {
delta.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let totalExcess = 0;
let excCount = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const nh = r_elevation[nb];
if (nh >= h) continue;
const d = neighborDist[j] || 1e-6;
const slope = (h - nh) / d;
if (slope > talusSlope) {
const excess = (slope - talusSlope) * d;
excNb[excCount] = nb;
excVal[excCount] = excess;
excAdjIdx[excCount] = j;
excCount++;
totalExcess += excess;
}
}
if (totalExcess <= 0) continue;
// Slope-weighted distribution: steeper neighbors get more debris
let totalSlopeWeighted = 0;
for (let k = 0; k < excCount; k++) {
const d = neighborDist[excAdjIdx[k]] || 1e-6;
excSlope[k] = (h - r_elevation[excNb[k]]) / d;
totalSlopeWeighted += excVal[k] * excSlope[k];
}
const transfer = kThermal * totalExcess * THERMAL_TRANSFER_FRAC;
if (totalSlopeWeighted > 0) {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] * excSlope[k] / totalSlopeWeighted) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
} else {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] / totalExcess) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
}
}
for (let i = 0; i < landCount; i++) {
r_elevation[landCells[i]] += delta[landCells[i]];
}
}
}
// Post-loop: light Laplacian smooth on glaciated cells to blend carving edges
if (glacIdx) {
const tmp = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] || glacIdx[r] <= 0) continue;
let sum = 0, count = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (!r_isOcean[adjList[j]]) { sum += r_elevation[adjList[j]]; count++; }
}
if (count > 0) {
const avg = sum / count;
tmp[r] = r_elevation[r] + (avg - r_elevation[r]) * GLACIAL_POST_SMOOTH;
}
}
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && glacIdx[r] > 0) r_elevation[r] = tmp[r];
}
}
}
/**
* Ridge sharpening — pushes cells that sit above their neighborhood average
* further upward, accentuating ridgelines without creating unrealistic spikes.
*/
export function sharpenRidges(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build land cell list to skip ~40% ocean cells each iteration
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
const tmp = new Float32Array(N);
const original = new Float32Array(r_elevation);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < landCount; li++) {
const r = landCells[li];
const h = r_elevation[r];
let sum = 0;
const count = adjOffset[r + 1] - adjOffset[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
sum += r_elevation[adjList[i]];
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
if (h > avg) {
// Ridge sharpening: push peaks up
let h_new = h + (h - avg) * strength;
// Clamp: don't exceed 1.5x original elevation
const cap = original[r] * RIDGE_SHARPEN_CAP;
if (h_new > cap) h_new = cap;
tmp[r] = h_new;
} else if (h < avg) {
// Valley deepening: push valleys down (weaker than ridge sharpening)
const VALLEY_FACTOR = VALLEY_DEEPEN_FACTOR;
let h_new = h - (avg - h) * strength * VALLEY_FACTOR;
// Floor cap: don't go below 0.5x original (symmetric to 1.5x ceiling)
const floor = original[r] * VALLEY_FLOOR_FRAC;
if (original[r] > 0 && h_new < floor) h_new = floor;
// Don't push land below sea level
if (original[r] > 0 && h_new < VALLEY_FLOOR_MIN) h_new = VALLEY_FLOOR_MIN;
tmp[r] = h_new;
} else {
tmp[r] = h;
}
}
for (let li = 0; li < landCount; li++) r_elevation[landCells[li]] = tmp[landCells[li]];
}
}
/**
* Soil creep — simple Laplacian diffusion on land cells.
* Unlike bilateral smoothing, this doesn't preserve ridges — it uniformly
* rounds off hillslopes. Coastline cells are locked.
*/
export function applySoilCreep(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build interior land cell list: skip ocean cells and coastline-locked cells
const interiorLand = [];
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
let coastal = false;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { coastal = true; break; }
}
if (!coastal) interiorLand.push(r);
}
const ilCount = interiorLand.length;
const tmp = new Float32Array(N);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < ilCount; li++) {
const r = interiorLand[li];
const h = r_elevation[r];
let sum = 0, count = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (!r_isOcean[adjList[i]]) {
sum += r_elevation[adjList[i]];
count++;
}
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
tmp[r] = h + (avg - h) * strength;
}
for (let li = 0; li < ilCount; li++) r_elevation[interiorLand[li]] = tmp[interiorLand[li]];
}
}
+578
View File
@@ -0,0 +1,578 @@
// Exporting a planet as Unreal landscape tiles.
//
// Unreal's landscape importer will not take either of Orogen's two existing exports. The preview is a
// picture. The heightmap is one flat 8192 x 4096 PNG with no scale attached to it - the ramp is absolute,
// so the shades mean metres, but nothing in the file says how wide the planet is, and without that there is
// no answer to "how many metres is a pixel". What the importer wants is per-tile 16-bit greyscale PNGs at
// exactly 255*N+1 vertices, one Landscape actor each, plus a separate 8-bit weightmap per paint layer, at
// the sample spacing the game actually uses. This module writes that, straight out of the planet.
//
// Three things about it are the whole design.
//
// **The scale is an input, not a guess.** A sphere mesh has no metres on it; `planet_circumference_km` is
// what turns the window's degrees into ground. It is asked for rather than derived because it cannot be
// derived, and because it is the number that decides how much land a window holds. The report prints what
// the choice bought, including the east-west stretch at the window's edges, so a window that does not fit
// on the planet says so instead of quietly producing 30 km of ground on a 31.8 km world.
//
// **The window is sampled once, then cut.** The planet is rendered into one float raster over the window,
// and every tile is resampled out of that raster by its *global* vertex position. This is what closes the
// seams: a vertex column shared by two neighbours is computed from the same source coordinates twice and
// comes out bit-identical, so nothing has to blend or stitch. Rendering each tile under its own camera
// would have been one step shorter and would have put a rasteriser's floating-point luck on every seam,
// where one 16-bit step is 11 cm of crack.
//
// **The intermediate is float and it is the window.** The old path read a whole-planet 16-bit PNG and spent
// its resolution on the whole planet; this spends all of it on the window, and carries kilometres as
// float32 rather than quantised to a -5000..6000 m ramp. Both matter less than they sound, because the
// sphere mesh only resolves a couple of hundred metres and no amount of sampling invents what is not there
// - the detail below that is still the Go generator's job. What they do buy is that nothing downstream has
// to know a magic number.
//
// The resampler is the Catmull-Rom from generate_region_tiles.py, clamped to its two central taps for the
// same reason: a plain cubic overshoots at a step, the steps here are coastlines, and unclamped every shore
// gets a raised lip on the land side and a trench on the sea side.
import { renderHeightWindowKm } from './unreal-render.js';
import { encodeGray16, encodeGray8 } from './png-write.js';
// One vertex of the neighbours on every side, sampled before the paint layers are derived and thrown away
// after. The layers read slope, a one-sided difference at an array edge is not what the neighbouring tile
// computes for that same vertex, and without this every tile boundary is a one-vertex line of different
// paint. One vertex is all a central difference needs.
const LAYER_MARGIN = 1;
// Pixels of the intermediate raster rendered beyond the window on each side, so the resampler's outer taps
// and the layer margin read real ground instead of a clamped edge. Catmull-Rom reaches two pixels.
const RASTER_PAD = 4;
export const LAYER_NAMES = ['Base_Layer', 'Layer_02', 'Layer_03']; // rock, meadow, high rock
// The defaults are a window that actually fits on this project's planet, which is a smaller one than it
// looks. Planet.json is 100 km round, so the whole globe is 3183 km2 of surface; the 936 km2 square the
// numpy region pipeline cuts is 29% of it, and that is why that window reads as 110 degrees on a side and
// stretches by three quarters at its edge. Four tiles by two is 20.4 x 10.2 km, 208 km2, and about 5%
// stretch at the edge - a window a sphere this size can actually hold flat.
export const DEFAULTS = {
level: '/Game/Maps/L_Region',
planet_circumference_km: 100,
centre: { lon_deg: 0, lat_deg: 0 },
tiles: { columns: 4, rows: 2, vertices: 2551 },
quad_cm: 200,
sea_level_m: 0,
spawn_pad_m: 150,
streaming_grid_components: 5,
elevation_m: { min: -1024, max: 6144 },
sea_scale: 0.17,
source_metres_per_pixel: 8,
layers: {
rock_slope_start: 0.55,
rock_slope_full: 1.05,
high_altitude_start_m: 1400,
high_altitude_full_m: 2000,
breakup_m: 18,
breakup_cells: 24,
breakup_seed: 7,
},
};
// ── Geometry ────────────────────────────────────────────────────────────────────────────────────────
/**
* Everything that follows from the options, with nothing rendered yet. Cheap, so the UI can call it on
* every keystroke to show what a setting buys - this is the export's equivalent of `--scout`.
*/
export function planRegion(opts) {
const o = mergeDefaults(opts);
const vertices = o.tiles.vertices;
if ((vertices - 1) % 255 !== 0) {
throw new Error(`tiles.vertices must be 255 * N + 1 (2551, 2041, 1021 ...), not ${vertices}`);
}
const quadsPerTile = vertices - 1;
const quadM = o.quad_cm / 100;
const quadsX = o.tiles.columns * quadsPerTile;
const quadsY = o.tiles.rows * quadsPerTile;
const widthM = quadsX * quadM;
const heightM = quadsY * quadM;
const radiusM = o.planet_circumference_km * 1000 / (2 * Math.PI);
const centreLat = o.centre.lat_deg * Math.PI / 180;
const centreLon = o.centre.lon_deg * Math.PI / 180;
const latSpan = heightM / radiusM;
// Cosine-corrected at the centre latitude, so ground metres are right there rather than only at the
// equator. A flat reading of an equirectangular map stretches east-west by 1/cos(latitude); this puts
// the error at zero in the middle of the window and splits it between the north and south edges.
const lonSpan = widthM / (radiusM * Math.cos(centreLat));
const latMin = centreLat - latSpan / 2;
const latMax = centreLat + latSpan / 2;
const innerW = Math.max(2, Math.round(widthM / o.source_metres_per_pixel) + 1);
const innerH = Math.max(2, Math.round(heightM / o.source_metres_per_pixel) + 1);
// What the flat reading costs at the window's edges: 1 at the centre latitude by construction.
const stretchAt = lat => Math.cos(centreLat) / Math.cos(Math.max(-1.55, Math.min(1.55, lat)));
const warnings = [];
if (latSpan >= Math.PI) {
warnings.push(`the window is ${(latSpan * 180 / Math.PI).toFixed(0)} degrees of latitude tall, which `
+ `is more than the planet has. Raise planet_circumference_km or use fewer tiles.`);
}
if (lonSpan >= 2 * Math.PI) {
warnings.push(`the window wraps the planet more than once at this latitude. Raise `
+ `planet_circumference_km or use fewer tiles.`);
}
if (Math.abs(latMax) > 1.4 || Math.abs(latMin) > 1.4) {
warnings.push('the window reaches past 80 degrees of latitude, where an equirectangular reading '
+ 'stretches without bound. Move the centre towards the equator.');
}
const worstStretch = Math.max(stretchAt(latMin), stretchAt(latMax));
if (worstStretch > 1.1 && warnings.length === 0) {
warnings.push(`the ground is stretched east-west by up to ${((worstStretch - 1) * 100).toFixed(1)}% `
+ 'at the window\'s edge. A window this tall on a planet this small cannot avoid it; a bigger '
+ 'planet_circumference_km or fewer rows would.');
}
return {
options: o,
quadsPerTile, quadM, quadsX, quadsY, widthM, heightM,
areaKm2: widthM * heightM / 1e6,
tileSideM: quadsPerTile * quadM,
tileCount: o.tiles.columns * o.tiles.rows,
componentsPerTile: (quadsPerTile / 255) ** 2,
radiusM, centreLat, centreLon, latSpan, lonSpan, latMin, latMax,
lonMin: centreLon - lonSpan / 2,
lonMax: centreLon + lonSpan / 2,
innerW, innerH,
rasterW: innerW + 2 * RASTER_PAD,
rasterH: innerH + 2 * RASTER_PAD,
metresPerPixel: widthM / (innerW - 1),
stretchNorth: stretchAt(latMax),
stretchSouth: stretchAt(latMin),
warnings,
};
}
function mergeDefaults(opts) {
const o = { ...DEFAULTS, ...(opts || {}) };
o.centre = { ...DEFAULTS.centre, ...(opts && opts.centre) };
o.tiles = { ...DEFAULTS.tiles, ...(opts && opts.tiles) };
o.elevation_m = { ...DEFAULTS.elevation_m, ...(opts && opts.elevation_m) };
o.layers = { ...DEFAULTS.layers, ...(opts && opts.layers) };
return o;
}
// ── Resampling ──────────────────────────────────────────────────────────────────────────────────────
/** Catmull-Rom weights for taps at -1, 0, +1, +2. */
function cubicWeights(t) {
const t2 = t * t, t3 = t2 * t;
return [
-0.5 * t3 + t2 - 0.5 * t,
1.5 * t3 - 2.5 * t2 + 1.0,
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
0.5 * t3 - 0.5 * t2,
];
}
/**
* One separable pass of clamped Catmull-Rom along x: `src` is srcW wide and `rows` tall, `coords` are
* float source columns. Held between the two central taps, which is what stops it ringing at a coastline.
*/
function resampleX(src, srcW, rows, coords) {
const outW = coords.length;
const out = new Float32Array(rows * outW);
const clampIdx = i => (i < 0 ? 0 : i >= srcW ? srcW - 1 : i);
for (let o = 0; o < outW; o++) {
const c = coords[o];
const i = Math.floor(c);
const w = cubicWeights(c - i);
const i0 = clampIdx(i - 1), i1 = clampIdx(i), i2 = clampIdx(i + 1), i3 = clampIdx(i + 2);
for (let r = 0; r < rows; r++) {
const base = r * srcW;
const a = src[base + i0], b = src[base + i1], c2 = src[base + i2], d = src[base + i3];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[r * outW + o] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
/** The same along y: `src` is width wide and srcH tall, `coords` are float source rows. */
function resampleY(src, width, srcH, coords) {
const outH = coords.length;
const out = new Float32Array(outH * width);
const clampIdx = j => (j < 0 ? 0 : j >= srcH ? srcH - 1 : j);
for (let o = 0; o < outH; o++) {
const c = coords[o];
const j = Math.floor(c);
const w = cubicWeights(c - j);
const r0 = clampIdx(j - 1) * width, r1 = clampIdx(j) * width;
const r2 = clampIdx(j + 1) * width, r3 = clampIdx(j + 2) * width;
const dst = o * width;
for (let x = 0; x < width; x++) {
const a = src[r0 + x], b = src[r1 + x], c2 = src[r2 + x], d = src[r3 + x];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[dst + x] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
// ── Break-up noise ──────────────────────────────────────────────────────────────────────────────────
//
// Value-noise fBm on a periodic lattice, sampled at *global* window coordinates so a tile boundary is not
// a discontinuity in the paint. The lattice values come from a hash of (seed, octave, cell) rather than
// from a stream of random numbers, which is what makes a single tile computable without generating the
// ones before it. This is the same shape as heightmap_noise.fbm_at but not the same numbers: numpy's PCG64
// stream cannot be reproduced here, and it does not need to be - the two pipelines are alternatives, never
// mixed, and this noise only decides where a paint boundary wobbles.
function hash01(seed, octave, cells, i, j) {
let h = (seed ^ Math.imul(octave + 1, 0x9E3779B1)) >>> 0;
h = Math.imul(h ^ Math.imul(i, 0x27D4EB2D), 0x165667B1);
h = Math.imul(h ^ Math.imul(j, 0x85EBCA77), 0xC2B2AE3D);
h = Math.imul(h ^ cells, 0x27D4EB2F);
h ^= h >>> 15; h = Math.imul(h, 0x2545F491); h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
const smoothstep = t => t * t * (3 - 2 * t);
function fbmAt(u, v, seed, baseCells, octaves = 4, gain = 0.5) {
let total = 0, amplitude = 1, cells = baseCells, norm = 0;
for (let o = 0; o < octaves; o++) {
const su = u * cells, sv = v * cells;
const i0 = Math.floor(su), j0 = Math.floor(sv);
const tu = smoothstep(su - i0), tv = smoothstep(sv - j0);
const ia = ((i0 % cells) + cells) % cells, ja = ((j0 % cells) + cells) % cells;
const ib = (ia + 1) % cells, jb = (ja + 1) % cells;
const top = hash01(seed, o, cells, ia, ja) * (1 - tu) + hash01(seed, o, cells, ib, ja) * tu;
const bottom = hash01(seed, o, cells, ia, jb) * (1 - tu) + hash01(seed, o, cells, ib, jb) * tu;
total += (top * (1 - tv) + bottom * tv) * amplitude;
norm += amplitude;
amplitude *= gain;
cells *= 2;
}
return total / norm;
}
// ── One tile ────────────────────────────────────────────────────────────────────────────────────────
/** Global vertex indices along one axis for a tile, with `margin` extra on each side. */
function tileVertices(plan, tile, margin) {
const n = plan.options.tiles.vertices + 2 * margin;
const out = new Float64Array(n);
for (let k = 0; k < n; k++) out[k] = tile * plan.quadsPerTile + (k - margin);
return out;
}
/** Global vertex indices to raster pixel coordinates. RASTER_PAD is where the window's first vertex sits. */
function toRasterCoords(plan, vertices, axis) {
const inner = axis === 0 ? plan.innerW : plan.innerH;
const quads = axis === 0 ? plan.quadsX : plan.quadsY;
const out = new Float64Array(vertices.length);
for (let k = 0; k < vertices.length; k++) {
out[k] = RASTER_PAD + vertices[k] * (inner - 1) / quads;
}
return out;
}
/** One tile's height in metres, sampled out of the window raster by global position. */
function tileMetres(plan, raster, tx, ty, margin) {
const vx = tileVertices(plan, tx, margin);
const vy = tileVertices(plan, ty, margin);
const sx = toRasterCoords(plan, vx, 0);
const sy = toRasterCoords(plan, vy, 1);
// Only the raster rows this tile reaches, so a tile costs a band rather than the whole window.
const row0 = Math.max(0, Math.floor(sy[0]) - 1);
const row1 = Math.min(plan.rasterH, Math.floor(sy[sy.length - 1]) + 3);
const bandRows = row1 - row0;
const band = raster.subarray(row0 * plan.rasterW, row1 * plan.rasterW);
const afterX = resampleX(band, plan.rasterW, bandRows, sx);
const shifted = new Float64Array(sy.length);
for (let k = 0; k < sy.length; k++) shifted[k] = sy[k] - row0;
const km = resampleY(afterX, sx.length, bandRows, shifted);
const out = new Float32Array(km.length);
const seaScale = plan.options.sea_scale;
for (let k = 0; k < km.length; k++) {
let m = km[k] * 1000;
if (m < 0) m *= seaScale;
out[k] = m;
}
return { metres: out, vx, vy, width: sx.length, height: sy.length };
}
/**
* The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
* computed from global position, so where it crosses a tile boundary the two tiles agree on it.
*/
function applySpawnPad(plan, tile, padMetres) {
const radius = plan.options.spawn_pad_m;
if (radius <= 0) return;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
for (let j = 0; j < height; j++) {
const dy = (vy[j] - plan.quadsY / 2) * quadM;
for (let i = 0; i < width; i++) {
const dx = (vx[i] - plan.quadsX / 2) * quadM;
const dist = Math.hypot(dx, dy);
const t = Math.max(0, Math.min(1, 1 - (dist - radius) / radius));
if (t <= 0) continue;
const w = smoothstep(t);
const at = j * width + i;
metres[at] = metres[at] * (1 - w) + padMetres * w;
}
}
}
/**
* The pack's three paint layers from height and slope: meadow everywhere, rock by slope, high rock by
* altitude, with an fBm break-up so neither boundary is a contour line. There is no erosion on this path,
* so unlike L_World's version there is no wear, curvature or deposit term. `tile` carries LAYER_MARGIN
* vertices of its neighbours on every side; the layers are computed over the lot and the margin cropped at
* the end, so the slope at a tile's edge is the central difference its neighbour computes there too.
*/
function deriveLayers(plan, tile) {
const rules = plan.options.layers;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
// Both axes divided by the *longer* one, so the noise stays square on the ground and, because neither
// coordinate then exceeds 1, it never repeats across the window.
const span = Math.max(plan.quadsX, plan.quadsY);
const breakupM = rules.breakup_m;
const slopeBreakupScale = breakupM ? 0.12 / breakupM : 0;
const layers = {};
for (const name of LAYER_NAMES) layers[name] = new Uint8Array(outW * outH);
for (let j = margin; j < height - margin; j++) {
for (let i = margin; i < width - margin; i++) {
const at = j * width + i;
// Central differences, which is why the margin is here.
const gx = (metres[at + 1] - metres[at - 1]) / (2 * quadM);
const gy = (metres[at + width] - metres[at - width]) / (2 * quadM);
const slope = Math.hypot(gx, gy);
const noise = fbmAt(vx[i] / span, vy[j] / span, rules.breakup_seed | 0, rules.breakup_cells | 0);
const breakup = (noise - 0.5) * 2 * breakupM;
let rock = smoothstep(Math.max(0, Math.min(1,
(slope + breakup * slopeBreakupScale - rules.rock_slope_start)
/ (rules.rock_slope_full - rules.rock_slope_start))));
let high = smoothstep(Math.max(0, Math.min(1,
(metres[at] + breakup - rules.high_altitude_start_m)
/ (rules.high_altitude_full_m - rules.high_altitude_start_m))));
high = high * (1 - rock * 0.5);
const meadow = Math.max(0, Math.min(1, 1 - rock - high));
const total = Math.max(meadow + rock + high, 1e-6);
const out = (j - margin) * outW + (i - margin);
layers.Base_Layer[out] = Math.round(rock / total * 255);
layers.Layer_02[out] = Math.round(meadow / total * 255);
layers.Layer_03[out] = Math.round(high / total * 255);
}
}
return { layers, width: outW, height: outH };
}
/** Metres to the 16-bit code the manifest's elevation_m range defines. */
function encodeHeights(plan, tile) {
const { metres, width, height } = tile;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
const lo = plan.options.elevation_m.min, hi = plan.options.elevation_m.max;
const span = hi - lo;
const out = new Uint16Array(outW * outH);
let clipped = 0;
// Measured over the cropped tile, not over `metres`, which still carries the margin ring. On an outside
// tile that ring is sampled beyond the window, so a range taken across it can quote ground that is not
// in the world - and this number is what the manifest prints as "the ground came out X..Y m".
let minM = Infinity, maxM = -Infinity;
for (let j = 0; j < outH; j++) {
for (let i = 0; i < outW; i++) {
const m = metres[(j + margin) * width + (i + margin)];
if (m < minM) minM = m;
if (m > maxM) maxM = m;
if (m < lo || m > hi) clipped++;
const bounded = m < lo ? lo : m > hi ? hi : m;
const v = Math.round((bounded - lo) / span * 65535);
out[j * outW + i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
}
}
return { heights: out, width: outW, height: outH, clipped: clipped / (outW * outH), minM, maxM };
}
// ── Writing ─────────────────────────────────────────────────────────────────────────────────────────
async function writeBlob(dirHandle, name, blob) {
const handle = await dirHandle.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
async function fileExists(dirHandle, name) {
try {
await dirHandle.getFileHandle(name);
return true;
} catch {
return false; // NotFoundError, and anything else here means we cannot claim it is there
}
}
function tileName(plan, tx, ty) {
const level = plan.options.level.split('/').pop();
return `${level}_x${tx}_y${ty}`;
}
/**
* The manifest, in the shape RawContent/World/Region.json has - so the Unreal side reads this export with
* region_manifest.py exactly as it reads a hand-written one, and nothing downstream needs to know which
* tool cut the tiles.
*
* The `source` block is kept, and made honest. generate_region_tiles.py is what reads it, and it will not
* run against these tiles because they are already there; what it records is where the ground came from
* and at what scale, which used to be a number somebody chose and wrote in a comment.
*/
function regionManifest(plan, meta) {
const o = plan.options;
const deg = r => +(r * 180 / Math.PI).toFixed(6);
return {
_comment: 'Written by World Orogen\'s Unreal landscape export. The tiles in RegionTiles/ are a '
+ 'product of this file and the planet named below; generate_region_tiles.py is not in this '
+ 'path and does not need to run. Every key is explained in Scripts/Authoring/region_manifest.py.',
level: o.level,
_comment_tiles: `${o.tiles.columns} x ${o.tiles.rows} landscapes of ${o.tiles.vertices} vertices. `
+ `${plan.quadsPerTile} quads is ${plan.quadsPerTile / 255} x 255, so the engine gives each tile `
+ `${plan.componentsPerTile} components of 255 quads: ${plan.tileCount * plan.componentsPerTile} `
+ `over the window. Neighbours share their edge vertices, so the grid is ${plan.quadsX + 1} x `
+ `${plan.quadsY + 1} vertices, ${(plan.widthM / 1000).toFixed(2)} x `
+ `${(plan.heightM / 1000).toFixed(2)} km, ${plan.areaKm2.toFixed(0)} km2 of map.`,
tiles: { ...o.tiles },
quad_cm: o.quad_cm,
sea_level_m: o.sea_level_m,
spawn_pad_m: o.spawn_pad_m,
streaming_grid_components: o.streaming_grid_components,
elevation_m: { ...o.elevation_m },
_comment_elevation: `The ground came out ${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, which `
+ `uses ${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp at `
+ `${((o.elevation_m.max - o.elevation_m.min) / 65535 * 100).toFixed(1)} cm a step. `
+ `${meta.clipped === 0 ? 'Nothing clips.' : (meta.clipped * 100).toFixed(3) + '% of vertices clip - widen elevation_m.'}`,
source: {
_comment: 'Rendered directly out of World Orogen rather than cut from a PNG, so the scale is '
+ 'recorded rather than chosen after the fact. metres_per_pixel is what one pixel of the '
+ 'intermediate float raster was worth; the tiles themselves are at quad_cm.',
kind: 'orogen_render',
planet: meta.planetCode || null,
planet_circumference_km: o.planet_circumference_km,
centre: { lon_deg: o.centre.lon_deg, lat_deg: o.centre.lat_deg },
window_deg: {
lon_min: deg(plan.lonMin), lon_max: deg(plan.lonMax),
lat_min: deg(plan.latMin), lat_max: deg(plan.latMax),
},
projection: 'equirectangular, cosine-corrected at the centre latitude',
east_west_stretch: { north: +plan.stretchNorth.toFixed(4), south: +plan.stretchSouth.toFixed(4) },
metres_per_pixel: +plan.metresPerPixel.toFixed(6),
// The window in pixels of the intermediate raster, so region_manifest.py's metres_per_pixel()
// has the same shape of answer here as it does for a manifest that names a PNG. The raster is
// rendered `pad` pixels wider on every side than the window, for the resampler's outer taps.
window: { x: RASTER_PAD, y: RASTER_PAD, width: plan.innerW, height: plan.innerH },
raster: { width: plan.rasterW, height: plan.rasterH, pad: RASTER_PAD },
elevation_m: { min: -5000, max: 6000 },
sea_scale: o.sea_scale,
exported: new Date().toISOString(),
},
layers: { ...o.layers },
};
}
/**
* Renders the window and writes the whole tile set, plus Region.json, into a directory the user picks.
*
* `dirHandle` should be the project's RawContent/World: the tiles go into RegionTiles/ beneath it and the
* manifest beside it, which is the layout create_region_world.py already reads. One tile is held in memory
* at a time; the window raster is the only large allocation and it is float32 over the window, not the
* planet.
*/
export async function exportUnrealRegion(opts, dirHandle, onProgress = () => {}) {
const plan = planRegion(opts);
const o = plan.options;
onProgress(0.02, 'Sampling the planet');
const raster = await renderHeightWindowKm({
lonMin: plan.lonMin - RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
lonMax: plan.lonMax + RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
latMin: plan.latMin - RASTER_PAD * plan.latSpan / (plan.innerH - 1),
latMax: plan.latMax + RASTER_PAD * plan.latSpan / (plan.innerH - 1),
width: plan.rasterW,
height: plan.rasterH,
onProgress: (f, label) => onProgress(0.02 + f * 0.18, label),
});
// The pad's height is read at the window's exact centre, once, so every tile it touches lifts to the
// same level. Never below the sea: a pad in the water is not a place to stand.
const cx = RASTER_PAD + (plan.innerW - 1) / 2;
const cy = RASTER_PAD + (plan.innerH - 1) / 2;
const centreKm = resampleY(
resampleX(raster, plan.rasterW, plan.rasterH, Float64Array.from([cx])),
1, plan.rasterH, Float64Array.from([cy]))[0];
let padMetres = centreKm * 1000;
if (padMetres < 0) padMetres *= o.sea_scale;
padMetres = Math.max(padMetres, o.sea_level_m + 30);
const tilesDir = await dirHandle.getDirectoryHandle('RegionTiles', { create: true });
const meta = { minM: Infinity, maxM: -Infinity, clipped: 0, planetCode: opts && opts.planet_code };
const total = plan.tileCount;
let done = 0;
for (let ty = 0; ty < o.tiles.rows; ty++) {
for (let tx = 0; tx < o.tiles.columns; tx++) {
const name = tileName(plan, tx, ty);
onProgress(0.2 + done / total * 0.8, `${name} (${done + 1}/${total})`);
const tile = tileMetres(plan, raster, tx, ty, LAYER_MARGIN);
applySpawnPad(plan, tile, padMetres);
const { heights, width, height, clipped, minM, maxM } = encodeHeights(plan, tile);
if (minM < meta.minM) meta.minM = minM;
if (maxM > meta.maxM) meta.maxM = maxM;
meta.clipped += clipped / total;
await writeBlob(tilesDir, `${name}_Height.png`, await encodeGray16(width, height, heights));
const derived = deriveLayers(plan, tile);
for (const layerName of LAYER_NAMES) {
await writeBlob(tilesDir, `${name}_${layerName}.png`,
await encodeGray8(derived.width, derived.height, derived.layers[layerName]));
}
done++;
await new Promise(r => setTimeout(r, 0));
}
}
meta.rampUsed = (meta.maxM - meta.minM) / (o.elevation_m.max - o.elevation_m.min);
const manifest = regionManifest(plan, meta);
// An existing Region.json is never replaced. The one in this project is hand-written and most of it is
// commentary explaining why each number is what it is; a generated file would throw all of that away,
// and the same rule already governs the terrain studio, which saves by patching a legend's *text* so its
// reasoning survives. The tiles are the product here - the manifest is a description of what was cut -
// so the generated one lands beside it under a name of its own and the caller is told which it got.
const manifestName = (await fileExists(dirHandle, 'Region.json'))
? 'Region.generated.json' : 'Region.json';
await writeBlob(dirHandle, manifestName,
new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' }));
onProgress(1, 'Done');
return { plan, meta, manifest, manifestName };
}
+185
View File
@@ -0,0 +1,185 @@
// Sampling the planet over a window, for the Unreal landscape export.
//
// The map exports in planet-mesh.js answer one question: "draw the whole planet at width W". Unreal needs
// a different one answered - "what is the ground, in metres, over *this* rectangle of the planet, at
// whatever sample spacing I ask for" - and this module is that question and nothing else. Keeping it
// separate is what lets unreal-export.js know about tiles and weightmaps without planet-mesh.js knowing
// about either.
//
// Heights come back as kilometres through the same fixed -5..6 km ramp `heightmapColor` uses, rather than
// as kilometres written straight into the vertex colours. The ramp is code that is already proven by the
// 16-bit export; the difference here is that the float render target is read as floats instead of being
// quantised to 16 bits. A float32 over 0..1 resolves about 1e-7, which over an 11 km ramp is a millimetre,
// three orders of magnitude finer than the 16-bit PNG's 17 cm - so nothing is lost coming back out, and
// negative values never have to survive a vertex-colour path where three.js colour management could reach
// them.
import * as THREE from 'three';
import { renderer } from './scene.js';
import { state } from './state.js';
import { elevToHeightKm } from './color-map.js';
export const RAMP_MIN_KM = -5;
export const RAMP_SPAN_KM = 11;
/** The ramp `heightmapColor` paints with, as a number rather than a colour. Clamping is a formality:
* elevToHeightKm cannot leave -5..6 by construction. */
function toRamp(elevation) {
const km = elevToHeightKm(elevation);
return Math.max(0, Math.min(1, (km - RAMP_MIN_KM) / RAMP_SPAN_KM));
}
// The same smooth triangle soup the 16-bit heightmap export builds: one triangle per mesh side, with the
// two triangle-centre vertices carrying the average of the three regions they touch, so a cell interpolates
// as a Gouraud gradient rather than reading as a flat hex.
//
// Two differences, both because this is sampled rather than looked at. Nothing is clamped into x in
// [-2, 2]: that clamp squashes the triangles straddling the date line, which is invisible in a whole-planet
// image because the wrapped copy covers it, and is a torn seam in a window that happens to sit there. And
// the caller draws the result three times, a full map apart, so a window crossing the date line sees real
// geometry on both sides instead of the edge of the mesh.
function buildHeightMapMesh(curData) {
const { mesh, r_xyz, t_xyz, r_elevation } = curData;
const { numSides, numTriangles } = mesh;
const PI = Math.PI;
const sx = 2 / PI;
const t_elev = new Float32Array(numTriangles);
const tris = mesh.triangles;
for (let t = 0; t < numTriangles; t++) {
const s0 = 3 * t;
t_elev[t] = (r_elevation[tris[s0]] + r_elevation[tris[s0 + 1]] + r_elevation[tris[s0 + 2]]) / 3;
}
const posArr = new Float32Array(numSides * 18);
const colArr = new Float32Array(numSides * 18);
let triCount = 0;
const emit = (lonA, latA, lonB, latB, lonC, latC, vA, vB, vC) => {
const off = triCount * 9;
posArr[off] = lonA * sx; posArr[off + 1] = latA * sx; posArr[off + 2] = 0;
posArr[off + 3] = lonB * sx; posArr[off + 4] = latB * sx; posArr[off + 5] = 0;
posArr[off + 6] = lonC * sx; posArr[off + 7] = latC * sx; posArr[off + 8] = 0;
colArr[off] = colArr[off + 1] = colArr[off + 2] = vA;
colArr[off + 3] = colArr[off + 4] = colArr[off + 5] = vB;
colArr[off + 6] = colArr[off + 7] = colArr[off + 8] = vC;
triCount++;
};
for (let s = 0; s < numSides; s++) {
const it = mesh.s_inner_t(s);
const ot = mesh.s_outer_t(s);
const br = mesh.s_begin_r(s);
const v0 = toRamp(t_elev[it]);
const v1 = toRamp(t_elev[ot]);
const v2 = toRamp(r_elevation[br]);
const x0 = t_xyz[3 * it], y0 = t_xyz[3 * it + 1], z0 = t_xyz[3 * it + 2];
const x1 = t_xyz[3 * ot], y1 = t_xyz[3 * ot + 1], z1 = t_xyz[3 * ot + 2];
const x2 = r_xyz[3 * br], y2 = r_xyz[3 * br + 1], z2 = r_xyz[3 * br + 2];
let lon0 = Math.atan2(x0, z0), lat0 = Math.asin(Math.max(-1, Math.min(1, y0)));
let lon1 = Math.atan2(x1, z1), lat1 = Math.asin(Math.max(-1, Math.min(1, y1)));
let lon2 = Math.atan2(x2, z2), lat2 = Math.asin(Math.max(-1, Math.min(1, y2)));
if (Math.max(lon0, lon1, lon2) - Math.min(lon0, lon1, lon2) > PI) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
emit(lon0 - 2 * PI, lat0, lon1 - 2 * PI, lat1, lon2 - 2 * PI, lat2, v0, v1, v2);
} else {
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(posArr.buffer, 0, triCount * 9), 3));
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colArr.buffer, 0, triCount * 9), 3));
return new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
}
/**
* Renders a longitude/latitude rectangle of the current planet into kilometres above sea level.
*
* The raster's *pixel centres* span the rectangle exactly - pixel 0 sits on lonMin, pixel width-1 on
* lonMax - because that is the convention the resampler downstream reads it with, so the frustum is
* widened by half a pixel on each side to put them there. Row 0 is the northern edge, as in an image.
*
* @returns {Promise<Float32Array>} width * height kilometres, row-major from the north.
*/
export async function renderHeightWindowKm({ lonMin, lonMax, latMin, latMax, width, height, onProgress }) {
if (!state.curData) throw new Error('no planet loaded to export');
if (width < 2 || height < 2) throw new Error('a height window needs at least 2 x 2 samples');
const sx = 2 / Math.PI;
const mapMesh = buildHeightMapMesh(state.curData);
const offScene = new THREE.Scene();
offScene.background = new THREE.Color(0x000000);
// Three copies, a full map apart, so a window crossing the date line is covered on both sides of it.
for (const shift of [-4, 0, 4]) {
const copy = new THREE.Mesh(mapMesh.geometry, mapMesh.material);
copy.position.x = shift;
offScene.add(copy);
}
const halfU = (lonMax - lonMin) * sx / (width - 1) / 2;
const halfV = (latMax - latMin) * sx / (height - 1) / 2;
const mx0 = lonMin * sx - halfU, mx1 = lonMax * sx + halfU;
const my0 = latMin * sx - halfV, my1 = latMax * sx + halfV;
const out = new Float32Array(width * height);
const step = Math.min(2048, renderer.capabilities.maxTextureSize);
const tilesX = Math.ceil(width / step);
const tilesY = Math.ceil(height / step);
const total = tilesX * tilesY;
let done = 0;
const prevColorSpace = renderer.outputColorSpace;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
try {
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const px0 = tx * step, py0 = ty * step;
const pw = Math.min(step, width - px0);
const ph = Math.min(step, height - py0);
const cam = new THREE.OrthographicCamera(
mx0 + (mx1 - mx0) * px0 / width,
mx0 + (mx1 - mx0) * (px0 + pw) / width,
my1 - (my1 - my0) * py0 / height,
my1 - (my1 - my0) * (py0 + ph) / height,
0.1, 10);
cam.position.set(0, 0, 5);
cam.lookAt(0, 0, 0);
const target = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
renderer.setRenderTarget(target);
renderer.render(offScene, cam);
const pixels = new Float32Array(pw * ph * 4);
renderer.readRenderTargetPixels(target, 0, 0, pw, ph, pixels);
renderer.setRenderTarget(null);
target.dispose();
for (let y = 0; y < ph; y++) {
const src = (ph - 1 - y) * pw; // the readback is bottom-up
const dst = (py0 + y) * width + px0;
for (let x = 0; x < pw; x++) {
out[dst + x] = pixels[(src + x) * 4] * RAMP_SPAN_KM + RAMP_MIN_KM;
}
}
done++;
if (onProgress) onProgress(done / total, 'Sampling the planet');
await new Promise(r => setTimeout(r, 0));
}
}
} finally {
renderer.outputColorSpace = prevColorSpace;
renderer.setRenderTarget(null);
mapMesh.geometry.dispose();
mapMesh.material.dispose();
}
return out;
}
+287
View File
@@ -0,0 +1,287 @@
// The Unreal landscape export's panel.
//
// Built in JavaScript rather than written into index.html and import.html, because it is the same panel on
// both pages and two copies of a form with fourteen fields drift within a week.
//
// The panel's job is not to collect the numbers - it is to show what a number *buys* before two hours are
// spent on it. Every field re-plans on the keystroke and the readout underneath says how much ground the
// window holds, how many landscape components that is, how finely the planet is being sampled and what the
// flat reading costs at the window's edges. That is the same service `terrain plan` does for a legend and
// `--scout` does for a region: the expensive step should never be how you find out you set a number wrong.
import { planRegion, exportUnrealRegion, DEFAULTS } from './unreal-export.js';
const FIELDS = [
{ key: 'level', label: 'Level', type: 'text', width: 'wide',
hint: 'The tiles are named after its last segment: L_Region_x0_y0_Height.png' },
{ key: 'planet_circumference_km', label: 'Planet circumference', unit: 'km', type: 'number', step: 1,
hint: 'The sphere carries no metres. This is what turns the window\'s degrees into ground, and it is '
+ 'the number that decides how much land a window can hold. RawContent/World/Planet.json is where '
+ 'this project\'s own value lives; at 100 km round the whole globe is 3183 km2 of surface, so a '
+ 'window of a few hundred km2 is already a large piece of it.' },
{ key: 'centre.lon_deg', label: 'Centre longitude', unit: '°', type: 'number', step: 0.1 },
{ key: 'centre.lat_deg', label: 'Centre latitude', unit: '°', type: 'number', step: 0.1,
hint: 'Keep the window near the equator: an equirectangular reading stretches east-west by '
+ '1/cos(latitude), without bound at the poles.' },
{ key: 'tiles.columns', label: 'Tiles across', type: 'number', step: 1, min: 1 },
{ key: 'tiles.rows', label: 'Tiles down', type: 'number', step: 1, min: 1 },
{ key: 'tiles.vertices', label: 'Vertices a tile', type: 'select',
options: [[1021, '1021 (4 x 4 components)'], [2041, '2041 (8 x 8)'], [2551, '2551 (10 x 10)'],
[3061, '3061 (12 x 12)']],
hint: '255 * N + 1, so the engine gives each tile N x N components of 255 quads. The component count '
+ 'is what costs, not the vertex count.' },
{ key: 'quad_cm', label: 'Quad size', unit: 'cm', type: 'number', step: 1,
hint: 'Metres between vertices, in centimetres. 200 is a 2 m quad.' },
{ key: 'elevation_m.min', label: 'Elevation floor', unit: 'm', type: 'number', step: 1 },
{ key: 'elevation_m.max', label: 'Elevation ceiling', unit: 'm', type: 'number', step: 1,
hint: 'What 0 and 65535 mean. Too narrow clips; too wide only costs height precision, and the export '
+ 'reports both.' },
{ key: 'sea_scale', label: 'Sea scale', type: 'number', step: 0.01,
hint: 'Multiplies everything below sea level. Orogen\'s abyss is 5 km down on a whole-planet ramp, '
+ 'which over a small window is either a clipped plateau or an elevation range so wide the land '
+ 'loses its precision. Land is untouched.' },
{ key: 'source_metres_per_pixel', label: 'Sample spacing', unit: 'm', type: 'number', step: 0.5,
hint: 'How finely the planet is rendered before the tiles are cut from it. The mesh only resolves a '
+ 'couple of hundred metres, so anything below about 25 m here is already lossless.' },
];
const get = (obj, path) => path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
function set(obj, path, value) {
const parts = path.split('.');
const last = parts.pop();
const target = parts.reduce((o, k) => (o[k] = o[k] || {}), obj);
target[last] = value;
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
const STORE_KEY = 'orogen.unrealExport';
function loadSettings() {
const settings = clone(DEFAULTS);
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
for (const { key } of FIELDS) {
const v = get(saved, key);
if (v !== undefined && v !== null) set(settings, key, v);
}
} catch { /* a stale or blocked store is not a reason to refuse to open */ }
return settings;
}
function saveSettings(settings) {
try {
const out = {};
for (const { key } of FIELDS) set(out, key, get(settings, key));
localStorage.setItem(STORE_KEY, JSON.stringify(out));
} catch { /* private windows and blocked site data are fine; the panel just forgets */ }
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (v !== undefined && v !== null) node.setAttribute(k, v);
}
for (const child of children) if (child) node.appendChild(child);
return node;
}
let panel = null;
function build() {
const settings = loadSettings();
const overlay = el('div', { id: 'unrealOverlay', class: 'hidden' });
const card = el('div', { id: 'unrealCard' });
const close = el('button', { id: 'unrealClose', type: 'button', 'aria-label': 'Close', text: '×' });
card.appendChild(close);
card.appendChild(el('h3', { text: 'Export Unreal Landscape' }));
card.appendChild(el('p', { class: 'unreal-blurb', text:
'Renders a window of this planet straight into the tile set Unreal\'s landscape importer wants: a '
+ '16-bit height and three 8-bit weightmaps per tile, plus the Region.json that describes them.' }));
const grid = el('div', { class: 'unreal-grid' });
const inputs = {};
for (const field of FIELDS) {
const wrap = el('div', { class: 'cg' + (field.width === 'wide' ? ' unreal-wide' : '') });
const label = el('label', { text: field.label });
if (field.unit) label.appendChild(el('span', { class: 'v', text: field.unit }));
if (field.hint) label.setAttribute('title', field.hint);
wrap.appendChild(label);
let input;
if (field.type === 'select') {
input = el('select');
for (const [value, text] of field.options) input.appendChild(el('option', { value, text }));
} else if (field.type === 'number') {
// Deliberately not <input type="number">. That control formats and parses in the *browser's*
// locale, so on a machine whose decimal separator is a comma a sea scale of 0.17 is shown as
// "0,17" and `.value` comes back as the empty string - the setting silently becomes NaN and the
// export writes a whole tile set of nothing. A text box with inputmode="decimal" gets the same
// numeric keyboard on a phone and leaves the parsing here, where both separators are accepted.
input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', spellcheck: 'false' });
} else {
input = el('input', { type: field.type, autocomplete: 'off', spellcheck: 'false' });
}
input.value = get(settings, field.key);
input.addEventListener('input', () => {
const raw = input.value;
if (field.type === 'text') {
set(settings, field.key, raw);
} else if (field.type === 'select') {
set(settings, field.key, Number(raw));
} else {
const parsed = Number(String(raw).trim().replace(',', '.'));
input.classList.toggle('unreal-bad', raw.trim() !== '' && !Number.isFinite(parsed));
if (!Number.isFinite(parsed)) return; // keep the last good value while it is being typed
set(settings, field.key, parsed);
}
saveSettings(settings);
refresh();
});
inputs[field.key] = input;
wrap.appendChild(input);
grid.appendChild(wrap);
}
card.appendChild(grid);
const readout = el('div', { class: 'unreal-readout' });
card.appendChild(readout);
const status = el('div', { class: 'unreal-status' });
card.appendChild(status);
const cancel = el('button', { class: 'btn-ghost', type: 'button', text: 'Close' });
const go = el('button', { class: 'btn-primary', type: 'button', text: 'Choose folder & export' });
const actions = el('div', { class: 'export-actions' }, cancel, go);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
function refresh() {
let plan;
try {
plan = planRegion(settings);
} catch (err) {
readout.innerHTML = '';
readout.appendChild(el('div', { class: 'unreal-warn', text: err.message }));
go.disabled = true;
return null;
}
go.disabled = false;
const rows = [
['Ground', `${(plan.widthM / 1000).toFixed(2)} × ${(plan.heightM / 1000).toFixed(2)} km, `
+ `${plan.areaKm2.toFixed(0)} km²`],
['Tiles', `${plan.tileCount} of ${(plan.tileSideM / 1000).toFixed(2)} km, `
+ `${plan.tileCount * plan.componentsPerTile} components`],
['Files', `${plan.tileCount * 4} PNGs, about `
+ `${(plan.tileCount * (settings.tiles.vertices ** 2) * 5 / 1e9).toFixed(1)} GB uncompressed`],
['Window', `${(plan.lonSpan * 180 / Math.PI).toFixed(2)}° × `
+ `${(plan.latSpan * 180 / Math.PI).toFixed(2)}° of the planet`],
['Sampled at', `${plan.metresPerPixel.toFixed(2)} m a pixel `
+ `(${plan.rasterW} × ${plan.rasterH})`],
['E-W stretch', `${((plan.stretchNorth - 1) * 100).toFixed(1)}% north, `
+ `${((plan.stretchSouth - 1) * 100).toFixed(1)}% south`],
];
readout.innerHTML = '';
for (const [name, value] of rows) {
readout.appendChild(el('div', { class: 'unreal-row' },
el('span', { class: 'unreal-key', text: name }),
el('span', { class: 'unreal-val', text: value })));
}
for (const warning of plan.warnings) {
readout.appendChild(el('div', { class: 'unreal-warn', text: warning }));
}
return plan;
}
function setStatus(text, kind = '') {
status.textContent = text;
status.className = 'unreal-status' + (kind ? ' ' + kind : '');
}
go.addEventListener('click', async () => {
if (!window.showDirectoryPicker) {
setStatus('This browser cannot write to a folder. The export needs the File System Access API: '
+ 'use Chrome or Edge on a desktop.', 'unreal-warn');
return;
}
let dir;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite', id: 'orogen-unreal-region' });
} catch {
return; // the picker was dismissed, which is not an error
}
go.disabled = true;
cancel.disabled = true;
try {
const result = await exportUnrealRegion(settings, dir, (fraction, label) => {
setStatus(`${Math.round(fraction * 100)}% — ${label}`);
});
const { meta } = result;
const manifestNote = result.manifestName === 'Region.json'
? 'Region.json written beside RegionTiles/.'
: 'A Region.json was already there and was left alone — the new one is '
+ 'Region.generated.json. Rename it over the old one when you are ready.';
setStatus(`Done. ${result.plan.tileCount} tiles, ground `
+ `${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, `
+ `${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp used`
+ `${meta.clipped > 0 ? `, ${(meta.clipped * 100).toFixed(3)}% clipped — widen the elevation range` : ', nothing clipped'}`
+ `. ${manifestNote}`, meta.clipped > 0 ? 'unreal-warn' : 'unreal-ok');
} catch (err) {
setStatus(`Failed: ${err.message}`, 'unreal-warn');
throw err;
} finally {
go.disabled = false;
cancel.disabled = false;
}
});
const hide = () => overlay.classList.add('hidden');
close.addEventListener('click', hide);
cancel.addEventListener('click', hide);
overlay.addEventListener('click', e => { if (e.target === overlay) hide(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) hide();
});
panel = {
overlay,
open() { overlay.classList.remove('hidden'); refresh(); },
settings,
refresh,
};
refresh();
return panel;
}
export function openUnrealExport() {
(panel || build()).open();
}
/**
* Adds the button that opens the panel to whichever export card the page has. Called by both entry points;
* does nothing if the page has no export card or the button is already there.
*/
export function installUnrealExportButton() {
const actions = document.querySelector('#exportCard .export-actions');
if (!actions || document.getElementById('unrealExportBtn')) return;
const button = el('button', { id: 'unrealExportBtn', class: 'btn-ghost', type: 'button',
text: 'Unreal Landscape…' });
button.addEventListener('click', () => {
document.getElementById('exportOverlay').classList.add('hidden');
openUnrealExport();
});
actions.insertBefore(button, actions.firstChild);
// A handle for headless runs, the same way the painted import exposes window.orogenPainted.
window.orogenUnreal = {
plan: opts => planRegion({ ...(panel ? panel.settings : DEFAULTS), ...(opts || {}) }),
exportTo: (opts, dirHandle, onProgress) => exportUnrealRegion(opts, dirHandle, onProgress),
open: openUnrealExport,
get settings() { return panel ? panel.settings : loadSettings(); },
};
}
+768
View File
@@ -0,0 +1,768 @@
// Wind simulation: pressure-driven seasonal wind with longitude-varying ITCZ.
// Computes pressure fields and wind vectors for summer and winter seasons.
import { elevToHeightKm } from './color-map.js';
import { smoothField, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
// ── Periodic cubic spline interpolation ──────────────────────────────────────
function buildPeriodicSpline(xs, ys) {
// xs: sorted longitude samples (radians), ys: ITCZ latitude values
// Returns spline data for evaluateSpline()
const n = xs.length;
const period = 2 * Math.PI;
// Build tridiagonal system for periodic natural cubic spline
const h = new Float64Array(n);
const alpha = new Float64Array(n);
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
h[i] = (xs[next] - xs[i] + period) % period;
if (h[i] === 0) h[i] = period / n;
}
for (let i = 0; i < n; i++) {
const prev = (i - 1 + n) % n;
const next = (i + 1) % n;
alpha[i] = (3 / h[i]) * (ys[next] - ys[i]) - (3 / h[prev]) * (ys[i] - ys[prev]);
}
// Solve with Thomas-like algorithm for periodic system
// Simplified: use iterative relaxation (fast enough for n=72)
const c = new Float64Array(n);
for (let iter = 0; iter < 20; iter++) {
for (let i = 0; i < n; i++) {
const prev = (i - 1 + n) % n;
const next = (i + 1) % n;
c[i] = (alpha[i] - h[prev] * c[prev] - h[i] * c[next]) /
(2 * (h[prev] + h[i]));
}
}
const b = new Float64Array(n);
const d = new Float64Array(n);
for (let i = 0; i < n; i++) {
const next = (i + 1) % n;
b[i] = (ys[next] - ys[i]) / h[i] - h[i] * (c[next] + 2 * c[i]) / 3;
d[i] = (c[next] - c[i]) / (3 * h[i]);
}
return { xs, ys, b, c, d, h, n, period };
}
function evaluateSpline(spline, lon) {
const { xs, ys, b, c, d, n, period } = spline;
// Normalize lon to [xs[0], xs[0] + period)
let t = ((lon - xs[0]) % period + period) % period + xs[0];
// Direct index calculation — segments are equally spaced
const segStep = period / n;
let seg = Math.floor((t - xs[0]) / segStep);
if (seg < 0) seg = 0;
else if (seg >= n) seg = n - 1;
const dx = t - xs[seg];
return ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx;
}
// ── Smoothstep utility ───────────────────────────────────────────────────────
export function smoothstep(edge0, edge1, x) {
if (edge0 === edge1) return x >= edge1 ? 1 : 0;
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// ── ITCZ computation ─────────────────────────────────────────────────────────
/**
* Build a spatial index binning regions by latitude/longitude for fast
* geographic sampling. Returns a function landFracAndElev(lat, lon, radius)
* that returns { landFrac, avgElev } by scanning nearby bins.
*/
function buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions) {
const LAT_BINS = 36; // 5° each
const LON_BINS = 72; // 5° each
const numBins = LAT_BINS * LON_BINS;
// CSR (compressed sparse row) format: count regions per bin, then prefix-sum
// Cache bin index per region to avoid recomputing in the fill pass
const r_bin = new Uint32Array(numRegions);
const binCount = new Uint32Array(numBins);
for (let r = 0; r < numRegions; r++) {
const latBin = Math.max(0, Math.min(LAT_BINS - 1,
Math.floor((r_lat[r] + Math.PI / 2) / Math.PI * LAT_BINS)));
const lonBin = Math.max(0, Math.min(LON_BINS - 1,
Math.floor((r_lon[r] + Math.PI) / (2 * Math.PI) * LON_BINS)));
const bin = latBin * LON_BINS + lonBin;
r_bin[r] = bin;
binCount[bin]++;
}
const binOffset = new Uint32Array(numBins + 1);
for (let i = 0; i < numBins; i++) {
binOffset[i + 1] = binOffset[i] + binCount[i];
}
const indices = new Uint32Array(numRegions);
const fillPos = new Uint32Array(numBins);
for (let r = 0; r < numRegions; r++) {
const bin = r_bin[r];
indices[binOffset[bin] + fillPos[bin]] = r;
fillPos[bin]++;
}
/**
* Sample land fraction and average elevation in a circular region.
* @param {number} lat - center latitude (radians)
* @param {number} lon - center longitude (radians)
* @param {number} radius - great-circle radius (radians)
*/
return function sample(lat, lon, radius) {
const latMin = lat - radius, latMax = lat + radius;
const bMin = Math.max(0, Math.floor((latMin + Math.PI / 2) / Math.PI * LAT_BINS));
const bMax = Math.min(LAT_BINS - 1, Math.floor((latMax + Math.PI / 2) / Math.PI * LAT_BINS));
// Longitude span widens near equator
const cosLat = Math.cos(lat) || 0.01;
const lonSpan = radius / cosLat;
const lMin = Math.floor((lon - lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
const lMax = Math.floor((lon + lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
let landCount = 0, totalCount = 0, elevSum = 0;
const cosRadius = Math.cos(radius);
const sinLat0 = Math.sin(lat), cosLat0 = Math.cos(lat);
for (let bi = bMin; bi <= bMax; bi++) {
for (let li = lMin; li <= lMax; li++) {
const lj = ((li % LON_BINS) + LON_BINS) % LON_BINS;
const bin = bi * LON_BINS + lj;
const start = binOffset[bin];
const end = binOffset[bin + 1];
for (let k = start; k < end; k++) {
const r = indices[k];
const sinLat1 = r_sinLat[r];
const cosLat1 = r_cosLat[r];
const dlon = r_lon[r] - lon;
const cosDist = sinLat0 * sinLat1 + cosLat0 * cosLat1 * Math.cos(dlon);
if (cosDist >= cosRadius) {
totalCount++;
if (r_isLand[r]) landCount++;
elevSum += Math.max(0, r_elevation[r]);
}
}
}
}
if (totalCount === 0) return { landFrac: 0, avgElev: 0 };
return { landFrac: landCount / totalCount, avgElev: elevSum / totalCount };
};
}
/**
* Compute ITCZ latitude at sampled longitudes for a given season.
* Uses a thermal equator search: scans latitudes from -30° to +30°,
* computes a heating score at each, and picks the peak.
*
* Heating score combines:
* - Solar insolation (cosine of latitude offset from subsolar point)
* - Land thermal boost (land heats faster than ocean)
* - Elevation boost (plateaus heat more intensely — thinner atmosphere)
* - Cross-equatorial anchoring (winter-hemisphere land pulls ITCZ equatorward)
*
* @param {function} geoSample - from buildGeoIndex
* @param {string} season - 'summer' (NH) or 'winter' (NH)
* @param {number} tiltRad - axial tilt in radians
* @returns {{ spline, lons: Float64Array, lats: Float64Array }}
*/
function computeITCZ(geoSample, season, tiltRad) {
const NUM_LON = 72;
// Two sampling radii: local (5°) for precise land detection, wide (30°) for continental scale
const localRadius = 5 * DEG;
const wideRadius = 30 * DEG;
// +1 = NH summer, -1 = SH summer (NH winter)
const sign = season === 'summer' ? 1 : -1;
// Subsolar latitude: where the sun is directly overhead this season
// Full tilt in summer hemisphere (e.g. +23.5° for NH summer)
const subsolarLat = sign * tiltRad;
// Scan range: -30° to +30° in 2.5° steps
const SCAN_MIN = -30;
const SCAN_MAX = 30;
const SCAN_STEP = 2.5;
const numScans = Math.round((SCAN_MAX - SCAN_MIN) / SCAN_STEP) + 1;
const lons = new Float64Array(NUM_LON);
const rawLats = new Float64Array(NUM_LON);
for (let i = 0; i < NUM_LON; i++) {
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / NUM_LON);
lons[i] = lon;
let bestScore = -Infinity;
let bestLat = sign * 5 * DEG; // fallback
for (let si = 0; si < numScans; si++) {
const latDeg = SCAN_MIN + si * SCAN_STEP;
const lat = latDeg * DEG;
const local = geoSample(lat, lon, localRadius);
const wide = geoSample(lat, lon, wideRadius);
// (a) Solar insolation: peaks at subsolar latitude, broad Gaussian falloff.
// σ = 25° gives a wide heating dome — the ITCZ doesn't track the
// subsolar point 1:1, it lags and is damped by ocean thermal inertia.
const dSolar = (lat - subsolarLat) * RAD; // degrees from subsolar
const solarScore = Math.exp(-0.5 * (dSolar / 25) ** 2);
// (b) Land thermal boost: uses multi-scale sampling.
// Only truly continental-scale landmasses pull the ITCZ significantly.
// Islands, thin peninsulas, and coastlines near ocean register low at
// the wide (30°) radius and get suppressed by the steep ramp.
const localLand = local.landFrac;
const wideLand = wide.landFrac;
// Also sample poleward of this latitude: a massive continent extending
// poleward (like Asia beyond 20°N) creates an enormous heat reservoir
// that pulls the ITCZ toward it even if the scan point itself is at
// the continent's edge. Sample 15° poleward in the summer hemisphere.
const polewardLat = lat + sign * 15 * DEG;
const poleward = geoSample(polewardLat, lon, wideRadius);
// Combined land signal: max of local-wide and poleward-wide.
// Poleward land contributes at 70% strength (heat diffuses equatorward).
const effectiveWideLand = Math.max(wideLand, poleward.landFrac * 0.7);
// Wide-scale land must exceed ~20% before any real pull kicks in.
const continentalScale = smoothstep(0.20, 0.45, effectiveWideLand);
// Square it so moderate land fractions still contribute little.
const scaledLand = continentalScale * continentalScale;
// Local land gate: require >25% local land fraction to activate.
// At 5° radius (~560 km), ocean near thin islands stays well below this.
const landGate = smoothstep(0.25, 0.55, localLand);
// Strong max boost so massive continents pull ITCZ toward 25-30°
const landBoost = landGate * scaledLand * 1.0;
// (c) Elevation boost: high plateaus heat more intensely
// (thinner atmosphere, stronger surface insolation).
// Also scaled by continental size — isolated volcanic peaks don't pull ITCZ.
const elevKm = elevToHeightKm(Math.max(0, wide.avgElev));
const elevBoost = Math.min(0.30, elevKm * 0.12) * scaledLand;
// (d) Cross-equatorial anchoring: if this latitude is in the
// winter hemisphere but there's significant land, it anchors
// the ITCZ closer to the equator (resists poleward migration).
const isWinterHemi = (sign > 0 && latDeg < 0) || (sign < 0 && latDeg > 0);
const anchorBoost = isWinterHemi ? landBoost * 0.4 : 0;
// (e) Ocean baseline: slight poleward bias in summer hemisphere
// even over open ocean (~6-8° from equator on average).
const isSummerHemi = !isWinterHemi;
const oceanBias = isSummerHemi && localLand < 0.1
? 0.08 * Math.exp(-0.5 * ((Math.abs(latDeg) - 7) / 5) ** 2)
: 0;
const score = solarScore + landBoost + elevBoost + anchorBoost + oceanBias;
if (score > bestScore) {
bestScore = score;
bestLat = lat;
}
}
rawLats[i] = bestLat;
}
// Pull extreme outliers toward the zonal mean before longitude smoothing.
// The ITCZ is a planetary-scale feature — individual longitude columns
// shouldn't deviate too far from the overall trend.
const lats = new Float64Array(rawLats);
const tmp = new Float64Array(NUM_LON);
// Wide periodic moving average (kernel = 5 neighbors) for heavy smoothing,
// then narrow (kernel = 3) for fine cleanup. More passes = smoother ITCZ.
// Wide kernel: weights [0.1, 0.2, 0.4, 0.2, 0.1] over 5 neighbors
for (let pass = 0; pass < 4; pass++) {
for (let i = 0; i < NUM_LON; i++) {
const p2 = (i - 2 + NUM_LON) % NUM_LON;
const p1 = (i - 1 + NUM_LON) % NUM_LON;
const n1 = (i + 1) % NUM_LON;
const n2 = (i + 2) % NUM_LON;
tmp[i] = 0.1 * lats[p2] + 0.2 * lats[p1] + 0.4 * lats[i] + 0.2 * lats[n1] + 0.1 * lats[n2];
}
lats.set(tmp);
}
// Narrow cleanup passes
for (let pass = 0; pass < 3; pass++) {
for (let i = 0; i < NUM_LON; i++) {
const p = (i - 1 + NUM_LON) % NUM_LON;
const n = (i + 1) % NUM_LON;
tmp[i] = 0.25 * lats[p] + 0.5 * lats[i] + 0.25 * lats[n];
}
lats.set(tmp);
}
// Clamp to ±30° (ITCZ never migrates beyond the tropics)
for (let i = 0; i < NUM_LON; i++) {
lats[i] = Math.max(-30 * DEG, Math.min(30 * DEG, lats[i]));
}
const spline = buildPeriodicSpline(lons, lats);
return { spline, lons, lats };
}
// ── Pressure field ───────────────────────────────────────────────────────────
/**
* Compute pressure at a single region.
*/
function regionPressure(lat, lon, itczSpline, season, landFrac, elevation, noiseFn, px, py, pz) {
const itczLat = evaluateSpline(itczSpline, lon);
const latDeg = lat * RAD;
const seasonSign = season === 'summer' ? 1 : -1;
let p = 1013; // baseline hPa
// (a) ITCZ low — follows thermal equator
const dItcz = (lat - itczLat) * RAD; // degrees from ITCZ
p -= 15 * Math.exp(-0.5 * (dItcz / 8) ** 2);
// (b) Subtropical highs — shift with season, weaker over hot land
const shiftDeg = seasonSign * 5;
const nhSubHigh = 30 + shiftDeg;
const shSubHigh = -(30 - shiftDeg);
const highIntensity = 12 * (1 - 0.3 * landFrac);
p += highIntensity * Math.exp(-0.5 * ((latDeg - nhSubHigh) / 10) ** 2);
p += highIntensity * Math.exp(-0.5 * ((latDeg - shSubHigh) / 10) ** 2);
// (c) Subpolar lows
p -= 10 * Math.exp(-0.5 * ((latDeg - 60) / 10) ** 2);
p -= 10 * Math.exp(-0.5 * ((latDeg + 60) / 10) ** 2);
// (d) Polar highs
p += 8 * Math.exp(-0.5 * ((latDeg - 85) / 8) ** 2);
p += 8 * Math.exp(-0.5 * ((latDeg + 85) / 8) ** 2);
// (e) Land/sea thermal modifier
// landFrac here is actually continentality (0 at coast → ~1 deep interior).
// Only continental-scale landmasses produce meaningful thermal pressure:
// small islands (continentality < 0.2) → 0, ramps to full at 0.5+.
const continentalScale = smoothstep(0.2, 0.5, landFrac);
if (continentalScale > 0.001) {
// Continental thermal effect profile:
// 0 at 0-15°, rises to ~0.75 at 30°, plateau ~1.0 at 45-60°, falls to ~0.5 at 75°, 0 at 90°
const absLatDeg = Math.abs(lat) * RAD;
const latFactor = absLatDeg < 15 ? 0
: absLatDeg < 30 ? 0.75 * smoothstep(15, 30, absLatDeg)
: absLatDeg < 45 ? 0.75 + 0.25 * smoothstep(30, 45, absLatDeg)
: absLatDeg < 60 ? 1
: absLatDeg < 90 ? smoothstep(90, 60, absLatDeg)
: 0;
const isSummerHemisphere = (seasonSign > 0 && lat > 0) || (seasonSign < 0 && lat < 0);
if (isSummerHemisphere) {
// Thermal low over hot continent
p -= 10 * latFactor * continentalScale;
} else {
// Thermal high over cold continent (stronger — Siberian/Canadian highs)
p += 14 * latFactor * continentalScale;
}
}
// (f) Elevation (barometric) — mild effect; real weather maps use
// sea-level-reduced pressure so elevation doesn't dominate zonal bands
p -= 3 * elevToHeightKm(Math.max(0, elevation));
// (g) Noise perturbation
if (noiseFn) {
p += noiseFn.fbm(px * 2, py * 2, pz * 2, 3) * 2;
}
return p;
}
// ── Pressure gradient on mesh ────────────────────────────────────────────────
export function computeGradients(mesh, r_xyz, r_pressure,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_gradE, r_gradN) {
const { adjOffset, adjList, numRegions } = mesh;
for (let r = 0; r < numRegions; r++) {
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
const ex = r_eastX[r], ey = r_eastY[r], ez = r_eastZ[r];
const nx = r_northX[r], ny = r_northY[r], nz = r_northZ[r];
const pHere = r_pressure[r];
let sumEP = 0, sumEE = 0, sumNP = 0, sumNN = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
const dx = r_xyz[3 * nb] - px;
const dy = r_xyz[3 * nb + 1] - py;
const dz = r_xyz[3 * nb + 2] - pz;
const de = dx * ex + dy * ey + dz * ez;
const dn = dx * nx + dy * ny + dz * nz;
const dp = r_pressure[nb] - pHere;
sumEP += de * dp;
sumEE += de * de;
sumNP += dn * dp;
sumNN += dn * dn;
}
r_gradE[r] = sumEE > 1e-12 ? sumEP / sumEE : 0;
r_gradN[r] = sumNN > 1e-12 ? sumNP / sumNN : 0;
}
}
// ── Pressure gradient → wind ─────────────────────────────────────────────────
function pressureToWind(r_gradE, r_gradN, r_sinLat,
r_windE, r_windN, r_windSpeed, numRegions) {
const sin5 = Math.sin(5 * DEG);
for (let r = 0; r < numRegions; r++) {
// PGF: from high to low = negative gradient
const pgfE = -r_gradE[r];
const pgfN = -r_gradN[r];
const sinLat = r_sinLat[r];
const absSinLat = Math.abs(sinLat);
// Geostrophic deflection: 0° at equator → 70° at ≥5° latitude
const geoAngle = 70 * DEG * smoothstep(0, sin5, absSinLat);
// Surface friction turns wind 20° back toward low pressure
const frictionAngle = 20 * DEG;
// Net rotation: NH = clockwise (negative), SH = counterclockwise (positive)
// The rotation matrix [cosθ,-sinθ; sinθ,cosθ] is counterclockwise for +θ,
// so NH right-deflection needs negative angle, SH left-deflection needs positive.
const sign = sinLat >= 0 ? -1 : 1;
const totalAngle = sign * (geoAngle - frictionAngle);
const cosA = Math.cos(totalAngle);
const sinA = Math.sin(totalAngle);
// Rotate PGF vector and apply friction speed reduction
const we = (pgfE * cosA - pgfN * sinA) * 0.6;
const wn = (pgfE * sinA + pgfN * cosA) * 0.6;
r_windE[r] = we;
r_windN[r] = wn;
r_windSpeed[r] = Math.sqrt(we * we + wn * wn);
}
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute seasonal pressure fields and wind vectors.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions (3 * numRegions)
* @param {Float32Array} r_elevation - per-region elevation
* @param {Set} plateIsOcean - ocean plate seed set
* @param {Int32Array} r_plate - per-region plate ID
* @param {SimplexNoise} noise - seeded noise instance
* @param {number} [axialTilt=23.5] - axial tilt in degrees
* @returns {object} pressure and wind arrays for both seasons
*/
export function computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt = 23.5) {
const numRegions = mesh.numRegions;
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const tiltRad = axialTilt * DEG;
const timing = [];
// ── Step 0: Precompute per-region properties ──
let t0 = performance.now();
const r_lat = new Float32Array(numRegions);
const r_lon = new Float32Array(numRegions);
const r_sinLat = new Float32Array(numRegions);
const r_cosLat = new Float32Array(numRegions);
const r_isLand = new Uint8Array(numRegions);
// Tangent frame arrays
const r_eastX = new Float32Array(numRegions);
const r_eastY = new Float32Array(numRegions);
const r_eastZ = new Float32Array(numRegions);
const r_northX = new Float32Array(numRegions);
const r_northY = new Float32Array(numRegions);
const r_northZ = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
// Y-up convention (matches map projection)
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
r_lon[r] = Math.atan2(x, z);
r_sinLat[r] = y;
r_cosLat[r] = Math.sqrt(1 - y * y) || 0.01;
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
// East = normalize(Ŷ × P) = normalize(z, 0, -x)
let ex = z, ey = 0, ez = -x;
let elen = Math.sqrt(ex * ex + ez * ez);
if (elen < 1e-10) { ex = 1; ez = 0; elen = 1; } // pole fallback
ex /= elen; ez /= elen;
// North = P × East
let nx = y * ez - z * ey;
let ny = z * ex - x * ez;
let nz = x * ey - y * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= nlen; ny /= nlen; nz /= nlen;
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
r_northX[r] = nx; r_northY[r] = ny; r_northZ[r] = nz;
}
timing.push({ stage: 'Wind: precompute lat/lon/tangent', ms: performance.now() - t0 });
// ── Step 1: Build geographic index + compute ITCZ ──
t0 = performance.now();
const geoSample = buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions);
const itczSummer = computeITCZ(geoSample, 'summer', tiltRad);
const itczWinter = computeITCZ(geoSample, 'winter', tiltRad);
timing.push({ stage: 'Wind: ITCZ computation', ms: performance.now() - t0 });
// ── Step 2–5: Compute pressure & wind for each season ──
const seasons = [
{ name: 'summer', itcz: itczSummer },
{ name: 'winter', itcz: itczWinter }
];
const result = {};
// Precompute continentality via BFS coast distance.
// Laplacian smoothing of binary r_isLand converges too fast — interior
// cells hit 0.95+ within a few hundred km. Instead, compute actual
// hop distance from coast through land, convert to km, and map with
// smoothstep for a wide, tunable gradient.
// 0 km (coast): cont ≈ 0.0
// 500 km: cont ≈ 0.16
// 1000 km: cont ≈ 0.50
// 1500 km: cont ≈ 0.84
// 2000 km+: cont ≈ 1.0
// Ocean cells near coast get a small value (~0.05–0.15) via a few
// smoothing passes, giving a natural land/sea thermal gradient.
t0 = performance.now();
const { adjOffset, adjList } = mesh;
// Find the main ocean: largest connected component of non-land cells.
// Inland seas / small lakes don't count as "ocean" for continentality.
const r_oceanLabel = new Int32Array(numRegions);
r_oceanLabel.fill(-1);
let mainOceanLabel = -1, mainOceanSize = 0;
let nextLabel = 0;
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] || r_oceanLabel[r] >= 0) continue;
const label = nextLabel++;
let size = 0;
const floodQueue = [r];
r_oceanLabel[r] = label;
let fHead = 0;
while (fHead < floodQueue.length) {
const cur = floodQueue[fHead++];
size++;
const end = adjOffset[cur + 1];
for (let ni = adjOffset[cur]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb] && r_oceanLabel[nb] === -1) {
r_oceanLabel[nb] = label;
floodQueue.push(nb);
}
}
}
if (size > mainOceanSize) {
mainOceanSize = size;
mainOceanLabel = label;
}
}
// BFS coast distance through land, seeded only from main-ocean coastline
const r_coastDist = new Int32Array(numRegions);
r_coastDist.fill(-1);
const bfsQueue = [];
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb] && r_oceanLabel[nb] === mainOceanLabel) {
r_coastDist[r] = 0;
bfsQueue.push(r);
break;
}
}
}
let head = 0;
while (head < bfsQueue.length) {
const r = bfsQueue[head++];
const d = r_coastDist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (r_isLand[nb] && r_coastDist[nb] === -1) {
r_coastDist[nb] = d;
bfsQueue.push(nb);
}
}
}
// Map BFS distance to continentality [0, 1]
const CONT_RANGE_KM = 2000; // distance at which cont reaches ~1.0
const r_continentality = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] && r_coastDist[r] >= 0) {
const distKm = r_coastDist[r] * avgEdgeKm;
r_continentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
}
// Ocean cells stay at 0; a few smooth passes below will bleed
// small values onto nearshore ocean for thermal gradient.
}
// Light smoothing (~100 km) to soften BFS stepping artifacts and
// bleed a small thermal signal onto nearshore ocean cells.
const contSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, r_continentality, contSmoothPasses);
// Plate-based continentality: uses plate type (continental vs oceanic)
// instead of actual land/ocean. Same BFS approach for wide gradient.
const r_plateContinentality = new Float32Array(numRegions);
// BFS through continental-plate cells
const r_plateDist = new Int32Array(numRegions);
r_plateDist.fill(-1);
const plateBfsQueue = [];
for (let r = 0; r < numRegions; r++) {
if (plateIsOcean.has(r_plate[r])) continue; // skip oceanic plate cells
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
if (plateIsOcean.has(r_plate[adjList[ni]])) {
r_plateDist[r] = 0;
plateBfsQueue.push(r);
break;
}
}
}
head = 0;
while (head < plateBfsQueue.length) {
const r = plateBfsQueue[head++];
const d = r_plateDist[r] + 1;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!plateIsOcean.has(r_plate[nb]) && r_plateDist[nb] === -1) {
r_plateDist[nb] = d;
plateBfsQueue.push(nb);
}
}
}
for (let r = 0; r < numRegions; r++) {
if (!plateIsOcean.has(r_plate[r]) && r_plateDist[r] >= 0) {
const distKm = r_plateDist[r] * avgEdgeKm;
r_plateContinentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
}
}
smoothField(mesh, r_plateContinentality, contSmoothPasses);
timing.push({ stage: 'Wind: continentality BFS', ms: performance.now() - t0 });
// Shared gradient scratch arrays
const r_gradE = new Float32Array(numRegions);
const r_gradN = new Float32Array(numRegions);
// Smooth pressure field ~75 km (scale-invariant) — constant across seasons
const pressSmoothPasses = Math.max(1, Math.round(75 / avgEdgeKm));
for (const { name, itcz } of seasons) {
// Step 2: Pressure field
t0 = performance.now();
const r_pressure = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_pressure[r] = regionPressure(
r_lat[r], r_lon[r], itcz.spline, name,
r_continentality[r], r_elevation[r], noise,
r_xyz[3 * r], r_xyz[3 * r + 1], r_xyz[3 * r + 2]
);
}
smoothField(mesh, r_pressure, pressSmoothPasses);
timing.push({ stage: `Wind: pressure field (${name})`, ms: performance.now() - t0 });
// Step 3: Gradient
t0 = performance.now();
r_gradE.fill(0);
r_gradN.fill(0);
computeGradients(mesh, r_xyz, r_pressure,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_gradE, r_gradN);
timing.push({ stage: `Wind: gradient (${name})`, ms: performance.now() - t0 });
// Step 4: Wind
t0 = performance.now();
const r_windE = new Float32Array(numRegions);
const r_windN = new Float32Array(numRegions);
const r_windSpeed = new Float32Array(numRegions);
pressureToWind(r_gradE, r_gradN, r_sinLat,
r_windE, r_windN, r_windSpeed, numRegions);
// Step 5: Normalize wind speed to 0-1
const maxSpeed = percentile(r_windSpeed, 0.95);
for (let r = 0; r < numRegions; r++) {
r_windSpeed[r] = Math.min(1, r_windSpeed[r] / maxSpeed);
}
timing.push({ stage: `Wind: pressure→wind (${name})`, ms: performance.now() - t0 });
// Store pressure as deviation from 1013 for visualization (blue=low, red=high)
const r_pressureDev = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_pressureDev[r] = r_pressure[r] - 1013;
}
const S = name === 'summer' ? 'Summer' : 'Winter';
result[`r_pressure_${name}`] = r_pressureDev;
result[`r_wind_east_${name}`] = r_windE;
result[`r_wind_north_${name}`] = r_windN;
result[`r_wind_speed_${name}`] = r_windSpeed;
}
// Pre-evaluate ITCZ splines at 360 longitude points for visualization
const ITCZ_SAMPLES = 360;
const itczLons = new Float32Array(ITCZ_SAMPLES);
const itczLatsSummer = new Float32Array(ITCZ_SAMPLES);
const itczLatsWinter = new Float32Array(ITCZ_SAMPLES);
for (let i = 0; i < ITCZ_SAMPLES; i++) {
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / ITCZ_SAMPLES);
itczLons[i] = lon;
itczLatsSummer[i] = evaluateSpline(itczSummer.spline, lon);
itczLatsWinter[i] = evaluateSpline(itczWinter.spline, lon);
}
result.itczLons = itczLons;
result.itczLatsSummer = itczLatsSummer;
result.itczLatsWinter = itczLatsWinter;
// Expose precomputed geographic data for downstream modules (ocean.js)
result.r_lat = r_lat;
result.r_lon = r_lon;
result.r_sinLat = r_sinLat;
result.r_isLand = r_isLand;
result.r_continentality = r_continentality;
result.r_coastDistLand = r_coastDist;
result.r_plateContinentality = r_plateContinentality;
result.r_eastX = r_eastX;
result.r_eastY = r_eastY;
result.r_eastZ = r_eastZ;
result.r_northX = r_northX;
result.r_northY = r_northY;
result.r_northZ = r_northZ;
result._windTiming = timing;
return result;
}
+49
View File
@@ -0,0 +1,49 @@
# World Orogen
> Procedural planet generator — free, browser-based, no signup required.
World Orogen generates realistic procedural planets shaped by tectonic plate simulation, erosion, and climate modeling. It runs entirely in the browser using Three.js and requires no installation, account, or payment.
## What it does
- Generates unique terrestrial planets with realistic continents, mountains, ocean trenches, and volcanic islands
- Simulates tectonic plates with convergent, divergent, and transform boundaries
- Applies glacial, hydraulic, and thermal erosion to carve fjords, river valleys, and talus slopes
- Simulates seasonal climate: wind patterns, ocean currents, precipitation, and Köppen classification
- Creates hotspot volcanism with drift-trail island chains (like Hawaii)
- Allows interactive editing — select multiple tectonic plates for batch reshaping with visual preview before rebuild
- Import your own equirectangular B&W heightmaps (Earth, Mars, hand-drawn) onto a 3D globe with automatic climate simulation
- Import a painted map whose colours are uplift rates and erodibilities (with a legend JSON) and solve it with stream-power erosion into terrain with real rivers, divides and drainage basins; export class, uplift, erodibility, drainage, slope, basin and overlay maps
- Show each class's typical and divide hillslope angle before solving, so a legend's uplift rates can be read as terrain rather than as numbers
- Annotate a painted world with an overlay layer of forests, settlements, roads and coastlines, drawn as a texture over the globe and the map, where one mark property pins or roughens the shore
- Export a window of the planet as Unreal Engine landscape tiles — per-tile 16-bit heights at 255*N+1 vertices, an 8-bit weightmap per paint layer, and a manifest that records the scale, the window in degrees and the projection, so the game engine side never has to guess metres-per-pixel
## Who it's for
- Worldbuilders creating fantasy or sci-fi settings
- Game developers who need heightmaps for Unity, Unreal, or other engines
- Tabletop RPG players building campaign worlds (D&D, Pathfinder, etc.)
- Fantasy/sci-fi authors designing believable planets
- Artists and hobbyists who enjoy procedural generation
- Educators teaching plate tectonics and planetary science
## Key capabilities
- Multiple views: terrain, satellite biome, Köppen climate, heightmap
- 26 detailed inspection layers (geology, atmosphere, ocean, climate)
- Export high-resolution equirectangular maps up to 65,536px wide
- Shareable planet codes — copy a compact code to reproduce any planet exactly
- Works on desktop and mobile browsers
- No build step, no dependencies to install — pure ES modules
## URL
https://orogen.studio/
## Technical details
- Built with Three.js (WebGL), vanilla JavaScript ES modules
- Fibonacci sphere meshing with Voronoi tessellation
- Braun-Willett stream power erosion, Barnes et al. pit-filling algorithm
- Dual-model precipitation (advection simulation + zonal heuristic)
- Fully client-side — no server, no data collection
+242
View File
@@ -0,0 +1,242 @@
# Seasonal Wind Simulation — Pressure-Driven with Longitude-Varying ITCZ
## Context
World Orogen has zero climate/atmospheric simulation. This adds seasonal wind driven by high/low pressure zones — the core physical mechanism behind all planetary wind. The ITCZ (low pressure convergence) tracks a longitude-varying "thermal equator" that hugs the equator over ocean but pushes 15-20° poleward over continents, creating monsoons and seasonal wind reversals. Inspired by Worldbuilding Pasta's climate methodology and Madeline James's pressure band approach.
---
## Algorithm
### Step 1: Compute the Thermal Equator / ITCZ Latitude (per season)
The ITCZ is NOT at a fixed latitude — it follows the hottest zone at each longitude.
**Approach**: Sample ~72 evenly-spaced longitudes (every 5°). At each longitude, scan latitudes from -30° to +30° to find the thermal maximum, then smooth with a periodic spline.
For each longitude sample, compute an "effective heating" at each latitude:
```
heating(lat, lon, season) = solarFlux(lat, season)
× (1 + 0.3 * landFraction(lat, lon, radius=10°))
- 0.006 * avgElevation(lat, lon, radius=10°)
```
Where:
- `solarFlux(lat, season)` = `cos(lat - subsolarLat)` clamped to [0,1]. `subsolarLat = tilt * sin(seasonAngle)` — 23.5° in summer, -23.5° in winter
- `landFraction` is sampled by scanning nearby regions within a ~10° great-circle radius. Land amplifies heating by up to 30% (land heats faster than ocean — Madeline James's core insight)
- `avgElevation` applies a lapse-rate cooling for high terrain
The latitude with maximum heating at each longitude = ITCZ position at that longitude.
**Result constraints** (inspired by both references):
- Over ocean: ITCZ stays ~5° from equator in summer hemisphere
- Over large land: ITCZ pushes to 15-20° from equator
- Default with no land: ~5° toward summer hemisphere (Earth's observed default)
**Smoothing**: Fit a periodic cubic spline through the 72 longitude samples. This guarantees smooth, non-jagged ITCZ contours.
**Data structure**: `itczLatAtLon(lon)` — returns ITCZ latitude in radians for any longitude.
### Step 2: Build Pressure Field (per season, per region)
Five additive components centered on the ITCZ position:
**a) ITCZ Low** (follows the thermal equator):
```
p_itcz = -15 * exp(-0.5 * ((lat - itczLat(lon)) / σ_itcz)²)
```
σ_itcz = 8° (~0.14 rad). A broad Gaussian trough that tracks the ITCZ.
**b) Subtropical Highs** (~25° winter, ~35° summer — per Worldbuilding Pasta):
- NH subtropical high at `+30 + seasonShift*5` degrees
- SH subtropical high at `-30 - seasonShift*5` degrees
- These are NOT a continuous belt — they're strongest over cool ocean. Modulate intensity:
```
highIntensity = 12 * (1 - 0.3 * landFraction) // weaker over hot land
```
- Gaussian with σ = 10°
**c) Subpolar Lows** at ~±60°:
```
p_subpolar = -10 * exp(-0.5 * ((lat ∓ 60°) / 10°)²)
```
**d) Polar Highs** at ~±85°:
```
p_polar = +8 * exp(-0.5 * ((lat ∓ 85°) / 8°)²)
```
**e) Land/Sea Thermal Modifier** (seasonal continental pressure — Madeline James):
- Summer hemisphere continents: thermal low (up to -8 hPa at mid-latitudes)
- Winter hemisphere continents: thermal high (up to +6 hPa)
- Modulated by `sin(2 * |lat|)` (peaks at 45°, weak at equator/poles)
- Scaled by land fraction in local area
**f) Elevation (barometric)**:
```
p_elev = -100 * max(0, elevation)
```
High plateaus = persistent low pressure. Mountains deflect wind naturally.
**g) Noise**: Low-frequency seeded Simplex fBm, ±2 hPa amplitude.
**h) Smoothing**: 3 Laplacian passes over mesh neighbors. Removes discretization artifacts and naturally diffuses land/sea contrast inward from coasts.
### Step 3: Compute Pressure Gradient (per region)
Least-squares fit over mesh neighbors, projecting onto local tangent plane:
For each region r with neighbors n₁..nₖ:
- Project displacement (nᵢ - r) onto east/north tangent vectors
- Accumulate `Σ(δe·δp)/Σ(δe²)` for eastward gradient, same for northward
- This gives `gradE`, `gradN` in the tangent plane
### Step 4: Pressure → Wind with Cross-Equatorial Handling
**Core conversion**: PGF direction = `-∇P` (high→low). Coriolis rotates this.
**The key insight for cross-equatorial flow**: `f = 2Ω·sin(lat)` naturally changes sign at the equator. We use this directly — no special-casing needed for monsoon winds. The SE trades in the SH (deflected left by negative f) naturally become SW monsoon winds in the NH (deflected right by positive f) as they cross the equator chasing the ITCZ.
**Implementation**:
```
f_coriolis = sin(lat) // proportional to Coriolis parameter
absSinLat = |f_coriolis|
// Geostrophic deflection angle: 0° at equator → 70° at mid-latitudes
// Ramps up over ~10° latitude (equatorial Rossby radius)
geoAngle = 70° * smoothstep(0, sin(10°), absSinLat)
// Surface friction: turns wind 20° back toward low pressure, reduces speed 40%
frictionAngle = 20°
// Net rotation from PGF: sign determines NH (right) vs SH (left)
sign = (lat >= 0) ? +1 : -1
totalAngle = sign * (geoAngle - frictionAngle)
// Rotate PGF vector
windE = pgfE·cos(totalAngle) - pgfN·sin(totalAngle)
windN = pgfE·sin(totalAngle) + pgfN·cos(totalAngle)
// Speed reduction from friction
wind *= 0.6
```
**Why this works for cross-equatorial flow**:
- At 10°S: sign=-1, geoAngle≈70° → rotation = -50° (leftward). SE trades.
- At 0°: geoAngle=0° → no rotation. Wind follows PGF directly (northward toward ITCZ).
- At 5°N: sign=+1, geoAngle≈35° → rotation = +15° (rightward). Wind turns from S to SW.
- At 10°N: sign=+1, geoAngle≈70° → rotation = +50°. Full SW monsoon westerlies.
The transition happens naturally over ~10° of latitude — smooth, physically correct, no heuristic needed.
### Step 5: Normalize
Scale wind speed to 0-1 using 95th percentile for visualization.
### Coordinate Convention
Map projection uses Y-up: `lat = asin(r_xyz[3*r+1])`, `lon = atan2(r_xyz[3*r], r_xyz[3*r+2])`.
Tangent frame (Y-up polar axis):
- East = normalize(z, 0, -x) [fallback at poles where x²+z² < ε]
- North = cross(position, east)
---
## File Changes
### New: `js/wind.js` (~300 lines)
Exported:
- `computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt=23.5)`
→ returns pressure/wind arrays for both seasons
Internal helpers:
- `computeITCZ(lonSamples, r_xyz, r_elevation, r_isLand, season, tilt)` — scan latitudes per longitude, find thermal max, return spline
- `evaluateITCZSpline(lon, splineData)` — periodic cubic interpolation
- `zonalPressure(lat, lon, itczSpline, season, landFrac)` — all Gaussian bands + thermal modifier
- `smoothPressure(mesh, pressure, passes)` — Laplacian over neighbors
- `computeGradients(...)` — least-squares pressure gradient
- `pressureToWind(gradE, gradN, sinLat)` — geostrophic + friction + cross-equatorial
### Modified: `js/planet-worker.js` (~25 lines)
- Import `computeWind` from `./wind.js`
- In `handleGenerate`: call after terrain post-processing, before triangle elevations. Add pressure/speed arrays to `debugLayers`, add wind vectors to result + transfer list. Store in retained state `W`.
- In `handleReapply`: recompute wind (elevation changed)
- In `handleEditRecompute`: recompute wind (plates/elevation changed)
### Modified: `js/generate.js` (~15 lines)
- In `case 'done'`, `'reapplyDone'`, `'editDone'`: store wind vectors in `state.curData`
- In synchronous fallback: call `computeWind` directly
### Modified: `index.html` (~4 lines)
Add to `#debugLayer` select:
```html
<option value="pressureSummer">Pressure (Summer)</option>
<option value="pressureWinter">Pressure (Winter)</option>
<option value="windSpeedSummer">Wind Speed (Summer)</option>
<option value="windSpeedWinter">Wind Speed (Winter)</option>
```
### Modified: `js/planet-mesh.js` (~100 lines)
- `buildWindArrows(season)`: subsample ~400 regions, draw line segments for wind direction/magnitude
- **Globe view**: 3D arrows on sphere at r=1.07, oriented via tangent frame
- **Map view**: 2D arrows on equirectangular projection
- Auto-shown when any wind/pressure debug layer is selected
- Season inferred from selected layer name
### Modified: `js/main.js` (~15 lines)
- Wire debug layer change → show/hide wind arrows
- Toggle arrows on globe/map mode switch
- Dispose arrows on new generation
### Modified: `js/state.js` (~2 lines)
- Add `windArrowGroup: null`
### Modified: `README.md`
- Document wind simulation, debug layers, wind arrows
### NOT modified: `js/planet-code.js`
No new sliders (axial tilt fixed at 23.5°).
---
## Performance Budget (200K regions)
| Step | Estimated Time |
|------|---------------|
| ITCZ computation (72 lon samples × lat scan) | ~15ms |
| Precompute lat + tangent frames | ~5ms |
| Pressure field (2 seasons) | ~20ms |
| Noise perturbation (2 seasons) | ~30ms |
| Smoothing (3 passes × 2) | ~20ms |
| Gradient computation (2 seasons) | ~25ms |
| Pressure → wind (2 seasons) | ~10ms |
| **Total** | **~125ms** |
Well within 500ms target. ITCZ computation adds ~15ms (scanning regions in geographic bins).
---
## Verification
### Visual checks
1. **Pressure (Summer)**: Blue ITCZ band that hugs ~5° over ocean but pushes 15-20° north over continents. Red subtropical highs at ~30-35° (weaker over continents). Blue subpolar lows at ~60°.
2. **Pressure (Winter)**: ITCZ shifts south, NH continents show red (thermal highs). Subtropical highs at ~25° (shifted equatorward).
3. **Wind arrows (Summer)**: NE trades in NH tropics, SE trades in SH tropics. Westerlies at 40-60°. Near large NH continents: SW monsoon winds where SH trades cross the equator.
4. **Cross-equatorial test**: Find a longitude where ITCZ is at ~15°N (over land). Verify arrows: SE at 10°S → S at equator → SW at 5°N → W at 15°N.
5. **Season comparison**: Toggle between summer/winter pressure layers. Verify ITCZ migration and continental pressure reversal.
### Performance
- Console timing: wind step < 200ms at 200K, < 500ms at 640K
### Determinism
- Same seed → identical pressure/wind arrays
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f34e94034396aed82e3c624bec9653f213eefa85f00fefdcd00fbc9faff9f11
size 530622
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://orogen.studio/sitemap.xml
+16
View File
@@ -0,0 +1,16 @@
{
"name": "World Orogen",
"short_name": "Orogen",
"description": "Procedural planet generator with tectonic plates, erosion, and climate simulation",
"start_url": "/",
"display": "browser",
"background_color": "#030308",
"theme_color": "#0a0e17",
"icons": [
{
"src": "preview.png",
"sizes": "1200x630",
"type": "image/png"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://orogen.studio/</loc>
<lastmod>2026-09-20</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://orogen.studio/import</loc>
<lastmod>2026-09-20</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
/**
* Autonomous terrain tuning script.
*
* Runs the app headlessly with fixed seeds, collects metrics, saves results.
* Designed to be driven by Claude Code — modify terrain-config.js between runs.
*
* Usage: node tuning/auto-tune.mjs [label]
* Output: tuning/results/<label>.json with metrics from all seeds
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const RESULTS_DIR = path.join(__dirname, 'results');
fs.mkdirSync(RESULTS_DIR, { recursive: true });
const label = process.argv[2] || `run-${Date.now()}`;
const MIME = {
'.html': 'text/html', '.js': 'application/javascript', '.mjs': 'application/javascript',
'.css': 'text/css', '.json': 'application/json', '.png': 'image/png',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
'.txt': 'text/plain', '.xml': 'application/xml',
};
function startServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
const filePath = path.join(PROJECT_ROOT, urlPath);
if (!filePath.startsWith(PROJECT_ROOT)) { res.writeHead(403); res.end(); return; }
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data);
});
});
server.listen(0, '127.0.0.1', () => {
resolve({ server, port: server.address().port });
});
});
}
// Seeds chosen for diversity: different plate configs, land coverage, etc.
const SEEDS = [42, 100, 200, 300, 400];
async function runSeed(browser, baseUrl, seed) {
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 900 });
page.on('dialog', (d) => d.dismiss());
page.on('pageerror', () => {}); // suppress
try {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await new Promise((r) => setTimeout(r, 2000));
// Dismiss overlays
await page.evaluate(() => {
for (const id of ['tutorialOverlay', 'whatsNewOverlay']) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
});
// Set low detail for speed
await page.evaluate(() => {
const el = document.getElementById('sN');
el.value = 400;
el.dispatchEvent(new Event('input', { bubbles: true }));
});
// Patch seed
await page.evaluate((s) => {
const origPost = Worker.prototype.postMessage;
Worker.prototype.postMessage = function(msg, ...rest) {
if (msg && msg.cmd === 'generate' && msg.seed === undefined) msg.seed = Number(s);
return origPost.call(this, msg, ...rest);
};
}, seed);
// Generate
const genDone = page.evaluate((timeout) => {
return new Promise((resolve, reject) => {
const btn = document.getElementById('generate');
const timer = setTimeout(() => reject(new Error('Generation timed out')), timeout);
btn.addEventListener('generate-done', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}, 120_000);
await new Promise((r) => setTimeout(r, 100));
await page.click('#generate');
await genDone;
await new Promise((r) => setTimeout(r, 500));
const metrics = await page.evaluate(() => window.__terrainMetrics);
return { seed, metrics: metrics || { _error: 'no metrics' } };
} finally {
await page.close().catch(() => {});
}
}
async function main() {
const { server, port } = await startServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-webgl',
'--use-gl=angle', '--use-angle=swiftshader-webgl', '--enable-unsafe-swiftshader'],
});
const results = [];
const t0 = performance.now();
try {
for (const seed of SEEDS) {
const r = await runSeed(browser, baseUrl, seed);
results.push(r);
process.stdout.write(` seed ${seed}: ${r.metrics._error ? 'ERROR' : 'OK'} (${(r.metrics._metrics_ms || 0).toFixed(0)}ms metrics)\n`);
}
} finally {
await browser.close();
server.close();
}
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
// Compute cross-seed averages for key metrics
const validMetrics = results.filter(r => !r.metrics._error).map(r => r.metrics);
const avg = {};
if (validMetrics.length > 0) {
const keys = Object.keys(validMetrics[0]).filter(k => !k.startsWith('_') && typeof validMetrics[0][k] === 'number');
for (const k of keys) {
const vals = validMetrics.map(m => m[k]).filter(v => v != null && !isNaN(v));
avg[k] = vals.length > 0 ? +(vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(4) : null;
}
}
const output = { label, elapsed_s: +elapsed, seeds: SEEDS, results, averages: avg };
const outPath = path.join(RESULTS_DIR, `${label}.json`);
fs.writeFileSync(outPath, JSON.stringify(output, null, 2));
console.log(`\nResults saved: ${outPath} (${elapsed}s total)`);
// Print summary
console.log('\n=== Cross-seed Averages ===');
const highlight = [
'continent_count', 'island_count_total', 'flat_ocean_plate_land_fraction',
'relief_headroom', 'coast_complexity_index', 'hypsometry_trough_depth',
'mountain_boundary_ratio', 'orogenic_elev_correlation', 'erosion_slope_correlation',
'coastal_lowland_fraction', 'land_band_500m_plus_frac',
'shelf_width_passive_km', 'shelf_width_active_km',
];
for (const k of highlight) {
if (avg[k] != null) console.log(` ${k}: ${avg[k]}`);
}
}
main().catch((err) => { console.error(err); process.exit(1); });
+323
View File
@@ -0,0 +1,323 @@
/**
* Headless rendering harness for World Orogen.
*
* Launches a local HTTP server, drives the app with Puppeteer, generates
* planets from fixed seeds/slider combos, and saves globe screenshots to
* tuning/screenshots/.
*
* Usage: node tuning/render-harness.mjs
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
// ---------------------------------------------------------------------------
// MIME types for the static server
// ---------------------------------------------------------------------------
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2':'font/woff2',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain',
'.xml': 'application/xml',
'.wasm': 'application/wasm',
};
// ---------------------------------------------------------------------------
// Static file server
// ---------------------------------------------------------------------------
function startServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
const filePath = path.join(PROJECT_ROOT, urlPath);
// Security: stay inside project root
if (!filePath.startsWith(PROJECT_ROOT)) {
res.writeHead(403); res.end(); return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath).toLowerCase();
const mime = MIME[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': mime });
res.end(data);
});
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
console.log(`Static server listening on http://127.0.0.1:${port}`);
resolve({ server, port });
});
});
}
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
const DETAIL_SLIDER_VALUE = 400; // ~31 000 regions — fast iteration
const TEST_CASES = [
{
name: 'default',
seed: '42',
sliders: {},
},
{
name: 'few-plates-high-land',
seed: '100',
sliders: { sP: 8, sLc: 0.6 },
},
{
name: 'many-plates-low-land',
seed: '200',
sliders: { sP: 80, sLc: 0.25 },
},
{
name: 'high-erosion',
seed: '300',
sliders: { sGl: 0.8, sHEr: 0.8, sTEr: 0.8 },
},
{
name: 'mountainous-sharp-ridges',
seed: '400',
sliders: { sNs: 0.4, sRs: 0.8 },
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Set a slider's value and dispatch an 'input' event so the app reacts. */
async function setSlider(page, id, value) {
await page.evaluate(({ id, value }) => {
const el = document.getElementById(id);
if (!el) throw new Error(`Slider #${id} not found`);
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}, { id, value: String(value) });
}
/**
* Install a one-shot listener for 'generate-done' on #generate BEFORE
* clicking the button, and return a promise that resolves when it fires.
* Call this, store the promise, click the button, then await the promise.
*/
function installGenerationWaiter(page, timeoutMs = 120_000) {
// page.evaluate returns a promise that resolves when the inner promise does
return page.evaluate((timeout) => {
return new Promise((resolve, reject) => {
const btn = document.getElementById('generate');
const timer = setTimeout(() => reject(new Error('Generation timed out')), timeout);
btn.addEventListener('generate-done', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}, timeoutMs);
}
/** Rotate the globe by dragging horizontally (yaw) and/or vertically (pitch). */
async function rotateGlobe(page, yawRadians, pitchRadians = 0) {
await page.evaluate(({ yaw, pitch }) => {
const canvas = document.getElementById('canvas');
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const cx = w / 2;
const cy = h / 2;
// OrbitControls maps 2*PI rotation to a full canvas-width/height drag.
const dx = (yaw / (2 * Math.PI)) * w;
const dy = (pitch / (Math.PI)) * h;
const pointerDown = new PointerEvent('pointerdown', {
clientX: cx, clientY: cy, button: 0, bubbles: true, pointerId: 1,
});
const pointerMove = new PointerEvent('pointermove', {
clientX: cx - dx, clientY: cy - dy, button: 0, bubbles: true, pointerId: 1,
});
const pointerUp = new PointerEvent('pointerup', {
clientX: cx - dx, clientY: cy - dy, button: 0, bubbles: true, pointerId: 1,
});
canvas.dispatchEvent(pointerDown);
canvas.dispatchEvent(pointerMove);
canvas.dispatchEvent(pointerUp);
}, { yaw: yawRadians, pitch: pitchRadians });
// Let the render loop catch up.
await new Promise((r) => setTimeout(r, 1500));
}
/** Take a screenshot of the canvas element. */
async function screenshotCanvas(page, filePath) {
const canvas = await page.$('#canvas');
if (!canvas) throw new Error('Canvas not found');
await canvas.screenshot({ path: filePath });
console.log(` Saved: ${path.relative(PROJECT_ROOT, filePath)}`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const { server, port } = await startServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--enable-webgl',
'--use-gl=angle',
'--use-angle=swiftshader-webgl',
'--enable-unsafe-swiftshader',
],
});
try {
for (const tc of TEST_CASES) {
console.log(`\n=== Test case: ${tc.name} (seed ${tc.seed}) ===`);
let page;
try {
page = await browser.newPage();
await page.setViewport({ width: 1200, height: 900 });
// Suppress dialogs / permission prompts
page.on('dialog', (d) => d.dismiss());
// Forward page console and errors for debugging
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(` [PAGE ERROR] ${msg.text()}`);
});
page.on('pageerror', (err) => console.log(` [PAGE EXCEPTION] ${err.message}`));
// Navigate and wait for initial load
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
// Wait for ES modules and Three.js to initialize
await new Promise((r) => setTimeout(r, 2000));
// Close any overlay that may be showing (tutorial / what's new)
await page.evaluate(() => {
for (const id of ['tutorialOverlay', 'whatsNewOverlay']) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
});
// Set detail slider low for fast iteration
await setSlider(page, 'sN', DETAIL_SLIDER_VALUE);
// Set any custom sliders for this test case
for (const [id, val] of Object.entries(tc.sliders)) {
await setSlider(page, id, val);
}
// Intercept the Web Worker postMessage to inject our fixed seed.
// The generate() function passes seed as `undefined` for fresh builds,
// and the worker fills it with Math.random(). We patch postMessage so
// the next 'generate' command carries our chosen seed instead.
await page.evaluate((seed) => {
const origPost = Worker.prototype.postMessage;
Worker.prototype.postMessage = function(msg, ...rest) {
if (msg && msg.cmd === 'generate' && msg.seed === undefined) {
msg.seed = Number(seed);
}
return origPost.call(this, msg, ...rest);
};
}, tc.seed);
// Install the completion listener BEFORE clicking, then click, then await.
const t0 = performance.now();
const genDone = installGenerationWaiter(page, 120_000);
// Small delay so the evaluate above has time to register the listener
await new Promise((r) => setTimeout(r, 100));
await page.click('#generate');
// Wait for generation to finish
await genDone;
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
console.log(` Generation completed in ${elapsed}s`);
// Let rendering settle
await new Promise((r) => setTimeout(r, 1000));
// Extract terrain metrics scorecard
const metrics = await page.evaluate(() => window.__terrainMetrics);
if (metrics) {
const metricsPath = path.join(SCREENSHOT_DIR, `seed-${tc.seed}_${tc.name}_metrics.json`);
fs.writeFileSync(metricsPath, JSON.stringify(metrics, null, 2));
console.log(` Metrics: ${path.relative(PROJECT_ROOT, metricsPath)}`);
if (metrics._error) console.warn(` Metrics error: ${metrics._error}`);
} else {
console.warn(' No terrain metrics available');
}
// Collapse the side panel to maximize canvas area
await page.click('#sidebarToggle');
// Let the panel animate closed and Three.js resize
await new Promise((r) => setTimeout(r, 800));
// Take globe screenshots covering the full planet:
// 4 equatorial rotations (0°, 90°, 180°, 270°) + north pole + south pole
const base = `seed-${tc.seed}_${tc.name}`;
// Equatorial views — rotate around Y axis
for (let i = 0; i < 4; i++) {
if (i > 0) await rotateGlobe(page, Math.PI / 2, 0);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_eq-${i * 90}.png`));
}
// North pole — tilt camera up
await rotateGlobe(page, 0, -Math.PI / 2.2);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_north-pole.png`));
// South pole — tilt camera down (reset first, then go down)
await rotateGlobe(page, 0, Math.PI / 1.1);
await screenshotCanvas(page, path.join(SCREENSHOT_DIR, `${base}_south-pole.png`));
} catch (err) {
console.error(` FAILED: ${err.message}`);
} finally {
if (page) await page.close().catch(() => {});
}
}
} finally {
await browser.close();
server.close();
console.log('\nDone. Screenshots saved to tuning/screenshots/');
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+99
View File
@@ -0,0 +1,99 @@
# Terrain Tuning Session — Findings
## Current Best: v7 (`config-combined-v7.js`)
### All Changes from Original Baseline
```
# Mountain Structure
FOLD_FREQ_PRIMARY: 120 → 160 # tighter fold ridges
FOLD_FREQ_SECONDARY: 300 → 400 # finer secondary folds
FOLD_FREQ_MULT_SCALE: 1.5 → 2.0 # more chaotic fold belts
RIDGE_STRENGTH: 0.12 → 0.15 # taller convergent ridges
DISSECT_THRESHOLD: 0.12 → 0.10 # more mountain valley carving
DISSECT_AMP: 0.4 → 0.55 # deeper dissection valleys
SUMMIT_THRESHOLD: 0.65 → 0.55 # peaks on slightly lower mountains
SUMMIT_STRESS_MIN: 0.05 → 0.03 # peaks with less stress requirement
SUMMIT_SPIKE_OFFSET: 0.45 → 0.40 # more frequent summit spikes
SUMMIT_STRESS_FLOOR: 0.3 → 0.25 # lower stress floor for peaks
# Interior Terrain
INTERIOR_BASE_SHIELD: 0.10 → 0.14 # higher stable cratons
INTERIOR_BASE_BASIN: 0.06 → 0.04 # lower sedimentary basins
INTERIOR_TECTONIC: 0.16 → 0.20 # higher tectonic interiors
PLATEAU_BOOST: 0.025 → 0.04 # more prominent plateaus
CRATON_AMP_SUPPRESS: 0.4 → 0.25 # more texture on stable interiors
BASIN_AMP_SUPPRESS: 0.7 → 0.5 # more texture in basins
# Tectonic Features
RIFT_AXIS_DEPTH: -0.15 → -0.18 # deeper rift valleys
RIFT_AXIS_VOLCANIC_AMP: 0.04 → 0.06 # more rift volcanism texture
RIFT_SHOULDER_UPLIFT: 0.03 → 0.05 # higher rift shoulders
BACK_ARC_DEPTH: 0.10 → 0.14 # deeper back-arc basins
TRENCH_BASE_DEPTH: 0.15 → 0.20 # deeper ocean trenches
TRENCH_STRESS_DEPTH: 0.15 → 0.20 # more trench variation with stress
# Post-Processing
PEAK_COMPRESS_POWER: 0.85 → 0.90 # less peak compression = taller peaks
WARP_MAX_AMP_MULT: 0.12 → 0.13 # slightly more domain warp
SMOOTH_EDGE_SENSITIVITY: 8 → 12 # more edge preservation in smoothing
RIDGE_SHARPEN_CAP: 1.5 → 2.0 # sharper ridge post-processing
VALLEY_DEEPEN_FACTOR: 0.4 → 0.5 # deeper valley carving
```
### Metrics Comparison: Baseline → Best (v7)
| Metric | Baseline | v7 | Change |
|--------|----------|-----|--------|
| relief_headroom | 0.487 | 0.547 | +12% more dramatic |
| coast_complexity | 27.9 | 28.5 | +2% more complex |
| hypsometry_trough | 0.79 | 0.78 | ~same (good) |
| mountain_boundary_ratio | 0.52 | 0.52 | same |
| land_500m_plus_frac | 0.26 | 0.31 | slightly more highland |
| flat_ocean_plate_land | 0.65 | 0.59 | -9% improved |
| island_count | 286 | 288 | same |
| erosion_slope_corr | 0.46 | 0.46 | same |
| shelf_width_active_km | 293 | 222 | -24% narrower (more realistic) |
| shelf_width_passive_km | 478 | 443 | -7% narrower |
### Visual Improvements (confirmed at 31K and 90K regions)
1. **Mountain ridges** more defined with visible linear structure
2. **Continental interiors** have more elevation variety (craton vs basin contrast)
3. **Rift valleys** more visible as distinct features
4. **Ocean floor** more differentiated (deeper trenches, visible ridges)
5. **Coastlines** slightly more complex
6. **Peaks** more prominent and frequent
## Saved Config Snapshots
All in `tuning/results/`:
- `config-sharper-mountains.js` — fold freq + ridge + dissection only
- `config-sharper-mtn-interior-contrast.js` — + interior contrast
- `config-combined-v2.js` — + peak compress + less craton suppress
- `config-combined-v3-rifts-summits.js` — + rifts + summits
- `config-combined-v4-ocean.js` — + deeper trenches/back-arcs
- `config-combined-v5-warp.js` — + subtle warp boost
- `config-combined-v6-full.js` — + edge preserve + ridge sharpen
- `config-combined-v7.js` — + basin suppress + chaotic folds (**BEST**)
## Key Learnings
1. **Elevation thresholds must use quartic mapping** — elevToHeightKm is t^4-based, so 500m = elev 0.40, not 0.0625
2. **Hypsometric curve blend has minimal effect** — pre-existing distribution dominates
3. **Dissection is the #1 lever** for breaking up blobby mountains into realistic ridges
4. **Stress decay is delicate** — 0.5 original is right; 0.6 spreads too wide
5. **Deeper ocean features improve shelf differentiation** — more room for gradient
6. **Volcanic feature boosts backfire** — more arc uplift = more flat land, not taller islands
7. **Hotspot increases reduce island count** — merging features into fewer larger masses
8. **Interior shield/basin contrast** creates visual variety on continents
9. **Edge sensitivity in smoothing** preserves features that other steps create
10. **Fold frequency boost** is most visible at low detail levels (default view)
## Parameters Still Worth Exploring
- Glacial erosion parameters (only tested at user-slider level, not internal constants)
- Coastal plain width and depression (small effect individually)
- Island arc geometry (ARC_DIST_BASE, ARC_SIGMA_BASE_VAL)
- Super-plate blend weights (SMALL_W, SUPER_W)
- Hydraulic erosion deposit fraction and slope sensitivity
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 2.0;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.18;
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.05;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.14;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.5;
export const CRATON_AMP_SUPPRESS = 0.25;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.55;
export const SUMMIT_STRESS_MIN = 0.03;
export const SUMMIT_SPIKE_OFFSET = 0.40;
export const SUMMIT_STRESS_FLOOR = 0.25;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.20;
export const TRENCH_STRESS_DEPTH = 0.20;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.90;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.13;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 12;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.025;
export const GLACIAL_CONVERGENCE_BONUS = 0.015;
export const GLACIAL_DEPOSIT_AMOUNT = 0.007;
export const GLACIAL_FJORD_CARVE = 0.020;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 2.0;
export const VALLEY_DEEPEN_FACTOR = 0.5;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.4;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.10;
export const INTERIOR_BASE_BASIN = 0.06;
export const INTERIOR_TECTONIC = 0.16;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.025;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.85;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;
@@ -0,0 +1,351 @@
// Terrain generation tunable constants.
// Grouped by subsystem for iterative tuning.
// These are internal algorithm constants, NOT user-facing slider parameters.
// ── Collision & Stress ──
export const COLLISION_THRESHOLD = 0.75;
export const COLLISION_DT_BASE = 1e-2;
export const COLLISION_DT_REF_REGIONS = 10000;
export const PAIR_INTENSITY_BASE = 0.5;
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
export const SUBDUCT_UNDULATION_FREQ = 6;
export const SUBDUCT_UNDULATION_AMP = 0.4;
export const SUBDUCT_FACTOR_BASE = 0.5;
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
export const SUBDUCT_THRESHOLD = 0.55;
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
export const STRESS_PROPAGATE_MIN = 0.01;
export const STRESS_PROPAGATE_CUTOFF = 0.005;
export const STRESS_DIR_FACTOR_MIN = 0.1;
export const STRESS_DIR_FACTOR_BASE = 0.3;
export const STRESS_DIR_FACTOR_SCALE = 0.7;
export const STRESS_DIR_BLEND_PARENT = 0.8;
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
export const STRESS_DIR_SMOOTH_PASSES = 2;
export const STRESS_DIR_SELF_WEIGHT = 2;
export const STRESS_DECAY_BASE = 0.5;
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
export const STRESS_PASSES_PER_SPREAD = 3;
export const STRESS_PERCENTILE = 0.97;
// Blend weights for dual-layer orogeny (small plates vs super plates)
export const SMALL_W = 0.05;
export const SUPER_W = 0.95;
// ── Distance Fields & Zone Widths ──
export const INTERIOR_BAND_BASE = 16;
export const TECTONIC_REACH_BASE = 20;
export const COASTAL_PLAIN_WIDTH_BASE = 18;
export const COAST_BFS_WIDTH_BASE = 8;
// ── Mountain Profiles ──
export const RIDGE_STRENGTH = 0.15;
export const RIDGE_SIGMA_BASE = 5;
export const RIDGE_PEAK_SHIFT_BASE = 2;
export const RIDGE_EXTENT_BASE = 10;
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
export const BASE_SCALE = 0.6;
export const ASYMMETRY_FACTOR = 0.8;
export const SUBDUCTING_SUPPRESSION = 0.42;
export const STRESS_MAG_SCALE = 0.40;
export const STRESS_DEPRESS_FRAC = 0.4;
export const STRESS_HEIGHT_VAR_BASE = 0.60;
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
export const SUBDUCTING_REACH_MIN = 0.35;
export const SUBDUCTING_REACH_RANGE = 0.3;
// ── Fold Ridges ──
export const FOLD_FREQ_PRIMARY = 160;
export const FOLD_FREQ_SECONDARY = 400;
export const FOLD_MEAN_OFFSET = 0.36;
export const FOLD_PHASE_WARP_AMP = 0.08;
export const FOLD_PHASE_WARP2_AMP = 0.12;
export const FOLD_AMP_MOD_BASE = 0.6;
export const FOLD_AMP_MOD_SCALE = 0.4;
export const FOLD_AMP_MOD2_BASE = 0.5;
export const FOLD_AMP_MOD2_SCALE = 0.5;
export const FOLD_SECONDARY_ALONG = 0.85;
export const FOLD_SECONDARY_CROSS = 0.15;
export const FOLD_SECONDARY_AMP = 0.18;
export const FOLD_NOISE_MAG_SCALE = 0.8;
export const FOLD_ELEV_THRESHOLD = 0.05;
export const FOLD_ELEV_SCALE = 4;
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
export const FOLD_ELEV_BOOST_SCALE = 6;
export const FOLD_SF_SUPPRESS = 1.5;
export const FOLD_FREQ_MULT_SCALE = 1.5;
// ── Basins & Rifts ──
export const RIFT_HALF_WIDTH_BASE = 4;
export const RIFT_FLOOR_MULT = 1.5;
export const RIFT_SHOULDER_MULT = 2.5;
export const RIFT_AXIS_DEPTH = -0.15;
export const RIFT_AXIS_VOLCANIC_AMP = 0.04;
export const RIFT_FLOOR_DEPTH = -0.12;
export const RIFT_FLOOR_TAPER = 0.3;
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
export const RIFT_SHOULDER_UPLIFT = 0.03;
export const RIFT_FADEOUT_RESIDUAL = 0.2;
export const BASIN_FREQ = 1.8;
export const BASIN_FACTOR_BIAS = 0.5;
export const BASIN_FACTOR_SCALE = 0.6;
export const FORELAND_STRESS_THRESH = 0.15;
export const FORELAND_WIDTH_FRAC = 0.3;
export const FORELAND_BASIN_DEPTH = 0.05;
export const FORELAND_PEAK_POS = 0.2;
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
// ── Back-Arc & Foreland ──
export const BACK_ARC_START_BASE = 2;
export const BACK_ARC_PEAK_BASE = 3;
export const BACK_ARC_END_BASE = 5;
export const BACK_ARC_DEPTH = 0.10;
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
// ── Noise Layering ──
export const WARP_SCALE = 0.4;
export const OROGENIC_FREQ = 1.5;
export const NOISE_ACTIVITY_SCALE = 4;
export const NOISE_BASE_SCALE = 0.25;
export const NOISE_ACTIVITY_CONTRIB = 0.75;
export const PLATEAU_SUPPRESS_MIN = 0.30;
export const PLATEAU_SUPPRESS_SCALE = 0.60;
export const BASIN_AMP_SUPPRESS = 0.7;
export const CRATON_AMP_SUPPRESS = 0.4;
export const RIDGED_NOISE_AMP = 1.5;
export const DETAIL_NOISE_FREQ_MULT = 4;
export const DETAIL_NOISE_AMP = 0.5;
export const FINE_NOISE_FREQ_MULT = 8;
export const FINE_NOISE_AMP = 0.25;
export const OCEAN_NOISE_AMP = 0.3;
// ── Dissection & Summits ──
export const DISSECT_THRESHOLD = 0.10;
export const DISSECT_AMP = 0.55;
export const DISSECT_ELEV_SCALE = 2;
export const SUMMIT_THRESHOLD = 0.65;
export const SUMMIT_STRESS_MIN = 0.05;
export const SUMMIT_SPIKE_OFFSET = 0.45;
export const SUMMIT_STRESS_FLOOR = 0.3;
// ── Interior Elevation ──
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
export const INTERIOR_BASE_SHIELD = 0.14;
export const INTERIOR_BASE_BASIN = 0.04;
export const INTERIOR_TECTONIC = 0.20;
export const COASTAL_DEPRESSION = -0.08;
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
export const INTERIOR_FLOOR = 0.008;
export const PLATEAU_BOOST = 0.04;
export const PLATEAU_START_BASE = 3;
export const MOUNTAIN_BOOST_FRAC = 0.3;
export const FOLD_BELT_MULT = 3;
export const CRATON_TECTONIC_MULT = 2.5;
export const BASIN_TECTONIC_MULT = 2;
// ── Continental Margins ──
export const SHELF_NARROW_BASE = 4;
export const SHELF_WIDE_BASE = 12;
export const SLOPE_WIDTH_BASE = 7;
export const SHELF_DEPTH_START = -0.08;
export const SHELF_DEPTH_RANGE = 0.08;
export const SLOPE_DEPTH_RANGE = 0.19;
export const ABYSS_BASE = -0.35;
export const ABYSS_NOISE_AMP = 0.03;
export const OCEAN_FLOOR_CLAMP = -0.005;
// ── Mid-Ocean Features ──
export const RIDGE_HALF_WIDTH_BASE = 4;
export const RIDGE_UPLIFT_NOISE = 0.12;
export const RIDGE_UPLIFT_BASE = 0.06;
export const FRACTURE_HALF_WIDTH_BASE = 3;
export const FRACTURE_DEPTH = 0.03;
export const TRENCH_BASE_DEPTH = 0.15;
export const TRENCH_STRESS_DEPTH = 0.15;
// ── Coastal Roughening ──
export const COAST_ROUGHEN_BASE = 8;
export const COAST_PASSIVE_FREQ = 6;
export const COAST_ACTIVE_FREQ = 9;
export const COAST_PASSIVE_AMP = 0.08;
export const COAST_ACTIVE_AMP = 0.12;
export const COAST_WARP_PASSIVE_REACH = 1.2;
export const COAST_WARP_ACTIVE_REACH = 1.5;
export const COAST_WARP_AMT = 0.35;
export const COAST_SUBDUCT_SUP_LOW = 0.45;
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
// ── Island Scattering ──
export const ISLAND_DIST_BASE = 4;
export const ISLAND_FREQ = 17.5;
export const ISLAND_THRESHOLD_BASE = 0.25;
export const ISLAND_THRESHOLD_STRESS = 0.2;
export const ISLAND_BUMP_AMP = 0.18;
export const ISLAND_SUBDUCT_MAX = 0.3;
// ── Island Arcs ──
export const ARC_DIST_BASE = 5;
export const ARC_PEAK_DIST_BASE = 1.5;
export const ARC_SIGMA_BASE_VAL = 1.5;
export const ARC_THRESHOLD = 0.30;
export const ARC_UPLIFT_AMP = 0.55;
export const ARC_SUBDUCT_THRESH = 0.45;
// ── Volcanic Features ──
export const VOLC_MIN_SPACING = 0.015;
export const VOLC_SIGMA_BASE = 0.003;
export const VOLC_HEIGHT_BASE = 0.15;
export const VOLC_HEIGHT_VAR_BASE = 0.7;
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
export const VOLC_SIGMA_VAR_BASE = 0.6;
export const VOLC_SIGMA_VAR_RANGE = 0.8;
export const VOLC_SUBDUCT_THRESH = 0.45;
// ── Large Igneous Provinces ──
export const LIP_SIGMA = 0.025;
export const LIP_HEIGHT = 0.04;
// ── Hotspot Chains ──
export const NUM_HOTSPOTS = 5;
export const CHAIN_LENGTH = 6;
export const CHAIN_DECAY = 0.75;
export const CHAIN_SPACING = 0.06;
export const DOME_SIGMA = 0.006;
export const DOME_STRENGTH = 0.60;
export const SWELL_SIGMA_MULT = 2;
export const SWELL_STR_MULT = 0.10;
export const DOME_OCEAN_BOOST = 1.8;
export const DOME_PEAK_THRESH_SIGMA = 5.5;
export const DOME_SWELL_THRESH_SIGMA = 3;
export const DOME_DRIFT_STRETCH = 1.4;
export const DOME_RIFT_BOOST = 0.5;
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
export const DOME_AGE_BROADENING = 0.06;
export const DOME_SHAPE_WARP_FREQ = 8;
export const DOME_SHAPE_WARP_AMP = 0.4;
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
// ── Hypsometry & Isostasy ──
export const PEAK_COMPRESS_POWER = 0.85;
export const ISOSTATIC_K = 0.07;
export const HYPS_BLEND = 0.40;
export const HYPS_LOW_BREAK = 0.60;
export const HYPS_MID_BREAK = 0.85;
export const HYPS_LOW_ELEV_FRAC = 0.25;
export const HYPS_MID_ELEV_FRAC = 0.35;
export const HYPS_HIGH_POWER = 0.7;
export const FILL_LEVEL = 0.005;
// ── Passive Margin Coastal Plain ──
export const PLAIN_TARGET = 0.02;
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
// ── Domain Warp (terrain-post.js) ──
export const WARP_FREQ = 4;
export const WARP_OCTAVES = 5;
export const WARP_MAX_AMP_MULT = 0.12;
export const WARP_BIAS_BASE = 0.25;
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
export const WARP_HOTSPOT_DAMPEN = 0.8;
// ── Smoothing (terrain-post.js) ──
export const SMOOTH_EDGE_SENSITIVITY = 8;
// ── Glacial Erosion (terrain-post.js) ──
export const GLACIAL_LAT_DIVISOR = 4.5;
export const GLACIAL_ELEV_LOW = 0.5;
export const GLACIAL_ELEV_HIGH = 0.9;
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
export const GLACIAL_CARVE_RATE = 0.02;
export const GLACIAL_CONVERGENCE_BONUS = 0.01;
export const GLACIAL_DEPOSIT_AMOUNT = 0.005;
export const GLACIAL_FJORD_CARVE = 0.015;
export const GLACIAL_FLOW_THRESHOLD = 0.1;
export const GLACIAL_FJORD_THRESHOLD = 0.5;
export const GLACIAL_WIDENING_FRAC = 0.4;
export const GLACIAL_TERMINUS_RATIO = 0.3;
export const GLACIAL_FJORD_ICE_MIN = 0.2;
export const GLACIAL_POST_SMOOTH = 0.3;
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
export const GLACIAL_INITIAL_CARVE = 0.5;
// ── Hydraulic Erosion (terrain-post.js) ──
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
// ── Thermal Erosion (terrain-post.js) ──
export const THERMAL_TRANSFER_FRAC = 0.5;
// ── Ridge Sharpening (terrain-post.js) ──
export const RIDGE_SHARPEN_CAP = 1.5;
export const VALLEY_DEEPEN_FACTOR = 0.4;
export const VALLEY_FLOOR_FRAC = 0.5;
export const VALLEY_FLOOR_MIN = 0.001;
// ── Priority Flood (terrain-post.js) ──
export const FLOOD_NOISE_AMP = 0.01;
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
// ── Plate Generation ──
export const PLATE_LOW_PLATE_T_HIGH = 80;
export const PLATE_LOW_PLATE_T_RANGE = 60;
export const PLATE_RATE_MIN_BASE = 0.7;
export const PLATE_RATE_MIN_LOW_T = 0.4;
export const PLATE_RATE_RANGE_BASE = 2.3;
export const PLATE_RATE_RANGE_LOW_T = 2.4;
export const PLATE_DIR_BASE_BASE = 0.15;
export const PLATE_DIR_BASE_LOW_T = 0.25;
export const PLATE_DIR_SCALE_BASE = 0.25;
export const PLATE_DIR_SCALE_LOW_T = 0.25;
export const PLATE_DIR_STRENGTH_CAP = 0.85;
export const PLATE_COMPACT_BASE = 0.3;
export const PLATE_COMPACT_LOW_T = 0.22;
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
export const PLATE_COMPACT_PENALTY_MULT = 4;
export const PLATE_OMEGA_MIN = 0.5;
export const PLATE_OMEGA_RANGE = 1.5;
export const PLATE_SMOOTH_BASE = 3;
export const PLATE_SMOOTH_LOW_T = 2;
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
// ── Coarse Projection ──
export const N_COARSE = 20000;
export const COARSE_JITTER = 0.75;
export const COARSE_PERTURB_BASE = 1.5;
export const COARSE_PERTURB_LOW_T = 1.0;
export const COARSE_FBM_BASE_FREQ = 8;
export const COARSE_FBM_OCTAVES = 4;
export const COARSE_FBM_DECAY = 0.5;
export const COARSE_FBM_FREQ_MULT = 2;