@connected-web/terrain-editor 0.1.5 → 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 +189 -26
- package/dist/index.d.cts +28 -4
- package/dist/index.d.ts +28 -4
- package/dist/index.js +189 -26
- 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,12 +974,24 @@ 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);
|
|
945
981
|
let currentPixelRatio = 1;
|
|
946
|
-
function
|
|
947
|
-
return
|
|
982
|
+
function clampRenderScale(value) {
|
|
983
|
+
return THREE2.MathUtils.clamp(value, minRenderScale, 1);
|
|
948
984
|
}
|
|
949
|
-
|
|
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
|
+
});
|
|
950
995
|
renderer.toneMapping = THREE2.ACESFilmicToneMapping;
|
|
951
996
|
renderer.toneMappingExposure = 1.08;
|
|
952
997
|
currentPixelRatio = resolvePixelRatio();
|
|
@@ -967,6 +1012,22 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
967
1012
|
renderer.shadowMap.enabled = true;
|
|
968
1013
|
renderer.shadowMap.type = THREE2.PCFSoftShadowMap;
|
|
969
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();
|
|
970
1031
|
disposables.push(() => {
|
|
971
1032
|
renderer.dispose();
|
|
972
1033
|
renderer.domElement.remove();
|
|
@@ -1035,19 +1096,42 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1035
1096
|
}
|
|
1036
1097
|
const locationWorldCache = /* @__PURE__ */ new Map();
|
|
1037
1098
|
let markerGeneration = 0;
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
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({
|
|
1041
1119
|
color: 16735067,
|
|
1042
1120
|
transparent: true,
|
|
1043
|
-
opacity: 0.
|
|
1121
|
+
opacity: 0.2
|
|
1044
1122
|
})
|
|
1045
1123
|
);
|
|
1046
|
-
|
|
1047
|
-
|
|
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);
|
|
1048
1129
|
disposables.push(() => {
|
|
1049
|
-
|
|
1050
|
-
|
|
1130
|
+
placementStem.geometry.dispose();
|
|
1131
|
+
placementDot.geometry.dispose();
|
|
1132
|
+
placementRing.geometry.dispose();
|
|
1133
|
+
placementIndicatorMaterial.dispose();
|
|
1134
|
+
placementRing.material.dispose();
|
|
1051
1135
|
});
|
|
1052
1136
|
const loader = new THREE2.TextureLoader();
|
|
1053
1137
|
const iconTextureCache = /* @__PURE__ */ new Map();
|
|
@@ -1273,6 +1357,31 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1273
1357
|
stemRadius = computeStemRadius(markerTheme.stem);
|
|
1274
1358
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
1275
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
|
+
}
|
|
1276
1385
|
function applySeaLevelUpdate(nextSeaLevel) {
|
|
1277
1386
|
if (!heightSampler) {
|
|
1278
1387
|
return;
|
|
@@ -1399,6 +1508,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1399
1508
|
camera.updateProjectionMatrix();
|
|
1400
1509
|
}
|
|
1401
1510
|
let animationFrame = 0;
|
|
1511
|
+
let renderPaused = false;
|
|
1402
1512
|
let frameCount = 0;
|
|
1403
1513
|
let firstRenderComplete = false;
|
|
1404
1514
|
const frameTimings = [];
|
|
@@ -1456,19 +1566,32 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1456
1566
|
if (currentLifecycleState === "stabilizing" && stabilizingStartTime !== null) {
|
|
1457
1567
|
const stabilizingDuration = now - stabilizingStartTime;
|
|
1458
1568
|
if (stabilizingDuration > STABILITY_TIMEOUT_MS) {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
+
}
|
|
1462
1585
|
}
|
|
1463
1586
|
}
|
|
1464
1587
|
}
|
|
1465
|
-
if (!frameCaptureMode) {
|
|
1588
|
+
if (!frameCaptureMode && !renderPaused) {
|
|
1466
1589
|
animationFrame = window.requestAnimationFrame(animate);
|
|
1467
1590
|
}
|
|
1468
1591
|
}
|
|
1469
1592
|
animate();
|
|
1470
1593
|
options.onReady?.();
|
|
1471
|
-
|
|
1594
|
+
function handleResize() {
|
|
1472
1595
|
const { clientWidth, clientHeight } = container;
|
|
1473
1596
|
if (!clientWidth || !clientHeight) return;
|
|
1474
1597
|
viewportWidth = clientWidth;
|
|
@@ -1484,6 +1607,10 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1484
1607
|
}
|
|
1485
1608
|
renderer.setSize(clientWidth, clientHeight, false);
|
|
1486
1609
|
applyViewOffset();
|
|
1610
|
+
updateRenderResolution();
|
|
1611
|
+
}
|
|
1612
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1613
|
+
handleResize();
|
|
1487
1614
|
});
|
|
1488
1615
|
resizeObserver.observe(container);
|
|
1489
1616
|
function setPointerFromEvent(event) {
|
|
@@ -1516,16 +1643,18 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1516
1643
|
}
|
|
1517
1644
|
function updatePlacementIndicator() {
|
|
1518
1645
|
if (!interactiveEnabled) {
|
|
1519
|
-
|
|
1646
|
+
placementIndicatorGroup.visible = false;
|
|
1520
1647
|
return;
|
|
1521
1648
|
}
|
|
1522
1649
|
const hit = intersectTerrain();
|
|
1523
1650
|
if (!hit) {
|
|
1524
|
-
|
|
1651
|
+
placementIndicatorGroup.visible = false;
|
|
1525
1652
|
return;
|
|
1526
1653
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1654
|
+
placementIndicatorGroup.visible = true;
|
|
1655
|
+
const target = hit.point.clone();
|
|
1656
|
+
target.y += 0.02;
|
|
1657
|
+
placementIndicatorGroup.position.copy(target);
|
|
1529
1658
|
}
|
|
1530
1659
|
function handlePointerMove(event) {
|
|
1531
1660
|
setPointerFromEvent(event);
|
|
@@ -1584,7 +1713,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1584
1713
|
renderer.domElement.classList.add("terrain-pointer-active");
|
|
1585
1714
|
} else {
|
|
1586
1715
|
renderer.domElement.classList.remove("terrain-pointer-active");
|
|
1587
|
-
|
|
1716
|
+
placementIndicatorGroup.visible = false;
|
|
1588
1717
|
}
|
|
1589
1718
|
}
|
|
1590
1719
|
async function updateLayers(state) {
|
|
@@ -1654,6 +1783,33 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1654
1783
|
},
|
|
1655
1784
|
updateLayers,
|
|
1656
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 }),
|
|
1657
1813
|
updateLocations: (locations, focusedId) => {
|
|
1658
1814
|
setLocationMarkers(locations, focusedId);
|
|
1659
1815
|
},
|
|
@@ -1720,6 +1876,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1720
1876
|
},
|
|
1721
1877
|
setTheme: applyThemeUpdate,
|
|
1722
1878
|
setSeaLevel: applySeaLevelUpdate,
|
|
1879
|
+
setHeightScale: applyHeightScaleUpdate,
|
|
1723
1880
|
invalidateIconTextures: (paths) => {
|
|
1724
1881
|
invalidateIconCache(paths);
|
|
1725
1882
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
@@ -1738,7 +1895,9 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1738
1895
|
},
|
|
1739
1896
|
disableFrameCaptureMode: () => {
|
|
1740
1897
|
frameCaptureMode = false;
|
|
1741
|
-
|
|
1898
|
+
if (!renderPaused) {
|
|
1899
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1900
|
+
}
|
|
1742
1901
|
},
|
|
1743
1902
|
captureFrame: (frameNumber, fps = 30) => {
|
|
1744
1903
|
if (!frameCaptureMode) {
|
|
@@ -2966,12 +3125,16 @@ function buildEntries(legend, visibility) {
|
|
|
2966
3125
|
}
|
|
2967
3126
|
for (const [key, layer] of Object.entries(legend.overlays ?? {})) {
|
|
2968
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;
|
|
2969
3130
|
entries.push({
|
|
2970
3131
|
id,
|
|
2971
3132
|
kind: "overlay",
|
|
2972
3133
|
label: layer.label ?? key,
|
|
2973
|
-
mask: layer.mask,
|
|
2974
|
-
|
|
3134
|
+
mask: "mask" in layer ? layer.mask : void 0,
|
|
3135
|
+
rgba: "rgba" in layer ? layer.rgba : void 0,
|
|
3136
|
+
color,
|
|
3137
|
+
icon,
|
|
2975
3138
|
visible: visibility.get(id) ?? true
|
|
2976
3139
|
});
|
|
2977
3140
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -115,12 +115,16 @@ type LocationPickPayload = {
|
|
|
115
115
|
};
|
|
116
116
|
};
|
|
117
117
|
type ViewerLifecycleState = 'initializing' | 'loading-textures' | 'building-geometry' | 'first-render' | 'stabilizing' | 'ready';
|
|
118
|
+
type RenderScaleMode = 'auto' | 'fixed';
|
|
118
119
|
type TerrainInitOptions = {
|
|
119
120
|
onReady?: () => void;
|
|
120
121
|
onLifecycleChange?: (state: ViewerLifecycleState) => void;
|
|
121
122
|
heightScale?: number;
|
|
122
123
|
waterLevelPercent?: number;
|
|
123
124
|
maxPixelRatio?: number;
|
|
125
|
+
renderScale?: number;
|
|
126
|
+
renderScaleMode?: RenderScaleMode;
|
|
127
|
+
onRenderSizeChange?: (payload: RenderResolution) => void;
|
|
124
128
|
layers?: LayerToggleState;
|
|
125
129
|
interactive?: boolean;
|
|
126
130
|
onLocationPick?: (payload: LocationPickPayload) => void;
|
|
@@ -129,11 +133,16 @@ type TerrainInitOptions = {
|
|
|
129
133
|
locations?: TerrainLocation[];
|
|
130
134
|
theme?: TerrainThemeOverrides;
|
|
131
135
|
cameraView?: LocationViewState;
|
|
136
|
+
preserveDrawingBuffer?: boolean;
|
|
132
137
|
};
|
|
133
138
|
type TerrainHandle = {
|
|
134
139
|
destroy: () => void;
|
|
135
140
|
updateLayers: (state: LayerToggleState) => Promise<void>;
|
|
136
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;
|
|
137
146
|
updateLocations: (locations: TerrainLocation[], focusedId?: string) => void;
|
|
138
147
|
setFocusedLocation: (locationId?: string | null) => void;
|
|
139
148
|
navigateTo: (payload: {
|
|
@@ -156,6 +165,7 @@ type TerrainHandle = {
|
|
|
156
165
|
getViewState: () => LocationViewState;
|
|
157
166
|
setTheme: (overrides?: TerrainThemeOverrides) => void;
|
|
158
167
|
setSeaLevel: (level: number) => void;
|
|
168
|
+
setHeightScale: (scale: number) => void;
|
|
159
169
|
invalidateIconTextures: (paths?: string[]) => void;
|
|
160
170
|
invalidateLayerMasks: (paths?: string[]) => void;
|
|
161
171
|
enableFrameCaptureMode: (fps?: number) => {
|
|
@@ -168,18 +178,30 @@ type TerrainHandle = {
|
|
|
168
178
|
};
|
|
169
179
|
};
|
|
170
180
|
type Cleanup = () => void;
|
|
181
|
+
type RenderResolution = {
|
|
182
|
+
width: number;
|
|
183
|
+
height: number;
|
|
184
|
+
pixelRatio: number;
|
|
185
|
+
renderScale: number;
|
|
186
|
+
};
|
|
171
187
|
type Resolvable<T> = T | Promise<T>;
|
|
172
|
-
type
|
|
188
|
+
type MaskLegendLayer = {
|
|
173
189
|
mask: string;
|
|
174
190
|
rgb: [number, number, number];
|
|
175
191
|
label?: string;
|
|
176
192
|
};
|
|
193
|
+
type RgbaLegendLayer = {
|
|
194
|
+
rgba: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
};
|
|
197
|
+
type LegendLayer = MaskLegendLayer | RgbaLegendLayer;
|
|
177
198
|
type TerrainLegend = {
|
|
178
199
|
size: [number, number];
|
|
179
200
|
sea_level?: number;
|
|
201
|
+
height_scale?: number;
|
|
180
202
|
heightmap: string;
|
|
181
203
|
topology?: string;
|
|
182
|
-
biomes: Record<string,
|
|
204
|
+
biomes: Record<string, MaskLegendLayer>;
|
|
183
205
|
overlays: Record<string, LegendLayer>;
|
|
184
206
|
};
|
|
185
207
|
type TerrainDataset = {
|
|
@@ -348,8 +370,10 @@ type LayerBrowserEntry = {
|
|
|
348
370
|
id: string;
|
|
349
371
|
kind: 'biome' | 'overlay';
|
|
350
372
|
label: string;
|
|
351
|
-
mask
|
|
373
|
+
mask?: string;
|
|
374
|
+
rgba?: string;
|
|
352
375
|
color: [number, number, number];
|
|
376
|
+
icon?: string;
|
|
353
377
|
visible: boolean;
|
|
354
378
|
};
|
|
355
379
|
type LayerBrowserState = {
|
|
@@ -438,4 +462,4 @@ type EnhancedLocationsResult<T extends TerrainLocation = TerrainLocation> = {
|
|
|
438
462
|
declare function createIconUrlMap(files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): IconUrlMapResult;
|
|
439
463
|
declare function enhanceLocationsWithIconUrls<T extends TerrainLocation = TerrainLocation>(locations: T[], files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): EnhancedLocationsResult<T>;
|
|
440
464
|
|
|
441
|
-
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
|
@@ -115,12 +115,16 @@ type LocationPickPayload = {
|
|
|
115
115
|
};
|
|
116
116
|
};
|
|
117
117
|
type ViewerLifecycleState = 'initializing' | 'loading-textures' | 'building-geometry' | 'first-render' | 'stabilizing' | 'ready';
|
|
118
|
+
type RenderScaleMode = 'auto' | 'fixed';
|
|
118
119
|
type TerrainInitOptions = {
|
|
119
120
|
onReady?: () => void;
|
|
120
121
|
onLifecycleChange?: (state: ViewerLifecycleState) => void;
|
|
121
122
|
heightScale?: number;
|
|
122
123
|
waterLevelPercent?: number;
|
|
123
124
|
maxPixelRatio?: number;
|
|
125
|
+
renderScale?: number;
|
|
126
|
+
renderScaleMode?: RenderScaleMode;
|
|
127
|
+
onRenderSizeChange?: (payload: RenderResolution) => void;
|
|
124
128
|
layers?: LayerToggleState;
|
|
125
129
|
interactive?: boolean;
|
|
126
130
|
onLocationPick?: (payload: LocationPickPayload) => void;
|
|
@@ -129,11 +133,16 @@ type TerrainInitOptions = {
|
|
|
129
133
|
locations?: TerrainLocation[];
|
|
130
134
|
theme?: TerrainThemeOverrides;
|
|
131
135
|
cameraView?: LocationViewState;
|
|
136
|
+
preserveDrawingBuffer?: boolean;
|
|
132
137
|
};
|
|
133
138
|
type TerrainHandle = {
|
|
134
139
|
destroy: () => void;
|
|
135
140
|
updateLayers: (state: LayerToggleState) => Promise<void>;
|
|
136
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;
|
|
137
146
|
updateLocations: (locations: TerrainLocation[], focusedId?: string) => void;
|
|
138
147
|
setFocusedLocation: (locationId?: string | null) => void;
|
|
139
148
|
navigateTo: (payload: {
|
|
@@ -156,6 +165,7 @@ type TerrainHandle = {
|
|
|
156
165
|
getViewState: () => LocationViewState;
|
|
157
166
|
setTheme: (overrides?: TerrainThemeOverrides) => void;
|
|
158
167
|
setSeaLevel: (level: number) => void;
|
|
168
|
+
setHeightScale: (scale: number) => void;
|
|
159
169
|
invalidateIconTextures: (paths?: string[]) => void;
|
|
160
170
|
invalidateLayerMasks: (paths?: string[]) => void;
|
|
161
171
|
enableFrameCaptureMode: (fps?: number) => {
|
|
@@ -168,18 +178,30 @@ type TerrainHandle = {
|
|
|
168
178
|
};
|
|
169
179
|
};
|
|
170
180
|
type Cleanup = () => void;
|
|
181
|
+
type RenderResolution = {
|
|
182
|
+
width: number;
|
|
183
|
+
height: number;
|
|
184
|
+
pixelRatio: number;
|
|
185
|
+
renderScale: number;
|
|
186
|
+
};
|
|
171
187
|
type Resolvable<T> = T | Promise<T>;
|
|
172
|
-
type
|
|
188
|
+
type MaskLegendLayer = {
|
|
173
189
|
mask: string;
|
|
174
190
|
rgb: [number, number, number];
|
|
175
191
|
label?: string;
|
|
176
192
|
};
|
|
193
|
+
type RgbaLegendLayer = {
|
|
194
|
+
rgba: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
};
|
|
197
|
+
type LegendLayer = MaskLegendLayer | RgbaLegendLayer;
|
|
177
198
|
type TerrainLegend = {
|
|
178
199
|
size: [number, number];
|
|
179
200
|
sea_level?: number;
|
|
201
|
+
height_scale?: number;
|
|
180
202
|
heightmap: string;
|
|
181
203
|
topology?: string;
|
|
182
|
-
biomes: Record<string,
|
|
204
|
+
biomes: Record<string, MaskLegendLayer>;
|
|
183
205
|
overlays: Record<string, LegendLayer>;
|
|
184
206
|
};
|
|
185
207
|
type TerrainDataset = {
|
|
@@ -348,8 +370,10 @@ type LayerBrowserEntry = {
|
|
|
348
370
|
id: string;
|
|
349
371
|
kind: 'biome' | 'overlay';
|
|
350
372
|
label: string;
|
|
351
|
-
mask
|
|
373
|
+
mask?: string;
|
|
374
|
+
rgba?: string;
|
|
352
375
|
color: [number, number, number];
|
|
376
|
+
icon?: string;
|
|
353
377
|
visible: boolean;
|
|
354
378
|
};
|
|
355
379
|
type LayerBrowserState = {
|
|
@@ -438,4 +462,4 @@ type EnhancedLocationsResult<T extends TerrainLocation = TerrainLocation> = {
|
|
|
438
462
|
declare function createIconUrlMap(files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): IconUrlMapResult;
|
|
439
463
|
declare function enhanceLocationsWithIconUrls<T extends TerrainLocation = TerrainLocation>(locations: T[], files?: TerrainProjectFileEntry[], options?: CreateIconUrlMapOptions): EnhancedLocationsResult<T>;
|
|
440
464
|
|
|
441
|
-
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,12 +921,24 @@ 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);
|
|
892
928
|
let currentPixelRatio = 1;
|
|
893
|
-
function
|
|
894
|
-
return
|
|
929
|
+
function clampRenderScale(value) {
|
|
930
|
+
return THREE2.MathUtils.clamp(value, minRenderScale, 1);
|
|
895
931
|
}
|
|
896
|
-
|
|
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
|
+
});
|
|
897
942
|
renderer.toneMapping = THREE2.ACESFilmicToneMapping;
|
|
898
943
|
renderer.toneMappingExposure = 1.08;
|
|
899
944
|
currentPixelRatio = resolvePixelRatio();
|
|
@@ -914,6 +959,22 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
914
959
|
renderer.shadowMap.enabled = true;
|
|
915
960
|
renderer.shadowMap.type = THREE2.PCFSoftShadowMap;
|
|
916
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();
|
|
917
978
|
disposables.push(() => {
|
|
918
979
|
renderer.dispose();
|
|
919
980
|
renderer.domElement.remove();
|
|
@@ -982,19 +1043,42 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
982
1043
|
}
|
|
983
1044
|
const locationWorldCache = /* @__PURE__ */ new Map();
|
|
984
1045
|
let markerGeneration = 0;
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
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({
|
|
988
1066
|
color: 16735067,
|
|
989
1067
|
transparent: true,
|
|
990
|
-
opacity: 0.
|
|
1068
|
+
opacity: 0.2
|
|
991
1069
|
})
|
|
992
1070
|
);
|
|
993
|
-
|
|
994
|
-
|
|
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);
|
|
995
1076
|
disposables.push(() => {
|
|
996
|
-
|
|
997
|
-
|
|
1077
|
+
placementStem.geometry.dispose();
|
|
1078
|
+
placementDot.geometry.dispose();
|
|
1079
|
+
placementRing.geometry.dispose();
|
|
1080
|
+
placementIndicatorMaterial.dispose();
|
|
1081
|
+
placementRing.material.dispose();
|
|
998
1082
|
});
|
|
999
1083
|
const loader = new THREE2.TextureLoader();
|
|
1000
1084
|
const iconTextureCache = /* @__PURE__ */ new Map();
|
|
@@ -1220,6 +1304,31 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1220
1304
|
stemRadius = computeStemRadius(markerTheme.stem);
|
|
1221
1305
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
1222
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
|
+
}
|
|
1223
1332
|
function applySeaLevelUpdate(nextSeaLevel) {
|
|
1224
1333
|
if (!heightSampler) {
|
|
1225
1334
|
return;
|
|
@@ -1346,6 +1455,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1346
1455
|
camera.updateProjectionMatrix();
|
|
1347
1456
|
}
|
|
1348
1457
|
let animationFrame = 0;
|
|
1458
|
+
let renderPaused = false;
|
|
1349
1459
|
let frameCount = 0;
|
|
1350
1460
|
let firstRenderComplete = false;
|
|
1351
1461
|
const frameTimings = [];
|
|
@@ -1403,19 +1513,32 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1403
1513
|
if (currentLifecycleState === "stabilizing" && stabilizingStartTime !== null) {
|
|
1404
1514
|
const stabilizingDuration = now - stabilizingStartTime;
|
|
1405
1515
|
if (stabilizingDuration > STABILITY_TIMEOUT_MS) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
+
}
|
|
1409
1532
|
}
|
|
1410
1533
|
}
|
|
1411
1534
|
}
|
|
1412
|
-
if (!frameCaptureMode) {
|
|
1535
|
+
if (!frameCaptureMode && !renderPaused) {
|
|
1413
1536
|
animationFrame = window.requestAnimationFrame(animate);
|
|
1414
1537
|
}
|
|
1415
1538
|
}
|
|
1416
1539
|
animate();
|
|
1417
1540
|
options.onReady?.();
|
|
1418
|
-
|
|
1541
|
+
function handleResize() {
|
|
1419
1542
|
const { clientWidth, clientHeight } = container;
|
|
1420
1543
|
if (!clientWidth || !clientHeight) return;
|
|
1421
1544
|
viewportWidth = clientWidth;
|
|
@@ -1431,6 +1554,10 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1431
1554
|
}
|
|
1432
1555
|
renderer.setSize(clientWidth, clientHeight, false);
|
|
1433
1556
|
applyViewOffset();
|
|
1557
|
+
updateRenderResolution();
|
|
1558
|
+
}
|
|
1559
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1560
|
+
handleResize();
|
|
1434
1561
|
});
|
|
1435
1562
|
resizeObserver.observe(container);
|
|
1436
1563
|
function setPointerFromEvent(event) {
|
|
@@ -1463,16 +1590,18 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1463
1590
|
}
|
|
1464
1591
|
function updatePlacementIndicator() {
|
|
1465
1592
|
if (!interactiveEnabled) {
|
|
1466
|
-
|
|
1593
|
+
placementIndicatorGroup.visible = false;
|
|
1467
1594
|
return;
|
|
1468
1595
|
}
|
|
1469
1596
|
const hit = intersectTerrain();
|
|
1470
1597
|
if (!hit) {
|
|
1471
|
-
|
|
1598
|
+
placementIndicatorGroup.visible = false;
|
|
1472
1599
|
return;
|
|
1473
1600
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1601
|
+
placementIndicatorGroup.visible = true;
|
|
1602
|
+
const target = hit.point.clone();
|
|
1603
|
+
target.y += 0.02;
|
|
1604
|
+
placementIndicatorGroup.position.copy(target);
|
|
1476
1605
|
}
|
|
1477
1606
|
function handlePointerMove(event) {
|
|
1478
1607
|
setPointerFromEvent(event);
|
|
@@ -1531,7 +1660,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1531
1660
|
renderer.domElement.classList.add("terrain-pointer-active");
|
|
1532
1661
|
} else {
|
|
1533
1662
|
renderer.domElement.classList.remove("terrain-pointer-active");
|
|
1534
|
-
|
|
1663
|
+
placementIndicatorGroup.visible = false;
|
|
1535
1664
|
}
|
|
1536
1665
|
}
|
|
1537
1666
|
async function updateLayers(state) {
|
|
@@ -1601,6 +1730,33 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1601
1730
|
},
|
|
1602
1731
|
updateLayers,
|
|
1603
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 }),
|
|
1604
1760
|
updateLocations: (locations, focusedId) => {
|
|
1605
1761
|
setLocationMarkers(locations, focusedId);
|
|
1606
1762
|
},
|
|
@@ -1667,6 +1823,7 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1667
1823
|
},
|
|
1668
1824
|
setTheme: applyThemeUpdate,
|
|
1669
1825
|
setSeaLevel: applySeaLevelUpdate,
|
|
1826
|
+
setHeightScale: applyHeightScaleUpdate,
|
|
1670
1827
|
invalidateIconTextures: (paths) => {
|
|
1671
1828
|
invalidateIconCache(paths);
|
|
1672
1829
|
setLocationMarkers(currentLocations, currentFocusId);
|
|
@@ -1685,7 +1842,9 @@ async function initTerrainViewer(container, dataset, options = {}) {
|
|
|
1685
1842
|
},
|
|
1686
1843
|
disableFrameCaptureMode: () => {
|
|
1687
1844
|
frameCaptureMode = false;
|
|
1688
|
-
|
|
1845
|
+
if (!renderPaused) {
|
|
1846
|
+
animationFrame = window.requestAnimationFrame(animate);
|
|
1847
|
+
}
|
|
1689
1848
|
},
|
|
1690
1849
|
captureFrame: (frameNumber, fps = 30) => {
|
|
1691
1850
|
if (!frameCaptureMode) {
|
|
@@ -2913,12 +3072,16 @@ function buildEntries(legend, visibility) {
|
|
|
2913
3072
|
}
|
|
2914
3073
|
for (const [key, layer] of Object.entries(legend.overlays ?? {})) {
|
|
2915
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;
|
|
2916
3077
|
entries.push({
|
|
2917
3078
|
id,
|
|
2918
3079
|
kind: "overlay",
|
|
2919
3080
|
label: layer.label ?? key,
|
|
2920
|
-
mask: layer.mask,
|
|
2921
|
-
|
|
3081
|
+
mask: "mask" in layer ? layer.mask : void 0,
|
|
3082
|
+
rgba: "rgba" in layer ? layer.rgba : void 0,
|
|
3083
|
+
color,
|
|
3084
|
+
icon,
|
|
2922
3085
|
visible: visibility.get(id) ?? true
|
|
2923
3086
|
});
|
|
2924
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
|
}
|