diff --git a/public/main.js b/public/main.js index fefadca1..4e0d7f28 100644 --- a/public/main.js +++ b/public/main.js @@ -459,7 +459,7 @@ function handleZoom(isScaleChanged, isPositionChanged) { fitScaleBar(scaleBar, svgWidth, svgHeight); } - if (layerIsOn("toggleRelief")) renderReliefIcons(); + if (layerIsOn("toggleRelief")) rerenderReliefIcons(); // zoom image converter overlay if (customization === 1) { diff --git a/public/modules/ui/biomes-editor.js b/public/modules/ui/biomes-editor.js index 125aa0da..d301c142 100644 --- a/public/modules/ui/biomes-editor.js +++ b/public/modules/ui/biomes-editor.js @@ -323,7 +323,8 @@ function editBiomes() { } function regenerateIcons() { - drawReliefIcons(); + pack.relief = []; + drawRelief(); if (!layerIsOn("toggleRelief")) toggleRelief(); } diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 68829e22..40618472 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -192,7 +192,7 @@ function drawLayers() { if (layerIsOn("toggleCoordinates")) drawCoordinates(); if (layerIsOn("toggleCompass")) compass.style("display", "block"); if (layerIsOn("toggleRivers")) drawRivers(); - if (layerIsOn("toggleRelief")) drawReliefIcons(); + if (layerIsOn("toggleRelief")) drawRelief(); if (layerIsOn("toggleReligions")) drawReligions(); if (layerIsOn("toggleCultures")) drawCultures(); if (layerIsOn("toggleStates")) drawStates(); @@ -699,22 +699,12 @@ function toggleCompass(event) { function toggleRelief(event) { if (!layerIsOn("toggleRelief")) { turnButtonOn("toggleRelief"); - if (!terrain.selectAll("*").size()) { - drawReliefIcons(); - } else if ( - terrain.selectAll("use").size() && - !terrain.select("#terrainCanvasImage").size() && - !terrain.select("#terrainGlFo").size() - ) { - // Legacy SVG use elements present but no canvas/GL render yet – migrate now - if (typeof migrateReliefFromSvg === "function") migrateReliefFromSvg(); - } - $("#terrain").fadeIn(); + drawRelief(); if (event && isCtrlClick(event)) editStyle("terrain"); } else { if (event && isCtrlClick(event)) return editStyle("terrain"); - $("#terrain").fadeOut(); turnButtonOff("toggleRelief"); + undrawRelief(); } } diff --git a/public/modules/ui/relief-editor.js b/public/modules/ui/relief-editor.js index ed8aa455..32bcd85d 100644 --- a/public/modules/ui/relief-editor.js +++ b/public/modules/ui/relief-editor.js @@ -2,11 +2,12 @@ function editReliefIcon() { if (customization) return; closeDialogs(".stable"); + + // Switch from WebGL to editable SVG elements + undrawRelief(); + drawRelief("svg"); + if (!layerIsOn("toggleRelief")) toggleRelief(); - - // Switch from canvas image to editable SVG elements - if (typeof enterReliefSvgEditMode === "function") enterReliefSvgEditMode(); - terrain.selectAll("use").call(d3.drag().on("drag", dragReliefIcon)).classed("draggable", true); // When called from the Tools button there is no d3 click event; fall back to the first . @@ -31,30 +32,42 @@ function editReliefIcon() { modules.editReliefIcon = true; // add listeners - document.getElementById("reliefIndividual").addEventListener("click", enterIndividualMode); - document.getElementById("reliefBulkAdd").addEventListener("click", enterBulkAddMode); - document.getElementById("reliefBulkRemove").addEventListener("click", enterBulkRemoveMode); + byId("reliefIndividual").on("click", enterIndividualMode); + byId("reliefBulkAdd").on("click", enterBulkAddMode); + byId("reliefBulkRemove").on("click", enterBulkRemoveMode); - document.getElementById("reliefSize").addEventListener("input", changeIconSize); - document.getElementById("reliefSizeNumber").addEventListener("input", changeIconSize); - document.getElementById("reliefEditorSet").addEventListener("change", changeIconsSet); - reliefIconsDiv.querySelectorAll("svg").forEach(el => el.addEventListener("click", changeIcon)); + byId("reliefSize").on("input", changeIconSize); + byId("reliefSizeNumber").on("input", changeIconSize); + byId("reliefEditorSet").on("change", changeIconsSet); + reliefIconsDiv.querySelectorAll("svg").forEach(el => el.on("click", changeIcon)); - document.getElementById("reliefEditStyle").addEventListener("click", () => editStyle("terrain")); - document.getElementById("reliefCopy").addEventListener("click", copyIcon); - document.getElementById("reliefMoveFront").addEventListener("click", () => elSelected.raise()); - document.getElementById("reliefMoveBack").addEventListener("click", () => elSelected.lower()); - document.getElementById("reliefRemove").addEventListener("click", removeIcon); + byId("reliefEditStyle").on("click", () => editStyle("terrain")); + byId("reliefCopy").on("click", copyIcon); + byId("reliefMoveFront").on("click", () => elSelected.raise()); + byId("reliefMoveBack").on("click", () => elSelected.lower()); + byId("reliefRemove").on("click", removeIcon); function dragReliefIcon() { const dx = +this.getAttribute("x") - d3.event.x; const dy = +this.getAttribute("y") - d3.event.y; + let newX; + let newY; + d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("x", dx + x); - this.setAttribute("y", dy + y); + newX = dx + d3.event.x; + newY = dy + d3.event.y; + this.setAttribute("x", newX); + this.setAttribute("y", newY); + }); + + d3.event.on("end", function () { + const id = this.dataset.id; + const icon = pack.relief.find(icon => icon.i === +id); + if (icon) { + icon.x = newX; + icon.y = newY; + } }); } @@ -295,7 +308,23 @@ function editReliefIcon() { removeCircle(); unselect(); clearMainTip(); - // Read back edits and switch terrain to canvas rendering - if (typeof exitReliefSvgEditMode === "function") exitReliefSvgEditMode(); + + // Sync pack.relief from the current SVG DOM (captures all edits) + pack.relief = []; + terrain.selectAll("use").each(function () { + const href = this.getAttribute("href") || this.getAttribute("xlink:href") || ""; + if (!href) return; + pack.relief.push({ + i: pack.relief.length, + href, + x: +this.getAttribute("x"), + y: +this.getAttribute("y"), + s: +this.getAttribute("width") + }); + }); + + // Switch from SVG edit mode back to WebGL rendering + undrawRelief(); + drawRelief(); } } diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js index 16d94b5c..148e7595 100644 --- a/public/modules/ui/style.js +++ b/public/modules/ui/style.js @@ -729,19 +729,22 @@ styleHeightmapCurve.on("change", e => { styleReliefSet.on("change", e => { terrain.attr("set", e.target.value); - drawReliefIcons(); + pack.relief = []; + drawRelief(); if (!layerIsOn("toggleRelief")) toggleRelief(); }); styleReliefSize.on("change", e => { terrain.attr("size", e.target.value); - drawReliefIcons(); + pack.relief = []; + drawRelief(); if (!layerIsOn("toggleRelief")) toggleRelief(); }); styleReliefDensity.on("change", e => { terrain.attr("density", e.target.value); - drawReliefIcons(); + pack.relief = []; + drawRelief(); if (!layerIsOn("toggleRelief")) toggleRelief(); }); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index 34c1cd12..b6398c6e 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -79,7 +79,7 @@ function processFeatureRegeneration(event, button) { $("#labels").fadeIn(); drawStateLabels(); } else if (button === "regenerateReliefIcons") { - drawReliefIcons(); + generateReliefIcons(); if (!layerIsOn("toggleRelief")) toggleRelief(); } else if (button === "regenerateRoutes") { regenerateRoutes(); @@ -327,8 +327,8 @@ function recreateStates() { const type = nomadic ? "Nomadic" : pack.cultures[culture].type === "Nomadic" - ? "Generic" - : pack.cultures[culture].type; + ? "Generic" + : pack.cultures[culture].type; const expansionism = rn(Math.random() * byId("sizeVariety").value + 1, 1); const cultureType = pack.cultures[culture].type; @@ -898,8 +898,8 @@ function configMarkersGeneration() { + isExternal ? "" : "hidden" + } style="width:1.2em; height:1.2em; vertical-align: middle;"> ${isExternal ? "" : icon} diff --git a/src/modules/index.ts b/src/modules/index.ts index 787e3989..1d56db6f 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,20 +1,21 @@ -import "./voronoi"; -import "./heightmap-generator"; +import "./biomes"; +import "./burgs-generator"; +import "./cultures-generator"; +import "./emblem"; import "./features"; +import "./fonts"; +import "./heightmap-generator"; +import "./ice"; +import "./lakes"; +import "./markers-generator"; +import "./military-generator"; import "./names-generator"; import "./ocean-layers"; -import "./lakes"; +import "./provinces-generator"; +import "./relief-generator"; +import "./religions-generator"; import "./river-generator"; -import "./burgs-generator"; -import "./biomes"; -import "./cultures-generator"; import "./routes-generator"; import "./states-generator"; +import "./voronoi"; import "./zones-generator"; -import "./religions-generator"; -import "./provinces-generator"; -import "./emblem"; -import "./ice"; -import "./military-generator"; -import "./markers-generator"; -import "./fonts"; diff --git a/src/modules/relief-generator.ts b/src/modules/relief-generator.ts new file mode 100644 index 00000000..230bec32 --- /dev/null +++ b/src/modules/relief-generator.ts @@ -0,0 +1,257 @@ +import { extent, polygonContains } from "d3"; +import { + byId, + getPackPolygon, + minmax, + poissonDiscSampler, + rand, + rn, +} from "../utils"; + +export interface ReliefIcon { + i: number; + href: string; // e.g. "#relief-mount-1" + x: number; + y: number; + s: number; // size (width = height in map units) +} + +export function generateRelief(): ReliefIcon[] { + TIME && console.time("generateRelief"); + + const cells = pack.cells; + const terrain = byId("terrain"); + if (!terrain) throw new Error("Terrain element not found"); + + const set = terrain.getAttribute("set") || "simple"; + const density = Number(terrain.getAttribute("density")) || 0.4; + const size = 2 * (Number(terrain.getAttribute("size")) || 1); + const mod = 0.2 * size; + + const reliefIcons: ReliefIcon[] = []; + + for (const i of cells.i) { + const height = cells.h[i]; + if (height < 20 || cells.r[i]) continue; + const biome = cells.biome[i]; + if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; + + const polygon = getPackPolygon(i, pack); + const [minX, maxX] = extent(polygon, (p) => p[0]); + const [minY, maxY] = extent(polygon, (p) => p[1]); + if ( + minX === undefined || + minY === undefined || + maxX === undefined || + maxY === undefined + ) + continue; + + if (height < 50) { + const iconsDensity = biomesData.iconsDensity[biome] / 100; + const radius = 2 / iconsDensity / density; + if (Math.random() > iconsDensity * 10) continue; // skip very low density icons + + for (const [cx, cy] of poissonDiscSampler( + minX, + minY, + maxX, + maxY, + radius, + )) { + if (!polygonContains(polygon, [cx, cy])) continue; + let h = (4 + Math.random()) * size; + const icon = getBiomeIcon(i, biome); + if (icon === "#relief-grass-1") h *= 1.2; + reliefIcons.push({ + i: reliefIcons.length, + href: icon, + x: rn(cx - h, 2), + y: rn(cy - h, 2), + s: rn(h * 2, 2), + }); + } + } else { + const radius = 2 / density; + const [icon, h] = getReliefIconForCell(i, height); + for (const [cx, cy] of poissonDiscSampler( + minX, + minY, + maxX, + maxY, + radius, + )) { + if (!polygonContains(polygon, [cx, cy])) continue; + reliefIcons.push({ + i: reliefIcons.length, + href: icon, + x: rn(cx - h, 2), + y: rn(cy - h, 2), + s: rn(h * 2, 2), + }); + } + } + } + + reliefIcons.sort((a, b) => a.y + a.s - (b.y + b.s)); + pack.relief = reliefIcons; + + TIME && console.timeEnd("generateRelief"); + return reliefIcons; + + function getReliefIconForCell( + cellIndex: number, + h: number, + ): [string, number] { + const temp = grid.cells.temp[pack.cells.g[cellIndex]]; + const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill"; + const iconSize = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6); + return [getHref(type, set), iconSize]; + } + + function getBiomeIcon(cellIndex: number, biome: number): string { + const b = biomesData.icons[biome]; + let type = b[Math.floor(Math.random() * b.length)]; + const temp = grid.cells.temp[pack.cells.g[cellIndex]]; + if (type === "conifer" && temp < 0) type = "coniferSnow"; + return getHref(type, set); + } +} + +// ── Utilities ───────────────────────────────────────────────────────── +export const RELIEF_SYMBOLS: Record = { + simple: [ + "relief-mount-1", + "relief-hill-1", + "relief-conifer-1", + "relief-deciduous-1", + "relief-acacia-1", + "relief-palm-1", + "relief-grass-1", + "relief-swamp-1", + "relief-dune-1", + ], + gray: [ + "relief-mount-2-bw", + "relief-mount-3-bw", + "relief-mount-4-bw", + "relief-mount-5-bw", + "relief-mount-6-bw", + "relief-mount-7-bw", + "relief-mountSnow-1-bw", + "relief-mountSnow-2-bw", + "relief-mountSnow-3-bw", + "relief-mountSnow-4-bw", + "relief-mountSnow-5-bw", + "relief-mountSnow-6-bw", + "relief-hill-2-bw", + "relief-hill-3-bw", + "relief-hill-4-bw", + "relief-hill-5-bw", + "relief-conifer-2-bw", + "relief-coniferSnow-1-bw", + "relief-swamp-2-bw", + "relief-swamp-3-bw", + "relief-cactus-1-bw", + "relief-cactus-2-bw", + "relief-cactus-3-bw", + "relief-deadTree-1-bw", + "relief-deadTree-2-bw", + "relief-vulcan-1-bw", + "relief-vulcan-2-bw", + "relief-vulcan-3-bw", + "relief-dune-2-bw", + "relief-grass-2-bw", + "relief-acacia-2-bw", + "relief-palm-2-bw", + "relief-deciduous-2-bw", + "relief-deciduous-3-bw", + ], + colored: [ + "relief-mount-2", + "relief-mount-3", + "relief-mount-4", + "relief-mount-5", + "relief-mount-6", + "relief-mount-7", + "relief-mountSnow-1", + "relief-mountSnow-2", + "relief-mountSnow-3", + "relief-mountSnow-4", + "relief-mountSnow-5", + "relief-mountSnow-6", + "relief-hill-2", + "relief-hill-3", + "relief-hill-4", + "relief-hill-5", + "relief-conifer-2", + "relief-coniferSnow-1", + "relief-swamp-2", + "relief-swamp-3", + "relief-cactus-1", + "relief-cactus-2", + "relief-cactus-3", + "relief-deadTree-1", + "relief-deadTree-2", + "relief-vulcan-1", + "relief-vulcan-2", + "relief-vulcan-3", + "relief-dune-2", + "relief-grass-2", + "relief-acacia-2", + "relief-palm-2", + "relief-deciduous-2", + "relief-deciduous-3", + ], +}; + +// map a symbol href to its atlas set and tile index +export function resolveSprite(symbolHref: string): { + set: string; + tileIndex: number; +} { + const id = symbolHref.startsWith("#") ? symbolHref.slice(1) : symbolHref; + for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) { + const idx = ids.indexOf(id); + if (idx !== -1) return { set, tileIndex: idx }; + } + throw new Error(`Relief: unknown symbol href "${symbolHref}"`); +} + +const VARIANT_RANGES: Record = { + mount: [2, 7], + mountSnow: [1, 6], + hill: [2, 5], + conifer: [2, 2], + coniferSnow: [1, 1], + swamp: [2, 3], + cactus: [1, 3], + deadTree: [1, 2], + vulcan: [1, 3], + deciduous: [2, 3], +}; + +const COLORED_TO_SIMPLE_MAP: Record = { + mountSnow: "mount", + vulcan: "mount", + coniferSnow: "conifer", + cactus: "dune", + deadTree: "dune", +}; + +function getVariant(type: string): number { + const range = VARIANT_RANGES[type]; + return range ? rand(...range) : 2; +} + +function getHref(type: string, set: string): string { + if (set === "colored") return `#relief-${type}-${getVariant(type)}`; + if (set === "gray") return `#relief-${type}-${getVariant(type)}-bw`; + return `#relief-${COLORED_TO_SIMPLE_MAP[type] ?? type}-1`; +} + +window.generateReliefIcons = generateRelief; + +declare global { + var generateReliefIcons: () => ReliefIcon[]; +} diff --git a/src/renderers/draw-relief-icons.ts b/src/renderers/draw-relief-icons.ts index c37be3ec..237c38a2 100644 --- a/src/renderers/draw-relief-icons.ts +++ b/src/renderers/draw-relief-icons.ts @@ -1,33 +1,13 @@ -import {extent, polygonContains} from "d3"; import * as THREE from "three"; -import {minmax, rand, rn} from "../utils"; +import type { ReliefIcon } from "../modules/relief-generator"; +import { + generateRelief, + RELIEF_SYMBOLS, + resolveSprite, +} from "../modules/relief-generator"; +import { byId } from "../utils"; -interface ReliefIcon { - i: string; // e.g. "#relief-mount-1" - x: number; - y: number; - s: number; // size (width = height in map units) -} - -declare global { - var drawReliefIcons: () => void; - var terrain: import("d3").Selection; - var getPackPolygon: (i: number) => [number, number][]; - var graphWidth: number; - var graphHeight: number; - var poissonDiscSampler: (x0: number, y0: number, x1: number, y1: number, r: number) => Iterable<[number, number]>; - - var renderReliefIcons: () => void; - var enterReliefSvgEditMode: () => void; - var exitReliefSvgEditMode: () => void; - var prepareReliefForSave: () => void; - var restoreReliefAfterSave: () => void; - var migrateReliefFromSvg: () => void; -} - -// Module state -let reliefIconData: ReliefIcon[] = []; -let svgEditMode = false; +// ── Module state ─────────────────────────────────────────────────────────────── let fo: SVGForeignObjectElement | null = null; let renderer: any = null; // THREE.WebGLRenderer let camera: any = null; // THREE.OrthographicCamera @@ -35,109 +15,15 @@ let scene: any = null; // THREE.Scene const textureCache = new Map(); // set name → THREE.Texture -const RELIEF_SYMBOLS: Record = { - simple: [ - "relief-mount-1", - "relief-hill-1", - "relief-conifer-1", - "relief-deciduous-1", - "relief-acacia-1", - "relief-palm-1", - "relief-grass-1", - "relief-swamp-1", - "relief-dune-1" - ], - gray: [ - "relief-mount-2-bw", - "relief-mount-3-bw", - "relief-mount-4-bw", - "relief-mount-5-bw", - "relief-mount-6-bw", - "relief-mount-7-bw", - "relief-mountSnow-1-bw", - "relief-mountSnow-2-bw", - "relief-mountSnow-3-bw", - "relief-mountSnow-4-bw", - "relief-mountSnow-5-bw", - "relief-mountSnow-6-bw", - "relief-hill-2-bw", - "relief-hill-3-bw", - "relief-hill-4-bw", - "relief-hill-5-bw", - "relief-conifer-2-bw", - "relief-coniferSnow-1-bw", - "relief-swamp-2-bw", - "relief-swamp-3-bw", - "relief-cactus-1-bw", - "relief-cactus-2-bw", - "relief-cactus-3-bw", - "relief-deadTree-1-bw", - "relief-deadTree-2-bw", - "relief-vulcan-1-bw", - "relief-vulcan-2-bw", - "relief-vulcan-3-bw", - "relief-dune-2-bw", - "relief-grass-2-bw", - "relief-acacia-2-bw", - "relief-palm-2-bw", - "relief-deciduous-2-bw", - "relief-deciduous-3-bw" - ], - colored: [ - "relief-mount-2", - "relief-mount-3", - "relief-mount-4", - "relief-mount-5", - "relief-mount-6", - "relief-mount-7", - "relief-mountSnow-1", - "relief-mountSnow-2", - "relief-mountSnow-3", - "relief-mountSnow-4", - "relief-mountSnow-5", - "relief-mountSnow-6", - "relief-hill-2", - "relief-hill-3", - "relief-hill-4", - "relief-hill-5", - "relief-conifer-2", - "relief-coniferSnow-1", - "relief-swamp-2", - "relief-swamp-3", - "relief-cactus-1", - "relief-cactus-2", - "relief-cactus-3", - "relief-deadTree-1", - "relief-deadTree-2", - "relief-vulcan-1", - "relief-vulcan-2", - "relief-vulcan-3", - "relief-dune-2", - "relief-grass-2", - "relief-acacia-2", - "relief-palm-2", - "relief-deciduous-2", - "relief-deciduous-3" - ] -}; - -function resolveSprite(symbolHref: string) { - const id = symbolHref.startsWith("#") ? symbolHref.slice(1) : symbolHref; - for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) { - const idx = ids.indexOf(id); - if (idx !== -1) return {set, tileIndex: idx}; - } - - throw new Error(`Relief: unknown symbol href "${symbolHref}"`); -} +// ── Texture ──────────────────────────────────────────────────────────────────── function loadTexture(set: string): Promise { if (textureCache.has(set)) return Promise.resolve(textureCache.get(set)); - return new Promise(resolve => { + return new Promise((resolve) => { const loader = new THREE.TextureLoader(); loader.load( `images/relief/${set}.png`, - texture => { + (texture) => { texture.flipY = false; texture.colorSpace = THREE.SRGBColorSpace; texture.needsUpdate = true; @@ -150,17 +36,22 @@ function loadTexture(set: string): Promise { }, undefined, () => { - console.warn(`Relief: atlas not found for "${set}". Run: npm run generate-atlases`); + ERROR && console.error(`Relief: failed to load atlas for "${set}"`); resolve(null); - } + }, ); }); } +// ── WebGL bootstrap ──────────────────────────────────────────────────────────── + function ensureRenderer(): boolean { + const terrainEl = byId("terrain"); + if (!terrainEl) return false; + if (renderer) { - // Recover from WebGL context loss (can happen when canvas is detached from DOM) if (renderer.getContext().isContextLost()) { + // Recover from WebGL context loss renderer.forceContextRestore(); renderer.dispose(); renderer = null; @@ -169,14 +60,16 @@ function ensureRenderer(): boolean { disposeTextureCache(); // fall through to recreate } else { - if (fo && !fo.isConnected) terrain.node()!.appendChild(fo); + if (fo && !fo.isConnected) terrainEl.appendChild(fo); return true; } } // foreignObject hosts the WebGL canvas inside the SVG. - // Dimensions are set here so the browser can start compositing before renderFrame runs. - fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject") as unknown as SVGForeignObjectElement; + fo = document.createElementNS( + "http://www.w3.org/2000/svg", + "foreignObject", + ) as unknown as SVGForeignObjectElement; fo.id = "terrainFo"; fo.setAttribute("x", "0"); fo.setAttribute("y", "0"); @@ -184,40 +77,41 @@ function ensureRenderer(): boolean { fo.setAttribute("height", String(graphHeight)); // IMPORTANT: use document.createElement, not createElementNS. - // createElementNS with XHTML namespace can produce an Element without getContext(). const canvas = document.createElement("canvas"); canvas.id = "terrainGlCanvas"; - (fo as unknown as Element).appendChild(canvas); - terrain.node()!.appendChild(fo); + fo.appendChild(canvas); + terrainEl.appendChild(fo); try { - renderer = new THREE.WebGLRenderer({canvas, alpha: true, antialias: false, preserveDrawingBuffer: true}); + renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: false, + preserveDrawingBuffer: true, + }); renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(window.devicePixelRatio || 1); - // setSize sets canvas.width/height (physical) and canvas.style.width/height (CSS px). - // We override the CSS size to 100% so the canvas always fills the foreignObject. renderer.setSize(graphWidth, graphHeight); - canvas.style.cssText = "display:block;pointer-events:none;position:absolute;top:0;left:0;width:100%;height:100%;"; + canvas.style.cssText = + "display:block;pointer-events:none;position:absolute;top:0;left:0;width:100%;height:100%;"; } catch (e) { console.error("Relief: WebGL init failed", e); return false; } - // Camera in SVG coordinate space: (left, right, top, bottom, near, far). - // top=0, bottom=H puts map y=0 at screen-top and map y=H at screen-bottom. + // Camera in SVG coordinate space: top=0, bottom=H puts map y=0 at screen-top. camera = new THREE.OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1); scene = new THREE.Scene(); return true; } -// ─── Scene / geometry ───────────────────────────────────────────────────────── +// ── Scene / geometry ─────────────────────────────────────────────────────────── /** - * Build a single BufferGeometry with all icon quads for one atlas set. - * Geometry order matches the painter's-order sort of reliefIconData, - * so depth is correct within the set without needing depth testing. + * Build a BufferGeometry with all icon quads for one atlas set. + * Geometry is painter's-order sorted so depth is correct without depth testing. * - * UV layout (texture.flipY = false means v=0 is top of image): + * UV layout (texture.flipY = false — v=0 is top of image): * u = col/cols … (col+1)/cols * v = row/rows … (row+1)/rows */ @@ -234,7 +128,7 @@ function buildSetMesh(icons: ReliefIcon[], set: string, texture: any): any { let vi = 0, ii = 0; for (const r of icons) { - const {tileIndex} = resolveSprite(r.i); + const { tileIndex } = resolveSprite(r.href); const col = tileIndex % cols; const row = Math.floor(tileIndex / cols); const u0 = col / cols, @@ -245,26 +139,19 @@ function buildSetMesh(icons: ReliefIcon[], set: string, texture: any): any { x1 = r.x + r.s; const y0 = r.y, y1 = r.y + r.s; - const base = vi; - // TL positions.set([x0, y0, 0], vi * 3); uvs.set([u0, v0], vi * 2); vi++; - // TR positions.set([x1, y0, 0], vi * 3); uvs.set([u1, v0], vi * 2); vi++; - // BL positions.set([x0, y1, 0], vi * 3); uvs.set([u0, v1], vi * 2); vi++; - // BR positions.set([x1, y1, 0], vi * 3); uvs.set([u1, v1], vi * 2); vi++; - - // Two CCW triangles (winding doesn't matter with DoubleSide, but consistent) indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii); ii += 6; } @@ -279,7 +166,7 @@ function buildSetMesh(icons: ReliefIcon[], set: string, texture: any): any { transparent: true, side: THREE.DoubleSide, depthTest: false, - depthWrite: false + depthWrite: false, }); return new THREE.Mesh(geo, mat); @@ -296,7 +183,6 @@ function disposeScene(): void { const mesh = scene.children[0]; scene.remove(mesh); mesh.geometry?.dispose(); - // dispose material but NOT the texture (shared in textureCache, explicitly cleared separately) if (mesh.material) { mesh.material.map = null; mesh.material.dispose(); @@ -304,14 +190,13 @@ function disposeScene(): void { } } -function buildScene(): void { +function buildScene(icons: ReliefIcon[]): void { if (!scene) return; disposeScene(); - // Group icons by set (normally all icons belong to the same set) const bySet = new Map(); - for (const r of reliefIconData) { - const {set} = resolveSprite(r.i); + for (const r of icons) { + const { set } = resolveSprite(r.href); let arr = bySet.get(set); if (!arr) { arr = []; @@ -320,10 +205,10 @@ function buildScene(): void { arr.push(r); } - for (const [set, icons] of bySet) { + for (const [set, setIcons] of bySet) { const texture = textureCache.get(set); if (!texture) continue; - scene.add(buildSetMesh(icons, set, texture)); + scene.add(buildSetMesh(setIcons, set, texture)); } } @@ -335,197 +220,145 @@ function renderFrame(): void { const w = graphWidth / scale; const h = graphHeight / scale; - // Position the foreignObject to cancel the D3 zoom transform applied to #viewbox. fo.setAttribute("x", String(x)); fo.setAttribute("y", String(y)); fo.setAttribute("width", String(w)); fo.setAttribute("height", String(h)); - // Camera frustum = the map region currently visible through the fo aperture camera.left = x; camera.right = x + w; camera.top = y; camera.bottom = y + h; camera.updateProjectionMatrix(); - renderer.render(scene, camera); } -function enterSvgEditMode(): void { - if (svgEditMode) return; - svgEditMode = true; - terrain - .node()! - .insertAdjacentHTML( - "afterbegin", - reliefIconData.map(r => ``).join("") - ); -} +// ── Private draw / clear ─────────────────────────────────────────────────────── -function exitSvgEditMode(): void { - if (!svgEditMode) return; - reliefIconData = []; - terrain.selectAll("use").each(function () { - const href = this.getAttribute("href") || (this as any).getAttribute("xlink:href") || ""; - reliefIconData.push({ - i: href, - x: +this.getAttribute("x")!, - y: +this.getAttribute("y")!, - s: +this.getAttribute("width")! +function drawWebGl(icons: ReliefIcon[]): void { + const terrainEl = byId("terrain"); + if (!terrainEl) return; + if (!icons.length) return; + + terrainEl.innerHTML = ""; + terrainEl.dataset.mode = "webGL"; + const set = terrainEl.getAttribute("set") || "simple"; + + if (ensureRenderer()) { + loadTexture(set).then(() => { + buildScene(icons); + renderFrame(); }); - }); - terrain.selectAll("use").remove(); - svgEditMode = false; - loadTexture(terrain.attr("set")).then(() => { - buildScene(); - renderFrame(); - }); + } else { + WARN && console.warn("Relief: WebGL renderer failed"); + } } -function prepareReliefForSave(): void { - if (svgEditMode) return; - terrain - .node()! - .insertAdjacentHTML( - "afterbegin", - reliefIconData.map(r => ``).join("") - ); +function drawSvg(icons: ReliefIcon[]): void { + const terrainEl = byId("terrain"); + if (!terrainEl) return; + terrainEl.innerHTML = ""; + + const html = icons.map( + (r) => + ``, + ); + terrainEl.innerHTML = html.join(""); + terrainEl.dataset.mode = "svg"; } -function restoreReliefAfterSave(): void { - if (!svgEditMode) terrain.selectAll("use").remove(); -} +window.drawRelief = (type: "svg" | "webGL" = "webGL") => { + const icons = pack.relief?.length ? pack.relief : generateRelief(); + if (type === "svg") drawSvg(icons); + else drawWebGl(icons); +}; -const reliefIconsRenderer = (): void => { - TIME && console.time("drawRelief"); +window.undrawRelief = () => { + const terrainEl = byId("terrain"); + const mode = terrainEl?.dataset.mode || "webGL"; + if (mode === "webGL") { + disposeScene(); + disposeTextureCache(); + if (renderer) { + renderer.dispose(); + renderer = null; + } + if (fo) { + if (fo.isConnected) fo.remove(); + fo = null; + } + camera = null; + scene = null; + } - terrain.selectAll("*").remove(); - disposeTextureCache(); - disposeScene(); - reliefIconData = []; - svgEditMode = false; + if (terrainEl) terrainEl.innerHTML = ""; +}; - const cells = pack.cells; - const density = Number(terrain.attr("density")) || 0.4; - const size = 2 * (Number(terrain.attr("size")) || 1); - const mod = 0.2 * size; +// re-render the current WebGL frame (called on pan/zoom) +window.rerenderReliefIcons = renderFrame; + +// Migrate legacy saves: read elements from the terrain SVG into pack.relief, remove them from the DOM, then render via WebGL. +window.migrateReliefFromSvg = () => { + const terrainEl = byId("terrain"); + if (!terrainEl) return; const relief: ReliefIcon[] = []; - for (const i of cells.i) { - const height = cells.h[i]; - if (height < 20 || cells.r[i]) continue; - const biome = cells.biome[i]; - if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; + terrainEl.querySelectorAll("use").forEach((u) => { + const href = u.getAttribute("href") || u.getAttribute("xlink:href") || ""; + if (!href) return; + relief.push({ + i: relief.length, + href, + x: +u.getAttribute("x")!, + y: +u.getAttribute("y")!, + s: +u.getAttribute("width")!, + }); + }); + terrainEl.innerHTML = ""; + pack.relief = relief; + drawWebGl(relief); +}; - const polygon = getPackPolygon(i); - const [minX, maxX] = extent(polygon, p => p[0]) as [number, number]; - const [minY, maxY] = extent(polygon, p => p[1]) as [number, number]; +let _reliefSvgInjectedForSave = false; - if (height < 50) placeBiomeIcons(); - else placeReliefIcons(); - - function placeBiomeIcons(): void { - const iconsDensity = biomesData.iconsDensity[biome] / 100; - const radius = 2 / iconsDensity / density; - if (Math.random() > iconsDensity * 10) return; - for (const [cx, cy] of window.poissonDiscSampler(minX, minY, maxX, maxY, radius)) { - if (!polygonContains(polygon, [cx, cy])) continue; - let h = (4 + Math.random()) * size; - const icon = getBiomeIcon(i, biomesData.icons[biome]); - if (icon === "#relief-grass-1") h *= 1.2; - relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)}); - } - } - - function placeReliefIcons(): void { - const radius = 2 / density; - const [icon, h] = getReliefIcon(i, height); - for (const [cx, cy] of window.poissonDiscSampler(minX, minY, maxX, maxY, radius)) { - if (!polygonContains(polygon, [cx, cy])) continue; - relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)}); - } - } - - function getReliefIcon(cellIndex: number, h: number): [string, number] { - const temp = grid.cells.temp[pack.cells.g[cellIndex]]; - const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill"; - const iconSize = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6); - return [getIcon(type), iconSize]; - } - } - - relief.sort((a, b) => a.y + a.s - (b.y + b.s)); - reliefIconData = relief; - - TIME && console.timeEnd("drawRelief"); - - if (reliefIconData.length) { - if (ensureRenderer()) { - loadTexture(terrain.attr("set")).then(() => { - buildScene(); - renderFrame(); - }); - } else { - WARN && console.warn("Relief: WebGL renderer failed"); - } - } - - function getBiomeIcon(cellIndex: number, b: string[]): string { - let type = b[Math.floor(Math.random() * b.length)]; - const temp = grid.cells.temp[pack.cells.g[cellIndex]]; - if (type === "conifer" && temp < 0) type = "coniferSnow"; - return getIcon(type); - } - - function getVariant(type: string): number { - switch (type) { - case "mount": - return rand(2, 7); - case "mountSnow": - return rand(1, 6); - case "hill": - return rand(2, 5); - case "conifer": - return 2; - case "coniferSnow": - return 1; - case "swamp": - return rand(2, 3); - case "cactus": - return rand(1, 3); - case "deadTree": - return rand(1, 2); - case "vulcan": - return rand(1, 3); - case "deciduous": - return rand(2, 3); - default: - return 2; - } - } - - function getOldIcon(type: string): string { - const map: Record = { - mountSnow: "mount", - vulcan: "mount", - coniferSnow: "conifer", - cactus: "dune", - deadTree: "dune" - }; - return map[type] ?? type; - } - - function getIcon(type: string): string { - const set = terrain.attr("set") || "simple"; - if (set === "colored") return `#relief-${type}-${getVariant(type)}`; - if (set === "gray") return `#relief-${type}-${getVariant(type)}-bw`; - return `#relief-${getOldIcon(type)}-1`; +/** + * Before SVG serialization: ensure elements are in the terrain group. + * In WebGL mode, temporarily injects them from pack.relief. + * In SVG edit mode, elements are already live in the DOM. + */ +window.prepareReliefForSave = () => { + const terrainEl = byId("terrain"); + if (!terrainEl) return; + if (terrainEl.querySelectorAll("use").length > 0) { + _reliefSvgInjectedForSave = false; + } else { + terrainEl.insertAdjacentHTML( + "afterbegin", + (pack.relief || []) + .map( + (r) => + ``, + ) + .join(""), + ); + _reliefSvgInjectedForSave = true; } }; -window.renderReliefIcons = renderFrame; -window.drawReliefIcons = reliefIconsRenderer; -window.enterReliefSvgEditMode = enterSvgEditMode; -window.exitReliefSvgEditMode = exitSvgEditMode; -window.prepareReliefForSave = prepareReliefForSave; -window.restoreReliefAfterSave = restoreReliefAfterSave; +/** Remove temporarily injected elements after serialization. */ +window.restoreReliefAfterSave = () => { + if (_reliefSvgInjectedForSave) { + for (const el of byId("terrain")?.querySelectorAll("use") ?? []) + el.remove(); + _reliefSvgInjectedForSave = false; + } +}; + +declare global { + var drawRelief: (type?: "svg" | "webGL") => void; + var undrawRelief: () => void; + var rerenderReliefIcons: () => void; + var migrateReliefFromSvg: () => void; + var prepareReliefForSave: () => void; + var restoreReliefAfterSave: () => void; +} diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index b8749f0a..0eb7fdeb 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { Province } from "../modules/provinces-generator"; +import type { ReliefIcon } from "../modules/relief-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; @@ -53,6 +54,7 @@ export interface PackedGraph { p: [number, number][]; // vertex points }; rivers: River[]; + relief: ReliefIcon[]; features: PackedGraphFeature[]; burgs: Burg[]; states: State[]; diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 9b241780..4f816e61 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -424,7 +424,10 @@ export const findAllCellsInRadius = ( * @param {number} i - The index of the packed cell * @returns {Array} - An array of polygon points for the specified cell */ -export const getPackPolygon = (cellIndex: number, packedGraph: any) => { +export const getPackPolygon = ( + cellIndex: number, + packedGraph: any, +): [number, number][] => { return packedGraph.cells.v[cellIndex].map( (v: number) => packedGraph.vertices.p[v], );