@connected-web/terrain-editor 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +200 -24
- package/dist/index.d.cts +30 -4
- package/dist/index.d.ts +30 -4
- package/dist/index.js +200 -24
- package/documentation/WYN-FILE-FORMAT.md +9 -2
- package/documentation/schemas/legend.schema.json +23 -3
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -398,6 +398,9 @@ async function preprocessMask(file, resolveAssetUrl, imageCache, maskCache) {
|
|
|
398
398
|
function hexFromRgb(rgb) {
|
|
399
399
|
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
400
400
|
}
|
|
401
|
+
function isRgbaLayer(layer) {
|
|
402
|
+
return typeof layer.rgba === "string";
|
|
403
|
+
}
|
|
401
404
|
async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, maskCache, layerState) {
|
|
402
405
|
if (typeof document === "undefined") return null;
|
|
403
406
|
const [width, height] = legendData.size;
|
|
@@ -434,6 +437,17 @@ async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, mas
|
|
|
434
437
|
ctx.globalAlpha = alpha;
|
|
435
438
|
ctx.drawImage(temp, 0, 0, width, height);
|
|
436
439
|
}
|
|
440
|
+
async function drawRgbaLayer(textureFile, alpha = 1) {
|
|
441
|
+
let image = null;
|
|
442
|
+
try {
|
|
443
|
+
image = await loadLegendImage(textureFile, resolveAssetUrl, imageCache);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.warn("[WynnalTerrain] Unable to load overlay texture", textureFile, err);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
ctx.globalAlpha = alpha;
|
|
449
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
450
|
+
}
|
|
437
451
|
const activeBiomes = layerState?.biomes ?? {};
|
|
438
452
|
for (const [name, biome] of Object.entries(legendData.biomes)) {
|
|
439
453
|
if (layerState && activeBiomes[name] === false) continue;
|
|
@@ -443,6 +457,14 @@ async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, mas
|
|
|
443
457
|
for (const [name, overlay] of Object.entries(legendData.overlays)) {
|
|
444
458
|
if (layerState && activeOverlays[name] === false) continue;
|
|
445
459
|
const alpha = DEFAULT_LAYER_ALPHA[name] ?? 1;
|
|
460
|
+
if (isRgbaLayer(overlay)) {
|
|
461
|
+
await drawRgbaLayer(overlay.rgba, alpha);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (!overlay.mask || !overlay.rgb) {
|
|
465
|
+
console.warn("[WynnalTerrain] Overlay entry missing mask/rgb", name);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
446
468
|
await drawLayer(overlay.mask, overlay.rgb, alpha);
|
|
447
469
|
}
|
|
448
470
|
const texture = new THREE2.CanvasTexture(canvas);
|
|
@@ -812,6 +834,15 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
812
834
|
updateLayers: async () => {
|
|
813
835
|
},
|
|
814
836
|
setInteractiveMode: noop2,
|
|
837
|
+
setMaxPixelRatio: noop2,
|
|
838
|
+
setRenderScaleMode: noop2,
|
|
839
|
+
setRenderPaused: noop2,
|
|
840
|
+
getRenderResolution: () => ({
|
|
841
|
+
width: 0,
|
|
842
|
+
height: 0,
|
|
843
|
+
pixelRatio: 1,
|
|
844
|
+
renderScale: 1
|
|
845
|
+
}),
|
|
815
846
|
updateLocations: noop2,
|
|
816
847
|
setFocusedLocation: noop2,
|
|
817
848
|
navigateTo: noop2,
|
|
@@ -824,6 +855,8 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
824
855
|
},
|
|
825
856
|
setSeaLevel: () => {
|
|
826
857
|
},
|
|
858
|
+
setHeightScale: () => {
|
|
859
|
+
},
|
|
827
860
|
invalidateIconTextures: () => {
|
|
828
861
|
},
|
|
829
862
|
invalidateLayerMasks: () => {
|
|
@@ -908,7 +941,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
908
941
|
const { u, v } = pixelToUV(pixel);
|
|
909
942
|
return uvToWorld(u, v, heightSampler, currentHeightScale, seaLevel, terrainDimensions);
|
|
910
943
|
}
|
|
911
|
-
const heightScale = options.heightScale ?? HEIGHT_SCALE_DEFAULT;
|
|
944
|
+
const heightScale = options.heightScale ?? legend.height_scale ?? HEIGHT_SCALE_DEFAULT;
|
|
912
945
|
let currentHeightScale = heightScale;
|
|
913
946
|
const waterPercent = THREE2.MathUtils.clamp(
|
|
914
947
|
options.waterLevelPercent ?? WATER_PERCENT_DEFAULT,
|
|
@@ -941,10 +974,28 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
941
974
|
let viewportWidth = width;
|
|
942
975
|
let viewportHeight = height;
|
|
943
976
|
let viewOffsetPixels = 0;
|
|
944
|
-
|
|
977
|
+
let maxPixelRatio = Math.max(1, options.maxPixelRatio ?? 1.5);
|
|
978
|
+
const minRenderScale = 0.25;
|
|
979
|
+
let renderScaleMode = options.renderScaleMode ?? "fixed";
|
|
980
|
+
let renderScale = clampRenderScale(options.renderScale ?? 1);
|
|
981
|
+
let currentPixelRatio = 1;
|
|
982
|
+
function clampRenderScale(value) {
|
|
983
|
+
return THREE2.MathUtils.clamp(value, minRenderScale, 1);
|
|
984
|
+
}
|
|
985
|
+
function resolvePixelRatio() {
|
|
986
|
+
const deviceRatio = window.devicePixelRatio || 1;
|
|
987
|
+
const scaledRatio = deviceRatio * renderScale;
|
|
988
|
+
return Math.min(scaledRatio, maxPixelRatio);
|
|
989
|
+
}
|
|
990
|
+
const renderer = new THREE2.WebGLRenderer({
|
|
991
|
+
antialias: true,
|
|
992
|
+
alpha: true,
|
|
993
|
+
preserveDrawingBuffer: options.preserveDrawingBuffer ?? false
|
|
994
|
+
});
|
|
945
995
|
renderer.toneMapping = THREE2.ACESFilmicToneMapping;
|
|
946
996
|
renderer.toneMappingExposure = 1.08;
|
|
947
|
-
|
|
997
|
+
currentPixelRatio = resolvePixelRatio();
|
|
998
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
948
999
|
const hostStyle = window.getComputedStyle(container);
|
|
949
1000
|
if (hostStyle.position === "static") {
|
|
950
1001
|
container.style.position = "relative";
|
|
@@ -961,6 +1012,22 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
961
1012
|
renderer.shadowMap.enabled = true;
|
|
962
1013
|
renderer.shadowMap.type = THREE2.PCFSoftShadowMap;
|
|
963
1014
|
container.appendChild(renderer.domElement);
|
|
1015
|
+
const renderResolution = {
|
|
1016
|
+
width: 0,
|
|
1017
|
+
height: 0,
|
|
1018
|
+
pixelRatio: currentPixelRatio,
|
|
1019
|
+
renderScale
|
|
1020
|
+
};
|
|
1021
|
+
function updateRenderResolution() {
|
|
1022
|
+
const bufferSize = new THREE2.Vector2();
|
|
1023
|
+
renderer.getDrawingBufferSize(bufferSize);
|
|
1024
|
+
renderResolution.width = Math.round(bufferSize.x);
|
|
1025
|
+
renderResolution.height = Math.round(bufferSize.y);
|
|
1026
|
+
renderResolution.pixelRatio = currentPixelRatio;
|
|
1027
|
+
renderResolution.renderScale = renderScale;
|
|
1028
|
+
options.onRenderSizeChange?.({ ...renderResolution });
|
|
1029
|
+
}
|
|
1030
|
+
updateRenderResolution();
|
|
964
1031
|
disposables.push(() => {
|
|
965
1032
|
renderer.dispose();
|
|
966
1033
|
renderer.domElement.remove();
|
|
@@ -1029,19 +1096,42 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1029
1096
|
}
|
|
1030
1097
|
const locationWorldCache = /* @__PURE__ */ new Map();
|
|
1031
1098
|
let markerGeneration = 0;
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1099
|
+
const placementIndicatorHeight = 0.26;
|
|
1100
|
+
const placementIndicatorGroup = new THREE2.Group();
|
|
1101
|
+
const placementIndicatorMaterial = new THREE2.MeshStandardMaterial({
|
|
1102
|
+
color: 16735067,
|
|
1103
|
+
transparent: true,
|
|
1104
|
+
opacity: 0.85
|
|
1105
|
+
});
|
|
1106
|
+
const placementStem = new THREE2.Mesh(
|
|
1107
|
+
new THREE2.CylinderGeometry(0.01, 0.01, 0.06, 8),
|
|
1108
|
+
placementIndicatorMaterial
|
|
1109
|
+
);
|
|
1110
|
+
placementStem.position.y = 0.03;
|
|
1111
|
+
const placementDot = new THREE2.Mesh(
|
|
1112
|
+
new THREE2.SphereGeometry(0.014, 10, 10),
|
|
1113
|
+
placementIndicatorMaterial
|
|
1114
|
+
);
|
|
1115
|
+
placementDot.position.y = 5e-3;
|
|
1116
|
+
const placementRing = new THREE2.Mesh(
|
|
1117
|
+
new THREE2.CircleGeometry(0.12, 32),
|
|
1118
|
+
new THREE2.MeshBasicMaterial({
|
|
1035
1119
|
color: 16735067,
|
|
1036
1120
|
transparent: true,
|
|
1037
|
-
opacity: 0.
|
|
1121
|
+
opacity: 0.2
|
|
1038
1122
|
})
|
|
1039
1123
|
);
|
|
1040
|
-
|
|
1041
|
-
|
|
1124
|
+
placementRing.rotation.x = -Math.PI / 2;
|
|
1125
|
+
placementRing.position.y = 5e-3;
|
|
1126
|
+
placementIndicatorGroup.add(placementRing, placementStem, placementDot);
|
|
1127
|
+
placementIndicatorGroup.visible = false;
|
|
1128
|
+
scene.add(placementIndicatorGroup);
|
|
1042
1129
|
disposables.push(() => {
|
|
1043
|
-
|
|
1044
|
-
|
|
1130
|
+
placementStem.geometry.dispose();
|
|
1131
|
+
placementDot.geometry.dispose();
|
|
1132
|
+
placementRing.geometry.dispose();
|
|
1133
|
+
placementIndicatorMaterial.dispose();
|
|
1134
|
+
placementRing.material.dispose();
|
|
1045
1135
|
});
|
|
1046
1136
|
const loader = new THREE2.TextureLoader();
|
|
1047
1137
|
const iconTextureCache = /* @__PURE__ */ new Map();
|
|
@@ -1267,6 +1357,31 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1267
1357
|
stemRadius = computeStemRadius(markerTheme.stem);
|
|
1268
1358
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
1269
1359
|
}
|
|
1360
|
+
function applyHeightScaleUpdate(nextHeightScale) {
|
|
1361
|
+
if (!heightSampler) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const prevHeightScale = currentHeightScale;
|
|
1365
|
+
const nextScale = Math.max(0, nextHeightScale);
|
|
1366
|
+
currentHeightScale = nextScale;
|
|
1367
|
+
legend.height_scale = nextScale;
|
|
1368
|
+
dataset.legend.height_scale = nextScale;
|
|
1369
|
+
const stats = applyHeightField(terrainGeometry, heightSampler, {
|
|
1370
|
+
seaLevel,
|
|
1371
|
+
heightScale: currentHeightScale
|
|
1372
|
+
});
|
|
1373
|
+
terrainHeightRange = { min: stats.minY, max: stats.maxY };
|
|
1374
|
+
terrainGeometry.attributes.position.needsUpdate = true;
|
|
1375
|
+
terrainGeometry.computeVertexNormals();
|
|
1376
|
+
rebuildRimMesh();
|
|
1377
|
+
rebuildOceanMesh();
|
|
1378
|
+
if (Number.isFinite(prevHeightScale) && prevHeightScale > 0) {
|
|
1379
|
+
const factor = nextScale / prevHeightScale;
|
|
1380
|
+
controls.target.y *= factor;
|
|
1381
|
+
camera.position.y *= factor;
|
|
1382
|
+
}
|
|
1383
|
+
setLocationMarkers(currentLocations, currentFocusId);
|
|
1384
|
+
}
|
|
1270
1385
|
function applySeaLevelUpdate(nextSeaLevel) {
|
|
1271
1386
|
if (!heightSampler) {
|
|
1272
1387
|
return;
|
|
@@ -1393,6 +1508,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1393
1508
|
camera.updateProjectionMatrix();
|
|
1394
1509
|
}
|
|
1395
1510
|
let animationFrame = 0;
|
|
1511
|
+
let renderPaused = false;
|
|
1396
1512
|
let frameCount = 0;
|
|
1397
1513
|
let firstRenderComplete = false;
|
|
1398
1514
|
const frameTimings = [];
|
|
@@ -1450,19 +1566,32 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1450
1566
|
if (currentLifecycleState === "stabilizing" && stabilizingStartTime !== null) {
|
|
1451
1567
|
const stabilizingDuration = now - stabilizingStartTime;
|
|
1452
1568
|
if (stabilizingDuration > STABILITY_TIMEOUT_MS) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1569
|
+
if (renderScaleMode === "auto" && renderScale > minRenderScale + 0.01) {
|
|
1570
|
+
renderScale = clampRenderScale(Number((renderScale * 0.6).toFixed(2)));
|
|
1571
|
+
currentPixelRatio = resolvePixelRatio();
|
|
1572
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1573
|
+
renderer.setSize(viewportWidth, viewportHeight, false);
|
|
1574
|
+
updateRenderResolution();
|
|
1575
|
+
frameTimings.length = 0;
|
|
1576
|
+
stabilizingStartTime = now;
|
|
1577
|
+
console.warn(
|
|
1578
|
+
`[terrainViewer] Framerate unstable after ${STABILITY_TIMEOUT_MS}ms, reducing render scale to ${renderScale}`
|
|
1579
|
+
);
|
|
1580
|
+
} else {
|
|
1581
|
+
console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
|
|
1582
|
+
setLifecycleState("ready");
|
|
1583
|
+
stabilizingStartTime = null;
|
|
1584
|
+
}
|
|
1456
1585
|
}
|
|
1457
1586
|
}
|
|
1458
1587
|
}
|
|
1459
|
-
if (!frameCaptureMode) {
|
|
1588
|
+
if (!frameCaptureMode && !renderPaused) {
|
|
1460
1589
|
animationFrame = window.requestAnimationFrame(animate);
|
|
1461
1590
|
}
|
|
1462
1591
|
}
|
|
1463
1592
|
animate();
|
|
1464
1593
|
options.onReady?.();
|
|
1465
|
-
|
|
1594
|
+
function handleResize() {
|
|
1466
1595
|
const { clientWidth, clientHeight } = container;
|
|
1467
1596
|
if (!clientWidth || !clientHeight) return;
|
|
1468
1597
|
viewportWidth = clientWidth;
|
|
@@ -1471,8 +1600,17 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1471
1600
|
viewOffsetPixels = shiftTarget;
|
|
1472
1601
|
camera.aspect = clientWidth / clientHeight;
|
|
1473
1602
|
camera.updateProjectionMatrix();
|
|
1603
|
+
const nextPixelRatio = resolvePixelRatio();
|
|
1604
|
+
if (nextPixelRatio !== currentPixelRatio) {
|
|
1605
|
+
currentPixelRatio = nextPixelRatio;
|
|
1606
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1607
|
+
}
|
|
1474
1608
|
renderer.setSize(clientWidth, clientHeight, false);
|
|
1475
1609
|
applyViewOffset();
|
|
1610
|
+
updateRenderResolution();
|
|
1611
|
+
}
|
|
1612
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1613
|
+
handleResize();
|
|
1476
1614
|
});
|
|
1477
1615
|
resizeObserver.observe(container);
|
|
1478
1616
|
function setPointerFromEvent(event) {
|
|
@@ -1497,22 +1635,26 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1497
1635
|
const markerId = pickMarkerId();
|
|
1498
1636
|
if (markerId !== hoveredLocationId) {
|
|
1499
1637
|
hoveredLocationId = markerId;
|
|
1638
|
+
const hoveredLocation = markerId ? currentLocations.find((location) => location.id === markerId) : void 0;
|
|
1639
|
+
renderer.domElement.title = hoveredLocation?.name || hoveredLocation?.id || "";
|
|
1500
1640
|
options.onLocationHover?.(markerId);
|
|
1501
1641
|
updateMarkerVisuals();
|
|
1502
1642
|
}
|
|
1503
1643
|
}
|
|
1504
1644
|
function updatePlacementIndicator() {
|
|
1505
1645
|
if (!interactiveEnabled) {
|
|
1506
|
-
|
|
1646
|
+
placementIndicatorGroup.visible = false;
|
|
1507
1647
|
return;
|
|
1508
1648
|
}
|
|
1509
1649
|
const hit = intersectTerrain();
|
|
1510
1650
|
if (!hit) {
|
|
1511
|
-
|
|
1651
|
+
placementIndicatorGroup.visible = false;
|
|
1512
1652
|
return;
|
|
1513
1653
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1654
|
+
placementIndicatorGroup.visible = true;
|
|
1655
|
+
const target = hit.point.clone();
|
|
1656
|
+
target.y += 0.02;
|
|
1657
|
+
placementIndicatorGroup.position.copy(target);
|
|
1516
1658
|
}
|
|
1517
1659
|
function handlePointerMove(event) {
|
|
1518
1660
|
setPointerFromEvent(event);
|
|
@@ -1571,7 +1713,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1571
1713
|
renderer.domElement.classList.add("terrain-pointer-active");
|
|
1572
1714
|
} else {
|
|
1573
1715
|
renderer.domElement.classList.remove("terrain-pointer-active");
|
|
1574
|
-
|
|
1716
|
+
placementIndicatorGroup.visible = false;
|
|
1575
1717
|
}
|
|
1576
1718
|
}
|
|
1577
1719
|
async function updateLayers(state) {
|
|
@@ -1641,6 +1783,33 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1641
1783
|
},
|
|
1642
1784
|
updateLayers,
|
|
1643
1785
|
setInteractiveMode,
|
|
1786
|
+
setMaxPixelRatio: (value) => {
|
|
1787
|
+
maxPixelRatio = Math.max(1, value);
|
|
1788
|
+
handleResize();
|
|
1789
|
+
},
|
|
1790
|
+
setRenderScaleMode: (mode, scale) => {
|
|
1791
|
+
renderScaleMode = mode;
|
|
1792
|
+
if (typeof scale === "number") {
|
|
1793
|
+
renderScale = clampRenderScale(scale);
|
|
1794
|
+
} else if (renderScaleMode === "auto") {
|
|
1795
|
+
renderScale = clampRenderScale(renderScale);
|
|
1796
|
+
}
|
|
1797
|
+
currentPixelRatio = resolvePixelRatio();
|
|
1798
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1799
|
+
renderer.setSize(viewportWidth, viewportHeight, false);
|
|
1800
|
+
updateRenderResolution();
|
|
1801
|
+
frameTimings.length = 0;
|
|
1802
|
+
stabilizingStartTime = null;
|
|
1803
|
+
},
|
|
1804
|
+
setRenderPaused: (paused) => {
|
|
1805
|
+
renderPaused = paused;
|
|
1806
|
+
if (paused) {
|
|
1807
|
+
window.cancelAnimationFrame(animationFrame);
|
|
1808
|
+
} else if (!frameCaptureMode) {
|
|
1809
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
getRenderResolution: () => ({ ...renderResolution }),
|
|
1644
1813
|
updateLocations: (locations, focusedId) => {
|
|
1645
1814
|
setLocationMarkers(locations, focusedId);
|
|
1646
1815
|
},
|
|
@@ -1707,6 +1876,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1707
1876
|
},
|
|
1708
1877
|
setTheme: applyThemeUpdate,
|
|
1709
1878
|
setSeaLevel: applySeaLevelUpdate,
|
|
1879
|
+
setHeightScale: applyHeightScaleUpdate,
|
|
1710
1880
|
invalidateIconTextures: (paths) => {
|
|
1711
1881
|
invalidateIconCache(paths);
|
|
1712
1882
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
@@ -1725,7 +1895,9 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1725
1895
|
},
|
|
1726
1896
|
disableFrameCaptureMode: () => {
|
|
1727
1897
|
frameCaptureMode = false;
|
|
1728
|
-
|
|
1898
|
+
if (!renderPaused) {
|
|
1899
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1900
|
+
}
|
|
1729
1901
|
},
|
|
1730
1902
|
captureFrame: (frameNumber, fps = 30) => {
|
|
1731
1903
|
if (!frameCaptureMode) {
|
|
@@ -2953,12 +3125,16 @@ function buildEntries(legend, visibility) {
|
|
|
2953
3125
|
}
|
|
2954
3126
|
for (const [key, layer] of Object.entries(legend.overlays ?? {})) {
|
|
2955
3127
|
const id = makeLayerId("overlay", key);
|
|
3128
|
+
const color = "rgb" in layer && Array.isArray(layer.rgb) ? layer.rgb : [255, 255, 255];
|
|
3129
|
+
const icon = "rgba" in layer ? "image" : void 0;
|
|
2956
3130
|
entries.push({
|
|
2957
3131
|
id,
|
|
2958
3132
|
kind: "overlay",
|
|
2959
3133
|
label: layer.label ?? key,
|
|
2960
|
-
mask: layer.mask,
|
|
2961
|
-
|
|
3134
|
+
mask: "mask" in layer ? layer.mask : void 0,
|
|
3135
|
+
rgba: "rgba" in layer ? layer.rgba : void 0,
|
|
3136
|
+
color,
|
|
3137
|
+
icon,
|
|
2962
3138
|
visible: visibility.get(id) ?? true
|
|
2963
3139
|
});
|
|
2964
3140
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -82,6 +82,7 @@ type TerrainLocation = {
|
|
|
82
82
|
id: string;
|
|
83
83
|
name?: string;
|
|
84
84
|
icon?: string;
|
|
85
|
+
description?: string;
|
|
85
86
|
showBorder?: boolean;
|
|
86
87
|
pixel: {
|
|
87
88
|
x: number;
|
|
@@ -114,11 +115,16 @@ type LocationPickPayload = {
|
|
|
114
115
|
};
|
|
115
116
|
};
|
|
116
117
|
type ViewerLifecycleState = 'initializing' | 'loading-textures' | 'building-geometry' | 'first-render' | 'stabilizing' | 'ready';
|
|
118
|
+
type RenderScaleMode = 'auto' | 'fixed';
|
|
117
119
|
type TerrainInitOptions = {
|
|
118
120
|
onReady?: () => void;
|
|
119
121
|
onLifecycleChange?: (state: ViewerLifecycleState) => void;
|
|
120
122
|
heightScale?: number;
|
|
121
123
|
waterLevelPercent?: number;
|
|
124
|
+
maxPixelRatio?: number;
|
|
125
|
+
renderScale?: number;
|
|
126
|
+
renderScaleMode?: RenderScaleMode;
|
|
127
|
+
onRenderSizeChange?: (payload: RenderResolution) => void;
|
|
122
128
|
layers?: LayerToggleState;
|
|
123
129
|
interactive?: boolean;
|
|
124
130
|
onLocationPick?: (payload: LocationPickPayload) => void;
|
|
@@ -127,11 +133,16 @@ type TerrainInitOptions = {
|
|
|
127
133
|
locations?: TerrainLocation[];
|
|
128
134
|
theme?: TerrainThemeOverrides;
|
|
129
135
|
cameraView?: LocationViewState;
|
|
136
|
+
preserveDrawingBuffer?: boolean;
|
|
130
137
|
};
|
|
131
138
|
type TerrainHandle = {
|
|
132
139
|
destroy: () => void;
|
|
133
140
|
updateLayers: (state: LayerToggleState) => Promise<void>;
|
|
134
141
|
setInteractiveMode: (enabled: boolean) => void;
|
|
142
|
+
setMaxPixelRatio: (value: number) => void;
|
|
143
|
+
setRenderScaleMode: (mode: RenderScaleMode, scale?: number) => void;
|
|
144
|
+
setRenderPaused: (paused: boolean) => void;
|
|
145
|
+
getRenderResolution: () => RenderResolution;
|
|
135
146
|
updateLocations: (locations: TerrainLocation[], focusedId?: string) => void;
|
|
136
147
|
setFocusedLocation: (locationId?: string | null) => void;
|
|
137
148
|
navigateTo: (payload: {
|
|
@@ -154,6 +165,7 @@ type TerrainHandle = {
|
|
|
154
165
|
getViewState: () => LocationViewState;
|
|
155
166
|
setTheme: (overrides?: TerrainThemeOverrides) => void;
|
|
156
167
|
setSeaLevel: (level: number) => void;
|
|
168
|
+
setHeightScale: (scale: number) => void;
|
|
157
169
|
invalidateIconTextures: (paths?: string[]) => void;
|
|
158
170
|
invalidateLayerMasks: (paths?: string[]) => void;
|
|
159
171
|
enableFrameCaptureMode: (fps?: number) => {
|
|
@@ -166,18 +178,30 @@ type TerrainHandle = {
|
|
|
166
178
|
};
|
|
167
179
|
};
|
|
168
180
|
type Cleanup = () => void;
|
|
181
|
+
type RenderResolution = {
|
|
182
|
+
width: number;
|
|
183
|
+
height: number;
|
|
184
|
+
pixelRatio: number;
|
|
185
|
+
renderScale: number;
|
|
186
|
+
};
|
|
169
187
|
type Resolvable<T> = T | Promise<T>;
|
|
170
|
-
type
|
|
188
|
+
type MaskLegendLayer = {
|
|
171
189
|
mask: string;
|
|
172
190
|
rgb: [number, number, number];
|
|
173
191
|
label?: string;
|
|
174
192
|
};
|
|
193
|
+
type RgbaLegendLayer = {
|
|
194
|
+
rgba: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
};
|
|
197
|
+
type LegendLayer = MaskLegendLayer | RgbaLegendLayer;
|
|
175
198
|
type TerrainLegend = {
|
|
176
199
|
size: [number, number];
|
|
177
200
|
sea_level?: number;
|
|
201
|
+
height_scale?: number;
|
|
178
202
|
heightmap: string;
|
|
179
203
|
topology?: string;
|
|
180
|
-
biomes: Record<string,
|
|
204
|
+
biomes: Record<string, MaskLegendLayer>;
|
|
181
205
|
overlays: Record<string, LegendLayer>;
|
|
182
206
|
};
|
|
183
207
|
type TerrainDataset = {
|
|
@@ -346,8 +370,10 @@ type LayerBrowserEntry = {
|
|
|
346
370
|
id: string;
|
|
347
371
|
kind: 'biome' | 'overlay';
|
|
348
372
|
label: string;
|
|
349
|
-
mask
|
|
373
|
+
mask?: string;
|
|
374
|
+
rgba?: string;
|
|
350
375
|
color: [number, number, number];
|
|
376
|
+
icon?: string;
|
|
351
377
|
visible: boolean;
|
|
352
378
|
};
|
|
353
379
|
type LayerBrowserState = {
|
|
@@ -436,4 +462,4 @@ type EnhancedLocationsResult<T extends TerrainLocation = TerrainLocation> = {
|
|
|
436
462
|
declare function createIconUrlMap(files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): IconUrlMapResult;
|
|
437
463
|
declare function enhanceLocationsWithIconUrls<T extends TerrainLocation = TerrainLocation>(locations: T[], files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): EnhancedLocationsResult<T>;
|
|
438
464
|
|
|
439
|
-
export { type BuildWynArchiveOptions, type Cleanup, type CreateIconUrlMapOptions, type DeepPartial, type EnhancedLocationsResult, type HeightSampler, type IconUrlEntry, type IconUrlMapResult, type LayerBrowserEntry, type LayerBrowserState, type LayerBrowserStore, type LayerToggleState, type LegendLayer, type LoadWynArchiveOptions, type LoadedWynFile, type LocationPickPayload, type LocationViewState, type MarkerSpriteStateStyle, type MarkerSpriteTheme, type MarkerStemGeometryShape, type MarkerStemStateStyle, type MarkerStemTheme, type MaskEditorHandle, type MaskEditorState, type MaskImage, type MaskStroke, type MaskStrokeMode, type MaskStrokePoint, type TerrainDataset, type TerrainHandle, type TerrainLegend, type TerrainLocation, type TerrainProjectFileEntry, type TerrainProjectMetadata, type TerrainProjectSnapshot, type TerrainProjectStore, type TerrainTheme, type TerrainThemeOverrides, type TerrainViewMode, type TerrainViewerHostHandle, type TerrainViewerHostOptions, type ViewerLifecycleState, type ViewerOverlayCustomButton, type ViewerOverlayHandle, type ViewerOverlayLoadingState, type ViewerOverlayOptions, type WynArchiveProgressEvent, applyHeightField, buildRimMesh, buildWynArchive, createHeightSampler, createIconUrlMap, createLayerBrowserStore, createMaskEditor, createProjectStore, createTerrainViewerHost, createViewerOverlay, enhanceLocationsWithIconUrls, getDefaultTerrainTheme, initTerrainViewer, loadWynArchive, loadWynArchiveFromArrayBuffer, loadWynArchiveFromFile, resolveTerrainTheme, sampleHeightValue };
|
|
465
|
+
export { type BuildWynArchiveOptions, type Cleanup, type CreateIconUrlMapOptions, type DeepPartial, type EnhancedLocationsResult, type HeightSampler, type IconUrlEntry, type IconUrlMapResult, type LayerBrowserEntry, type LayerBrowserState, type LayerBrowserStore, type LayerToggleState, type LegendLayer, type LoadWynArchiveOptions, type LoadedWynFile, type LocationPickPayload, type LocationViewState, type MarkerSpriteStateStyle, type MarkerSpriteTheme, type MarkerStemGeometryShape, type MarkerStemStateStyle, type MarkerStemTheme, type MaskEditorHandle, type MaskEditorState, type MaskImage, type MaskLegendLayer, type MaskStroke, type MaskStrokeMode, type MaskStrokePoint, type RenderResolution, type RenderScaleMode, type RgbaLegendLayer, type TerrainDataset, type TerrainHandle, type TerrainLegend, type TerrainLocation, type TerrainProjectFileEntry, type TerrainProjectMetadata, type TerrainProjectSnapshot, type TerrainProjectStore, type TerrainTheme, type TerrainThemeOverrides, type TerrainViewMode, type TerrainViewerHostHandle, type TerrainViewerHostOptions, type ViewerLifecycleState, type ViewerOverlayCustomButton, type ViewerOverlayHandle, type ViewerOverlayLoadingState, type ViewerOverlayOptions, type WynArchiveProgressEvent, applyHeightField, buildRimMesh, buildWynArchive, createHeightSampler, createIconUrlMap, createLayerBrowserStore, createMaskEditor, createProjectStore, createTerrainViewerHost, createViewerOverlay, enhanceLocationsWithIconUrls, getDefaultTerrainTheme, initTerrainViewer, loadWynArchive, loadWynArchiveFromArrayBuffer, loadWynArchiveFromFile, resolveTerrainTheme, sampleHeightValue };
|
package/dist/index.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ type TerrainLocation = {
|
|
|
82
82
|
id: string;
|
|
83
83
|
name?: string;
|
|
84
84
|
icon?: string;
|
|
85
|
+
description?: string;
|
|
85
86
|
showBorder?: boolean;
|
|
86
87
|
pixel: {
|
|
87
88
|
x: number;
|
|
@@ -114,11 +115,16 @@ type LocationPickPayload = {
|
|
|
114
115
|
};
|
|
115
116
|
};
|
|
116
117
|
type ViewerLifecycleState = 'initializing' | 'loading-textures' | 'building-geometry' | 'first-render' | 'stabilizing' | 'ready';
|
|
118
|
+
type RenderScaleMode = 'auto' | 'fixed';
|
|
117
119
|
type TerrainInitOptions = {
|
|
118
120
|
onReady?: () => void;
|
|
119
121
|
onLifecycleChange?: (state: ViewerLifecycleState) => void;
|
|
120
122
|
heightScale?: number;
|
|
121
123
|
waterLevelPercent?: number;
|
|
124
|
+
maxPixelRatio?: number;
|
|
125
|
+
renderScale?: number;
|
|
126
|
+
renderScaleMode?: RenderScaleMode;
|
|
127
|
+
onRenderSizeChange?: (payload: RenderResolution) => void;
|
|
122
128
|
layers?: LayerToggleState;
|
|
123
129
|
interactive?: boolean;
|
|
124
130
|
onLocationPick?: (payload: LocationPickPayload) => void;
|
|
@@ -127,11 +133,16 @@ type TerrainInitOptions = {
|
|
|
127
133
|
locations?: TerrainLocation[];
|
|
128
134
|
theme?: TerrainThemeOverrides;
|
|
129
135
|
cameraView?: LocationViewState;
|
|
136
|
+
preserveDrawingBuffer?: boolean;
|
|
130
137
|
};
|
|
131
138
|
type TerrainHandle = {
|
|
132
139
|
destroy: () => void;
|
|
133
140
|
updateLayers: (state: LayerToggleState) => Promise<void>;
|
|
134
141
|
setInteractiveMode: (enabled: boolean) => void;
|
|
142
|
+
setMaxPixelRatio: (value: number) => void;
|
|
143
|
+
setRenderScaleMode: (mode: RenderScaleMode, scale?: number) => void;
|
|
144
|
+
setRenderPaused: (paused: boolean) => void;
|
|
145
|
+
getRenderResolution: () => RenderResolution;
|
|
135
146
|
updateLocations: (locations: TerrainLocation[], focusedId?: string) => void;
|
|
136
147
|
setFocusedLocation: (locationId?: string | null) => void;
|
|
137
148
|
navigateTo: (payload: {
|
|
@@ -154,6 +165,7 @@ type TerrainHandle = {
|
|
|
154
165
|
getViewState: () => LocationViewState;
|
|
155
166
|
setTheme: (overrides?: TerrainThemeOverrides) => void;
|
|
156
167
|
setSeaLevel: (level: number) => void;
|
|
168
|
+
setHeightScale: (scale: number) => void;
|
|
157
169
|
invalidateIconTextures: (paths?: string[]) => void;
|
|
158
170
|
invalidateLayerMasks: (paths?: string[]) => void;
|
|
159
171
|
enableFrameCaptureMode: (fps?: number) => {
|
|
@@ -166,18 +178,30 @@ type TerrainHandle = {
|
|
|
166
178
|
};
|
|
167
179
|
};
|
|
168
180
|
type Cleanup = () => void;
|
|
181
|
+
type RenderResolution = {
|
|
182
|
+
width: number;
|
|
183
|
+
height: number;
|
|
184
|
+
pixelRatio: number;
|
|
185
|
+
renderScale: number;
|
|
186
|
+
};
|
|
169
187
|
type Resolvable<T> = T | Promise<T>;
|
|
170
|
-
type
|
|
188
|
+
type MaskLegendLayer = {
|
|
171
189
|
mask: string;
|
|
172
190
|
rgb: [number, number, number];
|
|
173
191
|
label?: string;
|
|
174
192
|
};
|
|
193
|
+
type RgbaLegendLayer = {
|
|
194
|
+
rgba: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
};
|
|
197
|
+
type LegendLayer = MaskLegendLayer | RgbaLegendLayer;
|
|
175
198
|
type TerrainLegend = {
|
|
176
199
|
size: [number, number];
|
|
177
200
|
sea_level?: number;
|
|
201
|
+
height_scale?: number;
|
|
178
202
|
heightmap: string;
|
|
179
203
|
topology?: string;
|
|
180
|
-
biomes: Record<string,
|
|
204
|
+
biomes: Record<string, MaskLegendLayer>;
|
|
181
205
|
overlays: Record<string, LegendLayer>;
|
|
182
206
|
};
|
|
183
207
|
type TerrainDataset = {
|
|
@@ -346,8 +370,10 @@ type LayerBrowserEntry = {
|
|
|
346
370
|
id: string;
|
|
347
371
|
kind: 'biome' | 'overlay';
|
|
348
372
|
label: string;
|
|
349
|
-
mask
|
|
373
|
+
mask?: string;
|
|
374
|
+
rgba?: string;
|
|
350
375
|
color: [number, number, number];
|
|
376
|
+
icon?: string;
|
|
351
377
|
visible: boolean;
|
|
352
378
|
};
|
|
353
379
|
type LayerBrowserState = {
|
|
@@ -436,4 +462,4 @@ type EnhancedLocationsResult<T extends TerrainLocation = TerrainLocation> = {
|
|
|
436
462
|
declare function createIconUrlMap(files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): IconUrlMapResult;
|
|
437
463
|
declare function enhanceLocationsWithIconUrls<T extends TerrainLocation = TerrainLocation>(locations: T[], files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): EnhancedLocationsResult<T>;
|
|
438
464
|
|
|
439
|
-
export { type BuildWynArchiveOptions, type Cleanup, type CreateIconUrlMapOptions, type DeepPartial, type EnhancedLocationsResult, type HeightSampler, type IconUrlEntry, type IconUrlMapResult, type LayerBrowserEntry, type LayerBrowserState, type LayerBrowserStore, type LayerToggleState, type LegendLayer, type LoadWynArchiveOptions, type LoadedWynFile, type LocationPickPayload, type LocationViewState, type MarkerSpriteStateStyle, type MarkerSpriteTheme, type MarkerStemGeometryShape, type MarkerStemStateStyle, type MarkerStemTheme, type MaskEditorHandle, type MaskEditorState, type MaskImage, type MaskStroke, type MaskStrokeMode, type MaskStrokePoint, type TerrainDataset, type TerrainHandle, type TerrainLegend, type TerrainLocation, type TerrainProjectFileEntry, type TerrainProjectMetadata, type TerrainProjectSnapshot, type TerrainProjectStore, type TerrainTheme, type TerrainThemeOverrides, type TerrainViewMode, type TerrainViewerHostHandle, type TerrainViewerHostOptions, type ViewerLifecycleState, type ViewerOverlayCustomButton, type ViewerOverlayHandle, type ViewerOverlayLoadingState, type ViewerOverlayOptions, type WynArchiveProgressEvent, applyHeightField, buildRimMesh, buildWynArchive, createHeightSampler, createIconUrlMap, createLayerBrowserStore, createMaskEditor, createProjectStore, createTerrainViewerHost, createViewerOverlay, enhanceLocationsWithIconUrls, getDefaultTerrainTheme, initTerrainViewer, loadWynArchive, loadWynArchiveFromArrayBuffer, loadWynArchiveFromFile, resolveTerrainTheme, sampleHeightValue };
|
|
465
|
+
export { type BuildWynArchiveOptions, type Cleanup, type CreateIconUrlMapOptions, type DeepPartial, type EnhancedLocationsResult, type HeightSampler, type IconUrlEntry, type IconUrlMapResult, type LayerBrowserEntry, type LayerBrowserState, type LayerBrowserStore, type LayerToggleState, type LegendLayer, type LoadWynArchiveOptions, type LoadedWynFile, type LocationPickPayload, type LocationViewState, type MarkerSpriteStateStyle, type MarkerSpriteTheme, type MarkerStemGeometryShape, type MarkerStemStateStyle, type MarkerStemTheme, type MaskEditorHandle, type MaskEditorState, type MaskImage, type MaskLegendLayer, type MaskStroke, type MaskStrokeMode, type MaskStrokePoint, type RenderResolution, type RenderScaleMode, type RgbaLegendLayer, type TerrainDataset, type TerrainHandle, type TerrainLegend, type TerrainLocation, type TerrainProjectFileEntry, type TerrainProjectMetadata, type TerrainProjectSnapshot, type TerrainProjectStore, type TerrainTheme, type TerrainThemeOverrides, type TerrainViewMode, type TerrainViewerHostHandle, type TerrainViewerHostOptions, type ViewerLifecycleState, type ViewerOverlayCustomButton, type ViewerOverlayHandle, type ViewerOverlayLoadingState, type ViewerOverlayOptions, type WynArchiveProgressEvent, applyHeightField, buildRimMesh, buildWynArchive, createHeightSampler, createIconUrlMap, createLayerBrowserStore, createMaskEditor, createProjectStore, createTerrainViewerHost, createViewerOverlay, enhanceLocationsWithIconUrls, getDefaultTerrainTheme, initTerrainViewer, loadWynArchive, loadWynArchiveFromArrayBuffer, loadWynArchiveFromFile, resolveTerrainTheme, sampleHeightValue };
|
package/dist/index.js
CHANGED
|
@@ -345,6 +345,9 @@ async function preprocessMask(file, resolveAssetUrl, imageCache, maskCache) {
|
|
|
345
345
|
function hexFromRgb(rgb) {
|
|
346
346
|
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
347
347
|
}
|
|
348
|
+
function isRgbaLayer(layer) {
|
|
349
|
+
return typeof layer.rgba === "string";
|
|
350
|
+
}
|
|
348
351
|
async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, maskCache, layerState) {
|
|
349
352
|
if (typeof document === "undefined") return null;
|
|
350
353
|
const [width, height] = legendData.size;
|
|
@@ -381,6 +384,17 @@ async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, mas
|
|
|
381
384
|
ctx.globalAlpha = alpha;
|
|
382
385
|
ctx.drawImage(temp, 0, 0, width, height);
|
|
383
386
|
}
|
|
387
|
+
async function drawRgbaLayer(textureFile, alpha = 1) {
|
|
388
|
+
let image = null;
|
|
389
|
+
try {
|
|
390
|
+
image = await loadLegendImage(textureFile, resolveAssetUrl, imageCache);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.warn("[WynnalTerrain] Unable to load overlay texture", textureFile, err);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
ctx.globalAlpha = alpha;
|
|
396
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
397
|
+
}
|
|
384
398
|
const activeBiomes = layerState?.biomes ?? {};
|
|
385
399
|
for (const [name, biome] of Object.entries(legendData.biomes)) {
|
|
386
400
|
if (layerState && activeBiomes[name] === false) continue;
|
|
@@ -390,6 +404,14 @@ async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, mas
|
|
|
390
404
|
for (const [name, overlay] of Object.entries(legendData.overlays)) {
|
|
391
405
|
if (layerState && activeOverlays[name] === false) continue;
|
|
392
406
|
const alpha = DEFAULT_LAYER_ALPHA[name] ?? 1;
|
|
407
|
+
if (isRgbaLayer(overlay)) {
|
|
408
|
+
await drawRgbaLayer(overlay.rgba, alpha);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!overlay.mask || !overlay.rgb) {
|
|
412
|
+
console.warn("[WynnalTerrain] Overlay entry missing mask/rgb", name);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
393
415
|
await drawLayer(overlay.mask, overlay.rgb, alpha);
|
|
394
416
|
}
|
|
395
417
|
const texture = new THREE2.CanvasTexture(canvas);
|
|
@@ -759,6 +781,15 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
759
781
|
updateLayers: async () => {
|
|
760
782
|
},
|
|
761
783
|
setInteractiveMode: noop2,
|
|
784
|
+
setMaxPixelRatio: noop2,
|
|
785
|
+
setRenderScaleMode: noop2,
|
|
786
|
+
setRenderPaused: noop2,
|
|
787
|
+
getRenderResolution: () => ({
|
|
788
|
+
width: 0,
|
|
789
|
+
height: 0,
|
|
790
|
+
pixelRatio: 1,
|
|
791
|
+
renderScale: 1
|
|
792
|
+
}),
|
|
762
793
|
updateLocations: noop2,
|
|
763
794
|
setFocusedLocation: noop2,
|
|
764
795
|
navigateTo: noop2,
|
|
@@ -771,6 +802,8 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
771
802
|
},
|
|
772
803
|
setSeaLevel: () => {
|
|
773
804
|
},
|
|
805
|
+
setHeightScale: () => {
|
|
806
|
+
},
|
|
774
807
|
invalidateIconTextures: () => {
|
|
775
808
|
},
|
|
776
809
|
invalidateLayerMasks: () => {
|
|
@@ -855,7 +888,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
855
888
|
const { u, v } = pixelToUV(pixel);
|
|
856
889
|
return uvToWorld(u, v, heightSampler, currentHeightScale, seaLevel, terrainDimensions);
|
|
857
890
|
}
|
|
858
|
-
const heightScale = options.heightScale ?? HEIGHT_SCALE_DEFAULT;
|
|
891
|
+
const heightScale = options.heightScale ?? legend.height_scale ?? HEIGHT_SCALE_DEFAULT;
|
|
859
892
|
let currentHeightScale = heightScale;
|
|
860
893
|
const waterPercent = THREE2.MathUtils.clamp(
|
|
861
894
|
options.waterLevelPercent ?? WATER_PERCENT_DEFAULT,
|
|
@@ -888,10 +921,28 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
888
921
|
let viewportWidth = width;
|
|
889
922
|
let viewportHeight = height;
|
|
890
923
|
let viewOffsetPixels = 0;
|
|
891
|
-
|
|
924
|
+
let maxPixelRatio = Math.max(1, options.maxPixelRatio ?? 1.5);
|
|
925
|
+
const minRenderScale = 0.25;
|
|
926
|
+
let renderScaleMode = options.renderScaleMode ?? "fixed";
|
|
927
|
+
let renderScale = clampRenderScale(options.renderScale ?? 1);
|
|
928
|
+
let currentPixelRatio = 1;
|
|
929
|
+
function clampRenderScale(value) {
|
|
930
|
+
return THREE2.MathUtils.clamp(value, minRenderScale, 1);
|
|
931
|
+
}
|
|
932
|
+
function resolvePixelRatio() {
|
|
933
|
+
const deviceRatio = window.devicePixelRatio || 1;
|
|
934
|
+
const scaledRatio = deviceRatio * renderScale;
|
|
935
|
+
return Math.min(scaledRatio, maxPixelRatio);
|
|
936
|
+
}
|
|
937
|
+
const renderer = new THREE2.WebGLRenderer({
|
|
938
|
+
antialias: true,
|
|
939
|
+
alpha: true,
|
|
940
|
+
preserveDrawingBuffer: options.preserveDrawingBuffer ?? false
|
|
941
|
+
});
|
|
892
942
|
renderer.toneMapping = THREE2.ACESFilmicToneMapping;
|
|
893
943
|
renderer.toneMappingExposure = 1.08;
|
|
894
|
-
|
|
944
|
+
currentPixelRatio = resolvePixelRatio();
|
|
945
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
895
946
|
const hostStyle = window.getComputedStyle(container);
|
|
896
947
|
if (hostStyle.position === "static") {
|
|
897
948
|
container.style.position = "relative";
|
|
@@ -908,6 +959,22 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
908
959
|
renderer.shadowMap.enabled = true;
|
|
909
960
|
renderer.shadowMap.type = THREE2.PCFSoftShadowMap;
|
|
910
961
|
container.appendChild(renderer.domElement);
|
|
962
|
+
const renderResolution = {
|
|
963
|
+
width: 0,
|
|
964
|
+
height: 0,
|
|
965
|
+
pixelRatio: currentPixelRatio,
|
|
966
|
+
renderScale
|
|
967
|
+
};
|
|
968
|
+
function updateRenderResolution() {
|
|
969
|
+
const bufferSize = new THREE2.Vector2();
|
|
970
|
+
renderer.getDrawingBufferSize(bufferSize);
|
|
971
|
+
renderResolution.width = Math.round(bufferSize.x);
|
|
972
|
+
renderResolution.height = Math.round(bufferSize.y);
|
|
973
|
+
renderResolution.pixelRatio = currentPixelRatio;
|
|
974
|
+
renderResolution.renderScale = renderScale;
|
|
975
|
+
options.onRenderSizeChange?.({ ...renderResolution });
|
|
976
|
+
}
|
|
977
|
+
updateRenderResolution();
|
|
911
978
|
disposables.push(() => {
|
|
912
979
|
renderer.dispose();
|
|
913
980
|
renderer.domElement.remove();
|
|
@@ -976,19 +1043,42 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
976
1043
|
}
|
|
977
1044
|
const locationWorldCache = /* @__PURE__ */ new Map();
|
|
978
1045
|
let markerGeneration = 0;
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
1046
|
+
const placementIndicatorHeight = 0.26;
|
|
1047
|
+
const placementIndicatorGroup = new THREE2.Group();
|
|
1048
|
+
const placementIndicatorMaterial = new THREE2.MeshStandardMaterial({
|
|
1049
|
+
color: 16735067,
|
|
1050
|
+
transparent: true,
|
|
1051
|
+
opacity: 0.85
|
|
1052
|
+
});
|
|
1053
|
+
const placementStem = new THREE2.Mesh(
|
|
1054
|
+
new THREE2.CylinderGeometry(0.01, 0.01, 0.06, 8),
|
|
1055
|
+
placementIndicatorMaterial
|
|
1056
|
+
);
|
|
1057
|
+
placementStem.position.y = 0.03;
|
|
1058
|
+
const placementDot = new THREE2.Mesh(
|
|
1059
|
+
new THREE2.SphereGeometry(0.014, 10, 10),
|
|
1060
|
+
placementIndicatorMaterial
|
|
1061
|
+
);
|
|
1062
|
+
placementDot.position.y = 5e-3;
|
|
1063
|
+
const placementRing = new THREE2.Mesh(
|
|
1064
|
+
new THREE2.CircleGeometry(0.12, 32),
|
|
1065
|
+
new THREE2.MeshBasicMaterial({
|
|
982
1066
|
color: 16735067,
|
|
983
1067
|
transparent: true,
|
|
984
|
-
opacity: 0.
|
|
1068
|
+
opacity: 0.2
|
|
985
1069
|
})
|
|
986
1070
|
);
|
|
987
|
-
|
|
988
|
-
|
|
1071
|
+
placementRing.rotation.x = -Math.PI / 2;
|
|
1072
|
+
placementRing.position.y = 5e-3;
|
|
1073
|
+
placementIndicatorGroup.add(placementRing, placementStem, placementDot);
|
|
1074
|
+
placementIndicatorGroup.visible = false;
|
|
1075
|
+
scene.add(placementIndicatorGroup);
|
|
989
1076
|
disposables.push(() => {
|
|
990
|
-
|
|
991
|
-
|
|
1077
|
+
placementStem.geometry.dispose();
|
|
1078
|
+
placementDot.geometry.dispose();
|
|
1079
|
+
placementRing.geometry.dispose();
|
|
1080
|
+
placementIndicatorMaterial.dispose();
|
|
1081
|
+
placementRing.material.dispose();
|
|
992
1082
|
});
|
|
993
1083
|
const loader = new THREE2.TextureLoader();
|
|
994
1084
|
const iconTextureCache = /* @__PURE__ */ new Map();
|
|
@@ -1214,6 +1304,31 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1214
1304
|
stemRadius = computeStemRadius(markerTheme.stem);
|
|
1215
1305
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
1216
1306
|
}
|
|
1307
|
+
function applyHeightScaleUpdate(nextHeightScale) {
|
|
1308
|
+
if (!heightSampler) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const prevHeightScale = currentHeightScale;
|
|
1312
|
+
const nextScale = Math.max(0, nextHeightScale);
|
|
1313
|
+
currentHeightScale = nextScale;
|
|
1314
|
+
legend.height_scale = nextScale;
|
|
1315
|
+
dataset.legend.height_scale = nextScale;
|
|
1316
|
+
const stats = applyHeightField(terrainGeometry, heightSampler, {
|
|
1317
|
+
seaLevel,
|
|
1318
|
+
heightScale: currentHeightScale
|
|
1319
|
+
});
|
|
1320
|
+
terrainHeightRange = { min: stats.minY, max: stats.maxY };
|
|
1321
|
+
terrainGeometry.attributes.position.needsUpdate = true;
|
|
1322
|
+
terrainGeometry.computeVertexNormals();
|
|
1323
|
+
rebuildRimMesh();
|
|
1324
|
+
rebuildOceanMesh();
|
|
1325
|
+
if (Number.isFinite(prevHeightScale) && prevHeightScale > 0) {
|
|
1326
|
+
const factor = nextScale / prevHeightScale;
|
|
1327
|
+
controls.target.y *= factor;
|
|
1328
|
+
camera.position.y *= factor;
|
|
1329
|
+
}
|
|
1330
|
+
setLocationMarkers(currentLocations, currentFocusId);
|
|
1331
|
+
}
|
|
1217
1332
|
function applySeaLevelUpdate(nextSeaLevel) {
|
|
1218
1333
|
if (!heightSampler) {
|
|
1219
1334
|
return;
|
|
@@ -1340,6 +1455,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1340
1455
|
camera.updateProjectionMatrix();
|
|
1341
1456
|
}
|
|
1342
1457
|
let animationFrame = 0;
|
|
1458
|
+
let renderPaused = false;
|
|
1343
1459
|
let frameCount = 0;
|
|
1344
1460
|
let firstRenderComplete = false;
|
|
1345
1461
|
const frameTimings = [];
|
|
@@ -1397,19 +1513,32 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1397
1513
|
if (currentLifecycleState === "stabilizing" && stabilizingStartTime !== null) {
|
|
1398
1514
|
const stabilizingDuration = now - stabilizingStartTime;
|
|
1399
1515
|
if (stabilizingDuration > STABILITY_TIMEOUT_MS) {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1516
|
+
if (renderScaleMode === "auto" && renderScale > minRenderScale + 0.01) {
|
|
1517
|
+
renderScale = clampRenderScale(Number((renderScale * 0.6).toFixed(2)));
|
|
1518
|
+
currentPixelRatio = resolvePixelRatio();
|
|
1519
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1520
|
+
renderer.setSize(viewportWidth, viewportHeight, false);
|
|
1521
|
+
updateRenderResolution();
|
|
1522
|
+
frameTimings.length = 0;
|
|
1523
|
+
stabilizingStartTime = now;
|
|
1524
|
+
console.warn(
|
|
1525
|
+
`[terrainViewer] Framerate unstable after ${STABILITY_TIMEOUT_MS}ms, reducing render scale to ${renderScale}`
|
|
1526
|
+
);
|
|
1527
|
+
} else {
|
|
1528
|
+
console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
|
|
1529
|
+
setLifecycleState("ready");
|
|
1530
|
+
stabilizingStartTime = null;
|
|
1531
|
+
}
|
|
1403
1532
|
}
|
|
1404
1533
|
}
|
|
1405
1534
|
}
|
|
1406
|
-
if (!frameCaptureMode) {
|
|
1535
|
+
if (!frameCaptureMode && !renderPaused) {
|
|
1407
1536
|
animationFrame = window.requestAnimationFrame(animate);
|
|
1408
1537
|
}
|
|
1409
1538
|
}
|
|
1410
1539
|
animate();
|
|
1411
1540
|
options.onReady?.();
|
|
1412
|
-
|
|
1541
|
+
function handleResize() {
|
|
1413
1542
|
const { clientWidth, clientHeight } = container;
|
|
1414
1543
|
if (!clientWidth || !clientHeight) return;
|
|
1415
1544
|
viewportWidth = clientWidth;
|
|
@@ -1418,8 +1547,17 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1418
1547
|
viewOffsetPixels = shiftTarget;
|
|
1419
1548
|
camera.aspect = clientWidth / clientHeight;
|
|
1420
1549
|
camera.updateProjectionMatrix();
|
|
1550
|
+
const nextPixelRatio = resolvePixelRatio();
|
|
1551
|
+
if (nextPixelRatio !== currentPixelRatio) {
|
|
1552
|
+
currentPixelRatio = nextPixelRatio;
|
|
1553
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1554
|
+
}
|
|
1421
1555
|
renderer.setSize(clientWidth, clientHeight, false);
|
|
1422
1556
|
applyViewOffset();
|
|
1557
|
+
updateRenderResolution();
|
|
1558
|
+
}
|
|
1559
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1560
|
+
handleResize();
|
|
1423
1561
|
});
|
|
1424
1562
|
resizeObserver.observe(container);
|
|
1425
1563
|
function setPointerFromEvent(event) {
|
|
@@ -1444,22 +1582,26 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1444
1582
|
const markerId = pickMarkerId();
|
|
1445
1583
|
if (markerId !== hoveredLocationId) {
|
|
1446
1584
|
hoveredLocationId = markerId;
|
|
1585
|
+
const hoveredLocation = markerId ? currentLocations.find((location) => location.id === markerId) : void 0;
|
|
1586
|
+
renderer.domElement.title = hoveredLocation?.name || hoveredLocation?.id || "";
|
|
1447
1587
|
options.onLocationHover?.(markerId);
|
|
1448
1588
|
updateMarkerVisuals();
|
|
1449
1589
|
}
|
|
1450
1590
|
}
|
|
1451
1591
|
function updatePlacementIndicator() {
|
|
1452
1592
|
if (!interactiveEnabled) {
|
|
1453
|
-
|
|
1593
|
+
placementIndicatorGroup.visible = false;
|
|
1454
1594
|
return;
|
|
1455
1595
|
}
|
|
1456
1596
|
const hit = intersectTerrain();
|
|
1457
1597
|
if (!hit) {
|
|
1458
|
-
|
|
1598
|
+
placementIndicatorGroup.visible = false;
|
|
1459
1599
|
return;
|
|
1460
1600
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1601
|
+
placementIndicatorGroup.visible = true;
|
|
1602
|
+
const target = hit.point.clone();
|
|
1603
|
+
target.y += 0.02;
|
|
1604
|
+
placementIndicatorGroup.position.copy(target);
|
|
1463
1605
|
}
|
|
1464
1606
|
function handlePointerMove(event) {
|
|
1465
1607
|
setPointerFromEvent(event);
|
|
@@ -1518,7 +1660,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1518
1660
|
renderer.domElement.classList.add("terrain-pointer-active");
|
|
1519
1661
|
} else {
|
|
1520
1662
|
renderer.domElement.classList.remove("terrain-pointer-active");
|
|
1521
|
-
|
|
1663
|
+
placementIndicatorGroup.visible = false;
|
|
1522
1664
|
}
|
|
1523
1665
|
}
|
|
1524
1666
|
async function updateLayers(state) {
|
|
@@ -1588,6 +1730,33 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1588
1730
|
},
|
|
1589
1731
|
updateLayers,
|
|
1590
1732
|
setInteractiveMode,
|
|
1733
|
+
setMaxPixelRatio: (value) => {
|
|
1734
|
+
maxPixelRatio = Math.max(1, value);
|
|
1735
|
+
handleResize();
|
|
1736
|
+
},
|
|
1737
|
+
setRenderScaleMode: (mode, scale) => {
|
|
1738
|
+
renderScaleMode = mode;
|
|
1739
|
+
if (typeof scale === "number") {
|
|
1740
|
+
renderScale = clampRenderScale(scale);
|
|
1741
|
+
} else if (renderScaleMode === "auto") {
|
|
1742
|
+
renderScale = clampRenderScale(renderScale);
|
|
1743
|
+
}
|
|
1744
|
+
currentPixelRatio = resolvePixelRatio();
|
|
1745
|
+
renderer.setPixelRatio(currentPixelRatio);
|
|
1746
|
+
renderer.setSize(viewportWidth, viewportHeight, false);
|
|
1747
|
+
updateRenderResolution();
|
|
1748
|
+
frameTimings.length = 0;
|
|
1749
|
+
stabilizingStartTime = null;
|
|
1750
|
+
},
|
|
1751
|
+
setRenderPaused: (paused) => {
|
|
1752
|
+
renderPaused = paused;
|
|
1753
|
+
if (paused) {
|
|
1754
|
+
window.cancelAnimationFrame(animationFrame);
|
|
1755
|
+
} else if (!frameCaptureMode) {
|
|
1756
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1757
|
+
}
|
|
1758
|
+
},
|
|
1759
|
+
getRenderResolution: () => ({ ...renderResolution }),
|
|
1591
1760
|
updateLocations: (locations, focusedId) => {
|
|
1592
1761
|
setLocationMarkers(locations, focusedId);
|
|
1593
1762
|
},
|
|
@@ -1654,6 +1823,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1654
1823
|
},
|
|
1655
1824
|
setTheme: applyThemeUpdate,
|
|
1656
1825
|
setSeaLevel: applySeaLevelUpdate,
|
|
1826
|
+
setHeightScale: applyHeightScaleUpdate,
|
|
1657
1827
|
invalidateIconTextures: (paths) => {
|
|
1658
1828
|
invalidateIconCache(paths);
|
|
1659
1829
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
@@ -1672,7 +1842,9 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1672
1842
|
},
|
|
1673
1843
|
disableFrameCaptureMode: () => {
|
|
1674
1844
|
frameCaptureMode = false;
|
|
1675
|
-
|
|
1845
|
+
if (!renderPaused) {
|
|
1846
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1847
|
+
}
|
|
1676
1848
|
},
|
|
1677
1849
|
captureFrame: (frameNumber, fps = 30) => {
|
|
1678
1850
|
if (!frameCaptureMode) {
|
|
@@ -2900,12 +3072,16 @@ function buildEntries(legend, visibility) {
|
|
|
2900
3072
|
}
|
|
2901
3073
|
for (const [key, layer] of Object.entries(legend.overlays ?? {})) {
|
|
2902
3074
|
const id = makeLayerId("overlay", key);
|
|
3075
|
+
const color = "rgb" in layer && Array.isArray(layer.rgb) ? layer.rgb : [255, 255, 255];
|
|
3076
|
+
const icon = "rgba" in layer ? "image" : void 0;
|
|
2903
3077
|
entries.push({
|
|
2904
3078
|
id,
|
|
2905
3079
|
kind: "overlay",
|
|
2906
3080
|
label: layer.label ?? key,
|
|
2907
|
-
mask: layer.mask,
|
|
2908
|
-
|
|
3081
|
+
mask: "mask" in layer ? layer.mask : void 0,
|
|
3082
|
+
rgba: "rgba" in layer ? layer.rgba : void 0,
|
|
3083
|
+
color,
|
|
3084
|
+
icon,
|
|
2909
3085
|
visible: visibility.get(id) ?? true
|
|
2910
3086
|
});
|
|
2911
3087
|
}
|
|
@@ -48,7 +48,8 @@ Schema: `schemas/legend.schema.json`
|
|
|
48
48
|
"forest": { "mask": "layers/forest_mask.png", "rgb": [48, 92, 54], "label": "Forest" }
|
|
49
49
|
},
|
|
50
50
|
"overlays": {
|
|
51
|
-
"water": { "mask": "layers/water_mask.png", "rgb": [34, 92, 124], "label": "Water" }
|
|
51
|
+
"water": { "mask": "layers/water_mask.png", "rgb": [34, 92, 124], "label": "Water" },
|
|
52
|
+
"texture": { "rgba": "layers/atlas-texture.png", "label": "Texture" }
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
```
|
|
@@ -63,12 +64,18 @@ Schema:
|
|
|
63
64
|
- `biomes`: Record of biome layers. Keys are layer ids used by the editor/viewer.
|
|
64
65
|
- `overlays`: Record of overlay layers. Keys are layer ids used by the editor/viewer.
|
|
65
66
|
|
|
66
|
-
Each layer entry:
|
|
67
|
+
Each layer entry (biomes + mask-based overlays):
|
|
67
68
|
|
|
68
69
|
- `mask`: Path to a PNG mask. The viewer converts the max RGB channel into alpha.
|
|
69
70
|
- `rgb`: `[r, g, b]` integers (0-255) used to colorize the legend composite.
|
|
70
71
|
- `label`: Optional display name in the editor UI.
|
|
71
72
|
|
|
73
|
+
Overlay texture entries (overlays only):
|
|
74
|
+
|
|
75
|
+
- `rgba`: Path to a full-color PNG/WebP/etc. The viewer draws the image directly using the
|
|
76
|
+
image alpha channel.
|
|
77
|
+
- `label`: Optional display name in the editor UI.
|
|
78
|
+
|
|
72
79
|
## locations.json (optional)
|
|
73
80
|
|
|
74
81
|
Array of location markers. Coordinates are expressed in legend pixel space.
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
},
|
|
31
31
|
"biomes": {
|
|
32
32
|
"type": "object",
|
|
33
|
-
"additionalProperties": { "$ref": "#/definitions/
|
|
33
|
+
"additionalProperties": { "$ref": "#/definitions/maskLayer" }
|
|
34
34
|
},
|
|
35
35
|
"overlays": {
|
|
36
36
|
"type": "object",
|
|
37
|
-
"additionalProperties": { "$ref": "#/definitions/
|
|
37
|
+
"additionalProperties": { "$ref": "#/definitions/overlayLayer" }
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"definitions": {
|
|
41
|
-
"
|
|
41
|
+
"maskLayer": {
|
|
42
42
|
"type": "object",
|
|
43
43
|
"additionalProperties": false,
|
|
44
44
|
"required": ["mask", "rgb"],
|
|
@@ -61,6 +61,26 @@
|
|
|
61
61
|
"type": "string"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
},
|
|
65
|
+
"rgbaLayer": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": false,
|
|
68
|
+
"required": ["rgba"],
|
|
69
|
+
"properties": {
|
|
70
|
+
"rgba": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"minLength": 1
|
|
73
|
+
},
|
|
74
|
+
"label": {
|
|
75
|
+
"type": "string"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"overlayLayer": {
|
|
80
|
+
"oneOf": [
|
|
81
|
+
{ "$ref": "#/definitions/maskLayer" },
|
|
82
|
+
{ "$ref": "#/definitions/rgbaLayer" }
|
|
83
|
+
]
|
|
64
84
|
}
|
|
65
85
|
}
|
|
66
86
|
}
|