diff --git a/docs/arnis-city-parity-todo.md b/docs/arnis-city-parity-todo.md new file mode 100644 index 000000000..4485da712 --- /dev/null +++ b/docs/arnis-city-parity-todo.md @@ -0,0 +1,112 @@ +# Arnis City Parity TODO + +Goal: make Tellus generate city detail classes comparable to Arnis for the same OSM area, while keeping Tellus in charge of projection, terrain, chunk lifecycle, cache reuse, and WLB proxy routing. + +Done means feature-class parity, not byte-identical `.mca` output. For example, New York should have terrain, roads, buildings, bridges/tunnels, parks/water, barriers, city props, and building details generated from OSM in-game without requiring a prewritten Arnis world. + +## Ground Rules + +- Keep the existing `tellus/cache/map/arnis-overpass/` raw OSM cache reusable across updates. +- Missing OSM data should try direct routes first through the WLB rule proxy; do not force paid VPN traffic. +- Add detail through neutral feature contracts first, then adapt into Tellus worldgen. +- Prefer capped, cache-first behavior for every new data family. +- Keep original Tellus Overture/PMTiles sources as fallback when OSM cache or network is missing. + +## Arnis Element Inventory + +Status meanings: `Done` is implemented in Tellus; `Partial` is visible but less detailed than Arnis; `Todo` is not implemented yet; `Watch` needs validation because data volume or geometry can be risky. + +| Arnis module | OSM tags / behavior | Tellus status | Next task | +| --- | --- | --- | --- | +| `buildings.rs` | `building`, `building:part`, multipolygon shells, materials, roof shapes, storefront facades | Partial | Improve part stacking and Arnis-like interiors. | +| `buildings_interior.rs` | Room templates, stairs, beds, crafting/furnace/bookshelf/anvil/abandoned variants | Todo | Add opt-in simple interior pass for safe large buildings. | +| `doors.rs` | `door=*`, `entrance=*`, ground-level doors | Done | Validate snapping in dense downtown blocks. | +| `highways.rs` | `highway=*`, lanes, surface, sidewalks, zebra crossings, traffic signals, street lamps, bus stops, bridges/tunnels/layers | Partial | Tunnel portals remain; tunnel side shell/lighting exists. | +| `bridges.rs` | `bridge=*`, raised deck, ramps, edge rails/supports | Done | Validate long river bridges visually. | +| `railways.rs` | `railway=rail/light_rail/subway/tram`, slope rails, subway shells, crossing/tram-stop nodes | Partial | Surface rails and crossing/tram-stop markers exist; subway/tunnel shells remain. | +| `barriers.rs` | `barrier=*`, `fence_type`, `material`, `height`, bollard/gate nodes | Done | Validate gate replacement against dense barrier lines. | +| `amenities.rs` | parking, bicycle parking, bench, shelter, fountain, recycling, waste, vending/ATM, drinking water, fuel | Partial | Fountain/parking area shape and recycling metadata remain optional tuning. | +| `advertising.rs` | column, flag, poster box | Done | Validate density and collision. | +| `emergency.rs` | fire hydrant | Done | Validate density and underground filtering. | +| `historic.rs` | memorial, monument, wayside cross | Done | Add more subtype palettes if needed. | +| `tourisms.rs` | information boards | Done | Add map/guidepost subtype palettes if needed. | +| `man_made.rs` | pier, antenna/mast, chimney, water well, water tower | Done | Validate large towers against build height. | +| `power.rs` | power poles/towers/lines/minor lines | Done | Validate spacing and visual scale. | +| `tree.rs` | Arnis tree shapes, species/genus/leaf type | Done | Continue visual tuning for wild forests. | +| `landuse.rs` | grass, meadow, forest, orchard, farmland, cemetery, construction, traffic island, education, religious, industrial, military, railway, vineyard, brownfield, landfill, quarry | Partial | Selectors and first-pass props exist; visual density tuning remains. | +| `leisure.rs` | park, garden, nature reserve, golf/disc golf, schoolyard, playground, recreation ground, pitch, beach resort, dog park, pool, seating, water park, slipway, ice rink | Partial | Selectors and first-pass props exist; visual density tuning remains. | +| `natural.rs` | tree, wood, tree row, scrub, heath, grassland, beach/sand/dune/shoal, wetland, bare rock/scree/blockfield, mud, glacier, ridge/cliff/saddle/tundra/shrubbery | Partial | Selectors and first-pass surface/detail palettes exist; validation remains. | +| `surfaces.rs` | `surface=*` palettes for asphalt, gravel, wood, sand, tartan, grass, dirt, bricks, paving stones | Partial | Extend area/road surface palette where Tellus still falls back. | +| `waterways.rs` | river/canal/stream/ditch/drain with width and layer filters | Partial | Current light water traces exist; add width-aware channels when safe. | +| `water_areas.rs` | water multipolygons with inner islands | Partial | Current water areas exist; validate complex relation clipping. | + +## P0 Data And Cache + +- [x] Add neutral external feature source for roads/buildings. +- [x] Add automatic Overpass source with raw JSON cache. +- [x] Add cache estimate and capped warm-up actions to the world UI. +- [x] Preserve WLB proxy routing by using JVM networking. +- [x] Extend the neutral model to OSM line/area city details beyond roads/buildings. +- [x] Expand the Overpass query toward Arnis element families without making downloads unbounded. +- [x] Show expanded city-detail cache estimates in the UI. +- [x] Add OSM point city details for traffic signals, crossings, selected amenities, and trees. +- [x] Add OSM point city details for entrances and door nodes. +- [ ] Keep expanding cache profile in small versioned steps (`city-vN`) so raw cache remains reusable and new sidecars refresh in place. + +## P1 Roads + +- [x] Preserve road OSM tags on Tellus `RoadFeature`. +- [x] Use `surface=*` to distinguish paved paths and unpaved roads. +- [x] Use `lanes=*` to widen paved roads. +- [x] Render sidewalks from `sidewalk=*`. +- [x] Render dashed lane markings for multi-lane paved roads. +- [x] Add crossings and traffic signals. +- [x] Add street lamps and bus stop markers from Arnis `highways.rs`. +- [x] Add bridge edge rails beyond deck height/supports. +- [x] Add tunnel side shell and ceiling lights beyond current carving. +- [ ] Add explicit tunnel portal/headwall details at road tunnel mouths. +- [x] Add rail-related crossings. +- [x] Add rail line rendering from OSM `railway=*`. +- [x] Add barriers/guardrails/fences along OSM barrier lines. +- [x] Add barrier point nodes: bollards, blocks, gates, entrances, stiles. + +## P1 Buildings + +- [x] Preserve building tags through the external adapter. +- [x] Use building wall material and color tags. +- [x] Use roof material, color, shape, roof height, and roof levels. +- [x] Add entrances and doors from OSM `entrance=*` / `door=*` nodes. +- [ ] Improve building-part stacking and vertical alignment for dense downtown areas. +- [ ] Add simple Arnis-like interiors for accessible buildings. +- [x] Add OSM-aware windows/storefront variation for commercial buildings. + +## P1 Areas And Surfaces + +- [x] Add parking lots with surface material and painted parking markings. +- [x] Add landuse surfaces such as grass, residential, industrial, farmland, cemetery, and construction. +- [x] Add leisure surfaces such as parks, pitches, tracks, playgrounds, and gardens. +- [x] Add natural surfaces such as wood, scrub, heath, beach, wetland, rock, and scree. +- [x] Add water areas and waterways from OSM where they improve city-scale detail. +- [x] Add Arnis-style farmland crops/water points/hay bales. +- [x] Add Arnis-style cemetery graves/flowers/fence feel. +- [x] Add Arnis-style construction/quarry/brownfield/landfill clutter. +- [x] Add Arnis-style playground, pitch, pool, schoolyard, and parking-lot props. +- [x] Expand selectors for missing Arnis landuse/leisure/natural area types without making Overpass fetches unbounded. + +## P2 City Props + +- [x] Add amenity nodes/areas with high visual value: benches, bicycle parking, fuel, fountains, shelters. +- [x] Add tourism/historic/man_made/emergency/power/advertising feature families where Arnis renders them. +- [x] Add Arnis-style OSM natural tree nodes with species/genus/leaf-type selection. +- [x] Add tree distribution from OSM natural/landuse/leisure areas with building/road avoidance. +- [x] Replace coarse wild forest trees with the shared Arnis-style tree shapes. +- [x] Add street furniture placement that respects roads by shifting road-center OSM nodes to nearby non-road anchors. +- [ ] Add stronger building/sidewalk collision validation for street furniture after visual smoke tests. + +## P3 Validation + +- [ ] New York Manhattan smoke area: cache estimate, warm-up, generation, and visual pass. +- [ ] Dense European city smoke area with multipolygon buildings and narrow streets. +- [ ] Domestic-network pass: confirm direct routes are used when available and proxy/VPN only covers unreachable hosts. +- [ ] Cache-only pass: restart with `-Dtellus.arnis.overpass.network=cache-only` and confirm cached city details still render. +- [ ] Build and replace active WLB mod jar after each stable slice. diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md new file mode 100644 index 000000000..bea272f00 --- /dev/null +++ b/docs/wlb-arnis-integration.md @@ -0,0 +1,174 @@ +# WLB Arnis Integration + +This branch keeps the Tellus chunk generator as the owner of terrain, projection, caching, and block placement. Arnis should be integrated at the feature/rule layer, not by merging generated region files. + +## Project Placement + +The working copy lives under: + +```text +/home/kaijie/桌面/WorkLifeBalance/你在这里搞个项目/minecraft-mods/tellus-arnis-integration +``` + +This matches the WorkLifeBalance workspace convention: source projects stay in `你在这里搞个项目/`, Minecraft mod projects stay in `minecraft-mods/`, and built jars are copied separately to `../../.minecraft/mods/`. + +## Integration Boundary + +The first stable boundary is: + +```text +ExternalFeatureSource + -> ExternalRoadFeature + -> ExternalBuildingFeature + -> ExternalAreaFeature + -> ExternalLineFeature + -> GeoBounds / GeoPoint + -> ExternalFeatureAdapters + -> JsonExternalFeatureSource + -> OverpassExternalFeatureSource +``` + +This gives Tellus one neutral input shape for external real-world features. The first adapters can be: + +- `OverpassExternalFeatureSource`: reads OSM roads/buildings/city details from Overpass with the same bbox-style data source family used by Arnis. +- `ArnisJsonFeatureSource`: reads an Arnis/exporter-produced JSON or GeoJSON feature dump for offline overrides. +- Later: `ArnisProcessFeatureSource`: runs an Arnis-side exporter as a local process for a requested bbox. + +The current JSON source accepts this shape: + +```json +{ + "roads": [ + { + "source": "arnis", + "sourceId": "road-1", + "roadClass": "MAIN", + "mode": "NORMAL", + "bridgeLevel": 0, + "highwayTag": "primary", + "points": [{"lat": 35.0, "lon": 139.0}, {"lat": 35.001, "lon": 139.001}], + "tags": {"surface": "asphalt", "lanes": "4", "sidewalk": "both"} + } + ], + "buildings": [ + { + "source": "arnis", + "sourceId": "building-1", + "kind": "FOOTPRINT", + "heightMeters": 8.0, + "minHeightMeters": 0.0, + "floorCount": 2, + "rings": [[ + {"lat": 35.0, "lon": 139.0}, + {"lat": 35.0, "lon": 139.001}, + {"lat": 35.001, "lon": 139.001}, + {"lat": 35.001, "lon": 139.0}, + {"lat": 35.0, "lon": 139.0} + ]], + "tags": {"building": "house", "building:material": "brick", "building:colour": "white", "roof:material": "tile", "roof:colour": "red"} + } + ] +} +``` + +The same file can also include optional `areas`, `lines`, and `points` arrays for offline city-detail overrides. `areas` use `kind` values such as `PARKING`, `LANDUSE`, `LEISURE`, and `NATURAL`; `lines` use `kind` values such as `BARRIER` and `RAILWAY`; `points` use `kind` values such as `TRAFFIC_SIGNAL`, `CROSSING`, `AMENITY`, and `NATURAL`. + +## Runtime File + +The game-side loader now has two inputs: + +1. Automatic Overpass/OSM source, enabled by default. +2. Optional local JSON override/extension file. + +For normal use, no JSON file is required. When a chunk needs road/building/city-detail data, Tellus calculates the chunk's geographic bbox, fetches matching OSM `highway`, `building`, `building:part`, `amenity=parking`, selected `landuse`, `leisure`, `natural`, `barrier`, and `railway` ways/relations plus selected OSM city nodes from Overpass, caches the response under: + +```text +/tellus/cache/map/arnis-overpass/ +``` + +If the Overpass source returns usable features for a chunk, Tellus prefers that Arnis-style OSM source over the original Overture road/building PMTiles for that chunk. If the Overpass source returns nothing or fails, Tellus falls back to the original Overture source. Building and area relations/multipolygons are supported by merging outer/inner member way geometry into rings before handing the feature to Tellus. + +Older road/building cache files remain usable. City-detail data uses a `city-v4` sidecar profile next to the existing raw tile cache. If a tile was cached before city details existed, the UI warm-up or first in-game city-detail query upgrades that tile in place instead of invalidating the whole cache. The `city-v4` profile adds Arnis-style vegetation inputs such as OSM forest/orchard landuse and extra natural area tags. + +The optional local file is still supported at: + +```text +/tellus/external-features.json +``` + +For the current WLB instance that means: + +```text +/home/kaijie/桌面/WorkLifeBalance/.minecraft/tellus/external-features.json +``` + +It can also be overridden with `-Dtellus.external.features.path=/path/to/external-features.json`. + +Useful runtime switches: + +```text +-Dtellus.arnis.overpass.enabled=false +-Dtellus.arnis.overpass.network=cache-first +-Dtellus.arnis.overpass.network=cache-only +-Dtellus.arnis.overpass.maxNetworkTilesPerSession=96 +-Dtellus.arnis.overpass.prefetchMaxTiles=32 +-Dtellus.arnis.overpass.endpoints=https://overpass-api.de/api/interpreter,https://overpass.osm.ch/api/interpreter,https://overpass.kumi.systems/api/interpreter +-Dtellus.map.tile.endpoints=https://tile.openstreetmap.org/%d/%d/%d.png,https://tile.openstreetmap.de/%d/%d/%d.png +-Dtellus.external.features.prefer=false +``` + +Network behavior is intentionally conservative to avoid burning VPN traffic: + +- `cache-first` is the default. Existing cached OSM tiles are used without network; missing tiles may be fetched. +- `cache-only` never fetches missing tiles. It only uses `/tellus/cache/map/arnis-overpass/`, then falls back to Overture. +- `off` disables the Overpass source. +- `maxNetworkTilesPerSession` caps missing-tile downloads per game process. The default is `96`; after that, Tellus skips more Overpass requests and falls back. +- `prefetchMaxTiles` caps each UI cache warm-up batch. The default is `32`, so the button never starts an unbounded city download. +- Overpass endpoints are tried with per-endpoint cooldown. If one public source times out or rate-limits, it is skipped for a short period instead of delaying every following tile. +- Empty Overpass responses are still treated as a completed city-detail cache entry. This prevents empty ocean or low-detail tiles from being downloaded repeatedly. +- The spawn/world map tile loader also supports multiple raster tile endpoints through `tellus.map.tile.endpoints`; tile failures are written to the Tellus traffic log. + +The world customization UI has a Data Sources entry named `Test OSM connectivity`. It sends a tiny Overpass query to each configured endpoint from the current computer and reports how many endpoints are reachable plus per-endpoint timing in the tooltip. This is intended for checking whether the current network can direct-connect before spending cache/download budget. + +The same Data Sources section also has `Estimate OSM cache` and `Warm missing OSM cache`. The estimate uses the current spawnpoint and the Voxy pregen radius as the target area; when Voxy pregen is disabled it estimates a conservative 96-chunk spawn radius. It reports cached/missing raw Overpass tiles and compressed cache size. The warm-up button downloads only a capped batch of missing raw OSM tiles and reuses the existing WLB `127.0.0.1:18127` rule proxy, so direct-classified sources still avoid paid VPN traffic. + +## Current Arnis-Style Rules + +- OSM road tags are preserved on `RoadFeature`. +- `lanes=*` and `lanes:forward/backward` widen paved roads instead of using only the Tellus road class width. +- `sidewalk=*`, `sidewalk:left`, `sidewalk:right`, and `sidewalk:both` widen paved roads and paint smooth-stone sidewalk edge strips. +- unpaved surfaces such as `gravel`, `ground`, `dirt`, `sand`, and `mud` use the dirt-path road material. +- paved `footway`, `pedestrian`, and `cycleway` features render as smooth-stone paths when they are not explicitly unpaved. +- paved roads with at least two lanes get dashed white lane markings. +- `building:material`, `facade:material`, and `material` feed the building wall palette. +- `building:colour`, `facade:colour`, and `colour` feed the building wall color palette. +- `roof:material` feeds the roof palette, `roof:colour` feeds the roof color palette, and `roof:shape` still controls flat/gabled/hipped profiles. +- `height`, `building:height`, `building:levels`, `roof:height`, and `roof:levels` feed building and roof massing. Parsed Overture building cache version was bumped so older parsed tiles without roof height metadata are rebuilt. +- `amenity=parking` areas render as paved lots with painted parking stripes. +- selected `landuse`, `leisure`, and `natural` areas render as grass, dirt/gravel construction ground, cemetery moss, pitches, tracks, playgrounds, beaches, wetlands, rock, scree, wood, scrub, and heath surfaces. +- OSM forest, orchard, wood, tree-row, scrub, heath, grassland, wetland, parks, and gardens receive deterministic Arnis-style vegetation such as trees, shrubs, ferns, dead bushes, and rocks while avoiding roads/buildings. +- selected OSM `water=*`, `natural=water`, and `waterway=*` features render lightweight water surfaces/traces for city-scale ponds, canals, streams, ditches, and drains. +- OSM `barrier=*` lines render as fences, walls, hedges, guard rails, or iron-bar barriers when they do not collide with roads/buildings. +- OSM `railway=rail|light_rail|subway|tram` lines render as rail traces where the current surface can accept them. +- OSM `highway=traffic_signals` and `highway=crossing` nodes render traffic signal props and crosswalk stripes. +- OSM `entrance=*` / `door=*` nodes try to place actual building doors by snapping to a nearby safe facade column. +- selected OSM `amenity=*` nodes render benches, bicycle parking/shelter bars, fountains, and fuel markers. +- OSM `natural=tree` nodes render Arnis-style oak/spruce/birch/dark-oak/jungle/acacia trees, selecting species from `species`, `genus`, `genus:wikidata`, and `leaf_type` tags where available. + +## Why Not Merge Arnis Region Files + +Arnis is an offline world writer. Tellus is a Fabric chunk generator. Their final `.mca` output cannot be safely overlaid without solving projection alignment, terrain base height, feature ordering, chunk lifecycle, and collision handling. Keeping Tellus as the block-placement owner avoids those issues. + +## Development Phases + +1. Add neutral feature contracts and keep existing behavior unchanged. +2. Wrap current Overture road/building features behind the neutral contracts. +3. Add an Arnis JSON/GeoJSON adapter for roads and buildings. +4. Port Arnis styling rules into Tellus profiles: + - road `surface`, `lanes`, lane markings, pedestrian paths, bridge/tunnel hints; + - building category, material palette, roof shape, doors, simple interiors. +5. Wire source selection behind settings after the adapters are validated. + +## License Notes + +Tellus is LGPL-3.0. Arnis is Apache-2.0. Porting algorithms and interoperating through a data format is the lowest-risk path. If code is copied directly, keep copyright notices and review compatibility before upstream submission. diff --git a/mc1201/src/client/java/com/yucareux/tellus/TellusClient.java b/mc1201/src/client/java/com/yucareux/tellus/TellusClient.java index dba2b98a5..0ea412e28 100644 --- a/mc1201/src/client/java/com/yucareux/tellus/TellusClient.java +++ b/mc1201/src/client/java/com/yucareux/tellus/TellusClient.java @@ -5,24 +5,35 @@ import com.yucareux.tellus.network.TellusWeatherPayload; import com.yucareux.tellus.world.realtime.SnowGrid; import com.yucareux.tellus.world.realtime.TellusRealtimeState; +import com.mojang.blaze3d.platform.InputConstants; import java.util.Objects; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; +import org.lwjgl.glfw.GLFW; @Environment(EnvType.CLIENT) public class TellusClient implements ClientModInitializer { + private static KeyMapping openEarthMapKey; + @Override public void onInitializeClient() { + openEarthMapKey = KeyBindingHelper.registerKeyBinding( + new KeyMapping("key.tellus.open_earth_map", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_M, "category.tellus.keybinds") + ); + ClientTickEvents.END_CLIENT_TICK.register(TellusClient::handleClientTick); ClientPlayNetworking.registerGlobalReceiver(Objects.requireNonNull(GeoTpOpenMapPayload.TYPE, "GeoTpOpenMapPayload.TYPE"), (payload, player, responseSender) -> { Minecraft minecraft = Minecraft.getInstance(); minecraft.execute(() -> { Screen parent = minecraft.screen; - minecraft.setScreen(new EarthTeleportScreen(parent, payload.latitude(), payload.longitude())); + minecraft.setScreen(new EarthTeleportScreen(parent, payload.latitude(), payload.longitude(), payload.spawnLatitude(), payload.spawnLongitude())); }); }); ClientPlayNetworking.registerGlobalReceiver( @@ -39,4 +50,12 @@ public void onInitializeClient() { ); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TellusRealtimeState.clearRealtimeWeather()); } + + private static void handleClientTick(Minecraft client) { + while (openEarthMapKey != null && openEarthMapKey.consumeClick()) { + if (client.screen == null && client.player != null && client.player.connection != null) { + client.player.connection.sendCommand("tellus map"); + } + } + } } diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java b/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java index 76df0471b..d3fbab103 100644 --- a/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java +++ b/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java @@ -10,7 +10,10 @@ import com.yucareux.tellus.client.preview.TerrainPreviewWidget; import com.yucareux.tellus.client.widget.CustomizationList; import com.yucareux.tellus.client.widget.WidgetCompat; +import com.yucareux.tellus.world.data.integration.GeoBounds; +import com.yucareux.tellus.world.data.integration.OverpassExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; +import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; import java.io.IOException; import java.net.HttpURLConnection; @@ -101,6 +104,8 @@ public class EarthCustomizeScreen extends Screen { ResourceKey.create(Registries.DIMENSION_TYPE, DYNAMIC_DIMENSION_TYPE_ID), "dynamicDimensionTypeKey" ); private static final double OSM_ROADS_AND_BUILDINGS_MAX_WORLD_SCALE = 15.0; + private static final int DEFAULT_OVERPASS_CACHE_RADIUS_CHUNKS = 96; + private static final int MIN_OVERPASS_CACHE_RADIUS_CHUNKS = 16; private final CreateWorldScreen parent; private final List categories; private CustomizationList list; @@ -179,6 +184,45 @@ public double getSpawnLongitude() { return this.spawnLongitude; } + private EarthCustomizeScreen.OverpassCacheArea currentOverpassCacheArea() { + EarthGeneratorSettings settings = this.buildSettings(); + double worldScale = Math.max(1.0E-4, settings.worldScale()); + int radiusChunks = this.currentOverpassCacheRadiusChunks(); + double radiusBlocks = Math.max(16.0, radiusChunks * 16.0); + double blocksPerDegree = Math.max(1.0E-4, EarthProjection.blocksPerDegree(worldScale)); + double centerX = settings.spawnLongitude() * blocksPerDegree; + double centerZ = EarthProjection.latToBlockZ(settings.spawnLatitude(), worldScale); + double west = clampLongitude((centerX - radiusBlocks) / blocksPerDegree); + double east = clampLongitude((centerX + radiusBlocks) / blocksPerDegree); + double latA = EarthProjection.blockZToLat(centerZ - radiusBlocks, worldScale); + double latB = EarthProjection.blockZToLat(centerZ + radiusBlocks, worldScale); + double south = Math.max(-85.05112878, Math.min(latA, latB)); + double north = Math.min(85.05112878, Math.max(latA, latB)); + if (west > east) { + west = -180.0; + east = 180.0; + } + return new EarthCustomizeScreen.OverpassCacheArea( + new GeoBounds(south, west, north, east), + radiusChunks, + settings.spawnLatitude(), + settings.spawnLongitude(), + worldScale + ); + } + + private int currentOverpassCacheRadiusChunks() { + boolean voxyPregen = this.findToggleValue("voxy_chunk_pregen_enabled", EarthGeneratorSettings.DEFAULT.voxyChunkPregenEnabled()); + int radius = voxyPregen + ? (int)Math.round(this.findSliderValue("voxy_chunk_pregen_max_radius", EarthGeneratorSettings.DEFAULT.voxyChunkPregenMaxRadius())) + : DEFAULT_OVERPASS_CACHE_RADIUS_CHUNKS; + return Mth.clamp(radius, MIN_OVERPASS_CACHE_RADIUS_CHUNKS, 4096); + } + + private static double clampLongitude(double longitude) { + return Math.max(-180.0, Math.min(180.0, longitude)); + } + private void openPreviewFullScreen() { if (this.minecraft != null && this.previewWidget != null) { TerrainPreviewWidget.ViewState viewState = Objects.requireNonNull(this.previewWidget.getViewState(), "viewState"); @@ -387,7 +431,7 @@ private EarthGeneratorSettings buildSettings() { double terrestrialScale = this.findSliderValue("terrestrial_height_scale", EarthGeneratorSettings.DEFAULT.terrestrialHeightScale()); double oceanicScale = this.findSliderValue("oceanic_height_scale", EarthGeneratorSettings.DEFAULT.oceanicHeightScale()); int heightOffset = (int)Math.round(this.findSliderValue("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset())); - int seaLevel = this.resolveSeaLevelSetting("sea_level", -64.0); + int seaLevel = this.resolveSeaLevelSetting("sea_level", 62.0); int maxAltitude = this.resolveAltitudeSetting("max_altitude", -1.0); int minAltitude = this.resolveAltitudeSetting("min_altitude", -2048.0); int riverLakeShorelineBlend = (int)Math.round( @@ -535,7 +579,7 @@ private void applySettingsToCategories(EarthGeneratorSettings settings, boolean this.setSliderValue("terrestrial_height_scale", initialSettings.terrestrialHeightScale()); this.setSliderValue("oceanic_height_scale", initialSettings.oceanicHeightScale()); this.setSliderValue("height_offset", initialSettings.heightOffset()); - this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? -64.0 : initialSettings.seaLevel()); + this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? 62.0 : initialSettings.seaLevel()); this.setSliderValue("max_altitude", initialSettings.maxAltitude() == Integer.MIN_VALUE ? -1.0 : initialSettings.maxAltitude()); this.setSliderValue("min_altitude", initialSettings.minAltitude() == Integer.MIN_VALUE ? -2048.0 : initialSettings.minAltitude()); this.setSliderValue("river_lake_shoreline_blend", initialSettings.riverLakeShorelineBlend()); @@ -576,6 +620,25 @@ private void applySettingsToCategories(EarthGeneratorSettings settings, boolean this.setRenderModeValue("distant_horizons_render_mode", initialSettings.distantHorizonsRenderMode()); } + private void applyWlbPreset() { + this.setSliderValue("world_scale", EarthGeneratorSettings.DEFAULT.worldScale()); + this.setDemSelectionValue(EarthGeneratorSettings.DEFAULT.demSelection()); + this.setSliderValue("terrestrial_height_scale", EarthGeneratorSettings.DEFAULT.terrestrialHeightScale()); + this.setSliderValue("oceanic_height_scale", EarthGeneratorSettings.DEFAULT.oceanicHeightScale()); + this.setSliderValue("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset()); + this.setSliderValue("sea_level", 62.0); + this.setSliderValue("max_altitude", -1.0); + this.setSliderValue("min_altitude", -2048.0); + this.setToggleValue("enable_roads", true); + this.setToggleValue("enable_buildings", true); + this.setToggleValue("enable_water", true); + + EarthCustomizeScreen.CategoryDefinition structure = this.findCategoryById("structure"); + if (structure != null) { + this.setCategoryToggleValues(structure, false); + } + } + private void setSliderValue(String key, double value) { for (EarthCustomizeScreen.CategoryDefinition category : this.categories) { for (EarthCustomizeScreen.SettingDefinition setting : category.getSettings()) { @@ -745,7 +808,7 @@ private List createCategories() { ).hideFromRoot().parent("world"); List worldSettings = new ArrayList<>( List.of( - slider("world_scale", 30.0, 1.0, 500.0, 5.0) + slider("world_scale", EarthGeneratorSettings.DEFAULT.worldScale(), 1.0, 500.0, 5.0) .withDisplay(EarthCustomizeScreen::formatWorldScale) .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), this.categoryLink(demProvidersCategory) @@ -768,7 +831,7 @@ private List createCategories() { .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), slider("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset(), -2000.0, 128.0, 1.0) .withDisplay(EarthCustomizeScreen::formatHeightOffset), - slider("sea_level", -64.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), + slider("sea_level", 62.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), slider("max_altitude", -1.0, -1.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMaxAltitude), slider("min_altitude", EarthGeneratorSettings.DEFAULT.minAltitude(), -2048.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMinAltitude), slider("river_lake_shoreline_blend", EarthGeneratorSettings.DEFAULT.riverLakeShorelineBlend(), 0.0, 10.0, 1.0) @@ -1001,8 +1064,15 @@ private static EarthCustomizeScreen.CacheActionDefinition cacheActionButton( Com return new EarthCustomizeScreen.CacheActionDefinition(label, action); } - private static List dataSourcesEntries() { + private List dataSourcesEntries() { List entries = new ArrayList<>(); + entries.add(infoHeader("Arnis / OSM Overpass")); + entries.add(infoLine("Road and building details are cached locally and reused.")); + entries.add(new EarthCustomizeScreen.OverpassProbeDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheStatusDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.ESTIMATE)); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.PREFETCH)); + entries.add(infoSpacer()); entries.add(infoHeader("ESA WorldCover 2021 (land cover)")); entries.add(infoLine("ESA WorldCover 2021 (10 m land cover, v200)")); entries.add(infoLine("© ESA WorldCover project / Contains modified Copernicus Sentinel data (2021)")); @@ -1499,15 +1569,19 @@ private EarthCustomizeScreen.CategoryDefinition findCategoryById( String id) { private AbstractWidget createWorldHeaderActions(EarthCustomizeScreen.CategoryDefinition category) { EarthGeneratorSettings defaultSettings = Objects.requireNonNull(EarthGeneratorSettings.DEFAULT, "defaultSettings"); Component restoreDefaultsLabel = Objects.requireNonNull(Component.translatable("gui.tellus.restore_defaults"), "restoreDefaultsLabel"); - Component selectPresetLabel = Objects.requireNonNull(Component.translatable("gui.tellus.select_preset"), "selectPresetLabel"); - Component comingSoonTooltip = Objects.requireNonNull( - Component.translatable("gui.tellus.coming_soon").withStyle(ChatFormatting.GRAY), "selectPresetTooltip" + Component wlbPresetLabel = Objects.requireNonNull(Component.translatable("gui.tellus.wlb_preset"), "wlbPresetLabel"); + Component wlbPresetTooltip = Objects.requireNonNull( + Component.translatable("gui.tellus.wlb_preset.tooltip").withStyle(ChatFormatting.GRAY), "wlbPresetTooltip" ); return new EarthCustomizeScreen.DualButtonWidget(restoreDefaultsLabel, btn -> { this.applySettingsToCategories(defaultSettings, true); this.onSettingsChanged(); this.showCategory(category); - }, selectPresetLabel, btn -> {}, false, comingSoonTooltip); + }, wlbPresetLabel, btn -> { + this.applyWlbPreset(); + this.onSettingsChanged(); + this.showCategory(category); + }, true, wlbPresetTooltip); } @@ -1742,6 +1816,476 @@ public AbstractWidget createWidget(Runnable onChange) { } } + @Environment(EnvType.CLIENT) + private static final class OverpassProbeDefinition implements EarthCustomizeScreen.SettingDefinition { + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassProbeWidget(); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassProbeWidget extends AbstractWidget { + private final Button button; + + private OverpassProbeWidget() { + super(0, 0, 0, 20, Component.empty()); + this.button = Button.builder(EarthCustomizeScreen.OverpassProbeManager.state().message(), btn -> EarthCustomizeScreen.OverpassProbeManager.test()) + .bounds(0, 0, 0, 20) + .build(); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassProbeState state = EarthCustomizeScreen.OverpassProbeManager.state(); + this.button.active = !state.testing(); + this.button.setMessage(state.message()); + this.button.setTooltip(Tooltip.create(state.tooltip())); + this.button.setX(this.getX()); + this.button.setY(this.getY()); + this.button.setWidth(this.width); + WidgetCompat.setHeight(this.button, this.height); + this.button.render(graphics, mouseX, mouseY, delta); + } + + public void onClick(double mouseX, double mouseY) { + this.button.mouseClicked(mouseX, mouseY, 0); + } + + protected void onDrag(double mouseX, double mouseY, double deltaX, double deltaY) { + this.button.mouseDragged(mouseX, mouseY, 0, deltaX, deltaY); + } + + public void onRelease(double mouseX, double mouseY) { + this.button.mouseReleased(mouseX, mouseY, 0); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassProbeManager { + private static final AtomicReference STATE = new AtomicReference<>(EarthCustomizeScreen.OverpassProbeState.idle()); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() { + private int index; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "tellus-overpass-probe-" + ++this.index); + thread.setDaemon(true); + return thread; + } + }); + + private OverpassProbeManager() { + } + + private static EarthCustomizeScreen.OverpassProbeState state() { + return STATE.get(); + } + + private static void test() { + EarthCustomizeScreen.OverpassProbeState current = STATE.get(); + if (current.testing()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassProbeState.testingState()); + CompletableFuture.supplyAsync(OverpassExternalFeatureSource::probeConfiguredEndpoints, EXECUTOR) + .thenAccept(results -> STATE.set(EarthCustomizeScreen.OverpassProbeState.complete(results))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to test Arnis Overpass sources", error); + STATE.set(EarthCustomizeScreen.OverpassProbeState.failed(error)); + return null; + }); + } + } + + private record OverpassProbeState(boolean testing, Component message, Component tooltip) { + private static OverpassProbeState idle() { + return new OverpassProbeState( + false, + Component.translatable("tellus.datasource.overpass.test"), + Component.translatable("tellus.datasource.overpass.test.tooltip") + ); + } + + private static OverpassProbeState testingState() { + return new OverpassProbeState( + true, + Component.translatable("tellus.datasource.overpass.testing"), + Component.translatable("tellus.datasource.overpass.testing.tooltip") + ); + } + + private static OverpassProbeState failed(Throwable error) { + String message = error.getMessage(); + return new OverpassProbeState( + false, + Component.translatable("tellus.datasource.overpass.failed"), + Component.literal(message == null || message.isBlank() ? error.getClass().getSimpleName() : message) + ); + } + + private static OverpassProbeState complete(List results) { + int ok = 0; + for (OverpassExternalFeatureSource.EndpointProbeResult result : results) { + if (result.ok()) { + ok++; + } + } + + Component message = ok > 0 + ? Component.translatable("tellus.datasource.overpass.ok", ok, results.size()) + : Component.translatable("tellus.datasource.overpass.none", results.size()); + return new OverpassProbeState(false, message, Component.literal(describe(results))); + } + + private static String describe(List results) { + if (results.isEmpty()) { + return "No endpoints configured."; + } + + StringBuilder builder = new StringBuilder(); + for (OverpassExternalFeatureSource.EndpointProbeResult result : results) { + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(result.ok() ? "OK " : "FAIL ") + .append(hostLabel(result.endpoint())) + .append(" ") + .append(result.elapsedMs()) + .append("ms"); + if (!result.ok() && result.message() != null && !result.message().isBlank()) { + builder.append(" - ").append(result.message()); + } + } + return builder.toString(); + } + + private static String hostLabel(String endpoint) { + try { + URI uri = URI.create(endpoint); + return uri.getHost() == null ? endpoint : uri.getHost(); + } catch (IllegalArgumentException error) { + return endpoint; + } + } + } + + private record OverpassCacheArea(GeoBounds bounds, int radiusChunks, double centerLatitude, double centerLongitude, double worldScale) { + } + + private enum OverpassCacheAction { + ESTIMATE, + PREFETCH + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheStatusDefinition implements EarthCustomizeScreen.SettingDefinition { + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassCacheStatusWidget(); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheStatusWidget extends AbstractWidget { + private OverpassCacheStatusWidget() { + super(0, 0, 0, 20, Component.empty()); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassCacheState state = EarthCustomizeScreen.OverpassCacheManager.state(); + this.setTooltip(Tooltip.create(state.tooltip())); + Font font = Minecraft.getInstance().font; + int textWidth = font.width(state.message()); + int availableWidth = Math.max(1, this.width - 8); + float scale = textWidth > availableWidth ? (float)availableWidth / (float)textWidth : 1.0F; + float scaledWidth = textWidth * scale; + float scaledHeight = 9.0F * scale; + float textX = this.getX() + (this.width - scaledWidth) * 0.5F; + float textY = this.getY() + (this.height - scaledHeight) * 0.5F; + graphics.pose().pushPose(); + graphics.pose().translate(textX, textY, 0.0F); + graphics.pose().scale(scale, scale, 1.0F); + graphics.drawString(font, state.message(), 0, 0, -4605511, true); + graphics.pose().popPose(); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheActionDefinition implements EarthCustomizeScreen.SettingDefinition { + private final EarthCustomizeScreen screen; + private final EarthCustomizeScreen.OverpassCacheAction action; + + private OverpassCacheActionDefinition(EarthCustomizeScreen screen, EarthCustomizeScreen.OverpassCacheAction action) { + this.screen = Objects.requireNonNull(screen, "screen"); + this.action = Objects.requireNonNull(action, "action"); + } + + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassCacheActionWidget(this.screen, this.action); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheActionWidget extends AbstractWidget { + private final EarthCustomizeScreen screen; + private final EarthCustomizeScreen.OverpassCacheAction action; + private final Button button; + + private OverpassCacheActionWidget(EarthCustomizeScreen screen, EarthCustomizeScreen.OverpassCacheAction action) { + super(0, 0, 0, 20, Component.empty()); + this.screen = Objects.requireNonNull(screen, "screen"); + this.action = Objects.requireNonNull(action, "action"); + this.button = Button.builder(this.label(), btn -> this.runAction()).bounds(0, 0, 0, 20).build(); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassCacheState state = EarthCustomizeScreen.OverpassCacheManager.state(); + this.button.active = this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE ? !state.busy() : state.canPrefetch(); + this.button.setMessage(this.label()); + this.button.setTooltip(Tooltip.create(this.tooltip(state))); + this.button.setX(this.getX()); + this.button.setY(this.getY()); + this.button.setWidth(this.width); + WidgetCompat.setHeight(this.button, this.height); + this.button.render(graphics, mouseX, mouseY, delta); + } + + public void onClick(double mouseX, double mouseY) { + this.button.mouseClicked(mouseX, mouseY, 0); + } + + protected void onDrag(double mouseX, double mouseY, double deltaX, double deltaY) { + this.button.mouseDragged(mouseX, mouseY, 0, deltaX, deltaY); + } + + public void onRelease(double mouseX, double mouseY) { + this.button.mouseReleased(mouseX, mouseY, 0); + } + + private void runAction() { + EarthCustomizeScreen.OverpassCacheArea area = this.screen.currentOverpassCacheArea(); + if (this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE) { + EarthCustomizeScreen.OverpassCacheManager.estimate(area); + } else { + EarthCustomizeScreen.OverpassCacheManager.prefetch(area); + } + } + + private Component label() { + return this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE + ? Component.translatable("tellus.datasource.overpass.cache.estimate") + : Component.translatable("tellus.datasource.overpass.cache.prefetch"); + } + + private Component tooltip(EarthCustomizeScreen.OverpassCacheState state) { + if (this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE) { + return Component.translatable("tellus.datasource.overpass.cache.estimate.tooltip"); + } + if (state.busy()) { + return Component.translatable("tellus.datasource.overpass.cache.busy.tooltip"); + } + if (!state.canPrefetch()) { + return Component.translatable("tellus.datasource.overpass.cache.prefetch.unavailable.tooltip"); + } + return Component.translatable("tellus.datasource.overpass.cache.prefetch.tooltip"); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheManager { + private static final AtomicReference STATE = new AtomicReference<>(EarthCustomizeScreen.OverpassCacheState.idle()); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() { + private int index; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "tellus-overpass-cache-" + ++this.index); + thread.setDaemon(true); + return thread; + } + }); + + private OverpassCacheManager() { + } + + private static EarthCustomizeScreen.OverpassCacheState state() { + return STATE.get(); + } + + private static void estimate(EarthCustomizeScreen.OverpassCacheArea area) { + EarthCustomizeScreen.OverpassCacheState current = STATE.get(); + if (current.busy()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassCacheState.estimating(area)); + CompletableFuture.supplyAsync(() -> OverpassExternalFeatureSource.estimateConfiguredCache(area.bounds()), EXECUTOR) + .thenAccept(estimate -> STATE.set(EarthCustomizeScreen.OverpassCacheState.estimated(area, estimate))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to estimate Arnis Overpass cache", error); + STATE.set(EarthCustomizeScreen.OverpassCacheState.failed(error)); + return null; + }); + } + + private static void prefetch(EarthCustomizeScreen.OverpassCacheArea area) { + EarthCustomizeScreen.OverpassCacheState current = STATE.get(); + if (current.busy() || !current.canPrefetch()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassCacheState.prefetching(area, current.estimate())); + CompletableFuture.supplyAsync(() -> OverpassExternalFeatureSource.prefetchConfiguredBounds(area.bounds()), EXECUTOR) + .thenAccept(result -> STATE.set(EarthCustomizeScreen.OverpassCacheState.prefetched(area, result))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to prefetch Arnis Overpass cache", error); + STATE.set(EarthCustomizeScreen.OverpassCacheState.failed(error)); + return null; + }); + } + } + + private record OverpassCacheState( + boolean busy, + Component message, + Component tooltip, + EarthCustomizeScreen.OverpassCacheArea area, + OverpassExternalFeatureSource.CacheEstimate estimate + ) { + private static OverpassCacheState idle() { + return new OverpassCacheState( + false, + Component.translatable("tellus.datasource.overpass.cache.idle"), + Component.translatable("tellus.datasource.overpass.cache.idle.tooltip"), + null, + null + ); + } + + private static OverpassCacheState estimating(EarthCustomizeScreen.OverpassCacheArea area) { + return new OverpassCacheState( + true, + Component.translatable("tellus.datasource.overpass.cache.estimating"), + Component.literal(describeArea(area)), + area, + null + ); + } + + private static OverpassCacheState estimated(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate) { + Component message = estimate.enabled() + ? Component.translatable( + "tellus.datasource.overpass.cache.summary", + estimate.cachedTiles(), + estimate.totalTiles(), + estimate.missingTiles() + ) + : Component.translatable("tellus.datasource.overpass.cache.disabled"); + return new OverpassCacheState(false, message, Component.literal(describeEstimate(area, estimate)), area, estimate); + } + + private static OverpassCacheState prefetching( + EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate + ) { + return new OverpassCacheState( + true, + Component.translatable("tellus.datasource.overpass.cache.prefetching"), + Component.literal(describeEstimate(area, estimate)), + area, + estimate + ); + } + + private static OverpassCacheState prefetched(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.PrefetchResult result) { + OverpassExternalFeatureSource.CacheEstimate after = result.after(); + int beforeReady = result.before().cityDetailsEnabled() ? result.before().cityDetailCachedTiles() : result.before().cachedTiles(); + int afterReady = after.cityDetailsEnabled() ? after.cityDetailCachedTiles() : after.cachedTiles(); + int gained = Math.max(0, afterReady - beforeReady); + Component message = Component.translatable("tellus.datasource.overpass.cache.prefetched", gained, afterReady, after.totalTiles()); + return new OverpassCacheState(false, message, Component.literal(describePrefetch(area, result)), area, after); + } + + private static OverpassCacheState failed(Throwable error) { + String message = error.getMessage(); + return new OverpassCacheState( + false, + Component.translatable("tellus.datasource.overpass.cache.failed"), + Component.literal(message == null || message.isBlank() ? error.getClass().getSimpleName() : message), + null, + null + ); + } + + private boolean canPrefetch() { + return !this.busy + && this.estimate != null + && this.estimate.enabled() + && this.estimate.networkEnabled() + && this.estimate.missingTiles() > 0; + } + + private static String describeArea(EarthCustomizeScreen.OverpassCacheArea area) { + return String.format( + Locale.ROOT, + "Spawn %.5f, %.5f\nRadius: %d chunks\nWorld scale: 1:%.1fm", + area.centerLatitude(), + area.centerLongitude(), + area.radiusChunks(), + area.worldScale() + ); + } + + private static String describeEstimate(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate) { + if (estimate == null) { + return describeArea(area); + } + if (!estimate.enabled()) { + return "Overpass source is disabled."; + } + + return describeArea(area) + + "\nTiles: " + + estimate.cachedTiles() + + " cached / " + + estimate.totalTiles() + + " total" + + "\nMissing: " + + estimate.missingTiles() + + "\nCached size: " + + EarthCustomizeScreen.formatBytes(estimate.cachedBytes()) + + (estimate.cityDetailsEnabled() + ? "\nCity details: " + estimate.cityDetailCachedTiles() + " ready / " + estimate.totalTiles() + " total" + : "") + + "\nNetwork: " + + (estimate.networkEnabled() ? "cache-first" : "cache-only") + + "\nSession budget: " + + estimate.sessionNetworkTileBudget() + + " tiles" + + "\nRouting: WLB 18127 rule proxy."; + } + + private static String describePrefetch(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.PrefetchResult result) { + return describeEstimate(area, result.after()) + + "\nAttempted: " + + result.attemptedTiles() + + "\nNew cached: " + + result.cachedTiles() + + "\nFailed/skipped: " + + result.failedTiles(); + } + } + @Environment(EnvType.CLIENT) private static final class CacheActionDefinition implements EarthCustomizeScreen.SettingDefinition { diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java b/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java index b244b0990..0dcce2425 100644 --- a/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java +++ b/mc1201/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java @@ -1,36 +1,70 @@ package com.yucareux.tellus.client.screen; +import com.yucareux.tellus.client.teleport.TeleportWaypoint; +import com.yucareux.tellus.client.teleport.TeleportWaypointStore; import com.yucareux.tellus.client.widget.map.PlaceSearchWidget; import com.yucareux.tellus.client.widget.map.SlippyMapPoint; import com.yucareux.tellus.client.widget.map.SlippyMapWidget; import com.yucareux.tellus.client.widget.map.component.MarkerMapComponent; +import com.yucareux.tellus.client.widget.map.component.WaypointMapComponent; import com.yucareux.tellus.network.GeoTpTeleportPayload; import com.yucareux.tellus.world.data.source.Geocoder; import com.yucareux.tellus.world.data.source.NominatimGeocoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; @Environment(EnvType.CLIENT) public class EarthTeleportScreen extends Screen { private static final int DEFAULT_ZOOM = 6; - + private static final int WAYPOINT_ROW_HEIGHT = 22; + private static final int PANEL_PADDING = 10; + private static final int PANEL_MIN_WIDTH = 196; + private static final int PANEL_MAX_WIDTH = 232; + private static final int SEARCH_WIDTH = 220; + private final Screen parent; - private final double initialLatitude; - private final double initialLongitude; + private final double spawnLatitude; + private final double spawnLongitude; + private final TeleportWaypointStore waypointStore; + private final List waypoints; + private double markerLatitude; + private double markerLongitude; + private String selectedWaypointId; + private String noteDraft = ""; + private int waypointScrollOffset; + private int panelX; + private int panelWidth; + private int noteLabelY; + private int coordinateY; private SlippyMapWidget mapWidget; private MarkerMapComponent markerComponent; private PlaceSearchWidget searchWidget; + private EditBox noteBox; + private Button deleteButton; + private Button teleportButton; + private boolean suppressMapRelease; + private boolean pendingWaypointSelectionClick; - public EarthTeleportScreen( Screen parent, double latitude, double longitude) { + public EarthTeleportScreen(Screen parent, double latitude, double longitude, double spawnLatitude, double spawnLongitude) { super(Component.translatable("gui.earth.teleport_map")); this.parent = parent; - this.initialLatitude = latitude; - this.initialLongitude = longitude; + this.markerLatitude = latitude; + this.markerLongitude = longitude; + this.spawnLatitude = spawnLatitude; + this.spawnLongitude = spawnLongitude; + this.waypointStore = TeleportWaypointStore.create(Minecraft.getInstance()); + this.waypoints = new ArrayList<>(this.waypointStore.load(spawnLatitude, spawnLongitude)); } protected void init() { @@ -38,26 +72,103 @@ protected void init() { this.mapWidget.close(); } - int mapX = 20; - int mapY = 20; - int mapWidth = this.width - 40; - int mapHeight = this.height - 60; + if (this.searchWidget != null) { + this.searchWidget.close(); + } + + this.panelWidth = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, this.width / 3)); + if (this.width - this.panelWidth < 180) { + this.panelWidth = Math.max(180, this.width - 180); + } + + this.panelX = this.width - this.panelWidth; + int mapX = 8; + int mapY = 8; + int mapWidth = Math.max(64, this.panelX - mapX - 8); + int mapHeight = Math.max(64, this.height - 16); this.mapWidget = new SlippyMapWidget(mapX, mapY, mapWidth, mapHeight); - this.markerComponent = new MarkerMapComponent(new SlippyMapPoint(this.initialLatitude, this.initialLongitude)).allowMovement(); + this.mapWidget.setAttributionBottomPadding(0); + this.mapWidget.addComponent(new WaypointMapComponent(() -> this.waypoints, () -> this.selectedWaypointId, this::selectWaypointFromMap)); + this.markerComponent = new MarkerMapComponent(new SlippyMapPoint(this.markerLatitude, this.markerLongitude)).allowMovement(); this.mapWidget.addComponent(this.markerComponent); - this.mapWidget.getMap().focus(this.initialLatitude, this.initialLongitude, DEFAULT_ZOOM); + this.mapWidget.getMap().focus(this.markerLatitude, this.markerLongitude, DEFAULT_ZOOM); + Geocoder geocoder = new NominatimGeocoder(); - this.searchWidget = new PlaceSearchWidget(mapX + 5, mapY + 5, 200, 20, geocoder, this::handleSearch); + int searchWidth = Math.min(SEARCH_WIDTH, Math.max(96, mapWidth - 12)); + this.searchWidget = new PlaceSearchWidget(mapX + 6, mapY + 6, searchWidth, 20, geocoder, this::handleSearch); this.addRenderableOnly(this.mapWidget); this.addRenderableWidget(this.searchWidget); - int buttonY = this.height - 28; + this.addWidget(this.mapWidget); + + this.layoutSidePanel(); + this.updateActionState(); + } + + private void layoutSidePanel() { + int controlX = this.panelX + PANEL_PADDING; + int controlWidth = Math.max(96, this.panelWidth - PANEL_PADDING * 2); + int listY = 34; + int actionY = Math.max(130, this.height - 80); + int noteBoxY = Math.max(listY + 46, actionY - 34); + int listBottom = noteBoxY - 12; + int waypointRows = Math.max(2, (listBottom - listY) / WAYPOINT_ROW_HEIGHT); + int maxOffset = Math.max(0, this.waypoints.size() - waypointRows); + this.waypointScrollOffset = Math.min(this.waypointScrollOffset, maxOffset); + + int visibleRows = Math.min(waypointRows, Math.max(0, this.waypoints.size() - this.waypointScrollOffset)); + for (int i = 0; i < visibleRows; i++) { + TeleportWaypoint waypoint = this.waypoints.get(this.waypointScrollOffset + i); + int buttonY = listY + i * WAYPOINT_ROW_HEIGHT; + this.addRenderableWidget( + Button.builder(this.waypointButtonLabel(waypoint, controlWidth - 8), button -> this.selectWaypoint(waypoint.id(), true)) + .bounds(controlX, buttonY, controlWidth, 20) + .build() + ); + } + + if (maxOffset > 0) { + int pageY = listY + waypointRows * WAYPOINT_ROW_HEIGHT + 2; + int halfWidth = (controlWidth - 4) / 2; + Button previous = Button.builder(Component.translatable("gui.earth.waypoints.previous"), button -> { + this.captureCurrentInputs(); + this.waypointScrollOffset = Math.max(0, this.waypointScrollOffset - waypointRows); + this.rebuildScreen(); + }).bounds(controlX, pageY, halfWidth, 20).build(); + previous.active = this.waypointScrollOffset > 0; + this.addRenderableWidget(previous); + Button next = Button.builder(Component.translatable("gui.earth.waypoints.next"), button -> { + this.captureCurrentInputs(); + this.waypointScrollOffset = Math.min(maxOffset, this.waypointScrollOffset + waypointRows); + this.rebuildScreen(); + }).bounds(controlX + halfWidth + 4, pageY, halfWidth, 20).build(); + next.active = this.waypointScrollOffset < maxOffset; + this.addRenderableWidget(next); + } + + this.noteLabelY = noteBoxY - 11; + this.noteBox = new EditBox(this.font, controlX, noteBoxY, controlWidth, 20, Component.translatable("gui.earth.waypoint_note")); + this.noteBox.setMaxLength(64); + this.noteBox.setValue(this.noteDraft); + this.noteBox.setHint(Component.translatable("gui.earth.waypoint_note")); + this.addRenderableWidget(this.noteBox); + this.coordinateY = noteBoxY + 24; + int smallWidth = (controlWidth - 4) / 2; this.addRenderableWidget( - Button.builder(Component.translatable("gui.earth.teleport"), button -> this.sendTeleport()).bounds(this.width / 2 - 154, buttonY, 150, 20).build() + Button.builder(Component.translatable("gui.earth.waypoint_save"), button -> this.saveWaypoint()) + .bounds(controlX, actionY, smallWidth, 20) + .build() ); + this.deleteButton = Button.builder(Component.translatable("gui.earth.waypoint_delete"), button -> this.deleteWaypoint()) + .bounds(controlX + smallWidth + 4, actionY, smallWidth, 20) + .build(); + this.addRenderableWidget(this.deleteButton); + this.teleportButton = Button.builder(Component.translatable("gui.earth.teleport"), button -> this.sendTeleport()) + .bounds(controlX, actionY + 24, controlWidth, 20) + .build(); + this.addRenderableWidget(this.teleportButton); this.addRenderableWidget( - Button.builder(Component.translatable("gui.cancel"), button -> this.closeScreen()).bounds(this.width / 2 + 4, buttonY, 150, 20).build() + Button.builder(Component.translatable("gui.cancel"), button -> this.closeScreen()).bounds(controlX, actionY + 48, controlWidth, 20).build() ); - this.addWidget(this.mapWidget); } protected void setInitialFocus() { @@ -67,26 +178,154 @@ protected void setInitialFocus() { } private void handleSearch(double latitude, double longitude) { + this.markerLatitude = latitude; + this.markerLongitude = longitude; + this.selectedWaypointId = null; + this.noteDraft = ""; + if (this.noteBox != null) { + this.noteBox.setValue(""); + } + this.markerComponent.moveMarker(latitude, longitude); this.mapWidget.getMap().focus(latitude, longitude, 12); + this.updateActionState(); + } + + private void selectWaypointFromMap(String waypointId) { + this.pendingWaypointSelectionClick = true; + this.selectWaypoint(waypointId, false); + } + + private void selectWaypoint(String waypointId, boolean focusMap) { + TeleportWaypoint waypoint = this.findWaypoint(waypointId); + if (waypoint != null) { + this.selectedWaypointId = waypoint.id(); + this.noteDraft = waypoint.label(); + this.markerLatitude = waypoint.latitude(); + this.markerLongitude = waypoint.longitude(); + if (this.noteBox != null) { + this.noteBox.setValue(this.noteDraft); + } + + if (this.markerComponent != null) { + this.markerComponent.moveMarker(this.markerLatitude, this.markerLongitude); + } + + if (focusMap && this.mapWidget != null) { + this.mapWidget.getMap().focus(this.markerLatitude, this.markerLongitude, 12); + } + + this.updateActionState(); + } + } + + private void saveWaypoint() { + this.captureCurrentInputs(); + String label = this.noteDraft.trim(); + if (label.isEmpty()) { + label = String.format(Locale.ROOT, "Point %d", Math.max(1, this.waypoints.size())); + } + + TeleportWaypoint selected = this.selectedWaypoint(); + if (selected == null) { + selected = new TeleportWaypoint(UUID.randomUUID().toString(), label, this.markerLatitude, this.markerLongitude, false); + this.waypoints.add(selected); + this.selectedWaypointId = selected.id(); + } else { + selected.setLabel(label); + if (!selected.initialSpawn()) { + selected.setLatitude(this.markerLatitude); + selected.setLongitude(this.markerLongitude); + } + } + + this.noteDraft = selected.label(); + this.waypointStore.save(this.waypoints); + this.rebuildScreen(); + } + + private void deleteWaypoint() { + TeleportWaypoint selected = this.selectedWaypoint(); + if (selected != null && !selected.initialSpawn()) { + this.waypoints.remove(selected); + this.selectedWaypointId = null; + this.noteDraft = ""; + this.waypointStore.save(this.waypoints); + this.rebuildScreen(); + } } private void sendTeleport() { + this.captureCurrentMarker(); + if (this.minecraft != null) { + if (!ClientPlayNetworking.canSend(GeoTpTeleportPayload.TYPE)) { + if (this.minecraft.player != null) { + this.minecraft.player.displayClientMessage(Component.literal("Tellus: Server does not accept GeoTP requests."), true); + } + + this.closeScreen(); + } else { + ClientPlayNetworking.send(new GeoTpTeleportPayload(this.markerLatitude, this.markerLongitude)); + this.closeScreen(); + } + } + } + + private void captureCurrentInputs() { + this.captureCurrentMarker(); + if (this.noteBox != null) { + this.noteDraft = this.noteBox.getValue(); + } + } + + private void captureCurrentMarker() { if (this.markerComponent != null) { SlippyMapPoint marker = this.markerComponent.getMarker(); - if (marker != null && this.minecraft != null) { - if (!ClientPlayNetworking.canSend(GeoTpTeleportPayload.TYPE)) { - if (this.minecraft.player != null) { - this.minecraft.player.displayClientMessage(Component.literal("Tellus: Server does not accept GeoTP requests."), true); - } - - this.closeScreen(); - } else { - ClientPlayNetworking.send(new GeoTpTeleportPayload(marker.getLatitude(), marker.getLongitude())); - this.closeScreen(); - } + if (marker != null) { + this.markerLatitude = marker.getLatitude(); + this.markerLongitude = marker.getLongitude(); + } + } + } + + private void rebuildScreen() { + this.clearWidgets(); + this.init(); + } + + private TeleportWaypoint selectedWaypoint() { + return this.selectedWaypointId == null ? null : this.findWaypoint(this.selectedWaypointId); + } + + private TeleportWaypoint findWaypoint(String waypointId) { + for (TeleportWaypoint waypoint : this.waypoints) { + if (waypoint.id().equals(waypointId)) { + return waypoint; } } + + return null; + } + + private Component waypointButtonLabel(TeleportWaypoint waypoint, int maxWidth) { + String prefix = waypoint.initialSpawn() ? "* " : ""; + String label = prefix + waypoint.label(); + if (this.font.width(label) > maxWidth) { + label = this.font.plainSubstrByWidth(label, Math.max(8, maxWidth - this.font.width("..."))) + "..."; + } + + return Component.literal(label); + } + + private void updateActionState() { + TeleportWaypoint selected = this.selectedWaypoint(); + if (this.deleteButton != null) { + this.deleteButton.active = selected != null && !selected.initialSpawn(); + } + + if (this.teleportButton != null) { + this.teleportButton.active = this.markerComponent != null && this.markerComponent.getMarker() != null; + } } private void closeScreen() { @@ -95,9 +334,60 @@ private void closeScreen() { } } - public void render( GuiGraphics graphics, int mouseX, int mouseY, float delta) { + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (this.isSearchOverlayMouseOver(mouseX, mouseY)) { + this.suppressMapRelease = true; + this.cancelMapInteraction(); + this.setFocused(this.searchWidget); + this.searchWidget.setFocused(true); + this.searchWidget.mouseClicked(mouseX, mouseY, button); + return true; + } + + this.suppressMapRelease = false; + this.pendingWaypointSelectionClick = false; + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (this.suppressMapRelease || this.isSearchOverlayMouseOver(mouseX, mouseY)) { + this.suppressMapRelease = false; + this.cancelMapInteraction(); + return true; + } + + SlippyMapPoint before = this.markerComponent == null ? null : this.markerComponent.getMarker(); + boolean waypointClick = this.pendingWaypointSelectionClick; + boolean handled = super.mouseReleased(mouseX, mouseY, button); + SlippyMapPoint after = this.markerComponent == null ? null : this.markerComponent.getMarker(); + if (waypointClick && this.selectedWaypointId != null) { + this.selectWaypoint(this.selectedWaypointId, false); + } else if (button == 0 && this.mapWidget != null && this.mapWidget.isMouseOver(mouseX, mouseY) && markerMoved(before, after)) { + this.captureCurrentMarker(); + if (!this.pendingWaypointSelectionClick) { + this.selectedWaypointId = null; + this.noteDraft = ""; + if (this.noteBox != null) { + this.noteBox.setValue(""); + } + } + + this.updateActionState(); + } + + this.pendingWaypointSelectionClick = false; + return handled; + } + + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { graphics.fill(0, 0, this.width, this.height, -1072689136); - graphics.drawCenteredString(this.font, this.title, this.width / 2, 4, 16777215); + graphics.fill(this.panelX, 0, this.width, this.height, -15066598); + graphics.drawString(this.font, this.title, this.panelX + PANEL_PADDING, 12, 16777215, false); + graphics.drawString(this.font, Component.translatable("gui.earth.waypoints"), this.panelX + PANEL_PADDING, 24, 13421772, false); + graphics.drawString(this.font, Component.translatable("gui.earth.waypoint_note"), this.panelX + PANEL_PADDING, this.noteLabelY, 13421772, false); + graphics.drawString(this.font, Component.literal(this.formatMarkerCoordinates()), this.panelX + PANEL_PADDING, this.coordinateY, 11184810, false); super.render(graphics, mouseX, mouseY, delta); } @@ -121,4 +411,27 @@ public void removed() { this.searchWidget.close(); } } + + private String formatMarkerCoordinates() { + this.captureCurrentMarker(); + return String.format(Locale.ROOT, "%.5f, %.5f", this.markerLatitude, this.markerLongitude); + } + + private boolean isSearchOverlayMouseOver(double mouseX, double mouseY) { + return this.searchWidget != null && this.searchWidget.isMouseOver(mouseX, mouseY); + } + + private void cancelMapInteraction() { + if (this.mapWidget != null) { + this.mapWidget.cancelInteraction(); + } + } + + private static boolean markerMoved(SlippyMapPoint before, SlippyMapPoint after) { + if (before == null || after == null) { + return before != after; + } + + return Double.compare(before.getLatitude(), after.getLatitude()) != 0 || Double.compare(before.getLongitude(), after.getLongitude()) != 0; + } } diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java b/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java new file mode 100644 index 000000000..c677c5012 --- /dev/null +++ b/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java @@ -0,0 +1,59 @@ +package com.yucareux.tellus.client.teleport; + +import java.util.Objects; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(EnvType.CLIENT) +public final class TeleportWaypoint { + private final String id; + private String label; + private double latitude; + private double longitude; + private final boolean initialSpawn; + + public TeleportWaypoint(String id, String label, double latitude, double longitude, boolean initialSpawn) { + this.id = Objects.requireNonNull(id, "id"); + this.label = normalizeLabel(label); + this.latitude = latitude; + this.longitude = longitude; + this.initialSpawn = initialSpawn; + } + + public String id() { + return this.id; + } + + public String label() { + return this.label; + } + + public void setLabel(String label) { + this.label = normalizeLabel(label); + } + + public double latitude() { + return this.latitude; + } + + public void setLatitude(double latitude) { + this.latitude = latitude; + } + + public double longitude() { + return this.longitude; + } + + public void setLongitude(double longitude) { + this.longitude = longitude; + } + + public boolean initialSpawn() { + return this.initialSpawn; + } + + private static String normalizeLabel(String label) { + String safeLabel = label == null ? "" : label.trim(); + return safeLabel.isEmpty() ? "Waypoint" : safeLabel; + } +} diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java b/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java new file mode 100644 index 000000000..a59123cb6 --- /dev/null +++ b/mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java @@ -0,0 +1,145 @@ +package com.yucareux.tellus.client.teleport; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.yucareux.tellus.Tellus; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; + +@Environment(EnvType.CLIENT) +public final class TeleportWaypointStore { + public static final String INITIAL_SPAWN_ID = "initial_spawn"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final int FORMAT_VERSION = 1; + private final Path path; + + private TeleportWaypointStore(Path path) { + this.path = path; + } + + public static TeleportWaypointStore create(Minecraft minecraft) { + Path directory = FabricLoader.getInstance().getGameDir().resolve("tellus").resolve("teleport-points"); + return new TeleportWaypointStore(directory.resolve(safeFileName(resolveWorldKey(minecraft)) + ".json")); + } + + public List load(double spawnLatitude, double spawnLongitude) { + List waypoints = new ArrayList<>(); + if (Files.isRegularFile(this.path)) { + try { + JsonElement parsed = JsonParser.parseString(Files.readString(this.path, StandardCharsets.UTF_8)); + if (parsed.isJsonObject()) { + JsonArray array = parsed.getAsJsonObject().getAsJsonArray("waypoints"); + if (array != null) { + for (JsonElement element : array) { + if (element.isJsonObject()) { + readWaypoint(element.getAsJsonObject(), waypoints); + } + } + } + } + } catch (IOException | RuntimeException exception) { + Tellus.LOGGER.warn("Failed to read Tellus teleport waypoints from {}", this.path, exception); + } + } + + if (waypoints.stream().noneMatch(waypoint -> INITIAL_SPAWN_ID.equals(waypoint.id()))) { + waypoints.add(0, initialSpawn(spawnLatitude, spawnLongitude)); + this.save(waypoints); + } + + return waypoints; + } + + public void save(List waypoints) { + JsonObject root = new JsonObject(); + root.addProperty("version", FORMAT_VERSION); + JsonArray array = new JsonArray(); + for (TeleportWaypoint waypoint : waypoints) { + JsonObject entry = new JsonObject(); + entry.addProperty("id", waypoint.id()); + entry.addProperty("label", waypoint.label()); + entry.addProperty("latitude", waypoint.latitude()); + entry.addProperty("longitude", waypoint.longitude()); + entry.addProperty("initial_spawn", waypoint.initialSpawn()); + array.add(entry); + } + + root.add("waypoints", array); + try { + Files.createDirectories(this.path.getParent()); + Files.writeString(this.path, GSON.toJson(root), StandardCharsets.UTF_8); + } catch (IOException exception) { + Tellus.LOGGER.warn("Failed to save Tellus teleport waypoints to {}", this.path, exception); + } + } + + private static void readWaypoint(JsonObject object, List waypoints) { + String id = stringValue(object, "id", ""); + if (id.isBlank()) { + return; + } + + double latitude = doubleValue(object, "latitude", Double.NaN); + double longitude = doubleValue(object, "longitude", Double.NaN); + if (!Double.isFinite(latitude) || !Double.isFinite(longitude)) { + return; + } + + String label = stringValue(object, "label", id); + boolean initialSpawn = booleanValue(object, "initial_spawn", INITIAL_SPAWN_ID.equals(id)); + waypoints.add(new TeleportWaypoint(id, label, latitude, longitude, initialSpawn)); + } + + private static TeleportWaypoint initialSpawn(double latitude, double longitude) { + return new TeleportWaypoint(INITIAL_SPAWN_ID, "Initial Spawn", latitude, longitude, true); + } + + private static String resolveWorldKey(Minecraft minecraft) { + String dimension = minecraft.level == null ? "unknown" : minecraft.level.dimension().location().toString(); + if (minecraft.getSingleplayerServer() != null) { + return "singleplayer-" + minecraft.getSingleplayerServer().getWorldData().getLevelName() + "-" + dimension; + } + + ServerData server = minecraft.getCurrentServer(); + if (server != null) { + return "server-" + server.ip + "-" + dimension; + } + + return "level-" + dimension; + } + + private static String safeFileName(String raw) { + String normalized = raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_"); + String clipped = normalized.length() <= 96 ? normalized : normalized.substring(0, 96); + return clipped + "-" + Integer.toHexString(raw.hashCode()); + } + + private static String stringValue(JsonObject object, String key, String fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsString() : fallback; + } + + private static double doubleValue(JsonObject object, String key, double fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsDouble() : fallback; + } + + private static boolean booleanValue(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsBoolean() : fallback; + } +} diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java b/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java index 264bfceff..c84f96639 100644 --- a/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java +++ b/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java @@ -6,6 +6,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.mojang.blaze3d.platform.NativeImage; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -13,9 +14,11 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ExecutorService; @@ -29,6 +32,16 @@ @Environment(EnvType.CLIENT) public class SlippyMapTileCache { private static final int CACHE_SIZE = 1024; + private static final String TILE_ENDPOINTS_PROPERTY = "tellus.map.tile.endpoints"; + private static final String DEFAULT_TILE_ENDPOINTS = String.join( + ",", + "https://tile.openstreetmap.org/%d/%d/%d.png", + "https://a.tile.openstreetmap.org/%d/%d/%d.png", + "https://b.tile.openstreetmap.org/%d/%d/%d.png", + "https://c.tile.openstreetmap.org/%d/%d/%d.png", + "https://tile.openstreetmap.de/%d/%d/%d.png" + ); + private static final String[] TILE_ENDPOINTS = parseTileEndpoints(System.getProperty(TILE_ENDPOINTS_PROPERTY, DEFAULT_TILE_ENDPOINTS)); private final ExecutorService loadingService = Executors.newFixedThreadPool( 4, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("tellus-map-load-%d").build() ); @@ -108,16 +121,32 @@ private InputStream getStream(SlippyMapTilePos pos) throws IOException { if (Files.exists(cachePath)) { return new BufferedInputStream(Files.newInputStream(cachePath)); } else { - URI uri = URI.create(String.format("https://tile.openstreetmap.org/%s/%s/%s.png", pos.getZoom(), pos.getX(), pos.getY())); - URL url = uri.toURL(); - HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + byte[] data = this.fetchTileData(pos); + this.cacheData(cachePath, data); + return new ByteArrayInputStream(data); + } + } + + private byte[] fetchTileData(SlippyMapTilePos pos) throws IOException { + IOException lastError = null; + for (String endpoint : TILE_ENDPOINTS) { + String requestUrl; + try { + requestUrl = String.format(Locale.ROOT, endpoint, pos.getZoom(), pos.getX(), pos.getY()); + } catch (RuntimeException error) { + lastError = new IOException("Invalid map tile endpoint template: " + endpoint, error); + TellusDiagnostics.traffic("Slippy map tile endpoint invalid endpoint=%s error=%s", endpoint, lastError.getMessage()); + continue; + } + + HttpURLConnection connection = (HttpURLConnection)URI.create(requestUrl).toURL().openConnection(); try { connection.setConnectTimeout(5000); connection.setReadTimeout(5000); connection.setRequestProperty("User-Agent", "Tellus/2.0.0 (Minecraft Mod)"); int responseCode = connection.getResponseCode(); if (responseCode != 200) { - throw new IOException("OpenStreetMap tile request failed with HTTP " + responseCode + " for " + pos); + throw new IOException("HTTP " + responseCode); } InputStream stream = Objects.requireNonNull(connection.getInputStream(), "tileStream"); @@ -125,15 +154,32 @@ private InputStream getStream(SlippyMapTilePos pos) throws IOException { try (InputStream input = new BufferedInputStream(stream)) { byte[] data = input.readAllBytes(); - this.cacheData(cachePath, data); - return new ByteArrayInputStream(data); + TellusDiagnostics.traffic("Slippy map tile ok tile=%s endpoint=%s bytes=%d", pos, requestUrl, data.length); + return data; } finally { this.loadingStreams.remove(stream); } + } catch (IOException error) { + lastError = error; + TellusDiagnostics.traffic("Slippy map tile failed tile=%s endpoint=%s error=%s", pos, requestUrl, error.getMessage()); } finally { connection.disconnect(); } } + + throw new IOException("all map tile endpoints failed for " + pos, lastError); + } + + private static String[] parseTileEndpoints(String config) { + String[] parts = Objects.requireNonNull(config, "tileEndpoints").split(","); + List parsed = new ArrayList<>(parts.length); + for (String part : parts) { + String trimmed = part == null ? "" : part.trim(); + if (!trimmed.isEmpty()) { + parsed.add(trimmed); + } + } + return parsed.isEmpty() ? new String[]{"https://tile.openstreetmap.org/%d/%d/%d.png"} : parsed.toArray(String[]::new); } private void cacheData(Path cachePath, byte[] data) { diff --git a/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java b/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java new file mode 100644 index 000000000..63db33b3b --- /dev/null +++ b/mc1201/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java @@ -0,0 +1,105 @@ +package com.yucareux.tellus.client.widget.map.component; + +import com.yucareux.tellus.client.teleport.TeleportWaypoint; +import com.yucareux.tellus.client.widget.map.SlippyMap; +import com.yucareux.tellus.client.widget.map.SlippyMapPoint; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +@Environment(EnvType.CLIENT) +public final class WaypointMapComponent implements MapComponent { + private static final int PIN_RADIUS = 4; + private static final int PICK_RADIUS = 10; + private static final int COLOR_SPAWN = -12001301; + private static final int COLOR_WAYPOINT = -13382401; + private static final int COLOR_SELECTED = -29218; + private final Supplier> waypointSupplier; + private final Supplier selectedWaypointSupplier; + private final Consumer waypointClickHandler; + + public WaypointMapComponent( + Supplier> waypointSupplier, + Supplier selectedWaypointSupplier, + Consumer waypointClickHandler + ) { + this.waypointSupplier = Objects.requireNonNull(waypointSupplier, "waypointSupplier"); + this.selectedWaypointSupplier = Objects.requireNonNull(selectedWaypointSupplier, "selectedWaypointSupplier"); + this.waypointClickHandler = Objects.requireNonNull(waypointClickHandler, "waypointClickHandler"); + } + + @Override + public void onDrawMap(SlippyMap map, GuiGraphics graphics, int mouseX, int mouseY, SlippyMapPoint mouse) { + int zoom = map.getCameraZoom(); + int scale = Math.max(1, (int)Math.round(Minecraft.getInstance().getWindow().getGuiScale())); + String selectedId = this.selectedWaypointSupplier.get(); + + for (TeleportWaypoint waypoint : this.waypointSupplier.get()) { + int markerX = waypointX(waypoint, zoom) - map.getCameraX(); + int markerY = waypointY(waypoint, zoom) - map.getCameraY(); + int guiMarkerX = markerX / scale; + int guiMarkerY = markerY / scale; + boolean selected = waypoint.id().equals(selectedId); + int color = selected ? COLOR_SELECTED : waypoint.initialSpawn() ? COLOR_SPAWN : COLOR_WAYPOINT; + graphics.fill(guiMarkerX - PIN_RADIUS - 1, guiMarkerY - PIN_RADIUS - 1, guiMarkerX + PIN_RADIUS + 2, guiMarkerY + PIN_RADIUS + 2, -16777216); + graphics.fill(guiMarkerX - PIN_RADIUS, guiMarkerY - PIN_RADIUS, guiMarkerX + PIN_RADIUS + 1, guiMarkerY + PIN_RADIUS + 1, color); + if (selected || waypoint.initialSpawn()) { + drawLabel(graphics, guiMarkerX + 7, guiMarkerY - 12, waypoint.label()); + } + } + } + + @Override + public boolean onMouseClicked(SlippyMap map, SlippyMapPoint mouse, int button) { + if (button != 0) { + return false; + } + + int zoom = map.getCameraZoom(); + int scale = Math.max(1, (int)Math.round(Minecraft.getInstance().getWindow().getGuiScale())); + int mouseX = mouse.getX(zoom); + int mouseY = mouse.getY(zoom); + int pickRadius = PICK_RADIUS * scale; + int bestDistance = pickRadius * pickRadius + 1; + String bestWaypointId = null; + + for (TeleportWaypoint waypoint : this.waypointSupplier.get()) { + int deltaX = waypointX(waypoint, zoom) - mouseX; + int deltaY = waypointY(waypoint, zoom) - mouseY; + int distance = deltaX * deltaX + deltaY * deltaY; + if (distance < bestDistance) { + bestDistance = distance; + bestWaypointId = waypoint.id(); + } + } + + if (bestWaypointId != null) { + this.waypointClickHandler.accept(bestWaypointId); + return true; + } + + return false; + } + + private static int waypointX(TeleportWaypoint waypoint, int zoom) { + return new SlippyMapPoint(waypoint.latitude(), waypoint.longitude()).getX(zoom); + } + + private static int waypointY(TeleportWaypoint waypoint, int zoom) { + return new SlippyMapPoint(waypoint.latitude(), waypoint.longitude()).getY(zoom); + } + + private static void drawLabel(GuiGraphics graphics, int x, int y, String label) { + Font font = Minecraft.getInstance().font; + String clipped = font.width(label) <= 112 ? label : font.plainSubstrByWidth(label, 101) + "..."; + int width = font.width(clipped); + graphics.fill(x - 2, y - 2, x + width + 3, y + 10, -1442840576); + graphics.drawString(font, clipped, x, y, -1, false); + } +} diff --git a/mc1201/src/main/java/com/yucareux/tellus/Tellus.java b/mc1201/src/main/java/com/yucareux/tellus/Tellus.java index 0e0791cba..a20752c68 100644 --- a/mc1201/src/main/java/com/yucareux/tellus/Tellus.java +++ b/mc1201/src/main/java/com/yucareux/tellus/Tellus.java @@ -112,9 +112,7 @@ public void onInitialize() { (dispatcher, registryAccess, environment) -> dispatcher.register( ((Commands.literal("tellus") .then( - (Commands.literal("map") - .requires(source -> source.hasPermission(2))) - .executes(context -> openGeoTpMap((CommandSourceStack)context.getSource())) + Commands.literal("map").executes(context -> openGeoTpMap((CommandSourceStack)context.getSource())) )) .then( (Commands.literal("weather") @@ -263,7 +261,10 @@ private static int openGeoTpMap(CommandSourceStack source) { if (level.getChunkSource().getGenerator() instanceof EarthChunkGenerator earthGenerator) { double latitude = clampLatitude(earthGenerator.latitudeFromBlock(player.getZ())); double longitude = clampLongitude(earthGenerator.longitudeFromBlock(player.getX())); - ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude)); + EarthGeneratorSettings settings = earthGenerator.settings(); + double spawnLatitude = clampLatitude(settings.spawnLatitude()); + double spawnLongitude = clampLongitude(settings.spawnLongitude()); + ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude, spawnLatitude, spawnLongitude)); return 1; } else { source.sendFailure(Component.literal("Tellus: GeoTP map is only available in Tellus worlds.")); diff --git a/mc1201/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java b/mc1201/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java index 52ca6c53d..7e017e943 100644 --- a/mc1201/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java +++ b/mc1201/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java @@ -6,22 +6,30 @@ import net.fabricmc.fabric.api.networking.v1.PacketType; import net.minecraft.network.FriendlyByteBuf; -public record GeoTpOpenMapPayload(double latitude, double longitude) implements FabricPacket { +public record GeoTpOpenMapPayload(double latitude, double longitude, double spawnLatitude, double spawnLongitude) implements FabricPacket { public static final PacketType TYPE = PacketType.create(Tellus.id("geotp_open_map"), GeoTpOpenMapPayload::new); public GeoTpOpenMapPayload(FriendlyByteBuf buffer) { - this(buffer.readDouble(), buffer.readDouble()); + this(buffer.readDouble(), buffer.readDouble(), buffer.readDouble(), buffer.readDouble()); } public GeoTpOpenMapPayload(double latitude, double longitude) { + this(latitude, longitude, latitude, longitude); + } + + public GeoTpOpenMapPayload(double latitude, double longitude, double spawnLatitude, double spawnLongitude) { this.latitude = latitude; this.longitude = longitude; + this.spawnLatitude = spawnLatitude; + this.spawnLongitude = spawnLongitude; } @Override public void write(FriendlyByteBuf buffer) { buffer.writeDouble(this.latitude()); buffer.writeDouble(this.longitude()); + buffer.writeDouble(this.spawnLatitude()); + buffer.writeDouble(this.spawnLongitude()); } @Override diff --git a/mc1201/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java b/mc1201/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java index 39a4505bd..1e56c53d0 100644 --- a/mc1201/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java +++ b/mc1201/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java @@ -463,16 +463,21 @@ private static OsmBuildingMetadata resolveMetadata(Map tags, dou firstNonBlank(tags, "@name", "name"), floorCount, firstNonBlank(tags, "roof_shape", "roof:shape"), - firstNonBlank(tags, "roof_material", "roof:material", "roof:colour", "roof_color") + resolveRoofLevels(tags), + resolveRoofHeightMeters(tags), + firstNonBlank(tags, "roof_material", "roof:material"), + firstNonBlank(tags, "wall_material", "building_material", "building:material", "facade_material", "facade:material", "material"), + firstNonBlank(tags, "roof_color", "roof_colour", "roof:color", "roof:colour"), + firstNonBlank(tags, "wall_color", "wall_colour", "building_color", "building_colour", "building:color", "building:colour", "facade_color", "facade_colour", "facade:color", "facade:colour", "color", "colour") ); } private static double resolveFootprintHeightMeters(Map tags) { - Double height = parseDouble(tags.get("height")); + Double height = parseDouble(firstNonBlank(tags, "height", "building_height", "building:height")); if (height != null && height > 0.0) { return height; } else { - Double floors = parseDouble(tags.get("num_floors")); + Double floors = parseDouble(firstNonBlank(tags, "num_floors", "floors", "building_levels", "building:levels", "level")); if (floors != null && floors > 0.0) { return floors * 3.2; } else { @@ -482,11 +487,11 @@ private static double resolveFootprintHeightMeters(Map tags) { } private static double resolvePartHeightMeters(Map tags) { - Double height = parseDouble(tags.get("height")); + Double height = parseDouble(firstNonBlank(tags, "height", "building_height", "building:height")); if (height != null && height > 0.0) { return height; } else { - Double floors = parseDouble(tags.get("num_floors")); + Double floors = parseDouble(firstNonBlank(tags, "num_floors", "floors", "building_levels", "building:levels", "level")); if (floors != null && floors > 0.0) { return floors * 3.2; } else { @@ -496,11 +501,11 @@ private static double resolvePartHeightMeters(Map tags) { } private static double resolveMinHeightMeters(Map tags) { - Double minHeight = parseDouble(tags.get("min_height")); + Double minHeight = parseDouble(firstNonBlank(tags, "min_height", "min:height", "building:min_height")); if (minHeight != null && minHeight > 0.0) { return minHeight; } else { - Double minFloor = parseDouble(tags.get("min_floor")); + Double minFloor = parseDouble(firstNonBlank(tags, "min_floor", "min_level", "building:min_level")); return minFloor != null && minFloor > 0.0 ? minFloor * 3.2 : 0.0; } } @@ -520,6 +525,16 @@ private static int resolveFloorCount(Map tags, double heightMete return Math.max(1, (int)Math.round(heightMeters / 3.2)); } + private static int resolveRoofLevels(Map tags) { + Double levels = parseDouble(firstNonBlank(tags, "roof_levels", "roof:levels")); + return levels != null && levels > 0.0 ? Math.max(0, (int)Math.round(levels)) : 0; + } + + private static double resolveRoofHeightMeters(Map tags) { + Double height = parseDouble(firstNonBlank(tags, "roof_height", "roof:height")); + return height != null && height > 0.0 ? height : 0.0; + } + private static List> decodePolygonRings(List geometry) { if (geometry != null && !geometry.isEmpty()) { List> rings = new ArrayList<>(); @@ -777,7 +792,7 @@ private static Double parseDouble(Object value) { return null; } else { try { - return Double.parseDouble(text.trim()); + return Double.parseDouble(extractNumericPrefix(text)); } catch (NumberFormatException error) { return null; } @@ -785,6 +800,24 @@ private static Double parseDouble(Object value) { } } + private static String extractNumericPrefix(String value) { + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + return seenDigit ? number.toString() : normalized; + } + private static boolean isTruthy(String value) { if (value == null) { return false; diff --git a/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java b/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java index 77c0c33f4..2064a6574 100644 --- a/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java +++ b/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java @@ -3,6 +3,15 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; +import com.yucareux.tellus.world.data.integration.ExternalAreaFeature; +import com.yucareux.tellus.world.data.integration.ExternalAreaKind; +import com.yucareux.tellus.world.data.integration.TellusExternalFeatureSource; +import com.yucareux.tellus.world.data.integration.ExternalLineFeature; +import com.yucareux.tellus.world.data.integration.ExternalLineKind; +import com.yucareux.tellus.world.data.integration.ExternalPointFeature; +import com.yucareux.tellus.world.data.integration.ExternalPointKind; +import com.yucareux.tellus.world.data.integration.GeoPoint; import com.yucareux.tellus.world.data.cover.TellusLandCoverSource; import com.yucareux.tellus.world.data.elevation.TellusElevationSource; import com.yucareux.tellus.world.data.koppen.TellusKoppenSource; @@ -27,6 +36,8 @@ import com.yucareux.tellus.worldgen.building.TellusBuildingProfiles; import com.yucareux.tellus.worldgen.caves.TellusNoiseSettingsAdapter; import com.yucareux.tellus.worldgen.caves.TellusVanillaCarverRunner; +import com.yucareux.tellus.worldgen.vegetation.ArnisTreeGenerator; +import com.yucareux.tellus.worldgen.vegetation.ArnisTreeType; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongSet; @@ -145,6 +156,7 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final TellusLandMaskSource LAND_MASK_SOURCE = TellusWorldgenSources.landMask(); private static final TellusOsmRoadSource OSM_ROAD_SOURCE = TellusWorldgenSources.osmRoads(); private static final TellusOsmBuildingSource OSM_BUILDING_SOURCE = TellusWorldgenSources.osmBuildings(); + private static final TellusExternalFeatureSource EXTERNAL_FEATURE_SOURCE = TellusExternalFeatureSource.createDefault(); private static final TellusOsmSandSource OSM_SAND_SOURCE = TellusWorldgenSources.osmSand(); private static final double ESA_WORLD_COVER_RESOLUTION_METERS = 10.0; private static final int ESA_NO_DATA = 0; @@ -161,12 +173,18 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final int OSM_ROAD_QUERY_MARGIN = 64; private static final int OSM_BUILDING_MAX_SCALE = 15; private static final int OSM_BUILDING_QUERY_MARGIN = 8; + private static final int OSM_CITY_DETAIL_MAX_SCALE = 15; + private static final int OSM_CITY_DETAIL_QUERY_MARGIN = 32; private static final int OSM_ROAD_CLASS_SEPARATION = 0; private static final int OSM_ROAD_BRIDGE_LEVEL_HEIGHT = intProperty("tellus.osm.roads.bridgeLevelHeight", 3, 1, 16); private static final int OSM_ROAD_BRIDGE_MAX_RISE = intProperty("tellus.osm.roads.bridgeMaxRise", 10, 1, 64); private static final int OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL = intProperty("tellus.osm.roads.bridgeRampHorizontalPerVertical", 4, 1, 32); private static final int OSM_TUNNEL_SIDE_CLEARANCE = 3; private static final int OSM_TUNNEL_INTERNAL_HEIGHT = 7; + private static final int OSM_ROAD_MAX_TAGGED_WIDTH = intProperty("tellus.osm.roads.maxTaggedWidth", 18, 1, 64); + private static final byte ROAD_SURFACE_DEFAULT = 0; + private static final byte ROAD_SURFACE_UNPAVED = 1; + private static final byte ROAD_SURFACE_PAVED_PATH = 2; private static final int OCEAN_MONUMENT_SAMPLE_STEP = 8; private static final int OCEAN_MONUMENT_MARGIN = 8; private static final int OCEAN_MONUMENT_CORE_INSET = 8; @@ -180,11 +198,31 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final BlockState ROAD_NORMAL_STATE = Blocks.CYAN_TERRACOTTA.defaultBlockState(); private static final BlockState ROAD_DIRT_STATE = Blocks.DIRT_PATH.defaultBlockState(); + private static final BlockState ROAD_LANE_MARK_STATE = Blocks.WHITE_CONCRETE.defaultBlockState(); + private static final BlockState ROAD_SIDEWALK_STATE = Blocks.SMOOTH_STONE.defaultBlockState(); private static final BlockState BRIDGE_SUPPORT_SHAFT_STATE = Blocks.QUARTZ_PILLAR.defaultBlockState(); private static final BlockState BRIDGE_SUPPORT_CAP_STATE = Blocks.QUARTZ_BRICKS.defaultBlockState(); private static final BlockState ROAD_LIGHT_BASE_STATE = Blocks.STONE_BRICK_WALL.defaultBlockState(); private static final BlockState ROAD_LIGHT_FENCE_STATE = Blocks.OAK_FENCE.defaultBlockState(); private static final BlockState ROAD_LIGHT_GLOW_STATE = Blocks.GLOWSTONE.defaultBlockState(); + private static final BlockState CITY_PARKING_STATE = Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + private static final BlockState CITY_PARKING_DRIVE_STATE = Blocks.GRAY_CONCRETE.defaultBlockState(); + private static final BlockState CITY_PARKING_MARK_STATE = Blocks.WHITE_CONCRETE.defaultBlockState(); + private static final BlockState CITY_TRACK_STATE = Blocks.RED_TERRACOTTA.defaultBlockState(); + private static final BlockState CITY_PLAYGROUND_STATE = Blocks.ORANGE_TERRACOTTA.defaultBlockState(); + private static final BlockState CITY_BARRIER_FENCE_STATE = Blocks.OAK_FENCE.defaultBlockState(); + private static final BlockState CITY_BARRIER_WALL_STATE = Blocks.COBBLESTONE_WALL.defaultBlockState(); + private static final BlockState CITY_BARRIER_HEDGE_STATE = Blocks.OAK_LEAVES.defaultBlockState(); + private static final BlockState CITY_BARRIER_RAIL_STATE = Blocks.IRON_BARS.defaultBlockState(); + private static final BlockState CITY_RAIL_STATE = Blocks.RAIL.defaultBlockState(); + private static final BlockState CITY_TRAFFIC_POLE_STATE = Blocks.IRON_BARS.defaultBlockState(); + private static final BlockState CITY_TRAFFIC_LIGHT_STATE = Blocks.REDSTONE_LAMP.defaultBlockState(); + private static final BlockState CITY_BENCH_STATE = Blocks.OAK_SLAB.defaultBlockState(); + private static final BlockState CITY_FOUNTAIN_BASE_STATE = Blocks.STONE_BRICKS.defaultBlockState(); + private static final BlockState CITY_SHRUB_STATE = Blocks.OAK_LEAVES.defaultBlockState(); + private static final BlockState CITY_FERN_STATE = Blocks.FERN.defaultBlockState(); + private static final BlockState CITY_ROCK_STATE = Blocks.COBBLESTONE.defaultBlockState(); + private static final BlockState CITY_DEAD_BUSH_STATE = Blocks.DEAD_BUSH.defaultBlockState(); private static final BlockState BUILDING_BOOKSHELF_STATE = Blocks.BOOKSHELF.defaultBlockState(); private static final BlockState BUILDING_BARREL_STATE = Blocks.BARREL.defaultBlockState(); private static final BlockState BUILDING_CRAFTING_STATE = Blocks.CRAFTING_TABLE.defaultBlockState(); @@ -388,6 +426,17 @@ public EarthChunkGenerator(BiomeSource biomeSource, EarthGeneratorSettings setti } ); } + TellusDiagnostics.worldgen( + "EarthChunkGenerator init: scale=%s minAltitude=%d maxAltitude=%d heightOffset=%d limits=[minY=%d,height=%d,logicalHeight=%d] seaLevel=%d", + settings.worldScale(), + settings.minAltitude(), + settings.maxAltitude(), + settings.heightOffset(), + limits.minY(), + limits.height(), + limits.logicalHeight(), + this.seaLevel + ); } public static EarthChunkGenerator create(Provider registries, EarthGeneratorSettings settings) { @@ -554,7 +603,7 @@ private boolean shouldDeferBuildingDetails() { return !CHUNK_DETAIL_LEGACY_BLOCKING && CHUNK_DETAIL_DEFER_BUILDINGS && this.settings.enableBuildings() - && OSM_BUILDING_SOURCE.available() + && this.buildingSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; } @@ -571,6 +620,14 @@ private boolean shouldUseStructureOsmSyncFallback() { return CHUNK_DETAIL_LEGACY_BLOCKING; } + private boolean roadSourcesAvailable() { + return OSM_ROAD_SOURCE.available() || EXTERNAL_FEATURE_SOURCE.roadsAvailable(); + } + + private boolean buildingSourcesAvailable() { + return OSM_BUILDING_SOURCE.available() || EXTERNAL_FEATURE_SOURCE.buildingsAvailable(); + } + protected Codec codec() { return Objects.requireNonNull(CODEC, "CODEC"); @@ -682,6 +739,12 @@ public void applyBiomeDecoration( WorldGenLevel level, ChunkAccess chunk, Stru endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.DECORATION_BUILDINGS, phaseStartNs); } + if (!delayTellusDecoration && !this.shouldDeferRoadDetails() && !this.shouldDeferBuildingDetails()) { + phaseStartNs = beginFullChunkProfiling(); + this.applyExternalCityDetails(level, chunk); + endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.DECORATION_CITY_DETAILS, phaseStartNs); + } + phaseStartNs = beginFullChunkProfiling(); if (!delayTellusDecoration) { this.applyRealtimeSnowCover(level, chunk); @@ -816,6 +879,19 @@ private void fillTellusSurface( RandomState random, StructureManager structures this.settings.maxAltitude() } ); + TellusDiagnostics.worldgen( + "fillFromNoise layout: chunkPos=%s minY=%d height=%d maxY=%d sections=%d genMinY=%d genHeight=%d seaLevel=%d settingsMinAlt=%d settingsMaxAlt=%d", + pos, + chunkMinY, + chunkHeight, + chunkMinY + chunkHeight - 1, + chunkHeight >> 4, + this.minY, + this.height, + this.seaLevel, + this.settings.minAltitude(), + this.settings.maxAltitude() + ); } int deepslateStart = this.minY + 64; @@ -915,7 +991,7 @@ private void fillTellusSurface( RandomState random, StructureManager structures int[] convexities = new int[CHUNK_AREA]; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = useFastFullChunk && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(useFastFullChunk, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; phaseStartNs = beginFullChunkProfiling(); @@ -1029,8 +1105,8 @@ private void fillTellusSurface( RandomState random, StructureManager structures } endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.FILL_BLOCKS_SOLID_SECTIONS, solidSectionsStartNs); - for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { - int worldZ = chunkMinZ + localZ; + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + int worldZ = chunkMinZ + localZ; int rowIndex = localZ * CHUNK_SIDE; for (int localX = 0; localX < CHUNK_SIDE; localX++) { @@ -1299,12 +1375,16 @@ private void applyOsmRoadOverlay( scratch.ensureRoadExtCapacity(extArea); byte[] resolvedClass = scratch.resolvedClass; byte[] resolvedMode = scratch.resolvedMode; + byte[] resolvedSurface = scratch.resolvedSurface; int[] resolvedDeckY = scratch.resolvedDeckY; + int[] resolvedWidth = scratch.resolvedWidth; boolean[] resolvedTunnelCarve = scratch.resolvedTunnelCarve; boolean[] blockedByHigherClass = scratch.blockedByHigherClass; boolean[] bridgeOverlayPresent = scratch.bridgeOverlayPresent; int[] bridgeOverlayDeckY = scratch.bridgeOverlayDeckY; byte[] bridgeOverlayClass = scratch.bridgeOverlayClass; + byte[] bridgeOverlaySurface = scratch.bridgeOverlaySurface; + int[] bridgeOverlayWidth = scratch.bridgeOverlayWidth; boolean[] bridgeSupportShaftPresent = scratch.bridgeSupportShaftPresent; int[] bridgeSupportShaftBottomY = scratch.bridgeSupportShaftBottomY; int[] bridgeSupportShaftTopY = scratch.bridgeSupportShaftTopY; @@ -1333,12 +1413,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1360,12 +1444,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1387,12 +1475,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1480,10 +1572,12 @@ private void applyOsmRoadOverlay( byte[] chunkRoadClass = scratch.chunkRoadClass; byte[] chunkRoadMode = scratch.chunkRoadMode; int[] chunkRoadDeckY = scratch.chunkRoadDeckY; + int[] chunkRoadWidth = scratch.chunkRoadWidth; boolean[] chunkTunnelNeedsCarve = scratch.chunkTunnelNeedsCarve; Arrays.fill(chunkRoadClass, (byte)0); Arrays.fill(chunkRoadMode, (byte)0); Arrays.fill(chunkRoadDeckY, 0); + Arrays.fill(chunkRoadWidth, 0); Arrays.fill(chunkTunnelNeedsCarve, false); MutableBlockPos cursor = new MutableBlockPos(); @@ -1500,11 +1594,12 @@ private void applyOsmRoadOverlay( int worldX = chunkMinX + localX; int worldZ = chunkMinZ + localZ; cursor.set(worldX, deckY, worldZ); - this.setChunkBlock(level, chunk, cursor, roadStateForClass(roadClassFromId(classId))); + this.setChunkBlock(level, chunk, cursor, roadStateForOverlay(roadClassFromId(classId), resolvedSurface[extIndex])); int chunkIndex = chunkIndex(localX, localZ); chunkRoadClass[chunkIndex] = (byte)classId; chunkRoadMode[chunkIndex] = resolvedMode[extIndex]; chunkRoadDeckY[chunkIndex] = deckY; + chunkRoadWidth[chunkIndex] = resolvedWidth[extIndex]; chunkTunnelNeedsCarve[chunkIndex] = resolvedTunnelCarve[extIndex]; } } @@ -1524,12 +1619,65 @@ private void applyOsmRoadOverlay( int worldX = chunkMinX + localXx; int worldZ = chunkMinZ + localZ; cursor.set(worldX, deckY, worldZ); - this.setChunkBlock(level, chunk, cursor, roadStateForClass(roadClassFromId(classId))); + this.setChunkBlock(level, chunk, cursor, roadStateForOverlay(roadClassFromId(classId), bridgeOverlaySurface[extIndex])); } } } } + this.paintRoadSidewalks( + level, + chunk, + roads, + widths, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + + this.paintRoadLaneMarkings( + level, + chunk, + roads, + widths, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + + this.paintBridgeEdgeRails( + level, + chunk, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + bridgeOverlayWidth + ); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { for (int localX = 0; localX < CHUNK_SIDE; localX++) { int extIndex = extIndex(localX + padding, localZ + padding, extSide); @@ -1574,7 +1722,7 @@ private void applyOsmRoadOverlay( for (int localXxx = 0; localXxx < CHUNK_SIDE; localXxx++) { int centerIndex = chunkIndex(localXxx, localZ); if (chunkRoadClass[centerIndex] > 0 && chunkRoadMode[centerIndex] == tunnelModeId && chunkTunnelNeedsCarve[centerIndex]) { - int roadWidth = classWidths[chunkRoadClass[centerIndex]]; + int roadWidth = chunkRoadWidth[centerIndex] > 0 ? chunkRoadWidth[centerIndex] : classWidths[chunkRoadClass[centerIndex]]; int carveWidth = roadWidth + OSM_TUNNEL_SIDE_CLEARANCE * 2; double carveRadius = Math.max(0.5, (carveWidth - 1) * 0.5); int radius = Mth.ceil(carveRadius); @@ -1632,10 +1780,21 @@ private void applyOsmRoadOverlay( } } } - } - } - - EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights = this.prepareRoadLightsForChunk( + } + } + + this.paintTunnelShell( + level, + chunk, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + tunnelCarveMask, + tunnelCarveDeckY + ); + + EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights = this.prepareRoadLightsForChunk( pos, roads, widths, @@ -1680,29 +1839,40 @@ private void rasterizeRoadClassPass( Long2ObjectOpenHashMap edgeColumnCache, byte[] resolvedClass, byte[] resolvedMode, + byte[] resolvedSurface, int[] resolvedDeckY, + int[] resolvedWidth, boolean[] resolvedTunnelCarve, boolean[] blockedByHigherClass, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass, + byte[] bridgeOverlaySurface, + int[] bridgeOverlayWidth, EarthChunkGenerator.OsmOverlayScratch scratch, int extArea ) { if (!roads.isEmpty() && roadWidth > 0) { boolean[] candidatePresent = scratch.candidatePresent; int[] candidateDeckY = scratch.candidateDeckY; + int[] candidateWidth = scratch.candidateWidth; byte[] candidateMode = scratch.candidateMode; + byte[] candidateSurface = scratch.candidateSurface; boolean[] candidateTunnelCarve = scratch.candidateTunnelCarve; boolean[] bridgeCandidatePresent = scratch.bridgeCandidatePresent; int[] bridgeCandidateDeckY = scratch.bridgeCandidateDeckY; + int[] bridgeCandidateWidth = scratch.bridgeCandidateWidth; + byte[] bridgeCandidateSurface = scratch.bridgeCandidateSurface; scratch.clearRoadCandidateState(extArea); for (RoadFeature road : roads) { + int featureRoadWidth = roadWidthForFeature(road, roadWidth); + byte featureSurface = roadSurfaceId(road); if (road.mode() == RoadMode.BRIDGE) { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1717,13 +1887,16 @@ private void rasterizeRoadClassPass( edgeColumnCache, bridgeCandidatePresent, bridgeCandidateDeckY, + bridgeCandidateWidth, null, + bridgeCandidateSurface, null ); } else { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1738,7 +1911,9 @@ private void rasterizeRoadClassPass( edgeColumnCache, candidatePresent, candidateDeckY, + candidateWidth, candidateMode, + candidateSurface, candidateTunnelCarve ); } @@ -1748,7 +1923,18 @@ private void rasterizeRoadClassPass( for (int index = 0; index < extArea; index++) { if (bridgeCandidatePresent[index]) { - mergeBridgeOverlay(index, classId, bridgeCandidateDeckY[index], bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass); + mergeBridgeOverlay( + index, + classId, + bridgeCandidateSurface[index], + bridgeCandidateWidth[index], + bridgeCandidateDeckY[index], + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth + ); } } @@ -1759,7 +1945,9 @@ private void rasterizeRoadClassPass( if (candidatePresent[indexx] && !blockedByHigherClass[indexx]) { resolvedClass[indexx] = (byte)classId; resolvedMode[indexx] = candidateMode[indexx]; + resolvedSurface[indexx] = candidateSurface[indexx]; resolvedDeckY[indexx] = candidateDeckY[indexx]; + resolvedWidth[indexx] = candidateWidth[indexx]; resolvedTunnelCarve[indexx] = candidateTunnelCarve[indexx]; placed[placedCount++] = indexx; } @@ -1788,6 +1976,7 @@ private void rasterizeRoadClassPass( private void rasterizeRoadFeature( RoadFeature road, int roadWidth, + byte roadSurface, double blocksPerDegree, int extMinX, int extMinZ, @@ -1802,7 +1991,9 @@ private void rasterizeRoadFeature( Long2ObjectOpenHashMap edgeColumnCache, boolean[] candidatePresent, int[] candidateDeckY, + int[] candidateWidth, byte[] candidateMode, + byte[] candidateSurface, boolean[] candidateTunnelCarve ) { int pointCount = road.pointCount(); @@ -1924,6 +2115,8 @@ private void rasterizeRoadFeature( if (replaceCandidate) { candidatePresent[extIndex] = true; candidateDeckY[extIndex] = deckY; + candidateWidth[extIndex] = roadWidth; + candidateSurface[extIndex] = roadSurface; if (candidateMode != null && candidateTunnelCarve != null) { candidateMode[extIndex] = (byte)(road.mode().ordinal() + 1); candidateTunnelCarve[extIndex] = tunnelNeedsCarve; @@ -1942,124 +2135,566 @@ private void rasterizeRoadFeature( } } - private EarthChunkGenerator.RoadColumnSample sampleRoadColumnForOverlay( - int worldX, - int worldZ, + private void paintRoadSidewalks( + WorldGenLevel level, + ChunkAccess chunk, + List roads, + EarthChunkGenerator.RoadWidths widths, int chunkMinX, int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass ) { - if (worldX >= chunkMinX && worldX < chunkMinX + CHUNK_SIDE && worldZ >= chunkMinZ && worldZ < chunkMinZ + CHUNK_SIDE) { - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - int index = chunkIndex(localX, localZ); - return new EarthChunkGenerator.RoadColumnSample( - terrainSurfaces[index], waterSurfaces[index], waterFlags[index] && waterSurfaces[index] > terrainSurfaces[index] - ); - } else { - long packed = packColumn(worldX, worldZ); - EarthChunkGenerator.RoadColumnSample cached = (EarthChunkGenerator.RoadColumnSample)edgeColumnCache.get(packed); - if (cached != null) { - return cached; - } else { - WaterSurfaceResolver.WaterColumnData column = this.resolveAuxWaterColumn(worldX, worldZ); - EarthChunkGenerator.RoadColumnSample sampled = new EarthChunkGenerator.RoadColumnSample( - column.terrainSurface(), column.waterSurface(), column.hasWater() && column.waterSurface() > column.terrainSurface() - ); - edgeColumnCache.put(packed, sampled); - return sampled; + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + + for (RoadFeature road : roads) { + if (!road.hasSidewalk() || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; } - } - } - private static EarthChunkGenerator.RoadWidths resolveRoadWidths(double worldScale) { - double factor = roadWidthFactorForScale(worldScale); + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } - return new EarthChunkGenerator.RoadWidths( - widthForScale(RoadClass.MAIN.baseWidth(), factor), - widthForScale(RoadClass.NORMAL.baseWidth(), factor), - widthForScale(RoadClass.DIRT.baseWidth(), factor) - ); - } + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double sidewalkOffset = Math.max(0.5, roadWidth * 0.5 - 0.5); + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + for (int point = 1; point < road.pointCount(); point++) { + double currentX = road.lonAt(point) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(point), worldScale); + double dx = currentX - previousX; + double dz = currentZ - previousZ; + double segmentLength = Math.sqrt(dx * dx + dz * dz); + if (segmentLength <= 1.0E-6) { + previousX = currentX; + previousZ = currentZ; + continue; + } - private static int widthForScale(int baseWidth, double factor) { - return Math.max(1, (int)Math.round(baseWidth * factor)); - } + double tangentX = dx / segmentLength; + double tangentZ = dz / segmentLength; + double normalX = -tangentZ; + double normalZ = tangentX; + for (double station = 0.0; station <= segmentLength; station += 0.75) { + double centerX = previousX + tangentX * station; + double centerZ = previousZ + tangentZ * station; + if (road.hasLeftSidewalk()) { + this.paintRoadEdgeBlock( + level, + chunk, + centerX, + centerZ, + normalX, + normalZ, + -sidewalkOffset, + road.mode(), + roadClassId, + roadModeId, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + cursor, + ROAD_SIDEWALK_STATE + ); + } + if (road.hasRightSidewalk()) { + this.paintRoadEdgeBlock( + level, + chunk, + centerX, + centerZ, + normalX, + normalZ, + sidewalkOffset, + road.mode(), + roadClassId, + roadModeId, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + cursor, + ROAD_SIDEWALK_STATE + ); + } + } - private static double roadWidthFactorForScale(double worldScale) { - if (!(worldScale > 0.0)) { - return 0.25; - } else if (worldScale <= 1.0) { - return 1.8; - } else if (worldScale <= 5.0) { - double t = (worldScale - 1.0) / 4.0; - return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.8, 1.0); - } else if (worldScale <= 10.0) { - double t = (worldScale - 5.0) / 5.0; - return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.0, 0.5); - } else { - return 0.25; + previousX = currentX; + previousZ = currentZ; + } } } - private static int bridgeRiseAtStation(double station, double totalLength, int bridgeLevel) { - int requestedRise = Math.max(0, bridgeLevel) * OSM_ROAD_BRIDGE_LEVEL_HEIGHT; - requestedRise = Math.min(requestedRise, OSM_ROAD_BRIDGE_MAX_RISE); - if (requestedRise > 0 && !(totalLength <= 1.0E-6)) { - double maxRiseByLength = totalLength / (2.0 * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL); - int targetRise = Math.min(requestedRise, Math.max(0, (int)Math.floor(maxRiseByLength))); - if (targetRise <= 0) { - return 0; - } else { - double clampedStation = Mth.clamp(station, 0.0, totalLength); - double rampLength = targetRise * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; - double rise; - if (totalLength >= rampLength * 2.0) { - if (clampedStation < rampLength) { - rise = targetRise * (clampedStation / rampLength); - } else if (clampedStation > totalLength - rampLength) { - rise = targetRise * ((totalLength - clampedStation) / rampLength); - } else { - rise = targetRise; + private void paintBridgeEdgeRails( + WorldGenLevel level, + ChunkAccess chunk, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + int[] bridgeOverlayWidth + ) { + MutableBlockPos cursor = new MutableBlockPos(); + int flags = this.detailApplyFlags(level); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + int extIndex = extIndex(localX + padding, localZ + padding, extSide); + if (!bridgeOverlayPresent[extIndex] || bridgeOverlayClass[extIndex] <= 0 || bridgeOverlayWidth[extIndex] < 3) { + continue; + } + + int deckY = bridgeOverlayDeckY[extIndex]; + boolean edge = false; + for (Direction direction : Direction.Plane.HORIZONTAL) { + int neighborLocalX = localX + padding + direction.getStepX(); + int neighborLocalZ = localZ + padding + direction.getStepZ(); + if (neighborLocalX < 0 || neighborLocalX >= extSide || neighborLocalZ < 0 || neighborLocalZ >= extSide) { + edge = true; + break; } - } else { - double half = totalLength * 0.5; - if (half <= 1.0E-6) { - rise = targetRise; - } else if (clampedStation <= half) { - rise = targetRise * (clampedStation / half); - } else { - rise = targetRise * ((totalLength - clampedStation) / half); + + int neighborExt = extIndex(neighborLocalX, neighborLocalZ, extSide); + if (!bridgeOverlayPresent[neighborExt] + || bridgeOverlayClass[neighborExt] <= 0 + || Math.abs(bridgeOverlayDeckY[neighborExt] - deckY) > 1) { + edge = true; + break; } } + if (!edge) { + continue; + } - return Math.max(0, (int)Math.round(Mth.clamp(rise, 0.0, targetRise))); + int railY = Mth.clamp(deckY + 1, chunkMinY, chunkMaxY); + int worldX = chunkMinX + localX; + int worldZ = chunkMinZ + localZ; + cursor.set(worldX, railY, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, CITY_BARRIER_RAIL_STATE); + if (Math.floorMod(worldX + worldZ, 6) == 0 && railY + 1 <= chunkMaxY) { + cursor.set(worldX, railY + 1, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, Blocks.STONE_BRICK_SLAB.defaultBlockState()); + } + } + } } - } else { - return 0; } } - private static int bridgeDeckBaselineAtStation(double station, double totalLength, int startSurface, int endSurface) { - return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); - } - - private static int bridgeDeckYAtStation( - double station, - double totalLength, - int startSurface, - int endSurface, - int localRoadSurface, - int bridgeLevel, - RoadClass roadClass, - double worldScale + private void paintTunnelShell( + WorldGenLevel level, + ChunkAccess chunk, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + boolean[] tunnelCarveMask, + int[] tunnelCarveDeckY ) { - int baseline = bridgeDeckBaselineAtStation(station, totalLength, startSurface, endSurface); - int rise = bridgeRiseAtStation(station, totalLength, bridgeLevel); - int clearance = bridgeClearanceAtStation(station, totalLength, roadClass, worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + int chunkIndex = chunkIndex(localX, localZ); + if (!tunnelCarveMask[chunkIndex]) { + continue; + } + + int deckY = tunnelCarveDeckY[chunkIndex]; + if (deckY < chunkMinY || deckY >= chunkMaxY) { + continue; + } + + int worldX = chunkMinX + localX; + int worldZ = chunkMinZ + localZ; + int topY = Math.min(chunkMaxY, deckY + OSM_TUNNEL_INTERNAL_HEIGHT); + for (Direction direction : Direction.Plane.HORIZONTAL) { + int neighborLocalX = localX + direction.getStepX(); + int neighborLocalZ = localZ + direction.getStepZ(); + if (neighborLocalX < 0 || neighborLocalX >= CHUNK_SIDE || neighborLocalZ < 0 || neighborLocalZ >= CHUNK_SIDE) { + continue; + } + int neighborIndex = chunkIndex(neighborLocalX, neighborLocalZ); + if (tunnelCarveMask[neighborIndex] && Math.abs(tunnelCarveDeckY[neighborIndex] - deckY) <= 1) { + continue; + } + + int wallX = worldX + direction.getStepX(); + int wallZ = worldZ + direction.getStepZ(); + for (int y = deckY + 1; y <= topY; y++) { + cursor.set(wallX, y, wallZ); + if (isTunnelCarveReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, tunnelShellState(wallX, y, wallZ)); + } + } + } + + if (Math.floorMod(worldX * 31 + worldZ * 17, 13) == 0) { + cursor.set(worldX, topY, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, Blocks.SEA_LANTERN.defaultBlockState()); + } + } + } + } + } + + private static BlockState tunnelShellState(int worldX, int y, int worldZ) { + int roll = seededRandomInt(seedFromCoords(worldX, y, worldZ), 100); + if (roll < 12) { + return Blocks.CRACKED_STONE_BRICKS.defaultBlockState(); + } + if (roll < 16) { + return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); + } + return Blocks.STONE_BRICKS.defaultBlockState(); + } + + private void paintRoadLaneMarkings( + WorldGenLevel level, + ChunkAccess chunk, + List roads, + EarthChunkGenerator.RoadWidths widths, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass + ) { + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + + for (RoadFeature road : roads) { + int lanes = road.laneCount(); + if (lanes < 2 || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; + } + + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } + + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double laneWidth = roadWidth / (double)lanes; + if (laneWidth < 1.0) { + continue; + } + + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + double stationBase = 0.0; + for (int point = 1; point < road.pointCount(); point++) { + double currentX = road.lonAt(point) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(point), worldScale); + double dx = currentX - previousX; + double dz = currentZ - previousZ; + double segmentLength = Math.sqrt(dx * dx + dz * dz); + if (segmentLength <= 1.0E-6) { + previousX = currentX; + previousZ = currentZ; + continue; + } + + double tangentX = dx / segmentLength; + double tangentZ = dz / segmentLength; + double normalX = -tangentZ; + double normalZ = tangentX; + for (double station = 0.0; station <= segmentLength; station += 1.0) { + double globalStation = stationBase + station; + if (((int)Math.floor(globalStation / 5.0)) % 2 != 0) { + continue; + } + + double centerX = previousX + tangentX * station; + double centerZ = previousZ + tangentZ * station; + for (int laneBoundary = 1; laneBoundary < lanes; laneBoundary++) { + double offset = -roadWidth * 0.5 + laneWidth * laneBoundary; + int worldX = Mth.floor(centerX + normalX * offset + 0.5); + int worldZ = Mth.floor(centerZ + normalZ * offset + 0.5); + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (localX < 0 || localX >= CHUNK_SIDE || localZ < 0 || localZ >= CHUNK_SIDE) { + continue; + } + + int deckY = laneMarkDeckY( + road.mode(), + roadClassId, + roadModeId, + localX, + localZ, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + if (deckY < chunkMinY || deckY > chunkMaxY) { + continue; + } + + this.paintRoadDeckBlock(level, chunk, cursor, worldX, deckY, worldZ, ROAD_LANE_MARK_STATE); + } + } + + stationBase += segmentLength; + previousX = currentX; + previousZ = currentZ; + } + } + } + + private static int laneMarkDeckY( + RoadMode roadMode, + int roadClassId, + int roadModeId, + int localX, + int localZ, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass + ) { + if (roadMode == RoadMode.BRIDGE) { + int extIndex = extIndex(localX + padding, localZ + padding, extSide); + return bridgeOverlayPresent[extIndex] && bridgeOverlayClass[extIndex] == roadClassId ? bridgeOverlayDeckY[extIndex] : Integer.MIN_VALUE; + } + + int chunkIndex = chunkIndex(localX, localZ); + return chunkRoadClass[chunkIndex] == roadClassId && chunkRoadMode[chunkIndex] == roadModeId ? chunkRoadDeckY[chunkIndex] : Integer.MIN_VALUE; + } + + private void paintRoadEdgeBlock( + WorldGenLevel level, + ChunkAccess chunk, + double centerX, + double centerZ, + double normalX, + double normalZ, + double offset, + RoadMode roadMode, + int roadClassId, + int roadModeId, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + MutableBlockPos cursor, + BlockState state + ) { + int worldX = Mth.floor(centerX + normalX * offset + 0.5); + int worldZ = Mth.floor(centerZ + normalZ * offset + 0.5); + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (localX < 0 || localX >= CHUNK_SIDE || localZ < 0 || localZ >= CHUNK_SIDE) { + return; + } + + int deckY = laneMarkDeckY( + roadMode, + roadClassId, + roadModeId, + localX, + localZ, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + if (deckY >= chunkMinY && deckY <= chunkMaxY) { + this.paintRoadDeckBlock(level, chunk, cursor, worldX, deckY, worldZ, state); + } + } + + private void paintRoadDeckBlock(WorldGenLevel level, ChunkAccess chunk, MutableBlockPos cursor, int worldX, int deckY, int worldZ, BlockState state) { + cursor.set(worldX, deckY, worldZ); + if (isRoadDeckState(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, state); + } + } + + private EarthChunkGenerator.RoadColumnSample sampleRoadColumnForOverlay( + int worldX, + int worldZ, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + if (worldX >= chunkMinX && worldX < chunkMinX + CHUNK_SIDE && worldZ >= chunkMinZ && worldZ < chunkMinZ + CHUNK_SIDE) { + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + int index = chunkIndex(localX, localZ); + return new EarthChunkGenerator.RoadColumnSample( + terrainSurfaces[index], waterSurfaces[index], waterFlags[index] && waterSurfaces[index] > terrainSurfaces[index] + ); + } else { + long packed = packColumn(worldX, worldZ); + EarthChunkGenerator.RoadColumnSample cached = (EarthChunkGenerator.RoadColumnSample)edgeColumnCache.get(packed); + if (cached != null) { + return cached; + } else { + WaterSurfaceResolver.WaterColumnData column = this.resolveAuxWaterColumn(worldX, worldZ); + EarthChunkGenerator.RoadColumnSample sampled = new EarthChunkGenerator.RoadColumnSample( + column.terrainSurface(), column.waterSurface(), column.hasWater() && column.waterSurface() > column.terrainSurface() + ); + edgeColumnCache.put(packed, sampled); + return sampled; + } + } + } + + private static EarthChunkGenerator.RoadWidths resolveRoadWidths(double worldScale) { + double factor = roadWidthFactorForScale(worldScale); + + return new EarthChunkGenerator.RoadWidths( + widthForScale(RoadClass.MAIN.baseWidth(), factor), + widthForScale(RoadClass.NORMAL.baseWidth(), factor), + widthForScale(RoadClass.DIRT.baseWidth(), factor) + ); + } + + private static int widthForScale(int baseWidth, double factor) { + return Math.max(1, (int)Math.round(baseWidth * factor)); + } + + private static double roadWidthFactorForScale(double worldScale) { + if (!(worldScale > 0.0)) { + return 0.25; + } else if (worldScale <= 1.0) { + return 1.8; + } else if (worldScale <= 5.0) { + double t = (worldScale - 1.0) / 4.0; + return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.8, 1.0); + } else if (worldScale <= 10.0) { + double t = (worldScale - 5.0) / 5.0; + return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.0, 0.5); + } else { + return 0.25; + } + } + + private static int bridgeRiseAtStation(double station, double totalLength, int bridgeLevel) { + int requestedRise = Math.max(0, bridgeLevel) * OSM_ROAD_BRIDGE_LEVEL_HEIGHT; + requestedRise = Math.min(requestedRise, OSM_ROAD_BRIDGE_MAX_RISE); + if (requestedRise > 0 && !(totalLength <= 1.0E-6)) { + double maxRiseByLength = totalLength / (2.0 * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL); + int targetRise = Math.min(requestedRise, Math.max(0, (int)Math.floor(maxRiseByLength))); + if (targetRise <= 0) { + return 0; + } else { + double clampedStation = Mth.clamp(station, 0.0, totalLength); + double rampLength = targetRise * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; + double rise; + if (totalLength >= rampLength * 2.0) { + if (clampedStation < rampLength) { + rise = targetRise * (clampedStation / rampLength); + } else if (clampedStation > totalLength - rampLength) { + rise = targetRise * ((totalLength - clampedStation) / rampLength); + } else { + rise = targetRise; + } + } else { + double half = totalLength * 0.5; + if (half <= 1.0E-6) { + rise = targetRise; + } else if (clampedStation <= half) { + rise = targetRise * (clampedStation / half); + } else { + rise = targetRise * ((totalLength - clampedStation) / half); + } + } + + return Math.max(0, (int)Math.round(Mth.clamp(rise, 0.0, targetRise))); + } + } else { + return 0; + } + } + + private static int bridgeDeckBaselineAtStation(double station, double totalLength, int startSurface, int endSurface) { + return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); + } + + private static int bridgeDeckYAtStation( + double station, + double totalLength, + int startSurface, + int endSurface, + int localRoadSurface, + int bridgeLevel, + RoadClass roadClass, + double worldScale + ) { + int baseline = bridgeDeckBaselineAtStation(station, totalLength, startSurface, endSurface); + int rise = bridgeRiseAtStation(station, totalLength, bridgeLevel); + int clearance = bridgeClearanceAtStation(station, totalLength, roadClass, worldScale); return Math.max(baseline + rise, localRoadSurface + clearance); } @@ -2149,18 +2784,31 @@ private static int modePriority(byte modeId) { } private static void mergeBridgeOverlay( - int index, int classId, int deckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass + int index, + int classId, + byte surfaceId, + int roadWidth, + int deckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + byte[] bridgeOverlaySurface, + int[] bridgeOverlayWidth ) { if (!bridgeOverlayPresent[index]) { bridgeOverlayPresent[index] = true; bridgeOverlayDeckY[index] = deckY; bridgeOverlayClass[index] = (byte)classId; + bridgeOverlaySurface[index] = surfaceId; + bridgeOverlayWidth[index] = roadWidth; } else { int existingDeck = bridgeOverlayDeckY[index]; int existingClass = bridgeOverlayClass[index]; if (deckY > existingDeck || deckY == existingDeck && classId < existingClass) { bridgeOverlayDeckY[index] = deckY; bridgeOverlayClass[index] = (byte)classId; + bridgeOverlaySurface[index] = surfaceId; + bridgeOverlayWidth[index] = roadWidth; } } } @@ -2198,6 +2846,7 @@ private void rasterizeBridgeSupports( double worldScale = EarthProjection.worldScaleFromBlocksPerDegree(blocksPerDegree); for (RoadFeature road : roads) { + int featureRoadWidth = roadWidthForFeature(road, roadWidth); EarthChunkGenerator.RoadColumnSample startColumn = this.sampleRoadColumnForOverlay( Mth.floor(road.lonAt(0) * blocksPerDegree), Mth.floor(EarthProjection.latToBlockZ(road.latAt(0), worldScale)), @@ -2220,8 +2869,8 @@ private void rasterizeBridgeSupports( ); int startSurface = startColumn.roadSurface(); int endSurface = endColumn.roadSurface(); - BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), roadWidth); - BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, roadWidth, placement -> { + BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), featureRoadWidth); + BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, featureRoadWidth, placement -> { IntArrayList capCells = new IntArrayList(); IntArrayList[] shaftCells = new IntArrayList[style.shaftCount()]; int[] minTerrain = new int[style.shaftCount()]; @@ -2514,6 +3163,28 @@ private static BlockState roadStateForClass(RoadClass roadClass) { }; } + private static BlockState roadStateForOverlay(RoadClass roadClass, int surfaceId) { + return switch (surfaceId) { + case ROAD_SURFACE_UNPAVED -> ROAD_DIRT_STATE; + case ROAD_SURFACE_PAVED_PATH -> ROAD_SIDEWALK_STATE; + default -> roadStateForClass(roadClass); + }; + } + + private static byte roadSurfaceId(RoadFeature road) { + if (road.isUnpavedSurface()) { + return ROAD_SURFACE_UNPAVED; + } + return isPavedPedestrianRoad(road) ? ROAD_SURFACE_PAVED_PATH : ROAD_SURFACE_DEFAULT; + } + + private static boolean isPavedPedestrianRoad(RoadFeature road) { + boolean pedestrian = road.matchesHighwayTag("footway") + || road.matchesHighwayTag("pedestrian") + || road.matchesHighwayTag("cycleway"); + return pedestrian && (road.isPavedSurface() || road.surfaceTag().isEmpty()); + } + private static RoadClass roadClassFromId(int classId) { return switch (classId) { @@ -2539,6 +3210,21 @@ private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.Ro }; } + private static int roadWidthForFeature(RoadFeature road, int classWidth) { + int width = classWidth; + int lanes = road.laneCount(); + if (lanes > 0) { + int laneWidth = road.roadClass() == RoadClass.MAIN ? 3 : 2; + width = Math.max(width, lanes * laneWidth); + } + + if (road.hasSidewalk() && road.roadClass() != RoadClass.DIRT) { + width += 2; + } + + return Mth.clamp(width, 1, OSM_ROAD_MAX_TAGGED_WIDTH); + } + private static int roadLightSpacingBlocks(double worldScale) { if (!(worldScale > 0.0)) { return 40; @@ -2708,7 +3394,11 @@ private static BlockState roadLightTrapdoorState(Direction facing) { } private static boolean isRoadDeckState(BlockState state) { - return state.is(Blocks.GRAY_CONCRETE) || state.is(Blocks.CYAN_TERRACOTTA) || state.is(Blocks.DIRT_PATH); + return state.is(Blocks.GRAY_CONCRETE) + || state.is(Blocks.CYAN_TERRACOTTA) + || state.is(Blocks.DIRT_PATH) + || state.is(Blocks.WHITE_CONCRETE) + || state.is(Blocks.SMOOTH_STONE); } private static boolean isRoadLightReplaceable(BlockState state) { @@ -3454,8 +4144,8 @@ private boolean isWoodlandMansionStartTooSteep(StructureStart start) { private void filterStartsCollidingWithOsm(RegistryAccess registryAccess, ChunkAccess chunk) { double worldScale = this.settings.worldScale(); - boolean roadsActive = this.settings.enableRoads() && OSM_ROAD_SOURCE.available() && worldScale > 0.0 && worldScale <= OSM_ROAD_MAX_SCALE; - boolean buildingsActive = this.settings.enableBuildings() && OSM_BUILDING_SOURCE.available() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; + boolean roadsActive = this.settings.enableRoads() && this.roadSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_ROAD_MAX_SCALE; + boolean buildingsActive = this.settings.enableBuildings() && this.buildingSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; if (roadsActive || buildingsActive) { Map starts = chunk.getAllStarts(); if (!starts.isEmpty()) { @@ -3513,7 +4203,7 @@ private boolean structureCollidesWithBuildings(BoundingBox box) { } private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { - int marginBlocks = Math.max(roadWidths.main(), Math.max(roadWidths.normal(), roadWidths.dirt())) + 2; + int marginBlocks = Math.max(OSM_ROAD_MAX_TAGGED_WIDTH, Math.max(roadWidths.main(), Math.max(roadWidths.normal(), roadWidths.dirt()))) + 2; OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; EarthChunkGenerator.OsmRoadQueryResult query = this.fetchOsmRoadsForAreaDetailed( box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode @@ -3576,11 +4266,7 @@ private boolean structureIntersectsBuilding( private boolean structureIntersectsRoad( BoundingBox box, RoadFeature road, EarthChunkGenerator.RoadWidths roadWidths, double blocksPerDegree, double worldScale ) { - int roadWidth = switch (road.roadClass()) { - case MAIN -> roadWidths.main(); - case NORMAL -> roadWidths.normal(); - case DIRT -> roadWidths.dirt(); - }; + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), roadWidths)); double halfWidth = Math.max(0.5, (roadWidth - 1) * 0.5); double minX = structureFootprintMinX(box) - halfWidth; double maxX = structureFootprintMaxX(box) + halfWidth; @@ -3885,8 +4571,8 @@ && isSolidCaveAnchor(groundState) level.setBlock(ground, GRASS_BLOCK_STATE, 260); } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); + ArnisTreeType treeType = wildTreeTypeForBiome(biome, seed); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); } } } @@ -3898,120 +4584,1393 @@ && isSolidCaveAnchor(groundState) } } - private List prepareDeferredTreePlacements( - EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + private List prepareDeferredTreePlacements( + EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + ) { + ChunkPos pos = context.pos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + int shorelineBlendRadius = Math.max(this.settings.riverLakeShorelineBlend(), this.settings.oceanShorelineBlend()); + int cellMinX = Math.floorDiv(chunkMinX, 5); + int cellMaxX = Math.floorDiv(chunkMaxX, 5); + int cellMinZ = Math.floorDiv(chunkMinZ, 5); + int cellMaxZ = Math.floorDiv(chunkMaxZ, 5); + long worldSeed = this.worldSeed; + List placements = new ArrayList<>(); + + for (int cellX = cellMinX; cellX <= cellMaxX; cellX++) { + for (int cellZ = cellMinZ; cellZ <= cellMaxZ; cellZ++) { + long seed = seedFromCoords(cellX, 0, cellZ) ^ worldSeed; + RandomSource random = RandomSource.create(seed); + int worldX = cellX * 5 + random.nextInt(5); + int worldZ = cellZ * 5 + random.nextInt(5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } + + int index = chunkIndex(localX, localZ); + int coverClass = context.coverClasses()[index]; + if (coverClass != 10) { + continue; + } + + boolean nearWater = false; + if (shorelineBlendRadius > 0) { + nearWater = localX - shorelineBlendRadius >= 0 + && localX + shorelineBlendRadius <= CHUNK_MASK + && localZ - shorelineBlendRadius >= 0 + && localZ + shorelineBlendRadius <= CHUNK_MASK + ? hasWaterNear(context.waterFlags(), localX, localZ, shorelineBlendRadius) + : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + } + + if (nearWater) { + continue; + } + + int expectedSurface = context.terrainSurfaces()[index]; + if (expectedSurface < this.seaLevel) { + continue; + } + + Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); + if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { + continue; + } + + placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); + } + } + + return placements.isEmpty() ? List.of() : List.copyOf(placements); + } + + private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { + for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { + this.applyPreparedTreePlacement(level, placement); + } + } + + private void applyPreparedTreePlacement(WorldGenLevel level, EarthChunkGenerator.PreparedTreePlacement placement) { + int worldX = placement.worldX(); + int worldZ = placement.worldZ(); + int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (topY < level.getMinBuildHeight() + || topY < this.seaLevel + || placement.expectedSurface() - topY > TREE_MAX_SURFACE_DROP + || topY - placement.expectedSurface() > TREE_MAX_SURFACE_RISE) { + return; + } + + BlockPos ground = new BlockPos(worldX, topY, worldZ); + BlockState groundState = level.getBlockState(ground); + if (isRoadDeckState(groundState) + || isRoadDeckState(level.getBlockState(ground.below())) + || !isSolidCaveAnchor(groundState) + || groundState.is(BlockTags.LOGS) + || groundState.is(BlockTags.LEAVES)) { + return; + } + + Holder biome = placement.biome(); + if (biome.is(Biomes.MANGROVE_SWAMP)) { + return; + } + + List> features = treeFeaturesForBiome(biome); + if (features.isEmpty()) { + return; + } + + BlockPos position = ground.above(); + if (!groundState.is(BlockTags.DIRT)) { + level.setBlock(ground, GRASS_BLOCK_STATE, 260); + } + + ArnisTreeType treeType = wildTreeTypeForBiome(biome, placement.seed()); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); + } + + private void applyExternalCityDetails(WorldGenLevel level, ChunkAccess chunk) { + double worldScale = this.settings.worldScale(); + if (!(worldScale > 0.0) || worldScale > OSM_CITY_DETAIL_MAX_SCALE || !EXTERNAL_FEATURE_SOURCE.cityDetailsAvailable()) { + return; + } + + ChunkPos pos = chunk.getPos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + List areas = EXTERNAL_FEATURE_SOURCE.cityAreasForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!areas.isEmpty()) { + this.applyExternalCityAreas(level, chunk, areas, worldScale); + } + + List lines = EXTERNAL_FEATURE_SOURCE.cityLinesForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!lines.isEmpty()) { + this.applyExternalCityLines(level, chunk, lines, worldScale); + } + + List points = EXTERNAL_FEATURE_SOURCE.cityPointsForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!points.isEmpty()) { + this.applyExternalCityPoints(level, chunk, points, worldScale); + } + } + + private void applyExternalCityAreas(WorldGenLevel level, ChunkAccess chunk, List areas, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalAreaFeature area : areas) { + int partCount = area.rings().size(); + if (partCount == 0) { + continue; + } + + double[][] xs = new double[partCount][]; + double[][] zs = new double[partCount][]; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (int part = 0; part < partCount; part++) { + List ring = area.rings().get(part); + xs[part] = new double[ring.size()]; + zs[part] = new double[ring.size()]; + for (int point = 0; point < ring.size(); point++) { + GeoPoint geoPoint = ring.get(point); + double worldX = geoPoint.longitude() * blocksPerDegree - 0.5; + double worldZ = EarthProjection.latToBlockZ(geoPoint.latitude(), worldScale) - 0.5; + xs[part][point] = worldX; + zs[part][point] = worldZ; + minX = Math.min(minX, Mth.floor(worldX)); + maxX = Math.max(maxX, Mth.ceil(worldX)); + minZ = Math.min(minZ, Mth.floor(worldZ)); + maxZ = Math.max(maxZ, Mth.ceil(worldZ)); + } + } + + int clampedMinX = Math.max(chunkMinX, minX); + int clampedMaxX = Math.min(chunkMaxX, maxX); + int clampedMinZ = Math.max(chunkMinZ, minZ); + int clampedMaxZ = Math.min(chunkMaxZ, maxZ); + if (clampedMaxX < clampedMinX || clampedMaxZ < clampedMinZ) { + continue; + } + + ScanlinePolygonRasterizer.fill(xs, zs, clampedMinX, clampedMinZ, clampedMaxX, clampedMaxZ, (worldX, worldZ) -> { + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY) { + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState current = level.getBlockState(cursor); + if (!canReplaceCitySurface(current)) { + return; + } + BlockState state = cityAreaSurfaceState(area, worldX, worldZ); + if (state == null) { + return; + } + level.setBlock(cursor, state, this.detailApplyFlags(level)); + this.decorateExternalAreaFeature(level, cursor, area, worldX, surfaceY, worldZ, maxY); + this.decorateExternalAreaVegetation(level, cursor, area, worldX, surfaceY, worldZ, maxY); + }); + } + } + + private void decorateExternalAreaFeature( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + long seed = areaVegetationSeed(area, worldX, worldZ, 73); + int roll = seededRandomInt(seed ^ 1469598103934665603L, 1000); + int flags = this.detailApplyFlags(level); + + switch (area.kind()) { + case PARKING -> { + if (Math.floorMod(worldX, 18) == 0 && Math.floorMod(worldZ, 20) == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 5, worldZ, Blocks.GLOWSTONE.defaultBlockState(), maxY, flags); + } + } + case LANDUSE -> { + switch (type) { + case "farmland" -> { + if (Math.floorMod(worldX, 9) == 0 && Math.floorMod(worldZ, 9) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, WATER_STATE, flags); + } else if (roll < 760) { + BlockState crop = switch (roll % 4) { + case 0 -> Blocks.CARROTS.defaultBlockState(); + case 1 -> Blocks.POTATOES.defaultBlockState(); + case 2 -> Blocks.WHEAT.defaultBlockState(); + default -> Blocks.HAY_BLOCK.defaultBlockState(); + }; + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, crop, maxY, flags); + } + } + case "cemetery" -> { + if (Math.floorMod(worldX, 4) == 0 && Math.floorMod(worldZ, 5) == 0 && roll < 350) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 1, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } else if (roll < 430) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.POPPY.defaultBlockState(), maxY, flags); + } + } + case "construction" -> { + if (roll < 12) { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.SCAFFOLDING.defaultBlockState(), 4 + roll % 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.SCAFFOLDING.defaultBlockState(), maxY, flags); + } else if (roll < 55) { + BlockState material = switch (roll % 8) { + case 0 -> Blocks.OAK_LOG.defaultBlockState(); + case 1 -> Blocks.COBBLESTONE.defaultBlockState(); + case 2 -> Blocks.GRAVEL.defaultBlockState(); + case 3 -> Blocks.BRICKS.defaultBlockState(); + case 4 -> Blocks.IRON_BLOCK.defaultBlockState(); + case 5 -> Blocks.SAND.defaultBlockState(); + case 6 -> Blocks.CRAFTING_TABLE.defaultBlockState(); + default -> Blocks.FURNACE.defaultBlockState(); + }; + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, material, maxY, flags); + } + } + case "vineyard" -> { + if (Math.floorMod(worldX, 6) == 0 && roll < 720) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + if (Math.floorMod(worldZ, 3) != 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_LEAVES.defaultBlockState(), maxY, flags); + } + } + } + case "quarry", "brownfield", "landfill" -> { + if (roll < 60) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, roll < 25 ? Blocks.COBBLESTONE.defaultBlockState() : Blocks.GRAVEL.defaultBlockState(), maxY, flags); + } else if (roll < 85) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, CITY_DEAD_BUSH_STATE, maxY, flags); + } + } + default -> { + } + } + } + case LEISURE -> { + switch (type) { + case "playground", "recreation_ground", "dog_park" -> { + if (Math.floorMod(worldX, 13) == 0 && Math.floorMod(worldZ, 11) == 0 && roll < 450) { + if (roll < 150) { + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + } else if (roll < 300) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + 1, worldZ, Blocks.OAK_PLANKS.defaultBlockState(), maxY, flags); + } else { + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.SAND.defaultBlockState(), maxY, flags); + } + } + } + } + } + case "pitch" -> { + if (Math.floorMod(worldX, 16) == 0 || Math.floorMod(worldZ, 16) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + case "track" -> { + if (Math.floorMod(worldX + worldZ, 8) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + default -> { + } + } + } + case NATURAL -> { + if ("wetland".equals(type) && roll < 120) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, WATER_STATE, flags); + } else if (("bare_rock".equals(type) || "scree".equals(type) || "blockfield".equals(type) || "cliff".equals(type)) && roll < 90) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, CITY_ROCK_STATE, maxY, flags); + } + } + case WATER, AMENITY -> { + } + } + } + + private void decorateExternalAreaVegetation( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + if (!isVegetationArea(area, type)) { + return; + } + + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canDecorateAreaVegetationSurface(area, type, ground)) { + return; + } + + long seed = areaVegetationSeed(area, worldX, worldZ, 31); + if (shouldPlaceAreaTree(area, type, worldX, worldZ)) { + ArnisTreeType treeType = ArnisTreeType.chooseForAreaTags(area.tags(), type, seed); + ArnisTreeGenerator.place(level, new BlockPos(worldX, surfaceY + 1, worldZ), treeType, level.getMinBuildHeight(), maxY, this.detailApplyFlags(level)); + return; + } + + BlockState detail = areaVegetationDetailState(area, type, seed); + if (detail == null) { + return; + } + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, detail, this.detailApplyFlags(level)); + } + } + + private static boolean isVegetationArea(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "wood", "tree_row", "scrub", "heath", "grassland", "wetland", "beach", "sand", "dune", "shoal", "bare_rock", "scree", "blockfield", "mud", "mountain_range", "saddle", "ridge", "shrubbery", "tundra", "hill", "cliff" -> true; + default -> false; + }; + case LANDUSE -> switch (type) { + case "forest", "orchard", "greenfield", "meadow", "grass", "cemetery", "vineyard", "brownfield", "landfill" -> true; + default -> false; + }; + case LEISURE -> switch (type) { + case "park", "garden", "recreation_ground", "nature_reserve", "golf_course", "disc_golf_course", "dog_park" -> true; + default -> false; + }; + case PARKING, WATER, AMENITY -> false; + }; + } + + private static boolean canDecorateAreaVegetationSurface(ExternalAreaFeature area, String type, BlockState ground) { + if (ground.isAir() || !ground.getFluidState().isEmpty() || isRoadDeckState(ground) || ground.is(BlockTags.LOGS) || ground.is(BlockTags.LEAVES)) { + return false; + } + if ("beach".equals(type) || "sand".equals(type) || "dune".equals(type) || "shoal".equals(type)) { + return ground.is(Blocks.SAND) || ground.is(Blocks.RED_SAND) || ground.is(Blocks.GRAVEL); + } + if ("bare_rock".equals(type) || "scree".equals(type)) { + return ground.is(Blocks.STONE) || ground.is(Blocks.ANDESITE) || ground.is(Blocks.COBBLESTONE) || ground.is(Blocks.GRAVEL); + } + return ground.is(BlockTags.DIRT) || ground.is(Blocks.MOSS_BLOCK) || ground.is(Blocks.MUD); + } + + private static boolean shouldPlaceAreaTree(ExternalAreaFeature area, String type, int worldX, int worldZ) { + int spacing = areaTreeSpacing(area, type); + if (spacing <= 0) { + return false; + } + int cellX = Math.floorDiv(worldX, spacing); + int cellZ = Math.floorDiv(worldZ, spacing); + long seed = areaVegetationSeed(area, cellX, cellZ, 47); + if (seededRandomInt(seed, 100) >= areaTreeChancePercent(area, type)) { + return false; + } + int targetX = cellX * spacing + seededRandomInt(seed ^ 3405691582L, spacing); + int targetZ = cellZ * spacing + seededRandomInt(seed ^ 3735928559L, spacing); + return worldX == targetX && worldZ == targetZ; + } + + private static int areaTreeSpacing(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "tree_row" -> 4; + case "wood" -> 6; + case "scrub", "heath" -> 12; + default -> -1; + }; + case LANDUSE -> switch (type) { + case "forest" -> 7; + case "orchard" -> 8; + case "cemetery" -> 11; + default -> -1; + }; + case LEISURE -> switch (type) { + case "park", "garden", "recreation_ground" -> 10; + default -> -1; + }; + case PARKING, WATER, AMENITY -> -1; + }; + } + + private static int areaTreeChancePercent(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "tree_row" -> 95; + case "wood" -> 80; + case "scrub", "heath" -> 25; + default -> 0; + }; + case LANDUSE -> switch (type) { + case "forest" -> 75; + case "orchard" -> 60; + case "cemetery" -> 20; + default -> 0; + }; + case LEISURE -> 35; + case PARKING, WATER, AMENITY -> 0; + }; + } + + private static BlockState areaVegetationDetailState(ExternalAreaFeature area, String type, long seed) { + int roll = seededRandomInt(seed ^ 81985529216486895L, 100); + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "wood", "tree_row" -> roll < 28 ? CITY_FERN_STATE : roll < 36 ? CITY_SHRUB_STATE : null; + case "scrub" -> roll < 30 ? CITY_SHRUB_STATE : roll < 55 ? CITY_FERN_STATE : roll < 61 ? CITY_ROCK_STATE : null; + case "heath" -> roll < 18 ? CITY_SHRUB_STATE : roll < 45 ? CITY_FERN_STATE : roll < 50 ? CITY_ROCK_STATE : null; + case "grassland" -> roll < 42 ? CITY_FERN_STATE : roll < 46 ? CITY_SHRUB_STATE : null; + case "wetland" -> roll < 32 ? CITY_FERN_STATE : null; + case "beach", "sand", "dune", "shoal" -> roll < 4 ? CITY_DEAD_BUSH_STATE : null; + case "bare_rock", "scree", "blockfield", "mountain_range", "saddle", "ridge", "cliff" -> roll < 10 ? CITY_ROCK_STATE : null; + case "mud", "tundra", "hill" -> roll < 22 ? CITY_FERN_STATE : roll < 28 ? CITY_DEAD_BUSH_STATE : null; + case "shrubbery" -> roll < 60 ? CITY_SHRUB_STATE : null; + default -> null; + }; + case LANDUSE -> switch (type) { + case "forest", "orchard" -> roll < 25 ? CITY_FERN_STATE : roll < 33 ? CITY_SHRUB_STATE : null; + case "cemetery" -> roll < 10 ? CITY_FERN_STATE : roll < 15 ? CITY_SHRUB_STATE : null; + case "greenfield", "meadow", "grass" -> roll < 35 ? CITY_FERN_STATE : null; + case "vineyard", "brownfield", "landfill" -> roll < 18 ? CITY_FERN_STATE : roll < 24 ? CITY_DEAD_BUSH_STATE : null; + default -> null; + }; + case LEISURE -> roll < 20 ? CITY_FERN_STATE : roll < 26 ? CITY_SHRUB_STATE : null; + case PARKING, WATER, AMENITY -> null; + }; + } + + private static long areaVegetationSeed(ExternalAreaFeature area, int x, int z, int salt) { + long featureSeed = (long)area.sourceId().hashCode() * 7046029254386353131L ^ (long)area.typeTag().hashCode() * 2862933555777941757L; + return seedFromCoords(x, salt, z) ^ featureSeed; + } + + private void applyExternalCityLines(WorldGenLevel level, ChunkAccess chunk, List lines, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalLineFeature line : lines) { + if (line.points().size() < 2) { + continue; + } + for (int index = 1; index < line.points().size(); index++) { + GeoPoint previous = line.points().get(index - 1); + GeoPoint current = line.points().get(index); + double startX = previous.longitude() * blocksPerDegree; + double startZ = EarthProjection.latToBlockZ(previous.latitude(), worldScale); + double endX = current.longitude() * blocksPerDegree; + double endZ = EarthProjection.latToBlockZ(current.latitude(), worldScale); + int steps = Math.max(1, Mth.ceil(Math.max(Math.abs(endX - startX), Math.abs(endZ - startZ)) * 2.0)); + for (int step = 0; step <= steps; step++) { + double t = step / (double)steps; + int worldX = Mth.floor(Mth.lerp(t, startX, endX) + 0.5); + int worldZ = Mth.floor(Mth.lerp(t, startZ, endZ) + 0.5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + this.placeExternalCityLineColumn(level, cursor, line, worldX, worldZ, minY, maxY); + } + } + } + } + + private void placeExternalCityLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int worldZ, int minY, int maxY + ) { + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + return; + } + if (!cityFeatureVisible(line.tags())) { + return; + } + if (line.kind() == ExternalLineKind.POWER) { + this.placePowerLineColumn(level, cursor, line, worldX, surfaceY, worldZ, maxY); + return; + } + if (line.kind() == ExternalLineKind.MAN_MADE) { + this.placeManMadeLineColumn(level, cursor, line, worldX, surfaceY, worldZ, maxY); + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canAnchorCityLine(ground)) { + return; + } + + if (line.kind() == ExternalLineKind.RAILWAY) { + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_RAIL_STATE, this.detailApplyFlags(level)); + } + return; + } + if (line.kind() == ExternalLineKind.WATERWAY) { + cursor.set(worldX, surfaceY, worldZ); + if (canReplaceCitySurface(level.getBlockState(cursor))) { + level.setBlock(cursor, WATER_STATE, this.detailApplyFlags(level)); + } + return; + } + if (line.kind() != ExternalLineKind.BARRIER) { + return; + } + + BlockState state = cityBarrierState(line); + int height = cityBarrierHeight(line); + for (int offset = 1; offset <= height; offset++) { + int y = surfaceY + offset; + if (y > maxY) { + break; + } + cursor.set(worldX, y, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, state, this.detailApplyFlags(level)); + } + } + } + + private void placePowerLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"line".equals(line.typeTag()) && !"minor_line".equals(line.typeTag())) { + return; + } + int y = surfaceY + powerLineHeight(line); + if (y > maxY) { + return; + } + cursor.set(worldX, y, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, Blocks.IRON_BARS.defaultBlockState(), this.detailApplyFlags(level)); + } + } + + private static int powerLineHeight(ExternalLineFeature line) { + int voltage = intFromTag(line.tags().get("voltage"), 0); + if (voltage >= 220000) { + return 22; + } + if (voltage >= 110000) { + return 18; + } + if (voltage >= 33000) { + return 14; + } + return "minor_line".equals(line.typeTag()) ? 8 : 12; + } + + private void placeManMadeLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"pier".equals(line.typeTag()) || surfaceY + 1 > maxY) { + return; + } + int flags = this.detailApplyFlags(level); + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, Blocks.OAK_SLAB.defaultBlockState(), flags); + } + cursor.set(worldX, surfaceY, worldZ); + BlockState supportTarget = level.getBlockState(cursor); + if (supportTarget.getFluidState().isEmpty() && !isRoadLightReplaceable(supportTarget)) { + return; + } + level.setBlock(cursor, Blocks.OAK_FENCE.defaultBlockState(), flags); + } + + private void applyExternalCityPoints(WorldGenLevel level, ChunkAccess chunk, List points, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalPointFeature point : points) { + int worldX = Mth.floor(point.point().longitude() * blocksPerDegree + 0.5); + int worldZ = Mth.floor(EarthProjection.latToBlockZ(point.point().latitude(), worldScale) + 0.5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + this.placeExternalCityPoint(level, cursor, point, worldX, worldZ, minY, maxY); + } + } + + private void placeExternalCityPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int worldZ, int minY, int maxY + ) { + if (!cityFeatureVisible(point.tags())) { + return; + } + if (point.kind() == ExternalPointKind.CROSSING) { + this.paintCrossingPoint(level, cursor, worldX, worldZ); + return; + } + if (point.kind() == ExternalPointKind.ENTRANCE) { + this.placeEntrancePoint(level, cursor, worldX, worldZ, minY, maxY); + return; + } + + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canAnchorCityLine(ground)) { + int[] anchor = this.findCityPointAnchor(level, cursor, worldX, worldZ, minY, maxY); + if (anchor == null) { + return; + } + worldX = anchor[0]; + surfaceY = anchor[1]; + worldZ = anchor[2]; + } + + switch (point.kind()) { + case TRAFFIC_SIGNAL -> this.placeTrafficSignal(level, cursor, worldX, surfaceY, worldZ, maxY); + case HIGHWAY -> this.placeHighwayPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case AMENITY -> this.placeAmenityPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case NATURAL -> this.placeNaturalPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case ADVERTISING -> this.placeAdvertisingPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case EMERGENCY -> this.placeEmergencyPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case HISTORIC -> this.placeHistoricPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case TOURISM -> this.placeTourismPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case MAN_MADE -> this.placeManMadePoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case POWER -> this.placePowerPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case BARRIER -> this.placeBarrierPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case RAILWAY -> this.placeRailwayPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case ENTRANCE, CROSSING -> { + } + } + } + + private int[] findCityPointAnchor(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ, int minY, int maxY) { + for (int radius = 1; radius <= 3; radius++) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) { + continue; + } + + int targetX = worldX + dx; + int targetZ = worldZ + dz; + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, targetX, targetZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + continue; + } + cursor.set(targetX, surfaceY, targetZ); + if (canAnchorCityLine(level.getBlockState(cursor))) { + return new int[]{targetX, surfaceY, targetZ}; + } + } + } + } + return null; + } + + private void placeEntrancePoint(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ, int minY, int maxY) { + int flags = this.detailApplyFlags(level); + for (int radius = 0; radius <= 1; radius++) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) { + continue; + } + int targetX = worldX + dx; + int targetZ = worldZ + dz; + for (Direction facing : Direction.Plane.HORIZONTAL) { + int outsideX = targetX + facing.getStepX(); + int outsideZ = targetZ + facing.getStepZ(); + int outsideSurfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, outsideX, outsideZ) - 1; + int lowerY = outsideSurfaceY + 1; + int upperY = lowerY + 1; + if (lowerY < minY || upperY > maxY) { + continue; + } + + cursor.set(targetX, lowerY, targetZ); + BlockState lowerTarget = level.getBlockState(cursor); + cursor.set(targetX, upperY, targetZ); + BlockState upperTarget = level.getBlockState(cursor); + cursor.set(outsideX, lowerY, outsideZ); + BlockState lowerOutside = level.getBlockState(cursor); + cursor.set(outsideX, upperY, outsideZ); + BlockState upperOutside = level.getBlockState(cursor); + if (!isBuildingDoorTarget(lowerTarget) + || !isBuildingDoorTarget(upperTarget) + || !isRoadLightReplaceable(lowerOutside) + || !isRoadLightReplaceable(upperOutside)) { + continue; + } + + BlockState lower = Blocks.OAK_DOOR.defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, facing) + .setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER); + BlockState upper = lower.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER); + cursor.set(targetX, lowerY, targetZ); + level.setBlock(cursor, lower, flags); + cursor.set(targetX, upperY, targetZ); + level.setBlock(cursor, upper, flags); + return; + } + } + } + } + } + + private void paintCrossingPoint(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ) { + int flags = this.detailApplyFlags(level); + for (int dz = -2; dz <= 2; dz++) { + for (int dx = -2; dx <= 2; dx++) { + if (Math.floorMod(dx + dz, 2) != 0) { + continue; + } + int targetX = worldX + dx; + int targetZ = worldZ + dz; + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, targetX, targetZ) - 1; + if (surfaceY < level.getMinBuildHeight()) { + continue; + } + cursor.set(targetX, surfaceY, targetZ); + if (isRoadDeckState(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + } + } + + private void placeTrafficSignal(WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int maxY) { + int flags = this.detailApplyFlags(level); + if (surfaceY + 3 > maxY) { + return; + } + cursor.set(worldX, surfaceY + 1, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return; + } + level.setBlock(cursor, CITY_TRAFFIC_POLE_STATE, flags); + cursor.set(worldX, surfaceY + 2, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_TRAFFIC_POLE_STATE, flags); + } + cursor.set(worldX, surfaceY + 3, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_TRAFFIC_LIGHT_STATE, flags); + } + } + + private void placeHighwayPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "street_lamp" -> { + if (surfaceY + 5 > maxY) { + return; + } + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 5, worldZ, Blocks.GLOWSTONE.defaultBlockState(), maxY, flags); + } + case "bus_stop" -> { + if (surfaceY + 4 > maxY) { + return; + } + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 4, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + } + default -> { + } + } + } + + private void placeAmenityPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + cursor.set(worldX, surfaceY + 1, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return; + } + switch (type) { + case "bench" -> level.setBlock(cursor, CITY_BENCH_STATE, flags); + case "bicycle_parking", "shelter" -> level.setBlock(cursor, CITY_BARRIER_RAIL_STATE, flags); + case "recycling" -> level.setBlock(cursor, Blocks.BARREL.defaultBlockState(), flags); + case "waste_disposal", "waste_basket" -> level.setBlock(cursor, Blocks.CAULDRON.defaultBlockState(), flags); + case "vending_machine", "atm" -> { + level.setBlock(cursor, Blocks.IRON_BLOCK.defaultBlockState(), flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + case "drinking_water" -> { + level.setBlock(cursor, Blocks.COBBLESTONE_WALL.defaultBlockState(), flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.CAULDRON.defaultBlockState(), maxY, flags); + } + case "fountain" -> { + level.setBlock(cursor, WATER_STATE, flags); + for (Direction direction : Direction.Plane.HORIZONTAL) { + cursor.set(worldX + direction.getStepX(), surfaceY + 1, worldZ + direction.getStepZ()); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_FOUNTAIN_BASE_STATE, flags); + } + } + } + case "fuel" -> level.setBlock(cursor, CITY_TRAFFIC_LIGHT_STATE, flags); + default -> { + } + } + } + + private void placeNaturalPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY ) { - ChunkPos pos = context.pos(); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); - int chunkMaxX = chunkMinX + CHUNK_MASK; - int chunkMaxZ = chunkMinZ + CHUNK_MASK; - int shorelineBlendRadius = Math.max(this.settings.riverLakeShorelineBlend(), this.settings.oceanShorelineBlend()); - int cellMinX = Math.floorDiv(chunkMinX, 5); - int cellMaxX = Math.floorDiv(chunkMaxX, 5); - int cellMinZ = Math.floorDiv(chunkMinZ, 5); - int cellMaxZ = Math.floorDiv(chunkMaxZ, 5); - long worldSeed = this.worldSeed; - List placements = new ArrayList<>(); + if (!"tree".equals(point.typeTag())) { + return; + } + long seed = seedFromCoords(worldX, 5, worldZ) ^ this.worldSeed ^ (long)point.sourceId().hashCode() * 7046029254386353131L; + ArnisTreeType treeType = ArnisTreeType.chooseForPointTags(point.tags(), seed); + ArnisTreeGenerator.place(level, new BlockPos(worldX, surfaceY + 1, worldZ), treeType, level.getMinBuildHeight(), maxY, this.detailApplyFlags(level)); + } - for (int cellX = cellMinX; cellX <= cellMaxX; cellX++) { - for (int cellZ = cellMinZ; cellZ <= cellMaxZ; cellZ++) { - long seed = seedFromCoords(cellX, 0, cellZ) ^ worldSeed; - RandomSource random = RandomSource.create(seed); - int worldX = cellX * 5 + random.nextInt(5); - int worldZ = cellZ * 5 + random.nextInt(5); - if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { - continue; + private void placeAdvertisingPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "column" -> { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.GREEN_CONCRETE.defaultBlockState(), 2, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + case "flag" -> { + int height = Math.max(4, Math.min(12, intFromTag(point.tags().get("height"), 6))); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), height, maxY, flags); + BlockState flag = cityFlagState(point.sourceId()); + for (int dx = 1; dx <= 3; dx++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height, worldZ, flag, maxY, flags); + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height - 1, worldZ, flag, maxY, flags); } - - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { - continue; + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 1, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + case "poster_box" -> { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); + for (int y = surfaceY + 2; y <= surfaceY + 3; y++) { + this.placeCityBlock(level, cursor, worldX, y, worldZ, Blocks.SEA_LANTERN.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, y, worldZ, Blocks.SEA_LANTERN.defaultBlockState(), maxY, flags); } + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 4, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + default -> { + } + } + } - int index = chunkIndex(localX, localZ); - int coverClass = context.coverClasses()[index]; - if (coverClass != 10) { - continue; - } + private void placeEmergencyPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"fire_hydrant".equals(point.typeTag())) { + return; + } + String hydrantType = point.tags().getOrDefault("fire_hydrant:type", "pillar").trim().toLowerCase(Locale.ROOT); + if ("underground".equals(hydrantType) || "wall".equals(hydrantType) || "pond".equals(hydrantType)) { + return; + } + int flags = this.detailApplyFlags(level); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.BRICK_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.REDSTONE_BLOCK.defaultBlockState(), maxY, flags); + } - boolean nearWater = false; - if (shorelineBlendRadius > 0) { - nearWater = localX - shorelineBlendRadius >= 0 - && localX + shorelineBlendRadius <= CHUNK_MASK - && localZ - shorelineBlendRadius >= 0 - && localZ + shorelineBlendRadius <= CHUNK_MASK - ? hasWaterNear(context.waterFlags(), localX, localZ, shorelineBlendRadius) - : this.isNearWater(worldX, worldZ, shorelineBlendRadius); - } + private void placeHistoricPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + String subtype = point.tags().getOrDefault("memorial", "").trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + if ("wayside_cross".equals(type) || "cross".equals(subtype) || "war_memorial".equals(subtype)) { + this.placeCityCross(level, cursor, worldX, surfaceY, worldZ, 5, maxY, flags); + } else if ("monument".equals(type)) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + } + } + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.POLISHED_ANDESITE.defaultBlockState(), 6, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 8, worldZ, Blocks.CHISELED_STONE_BRICKS.defaultBlockState(), maxY, flags); + } else if ("obelisk".equals(subtype)) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.SMOOTH_QUARTZ.defaultBlockState(), 5, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 7, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } else if ("stone".equals(subtype) || "stolperstein".equals(subtype)) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, "stolperstein".equals(subtype) ? Blocks.GOLD_BLOCK.defaultBlockState() : Blocks.STONE.defaultBlockState(), flags); + } else { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.CHISELED_STONE_BRICKS.defaultBlockState(), maxY, flags); + if ("statue".equals(subtype) || "sculpture".equals(subtype) || "bust".equals(subtype)) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + } else { + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + } + } - if (nearWater) { - continue; - } + private void placeTourismPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"information".equals(point.typeTag())) { + return; + } + String information = point.tags().getOrDefault("information", "").trim().toLowerCase(Locale.ROOT); + if ("office".equals(information) || "visitor_centre".equals(information)) { + return; + } + int flags = this.detailApplyFlags(level); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_PLANKS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.BLUE_WOOL.defaultBlockState(), maxY, flags); + } - int expectedSurface = context.terrainSurfaces()[index]; - if (expectedSurface < this.seaLevel) { - continue; + private void placeManMadePoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "antenna", "mast" -> { + int height = Math.max(10, Math.min(30, intFromTag(point.tags().get("height"), 18))); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.GRAY_CONCRETE.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.IRON_BARS.defaultBlockState(), height, maxY, flags); + for (int y = surfaceY + 7; y <= surfaceY + height; y += 7) { + this.placeCityBlock(level, cursor, worldX + 1, y, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, y, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, y, worldZ + 1, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, y, worldZ - 1, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 2, worldZ, Blocks.LIGHTNING_ROD.defaultBlockState(), maxY, flags); + } + case "chimney" -> { + int height = Math.max(10, Math.min(25, intFromTag(point.tags().get("height"), 18))); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.BRICKS.defaultBlockState(), height, maxY, flags); + } + case "water_well" -> { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dz == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, WATER_STATE, maxY, flags); + } else { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + } + } } + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + } + case "water_tower" -> this.placeCompactWaterTower(level, cursor, worldX, surfaceY, worldZ, maxY, flags); + default -> { + } + } + } - Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); - if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { - continue; + private void placePowerPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + if ("pole".equals(type)) { + int height = Math.max(6, Math.min(15, intFromTag(point.tags().get("height"), 10))); + BlockState pole = switch (point.tags().getOrDefault("material", "wood").trim().toLowerCase(Locale.ROOT)) { + case "concrete" -> Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + case "steel", "metal" -> Blocks.IRON_BARS.defaultBlockState(); + default -> Blocks.OAK_LOG.defaultBlockState(); + }; + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, pole, height, maxY, flags); + for (int dx = -2; dx <= 2; dx++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX - 2, surfaceY + height + 1, worldZ, Blocks.END_ROD.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + height + 1, worldZ, Blocks.END_ROD.defaultBlockState(), maxY, flags); + } else if ("tower".equals(type)) { + int height = Math.max(14, Math.min(28, intFromTag(point.tags().get("height"), 20))); + for (int y = 1; y <= height; y++) { + int radius = y < height / 2 ? 2 : 1; + this.placeCityBlock(level, cursor, worldX - radius, surfaceY + y, worldZ - radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + radius, surfaceY + y, worldZ - radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - radius, surfaceY + y, worldZ + radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + radius, surfaceY + y, worldZ + radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + if (y % 5 == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + y, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); } + } + int armY = surfaceY + height - 3; + for (int d = -4; d <= 4; d++) { + this.placeCityBlock(level, cursor, worldX + d, armY, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, armY, worldZ + d, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 1, worldZ, Blocks.LIGHTNING_ROD.defaultBlockState(), maxY, flags); + } + } - placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); + private void placeBarrierPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "bollard" -> this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + case "block" -> this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE.defaultBlockState(), maxY, flags); + case "entrance" -> { + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + } + case "gate", "swing_gate", "lift_gate", "stile" -> { + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_FENCE_GATE.defaultBlockState(), maxY, flags); + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + } + default -> { } } + } - return placements.isEmpty() ? List.of() : List.copyOf(placements); + private void placeRailwayPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "level_crossing", "crossing" -> { + this.paintCrossingPoint(level, cursor, worldX, worldZ); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), 2, maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 3, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 3, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ - 1, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ + 1, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.REDSTONE_LAMP.defaultBlockState(), maxY, flags); + } + case "tram_stop" -> { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.YELLOW_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, CITY_BENCH_STATE, maxY, flags); + } + default -> { + } + } } - private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { - for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { - this.applyPreparedTreePlacement(level, placement); + private void placeCompactWaterTower( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int maxY, int flags + ) { + int legHeight = 12; + int[][] legs = {{-2, -2}, {2, -2}, {-2, 2}, {2, 2}}; + for (int[] leg : legs) { + this.placeCityStack(level, cursor, worldX + leg[0], surfaceY + 1, worldZ + leg[1], Blocks.IRON_BARS.defaultBlockState(), legHeight, maxY, flags); + } + for (int dx = -3; dx <= 3; dx++) { + for (int dz = -3; dz <= 3; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + legHeight + 1, worldZ + dz, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + legHeight + 2, worldZ + dz, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + } } } - private void applyPreparedTreePlacement(WorldGenLevel level, EarthChunkGenerator.PreparedTreePlacement placement) { - int worldX = placement.worldX(); - int worldZ = placement.worldZ(); - int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; - if (topY < level.getMinBuildHeight() - || topY < this.seaLevel - || placement.expectedSurface() - topY > TREE_MAX_SURFACE_DROP - || topY - placement.expectedSurface() > TREE_MAX_SURFACE_RISE) { - return; + private void placeCityCross( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int height, int maxY, int flags + ) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + for (int y = 2; y <= height; y++) { + this.placeCityBlock(level, cursor, worldX, surfaceY + y, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); } + int armY = surfaceY + Math.max(3, height - 1); + this.placeCityBlock(level, cursor, worldX - 1, armY, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, armY, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + } - BlockPos ground = new BlockPos(worldX, topY, worldZ); - BlockState groundState = level.getBlockState(ground); - if (isRoadDeckState(groundState) - || isRoadDeckState(level.getBlockState(ground.below())) - || !isSolidCaveAnchor(groundState) - || groundState.is(BlockTags.LOGS) - || groundState.is(BlockTags.LEAVES)) { - return; + private void placeCityStack( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int startY, int worldZ, BlockState state, int height, int maxY, int flags + ) { + for (int offset = 0; offset < height; offset++) { + this.placeCityBlock(level, cursor, worldX, startY + offset, worldZ, state, maxY, flags); } + } - Holder biome = placement.biome(); - if (biome.is(Biomes.MANGROVE_SWAMP)) { - return; + private boolean placeCityBlock( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int y, int worldZ, BlockState state, int maxY, int flags + ) { + if (y > maxY) { + return false; + } + cursor.set(worldX, y, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return false; } + level.setBlock(cursor, state, flags); + return true; + } - List> features = treeFeaturesForBiome(biome); - if (features.isEmpty()) { - return; + private boolean placeCityBlockReplacingBarrier( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int y, int worldZ, BlockState state, int maxY, int flags + ) { + if (y > maxY) { + return false; + } + cursor.set(worldX, y, worldZ); + BlockState current = level.getBlockState(cursor); + if (!isRoadLightReplaceable(current) && !isCityBarrierBlock(current)) { + return false; } + level.setBlock(cursor, state, flags); + return true; + } - BlockPos position = ground.above(); - RandomSource random = RandomSource.create(placement.seed()); - if (!groundState.is(BlockTags.DIRT)) { - level.setBlock(ground, GRASS_BLOCK_STATE, 260); + private static boolean isCityBarrierBlock(BlockState state) { + return state.is(Blocks.COBBLESTONE_WALL) + || state.is(Blocks.STONE_BRICK_WALL) + || state.is(Blocks.OAK_FENCE) + || state.is(Blocks.IRON_BARS) + || state.is(Blocks.OAK_LEAVES); + } + + private static boolean canReplaceCitySurface(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && (state.is(BlockTags.DIRT) + || state.is(Blocks.STONE) + || state.is(Blocks.ANDESITE) + || state.is(Blocks.GRAVEL) + || state.is(Blocks.SAND) + || state.is(Blocks.RED_SAND) + || state.is(Blocks.MOSS_BLOCK) + || state.is(Blocks.MUD) + || state.is(Blocks.SNOW_BLOCK) + || state.is(Blocks.LIGHT_GRAY_CONCRETE) + || state.is(Blocks.GRAY_CONCRETE)); + } + + private static boolean canAnchorCityLine(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES); + } + + private static boolean isBuildingDoorTarget(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && !canReplaceCitySurface(state) + && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES); + } + + private static boolean cityFeatureVisible(Map tags) { + if (intFromTag(tags.get("layer"), 0) < 0 || intFromTag(tags.get("level"), 0) < 0) { + return false; + } + String location = tags.getOrDefault("location", "").trim().toLowerCase(Locale.ROOT); + return !"underground".equals(location) && !"underwater".equals(location) && !truthyTag(tags.get("tunnel")); + } + + private static BlockState cityFlagState(String sourceId) { + return switch (Math.floorMod(sourceId.hashCode(), 6)) { + case 0 -> Blocks.RED_WOOL.defaultBlockState(); + case 1 -> Blocks.YELLOW_WOOL.defaultBlockState(); + case 2 -> Blocks.BLUE_WOOL.defaultBlockState(); + case 3 -> Blocks.GREEN_WOOL.defaultBlockState(); + case 4 -> Blocks.ORANGE_WOOL.defaultBlockState(); + default -> Blocks.WHITE_WOOL.defaultBlockState(); + }; + } + + private static BlockState cityAreaSurfaceState(ExternalAreaFeature area, int worldX, int worldZ) { + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + if (area.kind() == ExternalAreaKind.PARKING) { + if (isParkingPaintStripe(worldX, worldZ)) { + return CITY_PARKING_MARK_STATE; + } + return Math.floorMod(worldX + worldZ, 11) == 0 ? CITY_PARKING_DRIVE_STATE : CITY_PARKING_STATE; + } + return switch (area.kind()) { + case LANDUSE -> landuseSurfaceState(type, worldX, worldZ); + case LEISURE -> leisureSurfaceState(type); + case NATURAL -> naturalSurfaceState(type); + case WATER -> WATER_STATE; + case AMENITY -> CITY_PARKING_STATE; + case PARKING -> CITY_PARKING_STATE; + }; + } + + private static boolean isParkingPaintStripe(int worldX, int worldZ) { + int x = Math.floorMod(worldX, 6); + int z = Math.floorMod(worldZ, 10); + return x == 0 && z >= 1 && z <= 8 || z == 0 && x >= 1 && x <= 4; + } + + private static BlockState landuseSurfaceState(String type, int worldX, int worldZ) { + return switch (type) { + case "construction", "brownfield", "landfill" -> Math.floorMod(worldX + worldZ, 5) == 0 ? GRAVEL_STATE : COARSE_DIRT_STATE; + case "industrial" -> Math.floorMod(worldX + worldZ, 4) == 0 ? Blocks.STONE_BRICKS.defaultBlockState() : STONE_STATE; + case "military" -> Math.floorMod(worldX + worldZ, 7) == 0 ? Blocks.STONE_BRICKS.defaultBlockState() : Blocks.GRAY_CONCRETE.defaultBlockState(); + case "quarry" -> Math.floorMod(worldX + worldZ, 6) == 0 ? GRAVEL_STATE : STONE_STATE; + case "railway" -> GRAVEL_STATE; + case "traffic_island" -> Blocks.STONE_SLAB.defaultBlockState(); + case "education", "religious" -> Blocks.POLISHED_ANDESITE.defaultBlockState(); + case "cemetery" -> MOSS_BLOCK_STATE; + case "farmland" -> Blocks.FARMLAND.defaultBlockState(); + case "forest", "orchard", "greenfield" -> GRASS_BLOCK_STATE; + case "vineyard" -> COARSE_DIRT_STATE; + case "commercial", "retail", "residential" -> GRASS_BLOCK_STATE; + case "meadow", "grass", "recreation_ground" -> GRASS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState leisureSurfaceState(String type) { + return switch (type) { + case "track" -> CITY_TRACK_STATE; + case "pitch", "sports_centre", "schoolyard" -> Blocks.GREEN_CONCRETE.defaultBlockState(); + case "playground", "recreation_ground", "dog_park", "beach_resort" -> CITY_PLAYGROUND_STATE; + case "swimming_pool", "swimming_area" -> WATER_STATE; + case "bathing_place" -> Blocks.SMOOTH_SANDSTONE.defaultBlockState(); + case "outdoor_seating", "water_park", "slipway" -> Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + case "ice_rink" -> Blocks.PACKED_ICE.defaultBlockState(); + case "garden", "park", "nature_reserve", "golf_course", "disc_golf_course" -> GRASS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState naturalSurfaceState(String type) { + return switch (type) { + case "beach", "sand", "dune", "shoal" -> SAND_STATE; + case "wetland", "mud" -> MUD_STATE; + case "bare_rock", "cliff", "ridge", "saddle", "mountain_range" -> STONE_STATE; + case "scree", "blockfield" -> GRAVEL_STATE; + case "glacier" -> Blocks.PACKED_ICE.defaultBlockState(); + case "reef" -> WATER_STATE; + case "wood", "tree_row" -> PODZOL_STATE; + case "scrub", "heath", "shrubbery", "tundra" -> MOSS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState cityBarrierState(ExternalLineFeature line) { + String type = line.typeTag().trim().toLowerCase(Locale.ROOT); + return switch (type) { + case "wall", "city_wall", "retaining_wall" -> CITY_BARRIER_WALL_STATE; + case "hedge" -> CITY_BARRIER_HEDGE_STATE; + case "guard_rail", "chain", "bollard" -> CITY_BARRIER_RAIL_STATE; + default -> CITY_BARRIER_FENCE_STATE; + }; + } + + private static int cityBarrierHeight(ExternalLineFeature line) { + String type = line.typeTag().trim().toLowerCase(Locale.ROOT); + if ("hedge".equals(type) || "wall".equals(type) || "city_wall".equals(type) || "retaining_wall".equals(type)) { + return Math.max(1, Math.min(3, intFromTag(line.tags().get("height"), 2))); + } + return 1; + } + + private static boolean truthyTag(String value) { + if (value == null) { + return false; } + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "yes", "true", "1" -> true; + default -> false; + }; + } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); + private static int intFromTag(String value, int defaultValue) { + if (value == null || value.isBlank()) { + return defaultValue; + } + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + if (!seenDigit) { + return defaultValue; + } + try { + return (int)Math.round(Double.parseDouble(number.toString())); + } catch (NumberFormatException error) { + return defaultValue; + } } private boolean isNearWater(int worldX, int worldZ, int radius) { @@ -5013,7 +6972,7 @@ private EarthChunkGenerator.PreparedTerrainRefinement buildPreparedTerrainRefine this.repairAnomalousChunkTerrain(terrainSurfaces, waterSurfaces, waterFlags, coverClasses, heightGrid, gridSize, step, chunkMinY, shell.maxY()); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = FAST_FULL_CHUNK && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(FAST_FULL_CHUNK, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); @@ -5281,6 +7240,10 @@ private void fillChunkTerrainMetricsAndBiomes( } } + private static boolean shouldUseChunkClimateCache(boolean fastFullChunk, EarthBiomeSource earthBiomeSource, double worldScale) { + return fastFullChunk && earthBiomeSource != null && worldScale > 1.5; + } + private void applyPreparedTerrainRefinement(ServerLevel level, ChunkAccess chunk, EarthChunkGenerator.PreparedTerrainRefinement refinement) { long chunkKey = ChunkPos.asLong(chunk.getPos().x, chunk.getPos().z); if (!Objects.equals(this.terrainGenerationStamps.get(chunkKey), refinement.generationStamp())) { @@ -5415,6 +7378,8 @@ private void applyPreparedChunkDetail(WorldGenLevel level, ChunkAccess chunk, Ea this.placePreparedRoadLights(level, chunk); } + this.applyExternalCityDetails(level, chunk); + List treePlacements = detail.treePlacements(); if (!treePlacements.isEmpty()) { long treeApplyStartNs = beginFullChunkProfiling(); @@ -5491,10 +7456,20 @@ public EarthChunkGenerator.OsmRoadQueryResult fetchOsmRoadsForAreaDetailed( double worldScale = this.settings.worldScale(); if (!(worldScale <= 0.0) && !(worldScale > OSM_ROAD_MAX_SCALE)) { OsmQueryMode queryMode = mode == null ? OsmQueryMode.BLOCKING : mode; - TellusOsmRoadSource.RoadQueryResult result = OSM_ROAD_SOURCE.roadsForAreaWithStatus( - minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + List externalFeatures = EXTERNAL_FEATURE_SOURCE.roadsForArea( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks) ); - return new EarthChunkGenerator.OsmRoadQueryResult(result.features(), result.hadCacheMiss()); + boolean skipOsm = EXTERNAL_FEATURE_SOURCE.preferExternalRoads() && !externalFeatures.isEmpty(); + TellusOsmRoadSource.RoadQueryResult result = skipOsm + ? new TellusOsmRoadSource.RoadQueryResult(List.of(), false) + : OSM_ROAD_SOURCE.roadsForAreaWithStatus(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode); + if (externalFeatures.isEmpty()) { + return new EarthChunkGenerator.OsmRoadQueryResult(result.features(), result.hadCacheMiss()); + } + List features = new ArrayList<>(result.features().size() + externalFeatures.size()); + features.addAll(result.features()); + features.addAll(externalFeatures); + return new EarthChunkGenerator.OsmRoadQueryResult(List.copyOf(features), result.hadCacheMiss()); } else { return new EarthChunkGenerator.OsmRoadQueryResult(List.of(), false); } @@ -5518,12 +7493,22 @@ public EarthChunkGenerator.OsmBuildingQueryResult fetchOsmBuildingsForAreaDetail return new EarthChunkGenerator.OsmBuildingQueryResult(List.of(), false); } else { double worldScale = this.settings.worldScale(); - if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && OSM_BUILDING_SOURCE.available()) { + if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE)) { OsmQueryMode queryMode = mode == null ? OsmQueryMode.BLOCKING : mode; - TellusOsmBuildingSource.BuildingQueryResult result = OSM_BUILDING_SOURCE.buildingsForAreaWithStatus( - minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + List features = new ArrayList<>(); + boolean hadCacheMiss = false; + List externalFeatures = EXTERNAL_FEATURE_SOURCE.buildingsForArea( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks) ); - return new EarthChunkGenerator.OsmBuildingQueryResult(result.features(), result.hadCacheMiss()); + if ((!EXTERNAL_FEATURE_SOURCE.preferExternalBuildings() || externalFeatures.isEmpty()) && OSM_BUILDING_SOURCE.available()) { + TellusOsmBuildingSource.BuildingQueryResult result = OSM_BUILDING_SOURCE.buildingsForAreaWithStatus( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + ); + features.addAll(result.features()); + hadCacheMiss = result.hadCacheMiss(); + } + features.addAll(externalFeatures); + return new EarthChunkGenerator.OsmBuildingQueryResult(List.copyOf(features), hadCacheMiss); } else { return new EarthChunkGenerator.OsmBuildingQueryResult(List.of(), false); } @@ -5557,7 +7542,7 @@ private EarthChunkGenerator.PreparedChunkBuildings buildChunkBuildings( return null; } else { double worldScale = this.settings.worldScale(); - if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && OSM_BUILDING_SOURCE.available()) { + if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && this.buildingSourcesAvailable()) { int chunkMinX = pos.getMinBlockX(); int chunkMinZ = pos.getMinBlockZ(); int chunkMaxX = chunkMinX + CHUNK_MASK; @@ -5963,7 +7948,7 @@ private void applyPreparedBuildingTerrainToChunk(WorldGenLevel level, ChunkAcces private List fetchEntranceRoads( int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, OsmQueryMode queryMode ) { - if (!(worldScale > 0.0) || worldScale > OSM_ROAD_MAX_SCALE || !OSM_ROAD_SOURCE.available()) { + if (!(worldScale > 0.0) || worldScale > OSM_ROAD_MAX_SCALE || !this.roadSourcesAvailable()) { return List.of(); } @@ -6144,9 +8129,7 @@ private void placePreparedBuildingColumn( } else if (stairState != null) { state = stairState; } else if (facadeCell) { - state = this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y) - ? palette.window() - : palette.wall(); + state = this.facadeStateFor(blueprint, palette, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y); } else if (partitionCell) { state = palette.partition(); } else if (y == floorTop && TellusBuildingLighting.shouldPlaceInteriorLight(blueprint, boundaryDistance, worldX, worldZ, floorIndex)) { @@ -6202,8 +8185,8 @@ private void placeExteriorOnlyBuildingColumn( int floorBottom = blueprint.floorBottomY(floorIndex); int floorTop = Math.min(columnTopY, blueprint.floorTopY(floorIndex)); boolean facadeCell = blueprint.isFacadeCell(boundaryDistance, floorIndex); - if (facadeCell && this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y)) { - state = palette.window(); + if (facadeCell) { + state = this.facadeStateFor(blueprint, palette, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y); } else { state = palette.wall(); } @@ -6348,6 +8331,80 @@ private boolean isPartitionCell(BuildingBlueprint blueprint, EarthChunkGenerator }; } + private BlockState facadeStateFor( + BuildingBlueprint blueprint, + EarthChunkGenerator.BuildingPalette palette, + int boundaryDistance, + int worldX, + int worldZ, + int floorIndex, + int floorBottom, + int floorTop, + int y + ) { + if (this.isStorefrontFacade(blueprint, floorIndex)) { + int edgeCoord = this.facadeEdgeCoord(blueprint, boundaryDistance, worldX, worldZ); + if (edgeCoord >= 0) { + if (floorTop - floorBottom >= 3 && y == floorBottom + 3) { + return Math.floorMod(edgeCoord, 5) == 0 ? palette.trim() : this.storefrontAccentState(blueprint, worldX, worldZ); + } + if (y >= floorBottom + 1 && y <= Math.min(floorTop, floorBottom + 2)) { + return Math.floorMod(edgeCoord, 4) == 0 ? palette.trim() : palette.window(); + } + } + } + + return this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y) + ? palette.window() + : palette.wall(); + } + + private boolean isStorefrontFacade(BuildingBlueprint blueprint, int floorIndex) { + if (floorIndex != 0) { + return false; + } + BuildingProfile profile = blueprint.profile(); + if (profile.archetype() == BuildingProfile.Archetype.COMMERCIAL) { + return true; + } + String type = profile.primaryType(); + return type.contains("shop") + || type.contains("retail") + || type.contains("commercial") + || type.contains("mall") + || type.contains("market") + || type.contains("supermarket") + || type.contains("restaurant") + || type.contains("hotel") + || type.contains("office"); + } + + private int facadeEdgeCoord(BuildingBlueprint blueprint, int boundaryDistance, int worldX, int worldZ) { + int setback = blueprint.setbackForFloor(0); + int minWorldX = blueprint.minWorldX() + setback; + int maxWorldX = blueprint.maxWorldX() - setback; + int minWorldZ = blueprint.minWorldZ() + setback; + int maxWorldZ = blueprint.maxWorldZ() - setback; + if ((worldX == minWorldX || worldX == maxWorldX) && worldZ >= minWorldZ && worldZ <= maxWorldZ) { + return worldZ - minWorldZ; + } + if ((worldZ == minWorldZ || worldZ == maxWorldZ) && worldX >= minWorldX && worldX <= maxWorldX) { + return worldX - minWorldX; + } + return boundaryDistance == 0 ? 0 : -1; + } + + private BlockState storefrontAccentState(BuildingBlueprint blueprint, int worldX, int worldZ) { + int roll = Math.floorMod((int)(blueprint.blueprintSeed() ^ worldX * 73428767L ^ worldZ * 912931L), 5); + return switch (roll) { + case 0 -> Blocks.RED_WOOL.defaultBlockState(); + case 1 -> Blocks.BLUE_WOOL.defaultBlockState(); + case 2 -> Blocks.GREEN_WOOL.defaultBlockState(); + case 3 -> Blocks.YELLOW_WOOL.defaultBlockState(); + default -> Blocks.WHITE_WOOL.defaultBlockState(); + }; + } + private boolean shouldPlaceWindow( BuildingBlueprint blueprint, int boundaryDistance, @@ -6484,7 +8541,7 @@ private EarthChunkGenerator.PreparedChunkRoadLights prepareRoadLightsForChunk( continue; } - int roadWidth = roadWidthForClass(road.roadClass(), widths); + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); if (roadWidth <= 0 || road.pointCount() < 2) { continue; } @@ -8208,6 +10265,25 @@ private void stripIglooStarts(RegistryAccess registryAccess, ChunkAccess chunk) } } + private static ArnisTreeType wildTreeTypeForBiome(Holder biome, long seed) { + if (biome.is(Biomes.BIRCH_FOREST) || biome.is(Biomes.OLD_GROWTH_BIRCH_FOREST)) { + return ArnisTreeType.BIRCH; + } + if (biome.is(Biomes.TAIGA) || biome.is(Biomes.SNOWY_TAIGA) || biome.is(Biomes.OLD_GROWTH_PINE_TAIGA) || biome.is(Biomes.OLD_GROWTH_SPRUCE_TAIGA) || biome.is(Biomes.GROVE)) { + return ArnisTreeType.SPRUCE; + } + if (biome.is(Biomes.DARK_FOREST)) { + return ArnisTreeType.DARK_OAK; + } + if (biome.is(Biomes.JUNGLE) || biome.is(Biomes.SPARSE_JUNGLE) || biome.is(Biomes.BAMBOO_JUNGLE)) { + return ArnisTreeType.JUNGLE; + } + if (biome.is(Biomes.SAVANNA) || biome.is(Biomes.SAVANNA_PLATEAU) || biome.is(Biomes.WINDSWEPT_SAVANNA) || biome.is(Biomes.WOODED_BADLANDS)) { + return ArnisTreeType.ACACIA; + } + return ArnisTreeType.chooseDefault(seed); + } + private static List> treeFeaturesForBiome(Holder biome) { return TREE_FEATURES.computeIfAbsent(biome, holder -> { List> result = new ArrayList<>(); @@ -8875,12 +10951,16 @@ private static final class OsmOverlayScratch { private final Long2ObjectOpenHashMap edgeColumnCache = new Long2ObjectOpenHashMap<>(); private byte[] resolvedClass = new byte[CHUNK_AREA]; private byte[] resolvedMode = new byte[CHUNK_AREA]; + private byte[] resolvedSurface = new byte[CHUNK_AREA]; private int[] resolvedDeckY = new int[CHUNK_AREA]; + private int[] resolvedWidth = new int[CHUNK_AREA]; private boolean[] resolvedTunnelCarve = new boolean[CHUNK_AREA]; private boolean[] blockedByHigherClass = new boolean[CHUNK_AREA]; private boolean[] bridgeOverlayPresent = new boolean[CHUNK_AREA]; private int[] bridgeOverlayDeckY = new int[CHUNK_AREA]; private byte[] bridgeOverlayClass = new byte[CHUNK_AREA]; + private byte[] bridgeOverlaySurface = new byte[CHUNK_AREA]; + private int[] bridgeOverlayWidth = new int[CHUNK_AREA]; private boolean[] bridgeSupportShaftPresent = new boolean[CHUNK_AREA]; private int[] bridgeSupportShaftBottomY = new int[CHUNK_AREA]; private int[] bridgeSupportShaftTopY = new int[CHUNK_AREA]; @@ -8889,14 +10969,19 @@ private static final class OsmOverlayScratch { private int[] bridgeSupportCapTopY = new int[CHUNK_AREA]; private boolean[] candidatePresent = new boolean[CHUNK_AREA]; private int[] candidateDeckY = new int[CHUNK_AREA]; + private int[] candidateWidth = new int[CHUNK_AREA]; private byte[] candidateMode = new byte[CHUNK_AREA]; + private byte[] candidateSurface = new byte[CHUNK_AREA]; private boolean[] candidateTunnelCarve = new boolean[CHUNK_AREA]; private boolean[] bridgeCandidatePresent = new boolean[CHUNK_AREA]; private int[] bridgeCandidateDeckY = new int[CHUNK_AREA]; + private int[] bridgeCandidateWidth = new int[CHUNK_AREA]; + private byte[] bridgeCandidateSurface = new byte[CHUNK_AREA]; private int[] placed = new int[CHUNK_AREA]; private final byte[] chunkRoadClass = new byte[CHUNK_AREA]; private final byte[] chunkRoadMode = new byte[CHUNK_AREA]; private final int[] chunkRoadDeckY = new int[CHUNK_AREA]; + private final int[] chunkRoadWidth = new int[CHUNK_AREA]; private final boolean[] chunkTunnelNeedsCarve = new boolean[CHUNK_AREA]; private final boolean[] tunnelCarveMask = new boolean[CHUNK_AREA]; private final int[] tunnelCarveDeckY = new int[CHUNK_AREA]; @@ -8906,12 +10991,16 @@ private void ensureRoadExtCapacity(int extArea) { if (this.resolvedClass.length < extArea) { this.resolvedClass = new byte[extArea]; this.resolvedMode = new byte[extArea]; + this.resolvedSurface = new byte[extArea]; this.resolvedDeckY = new int[extArea]; + this.resolvedWidth = new int[extArea]; this.resolvedTunnelCarve = new boolean[extArea]; this.blockedByHigherClass = new boolean[extArea]; this.bridgeOverlayPresent = new boolean[extArea]; this.bridgeOverlayDeckY = new int[extArea]; this.bridgeOverlayClass = new byte[extArea]; + this.bridgeOverlaySurface = new byte[extArea]; + this.bridgeOverlayWidth = new int[extArea]; this.bridgeSupportShaftPresent = new boolean[extArea]; this.bridgeSupportShaftBottomY = new int[extArea]; this.bridgeSupportShaftTopY = new int[extArea]; @@ -8920,10 +11009,14 @@ private void ensureRoadExtCapacity(int extArea) { this.bridgeSupportCapTopY = new int[extArea]; this.candidatePresent = new boolean[extArea]; this.candidateDeckY = new int[extArea]; + this.candidateWidth = new int[extArea]; this.candidateMode = new byte[extArea]; + this.candidateSurface = new byte[extArea]; this.candidateTunnelCarve = new boolean[extArea]; this.bridgeCandidatePresent = new boolean[extArea]; this.bridgeCandidateDeckY = new int[extArea]; + this.bridgeCandidateWidth = new int[extArea]; + this.bridgeCandidateSurface = new byte[extArea]; this.placed = new int[extArea]; } } @@ -8931,12 +11024,16 @@ private void ensureRoadExtCapacity(int extArea) { private void clearRoadExtState(int extArea) { Arrays.fill(this.resolvedClass, 0, extArea, (byte)0); Arrays.fill(this.resolvedMode, 0, extArea, (byte)0); + Arrays.fill(this.resolvedSurface, 0, extArea, (byte)0); Arrays.fill(this.resolvedDeckY, 0, extArea, 0); + Arrays.fill(this.resolvedWidth, 0, extArea, 0); Arrays.fill(this.resolvedTunnelCarve, 0, extArea, false); Arrays.fill(this.blockedByHigherClass, 0, extArea, false); Arrays.fill(this.bridgeOverlayPresent, 0, extArea, false); Arrays.fill(this.bridgeOverlayDeckY, 0, extArea, 0); Arrays.fill(this.bridgeOverlayClass, 0, extArea, (byte)0); + Arrays.fill(this.bridgeOverlaySurface, 0, extArea, (byte)0); + Arrays.fill(this.bridgeOverlayWidth, 0, extArea, 0); Arrays.fill(this.bridgeSupportShaftPresent, 0, extArea, false); Arrays.fill(this.bridgeSupportShaftBottomY, 0, extArea, 0); Arrays.fill(this.bridgeSupportShaftTopY, 0, extArea, 0); @@ -8948,10 +11045,14 @@ private void clearRoadExtState(int extArea) { private void clearRoadCandidateState(int extArea) { Arrays.fill(this.candidatePresent, 0, extArea, false); Arrays.fill(this.candidateDeckY, 0, extArea, 0); + Arrays.fill(this.candidateWidth, 0, extArea, 0); Arrays.fill(this.candidateMode, 0, extArea, (byte)0); + Arrays.fill(this.candidateSurface, 0, extArea, (byte)0); Arrays.fill(this.candidateTunnelCarve, 0, extArea, false); Arrays.fill(this.bridgeCandidatePresent, 0, extArea, false); Arrays.fill(this.bridgeCandidateDeckY, 0, extArea, 0); + Arrays.fill(this.bridgeCandidateWidth, 0, extArea, 0); + Arrays.fill(this.bridgeCandidateSurface, 0, extArea, (byte)0); } } @@ -10188,6 +12289,7 @@ private static enum FullChunkPhase { DECORATION_AXOLOTLS("axolotls"), DECORATION_TREES("trees"), DECORATION_BUILDINGS("buildings"), + DECORATION_CITY_DETAILS("cityDetails"), DECORATION_REALTIME_SNOW("realtimeSnow"), DECORATION_ROAD_LIGHTS("roadLights"), DECORATION_DEFERRED_APPLY("deferredApply"), @@ -10396,7 +12498,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -10503,21 +12605,19 @@ private static void maybeLog() { long now = System.nanoTime(); long next = NEXT_LOG_AT_NS.get(); if (now >= next && NEXT_LOG_AT_NS.compareAndSet(next, now + LOG_INTERVAL_NS)) { - Tellus.LOGGER.info( - "Terrain streaming perf 15s: shell(chunks={},heightMisses={},coverMisses={},visualMisses={},waterFallbacks={},heightFallbacks={},chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections={}) refinement(queued={},applied={},staleDrops={},detailDelays={})", - new Object[]{ - SHELL_CHUNKS.sumThenReset(), - SHELL_HEIGHT_MISSES.sumThenReset(), - SHELL_COVER_MISSES.sumThenReset(), - SHELL_VISUAL_MISSES.sumThenReset(), - SHELL_WATER_FALLBACKS.sumThenReset(), - SHELL_HEIGHT_FALLBACKS.sumThenReset(), - PREFETCH_QUEUE_REJECTIONS.sumThenReset(), - REFINEMENT_QUEUED.sumThenReset(), - REFINEMENT_APPLIES.sumThenReset(), - REFINEMENT_STALE_DROPS.sumThenReset(), - DETAIL_DELAYS.sumThenReset() - } + TellusDiagnostics.worldgen( + "Terrain streaming perf 15s: shell(chunks=%d,heightMisses=%d,coverMisses=%d,visualMisses=%d,waterFallbacks=%d,heightFallbacks=%d,chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections=%d) refinement(queued=%d,applied=%d,staleDrops=%d,detailDelays=%d)", + SHELL_CHUNKS.sumThenReset(), + SHELL_HEIGHT_MISSES.sumThenReset(), + SHELL_COVER_MISSES.sumThenReset(), + SHELL_VISUAL_MISSES.sumThenReset(), + SHELL_WATER_FALLBACKS.sumThenReset(), + SHELL_HEIGHT_FALLBACKS.sumThenReset(), + PREFETCH_QUEUE_REJECTIONS.sumThenReset(), + REFINEMENT_QUEUED.sumThenReset(), + REFINEMENT_APPLIES.sumThenReset(), + REFINEMENT_STALE_DROPS.sumThenReset(), + DETAIL_DELAYS.sumThenReset() ); } } @@ -10616,20 +12716,18 @@ private static void maybeLog() { } private static void logAndReset() { - Tellus.LOGGER.info( - "Chunk detail perf 15s: baseTerrain(total={}ms,calls={}) detailJob(total={}ms,calls={}) detailApply(total={}ms,calls={}) skippedFallbacks={} staleDrops={} failures={} maxQueueDepth={}", - new Object[]{ - toMillis(BASE_TERRAIN_NS.sumThenReset()), - BASE_TERRAIN_CALLS.sumThenReset(), - toMillis(DETAIL_JOB_NS.sumThenReset()), - DETAIL_JOB_CALLS.sumThenReset(), - toMillis(DETAIL_APPLY_NS.sumThenReset()), - DETAIL_APPLY_CALLS.sumThenReset(), - SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), - STALE_DROPS.sumThenReset(), - FAILURES.sumThenReset(), - MAX_QUEUE_DEPTH.getAndSet(0L) - } + TellusDiagnostics.worldgen( + "Chunk detail perf 15s: baseTerrain(total=%sms,calls=%d) detailJob(total=%sms,calls=%d) detailApply(total=%sms,calls=%d) skippedFallbacks=%d staleDrops=%d failures=%d maxQueueDepth=%d", + toMillis(BASE_TERRAIN_NS.sumThenReset()), + BASE_TERRAIN_CALLS.sumThenReset(), + toMillis(DETAIL_JOB_NS.sumThenReset()), + DETAIL_JOB_CALLS.sumThenReset(), + toMillis(DETAIL_APPLY_NS.sumThenReset()), + DETAIL_APPLY_CALLS.sumThenReset(), + SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), + STALE_DROPS.sumThenReset(), + FAILURES.sumThenReset(), + MAX_QUEUE_DEPTH.getAndSet(0L) ); } diff --git a/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java b/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java index 06ae318bc..53eefdb93 100644 --- a/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java +++ b/mc1201/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java @@ -88,14 +88,14 @@ public record EarthGeneratorSettings( private static final int FIXED_DH_OSM_BUILDING_MAX_DETAIL = 6; private static final boolean FIXED_DH_OSM_NON_BLOCKING_FETCH = true; public static final EarthGeneratorSettings DEFAULT = new EarthGeneratorSettings( - 30.0, + 1.0, 1.0, 1.0, 64, - -2147483647, + 62, 27.9881, 86.925, - -64, + Integer.MIN_VALUE, Integer.MIN_VALUE, 5, 5, @@ -103,25 +103,25 @@ public record EarthGeneratorSettings( false, false, false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, false, FIXED_DH_OSM_FEATURES, FIXED_DH_OSM_ROAD_MAX_DETAIL, @@ -135,9 +135,9 @@ public record EarthGeneratorSettings( 4, EarthGeneratorSettings.DistantHorizonsRenderMode.FAST, EarthGeneratorSettings.DemSelection.automaticSelection(), - false, - false, - false + true, + true, + true ); private static final MapCodec BASE_TOGGLES_CODEC = RecordCodecBuilder.mapCodec( instance -> instance.group( diff --git a/mc1211/src/client/java/com/yucareux/tellus/TellusClient.java b/mc1211/src/client/java/com/yucareux/tellus/TellusClient.java index c09d36823..41c0a3427 100644 --- a/mc1211/src/client/java/com/yucareux/tellus/TellusClient.java +++ b/mc1211/src/client/java/com/yucareux/tellus/TellusClient.java @@ -5,23 +5,34 @@ import com.yucareux.tellus.network.TellusWeatherPayload; import com.yucareux.tellus.world.realtime.SnowGrid; import com.yucareux.tellus.world.realtime.TellusRealtimeState; +import com.mojang.blaze3d.platform.InputConstants; import java.util.Objects; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; +import org.lwjgl.glfw.GLFW; @Environment(EnvType.CLIENT) public class TellusClient implements ClientModInitializer { + private static KeyMapping openEarthMapKey; + @Override public void onInitializeClient() { + openEarthMapKey = KeyBindingHelper.registerKeyBinding( + new KeyMapping("key.tellus.open_earth_map", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_M, "category.tellus.keybinds") + ); + ClientTickEvents.END_CLIENT_TICK.register(TellusClient::handleClientTick); ClientPlayNetworking.registerGlobalReceiver(Objects.requireNonNull(GeoTpOpenMapPayload.TYPE, "GeoTpOpenMapPayload.TYPE"), (payload, context) -> context.client().execute(() -> { Minecraft minecraft = context.client(); Screen parent = minecraft.screen; - minecraft.setScreen(new EarthTeleportScreen(parent, payload.latitude(), payload.longitude())); + minecraft.setScreen(new EarthTeleportScreen(parent, payload.latitude(), payload.longitude(), payload.spawnLatitude(), payload.spawnLongitude())); })); ClientPlayNetworking.registerGlobalReceiver( Objects.requireNonNull(TellusWeatherPayload.TYPE, "TellusWeatherPayload.TYPE"), @@ -37,4 +48,12 @@ public void onInitializeClient() { ); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TellusRealtimeState.clearRealtimeWeather()); } + + private static void handleClientTick(Minecraft client) { + while (openEarthMapKey != null && openEarthMapKey.consumeClick()) { + if (client.screen == null && client.player != null && client.player.connection != null) { + client.player.connection.sendCommand("tellus map"); + } + } + } } diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java b/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java index 5d4b10923..562a93bb4 100644 --- a/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java +++ b/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java @@ -9,7 +9,10 @@ import com.yucareux.tellus.client.preview.TerrainPreview; import com.yucareux.tellus.client.preview.TerrainPreviewWidget; import com.yucareux.tellus.client.widget.CustomizationList; +import com.yucareux.tellus.world.data.integration.GeoBounds; +import com.yucareux.tellus.world.data.integration.OverpassExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; +import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; import java.io.IOException; import java.net.HttpURLConnection; @@ -103,6 +106,8 @@ public class EarthCustomizeScreen extends Screen { ResourceKey.create(Registries.DIMENSION_TYPE, DYNAMIC_DIMENSION_TYPE_ID), "dynamicDimensionTypeKey" ); private static final double OSM_ROADS_AND_BUILDINGS_MAX_WORLD_SCALE = 15.0; + private static final int DEFAULT_OVERPASS_CACHE_RADIUS_CHUNKS = 96; + private static final int MIN_OVERPASS_CACHE_RADIUS_CHUNKS = 16; private final CreateWorldScreen parent; private final List categories; private CustomizationList list; @@ -181,6 +186,45 @@ public double getSpawnLongitude() { return this.spawnLongitude; } + private EarthCustomizeScreen.OverpassCacheArea currentOverpassCacheArea() { + EarthGeneratorSettings settings = this.buildSettings(); + double worldScale = Math.max(1.0E-4, settings.worldScale()); + int radiusChunks = this.currentOverpassCacheRadiusChunks(); + double radiusBlocks = Math.max(16.0, radiusChunks * 16.0); + double blocksPerDegree = Math.max(1.0E-4, EarthProjection.blocksPerDegree(worldScale)); + double centerX = settings.spawnLongitude() * blocksPerDegree; + double centerZ = EarthProjection.latToBlockZ(settings.spawnLatitude(), worldScale); + double west = clampLongitude((centerX - radiusBlocks) / blocksPerDegree); + double east = clampLongitude((centerX + radiusBlocks) / blocksPerDegree); + double latA = EarthProjection.blockZToLat(centerZ - radiusBlocks, worldScale); + double latB = EarthProjection.blockZToLat(centerZ + radiusBlocks, worldScale); + double south = Math.max(-85.05112878, Math.min(latA, latB)); + double north = Math.min(85.05112878, Math.max(latA, latB)); + if (west > east) { + west = -180.0; + east = 180.0; + } + return new EarthCustomizeScreen.OverpassCacheArea( + new GeoBounds(south, west, north, east), + radiusChunks, + settings.spawnLatitude(), + settings.spawnLongitude(), + worldScale + ); + } + + private int currentOverpassCacheRadiusChunks() { + boolean voxyPregen = this.findToggleValue("voxy_chunk_pregen_enabled", EarthGeneratorSettings.DEFAULT.voxyChunkPregenEnabled()); + int radius = voxyPregen + ? (int)Math.round(this.findSliderValue("voxy_chunk_pregen_max_radius", EarthGeneratorSettings.DEFAULT.voxyChunkPregenMaxRadius())) + : DEFAULT_OVERPASS_CACHE_RADIUS_CHUNKS; + return Mth.clamp(radius, MIN_OVERPASS_CACHE_RADIUS_CHUNKS, 4096); + } + + private static double clampLongitude(double longitude) { + return Math.max(-180.0, Math.min(180.0, longitude)); + } + private void openPreviewFullScreen() { if (this.minecraft != null && this.previewWidget != null) { TerrainPreviewWidget.ViewState viewState = Objects.requireNonNull(this.previewWidget.getViewState(), "viewState"); @@ -407,7 +451,7 @@ private EarthGeneratorSettings buildSettings() { double terrestrialScale = this.findSliderValue("terrestrial_height_scale", EarthGeneratorSettings.DEFAULT.terrestrialHeightScale()); double oceanicScale = this.findSliderValue("oceanic_height_scale", EarthGeneratorSettings.DEFAULT.oceanicHeightScale()); int heightOffset = (int)Math.round(this.findSliderValue("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset())); - int seaLevel = this.resolveSeaLevelSetting("sea_level", -64.0); + int seaLevel = this.resolveSeaLevelSetting("sea_level", 62.0); int maxAltitude = this.resolveAltitudeSetting("max_altitude", -1.0); int minAltitude = this.resolveAltitudeSetting("min_altitude", -2048.0); int riverLakeShorelineBlend = (int)Math.round( @@ -555,7 +599,7 @@ private void applySettingsToCategories(EarthGeneratorSettings settings, boolean this.setSliderValue("terrestrial_height_scale", initialSettings.terrestrialHeightScale()); this.setSliderValue("oceanic_height_scale", initialSettings.oceanicHeightScale()); this.setSliderValue("height_offset", initialSettings.heightOffset()); - this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? -64.0 : initialSettings.seaLevel()); + this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? 62.0 : initialSettings.seaLevel()); this.setSliderValue("max_altitude", initialSettings.maxAltitude() == Integer.MIN_VALUE ? -1.0 : initialSettings.maxAltitude()); this.setSliderValue("min_altitude", initialSettings.minAltitude() == Integer.MIN_VALUE ? -2048.0 : initialSettings.minAltitude()); this.setSliderValue("river_lake_shoreline_blend", initialSettings.riverLakeShorelineBlend()); @@ -596,6 +640,25 @@ private void applySettingsToCategories(EarthGeneratorSettings settings, boolean this.setRenderModeValue("distant_horizons_render_mode", initialSettings.distantHorizonsRenderMode()); } + private void applyWlbPreset() { + this.setSliderValue("world_scale", EarthGeneratorSettings.DEFAULT.worldScale()); + this.setDemSelectionValue(EarthGeneratorSettings.DEFAULT.demSelection()); + this.setSliderValue("terrestrial_height_scale", EarthGeneratorSettings.DEFAULT.terrestrialHeightScale()); + this.setSliderValue("oceanic_height_scale", EarthGeneratorSettings.DEFAULT.oceanicHeightScale()); + this.setSliderValue("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset()); + this.setSliderValue("sea_level", 62.0); + this.setSliderValue("max_altitude", -1.0); + this.setSliderValue("min_altitude", -2048.0); + this.setToggleValue("enable_roads", true); + this.setToggleValue("enable_buildings", true); + this.setToggleValue("enable_water", true); + + EarthCustomizeScreen.CategoryDefinition structure = this.findCategoryById("structure"); + if (structure != null) { + this.setCategoryToggleValues(structure, false); + } + } + private void setSliderValue(String key, double value) { for (EarthCustomizeScreen.CategoryDefinition category : this.categories) { for (EarthCustomizeScreen.SettingDefinition setting : category.getSettings()) { @@ -764,7 +827,7 @@ private List createCategories() { ).hideFromRoot().parent("world"); List worldSettings = new ArrayList<>( List.of( - slider("world_scale", 30.0, 1.0, 500.0, 5.0) + slider("world_scale", EarthGeneratorSettings.DEFAULT.worldScale(), 1.0, 500.0, 5.0) .withDisplay(EarthCustomizeScreen::formatWorldScale) .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), this.categoryLink(demProvidersCategory) @@ -787,7 +850,7 @@ private List createCategories() { .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), slider("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset(), -2000.0, 128.0, 1.0) .withDisplay(EarthCustomizeScreen::formatHeightOffset), - slider("sea_level", -64.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), + slider("sea_level", 62.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), slider("max_altitude", -1.0, -1.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMaxAltitude), slider("min_altitude", EarthGeneratorSettings.DEFAULT.minAltitude(), -2048.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMinAltitude), slider("river_lake_shoreline_blend", EarthGeneratorSettings.DEFAULT.riverLakeShorelineBlend(), 0.0, 10.0, 1.0) @@ -1020,8 +1083,15 @@ private static EarthCustomizeScreen.CacheActionDefinition cacheActionButton( Com return new EarthCustomizeScreen.CacheActionDefinition(label, action); } - private static List dataSourcesEntries() { + private List dataSourcesEntries() { List entries = new ArrayList<>(); + entries.add(infoHeader("Arnis / OSM Overpass")); + entries.add(infoLine("Road and building details are cached locally and reused.")); + entries.add(new EarthCustomizeScreen.OverpassProbeDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheStatusDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.ESTIMATE)); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.PREFETCH)); + entries.add(infoSpacer()); entries.add(infoHeader("ESA WorldCover 2021 (land cover)")); entries.add(infoLine("ESA WorldCover 2021 (10 m land cover, v200)")); entries.add(infoLine("© ESA WorldCover project / Contains modified Copernicus Sentinel data (2021)")); @@ -1522,15 +1592,19 @@ private EarthCustomizeScreen.CategoryDefinition findCategoryById( String id) { private AbstractWidget createWorldHeaderActions(EarthCustomizeScreen.CategoryDefinition category) { EarthGeneratorSettings defaultSettings = Objects.requireNonNull(EarthGeneratorSettings.DEFAULT, "defaultSettings"); Component restoreDefaultsLabel = Objects.requireNonNull(Component.translatable("gui.tellus.restore_defaults"), "restoreDefaultsLabel"); - Component selectPresetLabel = Objects.requireNonNull(Component.translatable("gui.tellus.select_preset"), "selectPresetLabel"); - Component comingSoonTooltip = Objects.requireNonNull( - Component.translatable("gui.tellus.coming_soon").withStyle(ChatFormatting.GRAY), "selectPresetTooltip" + Component wlbPresetLabel = Objects.requireNonNull(Component.translatable("gui.tellus.wlb_preset"), "wlbPresetLabel"); + Component wlbPresetTooltip = Objects.requireNonNull( + Component.translatable("gui.tellus.wlb_preset.tooltip").withStyle(ChatFormatting.GRAY), "wlbPresetTooltip" ); return new EarthCustomizeScreen.DualButtonWidget(restoreDefaultsLabel, btn -> { this.applySettingsToCategories(defaultSettings, true); this.onSettingsChanged(); this.showCategory(category); - }, selectPresetLabel, btn -> {}, false, comingSoonTooltip); + }, wlbPresetLabel, btn -> { + this.applyWlbPreset(); + this.onSettingsChanged(); + this.showCategory(category); + }, true, wlbPresetTooltip); } @@ -1765,6 +1839,476 @@ public AbstractWidget createWidget(Runnable onChange) { } } + @Environment(EnvType.CLIENT) + private static final class OverpassProbeDefinition implements EarthCustomizeScreen.SettingDefinition { + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassProbeWidget(); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassProbeWidget extends AbstractWidget { + private final Button button; + + private OverpassProbeWidget() { + super(0, 0, 0, 20, Component.empty()); + this.button = Button.builder(EarthCustomizeScreen.OverpassProbeManager.state().message(), btn -> EarthCustomizeScreen.OverpassProbeManager.test()) + .bounds(0, 0, 0, 20) + .build(); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassProbeState state = EarthCustomizeScreen.OverpassProbeManager.state(); + this.button.active = !state.testing(); + this.button.setMessage(state.message()); + this.button.setTooltip(Tooltip.create(state.tooltip())); + this.button.setX(this.getX()); + this.button.setY(this.getY()); + this.button.setWidth(this.width); + this.button.setHeight(this.height); + this.button.render(graphics, mouseX, mouseY, delta); + } + + public void onClick(double mouseX, double mouseY) { + this.button.mouseClicked(mouseX, mouseY, 0); + } + + protected void onDrag(double mouseX, double mouseY, double deltaX, double deltaY) { + this.button.mouseDragged(mouseX, mouseY, 0, deltaX, deltaY); + } + + public void onRelease(double mouseX, double mouseY) { + this.button.mouseReleased(mouseX, mouseY, 0); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassProbeManager { + private static final AtomicReference STATE = new AtomicReference<>(EarthCustomizeScreen.OverpassProbeState.idle()); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() { + private int index; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "tellus-overpass-probe-" + ++this.index); + thread.setDaemon(true); + return thread; + } + }); + + private OverpassProbeManager() { + } + + private static EarthCustomizeScreen.OverpassProbeState state() { + return STATE.get(); + } + + private static void test() { + EarthCustomizeScreen.OverpassProbeState current = STATE.get(); + if (current.testing()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassProbeState.testingState()); + CompletableFuture.supplyAsync(OverpassExternalFeatureSource::probeConfiguredEndpoints, EXECUTOR) + .thenAccept(results -> STATE.set(EarthCustomizeScreen.OverpassProbeState.complete(results))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to test Arnis Overpass sources", error); + STATE.set(EarthCustomizeScreen.OverpassProbeState.failed(error)); + return null; + }); + } + } + + private record OverpassProbeState(boolean testing, Component message, Component tooltip) { + private static OverpassProbeState idle() { + return new OverpassProbeState( + false, + Component.translatable("tellus.datasource.overpass.test"), + Component.translatable("tellus.datasource.overpass.test.tooltip") + ); + } + + private static OverpassProbeState testingState() { + return new OverpassProbeState( + true, + Component.translatable("tellus.datasource.overpass.testing"), + Component.translatable("tellus.datasource.overpass.testing.tooltip") + ); + } + + private static OverpassProbeState failed(Throwable error) { + String message = error.getMessage(); + return new OverpassProbeState( + false, + Component.translatable("tellus.datasource.overpass.failed"), + Component.literal(message == null || message.isBlank() ? error.getClass().getSimpleName() : message) + ); + } + + private static OverpassProbeState complete(List results) { + int ok = 0; + for (OverpassExternalFeatureSource.EndpointProbeResult result : results) { + if (result.ok()) { + ok++; + } + } + + Component message = ok > 0 + ? Component.translatable("tellus.datasource.overpass.ok", ok, results.size()) + : Component.translatable("tellus.datasource.overpass.none", results.size()); + return new OverpassProbeState(false, message, Component.literal(describe(results))); + } + + private static String describe(List results) { + if (results.isEmpty()) { + return "No endpoints configured."; + } + + StringBuilder builder = new StringBuilder(); + for (OverpassExternalFeatureSource.EndpointProbeResult result : results) { + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(result.ok() ? "OK " : "FAIL ") + .append(hostLabel(result.endpoint())) + .append(" ") + .append(result.elapsedMs()) + .append("ms"); + if (!result.ok() && result.message() != null && !result.message().isBlank()) { + builder.append(" - ").append(result.message()); + } + } + return builder.toString(); + } + + private static String hostLabel(String endpoint) { + try { + URI uri = URI.create(endpoint); + return uri.getHost() == null ? endpoint : uri.getHost(); + } catch (IllegalArgumentException error) { + return endpoint; + } + } + } + + private record OverpassCacheArea(GeoBounds bounds, int radiusChunks, double centerLatitude, double centerLongitude, double worldScale) { + } + + private enum OverpassCacheAction { + ESTIMATE, + PREFETCH + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheStatusDefinition implements EarthCustomizeScreen.SettingDefinition { + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassCacheStatusWidget(); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheStatusWidget extends AbstractWidget { + private OverpassCacheStatusWidget() { + super(0, 0, 0, 20, Component.empty()); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassCacheState state = EarthCustomizeScreen.OverpassCacheManager.state(); + this.setTooltip(Tooltip.create(state.tooltip())); + Font font = Minecraft.getInstance().font; + int textWidth = font.width(state.message()); + int availableWidth = Math.max(1, this.width - 8); + float scale = textWidth > availableWidth ? (float)availableWidth / (float)textWidth : 1.0F; + float scaledWidth = textWidth * scale; + float scaledHeight = 9.0F * scale; + float textX = this.getX() + (this.width - scaledWidth) * 0.5F; + float textY = this.getY() + (this.height - scaledHeight) * 0.5F; + graphics.pose().pushPose(); + graphics.pose().translate(textX, textY, 0.0F); + graphics.pose().scale(scale, scale, 1.0F); + graphics.drawString(font, state.message(), 0, 0, -4605511, true); + graphics.pose().popPose(); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheActionDefinition implements EarthCustomizeScreen.SettingDefinition { + private final EarthCustomizeScreen screen; + private final EarthCustomizeScreen.OverpassCacheAction action; + + private OverpassCacheActionDefinition(EarthCustomizeScreen screen, EarthCustomizeScreen.OverpassCacheAction action) { + this.screen = Objects.requireNonNull(screen, "screen"); + this.action = Objects.requireNonNull(action, "action"); + } + + @Override + public AbstractWidget createWidget(Runnable onChange) { + return new EarthCustomizeScreen.OverpassCacheActionWidget(this.screen, this.action); + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheActionWidget extends AbstractWidget { + private final EarthCustomizeScreen screen; + private final EarthCustomizeScreen.OverpassCacheAction action; + private final Button button; + + private OverpassCacheActionWidget(EarthCustomizeScreen screen, EarthCustomizeScreen.OverpassCacheAction action) { + super(0, 0, 0, 20, Component.empty()); + this.screen = Objects.requireNonNull(screen, "screen"); + this.action = Objects.requireNonNull(action, "action"); + this.button = Button.builder(this.label(), btn -> this.runAction()).bounds(0, 0, 0, 20).build(); + } + + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + EarthCustomizeScreen.OverpassCacheState state = EarthCustomizeScreen.OverpassCacheManager.state(); + this.button.active = this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE ? !state.busy() : state.canPrefetch(); + this.button.setMessage(this.label()); + this.button.setTooltip(Tooltip.create(this.tooltip(state))); + this.button.setX(this.getX()); + this.button.setY(this.getY()); + this.button.setWidth(this.width); + this.button.setHeight(this.height); + this.button.render(graphics, mouseX, mouseY, delta); + } + + public void onClick(double mouseX, double mouseY) { + this.button.mouseClicked(mouseX, mouseY, 0); + } + + protected void onDrag(double mouseX, double mouseY, double deltaX, double deltaY) { + this.button.mouseDragged(mouseX, mouseY, 0, deltaX, deltaY); + } + + public void onRelease(double mouseX, double mouseY) { + this.button.mouseReleased(mouseX, mouseY, 0); + } + + private void runAction() { + EarthCustomizeScreen.OverpassCacheArea area = this.screen.currentOverpassCacheArea(); + if (this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE) { + EarthCustomizeScreen.OverpassCacheManager.estimate(area); + } else { + EarthCustomizeScreen.OverpassCacheManager.prefetch(area); + } + } + + private Component label() { + return this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE + ? Component.translatable("tellus.datasource.overpass.cache.estimate") + : Component.translatable("tellus.datasource.overpass.cache.prefetch"); + } + + private Component tooltip(EarthCustomizeScreen.OverpassCacheState state) { + if (this.action == EarthCustomizeScreen.OverpassCacheAction.ESTIMATE) { + return Component.translatable("tellus.datasource.overpass.cache.estimate.tooltip"); + } + if (state.busy()) { + return Component.translatable("tellus.datasource.overpass.cache.busy.tooltip"); + } + if (!state.canPrefetch()) { + return Component.translatable("tellus.datasource.overpass.cache.prefetch.unavailable.tooltip"); + } + return Component.translatable("tellus.datasource.overpass.cache.prefetch.tooltip"); + } + + protected void updateWidgetNarration(NarrationElementOutput narration) { + } + } + + @Environment(EnvType.CLIENT) + private static final class OverpassCacheManager { + private static final AtomicReference STATE = new AtomicReference<>(EarthCustomizeScreen.OverpassCacheState.idle()); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(new ThreadFactory() { + private int index; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "tellus-overpass-cache-" + ++this.index); + thread.setDaemon(true); + return thread; + } + }); + + private OverpassCacheManager() { + } + + private static EarthCustomizeScreen.OverpassCacheState state() { + return STATE.get(); + } + + private static void estimate(EarthCustomizeScreen.OverpassCacheArea area) { + EarthCustomizeScreen.OverpassCacheState current = STATE.get(); + if (current.busy()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassCacheState.estimating(area)); + CompletableFuture.supplyAsync(() -> OverpassExternalFeatureSource.estimateConfiguredCache(area.bounds()), EXECUTOR) + .thenAccept(estimate -> STATE.set(EarthCustomizeScreen.OverpassCacheState.estimated(area, estimate))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to estimate Arnis Overpass cache", error); + STATE.set(EarthCustomizeScreen.OverpassCacheState.failed(error)); + return null; + }); + } + + private static void prefetch(EarthCustomizeScreen.OverpassCacheArea area) { + EarthCustomizeScreen.OverpassCacheState current = STATE.get(); + if (current.busy() || !current.canPrefetch()) { + return; + } + + STATE.set(EarthCustomizeScreen.OverpassCacheState.prefetching(area, current.estimate())); + CompletableFuture.supplyAsync(() -> OverpassExternalFeatureSource.prefetchConfiguredBounds(area.bounds()), EXECUTOR) + .thenAccept(result -> STATE.set(EarthCustomizeScreen.OverpassCacheState.prefetched(area, result))) + .exceptionally(error -> { + Tellus.LOGGER.warn("Failed to prefetch Arnis Overpass cache", error); + STATE.set(EarthCustomizeScreen.OverpassCacheState.failed(error)); + return null; + }); + } + } + + private record OverpassCacheState( + boolean busy, + Component message, + Component tooltip, + EarthCustomizeScreen.OverpassCacheArea area, + OverpassExternalFeatureSource.CacheEstimate estimate + ) { + private static OverpassCacheState idle() { + return new OverpassCacheState( + false, + Component.translatable("tellus.datasource.overpass.cache.idle"), + Component.translatable("tellus.datasource.overpass.cache.idle.tooltip"), + null, + null + ); + } + + private static OverpassCacheState estimating(EarthCustomizeScreen.OverpassCacheArea area) { + return new OverpassCacheState( + true, + Component.translatable("tellus.datasource.overpass.cache.estimating"), + Component.literal(describeArea(area)), + area, + null + ); + } + + private static OverpassCacheState estimated(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate) { + Component message = estimate.enabled() + ? Component.translatable( + "tellus.datasource.overpass.cache.summary", + estimate.cachedTiles(), + estimate.totalTiles(), + estimate.missingTiles() + ) + : Component.translatable("tellus.datasource.overpass.cache.disabled"); + return new OverpassCacheState(false, message, Component.literal(describeEstimate(area, estimate)), area, estimate); + } + + private static OverpassCacheState prefetching( + EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate + ) { + return new OverpassCacheState( + true, + Component.translatable("tellus.datasource.overpass.cache.prefetching"), + Component.literal(describeEstimate(area, estimate)), + area, + estimate + ); + } + + private static OverpassCacheState prefetched(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.PrefetchResult result) { + OverpassExternalFeatureSource.CacheEstimate after = result.after(); + int beforeReady = result.before().cityDetailsEnabled() ? result.before().cityDetailCachedTiles() : result.before().cachedTiles(); + int afterReady = after.cityDetailsEnabled() ? after.cityDetailCachedTiles() : after.cachedTiles(); + int gained = Math.max(0, afterReady - beforeReady); + Component message = Component.translatable("tellus.datasource.overpass.cache.prefetched", gained, afterReady, after.totalTiles()); + return new OverpassCacheState(false, message, Component.literal(describePrefetch(area, result)), area, after); + } + + private static OverpassCacheState failed(Throwable error) { + String message = error.getMessage(); + return new OverpassCacheState( + false, + Component.translatable("tellus.datasource.overpass.cache.failed"), + Component.literal(message == null || message.isBlank() ? error.getClass().getSimpleName() : message), + null, + null + ); + } + + private boolean canPrefetch() { + return !this.busy + && this.estimate != null + && this.estimate.enabled() + && this.estimate.networkEnabled() + && this.estimate.missingTiles() > 0; + } + + private static String describeArea(EarthCustomizeScreen.OverpassCacheArea area) { + return String.format( + Locale.ROOT, + "Spawn %.5f, %.5f\nRadius: %d chunks\nWorld scale: 1:%.1fm", + area.centerLatitude(), + area.centerLongitude(), + area.radiusChunks(), + area.worldScale() + ); + } + + private static String describeEstimate(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.CacheEstimate estimate) { + if (estimate == null) { + return describeArea(area); + } + if (!estimate.enabled()) { + return "Overpass source is disabled."; + } + + return describeArea(area) + + "\nTiles: " + + estimate.cachedTiles() + + " cached / " + + estimate.totalTiles() + + " total" + + "\nMissing: " + + estimate.missingTiles() + + "\nCached size: " + + EarthCustomizeScreen.formatBytes(estimate.cachedBytes()) + + (estimate.cityDetailsEnabled() + ? "\nCity details: " + estimate.cityDetailCachedTiles() + " ready / " + estimate.totalTiles() + " total" + : "") + + "\nNetwork: " + + (estimate.networkEnabled() ? "cache-first" : "cache-only") + + "\nSession budget: " + + estimate.sessionNetworkTileBudget() + + " tiles" + + "\nRouting: WLB 18127 rule proxy."; + } + + private static String describePrefetch(EarthCustomizeScreen.OverpassCacheArea area, OverpassExternalFeatureSource.PrefetchResult result) { + return describeEstimate(area, result.after()) + + "\nAttempted: " + + result.attemptedTiles() + + "\nNew cached: " + + result.cachedTiles() + + "\nFailed/skipped: " + + result.failedTiles(); + } + } + @Environment(EnvType.CLIENT) private static final class CacheActionDefinition implements EarthCustomizeScreen.SettingDefinition { diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java b/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java index b244b0990..0dcce2425 100644 --- a/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java +++ b/mc1211/src/client/java/com/yucareux/tellus/client/screen/EarthTeleportScreen.java @@ -1,36 +1,70 @@ package com.yucareux.tellus.client.screen; +import com.yucareux.tellus.client.teleport.TeleportWaypoint; +import com.yucareux.tellus.client.teleport.TeleportWaypointStore; import com.yucareux.tellus.client.widget.map.PlaceSearchWidget; import com.yucareux.tellus.client.widget.map.SlippyMapPoint; import com.yucareux.tellus.client.widget.map.SlippyMapWidget; import com.yucareux.tellus.client.widget.map.component.MarkerMapComponent; +import com.yucareux.tellus.client.widget.map.component.WaypointMapComponent; import com.yucareux.tellus.network.GeoTpTeleportPayload; import com.yucareux.tellus.world.data.source.Geocoder; import com.yucareux.tellus.world.data.source.NominatimGeocoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; @Environment(EnvType.CLIENT) public class EarthTeleportScreen extends Screen { private static final int DEFAULT_ZOOM = 6; - + private static final int WAYPOINT_ROW_HEIGHT = 22; + private static final int PANEL_PADDING = 10; + private static final int PANEL_MIN_WIDTH = 196; + private static final int PANEL_MAX_WIDTH = 232; + private static final int SEARCH_WIDTH = 220; + private final Screen parent; - private final double initialLatitude; - private final double initialLongitude; + private final double spawnLatitude; + private final double spawnLongitude; + private final TeleportWaypointStore waypointStore; + private final List waypoints; + private double markerLatitude; + private double markerLongitude; + private String selectedWaypointId; + private String noteDraft = ""; + private int waypointScrollOffset; + private int panelX; + private int panelWidth; + private int noteLabelY; + private int coordinateY; private SlippyMapWidget mapWidget; private MarkerMapComponent markerComponent; private PlaceSearchWidget searchWidget; + private EditBox noteBox; + private Button deleteButton; + private Button teleportButton; + private boolean suppressMapRelease; + private boolean pendingWaypointSelectionClick; - public EarthTeleportScreen( Screen parent, double latitude, double longitude) { + public EarthTeleportScreen(Screen parent, double latitude, double longitude, double spawnLatitude, double spawnLongitude) { super(Component.translatable("gui.earth.teleport_map")); this.parent = parent; - this.initialLatitude = latitude; - this.initialLongitude = longitude; + this.markerLatitude = latitude; + this.markerLongitude = longitude; + this.spawnLatitude = spawnLatitude; + this.spawnLongitude = spawnLongitude; + this.waypointStore = TeleportWaypointStore.create(Minecraft.getInstance()); + this.waypoints = new ArrayList<>(this.waypointStore.load(spawnLatitude, spawnLongitude)); } protected void init() { @@ -38,26 +72,103 @@ protected void init() { this.mapWidget.close(); } - int mapX = 20; - int mapY = 20; - int mapWidth = this.width - 40; - int mapHeight = this.height - 60; + if (this.searchWidget != null) { + this.searchWidget.close(); + } + + this.panelWidth = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, this.width / 3)); + if (this.width - this.panelWidth < 180) { + this.panelWidth = Math.max(180, this.width - 180); + } + + this.panelX = this.width - this.panelWidth; + int mapX = 8; + int mapY = 8; + int mapWidth = Math.max(64, this.panelX - mapX - 8); + int mapHeight = Math.max(64, this.height - 16); this.mapWidget = new SlippyMapWidget(mapX, mapY, mapWidth, mapHeight); - this.markerComponent = new MarkerMapComponent(new SlippyMapPoint(this.initialLatitude, this.initialLongitude)).allowMovement(); + this.mapWidget.setAttributionBottomPadding(0); + this.mapWidget.addComponent(new WaypointMapComponent(() -> this.waypoints, () -> this.selectedWaypointId, this::selectWaypointFromMap)); + this.markerComponent = new MarkerMapComponent(new SlippyMapPoint(this.markerLatitude, this.markerLongitude)).allowMovement(); this.mapWidget.addComponent(this.markerComponent); - this.mapWidget.getMap().focus(this.initialLatitude, this.initialLongitude, DEFAULT_ZOOM); + this.mapWidget.getMap().focus(this.markerLatitude, this.markerLongitude, DEFAULT_ZOOM); + Geocoder geocoder = new NominatimGeocoder(); - this.searchWidget = new PlaceSearchWidget(mapX + 5, mapY + 5, 200, 20, geocoder, this::handleSearch); + int searchWidth = Math.min(SEARCH_WIDTH, Math.max(96, mapWidth - 12)); + this.searchWidget = new PlaceSearchWidget(mapX + 6, mapY + 6, searchWidth, 20, geocoder, this::handleSearch); this.addRenderableOnly(this.mapWidget); this.addRenderableWidget(this.searchWidget); - int buttonY = this.height - 28; + this.addWidget(this.mapWidget); + + this.layoutSidePanel(); + this.updateActionState(); + } + + private void layoutSidePanel() { + int controlX = this.panelX + PANEL_PADDING; + int controlWidth = Math.max(96, this.panelWidth - PANEL_PADDING * 2); + int listY = 34; + int actionY = Math.max(130, this.height - 80); + int noteBoxY = Math.max(listY + 46, actionY - 34); + int listBottom = noteBoxY - 12; + int waypointRows = Math.max(2, (listBottom - listY) / WAYPOINT_ROW_HEIGHT); + int maxOffset = Math.max(0, this.waypoints.size() - waypointRows); + this.waypointScrollOffset = Math.min(this.waypointScrollOffset, maxOffset); + + int visibleRows = Math.min(waypointRows, Math.max(0, this.waypoints.size() - this.waypointScrollOffset)); + for (int i = 0; i < visibleRows; i++) { + TeleportWaypoint waypoint = this.waypoints.get(this.waypointScrollOffset + i); + int buttonY = listY + i * WAYPOINT_ROW_HEIGHT; + this.addRenderableWidget( + Button.builder(this.waypointButtonLabel(waypoint, controlWidth - 8), button -> this.selectWaypoint(waypoint.id(), true)) + .bounds(controlX, buttonY, controlWidth, 20) + .build() + ); + } + + if (maxOffset > 0) { + int pageY = listY + waypointRows * WAYPOINT_ROW_HEIGHT + 2; + int halfWidth = (controlWidth - 4) / 2; + Button previous = Button.builder(Component.translatable("gui.earth.waypoints.previous"), button -> { + this.captureCurrentInputs(); + this.waypointScrollOffset = Math.max(0, this.waypointScrollOffset - waypointRows); + this.rebuildScreen(); + }).bounds(controlX, pageY, halfWidth, 20).build(); + previous.active = this.waypointScrollOffset > 0; + this.addRenderableWidget(previous); + Button next = Button.builder(Component.translatable("gui.earth.waypoints.next"), button -> { + this.captureCurrentInputs(); + this.waypointScrollOffset = Math.min(maxOffset, this.waypointScrollOffset + waypointRows); + this.rebuildScreen(); + }).bounds(controlX + halfWidth + 4, pageY, halfWidth, 20).build(); + next.active = this.waypointScrollOffset < maxOffset; + this.addRenderableWidget(next); + } + + this.noteLabelY = noteBoxY - 11; + this.noteBox = new EditBox(this.font, controlX, noteBoxY, controlWidth, 20, Component.translatable("gui.earth.waypoint_note")); + this.noteBox.setMaxLength(64); + this.noteBox.setValue(this.noteDraft); + this.noteBox.setHint(Component.translatable("gui.earth.waypoint_note")); + this.addRenderableWidget(this.noteBox); + this.coordinateY = noteBoxY + 24; + int smallWidth = (controlWidth - 4) / 2; this.addRenderableWidget( - Button.builder(Component.translatable("gui.earth.teleport"), button -> this.sendTeleport()).bounds(this.width / 2 - 154, buttonY, 150, 20).build() + Button.builder(Component.translatable("gui.earth.waypoint_save"), button -> this.saveWaypoint()) + .bounds(controlX, actionY, smallWidth, 20) + .build() ); + this.deleteButton = Button.builder(Component.translatable("gui.earth.waypoint_delete"), button -> this.deleteWaypoint()) + .bounds(controlX + smallWidth + 4, actionY, smallWidth, 20) + .build(); + this.addRenderableWidget(this.deleteButton); + this.teleportButton = Button.builder(Component.translatable("gui.earth.teleport"), button -> this.sendTeleport()) + .bounds(controlX, actionY + 24, controlWidth, 20) + .build(); + this.addRenderableWidget(this.teleportButton); this.addRenderableWidget( - Button.builder(Component.translatable("gui.cancel"), button -> this.closeScreen()).bounds(this.width / 2 + 4, buttonY, 150, 20).build() + Button.builder(Component.translatable("gui.cancel"), button -> this.closeScreen()).bounds(controlX, actionY + 48, controlWidth, 20).build() ); - this.addWidget(this.mapWidget); } protected void setInitialFocus() { @@ -67,26 +178,154 @@ protected void setInitialFocus() { } private void handleSearch(double latitude, double longitude) { + this.markerLatitude = latitude; + this.markerLongitude = longitude; + this.selectedWaypointId = null; + this.noteDraft = ""; + if (this.noteBox != null) { + this.noteBox.setValue(""); + } + this.markerComponent.moveMarker(latitude, longitude); this.mapWidget.getMap().focus(latitude, longitude, 12); + this.updateActionState(); + } + + private void selectWaypointFromMap(String waypointId) { + this.pendingWaypointSelectionClick = true; + this.selectWaypoint(waypointId, false); + } + + private void selectWaypoint(String waypointId, boolean focusMap) { + TeleportWaypoint waypoint = this.findWaypoint(waypointId); + if (waypoint != null) { + this.selectedWaypointId = waypoint.id(); + this.noteDraft = waypoint.label(); + this.markerLatitude = waypoint.latitude(); + this.markerLongitude = waypoint.longitude(); + if (this.noteBox != null) { + this.noteBox.setValue(this.noteDraft); + } + + if (this.markerComponent != null) { + this.markerComponent.moveMarker(this.markerLatitude, this.markerLongitude); + } + + if (focusMap && this.mapWidget != null) { + this.mapWidget.getMap().focus(this.markerLatitude, this.markerLongitude, 12); + } + + this.updateActionState(); + } + } + + private void saveWaypoint() { + this.captureCurrentInputs(); + String label = this.noteDraft.trim(); + if (label.isEmpty()) { + label = String.format(Locale.ROOT, "Point %d", Math.max(1, this.waypoints.size())); + } + + TeleportWaypoint selected = this.selectedWaypoint(); + if (selected == null) { + selected = new TeleportWaypoint(UUID.randomUUID().toString(), label, this.markerLatitude, this.markerLongitude, false); + this.waypoints.add(selected); + this.selectedWaypointId = selected.id(); + } else { + selected.setLabel(label); + if (!selected.initialSpawn()) { + selected.setLatitude(this.markerLatitude); + selected.setLongitude(this.markerLongitude); + } + } + + this.noteDraft = selected.label(); + this.waypointStore.save(this.waypoints); + this.rebuildScreen(); + } + + private void deleteWaypoint() { + TeleportWaypoint selected = this.selectedWaypoint(); + if (selected != null && !selected.initialSpawn()) { + this.waypoints.remove(selected); + this.selectedWaypointId = null; + this.noteDraft = ""; + this.waypointStore.save(this.waypoints); + this.rebuildScreen(); + } } private void sendTeleport() { + this.captureCurrentMarker(); + if (this.minecraft != null) { + if (!ClientPlayNetworking.canSend(GeoTpTeleportPayload.TYPE)) { + if (this.minecraft.player != null) { + this.minecraft.player.displayClientMessage(Component.literal("Tellus: Server does not accept GeoTP requests."), true); + } + + this.closeScreen(); + } else { + ClientPlayNetworking.send(new GeoTpTeleportPayload(this.markerLatitude, this.markerLongitude)); + this.closeScreen(); + } + } + } + + private void captureCurrentInputs() { + this.captureCurrentMarker(); + if (this.noteBox != null) { + this.noteDraft = this.noteBox.getValue(); + } + } + + private void captureCurrentMarker() { if (this.markerComponent != null) { SlippyMapPoint marker = this.markerComponent.getMarker(); - if (marker != null && this.minecraft != null) { - if (!ClientPlayNetworking.canSend(GeoTpTeleportPayload.TYPE)) { - if (this.minecraft.player != null) { - this.minecraft.player.displayClientMessage(Component.literal("Tellus: Server does not accept GeoTP requests."), true); - } - - this.closeScreen(); - } else { - ClientPlayNetworking.send(new GeoTpTeleportPayload(marker.getLatitude(), marker.getLongitude())); - this.closeScreen(); - } + if (marker != null) { + this.markerLatitude = marker.getLatitude(); + this.markerLongitude = marker.getLongitude(); + } + } + } + + private void rebuildScreen() { + this.clearWidgets(); + this.init(); + } + + private TeleportWaypoint selectedWaypoint() { + return this.selectedWaypointId == null ? null : this.findWaypoint(this.selectedWaypointId); + } + + private TeleportWaypoint findWaypoint(String waypointId) { + for (TeleportWaypoint waypoint : this.waypoints) { + if (waypoint.id().equals(waypointId)) { + return waypoint; } } + + return null; + } + + private Component waypointButtonLabel(TeleportWaypoint waypoint, int maxWidth) { + String prefix = waypoint.initialSpawn() ? "* " : ""; + String label = prefix + waypoint.label(); + if (this.font.width(label) > maxWidth) { + label = this.font.plainSubstrByWidth(label, Math.max(8, maxWidth - this.font.width("..."))) + "..."; + } + + return Component.literal(label); + } + + private void updateActionState() { + TeleportWaypoint selected = this.selectedWaypoint(); + if (this.deleteButton != null) { + this.deleteButton.active = selected != null && !selected.initialSpawn(); + } + + if (this.teleportButton != null) { + this.teleportButton.active = this.markerComponent != null && this.markerComponent.getMarker() != null; + } } private void closeScreen() { @@ -95,9 +334,60 @@ private void closeScreen() { } } - public void render( GuiGraphics graphics, int mouseX, int mouseY, float delta) { + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (this.isSearchOverlayMouseOver(mouseX, mouseY)) { + this.suppressMapRelease = true; + this.cancelMapInteraction(); + this.setFocused(this.searchWidget); + this.searchWidget.setFocused(true); + this.searchWidget.mouseClicked(mouseX, mouseY, button); + return true; + } + + this.suppressMapRelease = false; + this.pendingWaypointSelectionClick = false; + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (this.suppressMapRelease || this.isSearchOverlayMouseOver(mouseX, mouseY)) { + this.suppressMapRelease = false; + this.cancelMapInteraction(); + return true; + } + + SlippyMapPoint before = this.markerComponent == null ? null : this.markerComponent.getMarker(); + boolean waypointClick = this.pendingWaypointSelectionClick; + boolean handled = super.mouseReleased(mouseX, mouseY, button); + SlippyMapPoint after = this.markerComponent == null ? null : this.markerComponent.getMarker(); + if (waypointClick && this.selectedWaypointId != null) { + this.selectWaypoint(this.selectedWaypointId, false); + } else if (button == 0 && this.mapWidget != null && this.mapWidget.isMouseOver(mouseX, mouseY) && markerMoved(before, after)) { + this.captureCurrentMarker(); + if (!this.pendingWaypointSelectionClick) { + this.selectedWaypointId = null; + this.noteDraft = ""; + if (this.noteBox != null) { + this.noteBox.setValue(""); + } + } + + this.updateActionState(); + } + + this.pendingWaypointSelectionClick = false; + return handled; + } + + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { graphics.fill(0, 0, this.width, this.height, -1072689136); - graphics.drawCenteredString(this.font, this.title, this.width / 2, 4, 16777215); + graphics.fill(this.panelX, 0, this.width, this.height, -15066598); + graphics.drawString(this.font, this.title, this.panelX + PANEL_PADDING, 12, 16777215, false); + graphics.drawString(this.font, Component.translatable("gui.earth.waypoints"), this.panelX + PANEL_PADDING, 24, 13421772, false); + graphics.drawString(this.font, Component.translatable("gui.earth.waypoint_note"), this.panelX + PANEL_PADDING, this.noteLabelY, 13421772, false); + graphics.drawString(this.font, Component.literal(this.formatMarkerCoordinates()), this.panelX + PANEL_PADDING, this.coordinateY, 11184810, false); super.render(graphics, mouseX, mouseY, delta); } @@ -121,4 +411,27 @@ public void removed() { this.searchWidget.close(); } } + + private String formatMarkerCoordinates() { + this.captureCurrentMarker(); + return String.format(Locale.ROOT, "%.5f, %.5f", this.markerLatitude, this.markerLongitude); + } + + private boolean isSearchOverlayMouseOver(double mouseX, double mouseY) { + return this.searchWidget != null && this.searchWidget.isMouseOver(mouseX, mouseY); + } + + private void cancelMapInteraction() { + if (this.mapWidget != null) { + this.mapWidget.cancelInteraction(); + } + } + + private static boolean markerMoved(SlippyMapPoint before, SlippyMapPoint after) { + if (before == null || after == null) { + return before != after; + } + + return Double.compare(before.getLatitude(), after.getLatitude()) != 0 || Double.compare(before.getLongitude(), after.getLongitude()) != 0; + } } diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java b/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java new file mode 100644 index 000000000..c677c5012 --- /dev/null +++ b/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java @@ -0,0 +1,59 @@ +package com.yucareux.tellus.client.teleport; + +import java.util.Objects; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(EnvType.CLIENT) +public final class TeleportWaypoint { + private final String id; + private String label; + private double latitude; + private double longitude; + private final boolean initialSpawn; + + public TeleportWaypoint(String id, String label, double latitude, double longitude, boolean initialSpawn) { + this.id = Objects.requireNonNull(id, "id"); + this.label = normalizeLabel(label); + this.latitude = latitude; + this.longitude = longitude; + this.initialSpawn = initialSpawn; + } + + public String id() { + return this.id; + } + + public String label() { + return this.label; + } + + public void setLabel(String label) { + this.label = normalizeLabel(label); + } + + public double latitude() { + return this.latitude; + } + + public void setLatitude(double latitude) { + this.latitude = latitude; + } + + public double longitude() { + return this.longitude; + } + + public void setLongitude(double longitude) { + this.longitude = longitude; + } + + public boolean initialSpawn() { + return this.initialSpawn; + } + + private static String normalizeLabel(String label) { + String safeLabel = label == null ? "" : label.trim(); + return safeLabel.isEmpty() ? "Waypoint" : safeLabel; + } +} diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java b/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java new file mode 100644 index 000000000..a59123cb6 --- /dev/null +++ b/mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java @@ -0,0 +1,145 @@ +package com.yucareux.tellus.client.teleport; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.yucareux.tellus.Tellus; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; + +@Environment(EnvType.CLIENT) +public final class TeleportWaypointStore { + public static final String INITIAL_SPAWN_ID = "initial_spawn"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final int FORMAT_VERSION = 1; + private final Path path; + + private TeleportWaypointStore(Path path) { + this.path = path; + } + + public static TeleportWaypointStore create(Minecraft minecraft) { + Path directory = FabricLoader.getInstance().getGameDir().resolve("tellus").resolve("teleport-points"); + return new TeleportWaypointStore(directory.resolve(safeFileName(resolveWorldKey(minecraft)) + ".json")); + } + + public List load(double spawnLatitude, double spawnLongitude) { + List waypoints = new ArrayList<>(); + if (Files.isRegularFile(this.path)) { + try { + JsonElement parsed = JsonParser.parseString(Files.readString(this.path, StandardCharsets.UTF_8)); + if (parsed.isJsonObject()) { + JsonArray array = parsed.getAsJsonObject().getAsJsonArray("waypoints"); + if (array != null) { + for (JsonElement element : array) { + if (element.isJsonObject()) { + readWaypoint(element.getAsJsonObject(), waypoints); + } + } + } + } + } catch (IOException | RuntimeException exception) { + Tellus.LOGGER.warn("Failed to read Tellus teleport waypoints from {}", this.path, exception); + } + } + + if (waypoints.stream().noneMatch(waypoint -> INITIAL_SPAWN_ID.equals(waypoint.id()))) { + waypoints.add(0, initialSpawn(spawnLatitude, spawnLongitude)); + this.save(waypoints); + } + + return waypoints; + } + + public void save(List waypoints) { + JsonObject root = new JsonObject(); + root.addProperty("version", FORMAT_VERSION); + JsonArray array = new JsonArray(); + for (TeleportWaypoint waypoint : waypoints) { + JsonObject entry = new JsonObject(); + entry.addProperty("id", waypoint.id()); + entry.addProperty("label", waypoint.label()); + entry.addProperty("latitude", waypoint.latitude()); + entry.addProperty("longitude", waypoint.longitude()); + entry.addProperty("initial_spawn", waypoint.initialSpawn()); + array.add(entry); + } + + root.add("waypoints", array); + try { + Files.createDirectories(this.path.getParent()); + Files.writeString(this.path, GSON.toJson(root), StandardCharsets.UTF_8); + } catch (IOException exception) { + Tellus.LOGGER.warn("Failed to save Tellus teleport waypoints to {}", this.path, exception); + } + } + + private static void readWaypoint(JsonObject object, List waypoints) { + String id = stringValue(object, "id", ""); + if (id.isBlank()) { + return; + } + + double latitude = doubleValue(object, "latitude", Double.NaN); + double longitude = doubleValue(object, "longitude", Double.NaN); + if (!Double.isFinite(latitude) || !Double.isFinite(longitude)) { + return; + } + + String label = stringValue(object, "label", id); + boolean initialSpawn = booleanValue(object, "initial_spawn", INITIAL_SPAWN_ID.equals(id)); + waypoints.add(new TeleportWaypoint(id, label, latitude, longitude, initialSpawn)); + } + + private static TeleportWaypoint initialSpawn(double latitude, double longitude) { + return new TeleportWaypoint(INITIAL_SPAWN_ID, "Initial Spawn", latitude, longitude, true); + } + + private static String resolveWorldKey(Minecraft minecraft) { + String dimension = minecraft.level == null ? "unknown" : minecraft.level.dimension().location().toString(); + if (minecraft.getSingleplayerServer() != null) { + return "singleplayer-" + minecraft.getSingleplayerServer().getWorldData().getLevelName() + "-" + dimension; + } + + ServerData server = minecraft.getCurrentServer(); + if (server != null) { + return "server-" + server.ip + "-" + dimension; + } + + return "level-" + dimension; + } + + private static String safeFileName(String raw) { + String normalized = raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_"); + String clipped = normalized.length() <= 96 ? normalized : normalized.substring(0, 96); + return clipped + "-" + Integer.toHexString(raw.hashCode()); + } + + private static String stringValue(JsonObject object, String key, String fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsString() : fallback; + } + + private static double doubleValue(JsonObject object, String key, double fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsDouble() : fallback; + } + + private static boolean booleanValue(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element != null && element.isJsonPrimitive() ? element.getAsBoolean() : fallback; + } +} diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java b/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java index 264bfceff..c84f96639 100644 --- a/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java +++ b/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java @@ -6,6 +6,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.mojang.blaze3d.platform.NativeImage; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -13,9 +14,11 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ExecutorService; @@ -29,6 +32,16 @@ @Environment(EnvType.CLIENT) public class SlippyMapTileCache { private static final int CACHE_SIZE = 1024; + private static final String TILE_ENDPOINTS_PROPERTY = "tellus.map.tile.endpoints"; + private static final String DEFAULT_TILE_ENDPOINTS = String.join( + ",", + "https://tile.openstreetmap.org/%d/%d/%d.png", + "https://a.tile.openstreetmap.org/%d/%d/%d.png", + "https://b.tile.openstreetmap.org/%d/%d/%d.png", + "https://c.tile.openstreetmap.org/%d/%d/%d.png", + "https://tile.openstreetmap.de/%d/%d/%d.png" + ); + private static final String[] TILE_ENDPOINTS = parseTileEndpoints(System.getProperty(TILE_ENDPOINTS_PROPERTY, DEFAULT_TILE_ENDPOINTS)); private final ExecutorService loadingService = Executors.newFixedThreadPool( 4, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("tellus-map-load-%d").build() ); @@ -108,16 +121,32 @@ private InputStream getStream(SlippyMapTilePos pos) throws IOException { if (Files.exists(cachePath)) { return new BufferedInputStream(Files.newInputStream(cachePath)); } else { - URI uri = URI.create(String.format("https://tile.openstreetmap.org/%s/%s/%s.png", pos.getZoom(), pos.getX(), pos.getY())); - URL url = uri.toURL(); - HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + byte[] data = this.fetchTileData(pos); + this.cacheData(cachePath, data); + return new ByteArrayInputStream(data); + } + } + + private byte[] fetchTileData(SlippyMapTilePos pos) throws IOException { + IOException lastError = null; + for (String endpoint : TILE_ENDPOINTS) { + String requestUrl; + try { + requestUrl = String.format(Locale.ROOT, endpoint, pos.getZoom(), pos.getX(), pos.getY()); + } catch (RuntimeException error) { + lastError = new IOException("Invalid map tile endpoint template: " + endpoint, error); + TellusDiagnostics.traffic("Slippy map tile endpoint invalid endpoint=%s error=%s", endpoint, lastError.getMessage()); + continue; + } + + HttpURLConnection connection = (HttpURLConnection)URI.create(requestUrl).toURL().openConnection(); try { connection.setConnectTimeout(5000); connection.setReadTimeout(5000); connection.setRequestProperty("User-Agent", "Tellus/2.0.0 (Minecraft Mod)"); int responseCode = connection.getResponseCode(); if (responseCode != 200) { - throw new IOException("OpenStreetMap tile request failed with HTTP " + responseCode + " for " + pos); + throw new IOException("HTTP " + responseCode); } InputStream stream = Objects.requireNonNull(connection.getInputStream(), "tileStream"); @@ -125,15 +154,32 @@ private InputStream getStream(SlippyMapTilePos pos) throws IOException { try (InputStream input = new BufferedInputStream(stream)) { byte[] data = input.readAllBytes(); - this.cacheData(cachePath, data); - return new ByteArrayInputStream(data); + TellusDiagnostics.traffic("Slippy map tile ok tile=%s endpoint=%s bytes=%d", pos, requestUrl, data.length); + return data; } finally { this.loadingStreams.remove(stream); } + } catch (IOException error) { + lastError = error; + TellusDiagnostics.traffic("Slippy map tile failed tile=%s endpoint=%s error=%s", pos, requestUrl, error.getMessage()); } finally { connection.disconnect(); } } + + throw new IOException("all map tile endpoints failed for " + pos, lastError); + } + + private static String[] parseTileEndpoints(String config) { + String[] parts = Objects.requireNonNull(config, "tileEndpoints").split(","); + List parsed = new ArrayList<>(parts.length); + for (String part : parts) { + String trimmed = part == null ? "" : part.trim(); + if (!trimmed.isEmpty()) { + parsed.add(trimmed); + } + } + return parsed.isEmpty() ? new String[]{"https://tile.openstreetmap.org/%d/%d/%d.png"} : parsed.toArray(String[]::new); } private void cacheData(Path cachePath, byte[] data) { diff --git a/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java b/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java new file mode 100644 index 000000000..63db33b3b --- /dev/null +++ b/mc1211/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java @@ -0,0 +1,105 @@ +package com.yucareux.tellus.client.widget.map.component; + +import com.yucareux.tellus.client.teleport.TeleportWaypoint; +import com.yucareux.tellus.client.widget.map.SlippyMap; +import com.yucareux.tellus.client.widget.map.SlippyMapPoint; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +@Environment(EnvType.CLIENT) +public final class WaypointMapComponent implements MapComponent { + private static final int PIN_RADIUS = 4; + private static final int PICK_RADIUS = 10; + private static final int COLOR_SPAWN = -12001301; + private static final int COLOR_WAYPOINT = -13382401; + private static final int COLOR_SELECTED = -29218; + private final Supplier> waypointSupplier; + private final Supplier selectedWaypointSupplier; + private final Consumer waypointClickHandler; + + public WaypointMapComponent( + Supplier> waypointSupplier, + Supplier selectedWaypointSupplier, + Consumer waypointClickHandler + ) { + this.waypointSupplier = Objects.requireNonNull(waypointSupplier, "waypointSupplier"); + this.selectedWaypointSupplier = Objects.requireNonNull(selectedWaypointSupplier, "selectedWaypointSupplier"); + this.waypointClickHandler = Objects.requireNonNull(waypointClickHandler, "waypointClickHandler"); + } + + @Override + public void onDrawMap(SlippyMap map, GuiGraphics graphics, int mouseX, int mouseY, SlippyMapPoint mouse) { + int zoom = map.getCameraZoom(); + int scale = Math.max(1, (int)Math.round(Minecraft.getInstance().getWindow().getGuiScale())); + String selectedId = this.selectedWaypointSupplier.get(); + + for (TeleportWaypoint waypoint : this.waypointSupplier.get()) { + int markerX = waypointX(waypoint, zoom) - map.getCameraX(); + int markerY = waypointY(waypoint, zoom) - map.getCameraY(); + int guiMarkerX = markerX / scale; + int guiMarkerY = markerY / scale; + boolean selected = waypoint.id().equals(selectedId); + int color = selected ? COLOR_SELECTED : waypoint.initialSpawn() ? COLOR_SPAWN : COLOR_WAYPOINT; + graphics.fill(guiMarkerX - PIN_RADIUS - 1, guiMarkerY - PIN_RADIUS - 1, guiMarkerX + PIN_RADIUS + 2, guiMarkerY + PIN_RADIUS + 2, -16777216); + graphics.fill(guiMarkerX - PIN_RADIUS, guiMarkerY - PIN_RADIUS, guiMarkerX + PIN_RADIUS + 1, guiMarkerY + PIN_RADIUS + 1, color); + if (selected || waypoint.initialSpawn()) { + drawLabel(graphics, guiMarkerX + 7, guiMarkerY - 12, waypoint.label()); + } + } + } + + @Override + public boolean onMouseClicked(SlippyMap map, SlippyMapPoint mouse, int button) { + if (button != 0) { + return false; + } + + int zoom = map.getCameraZoom(); + int scale = Math.max(1, (int)Math.round(Minecraft.getInstance().getWindow().getGuiScale())); + int mouseX = mouse.getX(zoom); + int mouseY = mouse.getY(zoom); + int pickRadius = PICK_RADIUS * scale; + int bestDistance = pickRadius * pickRadius + 1; + String bestWaypointId = null; + + for (TeleportWaypoint waypoint : this.waypointSupplier.get()) { + int deltaX = waypointX(waypoint, zoom) - mouseX; + int deltaY = waypointY(waypoint, zoom) - mouseY; + int distance = deltaX * deltaX + deltaY * deltaY; + if (distance < bestDistance) { + bestDistance = distance; + bestWaypointId = waypoint.id(); + } + } + + if (bestWaypointId != null) { + this.waypointClickHandler.accept(bestWaypointId); + return true; + } + + return false; + } + + private static int waypointX(TeleportWaypoint waypoint, int zoom) { + return new SlippyMapPoint(waypoint.latitude(), waypoint.longitude()).getX(zoom); + } + + private static int waypointY(TeleportWaypoint waypoint, int zoom) { + return new SlippyMapPoint(waypoint.latitude(), waypoint.longitude()).getY(zoom); + } + + private static void drawLabel(GuiGraphics graphics, int x, int y, String label) { + Font font = Minecraft.getInstance().font; + String clipped = font.width(label) <= 112 ? label : font.plainSubstrByWidth(label, 101) + "..."; + int width = font.width(clipped); + graphics.fill(x - 2, y - 2, x + width + 3, y + 10, -1442840576); + graphics.drawString(font, clipped, x, y, -1, false); + } +} diff --git a/mc1211/src/main/java/com/yucareux/tellus/Tellus.java b/mc1211/src/main/java/com/yucareux/tellus/Tellus.java index 53b5ec5b0..a696ad8f5 100644 --- a/mc1211/src/main/java/com/yucareux/tellus/Tellus.java +++ b/mc1211/src/main/java/com/yucareux/tellus/Tellus.java @@ -117,9 +117,7 @@ public void onInitialize() { (dispatcher, registryAccess, environment) -> dispatcher.register( ((Commands.literal("tellus") .then( - (Commands.literal("map") - .requires(source -> source.hasPermission(2))) - .executes(context -> openGeoTpMap((CommandSourceStack)context.getSource())) + Commands.literal("map").executes(context -> openGeoTpMap((CommandSourceStack)context.getSource())) )) .then( (Commands.literal("weather") @@ -268,7 +266,10 @@ private static int openGeoTpMap(CommandSourceStack source) { if (level.getChunkSource().getGenerator() instanceof EarthChunkGenerator earthGenerator) { double latitude = clampLatitude(earthGenerator.latitudeFromBlock(player.getZ())); double longitude = clampLongitude(earthGenerator.longitudeFromBlock(player.getX())); - ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude)); + EarthGeneratorSettings settings = earthGenerator.settings(); + double spawnLatitude = clampLatitude(settings.spawnLatitude()); + double spawnLongitude = clampLongitude(settings.spawnLongitude()); + ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude, spawnLatitude, spawnLongitude)); return 1; } else { source.sendFailure(Component.literal("Tellus: GeoTP map is only available in Tellus worlds.")); diff --git a/mc1211/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java b/mc1211/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java index 39a4505bd..1e56c53d0 100644 --- a/mc1211/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java +++ b/mc1211/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java @@ -463,16 +463,21 @@ private static OsmBuildingMetadata resolveMetadata(Map tags, dou firstNonBlank(tags, "@name", "name"), floorCount, firstNonBlank(tags, "roof_shape", "roof:shape"), - firstNonBlank(tags, "roof_material", "roof:material", "roof:colour", "roof_color") + resolveRoofLevels(tags), + resolveRoofHeightMeters(tags), + firstNonBlank(tags, "roof_material", "roof:material"), + firstNonBlank(tags, "wall_material", "building_material", "building:material", "facade_material", "facade:material", "material"), + firstNonBlank(tags, "roof_color", "roof_colour", "roof:color", "roof:colour"), + firstNonBlank(tags, "wall_color", "wall_colour", "building_color", "building_colour", "building:color", "building:colour", "facade_color", "facade_colour", "facade:color", "facade:colour", "color", "colour") ); } private static double resolveFootprintHeightMeters(Map tags) { - Double height = parseDouble(tags.get("height")); + Double height = parseDouble(firstNonBlank(tags, "height", "building_height", "building:height")); if (height != null && height > 0.0) { return height; } else { - Double floors = parseDouble(tags.get("num_floors")); + Double floors = parseDouble(firstNonBlank(tags, "num_floors", "floors", "building_levels", "building:levels", "level")); if (floors != null && floors > 0.0) { return floors * 3.2; } else { @@ -482,11 +487,11 @@ private static double resolveFootprintHeightMeters(Map tags) { } private static double resolvePartHeightMeters(Map tags) { - Double height = parseDouble(tags.get("height")); + Double height = parseDouble(firstNonBlank(tags, "height", "building_height", "building:height")); if (height != null && height > 0.0) { return height; } else { - Double floors = parseDouble(tags.get("num_floors")); + Double floors = parseDouble(firstNonBlank(tags, "num_floors", "floors", "building_levels", "building:levels", "level")); if (floors != null && floors > 0.0) { return floors * 3.2; } else { @@ -496,11 +501,11 @@ private static double resolvePartHeightMeters(Map tags) { } private static double resolveMinHeightMeters(Map tags) { - Double minHeight = parseDouble(tags.get("min_height")); + Double minHeight = parseDouble(firstNonBlank(tags, "min_height", "min:height", "building:min_height")); if (minHeight != null && minHeight > 0.0) { return minHeight; } else { - Double minFloor = parseDouble(tags.get("min_floor")); + Double minFloor = parseDouble(firstNonBlank(tags, "min_floor", "min_level", "building:min_level")); return minFloor != null && minFloor > 0.0 ? minFloor * 3.2 : 0.0; } } @@ -520,6 +525,16 @@ private static int resolveFloorCount(Map tags, double heightMete return Math.max(1, (int)Math.round(heightMeters / 3.2)); } + private static int resolveRoofLevels(Map tags) { + Double levels = parseDouble(firstNonBlank(tags, "roof_levels", "roof:levels")); + return levels != null && levels > 0.0 ? Math.max(0, (int)Math.round(levels)) : 0; + } + + private static double resolveRoofHeightMeters(Map tags) { + Double height = parseDouble(firstNonBlank(tags, "roof_height", "roof:height")); + return height != null && height > 0.0 ? height : 0.0; + } + private static List> decodePolygonRings(List geometry) { if (geometry != null && !geometry.isEmpty()) { List> rings = new ArrayList<>(); @@ -777,7 +792,7 @@ private static Double parseDouble(Object value) { return null; } else { try { - return Double.parseDouble(text.trim()); + return Double.parseDouble(extractNumericPrefix(text)); } catch (NumberFormatException error) { return null; } @@ -785,6 +800,24 @@ private static Double parseDouble(Object value) { } } + private static String extractNumericPrefix(String value) { + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + return seenDigit ? number.toString() : normalized; + } + private static boolean isTruthy(String value) { if (value == null) { return false; diff --git a/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java b/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java index b90809f74..f888aabd3 100644 --- a/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java +++ b/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java @@ -3,6 +3,15 @@ import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; +import com.yucareux.tellus.world.data.integration.ExternalAreaFeature; +import com.yucareux.tellus.world.data.integration.ExternalAreaKind; +import com.yucareux.tellus.world.data.integration.TellusExternalFeatureSource; +import com.yucareux.tellus.world.data.integration.ExternalLineFeature; +import com.yucareux.tellus.world.data.integration.ExternalLineKind; +import com.yucareux.tellus.world.data.integration.ExternalPointFeature; +import com.yucareux.tellus.world.data.integration.ExternalPointKind; +import com.yucareux.tellus.world.data.integration.GeoPoint; import com.yucareux.tellus.world.data.cover.TellusLandCoverSource; import com.yucareux.tellus.world.data.elevation.TellusElevationSource; import com.yucareux.tellus.world.data.koppen.TellusKoppenSource; @@ -27,6 +36,8 @@ import com.yucareux.tellus.worldgen.building.TellusBuildingProfiles; import com.yucareux.tellus.worldgen.caves.TellusNoiseSettingsAdapter; import com.yucareux.tellus.worldgen.caves.TellusVanillaCarverRunner; +import com.yucareux.tellus.worldgen.vegetation.ArnisTreeGenerator; +import com.yucareux.tellus.worldgen.vegetation.ArnisTreeType; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongSet; @@ -145,6 +156,7 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final TellusLandMaskSource LAND_MASK_SOURCE = TellusWorldgenSources.landMask(); private static final TellusOsmRoadSource OSM_ROAD_SOURCE = TellusWorldgenSources.osmRoads(); private static final TellusOsmBuildingSource OSM_BUILDING_SOURCE = TellusWorldgenSources.osmBuildings(); + private static final TellusExternalFeatureSource EXTERNAL_FEATURE_SOURCE = TellusExternalFeatureSource.createDefault(); private static final TellusOsmSandSource OSM_SAND_SOURCE = TellusWorldgenSources.osmSand(); private static final double ESA_WORLD_COVER_RESOLUTION_METERS = 10.0; private static final int ESA_NO_DATA = 0; @@ -161,12 +173,18 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final int OSM_ROAD_QUERY_MARGIN = 64; private static final int OSM_BUILDING_MAX_SCALE = 15; private static final int OSM_BUILDING_QUERY_MARGIN = 8; + private static final int OSM_CITY_DETAIL_MAX_SCALE = 15; + private static final int OSM_CITY_DETAIL_QUERY_MARGIN = 32; private static final int OSM_ROAD_CLASS_SEPARATION = 0; private static final int OSM_ROAD_BRIDGE_LEVEL_HEIGHT = intProperty("tellus.osm.roads.bridgeLevelHeight", 3, 1, 16); private static final int OSM_ROAD_BRIDGE_MAX_RISE = intProperty("tellus.osm.roads.bridgeMaxRise", 10, 1, 64); private static final int OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL = intProperty("tellus.osm.roads.bridgeRampHorizontalPerVertical", 4, 1, 32); private static final int OSM_TUNNEL_SIDE_CLEARANCE = 3; private static final int OSM_TUNNEL_INTERNAL_HEIGHT = 7; + private static final int OSM_ROAD_MAX_TAGGED_WIDTH = intProperty("tellus.osm.roads.maxTaggedWidth", 18, 1, 64); + private static final byte ROAD_SURFACE_DEFAULT = 0; + private static final byte ROAD_SURFACE_UNPAVED = 1; + private static final byte ROAD_SURFACE_PAVED_PATH = 2; private static final int OCEAN_MONUMENT_SAMPLE_STEP = 8; private static final int OCEAN_MONUMENT_MARGIN = 8; private static final int OCEAN_MONUMENT_CORE_INSET = 8; @@ -180,11 +198,31 @@ public final class EarthChunkGenerator extends ChunkGenerator { private static final BlockState ROAD_NORMAL_STATE = Blocks.CYAN_TERRACOTTA.defaultBlockState(); private static final BlockState ROAD_DIRT_STATE = Blocks.DIRT_PATH.defaultBlockState(); + private static final BlockState ROAD_LANE_MARK_STATE = Blocks.WHITE_CONCRETE.defaultBlockState(); + private static final BlockState ROAD_SIDEWALK_STATE = Blocks.SMOOTH_STONE.defaultBlockState(); private static final BlockState BRIDGE_SUPPORT_SHAFT_STATE = Blocks.QUARTZ_PILLAR.defaultBlockState(); private static final BlockState BRIDGE_SUPPORT_CAP_STATE = Blocks.QUARTZ_BRICKS.defaultBlockState(); private static final BlockState ROAD_LIGHT_BASE_STATE = Blocks.STONE_BRICK_WALL.defaultBlockState(); private static final BlockState ROAD_LIGHT_FENCE_STATE = Blocks.OAK_FENCE.defaultBlockState(); private static final BlockState ROAD_LIGHT_GLOW_STATE = Blocks.GLOWSTONE.defaultBlockState(); + private static final BlockState CITY_PARKING_STATE = Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + private static final BlockState CITY_PARKING_DRIVE_STATE = Blocks.GRAY_CONCRETE.defaultBlockState(); + private static final BlockState CITY_PARKING_MARK_STATE = Blocks.WHITE_CONCRETE.defaultBlockState(); + private static final BlockState CITY_TRACK_STATE = Blocks.RED_TERRACOTTA.defaultBlockState(); + private static final BlockState CITY_PLAYGROUND_STATE = Blocks.ORANGE_TERRACOTTA.defaultBlockState(); + private static final BlockState CITY_BARRIER_FENCE_STATE = Blocks.OAK_FENCE.defaultBlockState(); + private static final BlockState CITY_BARRIER_WALL_STATE = Blocks.COBBLESTONE_WALL.defaultBlockState(); + private static final BlockState CITY_BARRIER_HEDGE_STATE = Blocks.OAK_LEAVES.defaultBlockState(); + private static final BlockState CITY_BARRIER_RAIL_STATE = Blocks.IRON_BARS.defaultBlockState(); + private static final BlockState CITY_RAIL_STATE = Blocks.RAIL.defaultBlockState(); + private static final BlockState CITY_TRAFFIC_POLE_STATE = Blocks.IRON_BARS.defaultBlockState(); + private static final BlockState CITY_TRAFFIC_LIGHT_STATE = Blocks.REDSTONE_LAMP.defaultBlockState(); + private static final BlockState CITY_BENCH_STATE = Blocks.OAK_SLAB.defaultBlockState(); + private static final BlockState CITY_FOUNTAIN_BASE_STATE = Blocks.STONE_BRICKS.defaultBlockState(); + private static final BlockState CITY_SHRUB_STATE = Blocks.OAK_LEAVES.defaultBlockState(); + private static final BlockState CITY_FERN_STATE = Blocks.FERN.defaultBlockState(); + private static final BlockState CITY_ROCK_STATE = Blocks.COBBLESTONE.defaultBlockState(); + private static final BlockState CITY_DEAD_BUSH_STATE = Blocks.DEAD_BUSH.defaultBlockState(); private static final BlockState BUILDING_BOOKSHELF_STATE = Blocks.BOOKSHELF.defaultBlockState(); private static final BlockState BUILDING_BARREL_STATE = Blocks.BARREL.defaultBlockState(); private static final BlockState BUILDING_CRAFTING_STATE = Blocks.CRAFTING_TABLE.defaultBlockState(); @@ -388,6 +426,17 @@ public EarthChunkGenerator(BiomeSource biomeSource, EarthGeneratorSettings setti } ); } + TellusDiagnostics.worldgen( + "EarthChunkGenerator init: scale=%s minAltitude=%d maxAltitude=%d heightOffset=%d limits=[minY=%d,height=%d,logicalHeight=%d] seaLevel=%d", + settings.worldScale(), + settings.minAltitude(), + settings.maxAltitude(), + settings.heightOffset(), + limits.minY(), + limits.height(), + limits.logicalHeight(), + this.seaLevel + ); } public static EarthChunkGenerator create(Provider registries, EarthGeneratorSettings settings) { @@ -554,7 +603,7 @@ private boolean shouldDeferBuildingDetails() { return !CHUNK_DETAIL_LEGACY_BLOCKING && CHUNK_DETAIL_DEFER_BUILDINGS && this.settings.enableBuildings() - && OSM_BUILDING_SOURCE.available() + && this.buildingSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; } @@ -571,6 +620,14 @@ private boolean shouldUseStructureOsmSyncFallback() { return CHUNK_DETAIL_LEGACY_BLOCKING; } + private boolean roadSourcesAvailable() { + return OSM_ROAD_SOURCE.available() || EXTERNAL_FEATURE_SOURCE.roadsAvailable(); + } + + private boolean buildingSourcesAvailable() { + return OSM_BUILDING_SOURCE.available() || EXTERNAL_FEATURE_SOURCE.buildingsAvailable(); + } + protected MapCodec codec() { return Objects.requireNonNull(CODEC, "CODEC"); @@ -682,6 +739,12 @@ public void applyBiomeDecoration( WorldGenLevel level, ChunkAccess chunk, Stru endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.DECORATION_BUILDINGS, phaseStartNs); } + if (!delayTellusDecoration && !this.shouldDeferRoadDetails() && !this.shouldDeferBuildingDetails()) { + phaseStartNs = beginFullChunkProfiling(); + this.applyExternalCityDetails(level, chunk); + endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.DECORATION_CITY_DETAILS, phaseStartNs); + } + phaseStartNs = beginFullChunkProfiling(); if (!delayTellusDecoration) { this.applyRealtimeSnowCover(level, chunk); @@ -816,6 +879,19 @@ private void fillTellusSurface( RandomState random, StructureManager structures this.settings.maxAltitude() } ); + TellusDiagnostics.worldgen( + "fillFromNoise layout: chunkPos=%s minY=%d height=%d maxY=%d sections=%d genMinY=%d genHeight=%d seaLevel=%d settingsMinAlt=%d settingsMaxAlt=%d", + pos, + chunkMinY, + chunkHeight, + chunkMinY + chunkHeight - 1, + chunkHeight >> 4, + this.minY, + this.height, + this.seaLevel, + this.settings.minAltitude(), + this.settings.maxAltitude() + ); } int deepslateStart = this.minY + 64; @@ -915,7 +991,7 @@ private void fillTellusSurface( RandomState random, StructureManager structures int[] convexities = new int[CHUNK_AREA]; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = useFastFullChunk && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(useFastFullChunk, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; phaseStartNs = beginFullChunkProfiling(); @@ -1029,8 +1105,8 @@ private void fillTellusSurface( RandomState random, StructureManager structures } endFullChunkProfiling(EarthChunkGenerator.FullChunkPhase.FILL_BLOCKS_SOLID_SECTIONS, solidSectionsStartNs); - for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { - int worldZ = chunkMinZ + localZ; + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + int worldZ = chunkMinZ + localZ; int rowIndex = localZ * CHUNK_SIDE; for (int localX = 0; localX < CHUNK_SIDE; localX++) { @@ -1299,12 +1375,16 @@ private void applyOsmRoadOverlay( scratch.ensureRoadExtCapacity(extArea); byte[] resolvedClass = scratch.resolvedClass; byte[] resolvedMode = scratch.resolvedMode; + byte[] resolvedSurface = scratch.resolvedSurface; int[] resolvedDeckY = scratch.resolvedDeckY; + int[] resolvedWidth = scratch.resolvedWidth; boolean[] resolvedTunnelCarve = scratch.resolvedTunnelCarve; boolean[] blockedByHigherClass = scratch.blockedByHigherClass; boolean[] bridgeOverlayPresent = scratch.bridgeOverlayPresent; int[] bridgeOverlayDeckY = scratch.bridgeOverlayDeckY; byte[] bridgeOverlayClass = scratch.bridgeOverlayClass; + byte[] bridgeOverlaySurface = scratch.bridgeOverlaySurface; + int[] bridgeOverlayWidth = scratch.bridgeOverlayWidth; boolean[] bridgeSupportShaftPresent = scratch.bridgeSupportShaftPresent; int[] bridgeSupportShaftBottomY = scratch.bridgeSupportShaftBottomY; int[] bridgeSupportShaftTopY = scratch.bridgeSupportShaftTopY; @@ -1333,12 +1413,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1360,12 +1444,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1387,12 +1475,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1480,10 +1572,12 @@ private void applyOsmRoadOverlay( byte[] chunkRoadClass = scratch.chunkRoadClass; byte[] chunkRoadMode = scratch.chunkRoadMode; int[] chunkRoadDeckY = scratch.chunkRoadDeckY; + int[] chunkRoadWidth = scratch.chunkRoadWidth; boolean[] chunkTunnelNeedsCarve = scratch.chunkTunnelNeedsCarve; Arrays.fill(chunkRoadClass, (byte)0); Arrays.fill(chunkRoadMode, (byte)0); Arrays.fill(chunkRoadDeckY, 0); + Arrays.fill(chunkRoadWidth, 0); Arrays.fill(chunkTunnelNeedsCarve, false); MutableBlockPos cursor = new MutableBlockPos(); @@ -1500,11 +1594,12 @@ private void applyOsmRoadOverlay( int worldX = chunkMinX + localX; int worldZ = chunkMinZ + localZ; cursor.set(worldX, deckY, worldZ); - this.setChunkBlock(level, chunk, cursor, roadStateForClass(roadClassFromId(classId))); + this.setChunkBlock(level, chunk, cursor, roadStateForOverlay(roadClassFromId(classId), resolvedSurface[extIndex])); int chunkIndex = chunkIndex(localX, localZ); chunkRoadClass[chunkIndex] = (byte)classId; chunkRoadMode[chunkIndex] = resolvedMode[extIndex]; chunkRoadDeckY[chunkIndex] = deckY; + chunkRoadWidth[chunkIndex] = resolvedWidth[extIndex]; chunkTunnelNeedsCarve[chunkIndex] = resolvedTunnelCarve[extIndex]; } } @@ -1524,12 +1619,65 @@ private void applyOsmRoadOverlay( int worldX = chunkMinX + localXx; int worldZ = chunkMinZ + localZ; cursor.set(worldX, deckY, worldZ); - this.setChunkBlock(level, chunk, cursor, roadStateForClass(roadClassFromId(classId))); + this.setChunkBlock(level, chunk, cursor, roadStateForOverlay(roadClassFromId(classId), bridgeOverlaySurface[extIndex])); } } } } + this.paintRoadSidewalks( + level, + chunk, + roads, + widths, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + + this.paintRoadLaneMarkings( + level, + chunk, + roads, + widths, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + + this.paintBridgeEdgeRails( + level, + chunk, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + bridgeOverlayWidth + ); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { for (int localX = 0; localX < CHUNK_SIDE; localX++) { int extIndex = extIndex(localX + padding, localZ + padding, extSide); @@ -1574,7 +1722,7 @@ private void applyOsmRoadOverlay( for (int localXxx = 0; localXxx < CHUNK_SIDE; localXxx++) { int centerIndex = chunkIndex(localXxx, localZ); if (chunkRoadClass[centerIndex] > 0 && chunkRoadMode[centerIndex] == tunnelModeId && chunkTunnelNeedsCarve[centerIndex]) { - int roadWidth = classWidths[chunkRoadClass[centerIndex]]; + int roadWidth = chunkRoadWidth[centerIndex] > 0 ? chunkRoadWidth[centerIndex] : classWidths[chunkRoadClass[centerIndex]]; int carveWidth = roadWidth + OSM_TUNNEL_SIDE_CLEARANCE * 2; double carveRadius = Math.max(0.5, (carveWidth - 1) * 0.5); int radius = Mth.ceil(carveRadius); @@ -1632,10 +1780,21 @@ private void applyOsmRoadOverlay( } } } - } - } - - EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights = this.prepareRoadLightsForChunk( + } + } + + this.paintTunnelShell( + level, + chunk, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + tunnelCarveMask, + tunnelCarveDeckY + ); + + EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights = this.prepareRoadLightsForChunk( pos, roads, widths, @@ -1680,29 +1839,40 @@ private void rasterizeRoadClassPass( Long2ObjectOpenHashMap edgeColumnCache, byte[] resolvedClass, byte[] resolvedMode, + byte[] resolvedSurface, int[] resolvedDeckY, + int[] resolvedWidth, boolean[] resolvedTunnelCarve, boolean[] blockedByHigherClass, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass, + byte[] bridgeOverlaySurface, + int[] bridgeOverlayWidth, EarthChunkGenerator.OsmOverlayScratch scratch, int extArea ) { if (!roads.isEmpty() && roadWidth > 0) { boolean[] candidatePresent = scratch.candidatePresent; int[] candidateDeckY = scratch.candidateDeckY; + int[] candidateWidth = scratch.candidateWidth; byte[] candidateMode = scratch.candidateMode; + byte[] candidateSurface = scratch.candidateSurface; boolean[] candidateTunnelCarve = scratch.candidateTunnelCarve; boolean[] bridgeCandidatePresent = scratch.bridgeCandidatePresent; int[] bridgeCandidateDeckY = scratch.bridgeCandidateDeckY; + int[] bridgeCandidateWidth = scratch.bridgeCandidateWidth; + byte[] bridgeCandidateSurface = scratch.bridgeCandidateSurface; scratch.clearRoadCandidateState(extArea); for (RoadFeature road : roads) { + int featureRoadWidth = roadWidthForFeature(road, roadWidth); + byte featureSurface = roadSurfaceId(road); if (road.mode() == RoadMode.BRIDGE) { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1717,13 +1887,16 @@ private void rasterizeRoadClassPass( edgeColumnCache, bridgeCandidatePresent, bridgeCandidateDeckY, + bridgeCandidateWidth, null, + bridgeCandidateSurface, null ); } else { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1738,7 +1911,9 @@ private void rasterizeRoadClassPass( edgeColumnCache, candidatePresent, candidateDeckY, + candidateWidth, candidateMode, + candidateSurface, candidateTunnelCarve ); } @@ -1748,7 +1923,18 @@ private void rasterizeRoadClassPass( for (int index = 0; index < extArea; index++) { if (bridgeCandidatePresent[index]) { - mergeBridgeOverlay(index, classId, bridgeCandidateDeckY[index], bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass); + mergeBridgeOverlay( + index, + classId, + bridgeCandidateSurface[index], + bridgeCandidateWidth[index], + bridgeCandidateDeckY[index], + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth + ); } } @@ -1759,7 +1945,9 @@ private void rasterizeRoadClassPass( if (candidatePresent[indexx] && !blockedByHigherClass[indexx]) { resolvedClass[indexx] = (byte)classId; resolvedMode[indexx] = candidateMode[indexx]; + resolvedSurface[indexx] = candidateSurface[indexx]; resolvedDeckY[indexx] = candidateDeckY[indexx]; + resolvedWidth[indexx] = candidateWidth[indexx]; resolvedTunnelCarve[indexx] = candidateTunnelCarve[indexx]; placed[placedCount++] = indexx; } @@ -1788,6 +1976,7 @@ private void rasterizeRoadClassPass( private void rasterizeRoadFeature( RoadFeature road, int roadWidth, + byte roadSurface, double blocksPerDegree, int extMinX, int extMinZ, @@ -1802,7 +1991,9 @@ private void rasterizeRoadFeature( Long2ObjectOpenHashMap edgeColumnCache, boolean[] candidatePresent, int[] candidateDeckY, + int[] candidateWidth, byte[] candidateMode, + byte[] candidateSurface, boolean[] candidateTunnelCarve ) { int pointCount = road.pointCount(); @@ -1924,6 +2115,8 @@ private void rasterizeRoadFeature( if (replaceCandidate) { candidatePresent[extIndex] = true; candidateDeckY[extIndex] = deckY; + candidateWidth[extIndex] = roadWidth; + candidateSurface[extIndex] = roadSurface; if (candidateMode != null && candidateTunnelCarve != null) { candidateMode[extIndex] = (byte)(road.mode().ordinal() + 1); candidateTunnelCarve[extIndex] = tunnelNeedsCarve; @@ -1942,124 +2135,566 @@ private void rasterizeRoadFeature( } } - private EarthChunkGenerator.RoadColumnSample sampleRoadColumnForOverlay( - int worldX, - int worldZ, + private void paintRoadSidewalks( + WorldGenLevel level, + ChunkAccess chunk, + List roads, + EarthChunkGenerator.RoadWidths widths, int chunkMinX, int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass ) { - if (worldX >= chunkMinX && worldX < chunkMinX + CHUNK_SIDE && worldZ >= chunkMinZ && worldZ < chunkMinZ + CHUNK_SIDE) { - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - int index = chunkIndex(localX, localZ); - return new EarthChunkGenerator.RoadColumnSample( - terrainSurfaces[index], waterSurfaces[index], waterFlags[index] && waterSurfaces[index] > terrainSurfaces[index] - ); - } else { - long packed = packColumn(worldX, worldZ); - EarthChunkGenerator.RoadColumnSample cached = (EarthChunkGenerator.RoadColumnSample)edgeColumnCache.get(packed); - if (cached != null) { - return cached; - } else { - WaterSurfaceResolver.WaterColumnData column = this.resolveAuxWaterColumn(worldX, worldZ); - EarthChunkGenerator.RoadColumnSample sampled = new EarthChunkGenerator.RoadColumnSample( - column.terrainSurface(), column.waterSurface(), column.hasWater() && column.waterSurface() > column.terrainSurface() - ); - edgeColumnCache.put(packed, sampled); - return sampled; + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + + for (RoadFeature road : roads) { + if (!road.hasSidewalk() || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; } - } - } - private static EarthChunkGenerator.RoadWidths resolveRoadWidths(double worldScale) { - double factor = roadWidthFactorForScale(worldScale); + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } - return new EarthChunkGenerator.RoadWidths( - widthForScale(RoadClass.MAIN.baseWidth(), factor), - widthForScale(RoadClass.NORMAL.baseWidth(), factor), - widthForScale(RoadClass.DIRT.baseWidth(), factor) - ); - } + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double sidewalkOffset = Math.max(0.5, roadWidth * 0.5 - 0.5); + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + for (int point = 1; point < road.pointCount(); point++) { + double currentX = road.lonAt(point) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(point), worldScale); + double dx = currentX - previousX; + double dz = currentZ - previousZ; + double segmentLength = Math.sqrt(dx * dx + dz * dz); + if (segmentLength <= 1.0E-6) { + previousX = currentX; + previousZ = currentZ; + continue; + } - private static int widthForScale(int baseWidth, double factor) { - return Math.max(1, (int)Math.round(baseWidth * factor)); - } + double tangentX = dx / segmentLength; + double tangentZ = dz / segmentLength; + double normalX = -tangentZ; + double normalZ = tangentX; + for (double station = 0.0; station <= segmentLength; station += 0.75) { + double centerX = previousX + tangentX * station; + double centerZ = previousZ + tangentZ * station; + if (road.hasLeftSidewalk()) { + this.paintRoadEdgeBlock( + level, + chunk, + centerX, + centerZ, + normalX, + normalZ, + -sidewalkOffset, + road.mode(), + roadClassId, + roadModeId, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + cursor, + ROAD_SIDEWALK_STATE + ); + } + if (road.hasRightSidewalk()) { + this.paintRoadEdgeBlock( + level, + chunk, + centerX, + centerZ, + normalX, + normalZ, + sidewalkOffset, + road.mode(), + roadClassId, + roadModeId, + chunkMinX, + chunkMinZ, + chunkMinY, + chunkMaxY, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass, + cursor, + ROAD_SIDEWALK_STATE + ); + } + } - private static double roadWidthFactorForScale(double worldScale) { - if (!(worldScale > 0.0)) { - return 0.25; - } else if (worldScale <= 1.0) { - return 1.8; - } else if (worldScale <= 5.0) { - double t = (worldScale - 1.0) / 4.0; - return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.8, 1.0); - } else if (worldScale <= 10.0) { - double t = (worldScale - 5.0) / 5.0; - return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.0, 0.5); - } else { - return 0.25; + previousX = currentX; + previousZ = currentZ; + } } } - private static int bridgeRiseAtStation(double station, double totalLength, int bridgeLevel) { - int requestedRise = Math.max(0, bridgeLevel) * OSM_ROAD_BRIDGE_LEVEL_HEIGHT; - requestedRise = Math.min(requestedRise, OSM_ROAD_BRIDGE_MAX_RISE); - if (requestedRise > 0 && !(totalLength <= 1.0E-6)) { - double maxRiseByLength = totalLength / (2.0 * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL); - int targetRise = Math.min(requestedRise, Math.max(0, (int)Math.floor(maxRiseByLength))); - if (targetRise <= 0) { - return 0; - } else { - double clampedStation = Mth.clamp(station, 0.0, totalLength); - double rampLength = targetRise * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; - double rise; - if (totalLength >= rampLength * 2.0) { - if (clampedStation < rampLength) { - rise = targetRise * (clampedStation / rampLength); - } else if (clampedStation > totalLength - rampLength) { - rise = targetRise * ((totalLength - clampedStation) / rampLength); - } else { - rise = targetRise; + private void paintBridgeEdgeRails( + WorldGenLevel level, + ChunkAccess chunk, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + int[] bridgeOverlayWidth + ) { + MutableBlockPos cursor = new MutableBlockPos(); + int flags = this.detailApplyFlags(level); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + int extIndex = extIndex(localX + padding, localZ + padding, extSide); + if (!bridgeOverlayPresent[extIndex] || bridgeOverlayClass[extIndex] <= 0 || bridgeOverlayWidth[extIndex] < 3) { + continue; + } + + int deckY = bridgeOverlayDeckY[extIndex]; + boolean edge = false; + for (Direction direction : Direction.Plane.HORIZONTAL) { + int neighborLocalX = localX + padding + direction.getStepX(); + int neighborLocalZ = localZ + padding + direction.getStepZ(); + if (neighborLocalX < 0 || neighborLocalX >= extSide || neighborLocalZ < 0 || neighborLocalZ >= extSide) { + edge = true; + break; } - } else { - double half = totalLength * 0.5; - if (half <= 1.0E-6) { - rise = targetRise; - } else if (clampedStation <= half) { - rise = targetRise * (clampedStation / half); - } else { - rise = targetRise * ((totalLength - clampedStation) / half); + + int neighborExt = extIndex(neighborLocalX, neighborLocalZ, extSide); + if (!bridgeOverlayPresent[neighborExt] + || bridgeOverlayClass[neighborExt] <= 0 + || Math.abs(bridgeOverlayDeckY[neighborExt] - deckY) > 1) { + edge = true; + break; } } + if (!edge) { + continue; + } - return Math.max(0, (int)Math.round(Mth.clamp(rise, 0.0, targetRise))); + int railY = Mth.clamp(deckY + 1, chunkMinY, chunkMaxY); + int worldX = chunkMinX + localX; + int worldZ = chunkMinZ + localZ; + cursor.set(worldX, railY, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, CITY_BARRIER_RAIL_STATE); + if (Math.floorMod(worldX + worldZ, 6) == 0 && railY + 1 <= chunkMaxY) { + cursor.set(worldX, railY + 1, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, Blocks.STONE_BRICK_SLAB.defaultBlockState()); + } + } + } } - } else { - return 0; } } - private static int bridgeDeckBaselineAtStation(double station, double totalLength, int startSurface, int endSurface) { - return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); - } - - private static int bridgeDeckYAtStation( - double station, - double totalLength, - int startSurface, - int endSurface, - int localRoadSurface, - int bridgeLevel, - RoadClass roadClass, - double worldScale + private void paintTunnelShell( + WorldGenLevel level, + ChunkAccess chunk, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + boolean[] tunnelCarveMask, + int[] tunnelCarveDeckY ) { - int baseline = bridgeDeckBaselineAtStation(station, totalLength, startSurface, endSurface); - int rise = bridgeRiseAtStation(station, totalLength, bridgeLevel); - int clearance = bridgeClearanceAtStation(station, totalLength, roadClass, worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + int chunkIndex = chunkIndex(localX, localZ); + if (!tunnelCarveMask[chunkIndex]) { + continue; + } + + int deckY = tunnelCarveDeckY[chunkIndex]; + if (deckY < chunkMinY || deckY >= chunkMaxY) { + continue; + } + + int worldX = chunkMinX + localX; + int worldZ = chunkMinZ + localZ; + int topY = Math.min(chunkMaxY, deckY + OSM_TUNNEL_INTERNAL_HEIGHT); + for (Direction direction : Direction.Plane.HORIZONTAL) { + int neighborLocalX = localX + direction.getStepX(); + int neighborLocalZ = localZ + direction.getStepZ(); + if (neighborLocalX < 0 || neighborLocalX >= CHUNK_SIDE || neighborLocalZ < 0 || neighborLocalZ >= CHUNK_SIDE) { + continue; + } + int neighborIndex = chunkIndex(neighborLocalX, neighborLocalZ); + if (tunnelCarveMask[neighborIndex] && Math.abs(tunnelCarveDeckY[neighborIndex] - deckY) <= 1) { + continue; + } + + int wallX = worldX + direction.getStepX(); + int wallZ = worldZ + direction.getStepZ(); + for (int y = deckY + 1; y <= topY; y++) { + cursor.set(wallX, y, wallZ); + if (isTunnelCarveReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, tunnelShellState(wallX, y, wallZ)); + } + } + } + + if (Math.floorMod(worldX * 31 + worldZ * 17, 13) == 0) { + cursor.set(worldX, topY, worldZ); + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, Blocks.SEA_LANTERN.defaultBlockState()); + } + } + } + } + } + + private static BlockState tunnelShellState(int worldX, int y, int worldZ) { + int roll = seededRandomInt(seedFromCoords(worldX, y, worldZ), 100); + if (roll < 12) { + return Blocks.CRACKED_STONE_BRICKS.defaultBlockState(); + } + if (roll < 16) { + return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); + } + return Blocks.STONE_BRICKS.defaultBlockState(); + } + + private void paintRoadLaneMarkings( + WorldGenLevel level, + ChunkAccess chunk, + List roads, + EarthChunkGenerator.RoadWidths widths, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass + ) { + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + + for (RoadFeature road : roads) { + int lanes = road.laneCount(); + if (lanes < 2 || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; + } + + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } + + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double laneWidth = roadWidth / (double)lanes; + if (laneWidth < 1.0) { + continue; + } + + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + double stationBase = 0.0; + for (int point = 1; point < road.pointCount(); point++) { + double currentX = road.lonAt(point) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(point), worldScale); + double dx = currentX - previousX; + double dz = currentZ - previousZ; + double segmentLength = Math.sqrt(dx * dx + dz * dz); + if (segmentLength <= 1.0E-6) { + previousX = currentX; + previousZ = currentZ; + continue; + } + + double tangentX = dx / segmentLength; + double tangentZ = dz / segmentLength; + double normalX = -tangentZ; + double normalZ = tangentX; + for (double station = 0.0; station <= segmentLength; station += 1.0) { + double globalStation = stationBase + station; + if (((int)Math.floor(globalStation / 5.0)) % 2 != 0) { + continue; + } + + double centerX = previousX + tangentX * station; + double centerZ = previousZ + tangentZ * station; + for (int laneBoundary = 1; laneBoundary < lanes; laneBoundary++) { + double offset = -roadWidth * 0.5 + laneWidth * laneBoundary; + int worldX = Mth.floor(centerX + normalX * offset + 0.5); + int worldZ = Mth.floor(centerZ + normalZ * offset + 0.5); + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (localX < 0 || localX >= CHUNK_SIDE || localZ < 0 || localZ >= CHUNK_SIDE) { + continue; + } + + int deckY = laneMarkDeckY( + road.mode(), + roadClassId, + roadModeId, + localX, + localZ, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + if (deckY < chunkMinY || deckY > chunkMaxY) { + continue; + } + + this.paintRoadDeckBlock(level, chunk, cursor, worldX, deckY, worldZ, ROAD_LANE_MARK_STATE); + } + } + + stationBase += segmentLength; + previousX = currentX; + previousZ = currentZ; + } + } + } + + private static int laneMarkDeckY( + RoadMode roadMode, + int roadClassId, + int roadModeId, + int localX, + int localZ, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass + ) { + if (roadMode == RoadMode.BRIDGE) { + int extIndex = extIndex(localX + padding, localZ + padding, extSide); + return bridgeOverlayPresent[extIndex] && bridgeOverlayClass[extIndex] == roadClassId ? bridgeOverlayDeckY[extIndex] : Integer.MIN_VALUE; + } + + int chunkIndex = chunkIndex(localX, localZ); + return chunkRoadClass[chunkIndex] == roadClassId && chunkRoadMode[chunkIndex] == roadModeId ? chunkRoadDeckY[chunkIndex] : Integer.MIN_VALUE; + } + + private void paintRoadEdgeBlock( + WorldGenLevel level, + ChunkAccess chunk, + double centerX, + double centerZ, + double normalX, + double normalZ, + double offset, + RoadMode roadMode, + int roadClassId, + int roadModeId, + int chunkMinX, + int chunkMinZ, + int chunkMinY, + int chunkMaxY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + MutableBlockPos cursor, + BlockState state + ) { + int worldX = Mth.floor(centerX + normalX * offset + 0.5); + int worldZ = Mth.floor(centerZ + normalZ * offset + 0.5); + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (localX < 0 || localX >= CHUNK_SIDE || localZ < 0 || localZ >= CHUNK_SIDE) { + return; + } + + int deckY = laneMarkDeckY( + roadMode, + roadClassId, + roadModeId, + localX, + localZ, + padding, + extSide, + chunkRoadClass, + chunkRoadMode, + chunkRoadDeckY, + bridgeOverlayPresent, + bridgeOverlayDeckY, + bridgeOverlayClass + ); + if (deckY >= chunkMinY && deckY <= chunkMaxY) { + this.paintRoadDeckBlock(level, chunk, cursor, worldX, deckY, worldZ, state); + } + } + + private void paintRoadDeckBlock(WorldGenLevel level, ChunkAccess chunk, MutableBlockPos cursor, int worldX, int deckY, int worldZ, BlockState state) { + cursor.set(worldX, deckY, worldZ); + if (isRoadDeckState(chunk.getBlockState(cursor))) { + this.setChunkBlock(level, chunk, cursor, state); + } + } + + private EarthChunkGenerator.RoadColumnSample sampleRoadColumnForOverlay( + int worldX, + int worldZ, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + if (worldX >= chunkMinX && worldX < chunkMinX + CHUNK_SIDE && worldZ >= chunkMinZ && worldZ < chunkMinZ + CHUNK_SIDE) { + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + int index = chunkIndex(localX, localZ); + return new EarthChunkGenerator.RoadColumnSample( + terrainSurfaces[index], waterSurfaces[index], waterFlags[index] && waterSurfaces[index] > terrainSurfaces[index] + ); + } else { + long packed = packColumn(worldX, worldZ); + EarthChunkGenerator.RoadColumnSample cached = (EarthChunkGenerator.RoadColumnSample)edgeColumnCache.get(packed); + if (cached != null) { + return cached; + } else { + WaterSurfaceResolver.WaterColumnData column = this.resolveAuxWaterColumn(worldX, worldZ); + EarthChunkGenerator.RoadColumnSample sampled = new EarthChunkGenerator.RoadColumnSample( + column.terrainSurface(), column.waterSurface(), column.hasWater() && column.waterSurface() > column.terrainSurface() + ); + edgeColumnCache.put(packed, sampled); + return sampled; + } + } + } + + private static EarthChunkGenerator.RoadWidths resolveRoadWidths(double worldScale) { + double factor = roadWidthFactorForScale(worldScale); + + return new EarthChunkGenerator.RoadWidths( + widthForScale(RoadClass.MAIN.baseWidth(), factor), + widthForScale(RoadClass.NORMAL.baseWidth(), factor), + widthForScale(RoadClass.DIRT.baseWidth(), factor) + ); + } + + private static int widthForScale(int baseWidth, double factor) { + return Math.max(1, (int)Math.round(baseWidth * factor)); + } + + private static double roadWidthFactorForScale(double worldScale) { + if (!(worldScale > 0.0)) { + return 0.25; + } else if (worldScale <= 1.0) { + return 1.8; + } else if (worldScale <= 5.0) { + double t = (worldScale - 1.0) / 4.0; + return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.8, 1.0); + } else if (worldScale <= 10.0) { + double t = (worldScale - 5.0) / 5.0; + return Mth.lerp(Mth.clamp(t, 0.0, 1.0), 1.0, 0.5); + } else { + return 0.25; + } + } + + private static int bridgeRiseAtStation(double station, double totalLength, int bridgeLevel) { + int requestedRise = Math.max(0, bridgeLevel) * OSM_ROAD_BRIDGE_LEVEL_HEIGHT; + requestedRise = Math.min(requestedRise, OSM_ROAD_BRIDGE_MAX_RISE); + if (requestedRise > 0 && !(totalLength <= 1.0E-6)) { + double maxRiseByLength = totalLength / (2.0 * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL); + int targetRise = Math.min(requestedRise, Math.max(0, (int)Math.floor(maxRiseByLength))); + if (targetRise <= 0) { + return 0; + } else { + double clampedStation = Mth.clamp(station, 0.0, totalLength); + double rampLength = targetRise * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; + double rise; + if (totalLength >= rampLength * 2.0) { + if (clampedStation < rampLength) { + rise = targetRise * (clampedStation / rampLength); + } else if (clampedStation > totalLength - rampLength) { + rise = targetRise * ((totalLength - clampedStation) / rampLength); + } else { + rise = targetRise; + } + } else { + double half = totalLength * 0.5; + if (half <= 1.0E-6) { + rise = targetRise; + } else if (clampedStation <= half) { + rise = targetRise * (clampedStation / half); + } else { + rise = targetRise * ((totalLength - clampedStation) / half); + } + } + + return Math.max(0, (int)Math.round(Mth.clamp(rise, 0.0, targetRise))); + } + } else { + return 0; + } + } + + private static int bridgeDeckBaselineAtStation(double station, double totalLength, int startSurface, int endSurface) { + return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); + } + + private static int bridgeDeckYAtStation( + double station, + double totalLength, + int startSurface, + int endSurface, + int localRoadSurface, + int bridgeLevel, + RoadClass roadClass, + double worldScale + ) { + int baseline = bridgeDeckBaselineAtStation(station, totalLength, startSurface, endSurface); + int rise = bridgeRiseAtStation(station, totalLength, bridgeLevel); + int clearance = bridgeClearanceAtStation(station, totalLength, roadClass, worldScale); return Math.max(baseline + rise, localRoadSurface + clearance); } @@ -2149,18 +2784,31 @@ private static int modePriority(byte modeId) { } private static void mergeBridgeOverlay( - int index, int classId, int deckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass + int index, + int classId, + byte surfaceId, + int roadWidth, + int deckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + byte[] bridgeOverlayClass, + byte[] bridgeOverlaySurface, + int[] bridgeOverlayWidth ) { if (!bridgeOverlayPresent[index]) { bridgeOverlayPresent[index] = true; bridgeOverlayDeckY[index] = deckY; bridgeOverlayClass[index] = (byte)classId; + bridgeOverlaySurface[index] = surfaceId; + bridgeOverlayWidth[index] = roadWidth; } else { int existingDeck = bridgeOverlayDeckY[index]; int existingClass = bridgeOverlayClass[index]; if (deckY > existingDeck || deckY == existingDeck && classId < existingClass) { bridgeOverlayDeckY[index] = deckY; bridgeOverlayClass[index] = (byte)classId; + bridgeOverlaySurface[index] = surfaceId; + bridgeOverlayWidth[index] = roadWidth; } } } @@ -2198,6 +2846,7 @@ private void rasterizeBridgeSupports( double worldScale = EarthProjection.worldScaleFromBlocksPerDegree(blocksPerDegree); for (RoadFeature road : roads) { + int featureRoadWidth = roadWidthForFeature(road, roadWidth); EarthChunkGenerator.RoadColumnSample startColumn = this.sampleRoadColumnForOverlay( Mth.floor(road.lonAt(0) * blocksPerDegree), Mth.floor(EarthProjection.latToBlockZ(road.latAt(0), worldScale)), @@ -2220,8 +2869,8 @@ private void rasterizeBridgeSupports( ); int startSurface = startColumn.roadSurface(); int endSurface = endColumn.roadSurface(); - BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), roadWidth); - BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, roadWidth, placement -> { + BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), featureRoadWidth); + BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, featureRoadWidth, placement -> { IntArrayList capCells = new IntArrayList(); IntArrayList[] shaftCells = new IntArrayList[style.shaftCount()]; int[] minTerrain = new int[style.shaftCount()]; @@ -2514,6 +3163,28 @@ private static BlockState roadStateForClass(RoadClass roadClass) { }; } + private static BlockState roadStateForOverlay(RoadClass roadClass, int surfaceId) { + return switch (surfaceId) { + case ROAD_SURFACE_UNPAVED -> ROAD_DIRT_STATE; + case ROAD_SURFACE_PAVED_PATH -> ROAD_SIDEWALK_STATE; + default -> roadStateForClass(roadClass); + }; + } + + private static byte roadSurfaceId(RoadFeature road) { + if (road.isUnpavedSurface()) { + return ROAD_SURFACE_UNPAVED; + } + return isPavedPedestrianRoad(road) ? ROAD_SURFACE_PAVED_PATH : ROAD_SURFACE_DEFAULT; + } + + private static boolean isPavedPedestrianRoad(RoadFeature road) { + boolean pedestrian = road.matchesHighwayTag("footway") + || road.matchesHighwayTag("pedestrian") + || road.matchesHighwayTag("cycleway"); + return pedestrian && (road.isPavedSurface() || road.surfaceTag().isEmpty()); + } + private static RoadClass roadClassFromId(int classId) { return switch (classId) { @@ -2539,6 +3210,21 @@ private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.Ro }; } + private static int roadWidthForFeature(RoadFeature road, int classWidth) { + int width = classWidth; + int lanes = road.laneCount(); + if (lanes > 0) { + int laneWidth = road.roadClass() == RoadClass.MAIN ? 3 : 2; + width = Math.max(width, lanes * laneWidth); + } + + if (road.hasSidewalk() && road.roadClass() != RoadClass.DIRT) { + width += 2; + } + + return Mth.clamp(width, 1, OSM_ROAD_MAX_TAGGED_WIDTH); + } + private static int roadLightSpacingBlocks(double worldScale) { if (!(worldScale > 0.0)) { return 40; @@ -2708,7 +3394,11 @@ private static BlockState roadLightTrapdoorState(Direction facing) { } private static boolean isRoadDeckState(BlockState state) { - return state.is(Blocks.GRAY_CONCRETE) || state.is(Blocks.CYAN_TERRACOTTA) || state.is(Blocks.DIRT_PATH); + return state.is(Blocks.GRAY_CONCRETE) + || state.is(Blocks.CYAN_TERRACOTTA) + || state.is(Blocks.DIRT_PATH) + || state.is(Blocks.WHITE_CONCRETE) + || state.is(Blocks.SMOOTH_STONE); } private static boolean isRoadLightReplaceable(BlockState state) { @@ -3454,8 +4144,8 @@ private boolean isWoodlandMansionStartTooSteep(StructureStart start) { private void filterStartsCollidingWithOsm(RegistryAccess registryAccess, ChunkAccess chunk) { double worldScale = this.settings.worldScale(); - boolean roadsActive = this.settings.enableRoads() && OSM_ROAD_SOURCE.available() && worldScale > 0.0 && worldScale <= OSM_ROAD_MAX_SCALE; - boolean buildingsActive = this.settings.enableBuildings() && OSM_BUILDING_SOURCE.available() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; + boolean roadsActive = this.settings.enableRoads() && this.roadSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_ROAD_MAX_SCALE; + boolean buildingsActive = this.settings.enableBuildings() && this.buildingSourcesAvailable() && worldScale > 0.0 && worldScale <= OSM_BUILDING_MAX_SCALE; if (roadsActive || buildingsActive) { Map starts = chunk.getAllStarts(); if (!starts.isEmpty()) { @@ -3513,7 +4203,7 @@ private boolean structureCollidesWithBuildings(BoundingBox box) { } private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { - int marginBlocks = Math.max(roadWidths.main(), Math.max(roadWidths.normal(), roadWidths.dirt())) + 2; + int marginBlocks = Math.max(OSM_ROAD_MAX_TAGGED_WIDTH, Math.max(roadWidths.main(), Math.max(roadWidths.normal(), roadWidths.dirt()))) + 2; OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; EarthChunkGenerator.OsmRoadQueryResult query = this.fetchOsmRoadsForAreaDetailed( box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode @@ -3576,11 +4266,7 @@ private boolean structureIntersectsBuilding( private boolean structureIntersectsRoad( BoundingBox box, RoadFeature road, EarthChunkGenerator.RoadWidths roadWidths, double blocksPerDegree, double worldScale ) { - int roadWidth = switch (road.roadClass()) { - case MAIN -> roadWidths.main(); - case NORMAL -> roadWidths.normal(); - case DIRT -> roadWidths.dirt(); - }; + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), roadWidths)); double halfWidth = Math.max(0.5, (roadWidth - 1) * 0.5); double minX = structureFootprintMinX(box) - halfWidth; double maxX = structureFootprintMaxX(box) + halfWidth; @@ -3885,8 +4571,8 @@ && isSolidCaveAnchor(groundState) level.setBlock(ground, GRASS_BLOCK_STATE, 260); } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); + ArnisTreeType treeType = wildTreeTypeForBiome(biome, seed); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); } } } @@ -3898,120 +4584,1393 @@ && isSolidCaveAnchor(groundState) } } - private List prepareDeferredTreePlacements( - EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + private List prepareDeferredTreePlacements( + EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + ) { + ChunkPos pos = context.pos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + int shorelineBlendRadius = Math.max(this.settings.riverLakeShorelineBlend(), this.settings.oceanShorelineBlend()); + int cellMinX = Math.floorDiv(chunkMinX, 5); + int cellMaxX = Math.floorDiv(chunkMaxX, 5); + int cellMinZ = Math.floorDiv(chunkMinZ, 5); + int cellMaxZ = Math.floorDiv(chunkMaxZ, 5); + long worldSeed = this.worldSeed; + List placements = new ArrayList<>(); + + for (int cellX = cellMinX; cellX <= cellMaxX; cellX++) { + for (int cellZ = cellMinZ; cellZ <= cellMaxZ; cellZ++) { + long seed = seedFromCoords(cellX, 0, cellZ) ^ worldSeed; + RandomSource random = RandomSource.create(seed); + int worldX = cellX * 5 + random.nextInt(5); + int worldZ = cellZ * 5 + random.nextInt(5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } + + int index = chunkIndex(localX, localZ); + int coverClass = context.coverClasses()[index]; + if (coverClass != 10) { + continue; + } + + boolean nearWater = false; + if (shorelineBlendRadius > 0) { + nearWater = localX - shorelineBlendRadius >= 0 + && localX + shorelineBlendRadius <= CHUNK_MASK + && localZ - shorelineBlendRadius >= 0 + && localZ + shorelineBlendRadius <= CHUNK_MASK + ? hasWaterNear(context.waterFlags(), localX, localZ, shorelineBlendRadius) + : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + } + + if (nearWater) { + continue; + } + + int expectedSurface = context.terrainSurfaces()[index]; + if (expectedSurface < this.seaLevel) { + continue; + } + + Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); + if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { + continue; + } + + placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); + } + } + + return placements.isEmpty() ? List.of() : List.copyOf(placements); + } + + private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { + for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { + this.applyPreparedTreePlacement(level, placement); + } + } + + private void applyPreparedTreePlacement(WorldGenLevel level, EarthChunkGenerator.PreparedTreePlacement placement) { + int worldX = placement.worldX(); + int worldZ = placement.worldZ(); + int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (topY < level.getMinBuildHeight() + || topY < this.seaLevel + || placement.expectedSurface() - topY > TREE_MAX_SURFACE_DROP + || topY - placement.expectedSurface() > TREE_MAX_SURFACE_RISE) { + return; + } + + BlockPos ground = new BlockPos(worldX, topY, worldZ); + BlockState groundState = level.getBlockState(ground); + if (isRoadDeckState(groundState) + || isRoadDeckState(level.getBlockState(ground.below())) + || !isSolidCaveAnchor(groundState) + || groundState.is(BlockTags.LOGS) + || groundState.is(BlockTags.LEAVES)) { + return; + } + + Holder biome = placement.biome(); + if (biome.is(Biomes.MANGROVE_SWAMP)) { + return; + } + + List> features = treeFeaturesForBiome(biome); + if (features.isEmpty()) { + return; + } + + BlockPos position = ground.above(); + if (!groundState.is(BlockTags.DIRT)) { + level.setBlock(ground, GRASS_BLOCK_STATE, 260); + } + + ArnisTreeType treeType = wildTreeTypeForBiome(biome, placement.seed()); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); + } + + private void applyExternalCityDetails(WorldGenLevel level, ChunkAccess chunk) { + double worldScale = this.settings.worldScale(); + if (!(worldScale > 0.0) || worldScale > OSM_CITY_DETAIL_MAX_SCALE || !EXTERNAL_FEATURE_SOURCE.cityDetailsAvailable()) { + return; + } + + ChunkPos pos = chunk.getPos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + List areas = EXTERNAL_FEATURE_SOURCE.cityAreasForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!areas.isEmpty()) { + this.applyExternalCityAreas(level, chunk, areas, worldScale); + } + + List lines = EXTERNAL_FEATURE_SOURCE.cityLinesForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!lines.isEmpty()) { + this.applyExternalCityLines(level, chunk, lines, worldScale); + } + + List points = EXTERNAL_FEATURE_SOURCE.cityPointsForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN + ); + if (!points.isEmpty()) { + this.applyExternalCityPoints(level, chunk, points, worldScale); + } + } + + private void applyExternalCityAreas(WorldGenLevel level, ChunkAccess chunk, List areas, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalAreaFeature area : areas) { + int partCount = area.rings().size(); + if (partCount == 0) { + continue; + } + + double[][] xs = new double[partCount][]; + double[][] zs = new double[partCount][]; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (int part = 0; part < partCount; part++) { + List ring = area.rings().get(part); + xs[part] = new double[ring.size()]; + zs[part] = new double[ring.size()]; + for (int point = 0; point < ring.size(); point++) { + GeoPoint geoPoint = ring.get(point); + double worldX = geoPoint.longitude() * blocksPerDegree - 0.5; + double worldZ = EarthProjection.latToBlockZ(geoPoint.latitude(), worldScale) - 0.5; + xs[part][point] = worldX; + zs[part][point] = worldZ; + minX = Math.min(minX, Mth.floor(worldX)); + maxX = Math.max(maxX, Mth.ceil(worldX)); + minZ = Math.min(minZ, Mth.floor(worldZ)); + maxZ = Math.max(maxZ, Mth.ceil(worldZ)); + } + } + + int clampedMinX = Math.max(chunkMinX, minX); + int clampedMaxX = Math.min(chunkMaxX, maxX); + int clampedMinZ = Math.max(chunkMinZ, minZ); + int clampedMaxZ = Math.min(chunkMaxZ, maxZ); + if (clampedMaxX < clampedMinX || clampedMaxZ < clampedMinZ) { + continue; + } + + ScanlinePolygonRasterizer.fill(xs, zs, clampedMinX, clampedMinZ, clampedMaxX, clampedMaxZ, (worldX, worldZ) -> { + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY) { + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState current = level.getBlockState(cursor); + if (!canReplaceCitySurface(current)) { + return; + } + BlockState state = cityAreaSurfaceState(area, worldX, worldZ); + if (state == null) { + return; + } + level.setBlock(cursor, state, this.detailApplyFlags(level)); + this.decorateExternalAreaFeature(level, cursor, area, worldX, surfaceY, worldZ, maxY); + this.decorateExternalAreaVegetation(level, cursor, area, worldX, surfaceY, worldZ, maxY); + }); + } + } + + private void decorateExternalAreaFeature( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + long seed = areaVegetationSeed(area, worldX, worldZ, 73); + int roll = seededRandomInt(seed ^ 1469598103934665603L, 1000); + int flags = this.detailApplyFlags(level); + + switch (area.kind()) { + case PARKING -> { + if (Math.floorMod(worldX, 18) == 0 && Math.floorMod(worldZ, 20) == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 5, worldZ, Blocks.GLOWSTONE.defaultBlockState(), maxY, flags); + } + } + case LANDUSE -> { + switch (type) { + case "farmland" -> { + if (Math.floorMod(worldX, 9) == 0 && Math.floorMod(worldZ, 9) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, WATER_STATE, flags); + } else if (roll < 760) { + BlockState crop = switch (roll % 4) { + case 0 -> Blocks.CARROTS.defaultBlockState(); + case 1 -> Blocks.POTATOES.defaultBlockState(); + case 2 -> Blocks.WHEAT.defaultBlockState(); + default -> Blocks.HAY_BLOCK.defaultBlockState(); + }; + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, crop, maxY, flags); + } + } + case "cemetery" -> { + if (Math.floorMod(worldX, 4) == 0 && Math.floorMod(worldZ, 5) == 0 && roll < 350) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 1, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } else if (roll < 430) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.POPPY.defaultBlockState(), maxY, flags); + } + } + case "construction" -> { + if (roll < 12) { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.SCAFFOLDING.defaultBlockState(), 4 + roll % 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.SCAFFOLDING.defaultBlockState(), maxY, flags); + } else if (roll < 55) { + BlockState material = switch (roll % 8) { + case 0 -> Blocks.OAK_LOG.defaultBlockState(); + case 1 -> Blocks.COBBLESTONE.defaultBlockState(); + case 2 -> Blocks.GRAVEL.defaultBlockState(); + case 3 -> Blocks.BRICKS.defaultBlockState(); + case 4 -> Blocks.IRON_BLOCK.defaultBlockState(); + case 5 -> Blocks.SAND.defaultBlockState(); + case 6 -> Blocks.CRAFTING_TABLE.defaultBlockState(); + default -> Blocks.FURNACE.defaultBlockState(); + }; + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, material, maxY, flags); + } + } + case "vineyard" -> { + if (Math.floorMod(worldX, 6) == 0 && roll < 720) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + if (Math.floorMod(worldZ, 3) != 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_LEAVES.defaultBlockState(), maxY, flags); + } + } + } + case "quarry", "brownfield", "landfill" -> { + if (roll < 60) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, roll < 25 ? Blocks.COBBLESTONE.defaultBlockState() : Blocks.GRAVEL.defaultBlockState(), maxY, flags); + } else if (roll < 85) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, CITY_DEAD_BUSH_STATE, maxY, flags); + } + } + default -> { + } + } + } + case LEISURE -> { + switch (type) { + case "playground", "recreation_ground", "dog_park" -> { + if (Math.floorMod(worldX, 13) == 0 && Math.floorMod(worldZ, 11) == 0 && roll < 450) { + if (roll < 150) { + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + } else if (roll < 300) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + 1, worldZ, Blocks.OAK_PLANKS.defaultBlockState(), maxY, flags); + } else { + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.SAND.defaultBlockState(), maxY, flags); + } + } + } + } + } + case "pitch" -> { + if (Math.floorMod(worldX, 16) == 0 || Math.floorMod(worldZ, 16) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + case "track" -> { + if (Math.floorMod(worldX + worldZ, 8) == 0) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + default -> { + } + } + } + case NATURAL -> { + if ("wetland".equals(type) && roll < 120) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, WATER_STATE, flags); + } else if (("bare_rock".equals(type) || "scree".equals(type) || "blockfield".equals(type) || "cliff".equals(type)) && roll < 90) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, CITY_ROCK_STATE, maxY, flags); + } + } + case WATER, AMENITY -> { + } + } + } + + private void decorateExternalAreaVegetation( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + if (!isVegetationArea(area, type)) { + return; + } + + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canDecorateAreaVegetationSurface(area, type, ground)) { + return; + } + + long seed = areaVegetationSeed(area, worldX, worldZ, 31); + if (shouldPlaceAreaTree(area, type, worldX, worldZ)) { + ArnisTreeType treeType = ArnisTreeType.chooseForAreaTags(area.tags(), type, seed); + ArnisTreeGenerator.place(level, new BlockPos(worldX, surfaceY + 1, worldZ), treeType, level.getMinBuildHeight(), maxY, this.detailApplyFlags(level)); + return; + } + + BlockState detail = areaVegetationDetailState(area, type, seed); + if (detail == null) { + return; + } + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, detail, this.detailApplyFlags(level)); + } + } + + private static boolean isVegetationArea(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "wood", "tree_row", "scrub", "heath", "grassland", "wetland", "beach", "sand", "dune", "shoal", "bare_rock", "scree", "blockfield", "mud", "mountain_range", "saddle", "ridge", "shrubbery", "tundra", "hill", "cliff" -> true; + default -> false; + }; + case LANDUSE -> switch (type) { + case "forest", "orchard", "greenfield", "meadow", "grass", "cemetery", "vineyard", "brownfield", "landfill" -> true; + default -> false; + }; + case LEISURE -> switch (type) { + case "park", "garden", "recreation_ground", "nature_reserve", "golf_course", "disc_golf_course", "dog_park" -> true; + default -> false; + }; + case PARKING, WATER, AMENITY -> false; + }; + } + + private static boolean canDecorateAreaVegetationSurface(ExternalAreaFeature area, String type, BlockState ground) { + if (ground.isAir() || !ground.getFluidState().isEmpty() || isRoadDeckState(ground) || ground.is(BlockTags.LOGS) || ground.is(BlockTags.LEAVES)) { + return false; + } + if ("beach".equals(type) || "sand".equals(type) || "dune".equals(type) || "shoal".equals(type)) { + return ground.is(Blocks.SAND) || ground.is(Blocks.RED_SAND) || ground.is(Blocks.GRAVEL); + } + if ("bare_rock".equals(type) || "scree".equals(type)) { + return ground.is(Blocks.STONE) || ground.is(Blocks.ANDESITE) || ground.is(Blocks.COBBLESTONE) || ground.is(Blocks.GRAVEL); + } + return ground.is(BlockTags.DIRT) || ground.is(Blocks.MOSS_BLOCK) || ground.is(Blocks.MUD); + } + + private static boolean shouldPlaceAreaTree(ExternalAreaFeature area, String type, int worldX, int worldZ) { + int spacing = areaTreeSpacing(area, type); + if (spacing <= 0) { + return false; + } + int cellX = Math.floorDiv(worldX, spacing); + int cellZ = Math.floorDiv(worldZ, spacing); + long seed = areaVegetationSeed(area, cellX, cellZ, 47); + if (seededRandomInt(seed, 100) >= areaTreeChancePercent(area, type)) { + return false; + } + int targetX = cellX * spacing + seededRandomInt(seed ^ 3405691582L, spacing); + int targetZ = cellZ * spacing + seededRandomInt(seed ^ 3735928559L, spacing); + return worldX == targetX && worldZ == targetZ; + } + + private static int areaTreeSpacing(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "tree_row" -> 4; + case "wood" -> 6; + case "scrub", "heath" -> 12; + default -> -1; + }; + case LANDUSE -> switch (type) { + case "forest" -> 7; + case "orchard" -> 8; + case "cemetery" -> 11; + default -> -1; + }; + case LEISURE -> switch (type) { + case "park", "garden", "recreation_ground" -> 10; + default -> -1; + }; + case PARKING, WATER, AMENITY -> -1; + }; + } + + private static int areaTreeChancePercent(ExternalAreaFeature area, String type) { + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "tree_row" -> 95; + case "wood" -> 80; + case "scrub", "heath" -> 25; + default -> 0; + }; + case LANDUSE -> switch (type) { + case "forest" -> 75; + case "orchard" -> 60; + case "cemetery" -> 20; + default -> 0; + }; + case LEISURE -> 35; + case PARKING, WATER, AMENITY -> 0; + }; + } + + private static BlockState areaVegetationDetailState(ExternalAreaFeature area, String type, long seed) { + int roll = seededRandomInt(seed ^ 81985529216486895L, 100); + return switch (area.kind()) { + case NATURAL -> switch (type) { + case "wood", "tree_row" -> roll < 28 ? CITY_FERN_STATE : roll < 36 ? CITY_SHRUB_STATE : null; + case "scrub" -> roll < 30 ? CITY_SHRUB_STATE : roll < 55 ? CITY_FERN_STATE : roll < 61 ? CITY_ROCK_STATE : null; + case "heath" -> roll < 18 ? CITY_SHRUB_STATE : roll < 45 ? CITY_FERN_STATE : roll < 50 ? CITY_ROCK_STATE : null; + case "grassland" -> roll < 42 ? CITY_FERN_STATE : roll < 46 ? CITY_SHRUB_STATE : null; + case "wetland" -> roll < 32 ? CITY_FERN_STATE : null; + case "beach", "sand", "dune", "shoal" -> roll < 4 ? CITY_DEAD_BUSH_STATE : null; + case "bare_rock", "scree", "blockfield", "mountain_range", "saddle", "ridge", "cliff" -> roll < 10 ? CITY_ROCK_STATE : null; + case "mud", "tundra", "hill" -> roll < 22 ? CITY_FERN_STATE : roll < 28 ? CITY_DEAD_BUSH_STATE : null; + case "shrubbery" -> roll < 60 ? CITY_SHRUB_STATE : null; + default -> null; + }; + case LANDUSE -> switch (type) { + case "forest", "orchard" -> roll < 25 ? CITY_FERN_STATE : roll < 33 ? CITY_SHRUB_STATE : null; + case "cemetery" -> roll < 10 ? CITY_FERN_STATE : roll < 15 ? CITY_SHRUB_STATE : null; + case "greenfield", "meadow", "grass" -> roll < 35 ? CITY_FERN_STATE : null; + case "vineyard", "brownfield", "landfill" -> roll < 18 ? CITY_FERN_STATE : roll < 24 ? CITY_DEAD_BUSH_STATE : null; + default -> null; + }; + case LEISURE -> roll < 20 ? CITY_FERN_STATE : roll < 26 ? CITY_SHRUB_STATE : null; + case PARKING, WATER, AMENITY -> null; + }; + } + + private static long areaVegetationSeed(ExternalAreaFeature area, int x, int z, int salt) { + long featureSeed = (long)area.sourceId().hashCode() * 7046029254386353131L ^ (long)area.typeTag().hashCode() * 2862933555777941757L; + return seedFromCoords(x, salt, z) ^ featureSeed; + } + + private void applyExternalCityLines(WorldGenLevel level, ChunkAccess chunk, List lines, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalLineFeature line : lines) { + if (line.points().size() < 2) { + continue; + } + for (int index = 1; index < line.points().size(); index++) { + GeoPoint previous = line.points().get(index - 1); + GeoPoint current = line.points().get(index); + double startX = previous.longitude() * blocksPerDegree; + double startZ = EarthProjection.latToBlockZ(previous.latitude(), worldScale); + double endX = current.longitude() * blocksPerDegree; + double endZ = EarthProjection.latToBlockZ(current.latitude(), worldScale); + int steps = Math.max(1, Mth.ceil(Math.max(Math.abs(endX - startX), Math.abs(endZ - startZ)) * 2.0)); + for (int step = 0; step <= steps; step++) { + double t = step / (double)steps; + int worldX = Mth.floor(Mth.lerp(t, startX, endX) + 0.5); + int worldZ = Mth.floor(Mth.lerp(t, startZ, endZ) + 0.5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + this.placeExternalCityLineColumn(level, cursor, line, worldX, worldZ, minY, maxY); + } + } + } + } + + private void placeExternalCityLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int worldZ, int minY, int maxY + ) { + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + return; + } + if (!cityFeatureVisible(line.tags())) { + return; + } + if (line.kind() == ExternalLineKind.POWER) { + this.placePowerLineColumn(level, cursor, line, worldX, surfaceY, worldZ, maxY); + return; + } + if (line.kind() == ExternalLineKind.MAN_MADE) { + this.placeManMadeLineColumn(level, cursor, line, worldX, surfaceY, worldZ, maxY); + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canAnchorCityLine(ground)) { + return; + } + + if (line.kind() == ExternalLineKind.RAILWAY) { + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_RAIL_STATE, this.detailApplyFlags(level)); + } + return; + } + if (line.kind() == ExternalLineKind.WATERWAY) { + cursor.set(worldX, surfaceY, worldZ); + if (canReplaceCitySurface(level.getBlockState(cursor))) { + level.setBlock(cursor, WATER_STATE, this.detailApplyFlags(level)); + } + return; + } + if (line.kind() != ExternalLineKind.BARRIER) { + return; + } + + BlockState state = cityBarrierState(line); + int height = cityBarrierHeight(line); + for (int offset = 1; offset <= height; offset++) { + int y = surfaceY + offset; + if (y > maxY) { + break; + } + cursor.set(worldX, y, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, state, this.detailApplyFlags(level)); + } + } + } + + private void placePowerLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"line".equals(line.typeTag()) && !"minor_line".equals(line.typeTag())) { + return; + } + int y = surfaceY + powerLineHeight(line); + if (y > maxY) { + return; + } + cursor.set(worldX, y, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, Blocks.IRON_BARS.defaultBlockState(), this.detailApplyFlags(level)); + } + } + + private static int powerLineHeight(ExternalLineFeature line) { + int voltage = intFromTag(line.tags().get("voltage"), 0); + if (voltage >= 220000) { + return 22; + } + if (voltage >= 110000) { + return 18; + } + if (voltage >= 33000) { + return 14; + } + return "minor_line".equals(line.typeTag()) ? 8 : 12; + } + + private void placeManMadeLineColumn( + WorldGenLevel level, MutableBlockPos cursor, ExternalLineFeature line, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"pier".equals(line.typeTag()) || surfaceY + 1 > maxY) { + return; + } + int flags = this.detailApplyFlags(level); + cursor.set(worldX, surfaceY + 1, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, Blocks.OAK_SLAB.defaultBlockState(), flags); + } + cursor.set(worldX, surfaceY, worldZ); + BlockState supportTarget = level.getBlockState(cursor); + if (supportTarget.getFluidState().isEmpty() && !isRoadLightReplaceable(supportTarget)) { + return; + } + level.setBlock(cursor, Blocks.OAK_FENCE.defaultBlockState(), flags); + } + + private void applyExternalCityPoints(WorldGenLevel level, ChunkAccess chunk, List points, double worldScale) { + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + int chunkMaxX = chunkMinX + CHUNK_MASK; + int chunkMaxZ = chunkMinZ + CHUNK_MASK; + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; + + for (ExternalPointFeature point : points) { + int worldX = Mth.floor(point.point().longitude() * blocksPerDegree + 0.5); + int worldZ = Mth.floor(EarthProjection.latToBlockZ(point.point().latitude(), worldScale) + 0.5); + if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { + continue; + } + this.placeExternalCityPoint(level, cursor, point, worldX, worldZ, minY, maxY); + } + } + + private void placeExternalCityPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int worldZ, int minY, int maxY + ) { + if (!cityFeatureVisible(point.tags())) { + return; + } + if (point.kind() == ExternalPointKind.CROSSING) { + this.paintCrossingPoint(level, cursor, worldX, worldZ); + return; + } + if (point.kind() == ExternalPointKind.ENTRANCE) { + this.placeEntrancePoint(level, cursor, worldX, worldZ, minY, maxY); + return; + } + + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + return; + } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canAnchorCityLine(ground)) { + int[] anchor = this.findCityPointAnchor(level, cursor, worldX, worldZ, minY, maxY); + if (anchor == null) { + return; + } + worldX = anchor[0]; + surfaceY = anchor[1]; + worldZ = anchor[2]; + } + + switch (point.kind()) { + case TRAFFIC_SIGNAL -> this.placeTrafficSignal(level, cursor, worldX, surfaceY, worldZ, maxY); + case HIGHWAY -> this.placeHighwayPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case AMENITY -> this.placeAmenityPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case NATURAL -> this.placeNaturalPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case ADVERTISING -> this.placeAdvertisingPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case EMERGENCY -> this.placeEmergencyPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case HISTORIC -> this.placeHistoricPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case TOURISM -> this.placeTourismPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case MAN_MADE -> this.placeManMadePoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case POWER -> this.placePowerPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case BARRIER -> this.placeBarrierPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case RAILWAY -> this.placeRailwayPoint(level, cursor, point, worldX, surfaceY, worldZ, maxY); + case ENTRANCE, CROSSING -> { + } + } + } + + private int[] findCityPointAnchor(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ, int minY, int maxY) { + for (int radius = 1; radius <= 3; radius++) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) { + continue; + } + + int targetX = worldX + dx; + int targetZ = worldZ + dz; + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, targetX, targetZ) - 1; + if (surfaceY < minY || surfaceY >= maxY) { + continue; + } + cursor.set(targetX, surfaceY, targetZ); + if (canAnchorCityLine(level.getBlockState(cursor))) { + return new int[]{targetX, surfaceY, targetZ}; + } + } + } + } + return null; + } + + private void placeEntrancePoint(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ, int minY, int maxY) { + int flags = this.detailApplyFlags(level); + for (int radius = 0; radius <= 1; radius++) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) { + continue; + } + int targetX = worldX + dx; + int targetZ = worldZ + dz; + for (Direction facing : Direction.Plane.HORIZONTAL) { + int outsideX = targetX + facing.getStepX(); + int outsideZ = targetZ + facing.getStepZ(); + int outsideSurfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, outsideX, outsideZ) - 1; + int lowerY = outsideSurfaceY + 1; + int upperY = lowerY + 1; + if (lowerY < minY || upperY > maxY) { + continue; + } + + cursor.set(targetX, lowerY, targetZ); + BlockState lowerTarget = level.getBlockState(cursor); + cursor.set(targetX, upperY, targetZ); + BlockState upperTarget = level.getBlockState(cursor); + cursor.set(outsideX, lowerY, outsideZ); + BlockState lowerOutside = level.getBlockState(cursor); + cursor.set(outsideX, upperY, outsideZ); + BlockState upperOutside = level.getBlockState(cursor); + if (!isBuildingDoorTarget(lowerTarget) + || !isBuildingDoorTarget(upperTarget) + || !isRoadLightReplaceable(lowerOutside) + || !isRoadLightReplaceable(upperOutside)) { + continue; + } + + BlockState lower = Blocks.OAK_DOOR.defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, facing) + .setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.LOWER); + BlockState upper = lower.setValue(BlockStateProperties.DOUBLE_BLOCK_HALF, DoubleBlockHalf.UPPER); + cursor.set(targetX, lowerY, targetZ); + level.setBlock(cursor, lower, flags); + cursor.set(targetX, upperY, targetZ); + level.setBlock(cursor, upper, flags); + return; + } + } + } + } + } + + private void paintCrossingPoint(WorldGenLevel level, MutableBlockPos cursor, int worldX, int worldZ) { + int flags = this.detailApplyFlags(level); + for (int dz = -2; dz <= 2; dz++) { + for (int dx = -2; dx <= 2; dx++) { + if (Math.floorMod(dx + dz, 2) != 0) { + continue; + } + int targetX = worldX + dx; + int targetZ = worldZ + dz; + int surfaceY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, targetX, targetZ) - 1; + if (surfaceY < level.getMinBuildHeight()) { + continue; + } + cursor.set(targetX, surfaceY, targetZ); + if (isRoadDeckState(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_PARKING_MARK_STATE, flags); + } + } + } + } + + private void placeTrafficSignal(WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int maxY) { + int flags = this.detailApplyFlags(level); + if (surfaceY + 3 > maxY) { + return; + } + cursor.set(worldX, surfaceY + 1, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return; + } + level.setBlock(cursor, CITY_TRAFFIC_POLE_STATE, flags); + cursor.set(worldX, surfaceY + 2, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_TRAFFIC_POLE_STATE, flags); + } + cursor.set(worldX, surfaceY + 3, worldZ); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_TRAFFIC_LIGHT_STATE, flags); + } + } + + private void placeHighwayPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "street_lamp" -> { + if (surfaceY + 5 > maxY) { + return; + } + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 5, worldZ, Blocks.GLOWSTONE.defaultBlockState(), maxY, flags); + } + case "bus_stop" -> { + if (surfaceY + 4 > maxY) { + return; + } + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 4, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + } + default -> { + } + } + } + + private void placeAmenityPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (surfaceY + 1 > maxY) { + return; + } + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + cursor.set(worldX, surfaceY + 1, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return; + } + switch (type) { + case "bench" -> level.setBlock(cursor, CITY_BENCH_STATE, flags); + case "bicycle_parking", "shelter" -> level.setBlock(cursor, CITY_BARRIER_RAIL_STATE, flags); + case "recycling" -> level.setBlock(cursor, Blocks.BARREL.defaultBlockState(), flags); + case "waste_disposal", "waste_basket" -> level.setBlock(cursor, Blocks.CAULDRON.defaultBlockState(), flags); + case "vending_machine", "atm" -> { + level.setBlock(cursor, Blocks.IRON_BLOCK.defaultBlockState(), flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + case "drinking_water" -> { + level.setBlock(cursor, Blocks.COBBLESTONE_WALL.defaultBlockState(), flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.CAULDRON.defaultBlockState(), maxY, flags); + } + case "fountain" -> { + level.setBlock(cursor, WATER_STATE, flags); + for (Direction direction : Direction.Plane.HORIZONTAL) { + cursor.set(worldX + direction.getStepX(), surfaceY + 1, worldZ + direction.getStepZ()); + if (isRoadLightReplaceable(level.getBlockState(cursor))) { + level.setBlock(cursor, CITY_FOUNTAIN_BASE_STATE, flags); + } + } + } + case "fuel" -> level.setBlock(cursor, CITY_TRAFFIC_LIGHT_STATE, flags); + default -> { + } + } + } + + private void placeNaturalPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY ) { - ChunkPos pos = context.pos(); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); - int chunkMaxX = chunkMinX + CHUNK_MASK; - int chunkMaxZ = chunkMinZ + CHUNK_MASK; - int shorelineBlendRadius = Math.max(this.settings.riverLakeShorelineBlend(), this.settings.oceanShorelineBlend()); - int cellMinX = Math.floorDiv(chunkMinX, 5); - int cellMaxX = Math.floorDiv(chunkMaxX, 5); - int cellMinZ = Math.floorDiv(chunkMinZ, 5); - int cellMaxZ = Math.floorDiv(chunkMaxZ, 5); - long worldSeed = this.worldSeed; - List placements = new ArrayList<>(); + if (!"tree".equals(point.typeTag())) { + return; + } + long seed = seedFromCoords(worldX, 5, worldZ) ^ this.worldSeed ^ (long)point.sourceId().hashCode() * 7046029254386353131L; + ArnisTreeType treeType = ArnisTreeType.chooseForPointTags(point.tags(), seed); + ArnisTreeGenerator.place(level, new BlockPos(worldX, surfaceY + 1, worldZ), treeType, level.getMinBuildHeight(), maxY, this.detailApplyFlags(level)); + } - for (int cellX = cellMinX; cellX <= cellMaxX; cellX++) { - for (int cellZ = cellMinZ; cellZ <= cellMaxZ; cellZ++) { - long seed = seedFromCoords(cellX, 0, cellZ) ^ worldSeed; - RandomSource random = RandomSource.create(seed); - int worldX = cellX * 5 + random.nextInt(5); - int worldZ = cellZ * 5 + random.nextInt(5); - if (worldX < chunkMinX || worldX > chunkMaxX || worldZ < chunkMinZ || worldZ > chunkMaxZ) { - continue; + private void placeAdvertisingPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "column" -> { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.GREEN_CONCRETE.defaultBlockState(), 2, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + case "flag" -> { + int height = Math.max(4, Math.min(12, intFromTag(point.tags().get("height"), 6))); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), height, maxY, flags); + BlockState flag = cityFlagState(point.sourceId()); + for (int dx = 1; dx <= 3; dx++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height, worldZ, flag, maxY, flags); + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height - 1, worldZ, flag, maxY, flags); } - - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { - continue; + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 1, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + case "poster_box" -> { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); + for (int y = surfaceY + 2; y <= surfaceY + 3; y++) { + this.placeCityBlock(level, cursor, worldX, y, worldZ, Blocks.SEA_LANTERN.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, y, worldZ, Blocks.SEA_LANTERN.defaultBlockState(), maxY, flags); } + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 4, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + default -> { + } + } + } - int index = chunkIndex(localX, localZ); - int coverClass = context.coverClasses()[index]; - if (coverClass != 10) { - continue; - } + private void placeEmergencyPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"fire_hydrant".equals(point.typeTag())) { + return; + } + String hydrantType = point.tags().getOrDefault("fire_hydrant:type", "pillar").trim().toLowerCase(Locale.ROOT); + if ("underground".equals(hydrantType) || "wall".equals(hydrantType) || "pond".equals(hydrantType)) { + return; + } + int flags = this.detailApplyFlags(level); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.BRICK_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.REDSTONE_BLOCK.defaultBlockState(), maxY, flags); + } - boolean nearWater = false; - if (shorelineBlendRadius > 0) { - nearWater = localX - shorelineBlendRadius >= 0 - && localX + shorelineBlendRadius <= CHUNK_MASK - && localZ - shorelineBlendRadius >= 0 - && localZ + shorelineBlendRadius <= CHUNK_MASK - ? hasWaterNear(context.waterFlags(), localX, localZ, shorelineBlendRadius) - : this.isNearWater(worldX, worldZ, shorelineBlendRadius); - } + private void placeHistoricPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + String subtype = point.tags().getOrDefault("memorial", "").trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + if ("wayside_cross".equals(type) || "cross".equals(subtype) || "war_memorial".equals(subtype)) { + this.placeCityCross(level, cursor, worldX, surfaceY, worldZ, 5, maxY, flags); + } else if ("monument".equals(type)) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + } + } + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.POLISHED_ANDESITE.defaultBlockState(), 6, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 8, worldZ, Blocks.CHISELED_STONE_BRICKS.defaultBlockState(), maxY, flags); + } else if ("obelisk".equals(subtype)) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.SMOOTH_QUARTZ.defaultBlockState(), 5, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 7, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } else if ("stone".equals(subtype) || "stolperstein".equals(subtype)) { + cursor.set(worldX, surfaceY, worldZ); + level.setBlock(cursor, "stolperstein".equals(subtype) ? Blocks.GOLD_BLOCK.defaultBlockState() : Blocks.STONE.defaultBlockState(), flags); + } else { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.CHISELED_STONE_BRICKS.defaultBlockState(), maxY, flags); + if ("statue".equals(subtype) || "sculpture".equals(subtype) || "bust".equals(subtype)) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + } else { + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.STONE_BRICK_SLAB.defaultBlockState(), maxY, flags); + } + } + } - if (nearWater) { - continue; - } + private void placeTourismPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + if (!"information".equals(point.typeTag())) { + return; + } + String information = point.tags().getOrDefault("information", "").trim().toLowerCase(Locale.ROOT); + if ("office".equals(information) || "visitor_centre".equals(information)) { + return; + } + int flags = this.detailApplyFlags(level); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.OAK_PLANKS.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.BLUE_WOOL.defaultBlockState(), maxY, flags); + } - int expectedSurface = context.terrainSurfaces()[index]; - if (expectedSurface < this.seaLevel) { - continue; + private void placeManMadePoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "antenna", "mast" -> { + int height = Math.max(10, Math.min(30, intFromTag(point.tags().get("height"), 18))); + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.GRAY_CONCRETE.defaultBlockState(), maxY, flags); + this.placeCityStack(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.IRON_BARS.defaultBlockState(), height, maxY, flags); + for (int y = surfaceY + 7; y <= surfaceY + height; y += 7) { + this.placeCityBlock(level, cursor, worldX + 1, y, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, y, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, y, worldZ + 1, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, y, worldZ - 1, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 2, worldZ, Blocks.LIGHTNING_ROD.defaultBlockState(), maxY, flags); + } + case "chimney" -> { + int height = Math.max(10, Math.min(25, intFromTag(point.tags().get("height"), 18))); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.BRICKS.defaultBlockState(), height, maxY, flags); + } + case "water_well" -> { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dz == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, WATER_STATE, maxY, flags); + } else { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + 1, worldZ + dz, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + } + } } + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 2, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ, Blocks.OAK_SLAB.defaultBlockState(), maxY, flags); + } + case "water_tower" -> this.placeCompactWaterTower(level, cursor, worldX, surfaceY, worldZ, maxY, flags); + default -> { + } + } + } - Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); - if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { - continue; + private void placePowerPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + if ("pole".equals(type)) { + int height = Math.max(6, Math.min(15, intFromTag(point.tags().get("height"), 10))); + BlockState pole = switch (point.tags().getOrDefault("material", "wood").trim().toLowerCase(Locale.ROOT)) { + case "concrete" -> Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + case "steel", "metal" -> Blocks.IRON_BARS.defaultBlockState(); + default -> Blocks.OAK_LOG.defaultBlockState(); + }; + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, pole, height, maxY, flags); + for (int dx = -2; dx <= 2; dx++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + height, worldZ, Blocks.OAK_FENCE.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX - 2, surfaceY + height + 1, worldZ, Blocks.END_ROD.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 2, surfaceY + height + 1, worldZ, Blocks.END_ROD.defaultBlockState(), maxY, flags); + } else if ("tower".equals(type)) { + int height = Math.max(14, Math.min(28, intFromTag(point.tags().get("height"), 20))); + for (int y = 1; y <= height; y++) { + int radius = y < height / 2 ? 2 : 1; + this.placeCityBlock(level, cursor, worldX - radius, surfaceY + y, worldZ - radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + radius, surfaceY + y, worldZ - radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX - radius, surfaceY + y, worldZ + radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + radius, surfaceY + y, worldZ + radius, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + if (y % 5 == 0) { + this.placeCityBlock(level, cursor, worldX, surfaceY + y, worldZ, Blocks.IRON_BARS.defaultBlockState(), maxY, flags); } + } + int armY = surfaceY + height - 3; + for (int d = -4; d <= 4; d++) { + this.placeCityBlock(level, cursor, worldX + d, armY, worldZ, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, armY, worldZ + d, Blocks.IRON_BLOCK.defaultBlockState(), maxY, flags); + } + this.placeCityBlock(level, cursor, worldX, surfaceY + height + 1, worldZ, Blocks.LIGHTNING_ROD.defaultBlockState(), maxY, flags); + } + } - placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); + private void placeBarrierPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "bollard" -> this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), maxY, flags); + case "block" -> this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE.defaultBlockState(), maxY, flags); + case "entrance" -> { + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + } + case "gate", "swing_gate", "lift_gate", "stile" -> { + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.OAK_FENCE_GATE.defaultBlockState(), maxY, flags); + this.placeCityBlockReplacingBarrier(level, cursor, worldX, surfaceY + 2, worldZ, Blocks.AIR.defaultBlockState(), maxY, flags); + } + default -> { } } + } - return placements.isEmpty() ? List.of() : List.copyOf(placements); + private void placeRailwayPoint( + WorldGenLevel level, MutableBlockPos cursor, ExternalPointFeature point, int worldX, int surfaceY, int worldZ, int maxY + ) { + String type = point.typeTag().trim().toLowerCase(Locale.ROOT); + int flags = this.detailApplyFlags(level); + switch (type) { + case "level_crossing", "crossing" -> { + this.paintCrossingPoint(level, cursor, worldX, worldZ); + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.COBBLESTONE_WALL.defaultBlockState(), 2, maxY, flags); + this.placeCityBlock(level, cursor, worldX - 1, surfaceY + 3, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 3, worldZ, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ - 1, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 3, worldZ + 1, Blocks.WHITE_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.REDSTONE_LAMP.defaultBlockState(), maxY, flags); + } + case "tram_stop" -> { + this.placeCityStack(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.IRON_BARS.defaultBlockState(), 3, maxY, flags); + this.placeCityBlock(level, cursor, worldX, surfaceY + 4, worldZ, Blocks.YELLOW_WOOL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, surfaceY + 1, worldZ, CITY_BENCH_STATE, maxY, flags); + } + default -> { + } + } } - private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { - for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { - this.applyPreparedTreePlacement(level, placement); + private void placeCompactWaterTower( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int maxY, int flags + ) { + int legHeight = 12; + int[][] legs = {{-2, -2}, {2, -2}, {-2, 2}, {2, 2}}; + for (int[] leg : legs) { + this.placeCityStack(level, cursor, worldX + leg[0], surfaceY + 1, worldZ + leg[1], Blocks.IRON_BARS.defaultBlockState(), legHeight, maxY, flags); + } + for (int dx = -3; dx <= 3; dx++) { + for (int dz = -3; dz <= 3; dz++) { + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + legHeight + 1, worldZ + dz, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + dx, surfaceY + legHeight + 2, worldZ + dz, Blocks.POLISHED_ANDESITE.defaultBlockState(), maxY, flags); + } } } - private void applyPreparedTreePlacement(WorldGenLevel level, EarthChunkGenerator.PreparedTreePlacement placement) { - int worldX = placement.worldX(); - int worldZ = placement.worldZ(); - int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; - if (topY < level.getMinBuildHeight() - || topY < this.seaLevel - || placement.expectedSurface() - topY > TREE_MAX_SURFACE_DROP - || topY - placement.expectedSurface() > TREE_MAX_SURFACE_RISE) { - return; + private void placeCityCross( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int surfaceY, int worldZ, int height, int maxY, int flags + ) { + this.placeCityBlock(level, cursor, worldX, surfaceY + 1, worldZ, Blocks.STONE_BRICKS.defaultBlockState(), maxY, flags); + for (int y = 2; y <= height; y++) { + this.placeCityBlock(level, cursor, worldX, surfaceY + y, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); } + int armY = surfaceY + Math.max(3, height - 1); + this.placeCityBlock(level, cursor, worldX - 1, armY, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + this.placeCityBlock(level, cursor, worldX + 1, armY, worldZ, Blocks.STONE_BRICK_WALL.defaultBlockState(), maxY, flags); + } - BlockPos ground = new BlockPos(worldX, topY, worldZ); - BlockState groundState = level.getBlockState(ground); - if (isRoadDeckState(groundState) - || isRoadDeckState(level.getBlockState(ground.below())) - || !isSolidCaveAnchor(groundState) - || groundState.is(BlockTags.LOGS) - || groundState.is(BlockTags.LEAVES)) { - return; + private void placeCityStack( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int startY, int worldZ, BlockState state, int height, int maxY, int flags + ) { + for (int offset = 0; offset < height; offset++) { + this.placeCityBlock(level, cursor, worldX, startY + offset, worldZ, state, maxY, flags); } + } - Holder biome = placement.biome(); - if (biome.is(Biomes.MANGROVE_SWAMP)) { - return; + private boolean placeCityBlock( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int y, int worldZ, BlockState state, int maxY, int flags + ) { + if (y > maxY) { + return false; + } + cursor.set(worldX, y, worldZ); + if (!isRoadLightReplaceable(level.getBlockState(cursor))) { + return false; } + level.setBlock(cursor, state, flags); + return true; + } - List> features = treeFeaturesForBiome(biome); - if (features.isEmpty()) { - return; + private boolean placeCityBlockReplacingBarrier( + WorldGenLevel level, MutableBlockPos cursor, int worldX, int y, int worldZ, BlockState state, int maxY, int flags + ) { + if (y > maxY) { + return false; + } + cursor.set(worldX, y, worldZ); + BlockState current = level.getBlockState(cursor); + if (!isRoadLightReplaceable(current) && !isCityBarrierBlock(current)) { + return false; } + level.setBlock(cursor, state, flags); + return true; + } - BlockPos position = ground.above(); - RandomSource random = RandomSource.create(placement.seed()); - if (!groundState.is(BlockTags.DIRT)) { - level.setBlock(ground, GRASS_BLOCK_STATE, 260); + private static boolean isCityBarrierBlock(BlockState state) { + return state.is(Blocks.COBBLESTONE_WALL) + || state.is(Blocks.STONE_BRICK_WALL) + || state.is(Blocks.OAK_FENCE) + || state.is(Blocks.IRON_BARS) + || state.is(Blocks.OAK_LEAVES); + } + + private static boolean canReplaceCitySurface(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && (state.is(BlockTags.DIRT) + || state.is(Blocks.STONE) + || state.is(Blocks.ANDESITE) + || state.is(Blocks.GRAVEL) + || state.is(Blocks.SAND) + || state.is(Blocks.RED_SAND) + || state.is(Blocks.MOSS_BLOCK) + || state.is(Blocks.MUD) + || state.is(Blocks.SNOW_BLOCK) + || state.is(Blocks.LIGHT_GRAY_CONCRETE) + || state.is(Blocks.GRAY_CONCRETE)); + } + + private static boolean canAnchorCityLine(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES); + } + + private static boolean isBuildingDoorTarget(BlockState state) { + return !state.isAir() + && state.getFluidState().isEmpty() + && !isRoadDeckState(state) + && !canReplaceCitySurface(state) + && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES); + } + + private static boolean cityFeatureVisible(Map tags) { + if (intFromTag(tags.get("layer"), 0) < 0 || intFromTag(tags.get("level"), 0) < 0) { + return false; + } + String location = tags.getOrDefault("location", "").trim().toLowerCase(Locale.ROOT); + return !"underground".equals(location) && !"underwater".equals(location) && !truthyTag(tags.get("tunnel")); + } + + private static BlockState cityFlagState(String sourceId) { + return switch (Math.floorMod(sourceId.hashCode(), 6)) { + case 0 -> Blocks.RED_WOOL.defaultBlockState(); + case 1 -> Blocks.YELLOW_WOOL.defaultBlockState(); + case 2 -> Blocks.BLUE_WOOL.defaultBlockState(); + case 3 -> Blocks.GREEN_WOOL.defaultBlockState(); + case 4 -> Blocks.ORANGE_WOOL.defaultBlockState(); + default -> Blocks.WHITE_WOOL.defaultBlockState(); + }; + } + + private static BlockState cityAreaSurfaceState(ExternalAreaFeature area, int worldX, int worldZ) { + String type = area.typeTag().trim().toLowerCase(Locale.ROOT); + if (area.kind() == ExternalAreaKind.PARKING) { + if (isParkingPaintStripe(worldX, worldZ)) { + return CITY_PARKING_MARK_STATE; + } + return Math.floorMod(worldX + worldZ, 11) == 0 ? CITY_PARKING_DRIVE_STATE : CITY_PARKING_STATE; + } + return switch (area.kind()) { + case LANDUSE -> landuseSurfaceState(type, worldX, worldZ); + case LEISURE -> leisureSurfaceState(type); + case NATURAL -> naturalSurfaceState(type); + case WATER -> WATER_STATE; + case AMENITY -> CITY_PARKING_STATE; + case PARKING -> CITY_PARKING_STATE; + }; + } + + private static boolean isParkingPaintStripe(int worldX, int worldZ) { + int x = Math.floorMod(worldX, 6); + int z = Math.floorMod(worldZ, 10); + return x == 0 && z >= 1 && z <= 8 || z == 0 && x >= 1 && x <= 4; + } + + private static BlockState landuseSurfaceState(String type, int worldX, int worldZ) { + return switch (type) { + case "construction", "brownfield", "landfill" -> Math.floorMod(worldX + worldZ, 5) == 0 ? GRAVEL_STATE : COARSE_DIRT_STATE; + case "industrial" -> Math.floorMod(worldX + worldZ, 4) == 0 ? Blocks.STONE_BRICKS.defaultBlockState() : STONE_STATE; + case "military" -> Math.floorMod(worldX + worldZ, 7) == 0 ? Blocks.STONE_BRICKS.defaultBlockState() : Blocks.GRAY_CONCRETE.defaultBlockState(); + case "quarry" -> Math.floorMod(worldX + worldZ, 6) == 0 ? GRAVEL_STATE : STONE_STATE; + case "railway" -> GRAVEL_STATE; + case "traffic_island" -> Blocks.STONE_SLAB.defaultBlockState(); + case "education", "religious" -> Blocks.POLISHED_ANDESITE.defaultBlockState(); + case "cemetery" -> MOSS_BLOCK_STATE; + case "farmland" -> Blocks.FARMLAND.defaultBlockState(); + case "forest", "orchard", "greenfield" -> GRASS_BLOCK_STATE; + case "vineyard" -> COARSE_DIRT_STATE; + case "commercial", "retail", "residential" -> GRASS_BLOCK_STATE; + case "meadow", "grass", "recreation_ground" -> GRASS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState leisureSurfaceState(String type) { + return switch (type) { + case "track" -> CITY_TRACK_STATE; + case "pitch", "sports_centre", "schoolyard" -> Blocks.GREEN_CONCRETE.defaultBlockState(); + case "playground", "recreation_ground", "dog_park", "beach_resort" -> CITY_PLAYGROUND_STATE; + case "swimming_pool", "swimming_area" -> WATER_STATE; + case "bathing_place" -> Blocks.SMOOTH_SANDSTONE.defaultBlockState(); + case "outdoor_seating", "water_park", "slipway" -> Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); + case "ice_rink" -> Blocks.PACKED_ICE.defaultBlockState(); + case "garden", "park", "nature_reserve", "golf_course", "disc_golf_course" -> GRASS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState naturalSurfaceState(String type) { + return switch (type) { + case "beach", "sand", "dune", "shoal" -> SAND_STATE; + case "wetland", "mud" -> MUD_STATE; + case "bare_rock", "cliff", "ridge", "saddle", "mountain_range" -> STONE_STATE; + case "scree", "blockfield" -> GRAVEL_STATE; + case "glacier" -> Blocks.PACKED_ICE.defaultBlockState(); + case "reef" -> WATER_STATE; + case "wood", "tree_row" -> PODZOL_STATE; + case "scrub", "heath", "shrubbery", "tundra" -> MOSS_BLOCK_STATE; + default -> GRASS_BLOCK_STATE; + }; + } + + private static BlockState cityBarrierState(ExternalLineFeature line) { + String type = line.typeTag().trim().toLowerCase(Locale.ROOT); + return switch (type) { + case "wall", "city_wall", "retaining_wall" -> CITY_BARRIER_WALL_STATE; + case "hedge" -> CITY_BARRIER_HEDGE_STATE; + case "guard_rail", "chain", "bollard" -> CITY_BARRIER_RAIL_STATE; + default -> CITY_BARRIER_FENCE_STATE; + }; + } + + private static int cityBarrierHeight(ExternalLineFeature line) { + String type = line.typeTag().trim().toLowerCase(Locale.ROOT); + if ("hedge".equals(type) || "wall".equals(type) || "city_wall".equals(type) || "retaining_wall".equals(type)) { + return Math.max(1, Math.min(3, intFromTag(line.tags().get("height"), 2))); + } + return 1; + } + + private static boolean truthyTag(String value) { + if (value == null) { + return false; } + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "yes", "true", "1" -> true; + default -> false; + }; + } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); + private static int intFromTag(String value, int defaultValue) { + if (value == null || value.isBlank()) { + return defaultValue; + } + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + if (!seenDigit) { + return defaultValue; + } + try { + return (int)Math.round(Double.parseDouble(number.toString())); + } catch (NumberFormatException error) { + return defaultValue; + } } private boolean isNearWater(int worldX, int worldZ, int radius) { @@ -5013,7 +6972,7 @@ private EarthChunkGenerator.PreparedTerrainRefinement buildPreparedTerrainRefine this.repairAnomalousChunkTerrain(terrainSurfaces, waterSurfaces, waterFlags, coverClasses, heightGrid, gridSize, step, chunkMinY, shell.maxY()); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = FAST_FULL_CHUNK && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(FAST_FULL_CHUNK, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); @@ -5281,6 +7240,10 @@ private void fillChunkTerrainMetricsAndBiomes( } } + private static boolean shouldUseChunkClimateCache(boolean fastFullChunk, EarthBiomeSource earthBiomeSource, double worldScale) { + return fastFullChunk && earthBiomeSource != null && worldScale > 1.5; + } + private void applyPreparedTerrainRefinement(ServerLevel level, ChunkAccess chunk, EarthChunkGenerator.PreparedTerrainRefinement refinement) { long chunkKey = ChunkPos.asLong(chunk.getPos().x, chunk.getPos().z); if (!Objects.equals(this.terrainGenerationStamps.get(chunkKey), refinement.generationStamp())) { @@ -5415,6 +7378,8 @@ private void applyPreparedChunkDetail(WorldGenLevel level, ChunkAccess chunk, Ea this.placePreparedRoadLights(level, chunk); } + this.applyExternalCityDetails(level, chunk); + List treePlacements = detail.treePlacements(); if (!treePlacements.isEmpty()) { long treeApplyStartNs = beginFullChunkProfiling(); @@ -5491,10 +7456,20 @@ public EarthChunkGenerator.OsmRoadQueryResult fetchOsmRoadsForAreaDetailed( double worldScale = this.settings.worldScale(); if (!(worldScale <= 0.0) && !(worldScale > OSM_ROAD_MAX_SCALE)) { OsmQueryMode queryMode = mode == null ? OsmQueryMode.BLOCKING : mode; - TellusOsmRoadSource.RoadQueryResult result = OSM_ROAD_SOURCE.roadsForAreaWithStatus( - minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + List externalFeatures = EXTERNAL_FEATURE_SOURCE.roadsForArea( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks) ); - return new EarthChunkGenerator.OsmRoadQueryResult(result.features(), result.hadCacheMiss()); + boolean skipOsm = EXTERNAL_FEATURE_SOURCE.preferExternalRoads() && !externalFeatures.isEmpty(); + TellusOsmRoadSource.RoadQueryResult result = skipOsm + ? new TellusOsmRoadSource.RoadQueryResult(List.of(), false) + : OSM_ROAD_SOURCE.roadsForAreaWithStatus(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode); + if (externalFeatures.isEmpty()) { + return new EarthChunkGenerator.OsmRoadQueryResult(result.features(), result.hadCacheMiss()); + } + List features = new ArrayList<>(result.features().size() + externalFeatures.size()); + features.addAll(result.features()); + features.addAll(externalFeatures); + return new EarthChunkGenerator.OsmRoadQueryResult(List.copyOf(features), result.hadCacheMiss()); } else { return new EarthChunkGenerator.OsmRoadQueryResult(List.of(), false); } @@ -5518,12 +7493,22 @@ public EarthChunkGenerator.OsmBuildingQueryResult fetchOsmBuildingsForAreaDetail return new EarthChunkGenerator.OsmBuildingQueryResult(List.of(), false); } else { double worldScale = this.settings.worldScale(); - if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && OSM_BUILDING_SOURCE.available()) { + if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE)) { OsmQueryMode queryMode = mode == null ? OsmQueryMode.BLOCKING : mode; - TellusOsmBuildingSource.BuildingQueryResult result = OSM_BUILDING_SOURCE.buildingsForAreaWithStatus( - minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + List features = new ArrayList<>(); + boolean hadCacheMiss = false; + List externalFeatures = EXTERNAL_FEATURE_SOURCE.buildingsForArea( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks) ); - return new EarthChunkGenerator.OsmBuildingQueryResult(result.features(), result.hadCacheMiss()); + if ((!EXTERNAL_FEATURE_SOURCE.preferExternalBuildings() || externalFeatures.isEmpty()) && OSM_BUILDING_SOURCE.available()) { + TellusOsmBuildingSource.BuildingQueryResult result = OSM_BUILDING_SOURCE.buildingsForAreaWithStatus( + minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, Math.max(0, marginBlocks), queryMode + ); + features.addAll(result.features()); + hadCacheMiss = result.hadCacheMiss(); + } + features.addAll(externalFeatures); + return new EarthChunkGenerator.OsmBuildingQueryResult(List.copyOf(features), hadCacheMiss); } else { return new EarthChunkGenerator.OsmBuildingQueryResult(List.of(), false); } @@ -5557,7 +7542,7 @@ private EarthChunkGenerator.PreparedChunkBuildings buildChunkBuildings( return null; } else { double worldScale = this.settings.worldScale(); - if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && OSM_BUILDING_SOURCE.available()) { + if (!(worldScale <= 0.0) && !(worldScale > OSM_BUILDING_MAX_SCALE) && this.buildingSourcesAvailable()) { int chunkMinX = pos.getMinBlockX(); int chunkMinZ = pos.getMinBlockZ(); int chunkMaxX = chunkMinX + CHUNK_MASK; @@ -5963,7 +7948,7 @@ private void applyPreparedBuildingTerrainToChunk(WorldGenLevel level, ChunkAcces private List fetchEntranceRoads( int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, OsmQueryMode queryMode ) { - if (!(worldScale > 0.0) || worldScale > OSM_ROAD_MAX_SCALE || !OSM_ROAD_SOURCE.available()) { + if (!(worldScale > 0.0) || worldScale > OSM_ROAD_MAX_SCALE || !this.roadSourcesAvailable()) { return List.of(); } @@ -6144,9 +8129,7 @@ private void placePreparedBuildingColumn( } else if (stairState != null) { state = stairState; } else if (facadeCell) { - state = this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y) - ? palette.window() - : palette.wall(); + state = this.facadeStateFor(blueprint, palette, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y); } else if (partitionCell) { state = palette.partition(); } else if (y == floorTop && TellusBuildingLighting.shouldPlaceInteriorLight(blueprint, boundaryDistance, worldX, worldZ, floorIndex)) { @@ -6202,8 +8185,8 @@ private void placeExteriorOnlyBuildingColumn( int floorBottom = blueprint.floorBottomY(floorIndex); int floorTop = Math.min(columnTopY, blueprint.floorTopY(floorIndex)); boolean facadeCell = blueprint.isFacadeCell(boundaryDistance, floorIndex); - if (facadeCell && this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y)) { - state = palette.window(); + if (facadeCell) { + state = this.facadeStateFor(blueprint, palette, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y); } else { state = palette.wall(); } @@ -6348,6 +8331,80 @@ private boolean isPartitionCell(BuildingBlueprint blueprint, EarthChunkGenerator }; } + private BlockState facadeStateFor( + BuildingBlueprint blueprint, + EarthChunkGenerator.BuildingPalette palette, + int boundaryDistance, + int worldX, + int worldZ, + int floorIndex, + int floorBottom, + int floorTop, + int y + ) { + if (this.isStorefrontFacade(blueprint, floorIndex)) { + int edgeCoord = this.facadeEdgeCoord(blueprint, boundaryDistance, worldX, worldZ); + if (edgeCoord >= 0) { + if (floorTop - floorBottom >= 3 && y == floorBottom + 3) { + return Math.floorMod(edgeCoord, 5) == 0 ? palette.trim() : this.storefrontAccentState(blueprint, worldX, worldZ); + } + if (y >= floorBottom + 1 && y <= Math.min(floorTop, floorBottom + 2)) { + return Math.floorMod(edgeCoord, 4) == 0 ? palette.trim() : palette.window(); + } + } + } + + return this.shouldPlaceWindow(blueprint, boundaryDistance, worldX, worldZ, floorIndex, floorBottom, floorTop, y) + ? palette.window() + : palette.wall(); + } + + private boolean isStorefrontFacade(BuildingBlueprint blueprint, int floorIndex) { + if (floorIndex != 0) { + return false; + } + BuildingProfile profile = blueprint.profile(); + if (profile.archetype() == BuildingProfile.Archetype.COMMERCIAL) { + return true; + } + String type = profile.primaryType(); + return type.contains("shop") + || type.contains("retail") + || type.contains("commercial") + || type.contains("mall") + || type.contains("market") + || type.contains("supermarket") + || type.contains("restaurant") + || type.contains("hotel") + || type.contains("office"); + } + + private int facadeEdgeCoord(BuildingBlueprint blueprint, int boundaryDistance, int worldX, int worldZ) { + int setback = blueprint.setbackForFloor(0); + int minWorldX = blueprint.minWorldX() + setback; + int maxWorldX = blueprint.maxWorldX() - setback; + int minWorldZ = blueprint.minWorldZ() + setback; + int maxWorldZ = blueprint.maxWorldZ() - setback; + if ((worldX == minWorldX || worldX == maxWorldX) && worldZ >= minWorldZ && worldZ <= maxWorldZ) { + return worldZ - minWorldZ; + } + if ((worldZ == minWorldZ || worldZ == maxWorldZ) && worldX >= minWorldX && worldX <= maxWorldX) { + return worldX - minWorldX; + } + return boundaryDistance == 0 ? 0 : -1; + } + + private BlockState storefrontAccentState(BuildingBlueprint blueprint, int worldX, int worldZ) { + int roll = Math.floorMod((int)(blueprint.blueprintSeed() ^ worldX * 73428767L ^ worldZ * 912931L), 5); + return switch (roll) { + case 0 -> Blocks.RED_WOOL.defaultBlockState(); + case 1 -> Blocks.BLUE_WOOL.defaultBlockState(); + case 2 -> Blocks.GREEN_WOOL.defaultBlockState(); + case 3 -> Blocks.YELLOW_WOOL.defaultBlockState(); + default -> Blocks.WHITE_WOOL.defaultBlockState(); + }; + } + private boolean shouldPlaceWindow( BuildingBlueprint blueprint, int boundaryDistance, @@ -6484,7 +8541,7 @@ private EarthChunkGenerator.PreparedChunkRoadLights prepareRoadLightsForChunk( continue; } - int roadWidth = roadWidthForClass(road.roadClass(), widths); + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); if (roadWidth <= 0 || road.pointCount() < 2) { continue; } @@ -8208,6 +10265,25 @@ private void stripIglooStarts(RegistryAccess registryAccess, ChunkAccess chunk) } } + private static ArnisTreeType wildTreeTypeForBiome(Holder biome, long seed) { + if (biome.is(Biomes.BIRCH_FOREST) || biome.is(Biomes.OLD_GROWTH_BIRCH_FOREST)) { + return ArnisTreeType.BIRCH; + } + if (biome.is(Biomes.TAIGA) || biome.is(Biomes.SNOWY_TAIGA) || biome.is(Biomes.OLD_GROWTH_PINE_TAIGA) || biome.is(Biomes.OLD_GROWTH_SPRUCE_TAIGA) || biome.is(Biomes.GROVE)) { + return ArnisTreeType.SPRUCE; + } + if (biome.is(Biomes.DARK_FOREST)) { + return ArnisTreeType.DARK_OAK; + } + if (biome.is(Biomes.JUNGLE) || biome.is(Biomes.SPARSE_JUNGLE) || biome.is(Biomes.BAMBOO_JUNGLE)) { + return ArnisTreeType.JUNGLE; + } + if (biome.is(Biomes.SAVANNA) || biome.is(Biomes.SAVANNA_PLATEAU) || biome.is(Biomes.WINDSWEPT_SAVANNA) || biome.is(Biomes.WOODED_BADLANDS)) { + return ArnisTreeType.ACACIA; + } + return ArnisTreeType.chooseDefault(seed); + } + private static List> treeFeaturesForBiome(Holder biome) { return TREE_FEATURES.computeIfAbsent(biome, holder -> { List> result = new ArrayList<>(); @@ -8935,12 +11011,16 @@ private static final class OsmOverlayScratch { private final Long2ObjectOpenHashMap edgeColumnCache = new Long2ObjectOpenHashMap<>(); private byte[] resolvedClass = new byte[CHUNK_AREA]; private byte[] resolvedMode = new byte[CHUNK_AREA]; + private byte[] resolvedSurface = new byte[CHUNK_AREA]; private int[] resolvedDeckY = new int[CHUNK_AREA]; + private int[] resolvedWidth = new int[CHUNK_AREA]; private boolean[] resolvedTunnelCarve = new boolean[CHUNK_AREA]; private boolean[] blockedByHigherClass = new boolean[CHUNK_AREA]; private boolean[] bridgeOverlayPresent = new boolean[CHUNK_AREA]; private int[] bridgeOverlayDeckY = new int[CHUNK_AREA]; private byte[] bridgeOverlayClass = new byte[CHUNK_AREA]; + private byte[] bridgeOverlaySurface = new byte[CHUNK_AREA]; + private int[] bridgeOverlayWidth = new int[CHUNK_AREA]; private boolean[] bridgeSupportShaftPresent = new boolean[CHUNK_AREA]; private int[] bridgeSupportShaftBottomY = new int[CHUNK_AREA]; private int[] bridgeSupportShaftTopY = new int[CHUNK_AREA]; @@ -8949,14 +11029,19 @@ private static final class OsmOverlayScratch { private int[] bridgeSupportCapTopY = new int[CHUNK_AREA]; private boolean[] candidatePresent = new boolean[CHUNK_AREA]; private int[] candidateDeckY = new int[CHUNK_AREA]; + private int[] candidateWidth = new int[CHUNK_AREA]; private byte[] candidateMode = new byte[CHUNK_AREA]; + private byte[] candidateSurface = new byte[CHUNK_AREA]; private boolean[] candidateTunnelCarve = new boolean[CHUNK_AREA]; private boolean[] bridgeCandidatePresent = new boolean[CHUNK_AREA]; private int[] bridgeCandidateDeckY = new int[CHUNK_AREA]; + private int[] bridgeCandidateWidth = new int[CHUNK_AREA]; + private byte[] bridgeCandidateSurface = new byte[CHUNK_AREA]; private int[] placed = new int[CHUNK_AREA]; private final byte[] chunkRoadClass = new byte[CHUNK_AREA]; private final byte[] chunkRoadMode = new byte[CHUNK_AREA]; private final int[] chunkRoadDeckY = new int[CHUNK_AREA]; + private final int[] chunkRoadWidth = new int[CHUNK_AREA]; private final boolean[] chunkTunnelNeedsCarve = new boolean[CHUNK_AREA]; private final boolean[] tunnelCarveMask = new boolean[CHUNK_AREA]; private final int[] tunnelCarveDeckY = new int[CHUNK_AREA]; @@ -8966,12 +11051,16 @@ private void ensureRoadExtCapacity(int extArea) { if (this.resolvedClass.length < extArea) { this.resolvedClass = new byte[extArea]; this.resolvedMode = new byte[extArea]; + this.resolvedSurface = new byte[extArea]; this.resolvedDeckY = new int[extArea]; + this.resolvedWidth = new int[extArea]; this.resolvedTunnelCarve = new boolean[extArea]; this.blockedByHigherClass = new boolean[extArea]; this.bridgeOverlayPresent = new boolean[extArea]; this.bridgeOverlayDeckY = new int[extArea]; this.bridgeOverlayClass = new byte[extArea]; + this.bridgeOverlaySurface = new byte[extArea]; + this.bridgeOverlayWidth = new int[extArea]; this.bridgeSupportShaftPresent = new boolean[extArea]; this.bridgeSupportShaftBottomY = new int[extArea]; this.bridgeSupportShaftTopY = new int[extArea]; @@ -8980,10 +11069,14 @@ private void ensureRoadExtCapacity(int extArea) { this.bridgeSupportCapTopY = new int[extArea]; this.candidatePresent = new boolean[extArea]; this.candidateDeckY = new int[extArea]; + this.candidateWidth = new int[extArea]; this.candidateMode = new byte[extArea]; + this.candidateSurface = new byte[extArea]; this.candidateTunnelCarve = new boolean[extArea]; this.bridgeCandidatePresent = new boolean[extArea]; this.bridgeCandidateDeckY = new int[extArea]; + this.bridgeCandidateWidth = new int[extArea]; + this.bridgeCandidateSurface = new byte[extArea]; this.placed = new int[extArea]; } } @@ -8991,12 +11084,16 @@ private void ensureRoadExtCapacity(int extArea) { private void clearRoadExtState(int extArea) { Arrays.fill(this.resolvedClass, 0, extArea, (byte)0); Arrays.fill(this.resolvedMode, 0, extArea, (byte)0); + Arrays.fill(this.resolvedSurface, 0, extArea, (byte)0); Arrays.fill(this.resolvedDeckY, 0, extArea, 0); + Arrays.fill(this.resolvedWidth, 0, extArea, 0); Arrays.fill(this.resolvedTunnelCarve, 0, extArea, false); Arrays.fill(this.blockedByHigherClass, 0, extArea, false); Arrays.fill(this.bridgeOverlayPresent, 0, extArea, false); Arrays.fill(this.bridgeOverlayDeckY, 0, extArea, 0); Arrays.fill(this.bridgeOverlayClass, 0, extArea, (byte)0); + Arrays.fill(this.bridgeOverlaySurface, 0, extArea, (byte)0); + Arrays.fill(this.bridgeOverlayWidth, 0, extArea, 0); Arrays.fill(this.bridgeSupportShaftPresent, 0, extArea, false); Arrays.fill(this.bridgeSupportShaftBottomY, 0, extArea, 0); Arrays.fill(this.bridgeSupportShaftTopY, 0, extArea, 0); @@ -9008,10 +11105,14 @@ private void clearRoadExtState(int extArea) { private void clearRoadCandidateState(int extArea) { Arrays.fill(this.candidatePresent, 0, extArea, false); Arrays.fill(this.candidateDeckY, 0, extArea, 0); + Arrays.fill(this.candidateWidth, 0, extArea, 0); Arrays.fill(this.candidateMode, 0, extArea, (byte)0); + Arrays.fill(this.candidateSurface, 0, extArea, (byte)0); Arrays.fill(this.candidateTunnelCarve, 0, extArea, false); Arrays.fill(this.bridgeCandidatePresent, 0, extArea, false); Arrays.fill(this.bridgeCandidateDeckY, 0, extArea, 0); + Arrays.fill(this.bridgeCandidateWidth, 0, extArea, 0); + Arrays.fill(this.bridgeCandidateSurface, 0, extArea, (byte)0); } } @@ -10248,6 +12349,7 @@ private static enum FullChunkPhase { DECORATION_AXOLOTLS("axolotls"), DECORATION_TREES("trees"), DECORATION_BUILDINGS("buildings"), + DECORATION_CITY_DETAILS("cityDetails"), DECORATION_REALTIME_SNOW("realtimeSnow"), DECORATION_ROAD_LIGHTS("roadLights"), DECORATION_DEFERRED_APPLY("deferredApply"), @@ -10456,7 +12558,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -10563,21 +12665,19 @@ private static void maybeLog() { long now = System.nanoTime(); long next = NEXT_LOG_AT_NS.get(); if (now >= next && NEXT_LOG_AT_NS.compareAndSet(next, now + LOG_INTERVAL_NS)) { - Tellus.LOGGER.info( - "Terrain streaming perf 15s: shell(chunks={},heightMisses={},coverMisses={},visualMisses={},waterFallbacks={},heightFallbacks={},chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections={}) refinement(queued={},applied={},staleDrops={},detailDelays={})", - new Object[]{ - SHELL_CHUNKS.sumThenReset(), - SHELL_HEIGHT_MISSES.sumThenReset(), - SHELL_COVER_MISSES.sumThenReset(), - SHELL_VISUAL_MISSES.sumThenReset(), - SHELL_WATER_FALLBACKS.sumThenReset(), - SHELL_HEIGHT_FALLBACKS.sumThenReset(), - PREFETCH_QUEUE_REJECTIONS.sumThenReset(), - REFINEMENT_QUEUED.sumThenReset(), - REFINEMENT_APPLIES.sumThenReset(), - REFINEMENT_STALE_DROPS.sumThenReset(), - DETAIL_DELAYS.sumThenReset() - } + TellusDiagnostics.worldgen( + "Terrain streaming perf 15s: shell(chunks=%d,heightMisses=%d,coverMisses=%d,visualMisses=%d,waterFallbacks=%d,heightFallbacks=%d,chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections=%d) refinement(queued=%d,applied=%d,staleDrops=%d,detailDelays=%d)", + SHELL_CHUNKS.sumThenReset(), + SHELL_HEIGHT_MISSES.sumThenReset(), + SHELL_COVER_MISSES.sumThenReset(), + SHELL_VISUAL_MISSES.sumThenReset(), + SHELL_WATER_FALLBACKS.sumThenReset(), + SHELL_HEIGHT_FALLBACKS.sumThenReset(), + PREFETCH_QUEUE_REJECTIONS.sumThenReset(), + REFINEMENT_QUEUED.sumThenReset(), + REFINEMENT_APPLIES.sumThenReset(), + REFINEMENT_STALE_DROPS.sumThenReset(), + DETAIL_DELAYS.sumThenReset() ); } } @@ -10676,20 +12776,18 @@ private static void maybeLog() { } private static void logAndReset() { - Tellus.LOGGER.info( - "Chunk detail perf 15s: baseTerrain(total={}ms,calls={}) detailJob(total={}ms,calls={}) detailApply(total={}ms,calls={}) skippedFallbacks={} staleDrops={} failures={} maxQueueDepth={}", - new Object[]{ - toMillis(BASE_TERRAIN_NS.sumThenReset()), - BASE_TERRAIN_CALLS.sumThenReset(), - toMillis(DETAIL_JOB_NS.sumThenReset()), - DETAIL_JOB_CALLS.sumThenReset(), - toMillis(DETAIL_APPLY_NS.sumThenReset()), - DETAIL_APPLY_CALLS.sumThenReset(), - SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), - STALE_DROPS.sumThenReset(), - FAILURES.sumThenReset(), - MAX_QUEUE_DEPTH.getAndSet(0L) - } + TellusDiagnostics.worldgen( + "Chunk detail perf 15s: baseTerrain(total=%sms,calls=%d) detailJob(total=%sms,calls=%d) detailApply(total=%sms,calls=%d) skippedFallbacks=%d staleDrops=%d failures=%d maxQueueDepth=%d", + toMillis(BASE_TERRAIN_NS.sumThenReset()), + BASE_TERRAIN_CALLS.sumThenReset(), + toMillis(DETAIL_JOB_NS.sumThenReset()), + DETAIL_JOB_CALLS.sumThenReset(), + toMillis(DETAIL_APPLY_NS.sumThenReset()), + DETAIL_APPLY_CALLS.sumThenReset(), + SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), + STALE_DROPS.sumThenReset(), + FAILURES.sumThenReset(), + MAX_QUEUE_DEPTH.getAndSet(0L) ); } diff --git a/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java b/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java index 06ae318bc..53eefdb93 100644 --- a/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java +++ b/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java @@ -88,14 +88,14 @@ public record EarthGeneratorSettings( private static final int FIXED_DH_OSM_BUILDING_MAX_DETAIL = 6; private static final boolean FIXED_DH_OSM_NON_BLOCKING_FETCH = true; public static final EarthGeneratorSettings DEFAULT = new EarthGeneratorSettings( - 30.0, + 1.0, 1.0, 1.0, 64, - -2147483647, + 62, 27.9881, 86.925, - -64, + Integer.MIN_VALUE, Integer.MIN_VALUE, 5, 5, @@ -103,25 +103,25 @@ public record EarthGeneratorSettings( false, false, false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, false, FIXED_DH_OSM_FEATURES, FIXED_DH_OSM_ROAD_MAX_DETAIL, @@ -135,9 +135,9 @@ public record EarthGeneratorSettings( 4, EarthGeneratorSettings.DistantHorizonsRenderMode.FAST, EarthGeneratorSettings.DemSelection.automaticSelection(), - false, - false, - false + true, + true, + true ); private static final MapCodec BASE_TOGGLES_CODEC = RecordCodecBuilder.mapCodec( instance -> instance.group( diff --git a/mc261/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java b/mc261/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java index 45eb94173..cb122b465 100644 --- a/mc261/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java +++ b/mc261/src/client/java/com/yucareux/tellus/client/screen/EarthCustomizeScreen.java @@ -409,7 +409,7 @@ private EarthGeneratorSettings buildSettings() { double terrestrialScale = this.findSliderValue("terrestrial_height_scale", EarthGeneratorSettings.DEFAULT.terrestrialHeightScale()); double oceanicScale = this.findSliderValue("oceanic_height_scale", EarthGeneratorSettings.DEFAULT.oceanicHeightScale()); int heightOffset = (int)Math.round(this.findSliderValue("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset())); - int seaLevel = this.resolveSeaLevelSetting("sea_level", -64.0); + int seaLevel = this.resolveSeaLevelSetting("sea_level", 62.0); int maxAltitude = this.resolveAltitudeSetting("max_altitude", -1.0); int minAltitude = this.resolveAltitudeSetting("min_altitude", -2048.0); int riverLakeShorelineBlend = (int)Math.round( @@ -557,7 +557,7 @@ private void applySettingsToCategories(EarthGeneratorSettings settings, boolean this.setSliderValue("terrestrial_height_scale", initialSettings.terrestrialHeightScale()); this.setSliderValue("oceanic_height_scale", initialSettings.oceanicHeightScale()); this.setSliderValue("height_offset", initialSettings.heightOffset()); - this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? -64.0 : initialSettings.seaLevel()); + this.setSliderValue("sea_level", initialSettings.seaLevel() == -2147483647 ? 62.0 : initialSettings.seaLevel()); this.setSliderValue("max_altitude", initialSettings.maxAltitude() == Integer.MIN_VALUE ? -1.0 : initialSettings.maxAltitude()); this.setSliderValue("min_altitude", initialSettings.minAltitude() == Integer.MIN_VALUE ? -2048.0 : initialSettings.minAltitude()); this.setSliderValue("river_lake_shoreline_blend", initialSettings.riverLakeShorelineBlend()); @@ -766,7 +766,7 @@ private List createCategories() { ).hideFromRoot().parent("world"); List worldSettings = new ArrayList<>( List.of( - slider("world_scale", 30.0, 1.0, 500.0, 5.0) + slider("world_scale", EarthGeneratorSettings.DEFAULT.worldScale(), 1.0, 500.0, 5.0) .withDisplay(EarthCustomizeScreen::formatWorldScale) .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), this.categoryLink(demProvidersCategory) @@ -789,7 +789,7 @@ private List createCategories() { .withScale(EarthCustomizeScreen.SliderScale.power(3.0)), slider("height_offset", EarthGeneratorSettings.DEFAULT.heightOffset(), -2000.0, 128.0, 1.0) .withDisplay(EarthCustomizeScreen::formatHeightOffset), - slider("sea_level", -64.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), + slider("sea_level", 62.0, -64.0, 256.0, 1.0).withDisplay(EarthCustomizeScreen::formatSeaLevel), slider("max_altitude", -1.0, -1.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMaxAltitude), slider("min_altitude", EarthGeneratorSettings.DEFAULT.minAltitude(), -2048.0, 2031.0, 16.0).withDisplay(EarthCustomizeScreen::formatMinAltitude), slider("river_lake_shoreline_blend", EarthGeneratorSettings.DEFAULT.riverLakeShorelineBlend(), 0.0, 10.0, 1.0) diff --git a/mc261/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java b/mc261/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java index 6f3ba8592..6756c2abb 100644 --- a/mc261/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java +++ b/mc261/src/client/java/com/yucareux/tellus/client/widget/map/SlippyMapTileCache.java @@ -6,6 +6,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.mojang.blaze3d.platform.NativeImage; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -14,10 +15,12 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; import java.nio.channels.ClosedByInterruptException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ExecutorService; @@ -31,6 +34,16 @@ @Environment(EnvType.CLIENT) public class SlippyMapTileCache { private static final int CACHE_SIZE = 1024; + private static final String TILE_ENDPOINTS_PROPERTY = "tellus.map.tile.endpoints"; + private static final String DEFAULT_TILE_ENDPOINTS = String.join( + ",", + "https://tile.openstreetmap.org/%d/%d/%d.png", + "https://a.tile.openstreetmap.org/%d/%d/%d.png", + "https://b.tile.openstreetmap.org/%d/%d/%d.png", + "https://c.tile.openstreetmap.org/%d/%d/%d.png", + "https://tile.openstreetmap.de/%d/%d/%d.png" + ); + private static final String[] TILE_ENDPOINTS = parseTileEndpoints(System.getProperty(TILE_ENDPOINTS_PROPERTY, DEFAULT_TILE_ENDPOINTS)); private final ExecutorService loadingService = Executors.newFixedThreadPool( 4, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("tellus-map-load-%d").build() ); @@ -113,16 +126,34 @@ private byte[] readTileData(SlippyMapTilePos pos) throws IOException { if (Files.exists(cachePath)) { return Files.readAllBytes(cachePath); } else { - URI uri = URI.create(String.format("https://tile.openstreetmap.org/%s/%s/%s.png", pos.getZoom(), pos.getX(), pos.getY())); - URL url = uri.toURL(); - HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + byte[] data = this.fetchTileData(pos); + if (!this.shuttingDown && !Thread.currentThread().isInterrupted()) { + this.cacheData(cachePath, data); + } + return data; + } + } + + private byte[] fetchTileData(SlippyMapTilePos pos) throws IOException { + IOException lastError = null; + for (String endpoint : TILE_ENDPOINTS) { + String requestUrl; + try { + requestUrl = String.format(Locale.ROOT, endpoint, pos.getZoom(), pos.getX(), pos.getY()); + } catch (RuntimeException error) { + lastError = new IOException("Invalid map tile endpoint template: " + endpoint, error); + TellusDiagnostics.traffic("Slippy map tile endpoint invalid endpoint=%s error=%s", endpoint, lastError.getMessage()); + continue; + } + + HttpURLConnection connection = (HttpURLConnection)URI.create(requestUrl).toURL().openConnection(); try { connection.setConnectTimeout(5000); connection.setReadTimeout(5000); connection.setRequestProperty("User-Agent", "Tellus/2.0.0 (Minecraft Mod)"); int responseCode = connection.getResponseCode(); if (responseCode != 200) { - throw new IOException("OpenStreetMap tile request failed with HTTP " + responseCode + " for " + pos); + throw new IOException("HTTP " + responseCode); } InputStream stream = Objects.requireNonNull(connection.getInputStream(), "tileStream"); @@ -130,17 +161,32 @@ private byte[] readTileData(SlippyMapTilePos pos) throws IOException { try (InputStream input = new BufferedInputStream(stream)) { byte[] data = input.readAllBytes(); - if (!this.shuttingDown && !Thread.currentThread().isInterrupted()) { - this.cacheData(cachePath, data); - } + TellusDiagnostics.traffic("Slippy map tile ok tile=%s endpoint=%s bytes=%d", pos, requestUrl, data.length); return data; } finally { this.loadingStreams.remove(stream); } + } catch (IOException error) { + lastError = error; + TellusDiagnostics.traffic("Slippy map tile failed tile=%s endpoint=%s error=%s", pos, requestUrl, error.getMessage()); } finally { connection.disconnect(); } } + + throw new IOException("all map tile endpoints failed for " + pos, lastError); + } + + private static String[] parseTileEndpoints(String config) { + String[] parts = Objects.requireNonNull(config, "tileEndpoints").split(","); + List parsed = new ArrayList<>(parts.length); + for (String part : parts) { + String trimmed = part == null ? "" : part.trim(); + if (!trimmed.isEmpty()) { + parsed.add(trimmed); + } + } + return parsed.isEmpty() ? new String[]{"https://tile.openstreetmap.org/%d/%d/%d.png"} : parsed.toArray(String[]::new); } private boolean isCancelledLoad(IOException error) { diff --git a/mc261/src/main/java/com/yucareux/tellus/Tellus.java b/mc261/src/main/java/com/yucareux/tellus/Tellus.java index 531e039de..a83e9bc49 100644 --- a/mc261/src/main/java/com/yucareux/tellus/Tellus.java +++ b/mc261/src/main/java/com/yucareux/tellus/Tellus.java @@ -273,7 +273,10 @@ private static int openGeoTpMap(CommandSourceStack source) { if (level.getChunkSource().getGenerator() instanceof EarthChunkGenerator earthGenerator) { double latitude = clampLatitude(earthGenerator.latitudeFromBlock(player.getZ())); double longitude = clampLongitude(earthGenerator.longitudeFromBlock(player.getX())); - ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude)); + EarthGeneratorSettings settings = earthGenerator.settings(); + double spawnLatitude = clampLatitude(settings.spawnLatitude()); + double spawnLongitude = clampLongitude(settings.spawnLongitude()); + ServerPlayNetworking.send(player, new GeoTpOpenMapPayload(latitude, longitude, spawnLatitude, spawnLongitude)); return 1; } else { source.sendFailure(Component.literal("Tellus: GeoTP map is only available in Tellus worlds.")); diff --git a/mc261/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java b/mc261/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java index c9d6ae792..f3af76e25 100644 --- a/mc261/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java +++ b/mc261/src/main/java/com/yucareux/tellus/world/data/osm/TellusOsmBuildingSource.java @@ -456,6 +456,8 @@ private OsmBuildingFeature parseFeature(Feature feature, Layer layer, TileKey ke private static OsmBuildingMetadata resolveMetadata(Map tags, double heightMeters) { int floorCount = resolveFloorCount(tags, heightMeters); + Double roofLevels = parseDouble(firstNonBlank(tags, "roof_levels", "roof:levels")); + Double roofHeight = parseDouble(firstNonBlank(tags, "roof_height", "roof:height")); return new OsmBuildingMetadata( firstNonBlank(tags, "class", "building_class", "category", "kind"), firstNonBlank(tags, "subtype", "building_subtype", "building", "type"), @@ -463,7 +465,12 @@ private static OsmBuildingMetadata resolveMetadata(Map tags, dou firstNonBlank(tags, "@name", "name"), floorCount, firstNonBlank(tags, "roof_shape", "roof:shape"), - firstNonBlank(tags, "roof_material", "roof:material", "roof:colour", "roof_color") + roofLevels == null ? 0 : Math.max(0, (int)Math.round(roofLevels)), + roofHeight == null ? 0.0 : Math.max(0.0, roofHeight), + firstNonBlank(tags, "roof_material", "roof:material"), + firstNonBlank(tags, "wall_material", "building_material", "building:material", "facade_material", "facade:material", "material"), + firstNonBlank(tags, "roof_color", "roof_colour", "roof:color", "roof:colour"), + firstNonBlank(tags, "wall_color", "wall_colour", "building_color", "building_colour", "building:color", "building:colour", "facade:color", "facade:colour", "color", "colour") ); } diff --git a/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java index 59ee9e80c..c19523a4e 100644 --- a/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java +++ b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java @@ -3,6 +3,7 @@ import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import com.yucareux.tellus.world.data.cover.TellusLandCoverSource; import com.yucareux.tellus.world.data.elevation.TellusElevationSource; import com.yucareux.tellus.world.data.koppen.TellusKoppenSource; @@ -388,6 +389,17 @@ public EarthChunkGenerator(BiomeSource biomeSource, EarthGeneratorSettings setti } ); } + TellusDiagnostics.worldgen( + "EarthChunkGenerator init: scale=%s minAltitude=%d maxAltitude=%d heightOffset=%d limits=[minY=%d,height=%d,logicalHeight=%d] seaLevel=%d", + settings.worldScale(), + settings.minAltitude(), + settings.maxAltitude(), + settings.heightOffset(), + limits.minY(), + limits.height(), + limits.logicalHeight(), + this.seaLevel + ); } public static EarthChunkGenerator create(Provider registries, EarthGeneratorSettings settings) { @@ -812,6 +824,19 @@ private void fillTellusSurface( RandomState random, StructureManager structures this.settings.maxAltitude() } ); + TellusDiagnostics.worldgen( + "fillFromNoise layout: chunkPos=%s minY=%d height=%d maxY=%d sections=%d genMinY=%d genHeight=%d seaLevel=%d settingsMinAlt=%d settingsMaxAlt=%d", + pos, + chunkMinY, + chunkHeight, + chunkMinY + chunkHeight - 1, + chunkHeight >> 4, + this.minY, + this.height, + this.seaLevel, + this.settings.minAltitude(), + this.settings.maxAltitude() + ); } int deepslateStart = this.minY + 64; @@ -911,7 +936,7 @@ private void fillTellusSurface( RandomState random, StructureManager structures int[] convexities = new int[CHUNK_AREA]; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = useFastFullChunk && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(useFastFullChunk, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; phaseStartNs = beginFullChunkProfiling(); @@ -5007,7 +5032,7 @@ private EarthChunkGenerator.PreparedTerrainRefinement buildPreparedTerrainRefine this.repairAnomalousChunkTerrain(terrainSurfaces, waterSurfaces, waterFlags, coverClasses, heightGrid, gridSize, step, chunkMinY, shell.maxY()); EarthBiomeSource earthBiomeSource = this.biomeSource instanceof EarthBiomeSource typedEarthBiomeSource ? typedEarthBiomeSource : null; - EarthChunkGenerator.ChunkBiomeClimateCache climateCache = FAST_FULL_CHUNK && earthBiomeSource != null + EarthChunkGenerator.ChunkBiomeClimateCache climateCache = shouldUseChunkClimateCache(FAST_FULL_CHUNK, earthBiomeSource, this.settings.worldScale()) ? new EarthChunkGenerator.ChunkBiomeClimateCache(pos, this.settings.worldScale()) : null; Holder[] biomeCache = newBiomeCache(CHUNK_AREA); @@ -5275,6 +5300,10 @@ private void fillChunkTerrainMetricsAndBiomes( } } + private static boolean shouldUseChunkClimateCache(boolean fastFullChunk, EarthBiomeSource earthBiomeSource, double worldScale) { + return fastFullChunk && earthBiomeSource != null && worldScale > 1.5; + } + private void applyPreparedTerrainRefinement(ServerLevel level, ChunkAccess chunk, EarthChunkGenerator.PreparedTerrainRefinement refinement) { long chunkKey = ChunkPos.pack(chunk.getPos().x(), chunk.getPos().z()); if (!Objects.equals(this.terrainGenerationStamps.get(chunkKey), refinement.generationStamp())) { @@ -10448,7 +10477,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -10555,21 +10584,19 @@ private static void maybeLog() { long now = System.nanoTime(); long next = NEXT_LOG_AT_NS.get(); if (now >= next && NEXT_LOG_AT_NS.compareAndSet(next, now + LOG_INTERVAL_NS)) { - Tellus.LOGGER.info( - "Terrain streaming perf 15s: shell(chunks={},heightMisses={},coverMisses={},visualMisses={},waterFallbacks={},heightFallbacks={},chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections={}) refinement(queued={},applied={},staleDrops={},detailDelays={})", - new Object[]{ - SHELL_CHUNKS.sumThenReset(), - SHELL_HEIGHT_MISSES.sumThenReset(), - SHELL_COVER_MISSES.sumThenReset(), - SHELL_VISUAL_MISSES.sumThenReset(), - SHELL_WATER_FALLBACKS.sumThenReset(), - SHELL_HEIGHT_FALLBACKS.sumThenReset(), - PREFETCH_QUEUE_REJECTIONS.sumThenReset(), - REFINEMENT_QUEUED.sumThenReset(), - REFINEMENT_APPLIES.sumThenReset(), - REFINEMENT_STALE_DROPS.sumThenReset(), - DETAIL_DELAYS.sumThenReset() - } + TellusDiagnostics.worldgen( + "Terrain streaming perf 15s: shell(chunks=%d,heightMisses=%d,coverMisses=%d,visualMisses=%d,waterFallbacks=%d,heightFallbacks=%d,chunkThreadElevationDiskOpens=0,chunkThreadCoverDiskOpens=0,prefetchQueueRejections=%d) refinement(queued=%d,applied=%d,staleDrops=%d,detailDelays=%d)", + SHELL_CHUNKS.sumThenReset(), + SHELL_HEIGHT_MISSES.sumThenReset(), + SHELL_COVER_MISSES.sumThenReset(), + SHELL_VISUAL_MISSES.sumThenReset(), + SHELL_WATER_FALLBACKS.sumThenReset(), + SHELL_HEIGHT_FALLBACKS.sumThenReset(), + PREFETCH_QUEUE_REJECTIONS.sumThenReset(), + REFINEMENT_QUEUED.sumThenReset(), + REFINEMENT_APPLIES.sumThenReset(), + REFINEMENT_STALE_DROPS.sumThenReset(), + DETAIL_DELAYS.sumThenReset() ); } } @@ -10668,20 +10695,18 @@ private static void maybeLog() { } private static void logAndReset() { - Tellus.LOGGER.info( - "Chunk detail perf 15s: baseTerrain(total={}ms,calls={}) detailJob(total={}ms,calls={}) detailApply(total={}ms,calls={}) skippedFallbacks={} staleDrops={} failures={} maxQueueDepth={}", - new Object[]{ - toMillis(BASE_TERRAIN_NS.sumThenReset()), - BASE_TERRAIN_CALLS.sumThenReset(), - toMillis(DETAIL_JOB_NS.sumThenReset()), - DETAIL_JOB_CALLS.sumThenReset(), - toMillis(DETAIL_APPLY_NS.sumThenReset()), - DETAIL_APPLY_CALLS.sumThenReset(), - SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), - STALE_DROPS.sumThenReset(), - FAILURES.sumThenReset(), - MAX_QUEUE_DEPTH.getAndSet(0L) - } + TellusDiagnostics.worldgen( + "Chunk detail perf 15s: baseTerrain(total=%sms,calls=%d) detailJob(total=%sms,calls=%d) detailApply(total=%sms,calls=%d) skippedFallbacks=%d staleDrops=%d failures=%d maxQueueDepth=%d", + toMillis(BASE_TERRAIN_NS.sumThenReset()), + BASE_TERRAIN_CALLS.sumThenReset(), + toMillis(DETAIL_JOB_NS.sumThenReset()), + DETAIL_JOB_CALLS.sumThenReset(), + toMillis(DETAIL_APPLY_NS.sumThenReset()), + DETAIL_APPLY_CALLS.sumThenReset(), + SKIPPED_BLOCKING_FALLBACKS.sumThenReset(), + STALE_DROPS.sumThenReset(), + FAILURES.sumThenReset(), + MAX_QUEUE_DEPTH.getAndSet(0L) ); } diff --git a/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java index 262d53440..a1a79ee49 100644 --- a/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java +++ b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthGeneratorSettings.java @@ -88,14 +88,14 @@ public record EarthGeneratorSettings( private static final int FIXED_DH_OSM_BUILDING_MAX_DETAIL = 6; private static final boolean FIXED_DH_OSM_NON_BLOCKING_FETCH = true; public static final EarthGeneratorSettings DEFAULT = new EarthGeneratorSettings( - 30.0, + 1.0, 1.0, 1.0, 64, - -2147483647, + 62, 27.9881, 86.925, - -64, + Integer.MIN_VALUE, Integer.MIN_VALUE, 5, 5, @@ -103,25 +103,25 @@ public record EarthGeneratorSettings( false, false, false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, false, FIXED_DH_OSM_FEATURES, FIXED_DH_OSM_ROAD_MAX_DETAIL, @@ -135,9 +135,9 @@ public record EarthGeneratorSettings( 4, EarthGeneratorSettings.DistantHorizonsRenderMode.FAST, EarthGeneratorSettings.DemSelection.automaticSelection(), - false, - false, - false + true, + true, + true ); private static final MapCodec BASE_TOGGLES_CODEC = RecordCodecBuilder.mapCodec( instance -> instance.group( diff --git a/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java b/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java index ba07c6c10..52651f7ef 100644 --- a/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java +++ b/src/main/java/com/yucareux/tellus/network/GeoTpOpenMapPayload.java @@ -7,11 +7,19 @@ import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -public record GeoTpOpenMapPayload(double latitude, double longitude) implements CustomPacketPayload { +public record GeoTpOpenMapPayload(double latitude, double longitude, double spawnLatitude, double spawnLongitude) implements CustomPacketPayload { public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(Tellus.id("geotp_open_map")); public static final StreamCodec CODEC = StreamCodec.composite( - ByteBufCodecs.DOUBLE, GeoTpOpenMapPayload::latitude, ByteBufCodecs.DOUBLE, GeoTpOpenMapPayload::longitude, GeoTpOpenMapPayload::fromBoxed + ByteBufCodecs.DOUBLE, + GeoTpOpenMapPayload::latitude, + ByteBufCodecs.DOUBLE, + GeoTpOpenMapPayload::longitude, + ByteBufCodecs.DOUBLE, + GeoTpOpenMapPayload::spawnLatitude, + ByteBufCodecs.DOUBLE, + GeoTpOpenMapPayload::spawnLongitude, + GeoTpOpenMapPayload::fromBoxed ); @@ -19,7 +27,12 @@ public CustomPacketPayload.Type type() { return TYPE; } - private static GeoTpOpenMapPayload fromBoxed(Double latitude, Double longitude) { - return new GeoTpOpenMapPayload(Objects.requireNonNull(latitude, "latitude"), Objects.requireNonNull(longitude, "longitude")); + private static GeoTpOpenMapPayload fromBoxed(Double latitude, Double longitude, Double spawnLatitude, Double spawnLongitude) { + return new GeoTpOpenMapPayload( + Objects.requireNonNull(latitude, "latitude"), + Objects.requireNonNull(longitude, "longitude"), + Objects.requireNonNull(spawnLatitude, "spawnLatitude"), + Objects.requireNonNull(spawnLongitude, "spawnLongitude") + ); } } diff --git a/src/main/java/com/yucareux/tellus/util/TellusDiagnostics.java b/src/main/java/com/yucareux/tellus/util/TellusDiagnostics.java new file mode 100644 index 000000000..c8d195c1a --- /dev/null +++ b/src/main/java/com/yucareux/tellus/util/TellusDiagnostics.java @@ -0,0 +1,100 @@ +package com.yucareux.tellus.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class TellusDiagnostics { + private static final Logger LOGGER = LoggerFactory.getLogger("tellus"); + private static final boolean ENABLED = booleanProperty("tellus.diagnostics.enabled", true); + private static final boolean TRAFFIC_ENABLED = booleanProperty("tellus.diagnostics.traffic", true); + private static final boolean WORLDGEN_ENABLED = booleanProperty("tellus.diagnostics.worldgen", true); + private static final long MAX_BYTES = longProperty("tellus.diagnostics.maxBytes", 10L * 1024L * 1024L, 1024L, 512L * 1024L * 1024L); + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + private TellusDiagnostics() { + } + + public static void traffic(String message) { + if (ENABLED && TRAFFIC_ENABLED) { + append("traffic.log", message); + } + } + + public static void traffic(String format, Object... args) { + if (ENABLED && TRAFFIC_ENABLED) { + traffic(String.format(Locale.ROOT, format, args)); + } + } + + public static void worldgen(String message) { + if (ENABLED && WORLDGEN_ENABLED) { + append("worldgen.log", message); + } + } + + public static void worldgen(String format, Object... args) { + if (ENABLED && WORLDGEN_ENABLED) { + worldgen(String.format(Locale.ROOT, format, args)); + } + } + + private static synchronized void append(String fileName, String message) { + Path path = logRoot().resolve(fileName); + try { + Files.createDirectories(path.getParent()); + rotateIfNeeded(path); + String line = TIMESTAMP_FORMAT.format(OffsetDateTime.now()) + " " + sanitize(message) + System.lineSeparator(); + Files.writeString(path, line, StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); + } catch (IOException | RuntimeException error) { + LOGGER.debug("Failed to write Tellus diagnostics log {}", path, error); + } + } + + private static void rotateIfNeeded(Path path) throws IOException { + if (Files.isRegularFile(path) && Files.size(path) >= MAX_BYTES) { + Path rotated = path.resolveSibling(path.getFileName() + ".1"); + Files.deleteIfExists(rotated); + Files.move(path, rotated, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static Path logRoot() { + try { + return FabricLoader.getInstance().getGameDir().resolve("tellus/logs"); + } catch (RuntimeException error) { + return Path.of("tellus/logs"); + } + } + + private static String sanitize(String message) { + return message == null ? "" : message.replace('\n', ' ').replace('\r', ' '); + } + + private static boolean booleanProperty(String key, boolean defaultValue) { + String value = System.getProperty(key); + return value == null ? defaultValue : Boolean.parseBoolean(value); + } + + private static long longProperty(String key, long defaultValue, long minInclusive, long maxInclusive) { + String value = System.getProperty(key); + if (value == null) { + return defaultValue; + } + try { + long parsed = Long.parseLong(value.trim()); + return Math.max(minInclusive, Math.min(maxInclusive, parsed)); + } catch (NumberFormatException error) { + LOGGER.debug("Invalid long system property {}='{}', using {}", key, value, defaultValue); + return defaultValue; + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaFeature.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaFeature.java new file mode 100644 index 000000000..157e802bc --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaFeature.java @@ -0,0 +1,23 @@ +package com.yucareux.tellus.world.data.integration; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record ExternalAreaFeature( + String source, + String sourceId, + ExternalAreaKind kind, + String typeTag, + List> rings, + Map tags +) { + public ExternalAreaFeature { + source = Objects.requireNonNullElse(source, ""); + sourceId = Objects.requireNonNullElse(sourceId, ""); + kind = Objects.requireNonNull(kind, "kind"); + typeTag = Objects.requireNonNullElse(typeTag, ""); + rings = rings == null ? List.of() : rings.stream().map(List::copyOf).toList(); + tags = tags == null ? Map.of() : Map.copyOf(tags); + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaKind.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaKind.java new file mode 100644 index 000000000..3152609b2 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaKind.java @@ -0,0 +1,10 @@ +package com.yucareux.tellus.world.data.integration; + +public enum ExternalAreaKind { + PARKING, + LANDUSE, + LEISURE, + NATURAL, + WATER, + AMENITY +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingFeature.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingFeature.java new file mode 100644 index 000000000..e62b8df43 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingFeature.java @@ -0,0 +1,68 @@ +package com.yucareux.tellus.world.data.integration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record ExternalBuildingFeature( + String source, + String sourceId, + ExternalBuildingKind kind, + double heightMeters, + double minHeightMeters, + int floorCount, + List> rings, + Map tags +) { + public ExternalBuildingFeature { + source = normalizeRequired(source, "source"); + sourceId = normalizeRequired(sourceId, "sourceId"); + kind = Objects.requireNonNull(kind, "kind"); + if (!Double.isFinite(heightMeters) || heightMeters <= 0.0) { + throw new IllegalArgumentException("heightMeters must be finite and > 0"); + } + if (!Double.isFinite(minHeightMeters) || minHeightMeters < 0.0) { + throw new IllegalArgumentException("minHeightMeters must be finite and >= 0"); + } + if (heightMeters <= minHeightMeters) { + throw new IllegalArgumentException("heightMeters must be greater than minHeightMeters"); + } + if (floorCount < 1) { + throw new IllegalArgumentException("floorCount must be >= 1"); + } + rings = copyRings(rings); + tags = tags == null ? Map.of() : Map.copyOf(tags); + } + + public List outerRing() { + return this.rings.get(0); + } + + public List> innerRings() { + return this.rings.size() <= 1 ? List.of() : this.rings.subList(1, this.rings.size()); + } + + private static List> copyRings(List> rings) { + Objects.requireNonNull(rings, "rings"); + if (rings.isEmpty()) { + throw new IllegalArgumentException("building feature requires at least one ring"); + } + List> copy = new ArrayList<>(rings.size()); + for (List ring : rings) { + List ringCopy = List.copyOf(Objects.requireNonNull(ring, "ring")); + if (ringCopy.size() < 4) { + throw new IllegalArgumentException("building rings require at least four points"); + } + copy.add(ringCopy); + } + return List.copyOf(copy); + } + + private static String normalizeRequired(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingKind.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingKind.java new file mode 100644 index 000000000..dc255176b --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingKind.java @@ -0,0 +1,6 @@ +package com.yucareux.tellus.world.data.integration; + +public enum ExternalBuildingKind { + FOOTPRINT, + PART +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdapters.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdapters.java new file mode 100644 index 000000000..751f3d584 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdapters.java @@ -0,0 +1,232 @@ +package com.yucareux.tellus.world.data.integration; + +import com.yucareux.tellus.world.data.osm.OsmBuildingFeature; +import com.yucareux.tellus.world.data.osm.OsmBuildingKind; +import com.yucareux.tellus.world.data.osm.OsmBuildingMetadata; +import com.yucareux.tellus.world.data.osm.RoadFeature; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class ExternalFeatureAdapters { + public static final String TELLUS_OVERTURE_SOURCE = "tellus-overture"; + private static final long FNV_OFFSET_BASIS = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + + private ExternalFeatureAdapters() { + } + + public static RoadFeature toTellusRoad(ExternalRoadFeature feature) { + Objects.requireNonNull(feature, "feature"); + int pointCount = feature.points().size(); + double[] longitudes = new double[pointCount]; + double[] latitudes = new double[pointCount]; + for (int index = 0; index < pointCount; index++) { + GeoPoint point = feature.points().get(index); + longitudes[index] = point.longitude(); + latitudes[index] = point.latitude(); + } + + return new RoadFeature( + stableFeatureId(feature.source(), feature.sourceId()), + feature.roadClass(), + feature.mode(), + feature.bridgeLevel(), + feature.highwayTag(), + longitudes, + latitudes, + feature.tags() + ); + } + + public static ExternalRoadFeature fromTellusRoad(RoadFeature feature) { + return fromTellusRoad(TELLUS_OVERTURE_SOURCE, feature); + } + + public static ExternalRoadFeature fromTellusRoad(String source, RoadFeature feature) { + Objects.requireNonNull(feature, "feature"); + List points = new ArrayList<>(feature.pointCount()); + for (int index = 0; index < feature.pointCount(); index++) { + points.add(new GeoPoint(feature.latAt(index), feature.lonAt(index))); + } + + Map tags = new LinkedHashMap<>(feature.tags()); + putIfNotBlank(tags, "highway", feature.highwayTag()); + tags.put("road_class", feature.roadClass().name()); + tags.put("road_mode", feature.mode().name()); + if (feature.bridgeLevel() > 0) { + tags.put("bridge_level", Integer.toString(feature.bridgeLevel())); + } + + return new ExternalRoadFeature( + source, + Long.toString(feature.wayId()), + feature.roadClass(), + feature.mode(), + feature.bridgeLevel(), + feature.highwayTag(), + points, + tags + ); + } + + public static OsmBuildingFeature toTellusBuilding(ExternalBuildingFeature feature) { + Objects.requireNonNull(feature, "feature"); + double[][] longitudes = new double[feature.rings().size()][]; + double[][] latitudes = new double[feature.rings().size()][]; + for (int ringIndex = 0; ringIndex < feature.rings().size(); ringIndex++) { + List ring = feature.rings().get(ringIndex); + longitudes[ringIndex] = new double[ring.size()]; + latitudes[ringIndex] = new double[ring.size()]; + for (int pointIndex = 0; pointIndex < ring.size(); pointIndex++) { + GeoPoint point = ring.get(pointIndex); + longitudes[ringIndex][pointIndex] = point.longitude(); + latitudes[ringIndex][pointIndex] = point.latitude(); + } + } + + Map tags = feature.tags(); + OsmBuildingMetadata metadata = new OsmBuildingMetadata( + firstTag(tags, "building_class", "class", "building"), + firstTag(tags, "subtype", "building:part"), + firstTag(tags, "use", "building:use"), + firstTag(tags, "name"), + feature.floorCount(), + firstTag(tags, "roof_shape", "roof:shape"), + intFromTag(firstTag(tags, "roof_levels", "roof:levels"), 0), + doubleFromTag(firstTag(tags, "roof_height", "roof:height"), 0.0), + firstTag(tags, "roof_material", "roof:material"), + firstTag(tags, "wall_material", "building_material", "building:material", "facade_material", "facade:material", "material"), + firstTag(tags, "roof_color", "roof_colour", "roof:color", "roof:colour"), + firstTag(tags, "wall_color", "wall_colour", "building_color", "building_colour", "building:color", "building:colour", "facade:color", "facade:colour", "color", "colour") + ); + OsmBuildingKind kind = feature.kind() == ExternalBuildingKind.PART ? OsmBuildingKind.PART : OsmBuildingKind.FOOTPRINT; + String buildingId = firstTag(tags, "building_id", "building:id"); + if (buildingId == null) { + buildingId = feature.source() + ":" + feature.sourceId(); + } + + return new OsmBuildingFeature( + kind, + stableFeatureId(feature.source(), feature.sourceId()), + buildingId, + Boolean.parseBoolean(firstTag(tags, "has_parts")), + metadata, + feature.heightMeters(), + feature.minHeightMeters(), + longitudes, + latitudes + ); + } + + public static ExternalBuildingFeature fromTellusBuilding(OsmBuildingFeature feature) { + return fromTellusBuilding(TELLUS_OVERTURE_SOURCE, feature); + } + + public static ExternalBuildingFeature fromTellusBuilding(String source, OsmBuildingFeature feature) { + Objects.requireNonNull(feature, "feature"); + List> rings = new ArrayList<>(feature.partCount()); + for (int part = 0; part < feature.partCount(); part++) { + List ring = new ArrayList<>(feature.pointCount(part)); + for (int point = 0; point < feature.pointCount(part); point++) { + ring.add(new GeoPoint(feature.latAt(part, point), feature.lonAt(part, point))); + } + rings.add(ring); + } + + OsmBuildingMetadata metadata = feature.metadata(); + Map tags = new LinkedHashMap<>(); + putIfNotBlank(tags, "building_id", feature.buildingId()); + putIfNotBlank(tags, "building_class", metadata.buildingClass()); + putIfNotBlank(tags, "subtype", metadata.subtype()); + putIfNotBlank(tags, "use", metadata.use()); + putIfNotBlank(tags, "name", metadata.name()); + putIfNotBlank(tags, "roof_shape", metadata.roofShape()); + if (metadata.roofLevels() > 0) { + tags.put("roof_levels", Integer.toString(metadata.roofLevels())); + } + if (metadata.roofHeightMeters() > 0.0) { + tags.put("roof_height", Double.toString(metadata.roofHeightMeters())); + } + putIfNotBlank(tags, "roof_material", metadata.roofMaterial()); + putIfNotBlank(tags, "wall_material", metadata.wallMaterial()); + putIfNotBlank(tags, "roof_color", metadata.roofColor()); + putIfNotBlank(tags, "wall_color", metadata.wallColor()); + if (feature.hasParts()) { + tags.put("has_parts", "true"); + } + + return new ExternalBuildingFeature( + source, + Long.toString(feature.featureId()), + feature.kind() == OsmBuildingKind.PART ? ExternalBuildingKind.PART : ExternalBuildingKind.FOOTPRINT, + feature.heightMeters(), + feature.minHeightMeters(), + metadata.floorCount(), + rings, + tags + ); + } + + private static void putIfNotBlank(Map tags, String key, String value) { + if (value != null && !value.isBlank()) { + tags.put(key, value); + } + } + + private static String firstTag(Map tags, String... keys) { + for (String key : keys) { + String value = tags.get(key); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static int intFromTag(String value, int defaultValue) { + double parsed = doubleFromTag(value, defaultValue); + return Math.max(0, (int)Math.round(parsed)); + } + + private static double doubleFromTag(String value, double defaultValue) { + if (value == null || value.isBlank()) { + return defaultValue; + } + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + if (!seenDigit) { + return defaultValue; + } + try { + double parsed = Double.parseDouble(number.toString()); + return parsed > 0.0 ? parsed : defaultValue; + } catch (NumberFormatException error) { + return defaultValue; + } + } + + private static long stableFeatureId(String source, String sourceId) { + long hash = FNV_OFFSET_BASIS; + String key = source + ":" + sourceId; + for (int index = 0; index < key.length(); index++) { + hash ^= key.charAt(index); + hash *= FNV_PRIME; + } + return hash == 0L ? 1L : hash; + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureSource.java new file mode 100644 index 000000000..77f150912 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureSource.java @@ -0,0 +1,26 @@ +package com.yucareux.tellus.world.data.integration; + +import java.io.IOException; +import java.util.List; + +public interface ExternalFeatureSource extends AutoCloseable { + List roadsForBounds(GeoBounds bounds) throws IOException; + + List buildingsForBounds(GeoBounds bounds) throws IOException; + + default List areasForBounds(GeoBounds bounds) throws IOException { + return List.of(); + } + + default List linesForBounds(GeoBounds bounds) throws IOException { + return List.of(); + } + + default List pointsForBounds(GeoBounds bounds) throws IOException { + return List.of(); + } + + @Override + default void close() throws IOException { + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineFeature.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineFeature.java new file mode 100644 index 000000000..622ad2ae9 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineFeature.java @@ -0,0 +1,23 @@ +package com.yucareux.tellus.world.data.integration; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record ExternalLineFeature( + String source, + String sourceId, + ExternalLineKind kind, + String typeTag, + List points, + Map tags +) { + public ExternalLineFeature { + source = Objects.requireNonNullElse(source, ""); + sourceId = Objects.requireNonNullElse(sourceId, ""); + kind = Objects.requireNonNull(kind, "kind"); + typeTag = Objects.requireNonNullElse(typeTag, ""); + points = points == null ? List.of() : List.copyOf(points); + tags = tags == null ? Map.of() : Map.copyOf(tags); + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineKind.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineKind.java new file mode 100644 index 000000000..1e66c7a6a --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineKind.java @@ -0,0 +1,9 @@ +package com.yucareux.tellus.world.data.integration; + +public enum ExternalLineKind { + BARRIER, + RAILWAY, + WATERWAY, + POWER, + MAN_MADE +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointFeature.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointFeature.java new file mode 100644 index 000000000..8019d145d --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointFeature.java @@ -0,0 +1,22 @@ +package com.yucareux.tellus.world.data.integration; + +import java.util.Map; +import java.util.Objects; + +public record ExternalPointFeature( + String source, + String sourceId, + ExternalPointKind kind, + String typeTag, + GeoPoint point, + Map tags +) { + public ExternalPointFeature { + source = Objects.requireNonNullElse(source, ""); + sourceId = Objects.requireNonNullElse(sourceId, ""); + kind = Objects.requireNonNull(kind, "kind"); + typeTag = Objects.requireNonNullElse(typeTag, ""); + point = Objects.requireNonNull(point, "point"); + tags = tags == null ? Map.of() : Map.copyOf(tags); + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointKind.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointKind.java new file mode 100644 index 000000000..92d28b937 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointKind.java @@ -0,0 +1,18 @@ +package com.yucareux.tellus.world.data.integration; + +public enum ExternalPointKind { + ENTRANCE, + HIGHWAY, + CROSSING, + TRAFFIC_SIGNAL, + AMENITY, + NATURAL, + ADVERTISING, + EMERGENCY, + HISTORIC, + TOURISM, + MAN_MADE, + POWER, + BARRIER, + RAILWAY +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/ExternalRoadFeature.java b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalRoadFeature.java new file mode 100644 index 000000000..649fb3441 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/ExternalRoadFeature.java @@ -0,0 +1,47 @@ +package com.yucareux.tellus.world.data.integration; + +import com.yucareux.tellus.world.data.osm.RoadClass; +import com.yucareux.tellus.world.data.osm.RoadMode; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record ExternalRoadFeature( + String source, + String sourceId, + RoadClass roadClass, + RoadMode mode, + int bridgeLevel, + String highwayTag, + List points, + Map tags +) { + public ExternalRoadFeature { + source = normalizeRequired(source, "source"); + sourceId = normalizeRequired(sourceId, "sourceId"); + roadClass = Objects.requireNonNull(roadClass, "roadClass"); + mode = Objects.requireNonNull(mode, "mode"); + highwayTag = highwayTag == null ? "" : highwayTag.trim().toLowerCase(); + if (bridgeLevel < 0) { + throw new IllegalArgumentException("bridgeLevel must be >= 0"); + } + points = copyLine(points); + tags = tags == null ? Map.of() : Map.copyOf(tags); + } + + private static List copyLine(List points) { + Objects.requireNonNull(points, "points"); + List copy = List.copyOf(points); + if (copy.size() < 2) { + throw new IllegalArgumentException("road feature requires at least two points"); + } + return copy; + } + + private static String normalizeRequired(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/GeoBounds.java b/src/main/java/com/yucareux/tellus/world/data/integration/GeoBounds.java new file mode 100644 index 000000000..78cdc3398 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/GeoBounds.java @@ -0,0 +1,55 @@ +package com.yucareux.tellus.world.data.integration; + +import java.util.Objects; + +public record GeoBounds(double south, double west, double north, double east) { + public GeoBounds { + validateLatitude(south, "south"); + validateLatitude(north, "north"); + validateLongitude(west, "west"); + validateLongitude(east, "east"); + if (south > north) { + throw new IllegalArgumentException("south must be <= north"); + } + if (west > east) { + throw new IllegalArgumentException("west must be <= east"); + } + } + + public boolean contains(GeoPoint point) { + Objects.requireNonNull(point, "point"); + return point.latitude() >= this.south && point.latitude() <= this.north && point.longitude() >= this.west && point.longitude() <= this.east; + } + + public boolean intersects(GeoBounds other) { + Objects.requireNonNull(other, "other"); + return this.east >= other.west && this.west <= other.east && this.north >= other.south && this.south <= other.north; + } + + public GeoBounds expand(double latitudeDegrees, double longitudeDegrees) { + if (!Double.isFinite(latitudeDegrees) || latitudeDegrees < 0.0) { + throw new IllegalArgumentException("latitudeDegrees must be finite and >= 0"); + } + if (!Double.isFinite(longitudeDegrees) || longitudeDegrees < 0.0) { + throw new IllegalArgumentException("longitudeDegrees must be finite and >= 0"); + } + return new GeoBounds( + Math.max(-90.0, this.south - latitudeDegrees), + Math.max(-180.0, this.west - longitudeDegrees), + Math.min(90.0, this.north + latitudeDegrees), + Math.min(180.0, this.east + longitudeDegrees) + ); + } + + private static void validateLatitude(double value, String name) { + if (!Double.isFinite(value) || value < -90.0 || value > 90.0) { + throw new IllegalArgumentException(name + " latitude out of range: " + value); + } + } + + private static void validateLongitude(double value, String name) { + if (!Double.isFinite(value) || value < -180.0 || value > 180.0) { + throw new IllegalArgumentException(name + " longitude out of range: " + value); + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/GeoPoint.java b/src/main/java/com/yucareux/tellus/world/data/integration/GeoPoint.java new file mode 100644 index 000000000..88b23ab7c --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/GeoPoint.java @@ -0,0 +1,12 @@ +package com.yucareux.tellus.world.data.integration; + +public record GeoPoint(double latitude, double longitude) { + public GeoPoint { + if (!Double.isFinite(latitude) || latitude < -90.0 || latitude > 90.0) { + throw new IllegalArgumentException("latitude out of range: " + latitude); + } + if (!Double.isFinite(longitude) || longitude < -180.0 || longitude > 180.0) { + throw new IllegalArgumentException("longitude out of range: " + longitude); + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSource.java new file mode 100644 index 000000000..e8aad0ffe --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSource.java @@ -0,0 +1,414 @@ +package com.yucareux.tellus.world.data.integration; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.yucareux.tellus.world.data.osm.RoadClass; +import com.yucareux.tellus.world.data.osm.RoadMode; +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public final class JsonExternalFeatureSource implements ExternalFeatureSource { + private static final String DEFAULT_SOURCE = "json"; + private final List roads; + private final List buildings; + private final List areas; + private final List lines; + private final List points; + + public JsonExternalFeatureSource(List roads, List buildings) { + this(roads, buildings, List.of(), List.of()); + } + + public JsonExternalFeatureSource( + List roads, + List buildings, + List areas, + List lines + ) { + this(roads, buildings, areas, lines, List.of()); + } + + public JsonExternalFeatureSource( + List roads, + List buildings, + List areas, + List lines, + List points + ) { + this.roads = roads == null ? List.of() : List.copyOf(roads); + this.buildings = buildings == null ? List.of() : List.copyOf(buildings); + this.areas = areas == null ? List.of() : List.copyOf(areas); + this.lines = lines == null ? List.of() : List.copyOf(lines); + this.points = points == null ? List.of() : List.copyOf(points); + } + + public static JsonExternalFeatureSource fromPath(Path path) throws IOException { + Objects.requireNonNull(path, "path"); + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + return fromReader(reader); + } + } + + public static JsonExternalFeatureSource fromReader(Reader reader) throws IOException { + Objects.requireNonNull(reader, "reader"); + JsonElement parsed = JsonParser.parseReader(reader); + if (!parsed.isJsonObject()) { + throw new IOException("external feature JSON root must be an object"); + } + JsonObject root = parsed.getAsJsonObject(); + return new JsonExternalFeatureSource(parseRoads(root), parseBuildings(root), parseAreas(root), parseLines(root), parsePoints(root)); + } + + @Override + public List roadsForBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + List matches = new ArrayList<>(); + for (ExternalRoadFeature road : this.roads) { + if (boundsForLine(road.points()).intersects(bounds)) { + matches.add(road); + } + } + return List.copyOf(matches); + } + + @Override + public List buildingsForBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + List matches = new ArrayList<>(); + for (ExternalBuildingFeature building : this.buildings) { + if (boundsForRings(building.rings()).intersects(bounds)) { + matches.add(building); + } + } + return List.copyOf(matches); + } + + @Override + public List areasForBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + List matches = new ArrayList<>(); + for (ExternalAreaFeature area : this.areas) { + if (boundsForRings(area.rings()).intersects(bounds)) { + matches.add(area); + } + } + return List.copyOf(matches); + } + + @Override + public List linesForBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + List matches = new ArrayList<>(); + for (ExternalLineFeature line : this.lines) { + if (boundsForLine(line.points()).intersects(bounds)) { + matches.add(line); + } + } + return List.copyOf(matches); + } + + @Override + public List pointsForBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + List matches = new ArrayList<>(); + for (ExternalPointFeature point : this.points) { + if (bounds.contains(point.point())) { + matches.add(point); + } + } + return List.copyOf(matches); + } + + public List roads() { + return this.roads; + } + + public List buildings() { + return this.buildings; + } + + public List areas() { + return this.areas; + } + + public List lines() { + return this.lines; + } + + public List points() { + return this.points; + } + + private static List parseRoads(JsonObject root) throws IOException { + JsonArray array = arrayOrEmpty(root, "roads"); + List roads = new ArrayList<>(array.size()); + for (JsonElement element : array) { + JsonObject object = requireObject(element, "road"); + String source = stringOrDefault(object, DEFAULT_SOURCE, "source"); + String sourceId = requiredString(object, "road sourceId", "sourceId", "source_id", "id"); + RoadClass roadClass = enumOrDefault(RoadClass.class, RoadClass.NORMAL, stringOrDefault(object, "NORMAL", "roadClass", "road_class")); + RoadMode mode = enumOrDefault(RoadMode.class, RoadMode.NORMAL, stringOrDefault(object, "NORMAL", "mode", "road_mode")); + int bridgeLevel = intOrDefault(object, 0, "bridgeLevel", "bridge_level"); + String highwayTag = stringOrDefault(object, "", "highwayTag", "highway", "highway_tag"); + List points = parsePoints(requiredArray(object, "road points", "points")); + Map tags = parseTags(object); + roads.add(new ExternalRoadFeature(source, sourceId, roadClass, mode, bridgeLevel, highwayTag, points, tags)); + } + return roads; + } + + private static List parseBuildings(JsonObject root) throws IOException { + JsonArray array = arrayOrEmpty(root, "buildings"); + List buildings = new ArrayList<>(array.size()); + for (JsonElement element : array) { + JsonObject object = requireObject(element, "building"); + String source = stringOrDefault(object, DEFAULT_SOURCE, "source"); + String sourceId = requiredString(object, "building sourceId", "sourceId", "source_id", "id"); + ExternalBuildingKind kind = enumOrDefault( + ExternalBuildingKind.class, + ExternalBuildingKind.FOOTPRINT, + stringOrDefault(object, "FOOTPRINT", "kind", "building_kind") + ); + double heightMeters = doubleOrDefault(object, 6.0, "heightMeters", "height_meters", "height"); + double minHeightMeters = doubleOrDefault(object, 0.0, "minHeightMeters", "min_height_meters", "min_height"); + int floorCount = intOrDefault(object, Math.max(1, (int)Math.round(heightMeters / 3.2)), "floorCount", "floor_count", "building:levels"); + List> rings = parseRings(requiredArray(object, "building rings", "rings")); + Map tags = parseTags(object); + buildings.add(new ExternalBuildingFeature(source, sourceId, kind, heightMeters, minHeightMeters, floorCount, rings, tags)); + } + return buildings; + } + + private static List parseAreas(JsonObject root) throws IOException { + JsonArray array = arrayOrEmpty(root, "areas"); + List areas = new ArrayList<>(array.size()); + for (JsonElement element : array) { + JsonObject object = requireObject(element, "area"); + String source = stringOrDefault(object, DEFAULT_SOURCE, "source"); + String sourceId = requiredString(object, "area sourceId", "sourceId", "source_id", "id"); + ExternalAreaKind kind = enumOrDefault( + ExternalAreaKind.class, + ExternalAreaKind.LANDUSE, + stringOrDefault(object, "LANDUSE", "kind", "area_kind") + ); + String typeTag = stringOrDefault(object, "", "typeTag", "type_tag", "landuse", "leisure", "natural", "amenity"); + List> rings = parseRings(requiredArray(object, "area rings", "rings")); + Map tags = parseTags(object); + areas.add(new ExternalAreaFeature(source, sourceId, kind, typeTag, rings, tags)); + } + return areas; + } + + private static List parseLines(JsonObject root) throws IOException { + JsonArray array = arrayOrEmpty(root, "lines"); + List lines = new ArrayList<>(array.size()); + for (JsonElement element : array) { + JsonObject object = requireObject(element, "line"); + String source = stringOrDefault(object, DEFAULT_SOURCE, "source"); + String sourceId = requiredString(object, "line sourceId", "sourceId", "source_id", "id"); + ExternalLineKind kind = enumOrDefault( + ExternalLineKind.class, + ExternalLineKind.BARRIER, + stringOrDefault(object, "BARRIER", "kind", "line_kind") + ); + String typeTag = stringOrDefault(object, "", "typeTag", "type_tag", "barrier", "railway", "waterway"); + List points = parsePoints(requiredArray(object, "line points", "points")); + Map tags = parseTags(object); + lines.add(new ExternalLineFeature(source, sourceId, kind, typeTag, points, tags)); + } + return lines; + } + + private static List parsePoints(JsonObject root) throws IOException { + JsonArray array = arrayOrEmpty(root, "points"); + List points = new ArrayList<>(array.size()); + for (JsonElement element : array) { + JsonObject object = requireObject(element, "point feature"); + String source = stringOrDefault(object, DEFAULT_SOURCE, "source"); + String sourceId = requiredString(object, "point sourceId", "sourceId", "source_id", "id"); + ExternalPointKind kind = enumOrDefault( + ExternalPointKind.class, + ExternalPointKind.AMENITY, + stringOrDefault(object, "AMENITY", "kind", "point_kind") + ); + String typeTag = stringOrDefault(object, "", "typeTag", "type_tag", "amenity", "highway", "natural", "entrance", "door"); + GeoPoint point = object.has("point") + ? parsePoint(requireObject(object.get("point"), "point")) + : new GeoPoint(requiredDouble(object, "point latitude", "lat", "latitude"), requiredDouble(object, "point longitude", "lon", "lng", "longitude")); + Map tags = parseTags(object); + points.add(new ExternalPointFeature(source, sourceId, kind, typeTag, point, tags)); + } + return points; + } + + private static List parsePoints(JsonArray array) throws IOException { + List points = new ArrayList<>(array.size()); + for (JsonElement element : array) { + points.add(parsePoint(requireObject(element, "point"))); + } + return points; + } + + private static List> parseRings(JsonArray array) throws IOException { + List> rings = new ArrayList<>(array.size()); + for (JsonElement element : array) { + if (!element.isJsonArray()) { + throw new IOException("building ring must be an array"); + } + rings.add(parsePoints(element.getAsJsonArray())); + } + return rings; + } + + private static GeoPoint parsePoint(JsonObject object) throws IOException { + double lat = requiredDouble(object, "point latitude", "lat", "latitude"); + double lon = requiredDouble(object, "point longitude", "lon", "lng", "longitude"); + return new GeoPoint(lat, lon); + } + + private static Map parseTags(JsonObject object) throws IOException { + JsonElement tagsElement = object.get("tags"); + if (tagsElement == null || tagsElement.isJsonNull()) { + return Map.of(); + } + JsonObject tagsObject = requireObject(tagsElement, "tags"); + Map tags = new LinkedHashMap<>(); + for (Map.Entry entry : tagsObject.entrySet()) { + JsonElement value = entry.getValue(); + if (value != null && !value.isJsonNull()) { + tags.put(entry.getKey(), value.isJsonPrimitive() ? value.getAsString() : value.toString()); + } + } + return tags; + } + + private static GeoBounds boundsForLine(List points) { + double south = Double.POSITIVE_INFINITY; + double west = Double.POSITIVE_INFINITY; + double north = Double.NEGATIVE_INFINITY; + double east = Double.NEGATIVE_INFINITY; + for (GeoPoint point : points) { + south = Math.min(south, point.latitude()); + west = Math.min(west, point.longitude()); + north = Math.max(north, point.latitude()); + east = Math.max(east, point.longitude()); + } + return new GeoBounds(south, west, north, east); + } + + private static GeoBounds boundsForRings(List> rings) { + double south = Double.POSITIVE_INFINITY; + double west = Double.POSITIVE_INFINITY; + double north = Double.NEGATIVE_INFINITY; + double east = Double.NEGATIVE_INFINITY; + for (List ring : rings) { + GeoBounds bounds = boundsForLine(ring); + south = Math.min(south, bounds.south()); + west = Math.min(west, bounds.west()); + north = Math.max(north, bounds.north()); + east = Math.max(east, bounds.east()); + } + return new GeoBounds(south, west, north, east); + } + + private static JsonArray arrayOrEmpty(JsonObject object, String name) throws IOException { + JsonElement element = object.get(name); + if (element == null || element.isJsonNull()) { + return new JsonArray(); + } + if (!element.isJsonArray()) { + throw new IOException(name + " must be an array"); + } + return element.getAsJsonArray(); + } + + private static JsonArray requiredArray(JsonObject object, String label, String... names) throws IOException { + for (String name : names) { + JsonElement element = object.get(name); + if (element != null && !element.isJsonNull()) { + if (!element.isJsonArray()) { + throw new IOException(label + " must be an array"); + } + return element.getAsJsonArray(); + } + } + throw new IOException("missing " + label); + } + + private static JsonObject requireObject(JsonElement element, String label) throws IOException { + if (element == null || !element.isJsonObject()) { + throw new IOException(label + " must be an object"); + } + return element.getAsJsonObject(); + } + + private static String requiredString(JsonObject object, String label, String... names) throws IOException { + String value = stringOrDefault(object, null, names); + if (value == null || value.isBlank()) { + throw new IOException("missing " + label); + } + return value; + } + + private static String stringOrDefault(JsonObject object, String defaultValue, String... names) { + for (String name : names) { + JsonElement element = object.get(name); + if (element != null && !element.isJsonNull()) { + return element.getAsString(); + } + } + return defaultValue; + } + + private static double requiredDouble(JsonObject object, String label, String... names) throws IOException { + for (String name : names) { + JsonElement element = object.get(name); + if (element != null && !element.isJsonNull()) { + return element.getAsDouble(); + } + } + throw new IOException("missing " + label); + } + + private static double doubleOrDefault(JsonObject object, double defaultValue, String... names) { + for (String name : names) { + JsonElement element = object.get(name); + if (element != null && !element.isJsonNull()) { + return element.getAsDouble(); + } + } + return defaultValue; + } + + private static int intOrDefault(JsonObject object, int defaultValue, String... names) { + for (String name : names) { + JsonElement element = object.get(name); + if (element != null && !element.isJsonNull()) { + return element.getAsInt(); + } + } + return defaultValue; + } + + private static > T enumOrDefault(Class type, T defaultValue, String value) { + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Enum.valueOf(type, value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return defaultValue; + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java new file mode 100644 index 000000000..19e048585 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -0,0 +1,1797 @@ +package com.yucareux.tellus.world.data.integration; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.yucareux.tellus.util.TellusDiagnostics; +import com.yucareux.tellus.world.data.osm.RoadClass; +import com.yucareux.tellus.world.data.osm.RoadMode; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class OverpassExternalFeatureSource implements ExternalFeatureSource { + public static final String ENABLED_PROPERTY = "tellus.arnis.overpass.enabled"; + public static final String ENDPOINTS_PROPERTY = "tellus.arnis.overpass.endpoints"; + public static final String NETWORK_MODE_PROPERTY = "tellus.arnis.overpass.network"; + public static final String CITY_DETAILS_PROPERTY = "tellus.arnis.overpass.cityDetails"; + public static final String BLOCKING_CITY_DETAILS_NETWORK_PROPERTY = "tellus.arnis.overpass.cityDetails.blockingNetwork"; + public static final String OFFLINE_REGIONS_PROPERTY = "tellus.arnis.offlineRegions.enabled"; + private static final String SOURCE = "arnis-overpass"; + private static final String CITY_CACHE_PROFILE = "city-v7"; + private static final String NETWORK_CACHE_FIRST = "cache-first"; + private static final String NETWORK_CACHE_ONLY = "cache-only"; + private static final String NETWORK_OFF = "off"; + private static final String DEFAULT_ENDPOINTS = String.join( + ",", + "https://overpass-api.de/api/interpreter", + "https://overpass.osm.ch/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://z.overpass-api.de/api/interpreter", + "https://lz4.overpass-api.de/api/interpreter", + "https://overpass.private.coffee/api/interpreter" + ); + private static final Logger LOGGER = LoggerFactory.getLogger("tellus"); + private static final double MIN_LAT = -85.05112878; + private static final double MAX_LAT = 85.05112878; + private static final int QUERY_ZOOM = intProperty("tellus.arnis.overpass.queryZoom", 14, 0, 20); + private static final int QUERY_TIMEOUT_SECONDS = intProperty("tellus.arnis.overpass.queryTimeoutSec", 25, 5, 360); + private static final int CONNECT_TIMEOUT_MS = intProperty("tellus.arnis.overpass.connectTimeoutMs", 5000, 1, 120000); + private static final int READ_TIMEOUT_MS = intProperty("tellus.arnis.overpass.readTimeoutMs", 20000, 1, 360000); + private static final long MIN_REQUEST_SPACING_MS = longProperty("tellus.arnis.overpass.minSpacingMs", 500L, 0L, 60000L); + private static final long FAILURE_COOLDOWN_MS = longProperty("tellus.arnis.overpass.failureCooldownMs", 180000L, 0L, 600000L); + private static final int MAX_KEYS_PER_QUERY = intProperty("tellus.arnis.overpass.maxTilesPerQuery", 16, 1, 256); + private static final int MAX_NETWORK_TILES_PER_SESSION = intProperty("tellus.arnis.overpass.maxNetworkTilesPerSession", 96, 0, 1000000); + private static final int PROBE_TIMEOUT_SECONDS = intProperty("tellus.arnis.overpass.probeTimeoutSec", 5, 1, 60); + private static final int PROBE_CONNECT_TIMEOUT_MS = intProperty("tellus.arnis.overpass.probeConnectTimeoutMs", 3000, 1, 60000); + private static final int PROBE_READ_TIMEOUT_MS = intProperty("tellus.arnis.overpass.probeReadTimeoutMs", 7000, 1, 120000); + private static final int PREFETCH_MAX_TILES = intProperty("tellus.arnis.overpass.prefetchMaxTiles", 32, 1, 4096); + private static final String PROBE_QUERY = String.format(Locale.ROOT, "[out:json][timeout:%d];node(0,0,0.001,0.001);out ids 1;", PROBE_TIMEOUT_SECONDS); + private static final ExecutorService BACKGROUND_CITY_DETAILS_EXECUTOR = Executors.newFixedThreadPool( + intProperty("tellus.arnis.overpass.cityDetails.backgroundThreads", 2, 1, 8), + task -> { + Thread thread = new Thread(task, "Tellus Arnis city details"); + thread.setDaemon(true); + return thread; + } + ); + + private final boolean enabled; + private final boolean networkEnabled; + private final Path cacheRoot; + private final List offlineRegions; + private final URI[] endpoints; + private final ConcurrentMap memoryCache = new ConcurrentHashMap<>(); + private final ConcurrentMap tileLocks = new ConcurrentHashMap<>(); + private final ConcurrentMap> backgroundCityFetches = new ConcurrentHashMap<>(); + private final ConcurrentMap failedUntilMs = new ConcurrentHashMap<>(); + private final ConcurrentMap endpointFailedUntilMs = new ConcurrentHashMap<>(); + private final Semaphore requestGuard = new Semaphore(1, true); + private final AtomicLong nextAllowedRequestMs = new AtomicLong(0L); + private final AtomicLong endpointCursor = new AtomicLong(0L); + private final AtomicLong networkTilesReserved = new AtomicLong(0L); + private final AtomicLong skippedNetworkTiles = new AtomicLong(0L); + + private OverpassExternalFeatureSource(boolean enabled, boolean networkEnabled, Path cacheRoot, List offlineRegions, URI[] endpoints) { + this.enabled = enabled; + this.networkEnabled = networkEnabled; + this.cacheRoot = cacheRoot; + this.offlineRegions = List.copyOf(offlineRegions); + this.endpoints = endpoints; + } + + public static OverpassExternalFeatureSource createDefault() { + boolean enabled = Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); + if (!enabled) { + TellusDiagnostics.traffic("Overpass source disabled by %s=false", ENABLED_PROPERTY); + return disabled(); + } + String networkMode = normalizedNetworkMode(System.getProperty(NETWORK_MODE_PROPERTY, NETWORK_CACHE_FIRST)); + if (NETWORK_OFF.equals(networkMode)) { + TellusDiagnostics.traffic("Overpass source disabled by %s=%s", NETWORK_MODE_PROPERTY, NETWORK_OFF); + return disabled(); + } + Path cacheRoot = defaultCacheRoot(); + List offlineRegions = loadOfflineRegions(defaultOfflineRegionRoot()); + String endpoints = System.getProperty(ENDPOINTS_PROPERTY, DEFAULT_ENDPOINTS); + URI[] parsedEndpoints = parseEndpoints(endpoints); + TellusDiagnostics.traffic( + "Overpass source ready networkMode=%s networkEnabled=%s cacheRoot=%s offlineRegions=%d endpoints=%d queryZoom=%d cityDetails=%s blockingCityDetails=%s maxNetworkTiles=%d", + networkMode, + !NETWORK_CACHE_ONLY.equals(networkMode), + cacheRoot, + offlineRegions.size(), + parsedEndpoints.length, + QUERY_ZOOM, + cityDetailsEnabled(), + blockingCityDetailsNetwork(), + MAX_NETWORK_TILES_PER_SESSION + ); + return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, offlineRegions, parsedEndpoints); + } + + public static CacheEstimate estimateConfiguredCache(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + if (!Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true"))) { + return CacheEstimate.disabled(); + } + + String networkMode = normalizedNetworkMode(System.getProperty(NETWORK_MODE_PROPERTY, NETWORK_CACHE_FIRST)); + if (NETWORK_OFF.equals(networkMode)) { + return CacheEstimate.disabled(); + } + + return estimateCache(bounds, defaultCacheRoot(), !NETWORK_CACHE_ONLY.equals(networkMode)); + } + + public static PrefetchResult prefetchConfiguredBounds(GeoBounds bounds) { + return prefetchConfiguredBounds(bounds, PREFETCH_MAX_TILES); + } + + public static PrefetchResult prefetchConfiguredBounds(GeoBounds bounds, int maxMissingTiles) { + Objects.requireNonNull(bounds, "bounds"); + OverpassExternalFeatureSource source = createDefault(); + if (!source.enabled) { + return PrefetchResult.disabled(); + } + return source.prefetchBounds(bounds, maxMissingTiles); + } + + public static OverpassExternalFeatureSource disabled() { + return new OverpassExternalFeatureSource(false, false, Path.of("."), List.of(), new URI[0]); + } + + public boolean available() { + return this.enabled; + } + + public PrefetchResult prefetchBounds(GeoBounds bounds, int maxMissingTiles) { + Objects.requireNonNull(bounds, "bounds"); + if (!this.enabled) { + return PrefetchResult.disabled(); + } + + CacheEstimate before = estimateCache(bounds, this.cacheRoot, this.networkEnabled); + TellusDiagnostics.traffic( + "Overpass prefetch start bounds=%s tiles=%d cached=%d cityCached=%d missing=%d networkEnabled=%s maxMissing=%d", + bounds, + before.totalTiles(), + before.cachedTiles(), + before.cityDetailCachedTiles(), + before.missingTiles(), + this.networkEnabled, + maxMissingTiles + ); + if (!this.networkEnabled || maxMissingTiles <= 0 || before.missingTiles() <= 0) { + return new PrefetchResult(before, before, 0, 0, 0); + } + + List keys = tileKeysForBounds(bounds); + sortByDistanceToBoundsCenter(keys, bounds); + boolean requireCityDetails = cityDetailsEnabled(); + int attempted = 0; + int cachedAfterAttempt = 0; + for (TileKey key : keys) { + Path cachePath = this.cachePathFor(key); + if (this.tileReadyForPrefetch(cachePath, requireCityDetails)) { + continue; + } + if (attempted >= maxMissingTiles) { + break; + } + attempted++; + this.tileForKey(key, requireCityDetails, true); + if (this.tileReadyForPrefetch(cachePath, requireCityDetails)) { + cachedAfterAttempt++; + } + } + + CacheEstimate after = estimateCache(bounds, this.cacheRoot, this.networkEnabled); + TellusDiagnostics.traffic( + "Overpass prefetch done bounds=%s attempted=%d cachedAfterAttempt=%d failed=%d missingBefore=%d missingAfter=%d cachedBytes=%d", + bounds, + attempted, + cachedAfterAttempt, + Math.max(0, attempted - cachedAfterAttempt), + before.missingTiles(), + after.missingTiles(), + after.cachedBytes() + ); + return new PrefetchResult(before, after, attempted, cachedAfterAttempt, Math.max(0, attempted - cachedAfterAttempt)); + } + + private boolean tileReadyForPrefetch(Path cachePath, boolean requireCityDetails) { + if (!Files.exists(cachePath)) { + return false; + } + return !requireCityDetails || this.cacheHasCityProfile(cachePath); + } + + public static List probeConfiguredEndpoints() { + if (!Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true"))) { + TellusDiagnostics.traffic("Overpass probe skipped: source disabled"); + return List.of(new EndpointProbeResult("Overpass", false, -1, 0L, "disabled")); + } + + String networkMode = normalizedNetworkMode(System.getProperty(NETWORK_MODE_PROPERTY, NETWORK_CACHE_FIRST)); + if (NETWORK_OFF.equals(networkMode)) { + TellusDiagnostics.traffic("Overpass probe skipped: network off"); + return List.of(new EndpointProbeResult("Overpass", false, -1, 0L, "network off")); + } + + URI[] endpoints = parseEndpoints(System.getProperty(ENDPOINTS_PROPERTY, DEFAULT_ENDPOINTS)); + List results = new ArrayList<>(endpoints.length); + for (URI endpoint : endpoints) { + results.add(probeEndpoint(endpoint)); + } + return List.copyOf(results); + } + + @Override + public List roadsForBounds(GeoBounds bounds) { + if (!this.enabled) { + return List.of(); + } + + List roads = new ArrayList<>(); + for (TileFeatures tile : this.tilesForBounds(bounds, false)) { + roads.addAll(tile.roadsForBounds(bounds)); + } + return List.copyOf(roads); + } + + @Override + public List buildingsForBounds(GeoBounds bounds) { + if (!this.enabled) { + return List.of(); + } + + List buildings = new ArrayList<>(); + for (TileFeatures tile : this.tilesForBounds(bounds, false)) { + buildings.addAll(tile.buildingsForBounds(bounds)); + } + return List.copyOf(buildings); + } + + @Override + public List areasForBounds(GeoBounds bounds) { + if (!this.enabled || !cityDetailsEnabled()) { + return List.of(); + } + + List areas = new ArrayList<>(); + for (TileFeatures tile : this.tilesForBounds(bounds, true)) { + areas.addAll(tile.areasForBounds(bounds)); + } + return List.copyOf(areas); + } + + @Override + public List linesForBounds(GeoBounds bounds) { + if (!this.enabled || !cityDetailsEnabled()) { + return List.of(); + } + + List lines = new ArrayList<>(); + for (TileFeatures tile : this.tilesForBounds(bounds, true)) { + lines.addAll(tile.linesForBounds(bounds)); + } + return List.copyOf(lines); + } + + @Override + public List pointsForBounds(GeoBounds bounds) { + if (!this.enabled || !cityDetailsEnabled()) { + return List.of(); + } + + List points = new ArrayList<>(); + for (TileFeatures tile : this.tilesForBounds(bounds, true)) { + points.addAll(tile.pointsForBounds(bounds)); + } + return List.copyOf(points); + } + + private List tilesForBounds(GeoBounds bounds, boolean requireCityDetails) { + List keys = tileKeysForBounds(bounds); + if (keys.isEmpty()) { + return List.of(); + } + + if (keys.size() > MAX_KEYS_PER_QUERY) { + LOGGER.warn("Skipping Arnis Overpass query over {} tiles, limit is {}", keys.size(), MAX_KEYS_PER_QUERY); + TellusDiagnostics.traffic( + "Overpass query skipped bounds=%s tiles=%d limit=%d requireCityDetails=%s", + bounds, + keys.size(), + MAX_KEYS_PER_QUERY, + requireCityDetails + ); + return List.of(); + } + + List tiles = new ArrayList<>(keys.size()); + for (TileKey key : keys) { + tiles.add(this.tileForKey(key, requireCityDetails)); + } + return tiles; + } + + private TileFeatures tileForKey(TileKey key) { + return this.tileForKey(key, false); + } + + private TileFeatures tileForKey(TileKey key, boolean requireCityDetails) { + return this.tileForKey(key, requireCityDetails, !requireCityDetails || blockingCityDetailsNetwork()); + } + + private TileFeatures tileForKey(TileKey key, boolean requireCityDetails, boolean allowBlockingNetwork) { + TileFeatures cached = this.memoryCache.get(key); + if (cached != null && (!requireCityDetails || cached.cityDetailsLoaded())) { + return cached; + } + + Object lock = this.tileLocks.computeIfAbsent(key, ignored -> new Object()); + try { + synchronized(lock) { + cached = this.memoryCache.get(key); + if (cached != null && (!requireCityDetails || cached.cityDetailsLoaded())) { + return cached; + } + + Long failedUntil = this.failedUntilMs.get(key); + if (allowBlockingNetwork && failedUntil != null && System.currentTimeMillis() < failedUntil) { + return cached != null ? cached : TileFeatures.empty(key.bounds()); + } + + TileFeatures loaded = this.loadTile(key, requireCityDetails, cached, allowBlockingNetwork); + this.memoryCache.put(key, loaded); + return loaded; + } + } finally { + this.tileLocks.remove(key, lock); + } + } + + private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatures fallback, boolean allowBlockingNetwork) { + Path cachePath = this.cachePathFor(key); + boolean offlineRegionTile = this.offlineRegionCovers(key); + if (Files.exists(cachePath)) { + try { + TileFeatures parsed = this.parseTile(key, this.readCompressed(cachePath), this.cacheHasCityProfile(cachePath)); + if (!requireCityDetails || parsed.cityDetailsLoaded()) { + TellusDiagnostics.traffic( + "Overpass cache hit tile=%s cityDetails=%s roads=%d buildings=%d areas=%d lines=%d points=%d", + key, + parsed.cityDetailsLoaded(), + parsed.roads().size(), + parsed.buildings().size(), + parsed.areas().size(), + parsed.lines().size(), + parsed.points().size() + ); + return parsed; + } + fallback = parsed; + if (offlineRegionTile) { + TellusDiagnostics.traffic("Overpass offline region base cache hit tile=%s missingCityProfile=true; networkSkipped=true", key); + return fallback; + } + if (!allowBlockingNetwork) { + TellusDiagnostics.traffic("Overpass city details deferred tile=%s; using base cache and filling in background", key); + this.scheduleCityDetailsFetch(key); + return fallback; + } + TellusDiagnostics.traffic("Overpass cache missing city profile tile=%s; refetching with city details", key); + } catch (IOException | RuntimeException error) { + LOGGER.debug("Invalid Arnis Overpass cache tile {}, refetching", key, error); + TellusDiagnostics.traffic("Overpass cache invalid tile=%s error=%s", key, shortError(error)); + try { + Files.deleteIfExists(cachePath); + } catch (IOException deleteError) { + LOGGER.debug("Failed to delete invalid Arnis Overpass cache tile {}", cachePath, deleteError); + } + } + } + + if (requireCityDetails && !allowBlockingNetwork) { + if (offlineRegionTile) { + TellusDiagnostics.traffic("Overpass offline region cache miss tile=%s deferred=false networkSkipped=true fallback=%s", key, fallback != null); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + TellusDiagnostics.traffic("Overpass city details deferred tile=%s; no city cache available yet", key); + this.scheduleCityDetailsFetch(key); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + + if (offlineRegionTile) { + TellusDiagnostics.traffic("Overpass offline region cache miss tile=%s networkSkipped=true fallback=%s", key, fallback != null); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + + if (!this.networkEnabled) { + TellusDiagnostics.traffic("Overpass cache miss tile=%s networkEnabled=false fallback=%s", key, fallback != null); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + if (!this.reserveNetworkTile()) { + long skipped = this.skippedNetworkTiles.incrementAndGet(); + if (skipped == 1L || skipped % 64L == 0L) { + LOGGER.warn( + "Skipping Arnis Overpass network tile {} because session network tile budget {} is exhausted; cached data and Overture fallback remain available", + key, + MAX_NETWORK_TILES_PER_SESSION + ); + } + TellusDiagnostics.traffic( + "Overpass network budget exhausted tile=%s reserved=%d budget=%d skipped=%d fallback=%s", + key, + this.networkTilesReserved.get(), + MAX_NETWORK_TILES_PER_SESSION, + skipped, + fallback != null + ); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + + try { + boolean includeCityDetails = requireCityDetails && cityDetailsEnabled(); + String response = this.fetchTile(key, includeCityDetails); + TileFeatures parsed = this.parseTile(key, response, includeCityDetails); + this.cacheTile(cachePath, response, includeCityDetails); + this.failedUntilMs.remove(key); + TellusDiagnostics.traffic( + "Overpass tile fetched tile=%s cityDetails=%s bytes=%d roads=%d buildings=%d areas=%d lines=%d points=%d", + key, + includeCityDetails, + response.getBytes(StandardCharsets.UTF_8).length, + parsed.roads().size(), + parsed.buildings().size(), + parsed.areas().size(), + parsed.lines().size(), + parsed.points().size() + ); + return parsed; + } catch (IOException | RuntimeException error) { + LOGGER.debug("Arnis Overpass tile unavailable {}", key, error); + TellusDiagnostics.traffic("Overpass tile unavailable tile=%s cooldownMs=%d error=%s", key, FAILURE_COOLDOWN_MS, shortError(error)); + this.failedUntilMs.put(key, System.currentTimeMillis() + FAILURE_COOLDOWN_MS); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + } + + private void scheduleCityDetailsFetch(TileKey key) { + if (!this.networkEnabled || !cityDetailsEnabled()) { + return; + } + + this.backgroundCityFetches.computeIfAbsent( + key, + ignored -> CompletableFuture.runAsync( + () -> { + try { + this.tileForKey(key, true, true); + } catch (RuntimeException error) { + TellusDiagnostics.traffic("Overpass background city details failed tile=%s error=%s", key, shortError(error)); + } finally { + this.backgroundCityFetches.remove(key); + } + }, + BACKGROUND_CITY_DETAILS_EXECUTOR + ) + ); + } + + private boolean reserveNetworkTile() { + if (MAX_NETWORK_TILES_PER_SESSION <= 0) { + return false; + } + + while (true) { + long current = this.networkTilesReserved.get(); + if (current >= MAX_NETWORK_TILES_PER_SESSION) { + return false; + } + if (this.networkTilesReserved.compareAndSet(current, current + 1L)) { + return true; + } + } + } + + private String fetchTile(TileKey key, boolean includeCityDetails) throws IOException { + GeoBounds bounds = key.bounds(); + String query = overpassQuery(bounds, includeCityDetails); + + IOException lastError = null; + long startEndpoint = this.endpointCursor.getAndIncrement(); + int attempted = 0; + for (int attempt = 0; attempt < this.endpoints.length; attempt++) { + URI endpoint = this.endpoints[Math.floorMod(startEndpoint + attempt, this.endpoints.length)]; + long nowMs = System.currentTimeMillis(); + Long endpointFailedUntil = this.endpointFailedUntilMs.get(endpoint.toString()); + if (endpointFailedUntil != null && endpointFailedUntil > nowMs) { + TellusDiagnostics.traffic( + "Overpass endpoint skipped tile=%s endpoint=%s cooldownRemainingMs=%d", + key, + endpoint, + endpointFailedUntil - nowMs + ); + continue; + } + try { + attempted++; + TellusDiagnostics.traffic("Overpass request start tile=%s endpoint=%s cityDetails=%s attempt=%d", key, endpoint, includeCityDetails, attempted); + String response = this.executeQuery(endpoint, query); + this.endpointFailedUntilMs.remove(endpoint.toString()); + return response; + } catch (IOException error) { + lastError = error; + this.markEndpointFailure(endpoint, error); + TellusDiagnostics.traffic("Overpass request failed tile=%s endpoint=%s attempt=%d error=%s", key, endpoint, attempted, shortError(error)); + } + } + + if (attempted == 0) { + throw new IOException("all Arnis Overpass endpoints are cooling down"); + } + throw new IOException("all Arnis Overpass endpoints failed", lastError); + } + + private void markEndpointFailure(URI endpoint, IOException error) { + if (FAILURE_COOLDOWN_MS <= 0L) { + return; + } + long untilMs = System.currentTimeMillis() + FAILURE_COOLDOWN_MS; + this.endpointFailedUntilMs.put(endpoint.toString(), untilMs); + TellusDiagnostics.traffic( + "Overpass endpoint cooldown endpoint=%s cooldownMs=%d error=%s", + endpoint, + FAILURE_COOLDOWN_MS, + shortError(error) + ); + } + + private static String overpassQuery(GeoBounds bounds, boolean includeCityDetails) { + StringBuilder selectors = new StringBuilder() + .append("way[\"highway\"];") + .append("way[\"building\"];") + .append("way[\"building:part\"];") + .append("relation[\"building\"];") + .append("relation[\"building:part\"];") + .append("relation[\"type\"=\"multipolygon\"][\"building\"];") + .append("relation[\"type\"=\"multipolygon\"][\"building:part\"];"); + if (includeCityDetails) { + selectors + .append("way[\"barrier\"];") + .append("relation[\"barrier\"];") + .append("way[\"railway\"~\"^(rail|light_rail|subway|tram)$\"];") + .append("way[\"waterway\"~\"^(river|stream|canal|ditch|drain)$\"];") + .append("way[\"water\"];") + .append("relation[\"water\"];") + .append("way[\"natural\"=\"water\"];") + .append("relation[\"natural\"=\"water\"];") + .append("way[\"amenity\"=\"parking\"];") + .append("relation[\"amenity\"=\"parking\"];") + .append("way[\"landuse\"~\"^(grass|residential|commercial|retail|industrial|cemetery|construction|farmland|meadow|recreation_ground|forest|orchard|greenfield|vineyard|education|religious|military|railway|brownfield|landfill|quarry|traffic_island)$\"];") + .append("relation[\"landuse\"~\"^(grass|residential|commercial|retail|industrial|cemetery|construction|farmland|meadow|recreation_ground|forest|orchard|greenfield|vineyard|education|religious|military|railway|brownfield|landfill|quarry|traffic_island)$\"];") + .append("way[\"leisure\"~\"^(park|garden|pitch|playground|track|sports_centre|recreation_ground|nature_reserve|disc_golf_course|golf_course|schoolyard|beach_resort|dog_park|swimming_pool|swimming_area|bathing_place|outdoor_seating|water_park|slipway|ice_rink)$\"];") + .append("relation[\"leisure\"~\"^(park|garden|pitch|playground|track|sports_centre|recreation_ground|nature_reserve|disc_golf_course|golf_course|schoolyard|beach_resort|dog_park|swimming_pool|swimming_area|bathing_place|outdoor_seating|water_park|slipway|ice_rink)$\"];") + .append("way[\"natural\"~\"^(wood|tree_row|scrub|heath|beach|sand|dune|shoal|wetland|bare_rock|scree|grassland|blockfield|glacier|mud|reef|mountain_range|saddle|ridge|shrubbery|tundra|hill|cliff)$\"];") + .append("relation[\"natural\"~\"^(wood|tree_row|scrub|heath|beach|sand|dune|shoal|wetland|bare_rock|scree|grassland|blockfield|glacier|mud|reef|mountain_range|saddle|ridge|shrubbery|tundra|hill|cliff)$\"];") + .append("node[\"highway\"~\"^(traffic_signals|crossing|street_lamp|bus_stop)$\"];") + .append("node[\"amenity\"~\"^(bench|bicycle_parking|fountain|shelter|fuel|recycling|waste_disposal|waste_basket|vending_machine|atm|drinking_water)$\"];") + .append("node[\"advertising\"~\"^(column|flag|poster_box)$\"];") + .append("node[\"emergency\"=\"fire_hydrant\"];") + .append("node[\"historic\"~\"^(memorial|monument|wayside_cross)$\"];") + .append("node[\"tourism\"=\"information\"];") + .append("node[\"man_made\"~\"^(antenna|mast|chimney|water_well|water_tower)$\"];") + .append("node[\"power\"~\"^(tower|pole)$\"];") + .append("node[\"barrier\"~\"^(bollard|block|entrance|gate|swing_gate|lift_gate|stile)$\"];") + .append("node[\"railway\"~\"^(level_crossing|crossing|tram_stop)$\"];") + .append("way[\"power\"~\"^(line|minor_line)$\"];") + .append("way[\"man_made\"=\"pier\"];") + .append("node[\"natural\"=\"tree\"];") + .append("node[\"entrance\"];") + .append("node[\"door\"];"); + } + return String.format( + Locale.ROOT, + "[out:json][timeout:%d][bbox:%.7f,%.7f,%.7f,%.7f];(%s);out tags geom;", + QUERY_TIMEOUT_SECONDS, + bounds.south(), + bounds.west(), + bounds.north(), + bounds.east(), + selectors + ); + } + + private String executeQuery(URI endpoint, String query) throws IOException { + this.acquireRequestGuard(); + long startMs = System.currentTimeMillis(); + try { + this.applyRateLimitDelay(); + URI requestUri = URI.create(endpoint.toString() + "?data=" + URLEncoder.encode(query, StandardCharsets.UTF_8)); + HttpURLConnection connection = (HttpURLConnection)requestUri.toURL().openConnection(); + try { + connection.setRequestMethod("GET"); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("User-Agent", "Tellus-Arnis/1.0 (Minecraft Mod)"); + + int status = connection.getResponseCode(); + if (status != 200) { + throw new IOException("Arnis Overpass HTTP " + status + " (" + endpoint.getHost() + ")"); + } + try (InputStream input = Objects.requireNonNull(connection.getInputStream(), "overpassResponse")) { + byte[] response = input.readAllBytes(); + TellusDiagnostics.traffic( + "Overpass request ok endpoint=%s status=%d bytes=%d elapsedMs=%d", + endpoint, + status, + response.length, + System.currentTimeMillis() - startMs + ); + return new String(response, StandardCharsets.UTF_8); + } + } finally { + connection.disconnect(); + } + } finally { + this.nextAllowedRequestMs.accumulateAndGet(System.currentTimeMillis() + MIN_REQUEST_SPACING_MS, Math::max); + this.requestGuard.release(); + } + } + + private static EndpointProbeResult probeEndpoint(URI endpoint) { + long startMs = System.currentTimeMillis(); + int status = -1; + try { + URI requestUri = URI.create(endpoint.toString() + "?data=" + URLEncoder.encode(PROBE_QUERY, StandardCharsets.UTF_8)); + HttpURLConnection connection = (HttpURLConnection)requestUri.toURL().openConnection(); + try { + connection.setRequestMethod("GET"); + connection.setConnectTimeout(PROBE_CONNECT_TIMEOUT_MS); + connection.setReadTimeout(PROBE_READ_TIMEOUT_MS); + connection.setRequestProperty("User-Agent", "Tellus-Arnis/1.0 (Minecraft Mod; connectivity probe)"); + status = connection.getResponseCode(); + if (status == 200) { + try (InputStream input = Objects.requireNonNull(connection.getInputStream(), "overpassProbeResponse")) { + input.readNBytes(256); + } + EndpointProbeResult result = new EndpointProbeResult(endpoint.toString(), true, status, System.currentTimeMillis() - startMs, "ok"); + TellusDiagnostics.traffic("Overpass probe endpoint=%s ok=%s status=%d elapsedMs=%d message=%s", endpoint, result.ok(), result.httpStatus(), result.elapsedMs(), result.message()); + return result; + } + EndpointProbeResult result = new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, "HTTP " + status); + TellusDiagnostics.traffic("Overpass probe endpoint=%s ok=%s status=%d elapsedMs=%d message=%s", endpoint, result.ok(), result.httpStatus(), result.elapsedMs(), result.message()); + return result; + } finally { + connection.disconnect(); + } + } catch (IOException | RuntimeException error) { + EndpointProbeResult result = new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, shortError(error)); + TellusDiagnostics.traffic("Overpass probe endpoint=%s ok=%s status=%d elapsedMs=%d message=%s", endpoint, result.ok(), result.httpStatus(), result.elapsedMs(), result.message()); + return result; + } + } + + private static String shortError(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() ? error.getClass().getSimpleName() : message; + } + + private void acquireRequestGuard() throws IOException { + try { + this.requestGuard.acquire(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for Arnis Overpass request slot", error); + } + } + + private void applyRateLimitDelay() throws IOException { + long waitMs = this.nextAllowedRequestMs.get() - System.currentTimeMillis(); + if (waitMs > 0L) { + try { + TimeUnit.MILLISECONDS.sleep(waitMs); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while rate-limiting Arnis Overpass requests", error); + } + } + } + + private TileFeatures parseTile(TileKey key, String response, boolean cityDetailsLoaded) { + JsonElement parsed = JsonParser.parseString(response); + if (!parsed.isJsonObject()) { + return TileFeatures.empty(key.bounds()); + } + + JsonArray elements = parsed.getAsJsonObject().getAsJsonArray("elements"); + if (elements == null || elements.isEmpty()) { + return TileFeatures.empty(key.bounds(), cityDetailsLoaded); + } + + List roads = new ArrayList<>(); + List buildings = new ArrayList<>(); + List areas = new ArrayList<>(); + List lines = new ArrayList<>(); + List points = new ArrayList<>(); + for (JsonElement element : elements) { + if (!element.isJsonObject()) { + continue; + } + JsonObject object = element.getAsJsonObject(); + String type = stringOrDefault(object, "", "type"); + if (!"way".equals(type) && !"relation".equals(type) && !"node".equals(type)) { + continue; + } + Map tags = parseTags(object); + String id = Long.toString(longOrDefault(object, 0L, "id")); + if (cityDetailsLoaded && "node".equals(type)) { + ExternalPointFeature point = parsePointFeature("node/" + id, tags, object); + if (point != null) { + points.add(point); + } + continue; + } + if ("way".equals(type) && tags.containsKey("highway")) { + JsonArray geometry = object.getAsJsonArray("geometry"); + if (geometry == null) { + continue; + } + ExternalRoadFeature road = parseRoad(id, tags, geometry); + if (road != null) { + roads.add(road); + } + } + if ("way".equals(type) && (tags.containsKey("building") || tags.containsKey("building:part"))) { + JsonArray geometry = object.getAsJsonArray("geometry"); + if (geometry == null) { + continue; + } + ExternalBuildingFeature building = parseBuilding(id, tags, geometry); + if (building != null) { + buildings.add(building); + } + } else if ("relation".equals(type) && (tags.containsKey("building") || tags.containsKey("building:part"))) { + ExternalBuildingFeature building = parseBuildingRelation(id, tags, object.getAsJsonArray("members")); + if (building != null) { + buildings.add(building); + } + } + if (cityDetailsLoaded) { + if ("way".equals(type)) { + JsonArray geometry = object.getAsJsonArray("geometry"); + if (geometry != null) { + ExternalAreaFeature area = parseArea("way/" + id, tags, geometry); + if (area != null) { + areas.add(area); + } + ExternalLineFeature line = parseLine("way/" + id, tags, geometry); + if (line != null) { + lines.add(line); + } + } + } else if ("relation".equals(type)) { + ExternalAreaFeature area = parseAreaRelation("relation/" + id, tags, object.getAsJsonArray("members")); + if (area != null) { + areas.add(area); + } + } + } + } + + return new TileFeatures(key.bounds(), roads, buildings, areas, lines, points, cityDetailsLoaded); + } + + private static ExternalRoadFeature parseRoad(String id, Map tags, JsonArray geometry) { + String highway = tags.get("highway"); + RoadClass roadClass = RoadClass.fromHighwayTag(highway); + if (roadClass == null) { + return null; + } + List points = parsePoints(geometry); + if (points.size() < 2) { + return null; + } + RoadMode mode = roadMode(tags); + int bridgeLevel = mode == RoadMode.BRIDGE ? Math.max(1, intFromTag(tags.get("layer"), 1)) : 0; + return new ExternalRoadFeature(SOURCE, "way/" + id, roadClass, mode, bridgeLevel, highway, points, tags); + } + + private static ExternalBuildingFeature parseBuilding(String id, Map tags, JsonArray geometry) { + List ring = parsePoints(geometry); + if (ring.size() < 3) { + return null; + } + GeoPoint first = ring.get(0); + GeoPoint last = ring.get(ring.size() - 1); + if (!first.equals(last)) { + ring = new ArrayList<>(ring); + ring.add(first); + } + if (ring.size() < 4) { + return null; + } + + double height = heightMeters(tags); + double minHeight = minHeightMeters(tags); + if (!(height > minHeight)) { + height = minHeight + 3.2; + } + int floorCount = floorCount(tags, height); + ExternalBuildingKind kind = tags.containsKey("building:part") ? ExternalBuildingKind.PART : ExternalBuildingKind.FOOTPRINT; + return new ExternalBuildingFeature(SOURCE, "way/" + id, kind, height, minHeight, floorCount, List.of(ring), tags); + } + + private static ExternalBuildingFeature parseBuildingRelation(String id, Map tags, JsonArray members) { + if (members == null || members.isEmpty()) { + return null; + } + + List> outerSegments = new ArrayList<>(); + List> innerSegments = new ArrayList<>(); + for (JsonElement memberElement : members) { + if (!memberElement.isJsonObject()) { + continue; + } + JsonObject member = memberElement.getAsJsonObject(); + if (!"way".equals(stringOrDefault(member, "", "type"))) { + continue; + } + JsonArray geometry = member.getAsJsonArray("geometry"); + if (geometry == null) { + continue; + } + List segment = parsePoints(geometry); + if (segment.size() < 2) { + continue; + } + String role = stringOrDefault(member, "", "role").trim().toLowerCase(Locale.ROOT); + if ("inner".equals(role)) { + innerSegments.add(segment); + } else if (role.isEmpty() || "outer".equals(role) || "outline".equals(role)) { + outerSegments.add(segment); + } + } + + List> rings = new ArrayList<>(); + rings.addAll(mergeSegmentsToRings(outerSegments)); + if (rings.isEmpty()) { + return null; + } + rings.addAll(mergeSegmentsToRings(innerSegments)); + + double height = heightMeters(tags); + double minHeight = minHeightMeters(tags); + if (!(height > minHeight)) { + height = minHeight + 3.2; + } + int floorCount = floorCount(tags, height); + ExternalBuildingKind kind = tags.containsKey("building:part") ? ExternalBuildingKind.PART : ExternalBuildingKind.FOOTPRINT; + return new ExternalBuildingFeature(SOURCE, "relation/" + id, kind, height, minHeight, floorCount, rings, tags); + } + + private static ExternalAreaFeature parseArea(String id, Map tags, JsonArray geometry) { + ExternalAreaKind kind = areaKind(tags); + if (kind == null || tags.containsKey("building") || tags.containsKey("building:part")) { + return null; + } + List ring = parsePoints(geometry); + if (ring.size() < 3) { + return null; + } + closeRing(ring); + if (ring.size() < 4 || !isClosedRing(ring)) { + return null; + } + return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), List.of(List.copyOf(ring)), tags); + } + + private static ExternalAreaFeature parseAreaRelation(String id, Map tags, JsonArray members) { + ExternalAreaKind kind = areaKind(tags); + if (kind == null || tags.containsKey("building") || tags.containsKey("building:part")) { + return null; + } + if (members == null || members.isEmpty()) { + return null; + } + + List> outerSegments = new ArrayList<>(); + List> innerSegments = new ArrayList<>(); + for (JsonElement memberElement : members) { + if (!memberElement.isJsonObject()) { + continue; + } + JsonObject member = memberElement.getAsJsonObject(); + if (!"way".equals(stringOrDefault(member, "", "type"))) { + continue; + } + JsonArray geometry = member.getAsJsonArray("geometry"); + if (geometry == null) { + continue; + } + List segment = parsePoints(geometry); + if (segment.size() < 2) { + continue; + } + String role = stringOrDefault(member, "", "role").trim().toLowerCase(Locale.ROOT); + if ("inner".equals(role)) { + innerSegments.add(segment); + } else if (role.isEmpty() || "outer".equals(role) || "outline".equals(role)) { + outerSegments.add(segment); + } + } + + List> rings = new ArrayList<>(); + rings.addAll(mergeSegmentsToRings(outerSegments)); + if (rings.isEmpty()) { + return null; + } + rings.addAll(mergeSegmentsToRings(innerSegments)); + return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), rings, tags); + } + + private static ExternalLineFeature parseLine(String id, Map tags, JsonArray geometry) { + ExternalLineKind kind = lineKind(tags); + if (kind == null) { + return null; + } + List points = parsePoints(geometry); + if (points.size() < 2) { + return null; + } + return new ExternalLineFeature(SOURCE, id, kind, lineTypeTag(kind, tags), points, tags); + } + + private static ExternalPointFeature parsePointFeature(String id, Map tags, JsonObject object) { + ExternalPointKind kind = pointKind(tags); + if (kind == null) { + return null; + } + double lat = doubleOrDefault(object, Double.NaN, "lat"); + double lon = doubleOrDefault(object, Double.NaN, "lon"); + if (!Double.isFinite(lat) || !Double.isFinite(lon)) { + return null; + } + return new ExternalPointFeature(SOURCE, id, kind, pointTypeTag(kind, tags), new GeoPoint(lat, lon), tags); + } + + private static ExternalAreaKind areaKind(Map tags) { + if ("parking".equalsIgnoreCase(tags.get("amenity"))) { + return ExternalAreaKind.PARKING; + } + if (tags.containsKey("water") || "water".equals(tags.get("natural"))) { + return ExternalAreaKind.WATER; + } + if (tags.containsKey("landuse")) { + return ExternalAreaKind.LANDUSE; + } + if (tags.containsKey("leisure")) { + return ExternalAreaKind.LEISURE; + } + if (tags.containsKey("natural")) { + return ExternalAreaKind.NATURAL; + } + return tags.containsKey("amenity") ? ExternalAreaKind.AMENITY : null; + } + + private static String areaTypeTag(ExternalAreaKind kind, Map tags) { + return switch (kind) { + case PARKING, AMENITY -> Objects.toString(tags.get("amenity"), ""); + case LANDUSE -> Objects.toString(tags.get("landuse"), ""); + case LEISURE -> Objects.toString(tags.get("leisure"), ""); + case NATURAL -> Objects.toString(tags.get("natural"), ""); + case WATER -> { + String water = tags.get("water"); + yield water != null ? water : Objects.toString(tags.get("natural"), ""); + } + }; + } + + private static ExternalLineKind lineKind(Map tags) { + if (tags.containsKey("barrier")) { + return ExternalLineKind.BARRIER; + } + if (tags.containsKey("railway")) { + return ExternalLineKind.RAILWAY; + } + if (tags.containsKey("waterway")) { + return ExternalLineKind.WATERWAY; + } + if (tags.containsKey("power")) { + return ExternalLineKind.POWER; + } + return "pier".equals(tags.get("man_made")) ? ExternalLineKind.MAN_MADE : null; + } + + private static String lineTypeTag(ExternalLineKind kind, Map tags) { + return switch (kind) { + case BARRIER -> Objects.toString(tags.get("barrier"), ""); + case RAILWAY -> Objects.toString(tags.get("railway"), ""); + case WATERWAY -> Objects.toString(tags.get("waterway"), ""); + case POWER -> Objects.toString(tags.get("power"), ""); + case MAN_MADE -> Objects.toString(tags.get("man_made"), ""); + }; + } + + private static ExternalPointKind pointKind(Map tags) { + String highway = tags.get("highway"); + if ("traffic_signals".equals(highway)) { + return ExternalPointKind.TRAFFIC_SIGNAL; + } + if ("crossing".equals(highway)) { + return ExternalPointKind.CROSSING; + } + if ("street_lamp".equals(highway) || "bus_stop".equals(highway)) { + return ExternalPointKind.HIGHWAY; + } + if (tags.containsKey("entrance") || tags.containsKey("door")) { + return ExternalPointKind.ENTRANCE; + } + if (tags.containsKey("amenity")) { + return ExternalPointKind.AMENITY; + } + if ("tree".equals(tags.get("natural"))) { + return ExternalPointKind.NATURAL; + } + if (tags.containsKey("advertising")) { + return ExternalPointKind.ADVERTISING; + } + if (tags.containsKey("emergency")) { + return ExternalPointKind.EMERGENCY; + } + if (tags.containsKey("historic")) { + return ExternalPointKind.HISTORIC; + } + if (tags.containsKey("tourism")) { + return ExternalPointKind.TOURISM; + } + if (tags.containsKey("man_made")) { + return ExternalPointKind.MAN_MADE; + } + if (tags.containsKey("power")) { + return ExternalPointKind.POWER; + } + if (tags.containsKey("barrier")) { + return ExternalPointKind.BARRIER; + } + return tags.containsKey("railway") ? ExternalPointKind.RAILWAY : null; + } + + private static String pointTypeTag(ExternalPointKind kind, Map tags) { + return switch (kind) { + case TRAFFIC_SIGNAL, CROSSING, HIGHWAY -> Objects.toString(tags.get("highway"), ""); + case ENTRANCE -> { + String entrance = tags.get("entrance"); + yield entrance != null ? entrance : Objects.toString(tags.get("door"), ""); + } + case AMENITY -> Objects.toString(tags.get("amenity"), ""); + case NATURAL -> Objects.toString(tags.get("natural"), ""); + case ADVERTISING -> Objects.toString(tags.get("advertising"), ""); + case EMERGENCY -> Objects.toString(tags.get("emergency"), ""); + case HISTORIC -> Objects.toString(tags.get("historic"), ""); + case TOURISM -> Objects.toString(tags.get("tourism"), ""); + case MAN_MADE -> Objects.toString(tags.get("man_made"), ""); + case POWER -> Objects.toString(tags.get("power"), ""); + case BARRIER -> Objects.toString(tags.get("barrier"), ""); + case RAILWAY -> Objects.toString(tags.get("railway"), ""); + }; + } + + private static List> mergeSegmentsToRings(List> segments) { + List> remaining = new ArrayList<>(); + for (List segment : segments) { + if (segment.size() >= 2) { + remaining.add(new ArrayList<>(segment)); + } + } + + List> rings = new ArrayList<>(); + while (!remaining.isEmpty()) { + List ring = remaining.remove(0); + boolean changed = true; + while (changed && !isClosedRing(ring)) { + changed = false; + for (int index = 0; index < remaining.size(); index++) { + List segment = remaining.get(index); + if (appendOrPrepend(ring, segment)) { + remaining.remove(index); + changed = true; + break; + } + } + } + closeRing(ring); + if (ring.size() >= 4 && isClosedRing(ring)) { + rings.add(List.copyOf(ring)); + } + } + return rings; + } + + private static boolean appendOrPrepend(List ring, List segment) { + GeoPoint ringFirst = ring.get(0); + GeoPoint ringLast = ring.get(ring.size() - 1); + GeoPoint segmentFirst = segment.get(0); + GeoPoint segmentLast = segment.get(segment.size() - 1); + + if (samePoint(ringLast, segmentFirst)) { + ring.addAll(segment.subList(1, segment.size())); + return true; + } + if (samePoint(ringLast, segmentLast)) { + for (int index = segment.size() - 2; index >= 0; index--) { + ring.add(segment.get(index)); + } + return true; + } + if (samePoint(ringFirst, segmentLast)) { + ring.addAll(0, segment.subList(0, segment.size() - 1)); + return true; + } + if (samePoint(ringFirst, segmentFirst)) { + for (int index = 1; index < segment.size(); index++) { + ring.add(0, segment.get(index)); + } + return true; + } + return false; + } + + private static boolean isClosedRing(List ring) { + return ring.size() >= 2 && samePoint(ring.get(0), ring.get(ring.size() - 1)); + } + + private static void closeRing(List ring) { + if (ring.size() >= 3 && !isClosedRing(ring)) { + ring.add(ring.get(0)); + } + } + + private static boolean samePoint(GeoPoint first, GeoPoint second) { + return Math.abs(first.latitude() - second.latitude()) < 1.0E-7 && Math.abs(first.longitude() - second.longitude()) < 1.0E-7; + } + + private static List parsePoints(JsonArray geometry) { + List points = new ArrayList<>(geometry.size()); + GeoPoint previous = null; + for (JsonElement pointElement : geometry) { + if (!pointElement.isJsonObject()) { + continue; + } + JsonObject pointObject = pointElement.getAsJsonObject(); + double lat = doubleOrDefault(pointObject, Double.NaN, "lat"); + double lon = doubleOrDefault(pointObject, Double.NaN, "lon"); + if (Double.isFinite(lat) && Double.isFinite(lon)) { + GeoPoint point = new GeoPoint(lat, lon); + if (!point.equals(previous)) { + points.add(point); + previous = point; + } + } + } + return points; + } + + private static RoadMode roadMode(Map tags) { + if (truthy(tags.get("tunnel"))) { + return RoadMode.TUNNEL; + } + if (truthy(tags.get("bridge"))) { + return RoadMode.BRIDGE; + } + int layer = intFromTag(tags.get("layer"), 0); + return layer > 0 ? RoadMode.BRIDGE : layer < 0 ? RoadMode.TUNNEL : RoadMode.NORMAL; + } + + private static double heightMeters(Map tags) { + Double height = doubleFromTag(first(tags, "height", "building:height", "building_height")); + if (height != null && height > 0.0) { + return height; + } + Double levels = doubleFromTag(first(tags, "building:levels", "building_levels", "levels", "level")); + if (levels != null && levels > 0.0) { + return levels * 3.2; + } + return 6.0; + } + + private static double minHeightMeters(Map tags) { + Double minHeight = doubleFromTag(first(tags, "min_height", "min:height", "building:min_height")); + if (minHeight != null && minHeight > 0.0) { + return minHeight; + } + Double minLevel = doubleFromTag(first(tags, "building:min_level", "min_level")); + return minLevel != null && minLevel > 0.0 ? minLevel * 3.2 : 0.0; + } + + private static int floorCount(Map tags, double heightMeters) { + Double levels = doubleFromTag(first(tags, "building:levels", "building_levels", "levels", "level")); + return levels != null && levels > 0.0 ? Math.max(1, (int)Math.round(levels)) : Math.max(1, (int)Math.round(heightMeters / 3.2)); + } + + private static Map parseTags(JsonObject object) { + JsonObject tagsObject = object.getAsJsonObject("tags"); + if (tagsObject == null) { + return Map.of(); + } + Map tags = new LinkedHashMap<>(); + for (Map.Entry entry : tagsObject.entrySet()) { + JsonElement value = entry.getValue(); + if (value != null && !value.isJsonNull()) { + tags.put(entry.getKey(), value.isJsonPrimitive() ? value.getAsString() : value.toString()); + } + } + return tags; + } + + private String readCompressed(Path path) throws IOException { + try (InputStream input = new GZIPInputStream(Files.newInputStream(path))) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private void cacheTile(Path path, String response, boolean cityDetailsLoaded) { + try { + Files.createDirectories(path.getParent()); + Path tempPath = path.resolveSibling(path.getFileName() + ".tmp"); + try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(tempPath))) { + output.write(response.getBytes(StandardCharsets.UTF_8)); + } + Files.move(tempPath, path, StandardCopyOption.REPLACE_EXISTING); + if (cityDetailsLoaded) { + Files.writeString(cacheProfilePath(path), CITY_CACHE_PROFILE + "\n", StandardCharsets.UTF_8); + } else { + Files.deleteIfExists(cacheProfilePath(path)); + } + TellusDiagnostics.traffic( + "Overpass cache write path=%s cityDetails=%s bytes=%d", + path, + cityDetailsLoaded, + Files.size(path) + ); + } catch (IOException error) { + LOGGER.debug("Failed to cache Arnis Overpass tile {}", path, error); + TellusDiagnostics.traffic("Overpass cache write failed path=%s error=%s", path, shortError(error)); + } + } + + private static boolean cacheHasCityProfile(Path path) { + Path profilePath = cacheProfilePath(path); + if (!Files.isRegularFile(profilePath)) { + return false; + } + try { + return Files.readString(profilePath, StandardCharsets.UTF_8).trim().equals(CITY_CACHE_PROFILE); + } catch (IOException error) { + LOGGER.debug("Failed to read Arnis Overpass cache profile {}", profilePath, error); + return false; + } + } + + private static Path cacheProfilePath(Path path) { + return path.resolveSibling(path.getFileName() + ".profile"); + } + + private Path cachePathFor(TileKey key) { + return cachePathFor(this.cacheRoot, key); + } + + private static Path defaultCacheRoot() { + return FabricLoader.getInstance().getGameDir().resolve("tellus/cache/map/arnis-overpass"); + } + + private static Path defaultOfflineRegionRoot() { + return FabricLoader.getInstance().getGameDir().resolve("tellus/cache/offline-regions"); + } + + private static Path cachePathFor(Path cacheRoot, TileKey key) { + return cacheRoot.resolve(Integer.toString(key.zoom())).resolve(Integer.toString(key.x())).resolve(key.y() + ".json.gz"); + } + + private boolean offlineRegionCovers(TileKey key) { + if (this.offlineRegions.isEmpty()) { + return false; + } + GeoBounds bounds = key.bounds(); + double centerLat = (bounds.south() + bounds.north()) * 0.5; + double centerLon = (bounds.west() + bounds.east()) * 0.5; + for (OfflineRegion region : this.offlineRegions) { + if (region.contains(centerLat, centerLon)) { + return true; + } + } + return false; + } + + private static List loadOfflineRegions(Path root) { + if (!Boolean.parseBoolean(System.getProperty(OFFLINE_REGIONS_PROPERTY, "true"))) { + return List.of(); + } + if (!Files.isDirectory(root)) { + return List.of(); + } + List regions = new ArrayList<>(); + try (java.util.stream.Stream paths = Files.list(root)) { + paths.filter(path -> path.getFileName().toString().endsWith(".json")).forEach(path -> { + OfflineRegion region = readOfflineRegion(path); + if (region != null) { + regions.add(region); + } + }); + } catch (IOException error) { + LOGGER.debug("Failed to list Tellus offline regions {}", root, error); + TellusDiagnostics.traffic("Overpass offline regions unavailable root=%s error=%s", root, shortError(error)); + } + if (!regions.isEmpty()) { + TellusDiagnostics.traffic("Overpass offline regions loaded root=%s count=%d", root, regions.size()); + } + return regions; + } + + private static OfflineRegion readOfflineRegion(Path path) { + try { + JsonObject object = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)).getAsJsonObject(); + if (!SOURCE.equals(jsonString(object, "source", "")) || !CITY_CACHE_PROFILE.equals(jsonString(object, "profile", ""))) { + return null; + } + if (!jsonBoolean(object, "complete", false) || jsonInt(object, "zoom", -1) != QUERY_ZOOM) { + return null; + } + JsonObject bounds = object.has("bounds") && object.get("bounds").isJsonObject() ? object.getAsJsonObject("bounds") : null; + if (bounds == null) { + return null; + } + GeoBounds geoBounds = new GeoBounds( + jsonDouble(bounds, "south"), + jsonDouble(bounds, "west"), + jsonDouble(bounds, "north"), + jsonDouble(bounds, "east") + ); + String name = jsonString(object, "name", path.getFileName().toString()); + int totalTiles = jsonInt(object, "totalTiles", 0); + int cachedTiles = jsonInt(object, "profileCachedTiles", 0); + TellusDiagnostics.traffic("Overpass offline region ready name=%s bounds=%s cached=%d/%d", name, geoBounds, cachedTiles, totalTiles); + return new OfflineRegion(name, geoBounds); + } catch (IOException | IllegalArgumentException | IllegalStateException error) { + LOGGER.debug("Ignoring invalid Tellus offline region {}", path, error); + TellusDiagnostics.traffic("Overpass offline region invalid path=%s error=%s", path, shortError(error)); + return null; + } + } + + private static CacheEstimate estimateCache(GeoBounds bounds, Path cacheRoot, boolean networkEnabled) { + List keys = tileKeysForBounds(bounds); + int cached = 0; + int cityCached = 0; + long cachedBytes = 0L; + boolean cityDetailsEnabled = cityDetailsEnabled(); + for (TileKey key : keys) { + Path path = cachePathFor(cacheRoot, key); + if (Files.exists(path)) { + cached++; + try { + cachedBytes += Files.size(path); + } catch (IOException error) { + LOGGER.debug("Failed to stat Arnis Overpass cache tile {}", path, error); + } + if (cityDetailsEnabled && cacheHasCityProfile(path)) { + cityCached++; + } + } + } + int missing = cityDetailsEnabled ? keys.size() - cityCached : keys.size() - cached; + return new CacheEstimate( + true, + networkEnabled, + keys.size(), + cached, + missing, + cachedBytes, + MAX_NETWORK_TILES_PER_SESSION, + cityDetailsEnabled, + cityCached, + cityDetailsEnabled ? keys.size() - cityCached : 0 + ); + } + + private static void sortByDistanceToBoundsCenter(List keys, GeoBounds bounds) { + double centerLat = (bounds.south() + bounds.north()) * 0.5; + double centerLon = (bounds.west() + bounds.east()) * 0.5; + keys.sort((left, right) -> Double.compare(tileDistanceSq(left, centerLat, centerLon), tileDistanceSq(right, centerLat, centerLon))); + } + + private static double tileDistanceSq(TileKey key, double centerLat, double centerLon) { + GeoBounds tile = key.bounds(); + double tileLat = (tile.south() + tile.north()) * 0.5; + double tileLon = (tile.west() + tile.east()) * 0.5; + double dLat = tileLat - centerLat; + double dLon = tileLon - centerLon; + return dLat * dLat + dLon * dLon; + } + + private static List tileKeysForBounds(GeoBounds bounds) { + int minX = lonToTileX(bounds.west(), QUERY_ZOOM); + int maxX = lonToTileX(bounds.east(), QUERY_ZOOM); + int minY = latToTileY(bounds.north(), QUERY_ZOOM); + int maxY = latToTileY(bounds.south(), QUERY_ZOOM); + List keys = new ArrayList<>(); + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + keys.add(new TileKey(QUERY_ZOOM, x, y)); + } + } + return keys; + } + + private static int lonToTileX(double lon, int zoom) { + int tiles = 1 << zoom; + int x = (int)Math.floor((lon + 180.0) / 360.0 * tiles); + return Math.max(0, Math.min(tiles - 1, x)); + } + + private static int latToTileY(double lat, int zoom) { + double clamped = Math.max(MIN_LAT, Math.min(MAX_LAT, lat)); + int tiles = 1 << zoom; + double latRad = Math.toRadians(clamped); + int y = (int)Math.floor((1.0 - Math.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)) / Math.PI) * 0.5 * tiles); + return Math.max(0, Math.min(tiles - 1, y)); + } + + private static GeoBounds tileBounds(int zoom, int x, int y) { + int tiles = 1 << zoom; + double west = x / (double)tiles * 360.0 - 180.0; + double east = (x + 1) / (double)tiles * 360.0 - 180.0; + double north = tileYToLat(y, zoom); + double south = tileYToLat(y + 1, zoom); + return new GeoBounds(south, west, north, east); + } + + private static double tileYToLat(int y, int zoom) { + double n = Math.PI - 2.0 * Math.PI * y / (1 << zoom); + return Math.toDegrees(Math.atan(Math.sinh(n))); + } + + private static String stringOrDefault(JsonObject object, String defaultValue, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? defaultValue : value.getAsString(); + } + + private static long longOrDefault(JsonObject object, long defaultValue, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? defaultValue : value.getAsLong(); + } + + private static double doubleOrDefault(JsonObject object, double defaultValue, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? defaultValue : value.getAsDouble(); + } + + private static boolean truthy(String value) { + if (value == null) { + return false; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.equals("yes") || normalized.equals("true") || normalized.equals("1"); + } + + private static String first(Map tags, String... keys) { + for (String key : keys) { + String value = tags.get(key); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static int intFromTag(String value, int defaultValue) { + Double parsed = doubleFromTag(value); + return parsed == null ? defaultValue : (int)Math.round(parsed); + } + + private static Double doubleFromTag(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().replace(',', '.'); + StringBuilder number = new StringBuilder(); + boolean seenDigit = false; + for (int index = 0; index < normalized.length(); index++) { + char ch = normalized.charAt(index); + if ((ch >= '0' && ch <= '9') || ch == '.' || (ch == '-' && number.isEmpty())) { + number.append(ch); + if (ch >= '0' && ch <= '9') { + seenDigit = true; + } + } else if (seenDigit) { + break; + } + } + if (!seenDigit) { + return null; + } + try { + return Double.parseDouble(number.toString()); + } catch (NumberFormatException error) { + return null; + } + } + + private static URI[] parseEndpoints(String config) { + String[] parts = Objects.requireNonNull(config, "overpassEndpoints").split(","); + List parsed = new ArrayList<>(parts.length); + for (String part : parts) { + String trimmed = part == null ? "" : part.trim(); + if (!trimmed.isEmpty()) { + try { + parsed.add(URI.create(trimmed)); + } catch (IllegalArgumentException error) { + LOGGER.warn("Ignoring invalid Arnis Overpass endpoint '{}'", trimmed); + } + } + } + return parsed.isEmpty() ? new URI[]{URI.create("https://overpass-api.de/api/interpreter")} : parsed.toArray(URI[]::new); + } + + private static String normalizedNetworkMode(String value) { + String normalized = value == null ? NETWORK_CACHE_FIRST : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case NETWORK_CACHE_FIRST, NETWORK_CACHE_ONLY, NETWORK_OFF -> normalized; + default -> { + LOGGER.debug("Invalid Arnis Overpass network mode '{}', using {}", value, NETWORK_CACHE_FIRST); + yield NETWORK_CACHE_FIRST; + } + }; + } + + private static boolean cityDetailsEnabled() { + return Boolean.parseBoolean(System.getProperty(CITY_DETAILS_PROPERTY, "true")); + } + + private static boolean blockingCityDetailsNetwork() { + return Boolean.parseBoolean(System.getProperty(BLOCKING_CITY_DETAILS_NETWORK_PROPERTY, "false")); + } + + private static String jsonString(JsonObject object, String name, String fallback) { + JsonElement element = object.get(name); + return element != null && element.isJsonPrimitive() ? element.getAsString() : fallback; + } + + private static boolean jsonBoolean(JsonObject object, String name, boolean fallback) { + JsonElement element = object.get(name); + return element != null && element.isJsonPrimitive() ? element.getAsBoolean() : fallback; + } + + private static int jsonInt(JsonObject object, String name, int fallback) { + JsonElement element = object.get(name); + return element != null && element.isJsonPrimitive() ? element.getAsInt() : fallback; + } + + private static double jsonDouble(JsonObject object, String name) { + JsonElement element = object.get(name); + if (element == null || !element.isJsonPrimitive()) { + throw new IllegalArgumentException("missing numeric field " + name); + } + return element.getAsDouble(); + } + + private static int intProperty(String key, int defaultValue, int minInclusive, int maxInclusive) { + String value = System.getProperty(key); + if (value == null) { + return defaultValue; + } + try { + int parsed = Integer.parseInt(value.trim()); + return Math.max(minInclusive, Math.min(maxInclusive, parsed)); + } catch (NumberFormatException error) { + LOGGER.debug("Invalid integer system property {}='{}', using {}", key, value, defaultValue); + return defaultValue; + } + } + + private static long longProperty(String key, long defaultValue, long minInclusive, long maxInclusive) { + String value = System.getProperty(key); + if (value == null) { + return defaultValue; + } + try { + long parsed = Long.parseLong(value.trim()); + return Math.max(minInclusive, Math.min(maxInclusive, parsed)); + } catch (NumberFormatException error) { + LOGGER.debug("Invalid long system property {}='{}', using {}", key, value, defaultValue); + return defaultValue; + } + } + + private record TileKey(int zoom, int x, int y) { + private GeoBounds bounds() { + return tileBounds(this.zoom, this.x, this.y); + } + } + + private record OfflineRegion(String name, GeoBounds bounds) { + private boolean contains(double latitude, double longitude) { + return latitude >= this.bounds.south() + && latitude <= this.bounds.north() + && longitude >= this.bounds.west() + && longitude <= this.bounds.east(); + } + } + + public record EndpointProbeResult(String endpoint, boolean ok, int httpStatus, long elapsedMs, String message) { + } + + public record CacheEstimate( + boolean enabled, + boolean networkEnabled, + int totalTiles, + int cachedTiles, + int missingTiles, + long cachedBytes, + int sessionNetworkTileBudget, + boolean cityDetailsEnabled, + int cityDetailCachedTiles, + int cityDetailMissingTiles + ) { + private static CacheEstimate disabled() { + return new CacheEstimate(false, false, 0, 0, 0, 0L, 0, false, 0, 0); + } + } + + public record PrefetchResult(CacheEstimate before, CacheEstimate after, int attemptedTiles, int cachedTiles, int failedTiles) { + private static PrefetchResult disabled() { + CacheEstimate disabled = CacheEstimate.disabled(); + return new PrefetchResult(disabled, disabled, 0, 0, 0); + } + } + + private record TileFeatures( + GeoBounds bounds, + List roads, + List buildings, + List areas, + List lines, + List points, + boolean cityDetailsLoaded + ) { + private TileFeatures { + roads = roads == null ? List.of() : List.copyOf(roads); + buildings = buildings == null ? List.of() : List.copyOf(buildings); + areas = areas == null ? List.of() : List.copyOf(areas); + lines = lines == null ? List.of() : List.copyOf(lines); + points = points == null ? List.of() : List.copyOf(points); + } + + private static TileFeatures empty(GeoBounds bounds) { + return new TileFeatures(bounds, List.of(), List.of(), List.of(), List.of(), List.of(), false); + } + + private static TileFeatures empty(GeoBounds bounds, boolean cityDetailsLoaded) { + return new TileFeatures(bounds, List.of(), List.of(), List.of(), List.of(), List.of(), cityDetailsLoaded); + } + + private List roadsForBounds(GeoBounds queryBounds) { + if (!this.bounds.intersects(queryBounds)) { + return List.of(); + } + List matches = new ArrayList<>(); + for (ExternalRoadFeature road : this.roads) { + if (lineBounds(road.points()).intersects(queryBounds)) { + matches.add(road); + } + } + return matches; + } + + private List buildingsForBounds(GeoBounds queryBounds) { + if (!this.bounds.intersects(queryBounds)) { + return List.of(); + } + List matches = new ArrayList<>(); + for (ExternalBuildingFeature building : this.buildings) { + if (ringsBounds(building.rings()).intersects(queryBounds)) { + matches.add(building); + } + } + return matches; + } + + private List areasForBounds(GeoBounds queryBounds) { + if (!this.bounds.intersects(queryBounds)) { + return List.of(); + } + List matches = new ArrayList<>(); + for (ExternalAreaFeature area : this.areas) { + if (ringsBounds(area.rings()).intersects(queryBounds)) { + matches.add(area); + } + } + return matches; + } + + private List linesForBounds(GeoBounds queryBounds) { + if (!this.bounds.intersects(queryBounds)) { + return List.of(); + } + List matches = new ArrayList<>(); + for (ExternalLineFeature line : this.lines) { + if (lineBounds(line.points()).intersects(queryBounds)) { + matches.add(line); + } + } + return matches; + } + + private List pointsForBounds(GeoBounds queryBounds) { + if (!this.bounds.intersects(queryBounds)) { + return List.of(); + } + List matches = new ArrayList<>(); + for (ExternalPointFeature point : this.points) { + if (queryBounds.contains(point.point())) { + matches.add(point); + } + } + return matches; + } + + private static GeoBounds lineBounds(List points) { + double south = Double.POSITIVE_INFINITY; + double west = Double.POSITIVE_INFINITY; + double north = Double.NEGATIVE_INFINITY; + double east = Double.NEGATIVE_INFINITY; + for (GeoPoint point : points) { + south = Math.min(south, point.latitude()); + west = Math.min(west, point.longitude()); + north = Math.max(north, point.latitude()); + east = Math.max(east, point.longitude()); + } + return new GeoBounds(south, west, north, east); + } + + private static GeoBounds ringsBounds(List> rings) { + double south = Double.POSITIVE_INFINITY; + double west = Double.POSITIVE_INFINITY; + double north = Double.NEGATIVE_INFINITY; + double east = Double.NEGATIVE_INFINITY; + for (List ring : rings) { + GeoBounds bounds = lineBounds(ring); + south = Math.min(south, bounds.south()); + west = Math.min(west, bounds.west()); + north = Math.max(north, bounds.north()); + east = Math.max(east, bounds.east()); + } + return new GeoBounds(south, west, north, east); + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java new file mode 100644 index 000000000..838c73143 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java @@ -0,0 +1,297 @@ +package com.yucareux.tellus.world.data.integration; + +import com.yucareux.tellus.world.data.osm.OsmBuildingFeature; +import com.yucareux.tellus.world.data.osm.RoadFeature; +import com.yucareux.tellus.worldgen.EarthProjection; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class TellusExternalFeatureSource { + public static final String PATH_PROPERTY = "tellus.external.features.path"; + public static final String PREFER_EXTERNAL_PROPERTY = "tellus.external.features.prefer"; + public static final String DEFAULT_RELATIVE_PATH = "tellus/external-features.json"; + private static final Logger LOGGER = LoggerFactory.getLogger("tellus"); + private static final long REFRESH_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(2L); + + private final Path path; + private final OverpassExternalFeatureSource overpassSource; + private final boolean preferExternalFeatures; + private final Object refreshLock = new Object(); + private volatile JsonExternalFeatureSource source = new JsonExternalFeatureSource(List.of(), List.of()); + private volatile FileState fileState = FileState.missing(); + private volatile long nextRefreshAtNanos; + + public TellusExternalFeatureSource(Path path) { + this(path, OverpassExternalFeatureSource.createDefault(), Boolean.parseBoolean(System.getProperty(PREFER_EXTERNAL_PROPERTY, "true"))); + } + + public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource) { + this(path, overpassSource, false); + } + + public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource, boolean preferExternalFeatures) { + this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + this.overpassSource = Objects.requireNonNull(overpassSource, "overpassSource"); + this.preferExternalFeatures = preferExternalFeatures; + } + + public static TellusExternalFeatureSource createDefault() { + String configuredPath = System.getProperty(PATH_PROPERTY); + Path path = configuredPath == null || configuredPath.isBlank() + ? FabricLoader.getInstance().getGameDir().resolve(DEFAULT_RELATIVE_PATH) + : Path.of(configuredPath.trim()); + return new TellusExternalFeatureSource(path); + } + + public Path path() { + return this.path; + } + + public boolean available() { + JsonExternalFeatureSource current = this.currentSource(); + return !current.roads().isEmpty() + || !current.buildings().isEmpty() + || !current.areas().isEmpty() + || !current.lines().isEmpty() + || !current.points().isEmpty() + || this.overpassSource.available(); + } + + public boolean roadsAvailable() { + return !this.currentSource().roads().isEmpty() || this.overpassSource.available(); + } + + public boolean buildingsAvailable() { + return !this.currentSource().buildings().isEmpty() || this.overpassSource.available(); + } + + public boolean cityDetailsAvailable() { + JsonExternalFeatureSource current = this.currentSource(); + return !current.areas().isEmpty() || !current.lines().isEmpty() || !current.points().isEmpty() || this.overpassSource.available(); + } + + public boolean preferExternalRoads() { + return this.preferExternalFeatures && this.roadsAvailable(); + } + + public boolean preferExternalBuildings() { + return this.preferExternalFeatures && this.buildingsAvailable(); + } + + public List roadsForArea(int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks) { + GeoBounds bounds = blockBounds(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, marginBlocks); + if (bounds == null) { + return List.of(); + } + + try { + List externalRoads = new ArrayList<>(); + externalRoads.addAll(this.currentSource().roadsForBounds(bounds)); + externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); + if (externalRoads.isEmpty()) { + return List.of(); + } + List roads = new ArrayList<>(externalRoads.size()); + for (ExternalRoadFeature road : externalRoads) { + roads.add(ExternalFeatureAdapters.toTellusRoad(road)); + } + return List.copyOf(roads); + } catch (RuntimeException error) { + LOGGER.warn("Failed to query external Tellus roads from {}", this.path, error); + return List.of(); + } + } + + public List buildingsForArea( + int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks + ) { + GeoBounds bounds = blockBounds(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, marginBlocks); + if (bounds == null) { + return List.of(); + } + + try { + List externalBuildings = new ArrayList<>(); + externalBuildings.addAll(this.currentSource().buildingsForBounds(bounds)); + externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); + if (externalBuildings.isEmpty()) { + return List.of(); + } + List buildings = new ArrayList<>(externalBuildings.size()); + for (ExternalBuildingFeature building : externalBuildings) { + buildings.add(ExternalFeatureAdapters.toTellusBuilding(building)); + } + return List.copyOf(buildings); + } catch (RuntimeException error) { + LOGGER.warn("Failed to query external Tellus buildings from {}", this.path, error); + return List.of(); + } + } + + public List cityAreasForArea( + int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks + ) { + GeoBounds bounds = blockBounds(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, marginBlocks); + if (bounds == null) { + return List.of(); + } + + try { + List areas = new ArrayList<>(); + areas.addAll(this.currentSource().areasForBounds(bounds)); + areas.addAll(this.overpassSource.areasForBounds(bounds)); + return areas.isEmpty() ? List.of() : List.copyOf(areas); + } catch (RuntimeException error) { + LOGGER.warn("Failed to query external Tellus city areas from {}", this.path, error); + return List.of(); + } + } + + public List cityLinesForArea( + int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks + ) { + GeoBounds bounds = blockBounds(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, marginBlocks); + if (bounds == null) { + return List.of(); + } + + try { + List lines = new ArrayList<>(); + lines.addAll(this.currentSource().linesForBounds(bounds)); + lines.addAll(this.overpassSource.linesForBounds(bounds)); + return lines.isEmpty() ? List.of() : List.copyOf(lines); + } catch (RuntimeException error) { + LOGGER.warn("Failed to query external Tellus city lines from {}", this.path, error); + return List.of(); + } + } + + public List cityPointsForArea( + int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks + ) { + GeoBounds bounds = blockBounds(minBlockX, minBlockZ, maxBlockX, maxBlockZ, worldScale, marginBlocks); + if (bounds == null) { + return List.of(); + } + + try { + List points = new ArrayList<>(); + points.addAll(this.currentSource().pointsForBounds(bounds)); + points.addAll(this.overpassSource.pointsForBounds(bounds)); + return points.isEmpty() ? List.of() : List.copyOf(points); + } catch (RuntimeException error) { + LOGGER.warn("Failed to query external Tellus city points from {}", this.path, error); + return List.of(); + } + } + + private JsonExternalFeatureSource currentSource() { + this.refreshIfNeeded(); + return this.source; + } + + private void refreshIfNeeded() { + long now = System.nanoTime(); + if (now < this.nextRefreshAtNanos) { + return; + } + + synchronized (this.refreshLock) { + now = System.nanoTime(); + if (now < this.nextRefreshAtNanos) { + return; + } + this.nextRefreshAtNanos = now + REFRESH_INTERVAL_NANOS; + this.refresh(); + } + } + + private void refresh() { + FileState nextState = FileState.read(this.path); + if (nextState.equals(this.fileState)) { + return; + } + + if (!nextState.exists()) { + this.fileState = nextState; + this.source = new JsonExternalFeatureSource(List.of(), List.of()); + return; + } + + try { + JsonExternalFeatureSource nextSource = JsonExternalFeatureSource.fromPath(this.path); + this.source = nextSource; + this.fileState = nextState; + LOGGER.info( + "Loaded external Tellus features from {} (roads={}, buildings={}, areas={}, lines={}, points={})", + this.path, + nextSource.roads().size(), + nextSource.buildings().size(), + nextSource.areas().size(), + nextSource.lines().size(), + nextSource.points().size() + ); + } catch (IOException | RuntimeException error) { + this.fileState = nextState; + this.source = new JsonExternalFeatureSource(List.of(), List.of()); + LOGGER.warn("Failed to load external Tellus features from {}", this.path, error); + } + } + + private static GeoBounds blockBounds(int minBlockX, int minBlockZ, int maxBlockX, int maxBlockZ, double worldScale, int marginBlocks) { + if (!(worldScale > 0.0)) { + return null; + } + + int margin = Math.max(0, marginBlocks); + double blocksPerDegree = EarthProjection.blocksPerDegree(worldScale); + if (!(blocksPerDegree > 0.0)) { + return null; + } + + double lonA = (Math.min(minBlockX, maxBlockX) - margin) / blocksPerDegree; + double lonB = (Math.max(minBlockX, maxBlockX) + margin) / blocksPerDegree; + double westRaw = Math.min(lonA, lonB); + double eastRaw = Math.max(lonA, lonB); + if (eastRaw < -180.0 || westRaw > 180.0) { + return null; + } + + double latA = EarthProjection.blockZToLat(Math.min(minBlockZ, maxBlockZ) - margin, worldScale); + double latB = EarthProjection.blockZToLat(Math.max(minBlockZ, maxBlockZ) + margin, worldScale); + double south = Math.min(latA, latB); + double north = Math.max(latA, latB); + double west = Math.max(-180.0, westRaw); + double east = Math.min(180.0, eastRaw); + if (west > east) { + return null; + } + return new GeoBounds(south, west, north, east); + } + + private record FileState(boolean exists, long modifiedMillis, long size) { + private static FileState missing() { + return new FileState(false, 0L, 0L); + } + + private static FileState read(Path path) { + try { + if (!Files.isRegularFile(path)) { + return missing(); + } + return new FileState(true, Files.getLastModifiedTime(path).toMillis(), Files.size(path)); + } catch (IOException error) { + LOGGER.warn("Failed to stat external Tellus feature file {}", path, error); + return missing(); + } + } + } +} diff --git a/src/main/java/com/yucareux/tellus/world/data/mask/PmTilesReader.java b/src/main/java/com/yucareux/tellus/world/data/mask/PmTilesReader.java index 72f62677b..d7db381a9 100644 --- a/src/main/java/com/yucareux/tellus/world/data/mask/PmTilesReader.java +++ b/src/main/java/com/yucareux/tellus/world/data/mask/PmTilesReader.java @@ -4,6 +4,7 @@ import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; @@ -187,6 +188,7 @@ private byte[] readBytes(long offset, int length) throws IOException { if (length <= 0) { return new byte[0]; } else { + long startMs = System.currentTimeMillis(); HttpURLConnection connection = (HttpURLConnection)this.uri().toURL().openConnection(); connection.setRequestProperty("Range", "bytes=" + offset + "-" + (offset + length - 1L)); connection.setInstanceFollowRedirects(true); @@ -195,22 +197,32 @@ private byte[] readBytes(long offset, int length) throws IOException { int code = connection.getResponseCode(); if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) { connection.disconnect(); + TellusDiagnostics.traffic("Landmask PMTiles range failed uri=%s status=%d offset=%d requestedBytes=%d elapsedMs=%d", this.uri(), code, offset, length, System.currentTimeMillis() - startMs); throw new IOException("PMTiles HTTP error " + code); } - byte[] var7; + byte[] data; try (InputStream input = connection.getInputStream()) { if (code == HttpURLConnection.HTTP_OK) { skipFully(input, offset); - return readFully(input, length); + data = readFully(input, length); + } else { + data = readFully(input, length); } - - var7 = readFully(input, length); } finally { connection.disconnect(); } - return var7; + TellusDiagnostics.traffic( + "Landmask PMTiles range ok uri=%s status=%d offset=%d requestedBytes=%d bytes=%d elapsedMs=%d", + this.uri(), + code, + offset, + length, + data.length, + System.currentTimeMillis() - startMs + ); + return data; } } diff --git a/src/main/java/com/yucareux/tellus/world/data/osm/OsmBuildingMetadata.java b/src/main/java/com/yucareux/tellus/world/data/osm/OsmBuildingMetadata.java index 595e8a26c..ae5924b9b 100644 --- a/src/main/java/com/yucareux/tellus/world/data/osm/OsmBuildingMetadata.java +++ b/src/main/java/com/yucareux/tellus/world/data/osm/OsmBuildingMetadata.java @@ -7,16 +7,26 @@ public record OsmBuildingMetadata( String name, int floorCount, String roofShape, - String roofMaterial + int roofLevels, + double roofHeightMeters, + String roofMaterial, + String wallMaterial, + String roofColor, + String wallColor ) { public OsmBuildingMetadata { floorCount = Math.max(1, floorCount); + roofLevels = Math.max(0, roofLevels); + roofHeightMeters = Double.isFinite(roofHeightMeters) && roofHeightMeters > 0.0 ? roofHeightMeters : 0.0; buildingClass = normalize(buildingClass); subtype = normalize(subtype); use = normalize(use); name = normalize(name); roofShape = normalize(roofShape); roofMaterial = normalize(roofMaterial); + wallMaterial = normalize(wallMaterial); + roofColor = normalize(roofColor); + wallColor = normalize(wallColor); } public String primaryType() { diff --git a/src/main/java/com/yucareux/tellus/world/data/osm/OverpassRoadClient.java b/src/main/java/com/yucareux/tellus/world/data/osm/OverpassRoadClient.java index bf4c67f13..7200cea71 100644 --- a/src/main/java/com/yucareux/tellus/world/data/osm/OverpassRoadClient.java +++ b/src/main/java/com/yucareux/tellus/world/data/osm/OverpassRoadClient.java @@ -1,6 +1,7 @@ package com.yucareux.tellus.world.data.osm; import com.yucareux.tellus.Tellus; +import com.yucareux.tellus.util.TellusDiagnostics; import com.yucareux.tellus.world.data.source.DownloadProgressReporter; import java.io.IOException; import java.io.InputStream; @@ -92,6 +93,7 @@ private byte[] executeQuery(URI endpoint, String query) throws IOException { this.acquireRequestGuard(); byte[] responseBody; + long startMs = System.currentTimeMillis(); try { this.applyRateLimitDelay(); HttpURLConnection connection = (HttpURLConnection)endpoint.toURL().openConnection(); @@ -113,6 +115,15 @@ private byte[] executeQuery(URI endpoint, String query) throws IOException { String message = readErrorSnippet(connection); boolean retryableStatus = status == 408 || status == 429 || status >= 500; String detail = "Overpass HTTP " + status + " (" + endpoint.getHost() + ")" + (message.isEmpty() ? "" : ": " + message); + TellusDiagnostics.traffic( + "Legacy Overpass roads failed endpoint=%s status=%d retryable=%s payloadBytes=%d elapsedMs=%d detail=%s", + endpoint, + status, + retryableStatus, + payload.length, + System.currentTimeMillis() - startMs, + detail + ); if (retryableStatus) { this.applyRetryableCooldown(status, parseRetryAfterMillis(connection.getHeaderField("Retry-After"))); throw new OverpassRoadClient.RetryableOverpassException(detail); @@ -128,6 +139,14 @@ private byte[] executeQuery(URI endpoint, String query) throws IOException { } finally { DownloadProgressReporter.requestFinished(); } + TellusDiagnostics.traffic( + "Legacy Overpass roads ok endpoint=%s status=%d payloadBytes=%d bytes=%d elapsedMs=%d", + endpoint, + status, + payload.length, + responseBody.length, + System.currentTimeMillis() - startMs + ); } finally { connection.disconnect(); } diff --git a/src/main/java/com/yucareux/tellus/world/data/osm/ParsedTileCodec.java b/src/main/java/com/yucareux/tellus/world/data/osm/ParsedTileCodec.java index c167128a3..6ab288e1d 100644 --- a/src/main/java/com/yucareux/tellus/world/data/osm/ParsedTileCodec.java +++ b/src/main/java/com/yucareux/tellus/world/data/osm/ParsedTileCodec.java @@ -18,7 +18,7 @@ final class ParsedTileCodec { private static final int MAGIC_SAND = 1396787524; private static final int ROAD_VERSION = 3; private static final int WATER_VERSION = 1; - private static final int BUILDING_VERSION = 2; + private static final int BUILDING_VERSION = 5; private static final int SAND_VERSION = 1; private static final int MAX_FEATURES = 100000; private static final int MAX_POINTS_PER_FEATURE = 100000; @@ -312,7 +312,12 @@ static OsmBuildingTile readBuildingTile(Path path) throws IOException { String name = readOptionalUtf(input); int floorCount = input.readInt(); String roofShape = readOptionalUtf(input); + int roofLevels = input.readInt(); + double roofHeightMeters = input.readDouble(); String roofMaterial = readOptionalUtf(input); + String wallMaterial = readOptionalUtf(input); + String roofColor = readOptionalUtf(input); + String wallColor = readOptionalUtf(input); double heightMeters = input.readDouble(); double minHeightMeters = input.readDouble(); int partCount = boundedCount(input.readInt(), MAX_FEATURES, "building part"); @@ -344,7 +349,20 @@ static OsmBuildingTile readBuildingTile(Path path) throws IOException { featureId, buildingId, hasParts, - new OsmBuildingMetadata(buildingClass, subtype, use, name, floorCount, roofShape, roofMaterial), + new OsmBuildingMetadata( + buildingClass, + subtype, + use, + name, + floorCount, + roofShape, + roofLevels, + roofHeightMeters, + roofMaterial, + wallMaterial, + roofColor, + wallColor + ), heightMeters, minHeightMeters, longitudes, @@ -386,7 +404,12 @@ static void writeBuildingTile(Path path, OsmBuildingTile tile) throws IOExceptio writeOptionalUtf(output, feature.metadata().name()); output.writeInt(feature.metadata().floorCount()); writeOptionalUtf(output, feature.metadata().roofShape()); + output.writeInt(feature.metadata().roofLevels()); + output.writeDouble(feature.metadata().roofHeightMeters()); writeOptionalUtf(output, feature.metadata().roofMaterial()); + writeOptionalUtf(output, feature.metadata().wallMaterial()); + writeOptionalUtf(output, feature.metadata().roofColor()); + writeOptionalUtf(output, feature.metadata().wallColor()); output.writeDouble(feature.heightMeters()); output.writeDouble(feature.minHeightMeters()); output.writeInt(feature.partCount()); diff --git a/src/main/java/com/yucareux/tellus/world/data/osm/PmTilesRangeReader.java b/src/main/java/com/yucareux/tellus/world/data/osm/PmTilesRangeReader.java index fd791ff8f..e0879c399 100644 --- a/src/main/java/com/yucareux/tellus/world/data/osm/PmTilesRangeReader.java +++ b/src/main/java/com/yucareux/tellus/world/data/osm/PmTilesRangeReader.java @@ -3,6 +3,7 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; +import com.yucareux.tellus.util.TellusDiagnostics; import com.yucareux.tellus.world.data.source.DownloadProgressReporter; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -184,6 +185,7 @@ private byte[] readBytes(long offset, int length) throws IOException { if (length <= 0) { return new byte[0]; } else { + long startMs = System.currentTimeMillis(); HttpURLConnection connection = (HttpURLConnection)this.uri.toURL().openConnection(); connection.setRequestProperty("Range", "bytes=" + offset + "-" + (offset + length - 1L)); connection.setInstanceFollowRedirects(true); @@ -193,24 +195,44 @@ private byte[] readBytes(long offset, int length) throws IOException { long expectedBytes = connection.getContentLengthLong(); DownloadProgressReporter.requestStarted(expectedBytes); - byte[] var9; + byte[] data; try (InputStream input = openStream(connection, code)) { if (code == 200) { skipFully(input, offset); - return readFully(input, length); - } + data = readFully(input, length); + } else { + if (code != 206) { + throw new IOException("PMTiles HTTP error " + code); + } - if (code != 206) { - throw new IOException("PMTiles HTTP error " + code); + data = readFully(input, length); } - - var9 = readFully(input, length); + } catch (IOException | RuntimeException error) { + TellusDiagnostics.traffic( + "PMTiles range failed uri=%s status=%d offset=%d requestedBytes=%d elapsedMs=%d error=%s", + this.uri, + code, + offset, + length, + System.currentTimeMillis() - startMs, + error.getMessage() + ); + throw error; } finally { DownloadProgressReporter.requestFinished(); connection.disconnect(); } - return var9; + TellusDiagnostics.traffic( + "PMTiles range ok uri=%s status=%d offset=%d requestedBytes=%d bytes=%d elapsedMs=%d", + this.uri, + code, + offset, + length, + data.length, + System.currentTimeMillis() - startMs + ); + return data; } } diff --git a/src/main/java/com/yucareux/tellus/world/data/osm/RoadFeature.java b/src/main/java/com/yucareux/tellus/world/data/osm/RoadFeature.java index 32f5039e9..4fdeb7876 100644 --- a/src/main/java/com/yucareux/tellus/world/data/osm/RoadFeature.java +++ b/src/main/java/com/yucareux/tellus/world/data/osm/RoadFeature.java @@ -1,7 +1,10 @@ package com.yucareux.tellus.world.data.osm; import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Locale; +import java.util.Map; import java.util.Objects; public final class RoadFeature { @@ -10,6 +13,7 @@ public final class RoadFeature { private final RoadMode mode; private final int bridgeLevel; private final String highwayTag; + private final Map tags; private final double[] longitudes; private final double[] latitudes; private final double minLon; @@ -18,11 +22,25 @@ public final class RoadFeature { private final double maxLat; public RoadFeature(long wayId, RoadClass roadClass, RoadMode mode, int bridgeLevel, String highwayTag, double[] longitudes, double[] latitudes) { + this(wayId, roadClass, mode, bridgeLevel, highwayTag, longitudes, latitudes, Map.of()); + } + + public RoadFeature( + long wayId, + RoadClass roadClass, + RoadMode mode, + int bridgeLevel, + String highwayTag, + double[] longitudes, + double[] latitudes, + Map tags + ) { this.wayId = wayId; this.roadClass = Objects.requireNonNull(roadClass, "roadClass"); this.mode = Objects.requireNonNull(mode, "mode"); this.bridgeLevel = Math.max(0, bridgeLevel); this.highwayTag = normalizeHighwayTag(highwayTag); + this.tags = Collections.unmodifiableMap(normalizeTags(tags)); this.longitudes = Objects.requireNonNull(longitudes, "longitudes"); this.latitudes = Objects.requireNonNull(latitudes, "latitudes"); if (this.longitudes.length == this.latitudes.length && this.longitudes.length >= 2) { @@ -71,6 +89,73 @@ public String highwayTag() { return this.highwayTag; } + public Map tags() { + return this.tags; + } + + public String tag(String key) { + return this.tags.get(normalizeTagKey(key)); + } + + public String surfaceTag() { + String surface = this.tag("surface"); + return surface == null ? "" : surface.trim().toLowerCase(Locale.ROOT); + } + + public boolean hasSidewalk() { + return this.hasLeftSidewalk() || this.hasRightSidewalk(); + } + + public boolean hasLeftSidewalk() { + String sidewalk = this.tag("sidewalk"); + return isSidewalkBoth(sidewalk) + || isSidewalkSide(sidewalk, "left") + || isAttachedSidewalk(this.tag("sidewalk:left")) + || isAttachedSidewalk(this.tag("sidewalk:both")); + } + + public boolean hasRightSidewalk() { + String sidewalk = this.tag("sidewalk"); + return isSidewalkBoth(sidewalk) + || isSidewalkSide(sidewalk, "right") + || isAttachedSidewalk(this.tag("sidewalk:right")) + || isAttachedSidewalk(this.tag("sidewalk:both")); + } + + public int laneCount() { + int explicit = this.positiveIntTag("lanes"); + if (explicit > 0) { + return explicit; + } + + int directional = this.positiveIntTag("lanes:forward") + this.positiveIntTag("lanes:backward"); + return Math.max(0, directional); + } + + public boolean isUnpavedSurface() { + String surface = this.surfaceTag(); + if (surface.isEmpty()) { + return this.tag("tracktype") != null; + } + + return switch (surface) { + case "unpaved", "compacted", "fine_gravel", "gravel", "pebblestone", "ground", "earth", "dirt", "grass", "grass_paver", "mud", "sand", "woodchips" -> true; + default -> false; + }; + } + + public boolean isPavedSurface() { + String surface = this.surfaceTag(); + if (surface.isEmpty()) { + return false; + } + + return switch (surface) { + case "paved", "asphalt", "concrete", "concrete:lanes", "concrete:plates", "paving_stones", "sett", "cobblestone", "bricks", "brick", "tiles" -> true; + default -> false; + }; + } + public boolean matchesHighwayTag(String highwayTag) { return this.highwayTag.equals(normalizeHighwayTag(highwayTag)); } @@ -122,4 +207,72 @@ public boolean intersects(double south, double west, double north, double east) private static String normalizeHighwayTag(String highwayTag) { return highwayTag == null ? "" : highwayTag.trim().toLowerCase(Locale.ROOT); } + + private static Map normalizeTags(Map tags) { + if (tags == null || tags.isEmpty()) { + return Map.of(); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : tags.entrySet()) { + String key = normalizeTagKey(entry.getKey()); + String value = entry.getValue(); + if (!key.isEmpty() && value != null && !value.isBlank()) { + normalized.put(key, value.trim()); + } + } + return normalized; + } + + private static String normalizeTagKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); + } + + private int positiveIntTag(String key) { + String value = this.tag(key); + if (value == null || value.isBlank()) { + return 0; + } + + String trimmed = value.trim(); + int end = 0; + while (end < trimmed.length() && Character.isDigit(trimmed.charAt(end))) { + end++; + } + if (end == 0) { + return 0; + } + + try { + return Math.max(0, Integer.parseInt(trimmed.substring(0, end))); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private static boolean isAttachedSidewalk(String value) { + if (value == null || value.isBlank()) { + return false; + } + + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "no", "none", "separate" -> false; + default -> true; + }; + } + + private static boolean isSidewalkBoth(String value) { + if (value == null || value.isBlank()) { + return false; + } + + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "yes", "both", "left;right", "right;left" -> true; + default -> false; + }; + } + + private static boolean isSidewalkSide(String value, String side) { + return value != null && value.trim().toLowerCase(Locale.ROOT).equals(side); + } } diff --git a/src/main/java/com/yucareux/tellus/worldgen/building/BuildingProfile.java b/src/main/java/com/yucareux/tellus/worldgen/building/BuildingProfile.java index e6553d828..cb33392a8 100644 --- a/src/main/java/com/yucareux/tellus/worldgen/building/BuildingProfile.java +++ b/src/main/java/com/yucareux/tellus/worldgen/building/BuildingProfile.java @@ -1,5 +1,7 @@ package com.yucareux.tellus.worldgen.building; +import java.util.Locale; + public record BuildingProfile( BuildingProfile.Archetype archetype, BuildingProfile.RoofProfile roofProfile, @@ -11,7 +13,12 @@ public record BuildingProfile( int roofRise, int setbackEveryFloors, int maxSetback, - int windowSpacing + int windowSpacing, + String primaryType, + String wallMaterial, + String roofMaterial, + String wallColor, + String roofColor ) { public BuildingProfile { floorCount = Math.max(1, floorCount); @@ -21,6 +28,15 @@ public record BuildingProfile( setbackEveryFloors = Math.max(0, setbackEveryFloors); maxSetback = Math.max(0, maxSetback); windowSpacing = Math.max(2, windowSpacing); + primaryType = normalizeMaterial(primaryType); + wallMaterial = normalizeMaterial(wallMaterial); + roofMaterial = normalizeMaterial(roofMaterial); + wallColor = normalizeMaterial(wallColor); + roofColor = normalizeMaterial(roofColor); + } + + private static String normalizeMaterial(String value) { + return value == null || value.isBlank() ? "" : value.trim().toLowerCase(Locale.ROOT); } public enum Archetype { diff --git a/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingMaterials.java b/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingMaterials.java index 3f40eb2a8..7645edf2a 100644 --- a/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingMaterials.java +++ b/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingMaterials.java @@ -13,6 +13,12 @@ public final class TellusBuildingMaterials { private static final BlockState BUILDING_SLATE_ROOF_STATE = Blocks.DEEPSLATE_TILES.defaultBlockState(); private static final BlockState BUILDING_CLAY_TILE_ROOF_STATE = Blocks.BRICKS.defaultBlockState(); private static final BlockState BUILDING_STONE_ROOF_STATE = Blocks.STONE_BRICKS.defaultBlockState(); + private static final BlockState BUILDING_METAL_ROOF_STATE = Blocks.IRON_BLOCK.defaultBlockState(); + private static final BlockState BUILDING_DARK_ROOF_STATE = Blocks.BLACK_CONCRETE.defaultBlockState(); + private static final BlockState BUILDING_GREEN_ROOF_STATE = Blocks.MOSS_BLOCK.defaultBlockState(); + private static final BlockState BUILDING_RED_ROOF_STATE = Blocks.RED_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_ORANGE_ROOF_STATE = Blocks.ORANGE_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_BROWN_ROOF_STATE = Blocks.BROWN_TERRACOTTA.defaultBlockState(); private static final BlockState BUILDING_RESIDENTIAL_WALL_STATE = Blocks.WHITE_TERRACOTTA.defaultBlockState(); private static final BlockState BUILDING_ARID_WALL_STATE = Blocks.SANDSTONE.defaultBlockState(); private static final BlockState BUILDING_SANDSTONE_WALL_STATE = Blocks.SMOOTH_SANDSTONE.defaultBlockState(); @@ -23,6 +29,12 @@ public final class TellusBuildingMaterials { private static final BlockState BUILDING_COMMERCIAL_WALL_STATE = Blocks.LIGHT_GRAY_CONCRETE.defaultBlockState(); private static final BlockState BUILDING_INDUSTRIAL_WALL_STATE = Blocks.ANDESITE.defaultBlockState(); private static final BlockState BUILDING_TOWER_WALL_STATE = Blocks.CYAN_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_GLASS_WALL_STATE = Blocks.LIGHT_BLUE_STAINED_GLASS.defaultBlockState(); + private static final BlockState BUILDING_METAL_WALL_STATE = Blocks.IRON_BLOCK.defaultBlockState(); + private static final BlockState BUILDING_RED_WALL_STATE = Blocks.RED_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_YELLOW_WALL_STATE = Blocks.YELLOW_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_BROWN_WALL_STATE = Blocks.BROWN_TERRACOTTA.defaultBlockState(); + private static final BlockState BUILDING_DARK_WALL_STATE = Blocks.GRAY_CONCRETE.defaultBlockState(); private static final BlockState BUILDING_TRIM_STATE = Blocks.POLISHED_ANDESITE.defaultBlockState(); private static final BlockState BUILDING_WHITE_TRIM_STATE = Blocks.SMOOTH_QUARTZ.defaultBlockState(); private static final BlockState BUILDING_SANDSTONE_TRIM_STATE = Blocks.CUT_SANDSTONE.defaultBlockState(); @@ -81,6 +93,27 @@ public static TellusBuildingMaterials.BuildingMaterialPalette resolvePalette(Bui }; } + BlockState materialWall = wallBlockForMaterial(profile.wallMaterial()); + if (materialWall != null) { + wall = materialWall; + trim = trimBlockForWallMaterial(profile.wallMaterial(), trim); + } + + BlockState materialRoof = roofBlockForMaterial(profile.roofMaterial()); + if (materialRoof != null) { + roof = materialRoof; + } + + BlockState colorWall = wallBlockForColor(profile.wallColor()); + if (colorWall != null) { + wall = colorWall; + } + + BlockState colorRoof = roofBlockForColor(profile.roofColor()); + if (colorRoof != null) { + roof = colorRoof; + } + BlockState floor = switch (profile.archetype()) { case HOUSE, APARTMENT -> BUILDING_RESIDENTIAL_FLOOR_STATE; default -> BUILDING_FLOOR_STATE; @@ -99,6 +132,145 @@ public static TellusBuildingMaterials.BuildingMaterialPalette resolvePalette(Bui return new TellusBuildingMaterials.BuildingMaterialPalette(wall, trim, roof, window, floor, BUILDING_PARTITION_STATE, stair, slab, BUILDING_LIGHT_STATE); } + private static BlockState wallBlockForMaterial(String material) { + if (material == null || material.isBlank()) { + return null; + } + + if (containsAny(material, "brick", "masonry")) { + return BUILDING_BRICK_WALL_STATE; + } + if (containsAny(material, "concrete", "cement", "plaster", "stucco")) { + return BUILDING_COMMERCIAL_WALL_STATE; + } + if (containsAny(material, "stone", "limestone", "marble")) { + return BUILDING_PALE_STONE_WALL_STATE; + } + if (containsAny(material, "granite", "slate", "andesite")) { + return BUILDING_INDUSTRIAL_WALL_STATE; + } + if (containsAny(material, "wood", "timber", "log")) { + return BUILDING_TROPICAL_WALL_STATE; + } + if (containsAny(material, "glass")) { + return BUILDING_GLASS_WALL_STATE; + } + if (containsAny(material, "metal", "steel", "aluminium", "aluminum")) { + return BUILDING_METAL_WALL_STATE; + } + return null; + } + + private static BlockState trimBlockForWallMaterial(String material, BlockState fallback) { + if (material == null || material.isBlank()) { + return fallback; + } + + if (containsAny(material, "brick", "stone", "limestone", "marble", "granite", "slate")) { + return BUILDING_BRICK_TRIM_STATE; + } + if (containsAny(material, "concrete", "cement", "plaster", "stucco", "glass", "metal", "steel", "aluminium", "aluminum")) { + return BUILDING_TRIM_STATE; + } + if (containsAny(material, "wood", "timber", "log")) { + return BUILDING_WHITE_TRIM_STATE; + } + return fallback; + } + + private static BlockState wallBlockForColor(String color) { + if (color == null || color.isBlank()) { + return null; + } + + if (containsAny(color, "white", "cream", "ivory", "#fff", "#ffffff")) { + return BUILDING_RESIDENTIAL_WALL_STATE; + } + if (containsAny(color, "light gray", "light grey", "silver", "#ccc", "#cccccc")) { + return BUILDING_COLD_WALL_STATE; + } + if (containsAny(color, "gray", "grey")) { + return BUILDING_COMMERCIAL_WALL_STATE; + } + if (containsAny(color, "black", "dark")) { + return BUILDING_DARK_WALL_STATE; + } + if (containsAny(color, "red", "maroon")) { + return BUILDING_RED_WALL_STATE; + } + if (containsAny(color, "yellow", "gold", "beige")) { + return BUILDING_YELLOW_WALL_STATE; + } + if (containsAny(color, "brown", "tan")) { + return BUILDING_BROWN_WALL_STATE; + } + if (containsAny(color, "blue", "cyan")) { + return BUILDING_TOWER_WALL_STATE; + } + return null; + } + + private static BlockState roofBlockForColor(String color) { + if (color == null || color.isBlank()) { + return null; + } + + if (containsAny(color, "red", "maroon")) { + return BUILDING_RED_ROOF_STATE; + } + if (containsAny(color, "orange")) { + return BUILDING_ORANGE_ROOF_STATE; + } + if (containsAny(color, "brown", "tan")) { + return BUILDING_BROWN_ROOF_STATE; + } + if (containsAny(color, "black", "dark")) { + return BUILDING_DARK_ROOF_STATE; + } + if (containsAny(color, "gray", "grey", "silver")) { + return BUILDING_ROOF_STATE; + } + if (containsAny(color, "green")) { + return BUILDING_GREEN_ROOF_STATE; + } + return null; + } + + private static BlockState roofBlockForMaterial(String material) { + if (material == null || material.isBlank()) { + return null; + } + + if (containsAny(material, "tile", "clay", "terracotta", "brick")) { + return BUILDING_CLAY_TILE_ROOF_STATE; + } + if (containsAny(material, "slate", "shingle")) { + return BUILDING_SLATE_ROOF_STATE; + } + if (containsAny(material, "stone", "concrete", "cement")) { + return BUILDING_STONE_ROOF_STATE; + } + if (containsAny(material, "metal", "steel", "tin", "copper", "zinc", "aluminium", "aluminum")) { + return BUILDING_METAL_ROOF_STATE; + } + if (containsAny(material, "asphalt", "bitumen", "tar", "rubber")) { + return BUILDING_DARK_ROOF_STATE; + } + if (containsAny(material, "grass", "green", "vegetation", "moss")) { + return BUILDING_GREEN_ROOF_STATE; + } + return null; + } + + private static boolean containsAny(String value, String... parts) { + for (String part : parts) { + if (value.contains(part)) { + return true; + } + } + return false; + } + public static BlockState resolveLodFacadeBlock( BuildingBlueprint blueprint, TellusBuildingMaterials.BuildingMaterialPalette palette, int boundaryDistance, int floorIndex ) { diff --git a/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingProfiles.java b/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingProfiles.java index bd3c3c09c..3b6b0fa10 100644 --- a/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingProfiles.java +++ b/src/main/java/com/yucareux/tellus/worldgen/building/TellusBuildingProfiles.java @@ -25,11 +25,12 @@ public static BuildingProfile resolveProfile(OsmBuildingFeature feature, double case TOWER -> 2; case GENERIC -> 1; }; + int taggedRoofRise = resolveTaggedRoofRise(metadata, worldScale, storeyHeightBlocks); int roofRise = switch (roofProfile) { - case GABLED_X, GABLED_Z, HIPPED -> Math.max(1, Math.min(4, (int)Math.round(feature.heightMeters() / 12.0))); - case FLAT_CROWN -> 2; - case FLAT_SKYLIGHT -> 1; - case FLAT -> 0; + case GABLED_X, GABLED_Z, HIPPED -> taggedRoofRise > 0 ? taggedRoofRise : Math.max(1, Math.min(4, (int)Math.round(feature.heightMeters() / 12.0))); + case FLAT_CROWN -> taggedRoofRise > 0 ? taggedRoofRise : 2; + case FLAT_SKYLIGHT -> taggedRoofRise > 0 ? taggedRoofRise : 1; + case FLAT -> taggedRoofRise; }; int setbackEveryFloors = archetype == BuildingProfile.Archetype.TOWER && floorCount >= 12 ? 8 : 0; int maxSetback = archetype == BuildingProfile.Archetype.TOWER ? 3 : 0; @@ -41,13 +42,40 @@ public static BuildingProfile resolveProfile(OsmBuildingFeature feature, double case TOWER -> 4; case GENERIC -> 4; }; - return new BuildingProfile(archetype, roofProfile, climate, floorCount, storeyHeightBlocks, interiorsEnabled, parapetHeight, roofRise, setbackEveryFloors, maxSetback, windowSpacing); + return new BuildingProfile( + archetype, + roofProfile, + climate, + floorCount, + storeyHeightBlocks, + interiorsEnabled, + parapetHeight, + roofRise, + setbackEveryFloors, + maxSetback, + windowSpacing, + metadata.primaryType(), + metadata.wallMaterial(), + metadata.roofMaterial(), + metadata.wallColor(), + metadata.roofColor() + ); } public static int inferFloorCount(double heightMeters) { return Math.max(1, (int)Math.round(heightMeters / DEFAULT_STOREY_METERS)); } + private static int resolveTaggedRoofRise(OsmBuildingMetadata metadata, double worldScale, int storeyHeightBlocks) { + if (metadata.roofHeightMeters() > 0.0) { + return Math.max(1, (int)Math.round(metadata.roofHeightMeters() / Math.max(1.0, worldScale))); + } + if (metadata.roofLevels() > 0) { + return Math.max(1, metadata.roofLevels() * Math.max(1, storeyHeightBlocks)); + } + return 0; + } + private static BuildingProfile.Archetype resolveArchetype(OsmBuildingMetadata metadata, double areaSquareMeters, int floorCount, double heightMeters) { String type = metadata.primaryType(); String normalized = type == null ? "" : type.toLowerCase(); diff --git a/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeGenerator.java b/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeGenerator.java new file mode 100644 index 000000000..217df5cb5 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeGenerator.java @@ -0,0 +1,239 @@ +package com.yucareux.tellus.worldgen.vegetation; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.BlockPos.MutableBlockPos; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; + +public final class ArnisTreeGenerator { + private static final int[][] ROUND1_PATTERN = { + {-2, 0, 0}, + {2, 0, 0}, + {0, 0, -2}, + {0, 0, 2}, + {-1, 0, -1}, + {1, 0, 1}, + {1, 0, -1}, + {-1, 0, 1} + }; + private static final int[][] ROUND2_PATTERN = { + {3, 0, 0}, + {2, 0, -1}, + {2, 0, 1}, + {1, 0, -2}, + {1, 0, 2}, + {-3, 0, 0}, + {-2, 0, -1}, + {-2, 0, 1}, + {-1, 0, 2}, + {-1, 0, -2}, + {0, 0, -3}, + {0, 0, 3} + }; + private static final int[][] ROUND3_PATTERN = { + {3, 0, -1}, + {3, 0, 1}, + {2, 0, -2}, + {2, 0, 2}, + {1, 0, -3}, + {1, 0, 3}, + {-3, 0, -1}, + {-3, 0, 1}, + {-2, 0, -2}, + {-2, 0, 2}, + {-1, 0, 3}, + {-1, 0, -3} + }; + private static final int[][][] ROUND_PATTERNS = {ROUND1_PATTERN, ROUND2_PATTERN, ROUND3_PATTERN}; + + private static final int[][][] OAK_LEAVES_FILL = { + {{-1, 3, 0}, {-1, 9, 0}}, + {{1, 3, 0}, {1, 9, 0}}, + {{0, 3, -1}, {0, 9, -1}}, + {{0, 3, 1}, {0, 9, 1}}, + {{0, 9, 0}, {0, 10, 0}} + }; + private static final int[][][] SPRUCE_LEAVES_FILL = { + {{-1, 3, 0}, {-1, 10, 0}}, + {{0, 3, -1}, {0, 10, -1}}, + {{1, 3, 0}, {1, 10, 0}}, + {{0, 3, 1}, {0, 10, 1}}, + {{0, 11, 0}, {0, 11, 0}} + }; + private static final int[][][] BIRCH_LEAVES_FILL = { + {{-1, 2, 0}, {-1, 7, 0}}, + {{1, 2, 0}, {1, 7, 0}}, + {{0, 2, -1}, {0, 7, -1}}, + {{0, 2, 1}, {0, 7, 1}}, + {{0, 7, 0}, {0, 8, 0}} + }; + private static final int[][][] DARK_OAK_LEAVES_FILL = { + {{-1, 3, 0}, {-1, 6, 0}}, + {{1, 3, 0}, {1, 6, 0}}, + {{0, 3, -1}, {0, 6, -1}}, + {{0, 3, 1}, {0, 6, 1}}, + {{0, 6, 0}, {0, 7, 0}} + }; + private static final int[][][] JUNGLE_LEAVES_FILL = { + {{-1, 7, 0}, {-1, 11, 0}}, + {{1, 7, 0}, {1, 11, 0}}, + {{0, 7, -1}, {0, 11, -1}}, + {{0, 7, 1}, {0, 11, 1}}, + {{0, 11, 0}, {0, 12, 0}} + }; + private static final int[][][] ACACIA_LEAVES_FILL = { + {{-1, 5, 0}, {-1, 8, 0}}, + {{1, 5, 0}, {1, 8, 0}}, + {{0, 5, -1}, {0, 8, -1}}, + {{0, 5, 1}, {0, 8, 1}}, + {{0, 8, 0}, {0, 9, 0}} + }; + + private static final BlockState OAK_LEAVES = persistent(Blocks.OAK_LEAVES.defaultBlockState()); + private static final BlockState SPRUCE_LEAVES = persistent(Blocks.SPRUCE_LEAVES.defaultBlockState()); + private static final BlockState BIRCH_LEAVES = persistent(Blocks.BIRCH_LEAVES.defaultBlockState()); + private static final BlockState DARK_OAK_LEAVES = persistent(Blocks.DARK_OAK_LEAVES.defaultBlockState()); + private static final BlockState JUNGLE_LEAVES = persistent(Blocks.JUNGLE_LEAVES.defaultBlockState()); + private static final BlockState ACACIA_LEAVES = persistent(Blocks.ACACIA_LEAVES.defaultBlockState()); + + private ArnisTreeGenerator() { + } + + public static boolean place(WorldGenLevel level, BlockPos base, ArnisTreeType type, int minY, int maxY, int flags) { + TreeShape shape = shape(type); + int baseY = base.getY(); + if (baseY < minY || baseY + shape.maxYOffset() > maxY) { + return false; + } + + MutableBlockPos cursor = new MutableBlockPos(); + for (int y = 0; y <= shape.logHeight(); y++) { + cursor.set(base.getX(), baseY + y, base.getZ()); + if (!canPlaceTrunk(level.getBlockState(cursor))) { + return false; + } + } + + for (int y = 0; y <= shape.logHeight(); y++) { + cursor.set(base.getX(), baseY + y, base.getZ()); + level.setBlock(cursor, shape.logBlock(), flags); + } + for (int[][] fill : shape.leavesFill()) { + fillLeaves(level, cursor, base, shape.leavesBlock(), fill[0], fill[1], flags); + } + for (int patternIndex = 0; patternIndex < ROUND_PATTERNS.length; patternIndex++) { + for (int yOffset : shape.roundRanges()[patternIndex]) { + placeRound(level, cursor, base, shape.leavesBlock(), yOffset, ROUND_PATTERNS[patternIndex], flags); + } + } + return true; + } + + public static int maxHeight(ArnisTreeType type) { + return shape(type).maxYOffset(); + } + + private static void fillLeaves( + WorldGenLevel level, MutableBlockPos cursor, BlockPos base, BlockState state, int[] from, int[] to, int flags + ) { + for (int x = from[0]; x <= to[0]; x++) { + for (int y = from[1]; y <= to[1]; y++) { + for (int z = from[2]; z <= to[2]; z++) { + placeLeaf(level, cursor, base, state, x, y, z, flags); + } + } + } + } + + private static void placeRound( + WorldGenLevel level, MutableBlockPos cursor, BlockPos base, BlockState state, int yOffset, int[][] pattern, int flags + ) { + for (int[] offset : pattern) { + placeLeaf(level, cursor, base, state, offset[0], yOffset + offset[1], offset[2], flags); + } + } + + private static void placeLeaf( + WorldGenLevel level, MutableBlockPos cursor, BlockPos base, BlockState state, int xOffset, int yOffset, int zOffset, int flags + ) { + cursor.set(base.getX() + xOffset, base.getY() + yOffset, base.getZ() + zOffset); + if (canPlaceLeaf(level.getBlockState(cursor))) { + level.setBlock(cursor, state, flags); + } + } + + private static boolean canPlaceTrunk(BlockState state) { + return state.isAir() || state.is(BlockTags.LEAVES) || isSoftPlant(state); + } + + private static boolean canPlaceLeaf(BlockState state) { + return state.isAir() || state.is(BlockTags.LEAVES) || isSoftPlant(state); + } + + private static boolean isSoftPlant(BlockState state) { + return state.is(Blocks.FERN) || state.is(Blocks.TALL_GRASS) || state.is(Blocks.DEAD_BUSH) || state.is(Blocks.VINE); + } + + private static TreeShape shape(ArnisTreeType type) { + return switch (type) { + case SPRUCE -> new TreeShape( + Blocks.SPRUCE_LOG.defaultBlockState(), + 9, + SPRUCE_LEAVES, + SPRUCE_LEAVES_FILL, + new int[][]{{9, 7, 6, 4, 3}, {6, 3}, {}}, + 11 + ); + case BIRCH -> new TreeShape( + Blocks.BIRCH_LOG.defaultBlockState(), + 6, + BIRCH_LEAVES, + BIRCH_LEAVES_FILL, + new int[][]{{6, 5, 4, 3, 2}, {2, 3, 4}, {}}, + 8 + ); + case DARK_OAK -> new TreeShape( + Blocks.DARK_OAK_LOG.defaultBlockState(), + 5, + DARK_OAK_LEAVES, + DARK_OAK_LEAVES_FILL, + new int[][]{{6, 5, 4, 3}, {5, 4, 3}, {5, 4}}, + 7 + ); + case JUNGLE -> new TreeShape( + Blocks.JUNGLE_LOG.defaultBlockState(), + 10, + JUNGLE_LEAVES, + JUNGLE_LEAVES_FILL, + new int[][]{{11, 10, 9, 8, 7}, {10, 9, 8}, {}}, + 12 + ); + case ACACIA -> new TreeShape( + Blocks.ACACIA_LOG.defaultBlockState(), + 6, + ACACIA_LEAVES, + ACACIA_LEAVES_FILL, + new int[][]{{8, 7, 6, 5}, {7, 6, 5}, {7, 6}}, + 9 + ); + default -> new TreeShape( + Blocks.OAK_LOG.defaultBlockState(), + 8, + OAK_LEAVES, + OAK_LEAVES_FILL, + new int[][]{{8, 7, 6, 5, 4, 3}, {7, 6, 5, 4}, {6, 5}}, + 10 + ); + }; + } + + private static BlockState persistent(BlockState state) { + return state.hasProperty(BlockStateProperties.PERSISTENT) ? state.setValue(BlockStateProperties.PERSISTENT, Boolean.TRUE) : state; + } + + private record TreeShape(BlockState logBlock, int logHeight, BlockState leavesBlock, int[][][] leavesFill, int[][] roundRanges, int maxYOffset) { + } +} diff --git a/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeType.java b/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeType.java new file mode 100644 index 000000000..7a721a75f --- /dev/null +++ b/src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeType.java @@ -0,0 +1,187 @@ +package com.yucareux.tellus.worldgen.vegetation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public enum ArnisTreeType { + OAK, + SPRUCE, + BIRCH, + DARK_OAK, + JUNGLE, + ACACIA; + + public static ArnisTreeType chooseDefault(long seed) { + return switch ((int)Math.floorMod(mixSeed(seed), 10L)) { + case 0, 1, 2 -> OAK; + case 3, 4 -> SPRUCE; + case 5, 6 -> BIRCH; + case 7 -> DARK_OAK; + case 8 -> JUNGLE; + default -> ACACIA; + }; + } + + public static ArnisTreeType chooseForPointTags(Map tags, long seed) { + List candidates = candidatesFromTags(tags); + if (candidates.isEmpty()) { + candidates.add(OAK); + candidates.add(SPRUCE); + candidates.add(BIRCH); + } + return choose(candidates, seed); + } + + public static ArnisTreeType chooseForAreaTags(Map tags, String areaType, long seed) { + List candidates = candidatesFromTags(tags); + if (candidates.isEmpty()) { + switch (normalize(areaType)) { + case "scrub", "heath" -> { + candidates.add(OAK); + candidates.add(BIRCH); + candidates.add(ACACIA); + } + case "orchard" -> { + candidates.add(OAK); + candidates.add(BIRCH); + } + default -> { + candidates.add(OAK); + candidates.add(SPRUCE); + candidates.add(BIRCH); + } + } + } + return choose(candidates, seed); + } + + private static List candidatesFromTags(Map tags) { + List candidates = speciesCandidates(tags); + if (!candidates.isEmpty()) { + return candidates; + } + + candidates = genusWikidataCandidates(tags); + if (!candidates.isEmpty()) { + return candidates; + } + + candidates = genusCandidates(tags); + if (!candidates.isEmpty()) { + return candidates; + } + + candidates = leafTypeCandidates(tags); + if (!candidates.isEmpty()) { + return candidates; + } + + String leafCycle = normalize(tags.get("leaf_cycle")); + if ("evergreen".equals(leafCycle)) { + candidates.add(SPRUCE); + } + return candidates; + } + + private static List speciesCandidates(Map tags) { + String species = normalize(join(tags, "species", "species:en", "taxon", "taxon:species")); + List candidates = new ArrayList<>(); + addSpeciesMatch(candidates, species); + return candidates; + } + + private static List genusCandidates(Map tags) { + String genus = normalize(join(tags, "genus", "genus:en", "taxon:genus")); + List candidates = new ArrayList<>(); + addSpeciesMatch(candidates, genus); + return candidates; + } + + private static List genusWikidataCandidates(Map tags) { + String value = normalize(tags.get("genus:wikidata")); + List candidates = new ArrayList<>(); + if (value.contains("q12004")) { + addUnique(candidates, BIRCH); + } + if (value.contains("q26782")) { + addUnique(candidates, OAK); + } + if (value.contains("q25243")) { + addUnique(candidates, SPRUCE); + } + return candidates; + } + + private static List leafTypeCandidates(Map tags) { + String leafType = normalize(tags.get("leaf_type")); + List candidates = new ArrayList<>(); + switch (leafType) { + case "broadleaved", "broadleaf" -> { + candidates.add(OAK); + candidates.add(BIRCH); + } + case "needleleaved", "needleleaf" -> candidates.add(SPRUCE); + default -> { + } + } + return candidates; + } + + private static void addSpeciesMatch(List candidates, String value) { + if (value.contains("betula") || value.contains("birch")) { + addUnique(candidates, BIRCH); + } + if (value.contains("quercus") || value.contains("oak")) { + addUnique(candidates, OAK); + } + if (value.contains("picea") || value.contains("spruce") || value.contains("pinus") || value.contains("pine") || value.contains("abies") || value.contains("fir")) { + addUnique(candidates, SPRUCE); + } + if (value.contains("acacia")) { + addUnique(candidates, ACACIA); + } + if (value.contains("jungle") || value.contains("palm")) { + addUnique(candidates, JUNGLE); + } + } + + private static ArnisTreeType choose(List candidates, long seed) { + if (candidates.isEmpty()) { + return chooseDefault(seed); + } + return candidates.get((int)Math.floorMod(mixSeed(seed), (long)candidates.size())); + } + + private static void addUnique(List candidates, ArnisTreeType type) { + if (!candidates.contains(type)) { + candidates.add(type); + } + } + + private static String join(Map tags, String... keys) { + StringBuilder result = new StringBuilder(); + for (String key : keys) { + String value = tags.get(key); + if (value != null && !value.isBlank()) { + if (!result.isEmpty()) { + result.append(' '); + } + result.append(value); + } + } + return result.toString(); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private static long mixSeed(long seed) { + long mixed = seed + 0x9E3779B97F4A7C15L; + mixed = (mixed ^ mixed >>> 30) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ mixed >>> 27) * 0x94D049BB133111EBL; + return mixed ^ mixed >>> 31; + } +} diff --git a/src/main/resources/assets/tellus/lang/en_us.json b/src/main/resources/assets/tellus/lang/en_us.json index 584ef57f5..6c493d019 100644 --- a/src/main/resources/assets/tellus/lang/en_us.json +++ b/src/main/resources/assets/tellus/lang/en_us.json @@ -6,12 +6,22 @@ "gui.tellus.coming_soon": "Coming Soon", "gui.tellus.restore_defaults": "Restore Defaults", "gui.tellus.select_preset": "Select Preset", + "gui.tellus.wlb_preset": "WLB Preset", + "gui.tellus.wlb_preset.tooltip": "Applies WLB city defaults: 1:1 scale, automatic DEM and height limits, sea level 62, default offset, OSM roads/buildings/water on, structures off.", "gui.tellus.enable_all": "Enable all", "gui.tellus.disable_all": "Disable all", "gui.earth.spawnpoint": "Set Spawnpoint", "gui.earth.teleport_map": "Teleport Map", "gui.earth.teleport": "Teleport", "gui.earth.search": "Search", + "gui.earth.waypoints": "Teleport Points", + "gui.earth.waypoints.previous": "Prev", + "gui.earth.waypoints.next": "Next", + "gui.earth.waypoint_note": "Note", + "gui.earth.waypoint_save": "Save", + "gui.earth.waypoint_delete": "Delete", + "key.tellus.open_earth_map": "Open Tellus Map", + "category.tellus.keybinds": "Tellus", "options.tellus.customize_world_title.name": "Customize World Generation", "category.tellus.world.name": "World Settings", "category.tellus.hma_access.name": "HMA Access", @@ -45,6 +55,27 @@ "tellus.cache.section.total": "Total", "tellus.cache.delete": "Delete cache", "tellus.cache.delete_all": "Delete all cache", + "tellus.datasource.overpass.test": "Test OSM connectivity", + "tellus.datasource.overpass.test.tooltip": "Sends a tiny Overpass query to each configured endpoint from this computer.", + "tellus.datasource.overpass.testing": "Testing OSM connectivity...", + "tellus.datasource.overpass.testing.tooltip": "Testing configured Overpass endpoints.", + "tellus.datasource.overpass.ok": "OSM source reachable: %s/%s endpoints", + "tellus.datasource.overpass.none": "OSM source unavailable: 0/%s endpoints", + "tellus.datasource.overpass.failed": "OSM connectivity test failed", + "tellus.datasource.overpass.cache.idle": "OSM cache estimate not run", + "tellus.datasource.overpass.cache.idle.tooltip": "Estimate the current spawn-area raw OSM cache before downloading more data.", + "tellus.datasource.overpass.cache.estimate": "Estimate OSM cache", + "tellus.datasource.overpass.cache.estimate.tooltip": "Checks how many raw Overpass tiles around the spawn area are already cached locally.", + "tellus.datasource.overpass.cache.estimating": "Estimating OSM cache...", + "tellus.datasource.overpass.cache.summary": "OSM cache: %s/%s cached, %s missing", + "tellus.datasource.overpass.cache.disabled": "OSM cache: Overpass disabled", + "tellus.datasource.overpass.cache.prefetch": "Warm missing OSM cache", + "tellus.datasource.overpass.cache.prefetch.tooltip": "Downloads a small batch of missing raw OSM tiles using the existing WLB proxy routing.", + "tellus.datasource.overpass.cache.prefetch.unavailable.tooltip": "Run an estimate first. Prefetch is available only when network mode allows downloads and tiles are missing.", + "tellus.datasource.overpass.cache.busy.tooltip": "An OSM cache task is already running.", + "tellus.datasource.overpass.cache.prefetching": "Warming OSM cache...", + "tellus.datasource.overpass.cache.prefetched": "OSM cache warmed: +%s tiles, %s/%s cached", + "tellus.datasource.overpass.cache.failed": "OSM cache task failed", "property.tellus.world_scale.name": "World Scale", "property.tellus.dem_provider.name": "DEM Provider", "property.tellus.dem_automatic.name": "Automatic", diff --git a/src/main/resources/assets/tellus/lang/es_es.json b/src/main/resources/assets/tellus/lang/es_es.json index 0609bf924..3dcb9a602 100644 --- a/src/main/resources/assets/tellus/lang/es_es.json +++ b/src/main/resources/assets/tellus/lang/es_es.json @@ -6,12 +6,22 @@ "gui.tellus.coming_soon": "Próximamente", "gui.tellus.restore_defaults": "Restablecer valores", "gui.tellus.select_preset": "Seleccionar preset", + "gui.tellus.wlb_preset": "Preset WLB", + "gui.tellus.wlb_preset.tooltip": "Aplica valores WLB: escala 1:1, DEM y limites de altura automaticos, nivel del mar 62, offset predeterminado, carreteras/edificios/agua OSM activados y estructuras desactivadas.", "gui.tellus.enable_all": "Activar todo", "gui.tellus.disable_all": "Desactivar todo", "gui.earth.spawnpoint": "Establecer punto de aparición", "gui.earth.teleport_map": "Mapa de teletransporte", "gui.earth.teleport": "Teletransportar", "gui.earth.search": "Buscar", + "gui.earth.waypoints": "Puntos", + "gui.earth.waypoints.previous": "Ant", + "gui.earth.waypoints.next": "Sig", + "gui.earth.waypoint_note": "Nota", + "gui.earth.waypoint_save": "Guardar", + "gui.earth.waypoint_delete": "Eliminar", + "key.tellus.open_earth_map": "Abrir mapa Tellus", + "category.tellus.keybinds": "Tellus", "options.tellus.customize_world_title.name": "Personalizar generación del mundo", "category.tellus.world.name": "Ajustes del mundo", "category.tellus.hma_access.name": "Acceso HMA", @@ -45,6 +55,27 @@ "tellus.cache.section.total": "Total", "tellus.cache.delete": "Eliminar cache", "tellus.cache.delete_all": "Eliminar todo el cache", + "tellus.datasource.overpass.test": "Probar conectividad OSM", + "tellus.datasource.overpass.test.tooltip": "Envía una consulta Overpass mínima a cada endpoint configurado desde este equipo.", + "tellus.datasource.overpass.testing": "Probando conectividad OSM...", + "tellus.datasource.overpass.testing.tooltip": "Probando endpoints Overpass configurados.", + "tellus.datasource.overpass.ok": "Fuente OSM accesible: %s/%s endpoints", + "tellus.datasource.overpass.none": "Fuente OSM no disponible: 0/%s endpoints", + "tellus.datasource.overpass.failed": "Falló la prueba de conectividad OSM", + "tellus.datasource.overpass.cache.idle": "Estimación de cache OSM no ejecutada", + "tellus.datasource.overpass.cache.idle.tooltip": "Estima el cache OSM bruto del área de aparición antes de descargar más datos.", + "tellus.datasource.overpass.cache.estimate": "Estimar cache OSM", + "tellus.datasource.overpass.cache.estimate.tooltip": "Comprueba cuántos tiles Overpass brutos alrededor del área de aparición ya están en cache local.", + "tellus.datasource.overpass.cache.estimating": "Estimando cache OSM...", + "tellus.datasource.overpass.cache.summary": "Cache OSM: %s/%s en cache, %s faltantes", + "tellus.datasource.overpass.cache.disabled": "Cache OSM: Overpass desactivado", + "tellus.datasource.overpass.cache.prefetch": "Preparar cache OSM faltante", + "tellus.datasource.overpass.cache.prefetch.tooltip": "Descarga un lote pequeño de tiles OSM brutos faltantes usando el enrutamiento proxy WLB existente.", + "tellus.datasource.overpass.cache.prefetch.unavailable.tooltip": "Ejecuta una estimación primero. La preparación solo está disponible cuando el modo de red permite descargas y faltan tiles.", + "tellus.datasource.overpass.cache.busy.tooltip": "Ya hay una tarea de cache OSM en ejecución.", + "tellus.datasource.overpass.cache.prefetching": "Preparando cache OSM...", + "tellus.datasource.overpass.cache.prefetched": "Cache OSM preparado: +%s tiles, %s/%s en cache", + "tellus.datasource.overpass.cache.failed": "Falló la tarea de cache OSM", "property.tellus.world_scale.name": "Escala del mundo", "property.tellus.dem_provider.name": "Proveedor de DEM", "property.tellus.dem_automatic.name": "Automático", diff --git a/src/main/resources/data/tellus/worldgen/world_preset/earth.json b/src/main/resources/data/tellus/worldgen/world_preset/earth.json index 4d02bb882..68290d804 100644 --- a/src/main/resources/data/tellus/worldgen/world_preset/earth.json +++ b/src/main/resources/data/tellus/worldgen/world_preset/earth.json @@ -7,21 +7,71 @@ "biome_source": { "type": "tellus:earth", "settings": { - "world_scale": 30.0, + "world_scale": 1.0, "terrestrial_height_scale": 1.0, "oceanic_height_scale": 1.0, "height_offset": 64, + "sea_level": 62, + "min_altitude": -2147483648, + "max_altitude": -2147483648, "spawn_latitude": 27.9881, - "spawn_longitude": 86.925 + "spawn_longitude": 86.925, + "enable_roads": true, + "enable_buildings": true, + "enable_water": true, + "add_strongholds": false, + "add_villages": false, + "add_mineshafts": false, + "add_ocean_monuments": false, + "add_woodland_mansions": false, + "add_desert_temples": false, + "add_jungle_temples": false, + "add_pillager_outposts": false, + "add_ruined_portals": false, + "add_shipwrecks": false, + "add_ocean_ruins": false, + "add_buried_treasure": false, + "add_igloos": false, + "add_witch_huts": false, + "add_ancient_cities": false, + "add_trial_chambers": false, + "add_trail_ruins": false, + "deep_dark": false, + "geodes": false } }, "settings": { - "world_scale": 30.0, + "world_scale": 1.0, "terrestrial_height_scale": 1.0, "oceanic_height_scale": 1.0, "height_offset": 64, + "sea_level": 62, + "min_altitude": -2147483648, + "max_altitude": -2147483648, "spawn_latitude": 27.9881, - "spawn_longitude": 86.925 + "spawn_longitude": 86.925, + "enable_roads": true, + "enable_buildings": true, + "enable_water": true, + "add_strongholds": false, + "add_villages": false, + "add_mineshafts": false, + "add_ocean_monuments": false, + "add_woodland_mansions": false, + "add_desert_temples": false, + "add_jungle_temples": false, + "add_pillager_outposts": false, + "add_ruined_portals": false, + "add_shipwrecks": false, + "add_ocean_ruins": false, + "add_buried_treasure": false, + "add_igloos": false, + "add_witch_huts": false, + "add_ancient_cities": false, + "add_trial_chambers": false, + "add_trail_ruins": false, + "deep_dark": false, + "geodes": false } } }, diff --git a/src/test/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdaptersTest.java b/src/test/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdaptersTest.java new file mode 100644 index 000000000..54b3a47a4 --- /dev/null +++ b/src/test/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdaptersTest.java @@ -0,0 +1,156 @@ +package com.yucareux.tellus.world.data.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import com.yucareux.tellus.world.data.osm.OsmBuildingFeature; +import com.yucareux.tellus.world.data.osm.OsmBuildingKind; +import com.yucareux.tellus.world.data.osm.OsmBuildingMetadata; +import com.yucareux.tellus.world.data.osm.RoadClass; +import com.yucareux.tellus.world.data.osm.RoadFeature; +import com.yucareux.tellus.world.data.osm.RoadMode; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExternalFeatureAdaptersTest { + @Test + void convertsExternalRoadFeatureToTellusFeature() { + ExternalRoadFeature external = new ExternalRoadFeature( + "arnis", + "road-1", + RoadClass.MAIN, + RoadMode.BRIDGE, + 1, + "primary", + List.of(new GeoPoint(35.0, 139.0), new GeoPoint(35.001, 139.001)), + Map.of("lanes", "4", "sidewalk", "both", "surface", "asphalt") + ); + + RoadFeature converted = ExternalFeatureAdapters.toTellusRoad(external); + + assertNotEquals(0L, converted.wayId()); + assertEquals(RoadClass.MAIN, converted.roadClass()); + assertEquals(RoadMode.BRIDGE, converted.mode()); + assertEquals(1, converted.bridgeLevel()); + assertEquals("primary", converted.highwayTag()); + assertEquals(4, converted.laneCount()); + assertEquals("asphalt", converted.surfaceTag()); + assertEquals("both", converted.tag("sidewalk")); + assertEquals(139.0, converted.lonAt(0)); + assertEquals(35.0, converted.latAt(0)); + } + + @Test + void convertsTellusRoadFeature() { + RoadFeature road = new RoadFeature( + 42L, + RoadClass.MAIN, + RoadMode.BRIDGE, + 2, + "primary", + new double[]{139.0, 139.001}, + new double[]{35.0, 35.001}, + Map.of("surface", "gravel") + ); + + ExternalRoadFeature converted = ExternalFeatureAdapters.fromTellusRoad(road); + + assertEquals(ExternalFeatureAdapters.TELLUS_OVERTURE_SOURCE, converted.source()); + assertEquals("42", converted.sourceId()); + assertEquals(RoadClass.MAIN, converted.roadClass()); + assertEquals(RoadMode.BRIDGE, converted.mode()); + assertEquals(2, converted.bridgeLevel()); + assertEquals("primary", converted.highwayTag()); + assertEquals(2, converted.points().size()); + assertEquals(new GeoPoint(35.0, 139.0), converted.points().get(0)); + assertEquals("gravel", converted.tags().get("surface")); + assertEquals("MAIN", converted.tags().get("road_class")); + assertEquals("BRIDGE", converted.tags().get("road_mode")); + } + + @Test + void convertsExternalBuildingFeatureToTellusFeature() { + ExternalBuildingFeature external = new ExternalBuildingFeature( + "arnis", + "building-1", + ExternalBuildingKind.FOOTPRINT, + 9.0, + 0.0, + 3, + List.of( + List.of( + new GeoPoint(35.0, 139.0), + new GeoPoint(35.0, 139.001), + new GeoPoint(35.001, 139.001), + new GeoPoint(35.001, 139.0), + new GeoPoint(35.0, 139.0) + ) + ), + Map.of("building_class", "residential", "name", "Example", "building:material", "brick", "roof:colour", "red", "roof:levels", "2") + ); + + OsmBuildingFeature converted = ExternalFeatureAdapters.toTellusBuilding(external); + + assertNotEquals(0L, converted.featureId()); + assertEquals(OsmBuildingKind.FOOTPRINT, converted.kind()); + assertEquals("arnis:building-1", converted.buildingId()); + assertEquals(9.0, converted.heightMeters()); + assertEquals(3, converted.metadata().floorCount()); + assertEquals("residential", converted.metadata().buildingClass()); + assertEquals("Example", converted.metadata().name()); + assertEquals("brick", converted.metadata().wallMaterial()); + assertEquals("red", converted.metadata().roofColor()); + assertEquals(2, converted.metadata().roofLevels()); + assertEquals(139.0, converted.lonAt(0, 0)); + assertEquals(35.0, converted.latAt(0, 0)); + } + + @Test + void convertsTellusBuildingFeature() { + OsmBuildingMetadata metadata = new OsmBuildingMetadata( + "residential", + "house", + "home", + "Example", + 2, + "gabled", + 1, + 4.5, + "tile", + "brick", + "red", + "white" + ); + OsmBuildingFeature building = new OsmBuildingFeature( + OsmBuildingKind.FOOTPRINT, + 7L, + "building-7", + true, + metadata, + 8.0, + 0.0, + new double[][]{{139.0, 139.001, 139.001, 139.0, 139.0}}, + new double[][]{{35.0, 35.0, 35.001, 35.001, 35.0}} + ); + + ExternalBuildingFeature converted = ExternalFeatureAdapters.fromTellusBuilding(building); + + assertEquals(ExternalFeatureAdapters.TELLUS_OVERTURE_SOURCE, converted.source()); + assertEquals("7", converted.sourceId()); + assertEquals(ExternalBuildingKind.FOOTPRINT, converted.kind()); + assertEquals(8.0, converted.heightMeters()); + assertEquals(2, converted.floorCount()); + assertEquals(new GeoPoint(35.0, 139.0), converted.outerRing().get(0)); + assertFalse(converted.tags().isEmpty()); + assertEquals("building-7", converted.tags().get("building_id")); + assertEquals("gabled", converted.tags().get("roof_shape")); + assertEquals("1", converted.tags().get("roof_levels")); + assertEquals("4.5", converted.tags().get("roof_height")); + assertEquals("tile", converted.tags().get("roof_material")); + assertEquals("brick", converted.tags().get("wall_material")); + assertEquals("red", converted.tags().get("roof_color")); + assertEquals("white", converted.tags().get("wall_color")); + } +} diff --git a/src/test/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSourceTest.java b/src/test/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSourceTest.java new file mode 100644 index 000000000..54710eb5f --- /dev/null +++ b/src/test/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSourceTest.java @@ -0,0 +1,116 @@ +package com.yucareux.tellus.world.data.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.yucareux.tellus.world.data.osm.RoadClass; +import com.yucareux.tellus.world.data.osm.RoadMode; +import java.io.StringReader; +import org.junit.jupiter.api.Test; + +class JsonExternalFeatureSourceTest { + @Test + void readsAndFiltersExternalFeatures() throws Exception { + JsonExternalFeatureSource source = JsonExternalFeatureSource.fromReader(new StringReader(""" + { + "roads": [ + { + "source": "arnis", + "sourceId": "road-1", + "roadClass": "MAIN", + "mode": "BRIDGE", + "bridgeLevel": 2, + "highwayTag": "primary", + "points": [ + {"lat": 35.0, "lon": 139.0}, + {"lat": 35.001, "lon": 139.001} + ], + "tags": {"surface": "asphalt"} + } + ], + "buildings": [ + { + "source": "arnis", + "sourceId": "building-1", + "kind": "FOOTPRINT", + "heightMeters": 8.0, + "floorCount": 2, + "rings": [[ + {"lat": 35.0, "lon": 139.0}, + {"lat": 35.0, "lon": 139.001}, + {"lat": 35.001, "lon": 139.001}, + {"lat": 35.001, "lon": 139.0}, + {"lat": 35.0, "lon": 139.0} + ]], + "tags": {"building": "house"} + } + ], + "areas": [ + { + "source": "arnis", + "sourceId": "parking-1", + "kind": "PARKING", + "typeTag": "parking", + "rings": [[ + {"lat": 35.0, "lon": 139.0}, + {"lat": 35.0, "lon": 139.001}, + {"lat": 35.001, "lon": 139.001}, + {"lat": 35.001, "lon": 139.0}, + {"lat": 35.0, "lon": 139.0} + ]], + "tags": {"amenity": "parking"} + } + ], + "lines": [ + { + "source": "arnis", + "sourceId": "barrier-1", + "kind": "BARRIER", + "typeTag": "fence", + "points": [ + {"lat": 35.0, "lon": 139.0}, + {"lat": 35.001, "lon": 139.001} + ], + "tags": {"barrier": "fence"} + } + ], + "points": [ + { + "source": "arnis", + "sourceId": "signal-1", + "kind": "TRAFFIC_SIGNAL", + "typeTag": "traffic_signals", + "lat": 35.0, + "lon": 139.0, + "tags": {"highway": "traffic_signals"} + } + ] + } + """)); + + GeoBounds matchingBounds = new GeoBounds(34.999, 138.999, 35.002, 139.002); + ExternalRoadFeature road = source.roadsForBounds(matchingBounds).get(0); + ExternalBuildingFeature building = source.buildingsForBounds(matchingBounds).get(0); + ExternalAreaFeature area = source.areasForBounds(matchingBounds).get(0); + ExternalLineFeature line = source.linesForBounds(matchingBounds).get(0); + ExternalPointFeature point = source.pointsForBounds(matchingBounds).get(0); + + assertEquals(RoadClass.MAIN, road.roadClass()); + assertEquals(RoadMode.BRIDGE, road.mode()); + assertEquals("asphalt", road.tags().get("surface")); + assertEquals(ExternalBuildingKind.FOOTPRINT, building.kind()); + assertEquals(2, building.floorCount()); + assertEquals("house", building.tags().get("building")); + assertEquals(ExternalAreaKind.PARKING, area.kind()); + assertEquals("parking", area.typeTag()); + assertEquals(ExternalLineKind.BARRIER, line.kind()); + assertEquals("fence", line.typeTag()); + assertEquals(ExternalPointKind.TRAFFIC_SIGNAL, point.kind()); + assertEquals("traffic_signals", point.typeTag()); + assertTrue(source.roadsForBounds(new GeoBounds(0.0, 0.0, 1.0, 1.0)).isEmpty()); + assertTrue(source.buildingsForBounds(new GeoBounds(0.0, 0.0, 1.0, 1.0)).isEmpty()); + assertTrue(source.areasForBounds(new GeoBounds(0.0, 0.0, 1.0, 1.0)).isEmpty()); + assertTrue(source.linesForBounds(new GeoBounds(0.0, 0.0, 1.0, 1.0)).isEmpty()); + assertTrue(source.pointsForBounds(new GeoBounds(0.0, 0.0, 1.0, 1.0)).isEmpty()); + } +} diff --git a/src/test/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSourceTest.java b/src/test/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSourceTest.java new file mode 100644 index 000000000..7fdc3a6b5 --- /dev/null +++ b/src/test/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSourceTest.java @@ -0,0 +1,107 @@ +package com.yucareux.tellus.world.data.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.yucareux.tellus.world.data.osm.OsmBuildingFeature; +import com.yucareux.tellus.world.data.osm.RoadFeature; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TellusExternalFeatureSourceTest { + @TempDir + Path tempDir; + + @Test + void loadsFeaturesForBlockArea() throws Exception { + Path featureFile = this.tempDir.resolve("external-features.json"); + Files.writeString( + featureFile, + """ + { + "roads": [ + { + "source": "arnis", + "sourceId": "road-1", + "roadClass": "MAIN", + "points": [ + {"lat": 0.0, "lon": 0.0}, + {"lat": 0.0001, "lon": 0.0001} + ] + } + ], + "buildings": [ + { + "source": "arnis", + "sourceId": "building-1", + "heightMeters": 6.0, + "rings": [[ + {"lat": 0.0, "lon": 0.0}, + {"lat": 0.0, "lon": 0.0001}, + {"lat": 0.0001, "lon": 0.0001}, + {"lat": 0.0001, "lon": 0.0}, + {"lat": 0.0, "lon": 0.0} + ]] + } + ], + "areas": [ + { + "source": "arnis", + "sourceId": "parking-1", + "kind": "PARKING", + "typeTag": "parking", + "rings": [[ + {"lat": 0.0, "lon": 0.0}, + {"lat": 0.0, "lon": 0.0001}, + {"lat": 0.0001, "lon": 0.0001}, + {"lat": 0.0001, "lon": 0.0}, + {"lat": 0.0, "lon": 0.0} + ]] + } + ], + "lines": [ + { + "source": "arnis", + "sourceId": "barrier-1", + "kind": "BARRIER", + "typeTag": "fence", + "points": [ + {"lat": 0.0, "lon": 0.0}, + {"lat": 0.0001, "lon": 0.0001} + ] + } + ], + "points": [ + { + "source": "arnis", + "sourceId": "bench-1", + "kind": "AMENITY", + "typeTag": "bench", + "lat": 0.0, + "lon": 0.0 + } + ] + } + """, + StandardCharsets.UTF_8 + ); + TellusExternalFeatureSource source = new TellusExternalFeatureSource(featureFile, OverpassExternalFeatureSource.disabled()); + + List roads = source.roadsForArea(-16, -16, 16, 16, 1.0, 0); + List buildings = source.buildingsForArea(-16, -16, 16, 16, 1.0, 0); + List areas = source.cityAreasForArea(-16, -16, 16, 16, 1.0, 0); + List lines = source.cityLinesForArea(-16, -16, 16, 16, 1.0, 0); + List points = source.cityPointsForArea(-16, -16, 16, 16, 1.0, 0); + + assertFalse(roads.isEmpty()); + assertFalse(buildings.isEmpty()); + assertFalse(areas.isEmpty()); + assertFalse(lines.isEmpty()); + assertFalse(points.isEmpty()); + assertEquals("arnis:building-1", buildings.get(0).buildingId()); + } +} diff --git a/src/test/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeTypeTest.java b/src/test/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeTypeTest.java new file mode 100644 index 000000000..0e5619699 --- /dev/null +++ b/src/test/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeTypeTest.java @@ -0,0 +1,27 @@ +package com.yucareux.tellus.worldgen.vegetation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ArnisTreeTypeTest { + @Test + void choosesTreeTypeFromOsmSpeciesTags() { + assertEquals(ArnisTreeType.BIRCH, ArnisTreeType.chooseForPointTags(Map.of("species", "Betula pendula"), 1L)); + assertEquals(ArnisTreeType.OAK, ArnisTreeType.chooseForPointTags(Map.of("species", "Quercus robur"), 1L)); + assertEquals(ArnisTreeType.SPRUCE, ArnisTreeType.chooseForPointTags(Map.of("species", "Picea abies"), 1L)); + } + + @Test + void choosesTreeTypeFromArnisCompatibleFallbackTags() { + assertEquals(ArnisTreeType.BIRCH, ArnisTreeType.chooseForPointTags(Map.of("genus:wikidata", "Q12004"), 2L)); + assertEquals(ArnisTreeType.OAK, ArnisTreeType.chooseForPointTags(Map.of("genus", "Quercus"), 2L)); + assertEquals(ArnisTreeType.SPRUCE, ArnisTreeType.chooseForPointTags(Map.of("leaf_type", "needleleaved"), 2L)); + assertTrue( + ArnisTreeType.chooseForPointTags(Map.of("leaf_type", "broadleaved"), 2L) == ArnisTreeType.OAK + || ArnisTreeType.chooseForPointTags(Map.of("leaf_type", "broadleaved"), 2L) == ArnisTreeType.BIRCH + ); + } +}