feat: relief three.js renderer

This commit is contained in:
Azgaar 2026-03-09 02:47:13 +01:00
parent 7a49098425
commit 7481a2843e
19 changed files with 828 additions and 120 deletions

View file

@ -180,7 +180,10 @@ async function getMapURL(
fullMap = false
} = {}
) {
// Temporarily inject <use> 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 <use> elements canvas <image> 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 = {

View file

@ -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 <use> 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");

View file

@ -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 <use> 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");