diff --git a/package-lock.json b/package-lock.json index 3396b8f5..4466445e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.113.5", "license": "MIT", "dependencies": { + "@types/three": "^0.183.1", "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", - "polylabel": "^2.0.1" + "polylabel": "^2.0.1", + "three": "^0.183.2" }, "devDependencies": { "@biomejs/biome": "2.3.13", @@ -195,6 +197,12 @@ "node": ">=14.21.3" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1024,6 +1032,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1365,6 +1379,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@vitest/browser": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", @@ -1524,6 +1565,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/alea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz", @@ -2057,6 +2104,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2103,6 +2156,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -2388,6 +2447,12 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 11d2cfc7..820cafe6 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,12 @@ "vitest": "^4.0.18" }, "dependencies": { + "@types/three": "^0.183.1", "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", - "polylabel": "^2.0.1" + "polylabel": "^2.0.1", + "three": "^0.183.2" }, "engines": { "node": ">=24.0.0" diff --git a/public/images/relief/colored.png b/public/images/relief/colored.png new file mode 100644 index 00000000..9a0fc9aa Binary files /dev/null and b/public/images/relief/colored.png differ diff --git a/public/images/relief/gray.png b/public/images/relief/gray.png new file mode 100644 index 00000000..fd94c119 Binary files /dev/null and b/public/images/relief/gray.png differ diff --git a/public/images/relief/simple.png b/public/images/relief/simple.png new file mode 100644 index 00000000..bc7403cc Binary files /dev/null and b/public/images/relief/simple.png differ diff --git a/public/index.css b/public/index.css index c46fded9..607fe84a 100644 --- a/public/index.css +++ b/public/index.css @@ -231,7 +231,6 @@ t, pointer-events: none; } -#terrain, #burgIcons { cursor: pointer; } diff --git a/public/main.js b/public/main.js index c0ac9d11..fefadca1 100644 --- a/public/main.js +++ b/public/main.js @@ -459,6 +459,8 @@ function handleZoom(isScaleChanged, isPositionChanged) { fitScaleBar(scaleBar, svgWidth, svgHeight); } + if (layerIsOn("toggleRelief")) renderReliefIcons(); + // zoom image converter overlay if (customization === 1) { const canvas = byId("canvas"); @@ -1234,7 +1236,7 @@ function showStatistics() { INFO && console.info(stats); // Dispatch event for test automation and external integrations - window.dispatchEvent(new CustomEvent('map:generated', { detail: { seed, mapId } })); + window.dispatchEvent(new CustomEvent("map:generated", {detail: {seed, mapId}})); } const regenerateMap = debounce(async function (options) { diff --git a/public/modules/io/export.js b/public/modules/io/export.js index e2ab8263..482cc2ad 100644 --- a/public/modules/io/export.js +++ b/public/modules/io/export.js @@ -180,7 +180,10 @@ async function getMapURL( fullMap = false } = {} ) { + // Temporarily inject elements so the clone includes relief icon data + if (typeof prepareReliefForSave === "function") prepareReliefForSave(); const cloneEl = byId("map").cloneNode(true); // clone svg + if (typeof restoreReliefAfterSave === "function") restoreReliefAfterSave(); cloneEl.id = "fantasyMap"; document.body.appendChild(cloneEl); const clone = d3.select(cloneEl); @@ -286,13 +289,13 @@ async function getMapURL( } } - // add relief icons + // add relief icons (from elements – canvas is excluded) if (cloneEl.getElementById("terrain")) { const uniqueElements = new Set(); - const terrainNodes = cloneEl.getElementById("terrain").childNodes; - for (let i = 0; i < terrainNodes.length; i++) { - const href = terrainNodes[i].getAttribute("href") || terrainNodes[i].getAttribute("xlink:href"); - uniqueElements.add(href); + const terrainUses = cloneEl.getElementById("terrain").querySelectorAll("use"); + for (let i = 0; i < terrainUses.length; i++) { + const href = terrainUses[i].getAttribute("href") || terrainUses[i].getAttribute("xlink:href"); + if (href && href.startsWith("#")) uniqueElements.add(href); } const defsRelief = svgDefs.getElementById("defs-relief"); @@ -424,7 +427,8 @@ async function getMapURL( // remove hidden g elements and g elements without children to make downloaded svg smaller in size function removeUnusedElements(clone) { - if (!terrain.selectAll("use").size()) clone.select("#defs-relief")?.remove(); + // Check the clone (not the live terrain) so canvas-mode maps export correctly + if (!clone.select("#terrain use").size()) clone.select("#defs-relief")?.remove(); for (let empty = 1; empty; ) { empty = 0; @@ -583,31 +587,31 @@ function saveGeoJsonZones() { // Handles multiple disconnected components and holes properly function getZonePolygonCoordinates(zoneCells) { const cellsInZone = new Set(zoneCells); - const ofSameType = (cellId) => cellsInZone.has(cellId); - const ofDifferentType = (cellId) => !cellsInZone.has(cellId); - + const ofSameType = cellId => cellsInZone.has(cellId); + const ofDifferentType = cellId => !cellsInZone.has(cellId); + const checkedCells = new Set(); const rings = []; // Array of LinearRings (each ring is an array of coordinates) - + // Find all boundary components by tracing each connected region for (const cellId of zoneCells) { if (checkedCells.has(cellId)) continue; - + // Check if this cell is on the boundary (has a neighbor outside the zone) const neighbors = cells.c[cellId]; const onBorder = neighbors.some(ofDifferentType); if (!onBorder) continue; - + // Check if this is an inner lake (hole) - skip if so const feature = pack.features[cells.f[cellId]]; if (feature.type === "lake" && feature.shoreline) { if (feature.shoreline.every(ofSameType)) continue; } - + // Find a starting vertex that's on the boundary const cellVertices = cells.v[cellId]; let startingVertex = null; - + for (const vertexId of cellVertices) { const vertexCells = vertices.c[vertexId]; if (vertexCells.some(ofDifferentType)) { @@ -615,38 +619,38 @@ function saveGeoJsonZones() { break; } } - + if (startingVertex === null) continue; - + // Use connectVertices to trace the boundary (reusing existing logic) const vertexChain = connectVertices({ vertices, startingVertex, ofSameType, - addToChecked: (cellId) => checkedCells.add(cellId), - closeRing: false, // We'll close it manually after converting to coordinates + addToChecked: cellId => checkedCells.add(cellId), + closeRing: false // We'll close it manually after converting to coordinates }); - + if (vertexChain.length < 3) continue; - + // Convert vertex chain to coordinates const coordinates = []; for (const vertexId of vertexChain) { const [x, y] = vertices.p[vertexId]; coordinates.push(getCoordinates(x, y, 4)); } - + // Close the ring (first coordinate = last coordinate) if (coordinates.length > 0) { coordinates.push(coordinates[0]); } - + // Only add ring if it has at least 4 positions (minimum for valid LinearRing) if (coordinates.length >= 4) { rings.push(coordinates); } } - + return rings; } @@ -656,10 +660,10 @@ function saveGeoJsonZones() { if (zone.hidden || !zone.cells || zone.cells.length === 0) return; const rings = getZonePolygonCoordinates(zone.cells); - + // Skip if no valid rings were generated if (rings.length === 0) return; - + const properties = { id: zone.i, name: zone.name, @@ -667,7 +671,7 @@ function saveGeoJsonZones() { color: zone.color, cells: zone.cells }; - + // If there's only one ring, use Polygon geometry if (rings.length === 1) { const feature = { diff --git a/public/modules/io/load.js b/public/modules/io/load.js index f1ee17e4..eb334f10 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -440,7 +440,12 @@ async function parseLoadedData(data, mapVersion) { if (hasChildren(coordinates)) turnOn("toggleCoordinates"); if (isVisible(compass) && hasChild(compass, "use")) turnOn("toggleCompass"); if (hasChildren(rivers)) turnOn("toggleRivers"); - if (isVisible(terrain) && hasChildren(terrain)) turnOn("toggleRelief"); + if (isVisible(terrain) && hasChildren(terrain)) { + turnOn("toggleRelief"); + } + // Migrate any legacy SVG elements to canvas rendering + // (runs regardless of visibility to handle maps loaded with relief layer off) + if (typeof migrateReliefFromSvg === "function") migrateReliefFromSvg(); if (hasChildren(relig)) turnOn("toggleReligions"); if (hasChildren(cults)) turnOn("toggleCultures"); if (hasChildren(statesBody)) turnOn("toggleStates"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 25cd7493..488b0925 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,13 +32,12 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: { my: "center", at: "center", of: "svg" } + position: {my: "center", at: "center", of: "svg"} }); } } function prepareMapData() { - const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -79,7 +78,10 @@ function prepareMapData() { const fonts = JSON.stringify(getUsedFonts(svg.node())); // save svg + // Temporarily inject elements so the SVG snapshot includes relief icon data + if (typeof prepareReliefForSave === "function") prepareReliefForSave(); const cloneEl = document.getElementById("map").cloneNode(true); + if (typeof restoreReliefAfterSave === "function") restoreReliefAfterSave(); // reset transform values to default cloneEl.setAttribute("width", graphWidth); @@ -90,8 +92,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; - const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); + const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; + const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -165,14 +167,14 @@ function prepareMapData() { // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js index a87572c1..f6716595 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -27,7 +27,6 @@ function clicked() { else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); else if (parent.id === "ice") editIce(el); - else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); else if (grand.id === "lakes") editLake(); @@ -544,8 +543,8 @@ function changePickerSpace() { space === "hex" ? d3.rgb(this.value) : space === "rgb" - ? d3.rgb(i[0], i[1], i[2]) - : d3.hsl(i[0], i[1] / 100, i[2] / 100); + ? d3.rgb(i[0], i[1], i[2]) + : d3.hsl(i[0], i[1] / 100, i[2] / 100); const hsl = d3.hsl(fill); if (isNaN(hsl.l)) { diff --git a/public/modules/ui/general.js b/public/modules/ui/general.js index 1849f8a7..4405411a 100644 --- a/public/modules/ui/general.js +++ b/public/modules/ui/general.js @@ -129,8 +129,8 @@ function showMapTooltip(point, e, i, g) { parent.id === "burgEmblems" ? [pack.burgs, "burg"] : parent.id === "provinceEmblems" - ? [pack.provinces, "province"] - : [pack.states, "state"]; + ? [pack.provinces, "province"] + : [pack.states, "state"]; const i = +e.target.dataset.i; if (event.shiftKey) highlightEmblemElement(type, g[i]); @@ -160,8 +160,6 @@ function showMapTooltip(point, e, i, g) { } } - if (group === "terrain") return tip("Click to edit the Relief Icon"); - if (subgroup === "burgLabels" || subgroup === "burgIcons") { const burgId = +path[path.length - 10].dataset.id; if (burgId) { @@ -346,7 +344,8 @@ function getFriendlyHeight([x, y]) { function getHeight(h, abs) { const unit = heightUnit.value; let unitRatio = 3.281; // default calculations are in feet - if (unit === "m") unitRatio = 1; // if meter + if (unit === "m") + unitRatio = 1; // if meter else if (unit === "f") unitRatio = 0.5468; // if fathom let height = -990; diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index f2f04a4b..68829e22 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -699,7 +699,16 @@ function toggleCompass(event) { function toggleRelief(event) { if (!layerIsOn("toggleRelief")) { turnButtonOn("toggleRelief"); - if (!terrain.selectAll("*").size()) drawReliefIcons(); + 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(); if (event && isCtrlClick(event)) editStyle("terrain"); } else { diff --git a/public/modules/ui/relief-editor.js b/public/modules/ui/relief-editor.js index 44a2c727..ed8aa455 100644 --- a/public/modules/ui/relief-editor.js +++ b/public/modules/ui/relief-editor.js @@ -4,8 +4,16 @@ function editReliefIcon() { closeDialogs(".stable"); 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); - elSelected = d3.select(d3.event.target); + + // When called from the Tools button there is no d3 click event; fall back to the first . + // When called from a map click, prefer the actual clicked element if it is a . + const clickTarget = d3.event && d3.event.target; + const useTarget = clickTarget && clickTarget.tagName === "use" ? clickTarget : terrain.select("use").node(); + elSelected = d3.select(useTarget); restoreEditMode(); updateReliefIconSelected(); @@ -59,6 +67,7 @@ function editReliefIcon() { function updateReliefIconSelected() { const type = elSelected.attr("href") || elSelected.attr("data-type"); const button = reliefIconsDiv.querySelector("svg[data-type='" + type + "']"); + if (!button) return; reliefIconsDiv.querySelectorAll("svg.pressed").forEach(b => b.classList.remove("pressed")); button.classList.add("pressed"); @@ -260,7 +269,9 @@ function editReliefIcon() { const type = reliefIconsDiv.querySelector("svg.pressed")?.dataset.type; selection = type ? terrain.selectAll("use[href='" + type + "']") : terrain.selectAll("use"); const size = selection.size(); - alertMessage.innerHTML = type ? `Are you sure you want to remove all ${type} icons (${size})?` : `Are you sure you want to remove all icons (${size})?`; + alertMessage.innerHTML = type + ? `Are you sure you want to remove all ${type} icons (${size})?` + : `Are you sure you want to remove all icons (${size})?`; } $("#alert").dialog({ @@ -284,5 +295,7 @@ function editReliefIcon() { removeCircle(); unselect(); clearMainTip(); + // Read back edits and switch terrain to canvas rendering + if (typeof exitReliefSvgEditMode === "function") exitReliefSvgEditMode(); } } diff --git a/public/styles/default.json b/public/styles/default.json index 42d1fbf7..ef504818 100644 --- a/public/styles/default.json +++ b/public/styles/default.json @@ -176,7 +176,7 @@ }, "#terrain": { "opacity": null, - "set": "simple", + "set": "colored", "size": 1, "density": 0.4, "filter": null, diff --git a/scripts/generate-relief-atlases.js b/scripts/generate-relief-atlases.js new file mode 100644 index 00000000..d507fa9c --- /dev/null +++ b/scripts/generate-relief-atlases.js @@ -0,0 +1,232 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Generate pre-rasterised PNG sprite atlases for the three relief icon sets + * (simple, gray, colored) from the SVG definitions in src/index.html. + * + * Output: public/images/relief/{simple,gray,colored}.png + * + * Each atlas is a grid of SPRITE_SIZE × SPRITE_SIZE tiles arranged + * left-to-right, top-to-bottom. Tile order matches the SET_SYMBOLS arrays + * in src/renderers/draw-relief-icons.ts — keep the two in sync. + * + * Rendering strategy: each symbol is placed as a standalone element + * positioned in a CSS grid inside a regular HTML page, then Playwright's + * native page.screenshot() captures the result. This avoids the headless- + * Chromium restriction that prevents SVG blob/data URLs from rendering when + * drawn to a element. + * + * Usage: + * node scripts/generate-relief-atlases.js + */ + +const {chromium} = require("playwright"); +const path = require("path"); +const fs = require("fs"); + +// --------------------------------------------------------------------------- +// Configuration — keep in sync with SET_SYMBOLS in draw-relief-icons.ts +// --------------------------------------------------------------------------- + +const SPRITE_SIZE = 512; // px per symbol tile + +const SET_SYMBOLS = { + 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" + ] +}; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const ROOT = path.resolve(__dirname, ".."); +const INDEX_HTML = path.join(ROOT, "src", "index.html"); +const OUTPUT_DIR = path.join(ROOT, "public", "images", "relief"); + +// --------------------------------------------------------------------------- +// Extract every element from index.html +// Returns a map of symbolId → {viewBox, innerSvg} +// --------------------------------------------------------------------------- + +function extractSymbols(html) { + const symbols = {}; + // Match each ... block. + // elements never nest, so a lazy [\s\S]*? is safe here. + const re = /]+)>([\s\S]*?)<\/symbol>/g; + let m; + while ((m = re.exec(html)) !== null) { + const attrs = m[1]; + const body = m[2]; + + const idM = attrs.match(/id="(relief-[^"]+)"/); + if (!idM) continue; + + const vbM = attrs.match(/viewBox="([^"]+)"/); + symbols[idM[1]] = { + viewBox: vbM ? vbM[1] : "0 0 100 100", + body + }; + } + return symbols; +} + +const html = fs.readFileSync(INDEX_HTML, "utf8"); +const allSymbols = extractSymbols(html); + +const found = Object.keys(allSymbols).length; +if (found === 0) { + console.error("ERROR: no relief symbols found in src/index.html"); + process.exit(1); +} +console.log(`Extracted ${found} relief symbols from index.html`); + +fs.mkdirSync(OUTPUT_DIR, {recursive: true}); + +// --------------------------------------------------------------------------- +// Main — render each atlas via Playwright's native screenshot +// --------------------------------------------------------------------------- + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + + for (const [set, ids] of Object.entries(SET_SYMBOLS)) { + const n = ids.length; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + const W = cols * SPRITE_SIZE; + const H = rows * SPRITE_SIZE; + + // Build one SVG per symbol, absolutely positioned in a grid. + // Using standalone elements (not ) means the symbols are + // self-contained and render correctly regardless of CSP restrictions. + const svgTiles = ids + .map((symbolId, idx) => { + const sym = allSymbols[symbolId]; + if (!sym) { + console.warn(` WARNING: symbol "${symbolId}" not found — tile will be blank`); + return ""; + } + const col = idx % cols; + const row = Math.floor(idx / cols); + const x = col * SPRITE_SIZE; + const y = row * SPRITE_SIZE; + return ( + `${sym.body}` + ); + }) + .join("\n"); + + const pageHtml = + `` + + svgTiles + + ``; + + await page.setViewportSize({width: W, height: H}); + await page.setContent(pageHtml, {waitUntil: "load"}); + + const buffer = await page.screenshot({ + type: "png", + fullPage: false, + clip: {x: 0, y: 0, width: W, height: H}, + omitBackground: true + }); + + const outFile = path.join(OUTPUT_DIR, `${set}.png`); + fs.writeFileSync(outFile, buffer); + console.log(`✓ ${set}.png — ${cols}×${rows} tiles (${W}×${H} px, ${(buffer.length / 1024).toFixed(0)} KB)`); + } + + await browser.close(); + console.log("\nDone. Files saved to public/images/relief/"); +})().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/index.html b/src/index.html index 6ab9cc06..c076762e 100644 --- a/src/index.html +++ b/src/index.html @@ -2140,6 +2140,9 @@ + diff --git a/src/renderers/draw-relief-icons.ts b/src/renderers/draw-relief-icons.ts index c4960b25..b889855b 100644 --- a/src/renderers/draw-relief-icons.ts +++ b/src/renderers/draw-relief-icons.ts @@ -1,39 +1,430 @@ -import { extent, polygonContains } from "d3"; -import { minmax, rand, rn } from "../utils"; +import {extent, polygonContains} from "d3"; +import * as THREE from "three"; +import {minmax, rand, rn} from "../utils"; interface ReliefIcon { - i: string; + i: string; // e.g. "#relief-mount-1" x: number; y: number; - s: 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; +let fo: SVGForeignObjectElement | null = null; +let renderer: any = null; // THREE.WebGLRenderer +let camera: any = null; // THREE.OrthographicCamera +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}"`); +} + +function loadTexture(set: string): Promise { + if (textureCache.has(set)) return Promise.resolve(textureCache.get(set)); + return new Promise(resolve => { + const loader = new THREE.TextureLoader(); + loader.load( + `images/relief/${set}.png`, + texture => { + texture.flipY = false; + texture.needsUpdate = true; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + textureCache.set(set, texture); + resolve(texture); + }, + undefined, + () => { + console.warn(`Relief: atlas not found for "${set}". Run: npm run generate-atlases`); + resolve(null); + } + ); + }); +} + +async function preloadTextures(): Promise { + const sets = new Set(); + for (const r of reliefIconData) sets.add(resolveSprite(r.i).set); + await Promise.all([...sets].map(loadTexture)); +} + +function ensureRenderer(): boolean { + if (renderer) { + // Recover from WebGL context loss (can happen when canvas is detached from DOM) + if (renderer.getContext().isContextLost()) { + renderer.forceContextRestore(); + renderer.dispose(); + renderer = null; + camera = null; + scene = null; + disposeTextureCache(); + // fall through to recreate + } else { + if (fo && !fo.isConnected) terrain.node()!.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.id = "terrainFo"; + fo.setAttribute("x", "0"); + fo.setAttribute("y", "0"); + fo.setAttribute("width", String(graphWidth)); + 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); + + try { + 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%;"; + } 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 = new THREE.OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1); + scene = new THREE.Scene(); + return true; +} + +// ─── 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. + * + * UV layout (texture.flipY = false means v=0 is top of image): + * u = col/cols … (col+1)/cols + * v = row/rows … (row+1)/rows + */ +function buildSetMesh(icons: ReliefIcon[], set: string, texture: any): any { + const ids = RELIEF_SYMBOLS[set] ?? []; + const n = ids.length || 1; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + + const positions = new Float32Array(icons.length * 4 * 3); + const uvs = new Float32Array(icons.length * 4 * 2); + const indices = new Uint32Array(icons.length * 6); + + let vi = 0, + ii = 0; + for (const r of icons) { + const {tileIndex} = resolveSprite(r.i); + const col = tileIndex % cols; + const row = Math.floor(tileIndex / cols); + const u0 = col / cols, + u1 = (col + 1) / cols; + const v0 = row / rows, + v1 = (row + 1) / rows; + const x0 = r.x, + 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; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geo.setIndex(new THREE.BufferAttribute(indices, 1)); + + const mat = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.DoubleSide, + depthTest: false, + depthWrite: false + }); + + return new THREE.Mesh(geo, mat); +} + +function disposeTextureCache(): void { + for (const tex of textureCache.values()) tex?.dispose(); + textureCache.clear(); +} + +function disposeScene(): void { + if (!scene) return; + while (scene.children.length) { + 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(); + } + } +} + +function buildScene(): 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); + let arr = bySet.get(set); + if (!arr) { + arr = []; + bySet.set(set, arr); + } + arr.push(r); + } + + for (const [set, icons] of bySet) { + const texture = textureCache.get(set); + if (!texture) continue; + scene.add(buildSetMesh(icons, set, texture)); + } +} + +function renderFrame(): void { + if (!renderer || !camera || !scene || !fo) return; + + const x = -viewX / scale; + const y = -viewY / scale; + 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("") + ); +} + +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")! + }); + }); + terrain.selectAll("use").remove(); + svgEditMode = false; + preloadTextures().then(() => { + buildScene(); + renderFrame(); + }); +} + +function prepareReliefForSave(): void { + if (svgEditMode) return; + terrain + .node()! + .insertAdjacentHTML( + "afterbegin", + reliefIconData.map(r => ``).join("") + ); +} + +function restoreReliefAfterSave(): void { + if (!svgEditMode) terrain.selectAll("use").remove(); } const reliefIconsRenderer = (): void => { TIME && console.time("drawRelief"); + terrain.selectAll("*").remove(); + disposeTextureCache(); + disposeScene(); + reliefIconData = []; + svgEditMode = false; 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; // size modifier + const mod = 0.2 * size; const relief: ReliefIcon[] = []; for (const i of cells.i) { const height = cells.h[i]; - if (height < 20) continue; // no icons on water - if (cells.r[i]) continue; // no icons on rivers + if (height < 20 || cells.r[i]) continue; const biome = cells.biome[i]; - if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome + if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; 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]; + const [minX, maxX] = extent(polygon, p => p[0]) as [number, number]; + const [minY, maxY] = extent(polygon, p => p[1]) as [number, number]; if (height < 50) placeBiomeIcons(); else placeReliefIcons(); @@ -42,45 +433,21 @@ const reliefIconsRenderer = (): 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, - )) { + 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), - }); + 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, - )) { + 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), - }); + relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)}); } } @@ -92,19 +459,22 @@ const reliefIconsRenderer = (): void => { } } - // sort relief icons by y+size relief.sort((a, b) => a.y + a.s - (b.y + b.s)); - - const reliefHTML: string[] = []; - for (const r of relief) { - reliefHTML.push( - ``, - ); - } - terrain.html(reliefHTML.join("")); + reliefIconData = relief; TIME && console.timeEnd("drawRelief"); + if (reliefIconData.length) { + if (ensureRenderer()) { + preloadTextures().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]]; @@ -130,35 +500,37 @@ const reliefIconsRenderer = (): void => { 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 { - switch (type) { - case "mountSnow": - return "mount"; - case "vulcan": - return "mount"; - case "coniferSnow": - return "conifer"; - case "cactus": - return "dune"; - case "deadTree": - return "dune"; - default: - return type; - } + 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 === "simple") return `#relief-${getOldIcon(type)}-1`; if (set === "colored") return `#relief-${type}-${getVariant(type)}`; if (set === "gray") return `#relief-${type}-${getVariant(type)}-bw`; - return `#relief-${getOldIcon(type)}-1`; // simple + return `#relief-${getOldIcon(type)}-1`; } }; +window.renderReliefIcons = renderFrame; window.drawReliefIcons = reliefIconsRenderer; +window.enterReliefSvgEditMode = enterSvgEditMode; +window.exitReliefSvgEditMode = exitSvgEditMode; +window.prepareReliefForSave = prepareReliefForSave; +window.restoreReliefAfterSave = restoreReliefAfterSave; diff --git a/src/types/global.ts b/src/types/global.ts index 371dd583..9bfd1b58 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -1,6 +1,6 @@ -import type { Selection } from "d3"; -import type { NameBase } from "../modules/names-generator"; -import type { PackedGraph } from "./PackedGraph"; +import type {Selection} from "d3"; +import type {NameBase} from "../modules/names-generator"; +import type {PackedGraph} from "./PackedGraph"; declare global { var seed: string; @@ -11,7 +11,7 @@ declare global { var TIME: boolean; var WARN: boolean; var ERROR: boolean; - var DEBUG: { stateLabels?: boolean; [key: string]: boolean | undefined }; + var DEBUG: {stateLabels?: boolean; [key: string]: boolean | undefined}; var options: any; var heightmapTemplates: any; @@ -66,9 +66,9 @@ declare global { }; var notes: any[]; var style: { - burgLabels: { [key: string]: { [key: string]: string } }; - burgIcons: { [key: string]: { [key: string]: string } }; - anchors: { [key: string]: { [key: string]: string } }; + burgLabels: {[key: string]: {[key: string]: string}}; + burgIcons: {[key: string]: {[key: string]: string}}; + anchors: {[key: string]: {[key: string]: string}}; [key: string]: any; }; @@ -81,12 +81,14 @@ declare global { message: string, autoHide?: boolean, type?: "info" | "warn" | "error" | "success", - timeout?: number, + timeout?: number ) => void; var locked: (settingId: string) => boolean; var unlock: (settingId: string) => void; var $: (selector: any) => any; var scale: number; + var viewX: number; + var viewY: number; var changeFont: () => void; var getFriendlyHeight: (coords: [number, number]) => string; }