mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
Refactor/migrate first modules (#1273)
* chore: add npm + vite for progressive enhancement * fix: update Dockerfile to copy only the dist folder contents * fix: update Dockerfile to use multi-stage build for optimized production image * fix: correct nginx config file copy command in Dockerfile * chore: add netlify configuration for build and redirects * fix: add NODE_VERSION to environment in Netlify configuration * remove wrong dist folder * Update package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: split public and src * migrating all util files from js to ts * feat: Implement HeightmapGenerator and Voronoi module - Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.). - Introduced Voronoi class for creating Voronoi diagrams using Delaunator. - Updated index.html to include new modules. - Created index.ts to manage module imports. - Enhanced arrayUtils and graphUtils with type definitions and improved functionality. - Added utility functions for generating grids and calculating Voronoi cells. * chore: add GitHub Actions workflow for deploying to GitHub Pages * fix: update branch name in GitHub Actions workflow from 'main' to 'master' * chore: update package.json to specify Node.js engine version and remove unused launch.json * Initial plan * Update copilot guidelines to reflect NPM/Vite/TypeScript migration Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/utils/graphUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: Add TIME and ERROR variables to global scope in HeightmapGenerator * fix: Update base path in vite.config.ts for Netlify deployment * refactor: Migrate features to a new module and remove legacy script reference * refactor: Update feature interfaces and improve type safety in FeatureModule * refactor: Add documentation for markupPack and defineGroups methods in FeatureModule * refactor: Remove legacy ocean-layers.js and migrate functionality to ocean-layers.ts * refactor: Remove river-generator.js script reference and migrate river generation logic to river-generator.ts * refactor: Remove river-generator.js reference and add biomes module * refactor: Migrate lakes functionality to lakes.ts and update related interfaces * refactor: clean up global variable declarations and improve type definitions * refactor: update shoreline calculation and improve type imports in PackedGraph * fix: e2e tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Azgaar <maxganiev@yandex.com> Co-authored-by: Azgaar <azgaar.fmg@yandex.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
This commit is contained in:
parent
9903f0b9aa
commit
29bc2832e0
15 changed files with 826 additions and 677 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -1999,7 +1999,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.Features = (function () {
|
||||
const DEEPER_LAND = 3;
|
||||
const LANDLOCKED = 2;
|
||||
const LAND_COAST = 1;
|
||||
const UNMARKED = 0;
|
||||
const WATER_COAST = -1;
|
||||
const DEEP_WATER = -2;
|
||||
|
||||
// calculate distance to coast for every cell
|
||||
function markup({distanceField, neighbors, start, increment, limit = INT8_MAX}) {
|
||||
for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) {
|
||||
marked = 0;
|
||||
const prevDistance = distance - increment;
|
||||
for (let cellId = 0; cellId < neighbors.length; cellId++) {
|
||||
if (distanceField[cellId] !== prevDistance) continue;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
if (distanceField[neighborId] !== UNMARKED) continue;
|
||||
distanceField[neighborId] = distance;
|
||||
marked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mark Grid features (ocean, lakes, islands) and calculate distance field
|
||||
function markupGrid() {
|
||||
TIME && console.time("markupGrid");
|
||||
Math.random = aleaPRNG(seed); // get the same result on heightmap edit in Erase mode
|
||||
|
||||
const {h: heights, c: neighbors, b: borderCells, i} = grid.cells;
|
||||
const cellsNumber = i.length;
|
||||
const distanceField = new Int8Array(cellsNumber); // gird.cells.t
|
||||
const featureIds = new Uint16Array(cellsNumber); // gird.cells.f
|
||||
const features = [0];
|
||||
|
||||
const queue = [0];
|
||||
for (let featureId = 1; queue[0] !== -1; featureId++) {
|
||||
const firstCell = queue[0];
|
||||
featureIds[firstCell] = featureId;
|
||||
|
||||
const land = heights[firstCell] >= 20;
|
||||
let border = false; // set true if feature touches map edge
|
||||
|
||||
while (queue.length) {
|
||||
const cellId = queue.pop();
|
||||
if (!border && borderCells[cellId]) border = true;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
const isNeibLand = heights[neighborId] >= 20;
|
||||
|
||||
if (land === isNeibLand && featureIds[neighborId] === UNMARKED) {
|
||||
featureIds[neighborId] = featureId;
|
||||
queue.push(neighborId);
|
||||
} else if (land && !isNeibLand) {
|
||||
distanceField[cellId] = LAND_COAST;
|
||||
distanceField[neighborId] = WATER_COAST;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const type = land ? "island" : border ? "ocean" : "lake";
|
||||
features.push({i: featureId, land, border, type});
|
||||
|
||||
queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell
|
||||
}
|
||||
|
||||
// markup deep ocean cells
|
||||
markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10});
|
||||
|
||||
grid.cells.t = distanceField;
|
||||
grid.cells.f = featureIds;
|
||||
grid.features = features;
|
||||
|
||||
TIME && console.timeEnd("markupGrid");
|
||||
}
|
||||
|
||||
// mark Pack features (ocean, lakes, islands), calculate distance field and add properties
|
||||
function markupPack() {
|
||||
TIME && console.time("markupPack");
|
||||
|
||||
const {cells, vertices} = pack;
|
||||
const {c: neighbors, b: borderCells, i} = cells;
|
||||
const packCellsNumber = i.length;
|
||||
if (!packCellsNumber) return; // no cells -> there is nothing to do
|
||||
|
||||
const distanceField = new Int8Array(packCellsNumber); // pack.cells.t
|
||||
const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f
|
||||
const haven = createTypedArray({maxValue: packCellsNumber, length: packCellsNumber}); // haven: opposite water cell
|
||||
const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells
|
||||
const features = [0];
|
||||
|
||||
const queue = [0];
|
||||
for (let featureId = 1; queue[0] !== -1; featureId++) {
|
||||
const firstCell = queue[0];
|
||||
featureIds[firstCell] = featureId;
|
||||
|
||||
const land = isLand(firstCell);
|
||||
let border = Boolean(borderCells[firstCell]); // true if feature touches map border
|
||||
let totalCells = 1; // count cells in a feature
|
||||
|
||||
while (queue.length) {
|
||||
const cellId = queue.pop();
|
||||
if (borderCells[cellId]) border = true;
|
||||
if (!border && borderCells[cellId]) border = true;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
const isNeibLand = isLand(neighborId);
|
||||
|
||||
if (land && !isNeibLand) {
|
||||
distanceField[cellId] = LAND_COAST;
|
||||
distanceField[neighborId] = WATER_COAST;
|
||||
if (!haven[cellId]) defineHaven(cellId);
|
||||
} else if (land && isNeibLand) {
|
||||
if (distanceField[neighborId] === UNMARKED && distanceField[cellId] === LAND_COAST)
|
||||
distanceField[neighborId] = LANDLOCKED;
|
||||
else if (distanceField[cellId] === UNMARKED && distanceField[neighborId] === LAND_COAST)
|
||||
distanceField[cellId] = LANDLOCKED;
|
||||
}
|
||||
|
||||
if (!featureIds[neighborId] && land === isNeibLand) {
|
||||
queue.push(neighborId);
|
||||
featureIds[neighborId] = featureId;
|
||||
totalCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
features.push(addFeature({firstCell, land, border, featureId, totalCells}));
|
||||
queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell
|
||||
}
|
||||
|
||||
markup({distanceField, neighbors, start: DEEPER_LAND, increment: 1}); // markup pack land
|
||||
markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10}); // markup pack water
|
||||
|
||||
pack.cells.t = distanceField;
|
||||
pack.cells.f = featureIds;
|
||||
pack.cells.haven = haven;
|
||||
pack.cells.harbor = harbor;
|
||||
pack.features = features;
|
||||
|
||||
TIME && console.timeEnd("markupPack");
|
||||
|
||||
function defineHaven(cellId) {
|
||||
const waterCells = neighbors[cellId].filter(isWater);
|
||||
const distances = waterCells.map(neibCellId => dist2(cells.p[cellId], cells.p[neibCellId]));
|
||||
const closest = distances.indexOf(Math.min.apply(Math, distances));
|
||||
|
||||
haven[cellId] = waterCells[closest];
|
||||
harbor[cellId] = waterCells.length;
|
||||
}
|
||||
|
||||
function addFeature({firstCell, land, border, featureId, totalCells}) {
|
||||
const type = land ? "island" : border ? "ocean" : "lake";
|
||||
const [startCell, featureVertices] = getCellsData(type, firstCell);
|
||||
const points = clipPoly(featureVertices.map(vertex => vertices.p[vertex]));
|
||||
const area = d3.polygonArea(points); // feature perimiter area
|
||||
const absArea = Math.abs(rn(area));
|
||||
|
||||
const feature = {
|
||||
i: featureId,
|
||||
type,
|
||||
land,
|
||||
border,
|
||||
cells: totalCells,
|
||||
firstCell: startCell,
|
||||
vertices: featureVertices,
|
||||
area: absArea
|
||||
};
|
||||
|
||||
if (type === "lake") {
|
||||
if (area > 0) feature.vertices = feature.vertices.reverse();
|
||||
feature.shoreline = unique(feature.vertices.map(vertex => vertices.c[vertex].filter(isLand)).flat());
|
||||
feature.height = Lakes.getHeight(feature);
|
||||
}
|
||||
|
||||
return feature;
|
||||
|
||||
function getCellsData(featureType, firstCell) {
|
||||
if (featureType === "ocean") return [firstCell, []];
|
||||
|
||||
const getType = cellId => featureIds[cellId];
|
||||
const type = getType(firstCell);
|
||||
const ofSameType = cellId => getType(cellId) === type;
|
||||
const ofDifferentType = cellId => getType(cellId) !== type;
|
||||
|
||||
const startCell = findOnBorderCell(firstCell);
|
||||
const featureVertices = getFeatureVertices(startCell);
|
||||
return [startCell, featureVertices];
|
||||
|
||||
function findOnBorderCell(firstCell) {
|
||||
const isOnBorder = cellId => borderCells[cellId] || neighbors[cellId].some(ofDifferentType);
|
||||
if (isOnBorder(firstCell)) return firstCell;
|
||||
|
||||
const startCell = cells.i.filter(ofSameType).find(isOnBorder);
|
||||
if (startCell === undefined)
|
||||
throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`);
|
||||
|
||||
return startCell;
|
||||
}
|
||||
|
||||
function getFeatureVertices(startCell) {
|
||||
const startingVertex = cells.v[startCell].find(v => vertices.c[v].some(ofDifferentType));
|
||||
if (startingVertex === undefined)
|
||||
throw new Error(`Markup: startingVertex for cell ${startCell} is not found`);
|
||||
|
||||
return connectVertices({vertices, startingVertex, ofSameType, closeRing: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add properties to pack features
|
||||
function defineGroups() {
|
||||
const gridCellsNumber = grid.cells.i.length;
|
||||
const OCEAN_MIN_SIZE = gridCellsNumber / 25;
|
||||
const SEA_MIN_SIZE = gridCellsNumber / 1000;
|
||||
const CONTINENT_MIN_SIZE = gridCellsNumber / 10;
|
||||
const ISLAND_MIN_SIZE = gridCellsNumber / 1000;
|
||||
|
||||
for (const feature of pack.features) {
|
||||
if (!feature || feature.type === "ocean") continue;
|
||||
|
||||
if (feature.type === "lake") feature.height = Lakes.getHeight(feature);
|
||||
feature.group = defineGroup(feature);
|
||||
}
|
||||
|
||||
function defineGroup(feature) {
|
||||
if (feature.type === "island") return defineIslandGroup(feature);
|
||||
if (feature.type === "ocean") return defineOceanGroup();
|
||||
if (feature.type === "lake") return defineLakeGroup(feature);
|
||||
throw new Error(`Markup: unknown feature type ${feature.type}`);
|
||||
}
|
||||
|
||||
function defineOceanGroup(feature) {
|
||||
if (feature.cells > OCEAN_MIN_SIZE) return "ocean";
|
||||
if (feature.cells > SEA_MIN_SIZE) return "sea";
|
||||
return "gulf";
|
||||
}
|
||||
|
||||
function defineIslandGroup(feature) {
|
||||
const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]];
|
||||
if (prevFeature && prevFeature.type === "lake") return "lake_island";
|
||||
if (feature.cells > CONTINENT_MIN_SIZE) return "continent";
|
||||
if (feature.cells > ISLAND_MIN_SIZE) return "island";
|
||||
return "isle";
|
||||
}
|
||||
|
||||
function defineLakeGroup(feature) {
|
||||
if (feature.temp < -3) return "frozen";
|
||||
if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava";
|
||||
|
||||
if (!feature.inlets && !feature.outlet) {
|
||||
if (feature.evaporation > feature.flux * 4) return "dry";
|
||||
if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole";
|
||||
}
|
||||
|
||||
if (!feature.outlet && feature.evaporation > feature.flux) return "salt";
|
||||
|
||||
return "freshwater";
|
||||
}
|
||||
}
|
||||
|
||||
return {markupGrid, markupPack, defineGroups};
|
||||
})();
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.OceanLayers = (function () {
|
||||
let cells, vertices, pointsN, used;
|
||||
|
||||
const OceanLayers = function OceanLayers() {
|
||||
const outline = oceanLayers.attr("layers");
|
||||
if (outline === "none") return;
|
||||
TIME && console.time("drawOceanLayers");
|
||||
|
||||
lineGen.curve(d3.curveBasisClosed);
|
||||
(cells = grid.cells), (pointsN = grid.cells.i.length), (vertices = grid.vertices);
|
||||
const limits = outline === "random" ? randomizeOutline() : outline.split(",").map(s => +s);
|
||||
|
||||
const chains = [];
|
||||
const opacity = rn(0.4 / limits.length, 2);
|
||||
used = new Uint8Array(pointsN); // to detect already passed cells
|
||||
|
||||
for (const i of cells.i) {
|
||||
const t = cells.t[i];
|
||||
if (t > 0) continue;
|
||||
if (used[i] || !limits.includes(t)) continue;
|
||||
const start = findStart(i, t);
|
||||
if (!start) continue;
|
||||
used[i] = 1;
|
||||
const chain = connectVertices(start, t); // vertices chain to form a path
|
||||
if (chain.length < 4) continue;
|
||||
const relax = 1 + t * -2; // select only n-th point
|
||||
const relaxed = chain.filter((v, i) => !(i % relax) || vertices.c[v].some(c => c >= pointsN));
|
||||
if (relaxed.length < 4) continue;
|
||||
const points = clipPoly(
|
||||
relaxed.map(v => vertices.p[v]),
|
||||
1
|
||||
);
|
||||
chains.push([t, points]);
|
||||
}
|
||||
|
||||
for (const t of limits) {
|
||||
const layer = chains.filter(c => c[0] === t);
|
||||
let path = layer.map(c => round(lineGen(c[1]))).join("");
|
||||
if (path) oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity);
|
||||
}
|
||||
|
||||
// find eligible cell vertex to start path detection
|
||||
function findStart(i, t) {
|
||||
if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= pointsN)); // map border cell
|
||||
return cells.v[i][cells.c[i].findIndex(c => cells.t[c] < t || !cells.t[c])];
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("drawOceanLayers");
|
||||
};
|
||||
|
||||
function randomizeOutline() {
|
||||
const limits = [];
|
||||
let odd = 0.2;
|
||||
for (let l = -9; l < 0; l++) {
|
||||
if (P(odd)) {
|
||||
odd = 0.2;
|
||||
limits.push(l);
|
||||
} else {
|
||||
odd *= 2;
|
||||
}
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
// connect vertices to chain
|
||||
function connectVertices(start, t) {
|
||||
const chain = []; // vertices chain to form a path
|
||||
for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) {
|
||||
const prev = chain[chain.length - 1]; // previous vertex in chain
|
||||
chain.push(current); // add current vertex to sequence
|
||||
const c = vertices.c[current]; // cells adjacent to vertex
|
||||
c.filter(c => cells.t[c] === t).forEach(c => (used[c] = 1));
|
||||
const v = vertices.v[current]; // neighboring vertices
|
||||
const c0 = !cells.t[c[0]] || cells.t[c[0]] === t - 1;
|
||||
const c1 = !cells.t[c[1]] || cells.t[c[1]] === t - 1;
|
||||
const c2 = !cells.t[c[2]] || cells.t[c[2]] === t - 1;
|
||||
if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0];
|
||||
else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1];
|
||||
else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2];
|
||||
if (current === chain[chain.length - 1]) {
|
||||
ERROR && console.error("Next vertex is not found");
|
||||
break;
|
||||
}
|
||||
}
|
||||
chain.push(chain[0]); // push first vertex as the last one
|
||||
return chain;
|
||||
}
|
||||
|
||||
return OceanLayers;
|
||||
})();
|
||||
|
|
@ -8493,11 +8493,6 @@
|
|||
|
||||
<script defer src="config/heightmap-templates.js"></script>
|
||||
<script defer src="config/precreated-heightmaps.js"></script>
|
||||
<script defer src="modules/features.js?v=1.104.0"></script>
|
||||
<script defer src="modules/ocean-layers.js?v=1.108.4"></script>
|
||||
<script defer src="modules/river-generator.js?v=1.106.7"></script>
|
||||
<script defer src="modules/lakes.js?v=1.99.00"></script>
|
||||
<script defer src="modules/biomes.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ice.js?v=1.111.0"></script>
|
||||
<script defer src="modules/names-generator.js?v=1.106.0"></script>
|
||||
<script defer src="modules/cultures-generator.js?v=1.106.0"></script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"use strict";
|
||||
import { range, mean } from "d3";
|
||||
import { rn } from "../utils";
|
||||
|
||||
window.Biomes = (function () {
|
||||
const MIN_LAND_HEIGHT = 20;
|
||||
declare global {
|
||||
var Biomes: BiomesModule;
|
||||
}
|
||||
|
||||
const getDefault = () => {
|
||||
const name = [
|
||||
class BiomesModule {
|
||||
private MIN_LAND_HEIGHT = 20;
|
||||
|
||||
getDefault() {
|
||||
const name: string[] = [
|
||||
"Marine",
|
||||
"Hot desert",
|
||||
"Cold desert",
|
||||
|
|
@ -20,7 +25,7 @@ window.Biomes = (function () {
|
|||
"Wetland"
|
||||
];
|
||||
|
||||
const color = [
|
||||
const color: string[] = [
|
||||
"#466eab",
|
||||
"#fbe79f",
|
||||
"#b5b887",
|
||||
|
|
@ -35,9 +40,9 @@ window.Biomes = (function () {
|
|||
"#d5e7eb",
|
||||
"#0b9131"
|
||||
];
|
||||
const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12];
|
||||
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250];
|
||||
const icons = [
|
||||
const habitability: number[] = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12];
|
||||
const iconsDensity: number[] = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250];
|
||||
const icons: Array<{[key: string]: number}> = [
|
||||
{},
|
||||
{dune: 3, cactus: 6, deadTree: 1},
|
||||
{dune: 9, deadTree: 1},
|
||||
|
|
@ -52,8 +57,8 @@ window.Biomes = (function () {
|
|||
{},
|
||||
{swamp: 1}
|
||||
];
|
||||
const cost = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost
|
||||
const biomesMartix = [
|
||||
const cost: number[] = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost
|
||||
const biomesMatrix: Uint8Array[] = [
|
||||
// hot ↔ cold [>19°C; <-4°C]; dry ↕ wet
|
||||
new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]),
|
||||
new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]),
|
||||
|
|
@ -63,66 +68,66 @@ window.Biomes = (function () {
|
|||
];
|
||||
|
||||
// parse icons weighted array into a simple array
|
||||
const parsedIcons: string[][] = [];
|
||||
for (let i = 0; i < icons.length; i++) {
|
||||
const parsed = [];
|
||||
const parsed: string[] = [];
|
||||
for (const icon in icons[i]) {
|
||||
for (let j = 0; j < icons[i][icon]; j++) {
|
||||
parsed.push(icon);
|
||||
}
|
||||
}
|
||||
icons[i] = parsed;
|
||||
parsedIcons[i] = parsed;
|
||||
}
|
||||
|
||||
return {i: d3.range(0, name.length), name, color, biomesMartix, habitability, iconsDensity, icons, cost};
|
||||
return {i: range(0, name.length), name, color, biomesMatrix, habitability, iconsDensity, icons: parsedIcons, cost};
|
||||
};
|
||||
|
||||
// assign biome id for each cell
|
||||
function define() {
|
||||
define() {
|
||||
TIME && console.time("defineBiomes");
|
||||
|
||||
const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells;
|
||||
const {temp, prec} = grid.cells;
|
||||
pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array
|
||||
|
||||
for (let cellId = 0; cellId < heights.length; cellId++) {
|
||||
const height = heights[cellId];
|
||||
const moisture = height < MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId);
|
||||
const temperature = temp[gridReference[cellId]];
|
||||
pack.cells.biome[cellId] = getId(moisture, temperature, height, Boolean(riverIds[cellId]));
|
||||
}
|
||||
|
||||
function calculateMoisture(cellId) {
|
||||
const calculateMoisture = (cellId: number) => {
|
||||
let moisture = prec[gridReference[cellId]];
|
||||
if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2);
|
||||
|
||||
const moistAround = neighbors[cellId]
|
||||
.filter(neibCellId => heights[neibCellId] >= MIN_LAND_HEIGHT)
|
||||
.map(c => prec[gridReference[c]])
|
||||
.filter((neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT)
|
||||
.map((c: number) => prec[gridReference[c]])
|
||||
.concat([moisture]);
|
||||
return rn(4 + d3.mean(moistAround));
|
||||
return rn(4 + (mean(moistAround) as number));
|
||||
}
|
||||
|
||||
for (let cellId = 0; cellId < heights.length; cellId++) {
|
||||
const height = heights[cellId];
|
||||
const moisture = height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId);
|
||||
const temperature = temp[gridReference[cellId]];
|
||||
pack.cells.biome[cellId] = this.getId(moisture, temperature, height, Boolean(riverIds[cellId]));
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("defineBiomes");
|
||||
}
|
||||
|
||||
function getId(moisture, temperature, height, hasRiver) {
|
||||
getId(moisture: number, temperature: number, height: number, hasRiver: boolean) {
|
||||
if (height < 20) return 0; // all water cells: marine biome
|
||||
if (temperature < -5) return 11; // too cold: permafrost biome
|
||||
if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome
|
||||
if (isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome
|
||||
if (this.isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome
|
||||
|
||||
// in other cases use biome matrix
|
||||
const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4]
|
||||
const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25]
|
||||
return biomesData.biomesMartix[moistureBand][temperatureBand];
|
||||
return biomesData.biomesMatrix[moistureBand][temperatureBand];
|
||||
}
|
||||
|
||||
function isWetland(moisture, temperature, height) {
|
||||
private isWetland(moisture: number, temperature: number, height: number) {
|
||||
if (temperature <= -2) return false; // too cold
|
||||
if (moisture > 40 && height < 25) return true; // near coast
|
||||
if (moisture > 24 && height > 24 && height < 60) return true; // off coast
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {getDefault, define, getId};
|
||||
})();
|
||||
window.Biomes = new BiomesModule();
|
||||
328
src/modules/features.ts
Normal file
328
src/modules/features.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { clipPoly, connectVertices, createTypedArray, distanceSquared, isLand, isWater, rn, TYPED_ARRAY_MAX_VALUES, unique } from "../utils";
|
||||
import Alea from "alea";
|
||||
import { polygonArea } from "d3";
|
||||
|
||||
declare global {
|
||||
var Features: FeatureModule;
|
||||
}
|
||||
|
||||
type FeatureType = "ocean" | "lake" | "island";
|
||||
|
||||
export interface PackedGraphFeature {
|
||||
i: number;
|
||||
type: FeatureType;
|
||||
land: boolean;
|
||||
border: boolean;
|
||||
cells: number;
|
||||
firstCell: number;
|
||||
vertices: number[];
|
||||
area: number;
|
||||
shoreline: number[];
|
||||
height: number;
|
||||
group: string;
|
||||
temp: number;
|
||||
flux: number;
|
||||
evaporation: number;
|
||||
name: string;
|
||||
|
||||
// River related
|
||||
inlets?: number[];
|
||||
outlet?: number;
|
||||
river?: number;
|
||||
enteringFlux?: number;
|
||||
closed?: boolean;
|
||||
outCell?: number;
|
||||
}
|
||||
|
||||
export interface GridFeature {
|
||||
i: number;
|
||||
land: boolean;
|
||||
border: boolean;
|
||||
type: FeatureType;
|
||||
}
|
||||
|
||||
class FeatureModule {
|
||||
private DEEPER_LAND = 3;
|
||||
private LANDLOCKED = 2;
|
||||
private LAND_COAST = 1;
|
||||
private UNMARKED = 0;
|
||||
private WATER_COAST = -1;
|
||||
private DEEP_WATER = -2;
|
||||
|
||||
/**
|
||||
* calculate distance to coast for every cell
|
||||
*/
|
||||
private markup({ distanceField, neighbors, start, increment, limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX }: {
|
||||
distanceField: Int8Array;
|
||||
neighbors: number[][];
|
||||
start: number;
|
||||
increment: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) {
|
||||
marked = 0;
|
||||
const prevDistance = distance - increment;
|
||||
for (let cellId = 0; cellId < neighbors.length; cellId++) {
|
||||
if (distanceField[cellId] !== prevDistance) continue;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
if (distanceField[neighborId] !== this.UNMARKED) continue;
|
||||
distanceField[neighborId] = distance;
|
||||
marked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mark Grid features (ocean, lakes, islands) and calculate distance field
|
||||
*/
|
||||
markupGrid() {
|
||||
TIME && console.time("markupGrid");
|
||||
Math.random = Alea(seed); // get the same result on heightmap edit in Erase mode
|
||||
|
||||
const { h: heights, c: neighbors, b: borderCells, i } = grid.cells;
|
||||
const cellsNumber = i.length;
|
||||
const distanceField = new Int8Array(cellsNumber); // gird.cells.t
|
||||
const featureIds = new Uint16Array(cellsNumber); // gird.cells.f
|
||||
const features: GridFeature[] = [];
|
||||
|
||||
const queue = [0];
|
||||
for (let featureId = 1; queue[0] !== -1; featureId++) {
|
||||
const firstCell = queue[0];
|
||||
featureIds[firstCell] = featureId;
|
||||
|
||||
const land = heights[firstCell] >= 20;
|
||||
let border = false; // set true if feature touches map edge
|
||||
|
||||
while (queue.length) {
|
||||
const cellId = queue.pop() as number;
|
||||
if (!border && borderCells[cellId]) border = true;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
const isNeibLand = heights[neighborId] >= 20;
|
||||
|
||||
if (land === isNeibLand && featureIds[neighborId] === this.UNMARKED) {
|
||||
featureIds[neighborId] = featureId;
|
||||
queue.push(neighborId);
|
||||
} else if (land && !isNeibLand) {
|
||||
distanceField[cellId] = this.LAND_COAST;
|
||||
distanceField[neighborId] = this.WATER_COAST;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const type = land ? "island" : border ? "ocean" : "lake";
|
||||
features.push({ i: featureId, land, border, type });
|
||||
|
||||
queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell
|
||||
}
|
||||
|
||||
// markup deep ocean cells
|
||||
this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 });
|
||||
grid.cells.t = distanceField;
|
||||
grid.cells.f = featureIds;
|
||||
grid.features = [0, ...features];
|
||||
|
||||
TIME && console.timeEnd("markupGrid");
|
||||
}
|
||||
|
||||
/**
|
||||
* mark PackedGraph features (oceans, lakes, islands) and calculate distance field
|
||||
*/
|
||||
markupPack() {
|
||||
const defineHaven = (cellId: number) => {
|
||||
const waterCells = neighbors[cellId].filter((index: number) => isWater(index, pack));
|
||||
const distances = waterCells.map((neibCellId: number) => distanceSquared(cells.p[cellId], cells.p[neibCellId]));
|
||||
const closest = distances.indexOf(Math.min.apply(Math, distances));
|
||||
|
||||
haven[cellId] = waterCells[closest];
|
||||
harbor[cellId] = waterCells.length;
|
||||
}
|
||||
|
||||
const getCellsData = (featureType: string, firstCell: number): [number, number[]] => {
|
||||
if (featureType === "ocean") return [firstCell, []];
|
||||
|
||||
const getType = (cellId: number) => featureIds[cellId];
|
||||
const type = getType(firstCell);
|
||||
const ofSameType = (cellId: number) => getType(cellId) === type;
|
||||
const ofDifferentType = (cellId: number) => getType(cellId) !== type;
|
||||
|
||||
const startCell = findOnBorderCell(firstCell);
|
||||
const featureVertices = getFeatureVertices(startCell);
|
||||
return [startCell, featureVertices];
|
||||
|
||||
function findOnBorderCell(firstCell: number) {
|
||||
const isOnBorder = (cellId: number) => borderCells[cellId] || neighbors[cellId].some(ofDifferentType);
|
||||
if (isOnBorder(firstCell)) return firstCell;
|
||||
|
||||
const startCell = cells.i.filter(ofSameType).find(isOnBorder);
|
||||
if (startCell === undefined)
|
||||
throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`);
|
||||
|
||||
return startCell;
|
||||
}
|
||||
|
||||
function getFeatureVertices(startCell: number) {
|
||||
const startingVertex = cells.v[startCell].find((v: number) => vertices.c[v].some(ofDifferentType));
|
||||
if (startingVertex === undefined)
|
||||
throw new Error(`Markup: startingVertex for cell ${startCell} is not found`);
|
||||
|
||||
return connectVertices({ vertices, startingVertex, ofSameType, closeRing: false });
|
||||
}
|
||||
}
|
||||
|
||||
const addFeature = ({ firstCell, land, border, featureId, totalCells }: { firstCell: number; land: boolean; border: boolean; featureId: number; totalCells: number }): PackedGraphFeature => {
|
||||
const type = land ? "island" : border ? "ocean" : "lake";
|
||||
const [startCell, featureVertices] = getCellsData(type, firstCell);
|
||||
const points = clipPoly(featureVertices.map((vertex: number) => vertices.p[vertex]));
|
||||
const area = polygonArea(points); // feature perimiter area
|
||||
const absArea = Math.abs(rn(area));
|
||||
|
||||
const feature: Partial<PackedGraphFeature> = {
|
||||
i: featureId,
|
||||
type,
|
||||
land,
|
||||
border,
|
||||
cells: totalCells,
|
||||
firstCell: startCell,
|
||||
vertices: featureVertices,
|
||||
area: absArea,
|
||||
shoreline: [],
|
||||
height: 0,
|
||||
};
|
||||
|
||||
if (type === "lake") {
|
||||
if (area > 0) feature.vertices = (feature.vertices as number[]).reverse();
|
||||
feature.shoreline = unique(
|
||||
(feature.vertices as number[])
|
||||
.flatMap(
|
||||
vertexIndex => vertices.c[vertexIndex].filter((index) => isLand(index, pack))
|
||||
)
|
||||
);
|
||||
feature.height = Lakes.getHeight(feature as PackedGraphFeature);
|
||||
}
|
||||
|
||||
return {
|
||||
...feature
|
||||
} as PackedGraphFeature;
|
||||
}
|
||||
|
||||
TIME && console.time("markupPack");
|
||||
|
||||
const { cells, vertices } = pack;
|
||||
const { c: neighbors, b: borderCells, i } = cells;
|
||||
const packCellsNumber = i.length;
|
||||
if (!packCellsNumber) return; // no cells -> there is nothing to do
|
||||
|
||||
const distanceField = new Int8Array(packCellsNumber); // pack.cells.t
|
||||
const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f
|
||||
const haven = createTypedArray({ maxValue: packCellsNumber, length: packCellsNumber }); // haven: opposite water cell
|
||||
const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells
|
||||
const features: PackedGraphFeature[] = [];
|
||||
|
||||
const queue = [0];
|
||||
for (let featureId = 1; queue[0] !== -1; featureId++) {
|
||||
const firstCell = queue[0];
|
||||
featureIds[firstCell] = featureId;
|
||||
|
||||
const land = isLand(firstCell, pack);
|
||||
let border = Boolean(borderCells[firstCell]); // true if feature touches map border
|
||||
let totalCells = 1; // count cells in a feature
|
||||
|
||||
while (queue.length) {
|
||||
const cellId = queue.pop() as number;
|
||||
if (borderCells[cellId]) border = true;
|
||||
|
||||
for (const neighborId of neighbors[cellId]) {
|
||||
const isNeibLand = isLand(neighborId, pack);
|
||||
|
||||
if (land && !isNeibLand) {
|
||||
distanceField[cellId] = this.LAND_COAST;
|
||||
distanceField[neighborId] = this.WATER_COAST;
|
||||
if (!haven[cellId]) defineHaven(cellId);
|
||||
} else if (land && isNeibLand) {
|
||||
if (distanceField[neighborId] === this.UNMARKED && distanceField[cellId] === this.LAND_COAST)
|
||||
distanceField[neighborId] = this.LANDLOCKED;
|
||||
else if (distanceField[cellId] === this.UNMARKED && distanceField[neighborId] === this.LAND_COAST)
|
||||
distanceField[cellId] = this.LANDLOCKED;
|
||||
}
|
||||
|
||||
if (!featureIds[neighborId] && land === isNeibLand) {
|
||||
queue.push(neighborId);
|
||||
featureIds[neighborId] = featureId;
|
||||
totalCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
features.push(addFeature({ firstCell, land, border, featureId, totalCells }));
|
||||
queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell
|
||||
}
|
||||
|
||||
this.markup({ distanceField, neighbors, start: this.DEEPER_LAND, increment: 1 }); // markup pack land
|
||||
this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); // markup pack water
|
||||
|
||||
pack.cells.t = distanceField;
|
||||
pack.cells.f = featureIds;
|
||||
pack.cells.haven = haven;
|
||||
pack.cells.harbor = harbor;
|
||||
pack.features = [0 as unknown as PackedGraphFeature, ...features];
|
||||
TIME && console.timeEnd("markupPack");
|
||||
}
|
||||
|
||||
/**
|
||||
* define feature groups (ocean, sea, gulf, continent, island, isle, freshwater lake, salt lake, etc.)
|
||||
*/
|
||||
defineGroups() {
|
||||
const gridCellsNumber = grid.cells.i.length;
|
||||
const OCEAN_MIN_SIZE = gridCellsNumber / 25;
|
||||
const SEA_MIN_SIZE = gridCellsNumber / 1000;
|
||||
const CONTINENT_MIN_SIZE = gridCellsNumber / 10;
|
||||
const ISLAND_MIN_SIZE = gridCellsNumber / 1000;
|
||||
|
||||
const defineIslandGroup = (feature: PackedGraphFeature) => {
|
||||
const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]];
|
||||
if (prevFeature && prevFeature.type === "lake") return "lake_island";
|
||||
if (feature.cells > CONTINENT_MIN_SIZE) return "continent";
|
||||
if (feature.cells > ISLAND_MIN_SIZE) return "island";
|
||||
return "isle";
|
||||
}
|
||||
|
||||
const defineOceanGroup = (feature: PackedGraphFeature) => {
|
||||
if (feature.cells > OCEAN_MIN_SIZE) return "ocean";
|
||||
if (feature.cells > SEA_MIN_SIZE) return "sea";
|
||||
return "gulf";
|
||||
}
|
||||
|
||||
const defineLakeGroup = (feature: PackedGraphFeature) => {
|
||||
if (feature.temp < -3) return "frozen";
|
||||
if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava";
|
||||
|
||||
if (!feature.inlets && !feature.outlet) {
|
||||
if (feature.evaporation > feature.flux * 4) return "dry";
|
||||
if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole";
|
||||
}
|
||||
|
||||
if (!feature.outlet && feature.evaporation > feature.flux) return "salt";
|
||||
|
||||
return "freshwater";
|
||||
}
|
||||
|
||||
const defineGroup = (feature: PackedGraphFeature) => {
|
||||
if (feature.type === "island") return defineIslandGroup(feature);
|
||||
if (feature.type === "ocean") return defineOceanGroup(feature);
|
||||
if (feature.type === "lake") return defineLakeGroup(feature);
|
||||
throw new Error(`Markup: unknown feature type ${feature.type}`);
|
||||
}
|
||||
|
||||
for (const feature of pack.features) {
|
||||
if (!feature || feature.type === "ocean") continue;
|
||||
|
||||
if (feature.type === "lake") feature.height = Lakes.getHeight(feature);
|
||||
feature.group = defineGroup(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.Features = new FeatureModule();
|
||||
|
|
@ -3,12 +3,7 @@ import { range as d3Range, leastIndex, mean } from "d3";
|
|||
import { createTypedArray, byId, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
HeightmapGenerator: HeightmapGenerator;
|
||||
}
|
||||
var heightmapTemplates: any;
|
||||
var TIME: boolean;
|
||||
var ERROR: boolean;
|
||||
var HeightmapGenerator: HeightmapGenerator;
|
||||
}
|
||||
|
||||
type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth";
|
||||
|
|
@ -19,21 +14,6 @@ class HeightmapGenerator {
|
|||
blobPower: number = 0;
|
||||
linePower: number = 0;
|
||||
|
||||
// TODO: remove after migration to TS and use param in constructor
|
||||
get seed() {
|
||||
return (window as any).seed;
|
||||
}
|
||||
get graphWidth() {
|
||||
return (window as any).graphWidth;
|
||||
}
|
||||
get graphHeight() {
|
||||
return (window as any).graphHeight;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
private clearData() {
|
||||
this.heights = null;
|
||||
this.grid = null;
|
||||
|
|
@ -107,8 +87,8 @@ class HeightmapGenerator {
|
|||
let h = lim(getNumberInRange(height));
|
||||
|
||||
do {
|
||||
const x = this.getPointInRange(rangeX, this.graphWidth);
|
||||
const y = this.getPointInRange(rangeY, this.graphHeight);
|
||||
const x = this.getPointInRange(rangeX, graphWidth);
|
||||
const y = this.getPointInRange(rangeY, graphHeight);
|
||||
if (x === undefined || y === undefined) return;
|
||||
start = findGridCell(x, y, this.grid);
|
||||
limit++;
|
||||
|
|
@ -143,8 +123,8 @@ class HeightmapGenerator {
|
|||
let h = lim(getNumberInRange(height));
|
||||
|
||||
do {
|
||||
const x = this.getPointInRange(rangeX, this.graphWidth);
|
||||
const y = this.getPointInRange(rangeY, this.graphHeight);
|
||||
const x = this.getPointInRange(rangeX, graphWidth);
|
||||
const y = this.getPointInRange(rangeY, graphHeight);
|
||||
if (x === undefined || y === undefined) return;
|
||||
start = findGridCell(x, y, this.grid);
|
||||
limit++;
|
||||
|
|
@ -207,8 +187,8 @@ class HeightmapGenerator {
|
|||
|
||||
if (rangeX && rangeY) {
|
||||
// find start and end points
|
||||
const startX = this.getPointInRange(rangeX, this.graphWidth) as number;
|
||||
const startY = this.getPointInRange(rangeY, this.graphHeight) as number;
|
||||
const startX = this.getPointInRange(rangeX, graphWidth) as number;
|
||||
const startY = this.getPointInRange(rangeY, graphHeight) as number;
|
||||
|
||||
let dist = 0;
|
||||
let limit = 0;
|
||||
|
|
@ -216,11 +196,11 @@ class HeightmapGenerator {
|
|||
let endX;
|
||||
|
||||
do {
|
||||
endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1;
|
||||
endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15;
|
||||
endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1;
|
||||
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
|
||||
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
|
||||
limit++;
|
||||
} while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 3) && limit < 50);
|
||||
} while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50);
|
||||
|
||||
startCellId = findGridCell(startX, startY, this.grid);
|
||||
endCellId = findGridCell(endX, endY, this.grid);
|
||||
|
|
@ -311,19 +291,19 @@ class HeightmapGenerator {
|
|||
let endX: number;
|
||||
let endY: number;
|
||||
do {
|
||||
startX = this.getPointInRange(rangeX, this.graphWidth) as number;
|
||||
startY = this.getPointInRange(rangeY, this.graphHeight) as number;
|
||||
startX = this.getPointInRange(rangeX, graphWidth) as number;
|
||||
startY = this.getPointInRange(rangeY, graphHeight) as number;
|
||||
startCellId = findGridCell(startX, startY, this.grid);
|
||||
limit++;
|
||||
} while (this.heights[startCellId] < 20 && limit < 50);
|
||||
|
||||
limit = 0;
|
||||
do {
|
||||
endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1;
|
||||
endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15;
|
||||
endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1;
|
||||
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
|
||||
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
|
||||
limit++;
|
||||
} while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 2) && limit < 50);
|
||||
} while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50);
|
||||
|
||||
endCellId = findGridCell(endX, endY, this.grid);
|
||||
}
|
||||
|
|
@ -378,14 +358,14 @@ class HeightmapGenerator {
|
|||
if (desiredWidth < 1 && P(desiredWidth)) return;
|
||||
const used = new Uint8Array(this.heights.length);
|
||||
const vert = direction === "vertical";
|
||||
const startX = vert ? Math.floor(Math.random() * this.graphWidth * 0.4 + this.graphWidth * 0.3) : 5;
|
||||
const startY = vert ? 5 : Math.floor(Math.random() * this.graphHeight * 0.4 + this.graphHeight * 0.3);
|
||||
const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5;
|
||||
const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3);
|
||||
const endX = vert
|
||||
? Math.floor(this.graphWidth - startX - this.graphWidth * 0.1 + Math.random() * this.graphWidth * 0.2)
|
||||
: this.graphWidth - 5;
|
||||
? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2)
|
||||
: graphWidth - 5;
|
||||
const endY = vert
|
||||
? this.graphHeight - 5
|
||||
: Math.floor(this.graphHeight - startY - this.graphHeight * 0.1 + Math.random() * this.graphHeight * 0.2);
|
||||
? graphHeight - 5
|
||||
: Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2);
|
||||
|
||||
const start = findGridCell(startX, startY, this.grid);
|
||||
const end = findGridCell(endX, endY, this.grid);
|
||||
|
|
@ -462,8 +442,8 @@ class HeightmapGenerator {
|
|||
|
||||
this.heights = this.heights.map((h, i) => {
|
||||
const [x, y] = this.grid.points[i];
|
||||
const nx = (2 * x) / this.graphWidth - 1; // [-1, 1], 0 is center
|
||||
const ny = (2 * y) / this.graphHeight - 1; // [-1, 1], 0 is center
|
||||
const nx = (2 * x) / graphWidth - 1; // [-1, 1], 0 is center
|
||||
const ny = (2 * y) / graphHeight - 1; // [-1, 1], 0 is center
|
||||
let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge
|
||||
if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge
|
||||
const masked = h * distance;
|
||||
|
|
@ -509,7 +489,7 @@ class HeightmapGenerator {
|
|||
TIME && console.time("defineHeightmap");
|
||||
const id = (byId("templateInput")! as HTMLInputElement).value;
|
||||
|
||||
Math.random = Alea(this.seed);
|
||||
Math.random = Alea(seed);
|
||||
const isTemplate = id in heightmapTemplates;
|
||||
|
||||
const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
import "./voronoi";
|
||||
import "./heightmap-generator";
|
||||
import "./features";
|
||||
import "./lakes";
|
||||
import "./ocean-layers";
|
||||
import "./river-generator";
|
||||
import "./biomes"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,93 @@
|
|||
"use strict";
|
||||
import { PackedGraphFeature } from "./features";
|
||||
import { min, mean } from "d3";
|
||||
import { byId,
|
||||
rn } from "../utils";
|
||||
|
||||
window.Lakes = (function () {
|
||||
const LAKE_ELEVATION_DELTA = 0.1;
|
||||
declare global {
|
||||
var Lakes: LakesModule;
|
||||
}
|
||||
|
||||
export class LakesModule {
|
||||
private LAKE_ELEVATION_DELTA = 0.1;
|
||||
|
||||
getHeight(feature: PackedGraphFeature) {
|
||||
const heights = pack.cells.h;
|
||||
const minShoreHeight = min(feature.shoreline.map(cellId => heights[cellId])) || 20;
|
||||
return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2);
|
||||
};
|
||||
|
||||
defineNames() {
|
||||
pack.features.forEach((feature: PackedGraphFeature) => {
|
||||
if (feature.type !== "lake") return;
|
||||
feature.name = this.getName(feature);
|
||||
});
|
||||
};
|
||||
|
||||
getName(feature: PackedGraphFeature): string {
|
||||
const landCell = feature.shoreline[0];
|
||||
const culture = pack.cells.culture[landCell];
|
||||
return Names.getCulture(culture);
|
||||
};
|
||||
|
||||
cleanupLakeData = function () {
|
||||
for (const feature of pack.features) {
|
||||
if (feature.type !== "lake") continue;
|
||||
delete feature.river;
|
||||
delete feature.enteringFlux;
|
||||
delete feature.outCell;
|
||||
delete feature.closed;
|
||||
feature.height = rn(feature.height, 3);
|
||||
|
||||
const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r));
|
||||
if (!inlets || !inlets.length) delete feature.inlets;
|
||||
else feature.inlets = inlets;
|
||||
|
||||
const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet);
|
||||
if (!outlet) delete feature.outlet;
|
||||
}
|
||||
};
|
||||
|
||||
defineClimateData(heights: number[] | Uint8Array) {
|
||||
const {cells, features} = pack;
|
||||
const lakeOutCells = new Uint16Array(cells.i.length);
|
||||
|
||||
const getFlux = (lake: PackedGraphFeature) => {
|
||||
return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0);
|
||||
}
|
||||
|
||||
const getLakeTemp = (lake: PackedGraphFeature) => {
|
||||
if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]];
|
||||
return rn(mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])) as number, 1);
|
||||
}
|
||||
|
||||
const getLakeEvaporation = (lake: PackedGraphFeature) => {
|
||||
const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters
|
||||
const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11]
|
||||
return rn(evaporation * lake.cells);
|
||||
}
|
||||
|
||||
const getLowestShoreCell = (lake: PackedGraphFeature) => {
|
||||
return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0];
|
||||
}
|
||||
|
||||
features.forEach(feature => {
|
||||
if (feature.type !== "lake") return;
|
||||
feature.flux = getFlux(feature);
|
||||
feature.temp = getLakeTemp(feature);
|
||||
feature.evaporation = getLakeEvaporation(feature);
|
||||
if (feature.closed) return; // no outlet for lakes in depressed areas
|
||||
|
||||
feature.outCell = getLowestShoreCell(feature);
|
||||
lakeOutCells[feature.outCell as number] = feature.i;
|
||||
});
|
||||
|
||||
return lakeOutCells;
|
||||
};
|
||||
|
||||
// check if lake can be potentially open (not in deep depression)
|
||||
const detectCloseLakes = h => {
|
||||
detectCloseLakes(h: number[] | Uint8Array) {
|
||||
const {cells} = pack;
|
||||
const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value;
|
||||
const ELEVATION_LIMIT = +(byId("lakeElevationLimitOutput") as HTMLInputElement)?.value;
|
||||
|
||||
pack.features.forEach(feature => {
|
||||
if (feature.type !== "lake") return;
|
||||
|
|
@ -25,7 +106,7 @@ window.Lakes = (function () {
|
|||
checked[lowestShorelineCell] = true;
|
||||
|
||||
while (queue.length && isDeep) {
|
||||
const cellId = queue.pop();
|
||||
const cellId: number = queue.pop() as number;
|
||||
|
||||
for (const neibCellId of cells.c[cellId]) {
|
||||
if (checked[neibCellId]) continue;
|
||||
|
|
@ -44,80 +125,6 @@ window.Lakes = (function () {
|
|||
feature.closed = isDeep;
|
||||
});
|
||||
};
|
||||
|
||||
const defineClimateData = function (heights) {
|
||||
const {cells, features} = pack;
|
||||
const lakeOutCells = new Uint16Array(cells.i.length);
|
||||
|
||||
features.forEach(feature => {
|
||||
if (feature.type !== "lake") return;
|
||||
feature.flux = getFlux(feature);
|
||||
feature.temp = getLakeTemp(feature);
|
||||
feature.evaporation = getLakeEvaporation(feature);
|
||||
if (feature.closed) return; // no outlet for lakes in depressed areas
|
||||
|
||||
feature.outCell = getLowestShoreCell(feature);
|
||||
lakeOutCells[feature.outCell] = feature.i;
|
||||
});
|
||||
|
||||
return lakeOutCells;
|
||||
|
||||
function getFlux(lake) {
|
||||
return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0);
|
||||
}
|
||||
|
||||
function getLakeTemp(lake) {
|
||||
if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]];
|
||||
return rn(d3.mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])), 1);
|
||||
}
|
||||
|
||||
function getLakeEvaporation(lake) {
|
||||
const height = (lake.height - 18) ** heightExponentInput.value; // height in meters
|
||||
const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11]
|
||||
return rn(evaporation * lake.cells);
|
||||
}
|
||||
|
||||
function getLowestShoreCell(lake) {
|
||||
return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0];
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupLakeData = function () {
|
||||
for (const feature of pack.features) {
|
||||
if (feature.type !== "lake") continue;
|
||||
delete feature.river;
|
||||
delete feature.enteringFlux;
|
||||
delete feature.outCell;
|
||||
delete feature.closed;
|
||||
feature.height = rn(feature.height, 3);
|
||||
|
||||
const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r));
|
||||
if (!inlets || !inlets.length) delete feature.inlets;
|
||||
else feature.inlets = inlets;
|
||||
|
||||
const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet);
|
||||
if (!outlet) delete feature.outlet;
|
||||
}
|
||||
};
|
||||
|
||||
const getHeight = function (feature) {
|
||||
const heights = pack.cells.h;
|
||||
const minShoreHeight = d3.min(feature.shoreline.map(cellId => heights[cellId])) || 20;
|
||||
return rn(minShoreHeight - LAKE_ELEVATION_DELTA, 2);
|
||||
};
|
||||
|
||||
const defineNames = function () {
|
||||
pack.features.forEach(feature => {
|
||||
if (feature.type !== "lake") return;
|
||||
feature.name = getName(feature);
|
||||
});
|
||||
};
|
||||
|
||||
const getName = function (feature) {
|
||||
const landCell = feature.shoreline[0];
|
||||
const culture = pack.cells.culture[landCell];
|
||||
return Names.getCulture(culture);
|
||||
};
|
||||
|
||||
return {defineClimateData, cleanupLakeData, detectCloseLakes, getHeight, defineNames, getName};
|
||||
})();
|
||||
window.Lakes = new LakesModule();
|
||||
110
src/modules/ocean-layers.ts
Normal file
110
src/modules/ocean-layers.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { line, curveBasisClosed } from 'd3';
|
||||
import type { Selection } from 'd3';
|
||||
import { clipPoly,P,rn,round } from '../utils';
|
||||
|
||||
declare global {
|
||||
var OceanLayers: typeof OceanModule.prototype.draw;
|
||||
}
|
||||
class OceanModule {
|
||||
private cells: any;
|
||||
private vertices: any;
|
||||
private pointsN: any;
|
||||
private used: any;
|
||||
private lineGen = line().curve(curveBasisClosed);
|
||||
private oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
|
||||
|
||||
|
||||
constructor(oceanLayers: Selection<SVGGElement, unknown, null, undefined>) {
|
||||
this.oceanLayers = oceanLayers;
|
||||
}
|
||||
|
||||
randomizeOutline() {
|
||||
const limits = [];
|
||||
let odd = 0.2;
|
||||
for (let l = -9; l < 0; l++) {
|
||||
if (P(odd)) {
|
||||
odd = 0.2;
|
||||
limits.push(l);
|
||||
} else {
|
||||
odd *= 2;
|
||||
}
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
// connect vertices to chain
|
||||
connectVertices(start: number, t: number) {
|
||||
const chain = []; // vertices chain to form a path
|
||||
for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) {
|
||||
const prev = chain[chain.length - 1]; // previous vertex in chain
|
||||
chain.push(current); // add current vertex to sequence
|
||||
const c = this.vertices.c[current]; // cells adjacent to vertex
|
||||
c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => (this.used[c] = 1));
|
||||
const v = this.vertices.v[current]; // neighboring vertices
|
||||
const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1;
|
||||
const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1;
|
||||
const c2 = !this.cells.t[c[2]] || this.cells.t[c[2]] === t - 1;
|
||||
if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0];
|
||||
else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1];
|
||||
else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2];
|
||||
if (current === chain[chain.length - 1]) {
|
||||
ERROR && console.error("Next vertex is not found");
|
||||
break;
|
||||
}
|
||||
}
|
||||
chain.push(chain[0]); // push first vertex as the last one
|
||||
return chain;
|
||||
}
|
||||
|
||||
// find eligible cell vertex to start path detection
|
||||
findStart(i: number, t: number) {
|
||||
if (this.cells.b[i]) return this.cells.v[i].find((v: number) => this.vertices.c[v].some((c: number) => c >= this.pointsN)); // map border cell
|
||||
return this.cells.v[i][this.cells.c[i].findIndex((c: number)=> this.cells.t[c] < t || !this.cells.t[c])];
|
||||
}
|
||||
|
||||
draw() {
|
||||
const outline = this.oceanLayers.attr("layers");
|
||||
if (outline === "none") return;
|
||||
TIME && console.time("drawOceanLayers");
|
||||
this.cells = grid.cells;
|
||||
this.pointsN = grid.cells.i.length;
|
||||
this.vertices = grid.vertices;
|
||||
const limits = outline === "random" ? this.randomizeOutline() : outline.split(",").map((s: string) => +s);
|
||||
|
||||
const chains: [number, any[]][] = [];
|
||||
const opacity = rn(0.4 / limits.length, 2);
|
||||
this.used = new Uint8Array(this.pointsN); // to detect already passed cells
|
||||
|
||||
for (const i of this.cells.i) {
|
||||
const t = this.cells.t[i];
|
||||
if (t > 0) continue;
|
||||
if (this.used[i] || !limits.includes(t)) continue;
|
||||
const start = this.findStart(i, t);
|
||||
if (!start) continue;
|
||||
this.used[i] = 1;
|
||||
const chain = this.connectVertices(start, t); // vertices chain to form a path
|
||||
if (chain.length < 4) continue;
|
||||
const relax = 1 + t * -2; // select only n-th point
|
||||
const relaxed = chain.filter((v, i) => !(i % relax) || this.vertices.c[v].some((c: number) => c >= this.pointsN));
|
||||
if (relaxed.length < 4) continue;
|
||||
|
||||
const points = clipPoly(
|
||||
relaxed.map(v => this.vertices.p[v]),
|
||||
graphWidth,
|
||||
graphHeight,
|
||||
1
|
||||
);
|
||||
chains.push([t, points]);
|
||||
}
|
||||
|
||||
for (const t of limits) {
|
||||
const layer = chains.filter((c: [number, any[]]) => c[0] === t);
|
||||
let path = layer.map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")).join("");
|
||||
if (path) this.oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity);
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("drawOceanLayers");
|
||||
}
|
||||
}
|
||||
|
||||
window.OceanLayers = () => new OceanModule(oceanLayers).draw();
|
||||
|
|
@ -1,66 +1,89 @@
|
|||
"use strict";
|
||||
import Alea from "alea";
|
||||
import { each, rn, round, rw} from "../utils";
|
||||
import { curveBasis, line, mean, min, sum, curveCatmullRom } from "d3";
|
||||
|
||||
window.Rivers = (function () {
|
||||
const generate = function (allowErosion = true) {
|
||||
TIME && console.time("generateRivers");
|
||||
Math.random = aleaPRNG(seed);
|
||||
const {cells, features} = pack;
|
||||
|
||||
const riversData = {}; // rivers data
|
||||
const riverParents = {};
|
||||
|
||||
const addCellToRiver = function (cell, river) {
|
||||
if (!riversData[river]) riversData[river] = [cell];
|
||||
else riversData[river].push(cell);
|
||||
};
|
||||
|
||||
cells.fl = new Uint16Array(cells.i.length); // water flux array
|
||||
cells.r = new Uint16Array(cells.i.length); // rivers array
|
||||
cells.conf = new Uint8Array(cells.i.length); // confluences array
|
||||
let riverNext = 1; // first river id is 1
|
||||
|
||||
const h = alterHeights();
|
||||
Lakes.detectCloseLakes(h);
|
||||
resolveDepressions(h);
|
||||
drainWater();
|
||||
defineRivers();
|
||||
|
||||
calculateConfluenceFlux();
|
||||
Lakes.cleanupLakeData();
|
||||
|
||||
if (allowErosion) {
|
||||
cells.h = Uint8Array.from(h); // apply gradient
|
||||
downcutRivers(); // downcut river beds
|
||||
declare global {
|
||||
var Rivers: RiverModule;
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("generateRivers");
|
||||
export interface River {
|
||||
i: number; // river id
|
||||
source: number; // source cell index
|
||||
mouth: number; // mouth cell index
|
||||
parent: number; // parent river id
|
||||
basin: number; // basin river id
|
||||
length: number; // river length
|
||||
discharge: number; // river discharge in m3/s
|
||||
width: number; // mouth width in km
|
||||
widthFactor: number; // width scaling factor
|
||||
sourceWidth: number; // source width in km
|
||||
name: string; // river name
|
||||
type: string; // river type
|
||||
cells: number[]; // cells forming the river path
|
||||
}
|
||||
|
||||
function drainWater() {
|
||||
class RiverModule {
|
||||
private FLUX_FACTOR = 500;
|
||||
private MAX_FLUX_WIDTH = 1;
|
||||
private LENGTH_FACTOR = 200;
|
||||
private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR;
|
||||
private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / this.LENGTH_FACTOR);
|
||||
private lineGen = line().curve(curveBasis)
|
||||
|
||||
riverTypes = {
|
||||
main: {
|
||||
big: {River: 1},
|
||||
small: {Creek: 9, River: 3, Brook: 3, Stream: 1}
|
||||
},
|
||||
fork: {
|
||||
big: {Fork: 1},
|
||||
small: {Branch: 1}
|
||||
}
|
||||
};
|
||||
|
||||
smallLength: number | null = null;
|
||||
|
||||
generate(allowErosion = true) {
|
||||
TIME && console.time("generateRivers");
|
||||
Math.random = Alea(seed);
|
||||
const {cells, features} = pack;
|
||||
|
||||
const riversData: {[riverId: number]: number[]} = {};
|
||||
const riverParents: {[key: number]: number} = {};
|
||||
|
||||
const addCellToRiver = (cellId: number, riverId: number) => {
|
||||
if (!riversData[riverId]) riversData[riverId] = [cellId];
|
||||
else riversData[riverId].push(cellId);
|
||||
};
|
||||
|
||||
const drainWater = () => {
|
||||
const MIN_FLUX_TO_FORM_RIVER = 30;
|
||||
const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25;
|
||||
const cellsNumberModifier = ((pointsInput.dataset.cells as any) / 10000) ** 0.25;
|
||||
|
||||
const prec = grid.cells.prec;
|
||||
const land = cells.i.filter(i => h[i] >= 20).sort((a, b) => h[b] - h[a]);
|
||||
const land = cells.i.filter((i: number) => h[i] >= 20).sort((a: number, b: number) => h[b] - h[a]);
|
||||
const lakeOutCells = Lakes.defineClimateData(h);
|
||||
|
||||
land.forEach(function (i) {
|
||||
land.forEach(function (i: number) {
|
||||
cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation
|
||||
|
||||
// create lake outlet if lake is not in deep depression and flux > evaporation
|
||||
const lakes = lakeOutCells[i]
|
||||
? features.filter(feature => i === feature.outCell && feature.flux > feature.evaporation)
|
||||
? features.filter((feature: any) => i === feature.outCell && feature.flux > feature.evaporation)
|
||||
: [];
|
||||
for (const lake of lakes) {
|
||||
const lakeCell = cells.c[i].find(c => h[c] < 20 && cells.f[c] === lake.i);
|
||||
const lakeCell = cells.c[i].find((c: number) => h[c] < 20 && cells.f[c] === lake.i)!;
|
||||
cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet
|
||||
|
||||
// allow chain lakes to retain identity
|
||||
if (cells.r[lakeCell] !== lake.river) {
|
||||
const sameRiver = cells.c[lakeCell].some(c => cells.r[c] === lake.river);
|
||||
const sameRiver = cells.c[lakeCell].some((c: number) => cells.r[c] === lake.river);
|
||||
|
||||
if (sameRiver) {
|
||||
cells.r[lakeCell] = lake.river;
|
||||
addCellToRiver(lakeCell, lake.river);
|
||||
cells.r[lakeCell] = lake.river as number;
|
||||
addCellToRiver(lakeCell, lake.river as number);
|
||||
} else {
|
||||
cells.r[lakeCell] = riverNext;
|
||||
addCellToRiver(lakeCell, riverNext);
|
||||
|
|
@ -77,7 +100,7 @@ window.Rivers = (function () {
|
|||
for (const lake of lakes) {
|
||||
if (!Array.isArray(lake.inlets)) continue;
|
||||
for (const inlet of lake.inlets) {
|
||||
riverParents[inlet] = outlet;
|
||||
riverParents[inlet] = outlet as number;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +110,12 @@ window.Rivers = (function () {
|
|||
// downhill cell (make sure it's not in the source lake)
|
||||
let min = null;
|
||||
if (lakeOutCells[i]) {
|
||||
const filtered = cells.c[i].filter(c => !lakes.map(lake => lake.i).includes(cells.f[c]));
|
||||
min = filtered.sort((a, b) => h[a] - h[b])[0];
|
||||
const filtered = cells.c[i].filter((c: number) => !lakes.map((lake: any) => lake.i).includes(cells.f[c]));
|
||||
min = filtered.sort((a: number, b: number) => h[a] - h[b])[0];
|
||||
} else if (cells.haven[i]) {
|
||||
min = cells.haven[i];
|
||||
} else {
|
||||
min = cells.c[i].sort((a, b) => h[a] - h[b])[0];
|
||||
min = cells.c[i].sort((a: number, b: number) => h[a] - h[b])[0];
|
||||
}
|
||||
|
||||
// cells is depressed
|
||||
|
|
@ -124,7 +147,7 @@ window.Rivers = (function () {
|
|||
});
|
||||
}
|
||||
|
||||
function flowDown(toCell, fromFlux, river) {
|
||||
const flowDown = (toCell: number, fromFlux: number, river: number) => {
|
||||
const toFlux = cells.fl[toCell] - cells.conf[toCell];
|
||||
const toRiver = cells.r[toCell];
|
||||
|
||||
|
|
@ -144,7 +167,7 @@ window.Rivers = (function () {
|
|||
// pour water to the water body
|
||||
const waterBody = features[cells.f[toCell]];
|
||||
if (waterBody.type === "lake") {
|
||||
if (!waterBody.river || fromFlux > waterBody.enteringFlux) {
|
||||
if (!waterBody.river || fromFlux > (waterBody.enteringFlux as number)) {
|
||||
waterBody.river = river;
|
||||
waterBody.enteringFlux = fromFlux;
|
||||
}
|
||||
|
|
@ -160,13 +183,13 @@ window.Rivers = (function () {
|
|||
addCellToRiver(toCell, river);
|
||||
}
|
||||
|
||||
function defineRivers() {
|
||||
const defineRivers = () => {
|
||||
// re-initialize rivers and confluence arrays
|
||||
cells.r = new Uint16Array(cells.i.length);
|
||||
cells.conf = new Uint16Array(cells.i.length);
|
||||
pack.rivers = [];
|
||||
|
||||
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
|
||||
const defaultWidthFactor = rn(1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, 2);
|
||||
const mainStemWidthFactor = defaultWidthFactor * 1.2;
|
||||
|
||||
for (const key in riversData) {
|
||||
|
|
@ -187,12 +210,12 @@ window.Rivers = (function () {
|
|||
const parent = riverParents[key] || 0;
|
||||
|
||||
const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor;
|
||||
const meanderedPoints = addMeandering(riverCells);
|
||||
const meanderedPoints = this.addMeandering(riverCells);
|
||||
const discharge = cells.fl[mouth]; // m3 in second
|
||||
const length = getApproximateLength(meanderedPoints);
|
||||
const sourceWidth = getSourceWidth(cells.fl[source]);
|
||||
const width = getWidth(
|
||||
getOffset({
|
||||
const length = this.getApproximateLength(meanderedPoints);
|
||||
const sourceWidth = this.getSourceWidth(cells.fl[source]);
|
||||
const width = this.getWidth(
|
||||
this.getOffset({
|
||||
flux: discharge,
|
||||
pointIndex: meanderedPoints.length,
|
||||
widthFactor,
|
||||
|
|
@ -211,19 +234,19 @@ window.Rivers = (function () {
|
|||
sourceWidth,
|
||||
parent,
|
||||
cells: riverCells
|
||||
});
|
||||
} as River);
|
||||
}
|
||||
}
|
||||
|
||||
function downcutRivers() {
|
||||
const downcutRivers = () => {
|
||||
const MAX_DOWNCUT = 5;
|
||||
|
||||
for (const i of pack.cells.i) {
|
||||
if (cells.h[i] < 35) continue; // don't donwcut lowlands
|
||||
if (!cells.fl[i]) continue;
|
||||
|
||||
const higherCells = cells.c[i].filter(c => cells.h[c] > cells.h[i]);
|
||||
const higherFlux = higherCells.reduce((acc, c) => acc + cells.fl[c], 0) / higherCells.length;
|
||||
const higherCells = cells.c[i].filter((c: number) => cells.h[c] > cells.h[i]);
|
||||
const higherFlux = higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / higherCells.length;
|
||||
if (!higherFlux) continue;
|
||||
|
||||
const downcut = Math.floor(cells.fl[i] / higherFlux);
|
||||
|
|
@ -231,48 +254,68 @@ window.Rivers = (function () {
|
|||
}
|
||||
}
|
||||
|
||||
function calculateConfluenceFlux() {
|
||||
const calculateConfluenceFlux = () => {
|
||||
for (const i of cells.i) {
|
||||
if (!cells.conf[i]) continue;
|
||||
|
||||
const sortedInflux = cells.c[i]
|
||||
.filter(c => cells.r[c] && h[c] > h[i])
|
||||
.map(c => cells.fl[c])
|
||||
.sort((a, b) => b - a);
|
||||
cells.conf[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0);
|
||||
.filter((c: number) => cells.r[c] && h[c] > h[i])
|
||||
.map((c: number) => cells.fl[c])
|
||||
.sort((a: number, b: number) => b - a);
|
||||
cells.conf[i] = sortedInflux.reduce((acc: number, flux: number, index: number) => (index ? acc + flux : acc), 0);
|
||||
}
|
||||
}
|
||||
|
||||
cells.fl = new Uint16Array(cells.i.length); // water flux array
|
||||
cells.r = new Uint16Array(cells.i.length); // rivers array
|
||||
cells.conf = new Uint8Array(cells.i.length); // confluences array
|
||||
let riverNext = 1; // first river id is 1
|
||||
|
||||
const h = this.alterHeights();
|
||||
Lakes.detectCloseLakes(h);
|
||||
this.resolveDepressions(h);
|
||||
drainWater();
|
||||
defineRivers();
|
||||
|
||||
calculateConfluenceFlux();
|
||||
Lakes.cleanupLakeData();
|
||||
|
||||
if (allowErosion) {
|
||||
cells.h = Uint8Array.from(h); // apply gradient
|
||||
downcutRivers(); // downcut river beds
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("generateRivers");
|
||||
};
|
||||
|
||||
// add distance to water value to land cells to make map less depressed
|
||||
const alterHeights = () => {
|
||||
const {h, c, t} = pack.cells;
|
||||
alterHeights(): number[] {
|
||||
const {h, c, t} = pack.cells as {h: Uint8Array, c: number[][], t: Uint8Array};
|
||||
return Array.from(h).map((h, i) => {
|
||||
if (h < 20 || t[i] < 1) return h;
|
||||
return h + t[i] / 100 + d3.mean(c[i].map(c => t[c])) / 10000;
|
||||
return h + t[i] / 100 + (mean(c[i].map(c => t[c])) as number) / 10000;
|
||||
});
|
||||
};
|
||||
|
||||
// depression filling algorithm (for a correct water flux modeling)
|
||||
const resolveDepressions = function (h) {
|
||||
resolveDepressions(h: number[]) {
|
||||
const {cells, features} = pack;
|
||||
const maxIterations = +document.getElementById("resolveDepressionsStepsOutput").value;
|
||||
const maxIterations = +(document.getElementById("resolveDepressionsStepsOutput") as HTMLInputElement)?.value;
|
||||
const checkLakeMaxIteration = maxIterations * 0.85;
|
||||
const elevateLakeMaxIteration = maxIterations * 0.75;
|
||||
|
||||
const height = i => features[cells.f[i]].height || h[i]; // height of lake or specific cell
|
||||
const height = (i: number) => features[cells.f[i]].height || h[i]; // height of lake or specific cell
|
||||
|
||||
const lakes = features.filter(f => f.type === "lake");
|
||||
const land = cells.i.filter(i => h[i] >= 20 && !cells.b[i]); // exclude near-border cells
|
||||
land.sort((a, b) => h[a] - h[b]); // lowest cells go first
|
||||
const lakes = features.filter((feature) => feature.type === "lake");
|
||||
const land = cells.i.filter((i: number) => h[i] >= 20 && !cells.b[i]); // exclude near-border cells
|
||||
land.sort((a: number, b: number) => h[a] - h[b]); // lowest cells go first
|
||||
|
||||
const progress = [];
|
||||
let depressions = Infinity;
|
||||
let prevDepressions = null;
|
||||
for (let iteration = 0; depressions && iteration < maxIterations; iteration++) {
|
||||
if (progress.length > 5 && d3.sum(progress) > 0) {
|
||||
if (progress.length > 5 && sum(progress) > 0) {
|
||||
// bad progress, abort and set heights back
|
||||
h = alterHeights();
|
||||
h = this.alterHeights();
|
||||
depressions = progress[0];
|
||||
break;
|
||||
}
|
||||
|
|
@ -282,23 +325,23 @@ window.Rivers = (function () {
|
|||
if (iteration < checkLakeMaxIteration) {
|
||||
for (const l of lakes) {
|
||||
if (l.closed) continue;
|
||||
const minHeight = d3.min(l.shoreline.map(s => h[s]));
|
||||
const minHeight = min(l.shoreline.map((s: number) => h[s])) as number;
|
||||
if (minHeight >= 100 || l.height > minHeight) continue;
|
||||
|
||||
if (iteration > elevateLakeMaxIteration) {
|
||||
l.shoreline.forEach(i => (h[i] = cells.h[i]));
|
||||
l.height = d3.min(l.shoreline.map(s => h[s])) - 1;
|
||||
l.shoreline.forEach((i: number) => (h[i] = cells.h[i]));
|
||||
l.height = (min(l.shoreline.map((s: number) => h[s])) as number) - 1;
|
||||
l.closed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
depressions++;
|
||||
l.height = minHeight + 0.2;
|
||||
l.height = (minHeight as number) + 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
for (const i of land) {
|
||||
const minHeight = d3.min(cells.c[i].map(c => height(c)));
|
||||
const minHeight = min(cells.c[i].map((c: number) => height(c))) as number;
|
||||
if (minHeight >= 100 || h[i] > minHeight) continue;
|
||||
|
||||
depressions++;
|
||||
|
|
@ -312,12 +355,11 @@ window.Rivers = (function () {
|
|||
depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`);
|
||||
};
|
||||
|
||||
// add points at 1/3 and 2/3 of a line between adjacents river cells
|
||||
const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) {
|
||||
addMeandering(riverCells: number[], riverPoints = null, meandering = 0.5): [number, number, number][] {
|
||||
const {fl, h} = pack.cells;
|
||||
const meandered = [];
|
||||
const lastStep = riverCells.length - 1;
|
||||
const points = getRiverPoints(riverCells, riverPoints);
|
||||
const points = this.getRiverPoints(riverCells, riverPoints);
|
||||
let step = h[riverCells[0]] < 20 ? 1 : 10;
|
||||
|
||||
for (let i = 0; i <= lastStep; i++, step++) {
|
||||
|
|
@ -360,20 +402,20 @@ window.Rivers = (function () {
|
|||
}
|
||||
}
|
||||
|
||||
return meandered;
|
||||
return meandered as [number, number, number][];
|
||||
};
|
||||
|
||||
const getRiverPoints = (riverCells, riverPoints) => {
|
||||
getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) {
|
||||
if (riverPoints) return riverPoints;
|
||||
|
||||
const {p} = pack.cells;
|
||||
return riverCells.map((cell, i) => {
|
||||
if (cell === -1) return getBorderPoint(riverCells[i - 1]);
|
||||
if (cell === -1) return this.getBorderPoint(riverCells[i - 1]);
|
||||
return p[cell];
|
||||
});
|
||||
};
|
||||
|
||||
const getBorderPoint = i => {
|
||||
getBorderPoint(i: number) {
|
||||
const [x, y] = pack.cells.p[i];
|
||||
const min = Math.min(y, graphHeight - y, x, graphWidth - x);
|
||||
if (min === y) return [x, 0];
|
||||
|
|
@ -382,27 +424,23 @@ window.Rivers = (function () {
|
|||
return [graphWidth, y];
|
||||
};
|
||||
|
||||
const FLUX_FACTOR = 500;
|
||||
const MAX_FLUX_WIDTH = 1;
|
||||
const LENGTH_FACTOR = 200;
|
||||
const LENGTH_STEP_WIDTH = 1 / LENGTH_FACTOR;
|
||||
const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR);
|
||||
|
||||
const getOffset = ({flux, pointIndex, widthFactor, startingWidth}) => {
|
||||
getOffset({flux, pointIndex, widthFactor, startingWidth}: {flux: number, pointIndex: number, widthFactor: number, startingWidth: number}) {
|
||||
if (pointIndex === 0) return startingWidth;
|
||||
|
||||
const fluxWidth = Math.min(flux ** 0.7 / FLUX_FACTOR, MAX_FLUX_WIDTH);
|
||||
const lengthWidth = pointIndex * LENGTH_STEP_WIDTH + (LENGTH_PROGRESSION[pointIndex] || LENGTH_PROGRESSION.at(-1));
|
||||
const fluxWidth = Math.min(flux ** 0.7 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH);
|
||||
const lengthWidth = pointIndex * this.LENGTH_STEP_WIDTH + (this.LENGTH_PROGRESSION[pointIndex] || this.LENGTH_PROGRESSION.at(-1) as number);
|
||||
return widthFactor * (lengthWidth + fluxWidth) + startingWidth;
|
||||
};
|
||||
|
||||
const getSourceWidth = flux => rn(Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH), 2);
|
||||
getSourceWidth(flux: number) {
|
||||
return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2);
|
||||
}
|
||||
|
||||
// build polygon from a list of points and calculated offset (width)
|
||||
const getRiverPath = (points, widthFactor, startingWidth) => {
|
||||
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
|
||||
const riverPointsLeft = [];
|
||||
const riverPointsRight = [];
|
||||
getRiverPath(points: [number, number, number][], widthFactor: number, startingWidth: number) {
|
||||
this.lineGen.curve(curveCatmullRom.alpha(0.1));
|
||||
const riverPointsLeft: [number, number][] = [];
|
||||
const riverPointsRight: [number, number][] = [];
|
||||
let flux = 0;
|
||||
|
||||
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
|
||||
|
|
@ -411,7 +449,7 @@ window.Rivers = (function () {
|
|||
const [x2, y2] = points[pointIndex + 1] || points[pointIndex];
|
||||
if (pointFlux > flux) flux = pointFlux;
|
||||
|
||||
const offset = getOffset({flux, pointIndex, widthFactor, startingWidth});
|
||||
const offset = this.getOffset({flux, pointIndex, widthFactor, startingWidth});
|
||||
const angle = Math.atan2(y0 - y2, x0 - x2);
|
||||
const sinOffset = Math.sin(angle) * offset;
|
||||
const cosOffset = Math.cos(angle) * offset;
|
||||
|
|
@ -420,63 +458,52 @@ window.Rivers = (function () {
|
|||
riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]);
|
||||
}
|
||||
|
||||
const right = lineGen(riverPointsRight.reverse());
|
||||
let left = lineGen(riverPointsLeft);
|
||||
const right = this.lineGen(riverPointsRight.reverse());
|
||||
let left = this.lineGen(riverPointsLeft) || "";
|
||||
left = left.substring(left.indexOf("C"));
|
||||
|
||||
return round(right + left, 1);
|
||||
};
|
||||
|
||||
const specify = function () {
|
||||
specify() {
|
||||
const rivers = pack.rivers;
|
||||
if (!rivers.length) return;
|
||||
|
||||
for (const river of rivers) {
|
||||
river.basin = getBasin(river.i);
|
||||
river.name = getName(river.mouth);
|
||||
river.type = getType(river);
|
||||
river.basin = this.getBasin(river.i);
|
||||
river.name = this.getName(river.mouth);
|
||||
river.type = this.getType(river);
|
||||
}
|
||||
};
|
||||
|
||||
const getName = function (cell) {
|
||||
getName(cell: number) {
|
||||
return Names.getCulture(pack.cells.culture[cell]);
|
||||
};
|
||||
|
||||
// weighted arrays of river type names
|
||||
const riverTypes = {
|
||||
main: {
|
||||
big: {River: 1},
|
||||
small: {Creek: 9, River: 3, Brook: 3, Stream: 1}
|
||||
},
|
||||
fork: {
|
||||
big: {Fork: 1},
|
||||
small: {Branch: 1}
|
||||
}
|
||||
};
|
||||
|
||||
let smallLength = null;
|
||||
const getType = function ({i, length, parent}) {
|
||||
if (smallLength === null) {
|
||||
getType({i, length, parent}: River) {
|
||||
if (this.smallLength === null) {
|
||||
const threshold = Math.ceil(pack.rivers.length * 0.15);
|
||||
smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold];
|
||||
this.smallLength = pack.rivers.map(r => r.length || 0).sort((a: number, b: number) => a - b)[threshold];
|
||||
}
|
||||
|
||||
const isSmall = length < smallLength;
|
||||
const isSmall: boolean = length < (this.smallLength as number);
|
||||
const isFork = each(3)(i) && parent && parent !== i;
|
||||
return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]);
|
||||
return rw(this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]);
|
||||
};
|
||||
|
||||
const getApproximateLength = points => {
|
||||
getApproximateLength(points: [number, number, number][]) {
|
||||
const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0);
|
||||
return rn(length, 2);
|
||||
};
|
||||
|
||||
// Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m,
|
||||
// Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m
|
||||
const getWidth = offset => rn((offset / 1.5) ** 1.8, 2); // mouth width in km
|
||||
getWidth(offset: number) {
|
||||
return rn((offset / 1.5) ** 1.8, 2); // mouth width in km
|
||||
};
|
||||
|
||||
// remove river and all its tributaries
|
||||
const remove = function (id) {
|
||||
remove(id: number) {
|
||||
const cells = pack.cells;
|
||||
const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i);
|
||||
riversToRemove.forEach(r => rivers.select("#river" + r).remove());
|
||||
|
|
@ -489,32 +516,15 @@ window.Rivers = (function () {
|
|||
pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i));
|
||||
};
|
||||
|
||||
const getBasin = function (r) {
|
||||
getBasin(r: number): number {
|
||||
const parent = pack.rivers.find(river => river.i === r)?.parent;
|
||||
if (!parent || r === parent) return r;
|
||||
return getBasin(parent);
|
||||
return this.getBasin(parent);
|
||||
};
|
||||
|
||||
const getNextId = function (rivers) {
|
||||
getNextId(rivers: {i: number}[]) {
|
||||
return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generate,
|
||||
alterHeights,
|
||||
resolveDepressions,
|
||||
addMeandering,
|
||||
getRiverPath,
|
||||
specify,
|
||||
getName,
|
||||
getType,
|
||||
getBasin,
|
||||
getWidth,
|
||||
getOffset,
|
||||
getSourceWidth,
|
||||
getApproximateLength,
|
||||
getRiverPoints,
|
||||
remove,
|
||||
getNextId
|
||||
};
|
||||
})();
|
||||
window.Rivers = new RiverModule()
|
||||
36
src/types/PackedGraph.ts
Normal file
36
src/types/PackedGraph.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { PackedGraphFeature } from "../modules/features";
|
||||
import type { River } from "../modules/river-generator";
|
||||
|
||||
|
||||
type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array;
|
||||
|
||||
export interface PackedGraph {
|
||||
cells: {
|
||||
i: number[]; // cell indices
|
||||
c: number[][]; // neighboring cells
|
||||
v: number[][]; // neighboring vertices
|
||||
p: [number, number][]; // cell polygon points
|
||||
b: boolean[]; // cell is on border
|
||||
h: TypedArray; // cell heights
|
||||
t: TypedArray; // cell terrain types
|
||||
r: Uint16Array; // river id passing through cell
|
||||
f: Uint16Array; // feature id occupying cell
|
||||
fl: TypedArray; // flux presence in cell
|
||||
conf: TypedArray; // cell water confidence
|
||||
haven: TypedArray; // cell is a haven
|
||||
g: number[]; // cell ground type
|
||||
culture: number[]; // cell culture id
|
||||
biome: TypedArray; // cell biome id
|
||||
harbor: TypedArray; // cell harbour presence
|
||||
};
|
||||
vertices: {
|
||||
i: number[]; // vertex indices
|
||||
c: [number, number, number][]; // neighboring cells
|
||||
v: number[][]; // neighboring vertices
|
||||
x: number[]; // x coordinates
|
||||
y: number[]; // y coordinates
|
||||
p: [number, number][]; // vertex points
|
||||
};
|
||||
rivers: River[];
|
||||
features: PackedGraphFeature[];
|
||||
}
|
||||
33
src/types/global.ts
Normal file
33
src/types/global.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Selection } from 'd3';
|
||||
import { PackedGraph } from "./PackedGraph";
|
||||
|
||||
declare global {
|
||||
var seed: string;
|
||||
var pack: PackedGraph;
|
||||
var grid: any;
|
||||
var graphHeight: number;
|
||||
var graphWidth: number;
|
||||
|
||||
var TIME: boolean;
|
||||
var WARN: boolean;
|
||||
var ERROR: boolean;
|
||||
|
||||
var heightmapTemplates: any;
|
||||
var Names: any;
|
||||
|
||||
var pointsInput: HTMLInputElement;
|
||||
var heightExponentInput: HTMLInputElement;
|
||||
|
||||
var rivers: Selection<SVGElement, unknown, null, undefined>;
|
||||
var oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
|
||||
var biomesData: {
|
||||
i: number[];
|
||||
name: string[];
|
||||
color: string[];
|
||||
biomesMatrix: Uint8Array[];
|
||||
habitability: number[];
|
||||
iconsDensity: number[];
|
||||
icons: string[][];
|
||||
cost: number[];
|
||||
};
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { last } from "./arrayUtils";
|
|||
* @param secure - Secure clipping to avoid edge artifacts
|
||||
* @returns Clipped polygon points
|
||||
*/
|
||||
export const clipPoly = (points: [number, number][], graphWidth: number, graphHeight: number, secure: number = 0) => {
|
||||
export const clipPoly = (points: [number, number][], graphWidth?: number, graphHeight?: number, secure: number = 0) => {
|
||||
if (points.length < 2) return points;
|
||||
if (points.some(point => point === undefined)) {
|
||||
window.ERROR && console.error("Undefined point in clipPoly", points);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue