@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 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
- const renderer = new THREE2.WebGLRenderer({ antialias: true, alpha: true });
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
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
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 placementIndicator = new THREE2.Mesh(
1033
- new THREE2.CylinderGeometry(0.04, 0.01, 0.7, 18),
1034
- new THREE2.MeshStandardMaterial({
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.8
1121
+ opacity: 0.2
1038
1122
  })
1039
1123
  );
1040
- placementIndicator.visible = false;
1041
- scene.add(placementIndicator);
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
- placementIndicator.geometry.dispose();
1044
- placementIndicator.material.dispose();
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
- console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
1454
- setLifecycleState("ready");
1455
- stabilizingStartTime = null;
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
- const resizeObserver = new ResizeObserver(() => {
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
- placementIndicator.visible = false;
1646
+ placementIndicatorGroup.visible = false;
1507
1647
  return;
1508
1648
  }
1509
1649
  const hit = intersectTerrain();
1510
1650
  if (!hit) {
1511
- placementIndicator.visible = false;
1651
+ placementIndicatorGroup.visible = false;
1512
1652
  return;
1513
1653
  }
1514
- placementIndicator.visible = true;
1515
- placementIndicator.position.copy(hit.point).setY(hit.point.y + 0.2);
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
- placementIndicator.visible = false;
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
- animationFrame = window.requestAnimationFrame(animate);
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
- color: layer.rgb,
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 LegendLayer = {
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, LegendLayer>;
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: string;
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 LegendLayer = {
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, LegendLayer>;
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: string;
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
- const renderer = new THREE2.WebGLRenderer({ antialias: true, alpha: true });
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
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
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 placementIndicator = new THREE2.Mesh(
980
- new THREE2.CylinderGeometry(0.04, 0.01, 0.7, 18),
981
- new THREE2.MeshStandardMaterial({
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.8
1068
+ opacity: 0.2
985
1069
  })
986
1070
  );
987
- placementIndicator.visible = false;
988
- scene.add(placementIndicator);
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
- placementIndicator.geometry.dispose();
991
- placementIndicator.material.dispose();
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
- console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
1401
- setLifecycleState("ready");
1402
- stabilizingStartTime = null;
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
- const resizeObserver = new ResizeObserver(() => {
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
- placementIndicator.visible = false;
1593
+ placementIndicatorGroup.visible = false;
1454
1594
  return;
1455
1595
  }
1456
1596
  const hit = intersectTerrain();
1457
1597
  if (!hit) {
1458
- placementIndicator.visible = false;
1598
+ placementIndicatorGroup.visible = false;
1459
1599
  return;
1460
1600
  }
1461
- placementIndicator.visible = true;
1462
- placementIndicator.position.copy(hit.point).setY(hit.point.y + 0.2);
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
- placementIndicator.visible = false;
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
- animationFrame = window.requestAnimationFrame(animate);
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
- color: layer.rgb,
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/legendLayer" }
33
+ "additionalProperties": { "$ref": "#/definitions/maskLayer" }
34
34
  },
35
35
  "overlays": {
36
36
  "type": "object",
37
- "additionalProperties": { "$ref": "#/definitions/legendLayer" }
37
+ "additionalProperties": { "$ref": "#/definitions/overlayLayer" }
38
38
  }
39
39
  },
40
40
  "definitions": {
41
- "legendLayer": {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connected-web/terrain-editor",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Reusable viewer/editor utilities for Wyn terrain files.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",