@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 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
- const maxPixelRatio = Math.max(1, options.maxPixelRatio ?? 1.5);
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 resolvePixelRatio() {
947
- return Math.max(1, Math.min(window.devicePixelRatio || 1, maxPixelRatio));
982
+ function clampRenderScale(value) {
983
+ return THREE2.MathUtils.clamp(value, minRenderScale, 1);
948
984
  }
949
- const renderer = new THREE2.WebGLRenderer({ antialias: true, alpha: true });
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 placementIndicator = new THREE2.Mesh(
1039
- new THREE2.CylinderGeometry(0.04, 0.01, 0.7, 18),
1040
- 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({
1041
1119
  color: 16735067,
1042
1120
  transparent: true,
1043
- opacity: 0.8
1121
+ opacity: 0.2
1044
1122
  })
1045
1123
  );
1046
- placementIndicator.visible = false;
1047
- 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);
1048
1129
  disposables.push(() => {
1049
- placementIndicator.geometry.dispose();
1050
- placementIndicator.material.dispose();
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
- console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
1460
- setLifecycleState("ready");
1461
- 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
+ }
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
- const resizeObserver = new ResizeObserver(() => {
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
- placementIndicator.visible = false;
1646
+ placementIndicatorGroup.visible = false;
1520
1647
  return;
1521
1648
  }
1522
1649
  const hit = intersectTerrain();
1523
1650
  if (!hit) {
1524
- placementIndicator.visible = false;
1651
+ placementIndicatorGroup.visible = false;
1525
1652
  return;
1526
1653
  }
1527
- placementIndicator.visible = true;
1528
- 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);
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
- placementIndicator.visible = false;
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
- animationFrame = window.requestAnimationFrame(animate);
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
- 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,
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 LegendLayer = {
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, LegendLayer>;
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: string;
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 LegendLayer = {
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, LegendLayer>;
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: string;
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
- const maxPixelRatio = Math.max(1, options.maxPixelRatio ?? 1.5);
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 resolvePixelRatio() {
894
- return Math.max(1, Math.min(window.devicePixelRatio || 1, maxPixelRatio));
929
+ function clampRenderScale(value) {
930
+ return THREE2.MathUtils.clamp(value, minRenderScale, 1);
895
931
  }
896
- const renderer = new THREE2.WebGLRenderer({ antialias: true, alpha: true });
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 placementIndicator = new THREE2.Mesh(
986
- new THREE2.CylinderGeometry(0.04, 0.01, 0.7, 18),
987
- 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({
988
1066
  color: 16735067,
989
1067
  transparent: true,
990
- opacity: 0.8
1068
+ opacity: 0.2
991
1069
  })
992
1070
  );
993
- placementIndicator.visible = false;
994
- 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);
995
1076
  disposables.push(() => {
996
- placementIndicator.geometry.dispose();
997
- placementIndicator.material.dispose();
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
- console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
1407
- setLifecycleState("ready");
1408
- 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
+ }
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
- const resizeObserver = new ResizeObserver(() => {
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
- placementIndicator.visible = false;
1593
+ placementIndicatorGroup.visible = false;
1467
1594
  return;
1468
1595
  }
1469
1596
  const hit = intersectTerrain();
1470
1597
  if (!hit) {
1471
- placementIndicator.visible = false;
1598
+ placementIndicatorGroup.visible = false;
1472
1599
  return;
1473
1600
  }
1474
- placementIndicator.visible = true;
1475
- 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);
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
- placementIndicator.visible = false;
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
- animationFrame = window.requestAnimationFrame(animate);
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
- 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,
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/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.5",
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",