From ee2d4f6c5a9691208506850d02a75c7dd8a798dc Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 19:41:18 +0800 Subject: [PATCH 1/9] Add OSM city detail integration prototype --- docs/arnis-city-parity-todo.md | 112 + docs/wlb-arnis-integration.md | 170 + .../com/yucareux/tellus/TellusClient.java | 21 +- .../client/screen/EarthCustomizeScreen.java | 562 +- .../client/screen/EarthTeleportScreen.java | 373 +- .../client/teleport/TeleportWaypoint.java | 59 + .../teleport/TeleportWaypointStore.java | 145 + .../map/component/WaypointMapComponent.java | 105 + .../main/java/com/yucareux/tellus/Tellus.java | 9 +- .../tellus/network/GeoTpOpenMapPayload.java | 12 +- .../data/osm/TellusOsmBuildingSource.java | 49 +- .../tellus/worldgen/EarthChunkGenerator.java | 5429 ++++++++++++----- .../worldgen/EarthGeneratorSettings.java | 50 +- .../com/yucareux/tellus/TellusClient.java | 21 +- .../client/screen/EarthCustomizeScreen.java | 562 +- .../client/screen/EarthTeleportScreen.java | 373 +- .../client/teleport/TeleportWaypoint.java | 59 + .../teleport/TeleportWaypointStore.java | 145 + .../map/component/WaypointMapComponent.java | 105 + .../main/java/com/yucareux/tellus/Tellus.java | 9 +- .../data/osm/TellusOsmBuildingSource.java | 49 +- .../tellus/worldgen/EarthChunkGenerator.java | 5429 ++++++++++++----- .../worldgen/EarthGeneratorSettings.java | 50 +- .../client/screen/EarthCustomizeScreen.java | 8 +- .../tellus/worldgen/EarthChunkGenerator.java | 8 +- .../worldgen/EarthGeneratorSettings.java | 50 +- .../tellus/network/GeoTpOpenMapPayload.java | 21 +- .../data/integration/ExternalAreaFeature.java | 23 + .../data/integration/ExternalAreaKind.java | 10 + .../integration/ExternalBuildingFeature.java | 68 + .../integration/ExternalBuildingKind.java | 6 + .../integration/ExternalFeatureAdapters.java | 232 + .../integration/ExternalFeatureSource.java | 26 + .../data/integration/ExternalLineFeature.java | 23 + .../data/integration/ExternalLineKind.java | 9 + .../integration/ExternalPointFeature.java | 22 + .../data/integration/ExternalPointKind.java | 18 + .../data/integration/ExternalRoadFeature.java | 47 + .../world/data/integration/GeoBounds.java | 55 + .../world/data/integration/GeoPoint.java | 12 + .../JsonExternalFeatureSource.java | 414 ++ .../OverpassExternalFeatureSource.java | 1463 +++++ .../TellusExternalFeatureSource.java | 297 + .../world/data/osm/OsmBuildingMetadata.java | 12 +- .../world/data/osm/ParsedTileCodec.java | 27 +- .../tellus/world/data/osm/RoadFeature.java | 153 + .../worldgen/building/BuildingProfile.java | 18 +- .../building/TellusBuildingMaterials.java | 172 + .../building/TellusBuildingProfiles.java | 38 +- .../vegetation/ArnisTreeGenerator.java | 239 + .../worldgen/vegetation/ArnisTreeType.java | 187 + .../resources/assets/tellus/lang/en_us.json | 31 + .../resources/assets/tellus/lang/es_es.json | 31 + .../tellus/worldgen/world_preset/earth.json | 58 +- .../ExternalFeatureAdaptersTest.java | 156 + .../JsonExternalFeatureSourceTest.java | 116 + .../TellusExternalFeatureSourceTest.java | 107 + .../vegetation/ArnisTreeTypeTest.java | 27 + 58 files changed, 14526 insertions(+), 3556 deletions(-) create mode 100644 docs/arnis-city-parity-todo.md create mode 100644 docs/wlb-arnis-integration.md create mode 100644 mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java create mode 100644 mc1201/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java create mode 100644 mc1201/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java create mode 100644 mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypoint.java create mode 100644 mc1211/src/client/java/com/yucareux/tellus/client/teleport/TeleportWaypointStore.java create mode 100644 mc1211/src/client/java/com/yucareux/tellus/client/widget/map/component/WaypointMapComponent.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaFeature.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalAreaKind.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingFeature.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalBuildingKind.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdapters.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalFeatureSource.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineFeature.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalLineKind.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointFeature.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalPointKind.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/ExternalRoadFeature.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/GeoBounds.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/GeoPoint.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSource.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java create mode 100644 src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeGenerator.java create mode 100644 src/main/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeType.java create mode 100644 src/test/java/com/yucareux/tellus/world/data/integration/ExternalFeatureAdaptersTest.java create mode 100644 src/test/java/com/yucareux/tellus/world/data/integration/JsonExternalFeatureSourceTest.java create mode 100644 src/test/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSourceTest.java create mode 100644 src/test/java/com/yucareux/tellus/worldgen/vegetation/ArnisTreeTypeTest.java 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..12f28b98c --- /dev/null +++ b/docs/wlb-arnis-integration.md @@ -0,0 +1,170 @@ +# 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://lz4.overpass-api.de/api/interpreter +-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. + +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/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..956946034 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,14 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.yucareux.tellus.Tellus; +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 +35,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 +155,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 +172,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 +197,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(); @@ -554,7 +591,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 +608,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 +727,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); @@ -915,7 +966,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 +1080,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 +1350,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 +1388,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1360,12 +1419,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1387,12 +1450,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1480,10 +1547,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 +1569,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 +1594,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 +1697,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 +1755,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 +1814,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 +1862,16 @@ private void rasterizeRoadClassPass( edgeColumnCache, bridgeCandidatePresent, bridgeCandidateDeckY, + bridgeCandidateWidth, null, + bridgeCandidateSurface, null ); } else { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1738,7 +1886,9 @@ private void rasterizeRoadClassPass( edgeColumnCache, candidatePresent, candidateDeckY, + candidateWidth, candidateMode, + candidateSurface, candidateTunnelCarve ); } @@ -1748,7 +1898,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 +1920,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 +1951,7 @@ private void rasterizeRoadClassPass( private void rasterizeRoadFeature( RoadFeature road, int roadWidth, + byte roadSurface, double blocksPerDegree, int extMinX, int extMinZ, @@ -1802,7 +1966,9 @@ private void rasterizeRoadFeature( Long2ObjectOpenHashMap edgeColumnCache, boolean[] candidatePresent, int[] candidateDeckY, + int[] candidateWidth, byte[] candidateMode, + byte[] candidateSurface, boolean[] candidateTunnelCarve ) { int pointCount = road.pointCount(); @@ -1924,6 +2090,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,2076 +2110,3842 @@ 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(level.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(level.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); - return Math.max(baseline + rise, localRoadSurface + clearance); - } - - private static int bridgeClearanceAtStation(double station, double totalLength, RoadClass roadClass, double worldScale) { - int targetClearance = bridgeTargetClearanceBlocks(roadClass, worldScale); - if (targetClearance <= 0 || totalLength <= 1.0E-6) { - return 0; - } else { - double clampedStation = Mth.clamp(station, 0.0, totalLength); - double rampLength = targetClearance * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; - double clearance; - if (totalLength >= rampLength * 2.0) { - if (clampedStation < rampLength) { - clearance = targetClearance * (clampedStation / rampLength); - } else if (clampedStation > totalLength - rampLength) { - clearance = targetClearance * ((totalLength - clampedStation) / rampLength); - } else { - clearance = targetClearance; - } - } else { - double half = totalLength * 0.5; - if (half <= 1.0E-6) { - clearance = targetClearance; - } else if (clampedStation <= half) { - clearance = targetClearance * (clampedStation / half); - } else { - clearance = targetClearance * ((totalLength - clampedStation) / half); + 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; } - } - - return Math.max(0, (int)Math.round(Mth.clamp(clearance, 0.0, targetClearance))); - } - } - - private static int bridgeTargetClearanceBlocks(RoadClass roadClass, double worldScale) { - double safeScale = worldScale > 0.0 ? worldScale : 1.0; - double clearanceMeters = switch (roadClass) { - case MAIN -> 6.0; - case NORMAL -> 5.0; - case DIRT -> 3.0; - }; - return Math.max(1, (int)Math.ceil(clearanceMeters / safeScale)); - } - private static int tunnelDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { - return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); - } + int deckY = tunnelCarveDeckY[chunkIndex]; + if (deckY < chunkMinY || deckY >= chunkMaxY) { + continue; + } - private static int interpolateDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { - if (totalLength <= 1.0E-6) { - return startSurface; - } else { - double progress = Mth.clamp(station / totalLength, 0.0, 1.0); - double interpolated = startSurface + (endSurface - startSurface) * progress; - return (int)Math.round(interpolated); - } - } + 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; + } - private static boolean shouldReplaceBridgeCandidate(boolean existingPresent, int existingDeckY, int newDeckY) { - return !existingPresent || newDeckY > existingDeckY; - } + 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)); + } + } + } - private static boolean shouldReplaceRoadCandidate( - boolean existingPresent, int existingDeckY, byte existingModeId, boolean existingTunnelCarve, int newDeckY, RoadMode newMode, boolean newTunnelCarve - ) { - if (!existingPresent) { - return true; - } else if (newDeckY != existingDeckY) { - return newDeckY > existingDeckY; - } else { - int existingModePriority = modePriority(existingModeId); - if (newMode.priority() != existingModePriority) { - return newMode.priority() > existingModePriority; - } else { - return newTunnelCarve != existingTunnelCarve ? newTunnelCarve : false; + 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 int modePriority(byte modeId) { - if (modeId <= 0) { - return -1; - } else { - int index = modeId - 1; - return index >= 0 && index < RoadMode.values().length ? RoadMode.values()[index].priority() : -1; + 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(); } - } - - private static void mergeBridgeOverlay( - int index, int classId, int deckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass - ) { - if (!bridgeOverlayPresent[index]) { - bridgeOverlayPresent[index] = true; - bridgeOverlayDeckY[index] = deckY; - bridgeOverlayClass[index] = (byte)classId; - } else { - int existingDeck = bridgeOverlayDeckY[index]; - int existingClass = bridgeOverlayClass[index]; - if (deckY > existingDeck || deckY == existingDeck && classId < existingClass) { - bridgeOverlayDeckY[index] = deckY; - bridgeOverlayClass[index] = (byte)classId; - } + if (roll < 16) { + return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); } + return Blocks.STONE_BRICKS.defaultBlockState(); } - private void rasterizeBridgeSupports( + private void paintRoadLaneMarkings( + WorldGenLevel level, + ChunkAccess chunk, List roads, - int roadWidth, - double blocksPerDegree, - int extMinX, - int extMinZ, - int extSide, + EarthChunkGenerator.RoadWidths widths, int chunkMinX, int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, int chunkMinY, int chunkMaxY, - Long2ObjectOpenHashMap edgeColumnCache, - byte[] resolvedClass, - int[] resolvedDeckY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings, - boolean[] shaftPresent, - int[] shaftBottomY, - int[] shaftTopY, - boolean[] capPresent, - int[] capBottomY, - int[] capTopY + byte[] bridgeOverlayClass ) { - if (roads.isEmpty() || roadWidth <= 0) { - return; - } + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); - double worldScale = EarthProjection.worldScaleFromBlocksPerDegree(blocksPerDegree); for (RoadFeature road : roads) { - EarthChunkGenerator.RoadColumnSample startColumn = this.sampleRoadColumnForOverlay( - Mth.floor(road.lonAt(0) * blocksPerDegree), - Mth.floor(EarthProjection.latToBlockZ(road.latAt(0), worldScale)), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - EarthChunkGenerator.RoadColumnSample endColumn = this.sampleRoadColumnForOverlay( - Mth.floor(road.lonAt(road.pointCount() - 1) * blocksPerDegree), - Mth.floor(EarthProjection.latToBlockZ(road.latAt(road.pointCount() - 1), worldScale)), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - int startSurface = startColumn.roadSurface(); - int endSurface = endColumn.roadSurface(); - BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), roadWidth); - BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, roadWidth, placement -> { - IntArrayList capCells = new IntArrayList(); - IntArrayList[] shaftCells = new IntArrayList[style.shaftCount()]; - int[] minTerrain = new int[style.shaftCount()]; - int[] maxTerrain = new int[style.shaftCount()]; + int lanes = road.laneCount(); + if (lanes < 2 || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; + } - for (int i = 0; i < style.shaftCount(); i++) { - shaftCells[i] = new IntArrayList(); - minTerrain[i] = Integer.MAX_VALUE; - maxTerrain[i] = Integer.MIN_VALUE; - } + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } - EarthChunkGenerator.RoadColumnSample supportCenterColumn = this.sampleRoadColumnForOverlay( - Mth.floor(placement.centerX()), - Mth.floor(placement.centerZ()), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - int deckY = bridgeDeckYAtStation( - placement.station(), - placement.totalLength(), - startSurface, - endSurface, - supportCenterColumn.roadSurface(), - road.bridgeLevel(), - road.roadClass(), - worldScale - ); - int capTop = Math.min(chunkMaxY, deckY - 1); - int capBottom = Math.max(chunkMinY, capTop - style.capThickness() + 1); - if (capTop < capBottom) { - return; - } + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double laneWidth = roadWidth / (double)lanes; + if (laneWidth < 1.0) { + continue; + } - double radius = style.maxFootprintRadius() + 1.0; - int minLocalX = Mth.clamp((int)Math.floor(placement.centerX() - radius) - extMinX, 0, extSide - 1); - int maxLocalX = Mth.clamp((int)Math.ceil(placement.centerX() + radius) - extMinX, 0, extSide - 1); - int minLocalZ = Mth.clamp((int)Math.floor(placement.centerZ() - radius) - extMinZ, 0, extSide - 1); - int maxLocalZ = Mth.clamp((int)Math.ceil(placement.centerZ() + radius) - extMinZ, 0, extSide - 1); + 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; + } - for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { - int worldZ = extMinZ + localZ; + 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; + } - for (int localX = minLocalX; localX <= maxLocalX; localX++) { - int worldX = extMinX + localX; - double deltaX = worldX - placement.centerX(); - double deltaZ = worldZ - placement.centerZ(); - double along = deltaX * placement.tangentX() + deltaZ * placement.tangentZ(); - double across = deltaX * placement.normalX() + deltaZ * placement.normalZ(); - int index = extIndex(localX, localZ, extSide); - if (Math.abs(along) <= style.capHalfAlong() && Math.abs(across) <= style.capHalfAcross()) { - capCells.add(index); + 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; } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - double shaftAcross = style.shaftCount() == 1 ? 0.0 : (shaftIndex == 0 ? -style.shaftOffset() : style.shaftOffset()); - if (Math.abs(along) <= style.shaftHalfAlong() && Math.abs(across - shaftAcross) <= style.shaftHalfAcross()) { - EarthChunkGenerator.RoadColumnSample column = this.sampleRoadColumnForOverlay( - worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - int terrainSurface = column.terrainSurface(); - if (!BridgeSupportRoadMask.overlapsRoad( - index, terrainSurface, capBottom - 1, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY - )) { - shaftCells[shaftIndex].add(index); - minTerrain[shaftIndex] = Math.min(minTerrain[shaftIndex], terrainSurface); - maxTerrain[shaftIndex] = Math.max(maxTerrain[shaftIndex], terrainSurface); - } - } + 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); } } - // Nearby roads only remove directly overlapping support columns instead of suppressing the whole support. - BridgeSupportRoadMask.retainRoadFreeSupportCells( - capCells, index -> capBottom, capTop, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY - ); + 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); + } + + private static int bridgeClearanceAtStation(double station, double totalLength, RoadClass roadClass, double worldScale) { + int targetClearance = bridgeTargetClearanceBlocks(roadClass, worldScale); + if (targetClearance <= 0 || totalLength <= 1.0E-6) { + return 0; + } else { + double clampedStation = Mth.clamp(station, 0.0, totalLength); + double rampLength = targetClearance * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; + double clearance; + if (totalLength >= rampLength * 2.0) { + if (clampedStation < rampLength) { + clearance = targetClearance * (clampedStation / rampLength); + } else if (clampedStation > totalLength - rampLength) { + clearance = targetClearance * ((totalLength - clampedStation) / rampLength); + } else { + clearance = targetClearance; + } + } else { + double half = totalLength * 0.5; + if (half <= 1.0E-6) { + clearance = targetClearance; + } else if (clampedStation <= half) { + clearance = targetClearance * (clampedStation / half); + } else { + clearance = targetClearance * ((totalLength - clampedStation) / half); + } + } + + return Math.max(0, (int)Math.round(Mth.clamp(clearance, 0.0, targetClearance))); + } + } + + private static int bridgeTargetClearanceBlocks(RoadClass roadClass, double worldScale) { + double safeScale = worldScale > 0.0 ? worldScale : 1.0; + double clearanceMeters = switch (roadClass) { + case MAIN -> 6.0; + case NORMAL -> 5.0; + case DIRT -> 3.0; + }; + return Math.max(1, (int)Math.ceil(clearanceMeters / safeScale)); + } + + private static int tunnelDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { + return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); + } + + private static int interpolateDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { + if (totalLength <= 1.0E-6) { + return startSurface; + } else { + double progress = Mth.clamp(station / totalLength, 0.0, 1.0); + double interpolated = startSurface + (endSurface - startSurface) * progress; + return (int)Math.round(interpolated); + } + } + + private static boolean shouldReplaceBridgeCandidate(boolean existingPresent, int existingDeckY, int newDeckY) { + return !existingPresent || newDeckY > existingDeckY; + } + + private static boolean shouldReplaceRoadCandidate( + boolean existingPresent, int existingDeckY, byte existingModeId, boolean existingTunnelCarve, int newDeckY, RoadMode newMode, boolean newTunnelCarve + ) { + if (!existingPresent) { + return true; + } else if (newDeckY != existingDeckY) { + return newDeckY > existingDeckY; + } else { + int existingModePriority = modePriority(existingModeId); + if (newMode.priority() != existingModePriority) { + return newMode.priority() > existingModePriority; + } else { + return newTunnelCarve != existingTunnelCarve ? newTunnelCarve : false; + } + } + } + + private static int modePriority(byte modeId) { + if (modeId <= 0) { + return -1; + } else { + int index = modeId - 1; + return index >= 0 && index < RoadMode.values().length ? RoadMode.values()[index].priority() : -1; + } + } + + private static void mergeBridgeOverlay( + 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; + } + } + } + + private void rasterizeBridgeSupports( + List roads, + int roadWidth, + double blocksPerDegree, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + int chunkMinY, + int chunkMaxY, + Long2ObjectOpenHashMap edgeColumnCache, + byte[] resolvedClass, + int[] resolvedDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings, + boolean[] shaftPresent, + int[] shaftBottomY, + int[] shaftTopY, + boolean[] capPresent, + int[] capBottomY, + int[] capTopY + ) { + if (roads.isEmpty() || roadWidth <= 0) { + return; + } + + 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)), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + EarthChunkGenerator.RoadColumnSample endColumn = this.sampleRoadColumnForOverlay( + Mth.floor(road.lonAt(road.pointCount() - 1) * blocksPerDegree), + Mth.floor(EarthProjection.latToBlockZ(road.latAt(road.pointCount() - 1), worldScale)), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + int startSurface = startColumn.roadSurface(); + int endSurface = endColumn.roadSurface(); + 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()]; + int[] maxTerrain = new int[style.shaftCount()]; + + for (int i = 0; i < style.shaftCount(); i++) { + shaftCells[i] = new IntArrayList(); + minTerrain[i] = Integer.MAX_VALUE; + maxTerrain[i] = Integer.MIN_VALUE; + } + + EarthChunkGenerator.RoadColumnSample supportCenterColumn = this.sampleRoadColumnForOverlay( + Mth.floor(placement.centerX()), + Mth.floor(placement.centerZ()), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + int deckY = bridgeDeckYAtStation( + placement.station(), + placement.totalLength(), + startSurface, + endSurface, + supportCenterColumn.roadSurface(), + road.bridgeLevel(), + road.roadClass(), + worldScale + ); + int capTop = Math.min(chunkMaxY, deckY - 1); + int capBottom = Math.max(chunkMinY, capTop - style.capThickness() + 1); + if (capTop < capBottom) { + return; + } + + double radius = style.maxFootprintRadius() + 1.0; + int minLocalX = Mth.clamp((int)Math.floor(placement.centerX() - radius) - extMinX, 0, extSide - 1); + int maxLocalX = Mth.clamp((int)Math.ceil(placement.centerX() + radius) - extMinX, 0, extSide - 1); + int minLocalZ = Mth.clamp((int)Math.floor(placement.centerZ() - radius) - extMinZ, 0, extSide - 1); + int maxLocalZ = Mth.clamp((int)Math.ceil(placement.centerZ() + radius) - extMinZ, 0, extSide - 1); + + for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { + int worldZ = extMinZ + localZ; + + for (int localX = minLocalX; localX <= maxLocalX; localX++) { + int worldX = extMinX + localX; + double deltaX = worldX - placement.centerX(); + double deltaZ = worldZ - placement.centerZ(); + double along = deltaX * placement.tangentX() + deltaZ * placement.tangentZ(); + double across = deltaX * placement.normalX() + deltaZ * placement.normalZ(); + int index = extIndex(localX, localZ, extSide); + if (Math.abs(along) <= style.capHalfAlong() && Math.abs(across) <= style.capHalfAcross()) { + capCells.add(index); + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + double shaftAcross = style.shaftCount() == 1 ? 0.0 : (shaftIndex == 0 ? -style.shaftOffset() : style.shaftOffset()); + if (Math.abs(along) <= style.shaftHalfAlong() && Math.abs(across - shaftAcross) <= style.shaftHalfAcross()) { + EarthChunkGenerator.RoadColumnSample column = this.sampleRoadColumnForOverlay( + worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + int terrainSurface = column.terrainSurface(); + if (!BridgeSupportRoadMask.overlapsRoad( + index, terrainSurface, capBottom - 1, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY + )) { + shaftCells[shaftIndex].add(index); + minTerrain[shaftIndex] = Math.min(minTerrain[shaftIndex], terrainSurface); + maxTerrain[shaftIndex] = Math.max(maxTerrain[shaftIndex], terrainSurface); + } + } + } + } + } + + // Nearby roads only remove directly overlapping support columns instead of suppressing the whole support. + BridgeSupportRoadMask.retainRoadFreeSupportCells( + capCells, index -> capBottom, capTop, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY + ); if (capCells.isEmpty()) { return; } - int[] supportTops = new int[style.shaftCount()]; - boolean[] activeShafts = new boolean[style.shaftCount()]; - int activeShaftCount = 0; - int requiredClearance = Math.max(1, Math.min(style.minClearance(), bridgeTargetClearanceBlocks(road.roadClass(), worldScale))); - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (shaftCells[shaftIndex].isEmpty() || minTerrain[shaftIndex] == Integer.MAX_VALUE || maxTerrain[shaftIndex] == Integer.MIN_VALUE) { - continue; + int[] supportTops = new int[style.shaftCount()]; + boolean[] activeShafts = new boolean[style.shaftCount()]; + int activeShaftCount = 0; + int requiredClearance = Math.max(1, Math.min(style.minClearance(), bridgeTargetClearanceBlocks(road.roadClass(), worldScale))); + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (shaftCells[shaftIndex].isEmpty() || minTerrain[shaftIndex] == Integer.MAX_VALUE || maxTerrain[shaftIndex] == Integer.MIN_VALUE) { + continue; + } + + if (capBottom - maxTerrain[shaftIndex] < requiredClearance) { + continue; + } + + supportTops[shaftIndex] = capBottom - 1; + if (supportTops[shaftIndex] < minTerrain[shaftIndex]) { + continue; + } + + activeShafts[shaftIndex] = true; + activeShaftCount++; + } + + if (activeShaftCount == 0) { + return; + } + + for (int i = 0; i < capCells.size(); i++) { + int index = capCells.getInt(i); + if (this.bridgeSupportConflictsBuilding( + index, + capBottom, + capTop, + extMinX, + extMinZ, + extSide, + chunkMinX, + chunkMinZ, + preparedBuildings + )) { + return; + } + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (!activeShafts[shaftIndex]) { + continue; + } + + for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { + int index = shaftCells[shaftIndex].getInt(i); + int localBottom = this.bridgeSupportTerrainBottom( + index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + if (supportTops[shaftIndex] < localBottom) { + continue; + } + + if (this.bridgeSupportConflictsBuilding( + index, + localBottom, + supportTops[shaftIndex], + extMinX, + extMinZ, + extSide, + chunkMinX, + chunkMinZ, + preparedBuildings + )) { + return; + } + } + } + + for (int i = 0; i < capCells.size(); i++) { + mergeBridgeSupportColumn(capCells.getInt(i), capBottom, capTop, capPresent, capBottomY, capTopY); + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (!activeShafts[shaftIndex]) { + continue; + } + + for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { + int index = shaftCells[shaftIndex].getInt(i); + int localBottom = this.bridgeSupportTerrainBottom( + index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + if (supportTops[shaftIndex] < localBottom) { + continue; + } + + mergeBridgeSupportColumn( + index, + localBottom, + supportTops[shaftIndex], + shaftPresent, + shaftBottomY, + shaftTopY + ); + } + } + }); + } + } + + private boolean bridgeSupportConflictsBuilding( + int extIndex, + int bottomY, + int topY, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + ) { + if (topY < bottomY) { + return false; + } + + if (preparedBuildings != null) { + int localX = extIndex % extSide; + int localZ = extIndex / extSide; + int worldX = extMinX + localX; + int worldZ = extMinZ + localZ; + int chunkLocalX = worldX - chunkMinX; + int chunkLocalZ = worldZ - chunkMinZ; + if (chunkLocalX >= 0 + && chunkLocalX < CHUNK_SIDE + && chunkLocalZ >= 0 + && chunkLocalZ < CHUNK_SIDE + && preparedBuildings.intersectsSpan(chunkLocalX, chunkLocalZ, bottomY, topY)) { + return true; + } + } + + return false; + } + + private RoadColumnSample bridgeSupportColumnSample( + int extIndex, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + int localX = extIndex % extSide; + int localZ = extIndex / extSide; + int worldX = extMinX + localX; + int worldZ = extMinZ + localZ; + return this.sampleRoadColumnForOverlay(worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache); + } + + private int bridgeSupportTerrainBottom( + int extIndex, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + return this.bridgeSupportColumnSample( + extIndex, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ) + .terrainSurface(); + } + + private static void mergeBridgeSupportColumn(int index, int bottomY, int topY, boolean[] present, int[] bottoms, int[] tops) { + if (topY < bottomY) { + return; + } + + if (!present[index]) { + present[index] = true; + bottoms[index] = bottomY; + tops[index] = topY; + } else { + bottoms[index] = Math.min(bottoms[index], bottomY); + tops[index] = Math.max(tops[index], topY); + } + } + + private static int extIndex(int localX, int localZ, int side) { + return localZ * side + localX; + } + + private static long packColumn(int worldX, int worldZ) { + return (long)worldX << 32 ^ worldZ & 4294967295L; + } + + + private static BlockState roadStateForClass(RoadClass roadClass) { + return switch (roadClass) { + case MAIN -> ROAD_MAIN_STATE; + case NORMAL -> ROAD_NORMAL_STATE; + case DIRT -> ROAD_DIRT_STATE; + }; + } + + 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) { + case 1 -> RoadClass.MAIN; + case 2 -> RoadClass.NORMAL; + default -> RoadClass.DIRT; + }; + } + + private static int roadClassId(RoadClass roadClass) { + return switch (roadClass) { + case MAIN -> 1; + case NORMAL -> 2; + case DIRT -> 3; + }; + } + + private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.RoadWidths widths) { + return switch (roadClass) { + case MAIN -> widths.main(); + case NORMAL -> widths.normal(); + case DIRT -> widths.dirt(); + }; + } + + 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; + } else { + return Mth.clamp((int)Math.round(ROAD_LIGHT_BASE_SPACING_METERS / worldScale), 3, 40); + } + } + + private static int roadLightMinimumSpacingBlocks(int spacingBlocks) { + return Math.max(3, (int)Math.round(spacingBlocks * 0.75)); + } + + private static int roadLightFenceCount(double worldScale) { + if (worldScale <= 3.0) { + return 3; + } else { + return worldScale <= 8.0 ? 2 : 1; + } + } + + private static EarthChunkGenerator.SampledRoadStation sampleRoadStation( + double[] worldXs, double[] worldZs, double[] segmentStarts, double[] segmentLengths, double station + ) { + for (int i = 0; i < segmentLengths.length; i++) { + double segmentLength = segmentLengths[i]; + if (!(segmentLength <= 1.0E-6)) { + double segmentStart = segmentStarts[i]; + double segmentEnd = segmentStart + segmentLength; + if (station <= segmentEnd + 1.0E-6 || i == segmentLengths.length - 1) { + double dx = worldXs[i + 1] - worldXs[i]; + double dz = worldZs[i + 1] - worldZs[i]; + double t = Mth.clamp((station - segmentStart) / segmentLength, 0.0, 1.0); + return new EarthChunkGenerator.SampledRoadStation(worldXs[i] + dx * t, worldZs[i] + dz * t, dx / segmentLength, dz / segmentLength); + } + } + } + + return null; + } + + private static EarthChunkGenerator.RoadLightAnchor findRoadLightAnchor( + EarthChunkGenerator.SampledRoadStation sampled, + boolean placeLeft, + int roadWidth, + int roadClassId, + int roadModeId, + int chunkMinX, + int chunkMinZ, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY + ) { + double normalX = placeLeft ? -sampled.tangentZ() : sampled.tangentZ(); + double normalZ = placeLeft ? sampled.tangentX() : -sampled.tangentX(); + double scanRadius = Math.max(2.0, roadWidth + 2.0); + double alongTolerance = Math.max(1.25, roadWidth * 0.45); + int minLocalX = Math.max(0, quantizeRoadCoordinate(sampled.worldX() - scanRadius) - chunkMinX); + int maxLocalX = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldX() + scanRadius) - chunkMinX); + int minLocalZ = Math.max(0, quantizeRoadCoordinate(sampled.worldZ() - scanRadius) - chunkMinZ); + int maxLocalZ = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldZ() + scanRadius) - chunkMinZ); + EarthChunkGenerator.RoadLightAnchor bestAnchor = null; + double bestLateral = Double.NEGATIVE_INFINITY; + double bestAlong = Double.POSITIVE_INFINITY; + double bestDistanceSq = Double.POSITIVE_INFINITY; + double minLateral = Double.POSITIVE_INFINITY; + double maxLateral = Double.NEGATIVE_INFINITY; + + for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { + for (int localX = minLocalX; localX <= maxLocalX; localX++) { + int index = chunkIndex(localX, localZ); + if (chunkRoadClass[index] == roadClassId && chunkRoadMode[index] == roadModeId) { + double dx = chunkMinX + localX + 0.5 - sampled.worldX(); + double dz = chunkMinZ + localZ + 0.5 - sampled.worldZ(); + double along = dx * sampled.tangentX() + dz * sampled.tangentZ(); + if (!(Math.abs(along) > alongTolerance)) { + double lateral = dx * normalX + dz * normalZ; + minLateral = Math.min(minLateral, lateral); + maxLateral = Math.max(maxLateral, lateral); + if (!(lateral <= 0.05)) { + double distanceSq = dx * dx + dz * dz; + double absAlong = Math.abs(along); + if (lateral > bestLateral + 1.0E-6 + || Math.abs(lateral - bestLateral) <= 1.0E-6 && absAlong < bestAlong - 1.0E-6 + || Math.abs(lateral - bestLateral) <= 1.0E-6 && Math.abs(absAlong - bestAlong) <= 1.0E-6 && distanceSq < bestDistanceSq) { + bestLateral = lateral; + bestAlong = absAlong; + bestDistanceSq = distanceSq; + bestAnchor = new EarthChunkGenerator.RoadLightAnchor(localX, localZ, chunkRoadDeckY[index], index); + } + } + } + } + } + } + + if (bestAnchor == null || minLateral == Double.POSITIVE_INFINITY || maxLateral == Double.NEGATIVE_INFINITY) { + return null; + } + + double span = maxLateral - minLateral; + if (span < 0.75) { + return null; + } + + if (span > roadWidth + 0.75) { + return null; + } + + return bestAnchor; + } + + private static boolean hasNearbyPreparedRoadLight( + int localX, int localZ, int minSpacingBlocks, EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights + ) { + if (preparedRoadLights == null || preparedRoadLights.isEmpty()) { + return false; + } else { + int minSpacingSq = minSpacingBlocks * minSpacingBlocks; + + for (EarthChunkGenerator.PreparedRoadLight light : preparedRoadLights.lights()) { + int dx = light.localX() - localX; + int dz = light.localZ() - localZ; + if (dx * dx + dz * dz < minSpacingSq) { + return true; + } + } + + return false; + } + } + + private static boolean intersectsRoadLightBridgeSupport( + int localX, + int localZ, + int minY, + int maxY, + boolean[] bridgeSupportShaftPresent, + int[] bridgeSupportShaftBottomY, + int[] bridgeSupportShaftTopY, + boolean[] bridgeSupportCapPresent, + int[] bridgeSupportCapBottomY, + int[] bridgeSupportCapTopY + ) { + int index = chunkIndex(localX, localZ); + return bridgeSupportShaftPresent[index] && spansOverlap(minY, maxY, bridgeSupportShaftBottomY[index], bridgeSupportShaftTopY[index]) + || bridgeSupportCapPresent[index] && spansOverlap(minY, maxY, bridgeSupportCapBottomY[index], bridgeSupportCapTopY[index]); + } + + private static boolean spansOverlap(int minY, int maxY, int otherMinY, int otherMaxY) { + return maxY >= otherMinY && minY <= otherMaxY; + } + + private static int quantizeRoadCoordinate(double value) { + return Mth.floor(value + 0.5); + } + + private static Direction dominantHorizontalDirection(double tangentX, double tangentZ) { + if (Math.abs(tangentX) >= Math.abs(tangentZ)) { + return tangentX >= 0.0 ? Direction.EAST : Direction.WEST; + } else { + return tangentZ >= 0.0 ? Direction.SOUTH : Direction.NORTH; + } + } + + private static BlockState roadLightTrapdoorState(Direction facing) { + return (BlockState)ROAD_LIGHT_TRAPDOOR_BASE_STATE.setValue(BlockStateProperties.HORIZONTAL_FACING, facing); + } + + private static boolean isRoadDeckState(BlockState state) { + 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) { + return state.isAir() + || state.is(Blocks.SNOW) + || state.is(Blocks.POWDER_SNOW) + || state.getFluidState().isEmpty() && state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + } + + private static boolean isTunnelCarveReplaceable(BlockState state) { + return isReplaceableCaveBlock(state) && !isRoadDeckState(state) && !state.is(BRIDGE_SUPPORT_SHAFT_STATE.getBlock()) && !state.is(BRIDGE_SUPPORT_CAP_STATE.getBlock()); + } + + private static boolean[] computeFloodGuardColumns(boolean[] waterFlags) { + boolean[] result = new boolean[CHUNK_AREA]; + + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + boolean nearWater = false; + + for (int dz = -2; dz <= 2 && !nearWater; dz++) { + int z = localZ + dz; + if (z >= 0 && z < CHUNK_SIDE) { + for (int dx = -2; dx <= 2; dx++) { + int x = localX + dx; + if (x >= 0 && x < CHUNK_SIDE && waterFlags[chunkIndex(x, z)]) { + nearWater = true; + break; + } + } } + } - if (capBottom - maxTerrain[shaftIndex] < requiredClearance) { - continue; + result[chunkIndex(localX, localZ)] = nearWater; + } + } + + return result; + } + + private static boolean isReplaceableCaveBlock(BlockState state) { + return isSolidCaveAnchor(state) && !state.is(Blocks.BEDROCK); + } + + private static boolean isSolidCaveAnchor(BlockState state) { + return !state.isAir() && state.getFluidState().isEmpty() && !state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + } + + private static int chunkIndex(int localX, int localZ) { + return localZ * CHUNK_SIDE + localX; + } + + @SuppressWarnings("unchecked") + private static Holder[] newBiomeCache(int size) { + return (Holder[])new Holder[size]; + } + + private void carveStructureClearanceVolumes(StructureManager structures, ChunkAccess chunk) { + List starts = structures.startsForStructure( + chunk.getPos(), structure -> shouldApplyStructureTerrainAdjustment(structure.terrainAdaptation()) + ); + if (!starts.isEmpty()) { + ChunkPos pos = chunk.getPos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + 15; + int chunkMaxZ = chunkMinZ + 15; + int chunkMinY = chunk.getMinBuildHeight(); + int chunkMaxY = chunkMinY + chunk.getHeight() - 1; + MutableBlockPos cursor = new MutableBlockPos(); + + for (StructureStart start : starts) { + if (start != null && start.isValid()) { + for (StructurePiece piece : start.getPieces()) { + BoundingBox box = piece.getBoundingBox(); + if (box.intersects(chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ)) { + int centerX = box.minX() + box.maxX() >> 1; + int centerZ = box.minZ() + box.maxZ() >> 1; + int terrainSurface = this.resolveAuxWaterColumn(centerX, centerZ).terrainSurface(); + if (box.maxY() <= terrainSurface - 20) { + int coreMinX = box.minX() - 1; + int coreMaxX = box.maxX() + 1; + int coreMinZ = box.minZ() - 1; + int coreMaxZ = box.maxZ() + 1; + int coreMinY = box.minY() - 0; + int coreMaxY = box.maxY() + 0; + int minX = Math.max(chunkMinX, coreMinX - 6); + int maxX = Math.min(chunkMaxX, coreMaxX + 6); + int minZ = Math.max(chunkMinZ, coreMinZ - 6); + int maxZ = Math.min(chunkMaxZ, coreMaxZ + 6); + int minY = Math.max(chunkMinY + 1, coreMinY - 0); + int maxY = Math.min(chunkMaxY - 1, coreMaxY + 4); + if (maxY >= minY && maxX >= minX && maxZ >= minZ) { + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + double nx = axisDistanceNormalized(x, coreMinX, coreMaxX, 6); + double nz = axisDistanceNormalized(z, coreMinZ, coreMaxZ, 6); + double ny = axisDistanceNormalized(y, coreMinY, coreMaxY, 0, 4); + double distance = Math.sqrt(nx * nx + ny * ny + nz * nz); + double threshold = 1.0 + this.structureClearanceNoiseJitter(x, y, z) * 0.22; + if (!(distance > threshold)) { + cursor.set(x, y, z); + BlockState state = chunk.getBlockState(cursor); + if (isReplaceableCaveBlock(state)) { + chunk.setBlockState(cursor, CAVE_AIR_STATE, false); + } + } + } + } + } + } + } + } } + } + } + } + } + + private double structureClearanceNoiseJitter(int x, int y, int z) { + long seed = seedFromCoords(x, y, z) ^ this.worldSeed ^ 7951840804584193857L; + double t = Math.floorMod(seed, 2048L) / 2047.0; + return t * 2.0 - 1.0; + } + + private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadius) { + if (value < coreMin) { + return (double)(coreMin - value) / Math.max(1, shellRadius); + } else { + return value > coreMax ? (double)(value - coreMax) / Math.max(1, shellRadius) : 0.0; + } + } + + private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadiusBelow, int shellRadiusAbove) { + if (value < coreMin) { + return shellRadiusBelow <= 0 ? Double.POSITIVE_INFINITY : (double)(coreMin - value) / shellRadiusBelow; + } else if (value > coreMax) { + return shellRadiusAbove <= 0 ? Double.POSITIVE_INFINITY : (double)(value - coreMax) / shellRadiusAbove; + } else { + return 0.0; + } + } + + private static boolean shouldApplyStructureTerrainAdjustment(TerrainAdjustment adjustment) { + return adjustment == TerrainAdjustment.BEARD_THIN || adjustment == TerrainAdjustment.BEARD_BOX; + } + + private static int minSurfaceHeight(int[] terrainSurfaces) { + int min = Integer.MAX_VALUE; + + for (int surface : terrainSurfaces) { + if (surface < min) { + min = surface; + } + } + + return min == Integer.MAX_VALUE ? 0 : min; + } + + private EarthChunkGenerator.HeightGridBuildResult buildHeightGrid( + ChunkPos pos, int step, int gridSize, boolean allowCacheReuse, boolean useLocalTerrainInputs + ) { + int[] heightGrid = new int[gridSize * gridSize]; + int cacheHits = 0; + int cacheMisses = 0; + boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); + if (reusableLayout) { + Arrays.fill(heightGrid, Integer.MIN_VALUE); + cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, false); + } + + int gridMinX = pos.getMinBlockX() - step; + int gridMinZ = pos.getMinBlockZ() - step; + for (int dz = 0; dz < gridSize; dz++) { + int worldZ = gridMinZ + dz; + int row = dz * gridSize; + + for (int dx = 0; dx < gridSize; dx++) { + int index = row + dx; + if (!reusableLayout || heightGrid[index] == Integer.MIN_VALUE) { + int worldX = gridMinX + dx; + heightGrid[index] = useLocalTerrainInputs ? this.sampleSurfaceHeightLocalOnly(worldX, worldZ) : this.sampleSurfaceHeight(worldX, worldZ); + cacheMisses++; + } + } + } + + if (reusableLayout) { + this.heightGridCache.put(pos, step, gridSize, heightGrid, false); + } - supportTops[shaftIndex] = capBottom - 1; - if (supportTops[shaftIndex] < minTerrain[shaftIndex]) { - continue; - } + return new EarthChunkGenerator.HeightGridBuildResult(heightGrid, cacheHits, cacheMisses); + } - activeShafts[shaftIndex] = true; - activeShaftCount++; - } + private EarthChunkGenerator.TerrainShellHeightGridResult buildTerrainShellHeightGrid(ChunkPos pos, int step, int gridSize, boolean allowCacheReuse) { + int[] heightGrid = new int[gridSize * gridSize]; + Arrays.fill(heightGrid, Integer.MIN_VALUE); + int cacheHits = 0; + boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); + if (reusableLayout) { + cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, true); + } - if (activeShaftCount == 0) { - return; - } + int initialMisses = 0; + int gridMinX = pos.getMinBlockX() - step; + int gridMinZ = pos.getMinBlockZ() - step; + for (int dz = 0; dz < gridSize; dz++) { + int worldZ = gridMinZ + dz; + int row = dz * gridSize; - for (int i = 0; i < capCells.size(); i++) { - int index = capCells.getInt(i); - if (this.bridgeSupportConflictsBuilding( - index, - capBottom, - capTop, - extMinX, - extMinZ, - extSide, - chunkMinX, - chunkMinZ, - preparedBuildings - )) { - return; + for (int dx = 0; dx < gridSize; dx++) { + int index = row + dx; + if (heightGrid[index] == Integer.MIN_VALUE) { + int worldX = gridMinX + dx; + int sampled = this.sampleSurfaceHeightMemoryOnly(worldX, worldZ); + if (sampled != Integer.MIN_VALUE) { + heightGrid[index] = sampled; + } else { + initialMisses++; } } + } + } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (!activeShafts[shaftIndex]) { - continue; - } + boolean usedFallback = initialMisses > 0; + if (usedFallback) { + this.fillMissingTerrainShellHeights(heightGrid, gridSize); + } - for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { - int index = shaftCells[shaftIndex].getInt(i); - int localBottom = this.bridgeSupportTerrainBottom( - index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - if (supportTops[shaftIndex] < localBottom) { - continue; - } + if (reusableLayout) { + this.heightGridCache.put(pos, step, gridSize, heightGrid, usedFallback); + } - if (this.bridgeSupportConflictsBuilding( - index, - localBottom, - supportTops[shaftIndex], - extMinX, - extMinZ, - extSide, - chunkMinX, - chunkMinZ, - preparedBuildings - )) { - return; - } - } + return new EarthChunkGenerator.TerrainShellHeightGridResult(heightGrid, cacheHits, initialMisses, usedFallback); + } + + private void fillMissingTerrainShellHeights(int[] heightGrid, int gridSize) { + int[] anchors = buildShellAnchorCoordinates(gridSize); + int[][] coarse = new int[anchors.length][anchors.length]; + for (int z = 0; z < coarse.length; z++) { + Arrays.fill(coarse[z], Integer.MIN_VALUE); + } + + for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { + for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { + coarse[anchorZIndex][anchorXIndex] = nearestKnownTerrainHeight(heightGrid, gridSize, anchors[anchorXIndex], anchors[anchorZIndex], 2); + } + } + + int defaultHeight = this.seaLevel; + int knownAnchorCount = 0; + long knownAnchorSum = 0L; + for (int[] coarseRow : coarse) { + for (int coarseHeight : coarseRow) { + if (coarseHeight != Integer.MIN_VALUE) { + knownAnchorSum += coarseHeight; + knownAnchorCount++; } + } + } - for (int i = 0; i < capCells.size(); i++) { - mergeBridgeSupportColumn(capCells.getInt(i), capBottom, capTop, capPresent, capBottomY, capTopY); + if (knownAnchorCount > 0) { + defaultHeight = Mth.floor((double)knownAnchorSum / knownAnchorCount); + } + + defaultHeight = Mth.clamp(defaultHeight, this.minY, this.minY + this.height - 1); + for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { + for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { + if (coarse[anchorZIndex][anchorXIndex] == Integer.MIN_VALUE) { + int replacement = nearestKnownAnchorHeight(coarse, anchorXIndex, anchorZIndex); + coarse[anchorZIndex][anchorXIndex] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; } + } + } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (!activeShafts[shaftIndex]) { - continue; - } + for (int z = 0; z < gridSize; z++) { + for (int x = 0; x < gridSize; x++) { + int index = z * gridSize + x; + if (heightGrid[index] != Integer.MIN_VALUE) { + continue; + } - for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { - int index = shaftCells[shaftIndex].getInt(i); - int localBottom = this.bridgeSupportTerrainBottom( - index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - if (supportTops[shaftIndex] < localBottom) { - continue; - } + int lowAnchorX = lowerAnchorIndex(anchors, x); + int highAnchorX = upperAnchorIndex(anchors, x); + int lowAnchorZ = lowerAnchorIndex(anchors, z); + int highAnchorZ = upperAnchorIndex(anchors, z); + int h00 = coarse[lowAnchorZ][lowAnchorX]; + int h10 = coarse[lowAnchorZ][highAnchorX]; + int h01 = coarse[highAnchorZ][lowAnchorX]; + int h11 = coarse[highAnchorZ][highAnchorX]; + heightGrid[index] = bilinearInterpolateHeight( + anchors[lowAnchorX], anchors[highAnchorX], anchors[lowAnchorZ], anchors[highAnchorZ], h00, h10, h01, h11, x, z + ); + } + } - mergeBridgeSupportColumn( - index, - localBottom, - supportTops[shaftIndex], - shaftPresent, - shaftBottomY, - shaftTopY - ); - } + for (int z = 0; z < gridSize; z++) { + for (int x = 0; x < gridSize; x++) { + int index = z * gridSize + x; + if (heightGrid[index] == Integer.MIN_VALUE) { + int replacement = nearestKnownTerrainHeight(heightGrid, gridSize, x, z, gridSize); + heightGrid[index] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; } - }); + } } } - private boolean bridgeSupportConflictsBuilding( - int extIndex, - int bottomY, - int topY, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings - ) { - if (topY < bottomY) { - return false; + private static int[] buildShellAnchorCoordinates(int gridSize) { + IntArrayList coords = new IntArrayList(); + for (int index = 0; index < gridSize; index += 4) { + coords.add(index); } - if (preparedBuildings != null) { - int localX = extIndex % extSide; - int localZ = extIndex / extSide; - int worldX = extMinX + localX; - int worldZ = extMinZ + localZ; - int chunkLocalX = worldX - chunkMinX; - int chunkLocalZ = worldZ - chunkMinZ; - if (chunkLocalX >= 0 - && chunkLocalX < CHUNK_SIDE - && chunkLocalZ >= 0 - && chunkLocalZ < CHUNK_SIDE - && preparedBuildings.intersectsSpan(chunkLocalX, chunkLocalZ, bottomY, topY)) { - return true; - } + if (coords.isEmpty() || coords.getInt(coords.size() - 1) != gridSize - 1) { + coords.add(gridSize - 1); } - return false; - } - - private RoadColumnSample bridgeSupportColumnSample( - int extIndex, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache - ) { - int localX = extIndex % extSide; - int localZ = extIndex / extSide; - int worldX = extMinX + localX; - int worldZ = extMinZ + localZ; - return this.sampleRoadColumnForOverlay(worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache); + return coords.toIntArray(); } - private int bridgeSupportTerrainBottom( - int extIndex, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache - ) { - return this.bridgeSupportColumnSample( - extIndex, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ) - .terrainSurface(); - } + private static int nearestKnownAnchorHeight(int[][] anchors, int centerX, int centerZ) { + int maxRadius = Math.max(anchors.length, anchors[0].length); + for (int radius = 1; radius <= maxRadius; radius++) { + long sum = 0L; + int count = 0; + for (int z = Math.max(0, centerZ - radius); z <= Math.min(anchors.length - 1, centerZ + radius); z++) { + for (int x = Math.max(0, centerX - radius); x <= Math.min(anchors[z].length - 1, centerX + radius); x++) { + int value = anchors[z][x]; + if (value != Integer.MIN_VALUE) { + sum += value; + count++; + } + } + } - private static void mergeBridgeSupportColumn(int index, int bottomY, int topY, boolean[] present, int[] bottoms, int[] tops) { - if (topY < bottomY) { - return; + if (count > 0) { + return Mth.floor((double)sum / count); + } } - if (!present[index]) { - present[index] = true; - bottoms[index] = bottomY; - tops[index] = topY; - } else { - bottoms[index] = Math.min(bottoms[index], bottomY); - tops[index] = Math.max(tops[index], topY); - } + return Integer.MIN_VALUE; } - private static int extIndex(int localX, int localZ, int side) { - return localZ * side + localX; - } + private static int nearestKnownTerrainHeight(int[] heightGrid, int gridSize, int centerX, int centerZ, int maxRadius) { + if (centerX >= 0 && centerX < gridSize && centerZ >= 0 && centerZ < gridSize) { + int center = heightGrid[centerZ * gridSize + centerX]; + if (center != Integer.MIN_VALUE) { + return center; + } + } - private static long packColumn(int worldX, int worldZ) { - return (long)worldX << 32 ^ worldZ & 4294967295L; - } + for (int radius = 1; radius <= maxRadius; radius++) { + long sum = 0L; + int count = 0; + for (int z = Math.max(0, centerZ - radius); z <= Math.min(gridSize - 1, centerZ + radius); z++) { + for (int x = Math.max(0, centerX - radius); x <= Math.min(gridSize - 1, centerX + radius); x++) { + int value = heightGrid[z * gridSize + x]; + if (value != Integer.MIN_VALUE) { + sum += value; + count++; + } + } + } + if (count > 0) { + return Mth.floor((double)sum / count); + } + } - private static BlockState roadStateForClass(RoadClass roadClass) { - return switch (roadClass) { - case MAIN -> ROAD_MAIN_STATE; - case NORMAL -> ROAD_NORMAL_STATE; - case DIRT -> ROAD_DIRT_STATE; - }; + return Integer.MIN_VALUE; } + private static int lowerAnchorIndex(int[] anchors, int value) { + int best = 0; + for (int index = 0; index < anchors.length; index++) { + if (anchors[index] > value) { + break; + } - private static RoadClass roadClassFromId(int classId) { - return switch (classId) { - case 1 -> RoadClass.MAIN; - case 2 -> RoadClass.NORMAL; - default -> RoadClass.DIRT; - }; - } - - private static int roadClassId(RoadClass roadClass) { - return switch (roadClass) { - case MAIN -> 1; - case NORMAL -> 2; - case DIRT -> 3; - }; - } + best = index; + } - private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.RoadWidths widths) { - return switch (roadClass) { - case MAIN -> widths.main(); - case NORMAL -> widths.normal(); - case DIRT -> widths.dirt(); - }; + return best; } - private static int roadLightSpacingBlocks(double worldScale) { - if (!(worldScale > 0.0)) { - return 40; - } else { - return Mth.clamp((int)Math.round(ROAD_LIGHT_BASE_SPACING_METERS / worldScale), 3, 40); + private static int upperAnchorIndex(int[] anchors, int value) { + for (int index = 0; index < anchors.length; index++) { + if (anchors[index] >= value) { + return index; + } } - } - private static int roadLightMinimumSpacingBlocks(int spacingBlocks) { - return Math.max(3, (int)Math.round(spacingBlocks * 0.75)); + return anchors.length - 1; } - private static int roadLightFenceCount(double worldScale) { - if (worldScale <= 3.0) { - return 3; + private static int bilinearInterpolateHeight( + int x0, int x1, int z0, int z1, int h00, int h10, int h01, int h11, int x, int z + ) { + if (x0 == x1 && z0 == z1) { + return h00; + } else if (x0 == x1) { + double tz = (z - z0) / (double)Math.max(1, z1 - z0); + return Mth.floor(Mth.lerp(tz, h00, h01)); + } else if (z0 == z1) { + double tx = (x - x0) / (double)Math.max(1, x1 - x0); + return Mth.floor(Mth.lerp(tx, h00, h10)); } else { - return worldScale <= 8.0 ? 2 : 1; + double tx = (x - x0) / (double)Math.max(1, x1 - x0); + double tz = (z - z0) / (double)Math.max(1, z1 - z0); + double low = Mth.lerp(tx, h00, h10); + double high = Mth.lerp(tx, h01, h11); + return Mth.floor(Mth.lerp(tz, low, high)); } } - private static EarthChunkGenerator.SampledRoadStation sampleRoadStation( - double[] worldXs, double[] worldZs, double[] segmentStarts, double[] segmentLengths, double station - ) { - for (int i = 0; i < segmentLengths.length; i++) { - double segmentLength = segmentLengths[i]; - if (!(segmentLength <= 1.0E-6)) { - double segmentStart = segmentStarts[i]; - double segmentEnd = segmentStart + segmentLength; - if (station <= segmentEnd + 1.0E-6 || i == segmentLengths.length - 1) { - double dx = worldXs[i + 1] - worldXs[i]; - double dz = worldZs[i + 1] - worldZs[i]; - double t = Mth.clamp((station - segmentStart) / segmentLength, 0.0, 1.0); - return new EarthChunkGenerator.SampledRoadStation(worldXs[i] + dx * t, worldZs[i] + dz * t, dx / segmentLength, dz / segmentLength); - } - } - } + private static boolean isReusableHeightGridLayout(int step, int gridSize) { + return step == 4 && gridSize == 24; + } - return null; + private static String sampleKoppenCode(int blockX, int blockZ, double worldScale) { + String koppen = KOPPEN_SOURCE.sampleDitheredCode(blockX, blockZ, worldScale); + return koppen != null ? koppen : KOPPEN_SOURCE.findNearestCode(blockX, blockZ, worldScale); } - private static EarthChunkGenerator.RoadLightAnchor findRoadLightAnchor( - EarthChunkGenerator.SampledRoadStation sampled, - boolean placeLeft, - int roadWidth, - int roadClassId, - int roadModeId, - int chunkMinX, - int chunkMinZ, - byte[] chunkRoadClass, - byte[] chunkRoadMode, - int[] chunkRoadDeckY + private void repairAnomalousChunkTerrain( + int[] terrainSurfaces, int[] waterSurfaces, boolean[] waterFlags, int[] coverClasses, int[] heightGrid, int gridSize, int step, int minY, int maxY ) { - double normalX = placeLeft ? -sampled.tangentZ() : sampled.tangentZ(); - double normalZ = placeLeft ? sampled.tangentX() : -sampled.tangentX(); - double scanRadius = Math.max(2.0, roadWidth + 2.0); - double alongTolerance = Math.max(1.25, roadWidth * 0.45); - int minLocalX = Math.max(0, quantizeRoadCoordinate(sampled.worldX() - scanRadius) - chunkMinX); - int maxLocalX = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldX() + scanRadius) - chunkMinX); - int minLocalZ = Math.max(0, quantizeRoadCoordinate(sampled.worldZ() - scanRadius) - chunkMinZ); - int maxLocalZ = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldZ() + scanRadius) - chunkMinZ); - EarthChunkGenerator.RoadLightAnchor bestAnchor = null; - double bestLateral = Double.NEGATIVE_INFINITY; - double bestAlong = Double.POSITIVE_INFINITY; - double bestDistanceSq = Double.POSITIVE_INFINITY; - double minLateral = Double.POSITIVE_INFINITY; - double maxLateral = Double.NEGATIVE_INFINITY; + int[] repaired = (int[])terrainSurfaces.clone(); - for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { - for (int localX = minLocalX; localX <= maxLocalX; localX++) { - int index = chunkIndex(localX, localZ); - if (chunkRoadClass[index] == roadClassId && chunkRoadMode[index] == roadModeId) { - double dx = chunkMinX + localX + 0.5 - sampled.worldX(); - double dz = chunkMinZ + localZ + 0.5 - sampled.worldZ(); - double along = dx * sampled.tangentX() + dz * sampled.tangentZ(); - if (!(Math.abs(along) > alongTolerance)) { - double lateral = dx * normalX + dz * normalZ; - minLateral = Math.min(minLateral, lateral); - maxLateral = Math.max(maxLateral, lateral); - if (!(lateral <= 0.05)) { - double distanceSq = dx * dx + dz * dz; - double absAlong = Math.abs(along); - if (lateral > bestLateral + 1.0E-6 - || Math.abs(lateral - bestLateral) <= 1.0E-6 && absAlong < bestAlong - 1.0E-6 - || Math.abs(lateral - bestLateral) <= 1.0E-6 && Math.abs(absAlong - bestAlong) <= 1.0E-6 && distanceSq < bestDistanceSq) { - bestLateral = lateral; - bestAlong = absAlong; - bestDistanceSq = distanceSq; - bestAnchor = new EarthChunkGenerator.RoadLightAnchor(localX, localZ, chunkRoadDeckY[index], index); - } + for (int pass = 0; pass < 4; pass++) { + int[] source = (int[])repaired.clone(); + boolean changed = false; + + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int index = chunkIndex(localX, localZ); + int surface = source[index]; + if (this.shouldRepairTerrainAnomaly(coverClasses[index], surface, waterFlags[index])) { + int gridIndex = (localZ + step) * gridSize + localX + step; + int east = sampleNeighborHeight(source, localX + 1, localZ, heightGrid, gridIndex + 1); + int west = sampleNeighborHeight(source, localX - 1, localZ, heightGrid, gridIndex - 1); + int north = sampleNeighborHeight(source, localX, localZ - 1, heightGrid, gridIndex - gridSize); + int south = sampleNeighborHeight(source, localX, localZ + 1, heightGrid, gridIndex + gridSize); + int northEast = sampleNeighborHeight(source, localX + 1, localZ - 1, heightGrid, gridIndex - gridSize + 1); + int northWest = sampleNeighborHeight(source, localX - 1, localZ - 1, heightGrid, gridIndex - gridSize - 1); + int southEast = sampleNeighborHeight(source, localX + 1, localZ + 1, heightGrid, gridIndex + gridSize + 1); + int southWest = sampleNeighborHeight(source, localX - 1, localZ + 1, heightGrid, gridIndex + gridSize - 1); + int repairedHeight = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); + if (repairedHeight != surface) { + repaired[index] = Mth.clamp(repairedHeight, minY, maxY); + changed = true; } } } } - } - - if (bestAnchor == null || minLateral == Double.POSITIVE_INFINITY || maxLateral == Double.NEGATIVE_INFINITY) { - return null; - } - double span = maxLateral - minLateral; - if (span < 0.75) { - return null; - } + for (int localZ = 0; localZ < 16; localZ++) { + for (int localXx = 0; localXx < 16; localXx++) { + int index = chunkIndex(localXx, localZ); + int gridIndex = (localZ + step) * gridSize + localXx + step; + heightGrid[gridIndex] = repaired[index]; + } + } - if (span > roadWidth + 0.75) { - return null; + if (!changed) { + break; + } } - return bestAnchor; - } - - private static boolean hasNearbyPreparedRoadLight( - int localX, int localZ, int minSpacingBlocks, EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights - ) { - if (preparedRoadLights == null || preparedRoadLights.isEmpty()) { - return false; - } else { - int minSpacingSq = minSpacingBlocks * minSpacingBlocks; + System.arraycopy(repaired, 0, terrainSurfaces, 0, repaired.length); - for (EarthChunkGenerator.PreparedRoadLight light : preparedRoadLights.lights()) { - int dx = light.localX() - localX; - int dz = light.localZ() - localZ; - if (dx * dx + dz * dz < minSpacingSq) { - return true; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localXx = 0; localXx < 16; localXx++) { + int index = chunkIndex(localXx, localZ); + if (!waterFlags[index]) { + waterSurfaces[index] = terrainSurfaces[index]; + } else if (terrainSurfaces[index] >= waterSurfaces[index]) { + waterFlags[index] = false; + waterSurfaces[index] = terrainSurfaces[index]; } - } - return false; + int gridIndex = (localZ + step) * gridSize + localXx + step; + heightGrid[gridIndex] = terrainSurfaces[index]; + } } } - private static boolean intersectsRoadLightBridgeSupport( - int localX, - int localZ, - int minY, - int maxY, - boolean[] bridgeSupportShaftPresent, - int[] bridgeSupportShaftBottomY, - int[] bridgeSupportShaftTopY, - boolean[] bridgeSupportCapPresent, - int[] bridgeSupportCapBottomY, - int[] bridgeSupportCapTopY - ) { - int index = chunkIndex(localX, localZ); - return bridgeSupportShaftPresent[index] && spansOverlap(minY, maxY, bridgeSupportShaftBottomY[index], bridgeSupportShaftTopY[index]) - || bridgeSupportCapPresent[index] && spansOverlap(minY, maxY, bridgeSupportCapBottomY[index], bridgeSupportCapTopY[index]); - } - - private static boolean spansOverlap(int minY, int maxY, int otherMinY, int otherMaxY) { - return maxY >= otherMinY && minY <= otherMaxY; + private static int sampleNeighborHeight(int[] chunkSurfaces, int localX, int localZ, int[] heightGrid, int fallbackGridIndex) { + return localX >= 0 && localX < 16 && localZ >= 0 && localZ < 16 ? chunkSurfaces[chunkIndex(localX, localZ)] : heightGrid[fallbackGridIndex]; } - private static int quantizeRoadCoordinate(double value) { - return Mth.floor(value + 0.5); + private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY) { + return this.repairAnomalousSurfaceHeight(worldX, worldZ, surface, coverClass, minY, maxY, false); } - private static Direction dominantHorizontalDirection(double tangentX, double tangentZ) { - if (Math.abs(tangentX) >= Math.abs(tangentZ)) { - return tangentX >= 0.0 ? Direction.EAST : Direction.WEST; + private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY, boolean hasWater) { + if (!this.shouldRepairTerrainAnomaly(coverClass, surface, hasWater)) { + return Mth.clamp(surface, minY, maxY); } else { - return tangentZ >= 0.0 ? Direction.SOUTH : Direction.NORTH; + int east = this.sampleSurfaceHeight(worldX + 1, worldZ); + int west = this.sampleSurfaceHeight(worldX - 1, worldZ); + int north = this.sampleSurfaceHeight(worldX, worldZ - 1); + int south = this.sampleSurfaceHeight(worldX, worldZ + 1); + int northEast = this.sampleSurfaceHeight(worldX + 1, worldZ - 1); + int northWest = this.sampleSurfaceHeight(worldX - 1, worldZ - 1); + int southEast = this.sampleSurfaceHeight(worldX + 1, worldZ + 1); + int southWest = this.sampleSurfaceHeight(worldX - 1, worldZ + 1); + int repaired = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); + return Mth.clamp(repaired, minY, maxY); } } - private static BlockState roadLightTrapdoorState(Direction facing) { - return (BlockState)ROAD_LIGHT_TRAPDOOR_BASE_STATE.setValue(BlockStateProperties.HORIZONTAL_FACING, facing); + private boolean shouldRepairTerrainAnomaly(int coverClass, int surface, boolean hasWater) { + int heightAboveSea = surface - this.seaLevel; + return heightAboveSea < 50 ? false : !hasWater && !isWaterCoverClass(coverClass) || heightAboveSea >= 90; } - private static boolean isRoadDeckState(BlockState state) { - return state.is(Blocks.GRAY_CONCRETE) || state.is(Blocks.CYAN_TERRACOTTA) || state.is(Blocks.DIRT_PATH); + private static boolean isWaterCoverClass(int coverClass) { + return coverClass == 80 || coverClass == 95; } - private static boolean isRoadLightReplaceable(BlockState state) { - return state.isAir() - || state.is(Blocks.SNOW) - || state.is(Blocks.POWDER_SNOW) - || state.getFluidState().isEmpty() && state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + private static int repairAnomalousTerrainHeightFromNeighbors( + int center, int east, int west, int north, int south, int northEast, int northWest, int southEast, int southWest + ) { + return TerrainAnomalyRepair.repairHeightFromNeighbors(center, east, west, north, south, northEast, northWest, southEast, southWest); } - private static boolean isTunnelCarveReplaceable(BlockState state) { - return isReplaceableCaveBlock(state) && !isRoadDeckState(state) && !state.is(BRIDGE_SUPPORT_SHAFT_STATE.getBlock()) && !state.is(BRIDGE_SUPPORT_CAP_STATE.getBlock()); + private static int resolveSolidSectionMaxIndex(ChunkAccess chunk, int chunkMinY, int minSurface, int sectionCount) { + if (sectionCount == 0) { + return -1; + } else { + int sectionIndex = chunk.getSectionIndex(minSurface); + int sectionBottom = chunkMinY + (sectionIndex << 4); + int sectionTop = sectionBottom + 15; + int solidMaxIndex = minSurface >= sectionTop ? sectionIndex : sectionIndex - 1; + return solidMaxIndex < 0 ? -1 : Math.min(solidMaxIndex, sectionCount - 1); + } } - private static boolean[] computeFloodGuardColumns(boolean[] waterFlags) { - boolean[] result = new boolean[CHUNK_AREA]; - - for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { - for (int localX = 0; localX < CHUNK_SIDE; localX++) { - boolean nearWater = false; - - for (int dz = -2; dz <= 2 && !nearWater; dz++) { - int z = localZ + dz; - if (z >= 0 && z < CHUNK_SIDE) { - for (int dx = -2; dx <= 2; dx++) { - int x = localX + dx; - if (x >= 0 && x < CHUNK_SIDE && waterFlags[chunkIndex(x, z)]) { - nearWater = true; - break; - } - } - } - } + private static void fillSolidSections( + LevelChunkSection[] sections, + boolean[] solidSections, + int[] sectionTopYs, + int solidMaxIndex, + BlockState stone, + BlockState deepslate, + int deepslateStart, + EarthChunkGenerator.SolidSectionFillProfiler profiler + ) { + int sectionCount = sections.length; + long sectionScanStartNs = beginFullChunkProfiling(); - result[chunkIndex(localX, localZ)] = nearWater; + for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { + int topY = sectionTopYs[i]; + int bottomY = topY - 15; + if (bottomY >= 0 || topY < 0) { + BlockState fill = topY < deepslateStart ? deepslate : stone; + long sectionAccessStartNs = beginFullChunkProfiling(); + LevelChunkSection section = Objects.requireNonNull(sections[i], "section"); + profiler.sectionAccessNs += elapsedFullChunkProfilingSince(sectionAccessStartNs); + fillSection(section, fill, profiler); + solidSections[i] = true; } } - return result; - } - - private static boolean isReplaceableCaveBlock(BlockState state) { - return isSolidCaveAnchor(state) && !state.is(Blocks.BEDROCK); + long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); + long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; + profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private static boolean isSolidCaveAnchor(BlockState state) { - return !state.isAir() && state.getFluidState().isEmpty() && !state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); - } + private static void fillSolidSections( + EarthChunkGenerator.ChunkSectionWriter writer, + boolean[] solidSections, + int[] sectionTopYs, + int solidMaxIndex, + BlockState stone, + BlockState deepslate, + int deepslateStart, + EarthChunkGenerator.SolidSectionFillProfiler profiler + ) { + int sectionCount = sectionTopYs.length; + long sectionScanStartNs = beginFullChunkProfiling(); - private static int chunkIndex(int localX, int localZ) { - return localZ * CHUNK_SIDE + localX; - } + for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { + int topY = sectionTopYs[i]; + int bottomY = topY - 15; + if (bottomY >= 0 || topY < 0) { + BlockState fill = topY < deepslateStart ? deepslate : stone; + writer.fillSectionConstant(i, fill, profiler); + solidSections[i] = true; + } + } - @SuppressWarnings("unchecked") - private static Holder[] newBiomeCache(int size) { - return (Holder[])new Holder[size]; + long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); + long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; + profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private void carveStructureClearanceVolumes(StructureManager structures, ChunkAccess chunk) { - List starts = structures.startsForStructure( - chunk.getPos(), structure -> shouldApplyStructureTerrainAdjustment(structure.terrainAdaptation()) - ); - if (!starts.isEmpty()) { - ChunkPos pos = chunk.getPos(); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); - int chunkMaxX = chunkMinX + 15; - int chunkMaxZ = chunkMinZ + 15; - int chunkMinY = chunk.getMinBuildHeight(); - int chunkMaxY = chunkMinY + chunk.getHeight() - 1; - MutableBlockPos cursor = new MutableBlockPos(); + private static void fillSection(LevelChunkSection section, BlockState fill, EarthChunkGenerator.SolidSectionFillProfiler profiler) { + PalettedContainer states = section.getStates(); + long sectionWriteStartNs = beginFullChunkProfiling(); + section.acquire(); - for (StructureStart start : starts) { - if (start != null && start.isValid()) { - for (StructurePiece piece : start.getPieces()) { - BoundingBox box = piece.getBoundingBox(); - if (box.intersects(chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ)) { - int centerX = box.minX() + box.maxX() >> 1; - int centerZ = box.minZ() + box.maxZ() >> 1; - int terrainSurface = this.resolveAuxWaterColumn(centerX, centerZ).terrainSurface(); - if (box.maxY() <= terrainSurface - 20) { - int coreMinX = box.minX() - 1; - int coreMaxX = box.maxX() + 1; - int coreMinZ = box.minZ() - 1; - int coreMaxZ = box.maxZ() + 1; - int coreMinY = box.minY() - 0; - int coreMaxY = box.maxY() + 0; - int minX = Math.max(chunkMinX, coreMinX - 6); - int maxX = Math.min(chunkMaxX, coreMaxX + 6); - int minZ = Math.max(chunkMinZ, coreMinZ - 6); - int maxZ = Math.min(chunkMaxZ, coreMaxZ + 6); - int minY = Math.max(chunkMinY + 1, coreMinY - 0); - int maxY = Math.min(chunkMaxY - 1, coreMaxY + 4); - if (maxY >= minY && maxX >= minX && maxZ >= minZ) { - for (int z = minZ; z <= maxZ; z++) { - for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - double nx = axisDistanceNormalized(x, coreMinX, coreMaxX, 6); - double nz = axisDistanceNormalized(z, coreMinZ, coreMaxZ, 6); - double ny = axisDistanceNormalized(y, coreMinY, coreMaxY, 0, 4); - double distance = Math.sqrt(nx * nx + ny * ny + nz * nz); - double threshold = 1.0 + this.structureClearanceNoiseJitter(x, y, z) * 0.22; - if (!(distance > threshold)) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (isReplaceableCaveBlock(state)) { - chunk.setBlockState(cursor, CAVE_AIR_STATE, false); - } - } - } - } - } - } - } - } + try { + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + states.getAndSetUnchecked(x, y, z, fill); } } } + } finally { + section.release(); } - } + profiler.sectionWriteNs += elapsedFullChunkProfilingSince(sectionWriteStartNs); - private double structureClearanceNoiseJitter(int x, int y, int z) { - long seed = seedFromCoords(x, y, z) ^ this.worldSeed ^ 7951840804584193857L; - double t = Math.floorMod(seed, 2048L) / 2047.0; - return t * 2.0 - 1.0; + long sectionRecalcStartNs = beginFullChunkProfiling(); + section.recalcBlockCounts(); + profiler.recalcNs += elapsedFullChunkProfilingSince(sectionRecalcStartNs); } - private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadius) { - if (value < coreMin) { - return (double)(coreMin - value) / Math.max(1, shellRadius); - } else { - return value > coreMax ? (double)(value - coreMax) / Math.max(1, shellRadius) : 0.0; + private static void fillStoneColumnSpan( + LevelChunkSection[] sections, + boolean[] touchedSections, + int chunkMinY, + int localX, + int localZ, + int startY, + int endY, + int deepslateStart, + BlockState stone, + BlockState deepslate + ) { + if (endY < startY) { + return; } - } - private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadiusBelow, int shellRadiusAbove) { - if (value < coreMin) { - return shellRadiusBelow <= 0 ? Double.POSITIVE_INFINITY : (double)(coreMin - value) / shellRadiusBelow; - } else if (value > coreMax) { - return shellRadiusAbove <= 0 ? Double.POSITIVE_INFINITY : (double)(value - coreMax) / shellRadiusAbove; - } else { - return 0.0; + int sectionIndex = (startY - chunkMinY) >> 4; + if (sectionIndex < 0 || sectionIndex >= sections.length) { + return; } - } - - private static boolean shouldApplyStructureTerrainAdjustment(TerrainAdjustment adjustment) { - return adjustment == TerrainAdjustment.BEARD_THIN || adjustment == TerrainAdjustment.BEARD_BOX; - } - private static int minSurfaceHeight(int[] terrainSurfaces) { - int min = Integer.MAX_VALUE; + LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); + PalettedContainer states = section.getStates(); + int sectionBottomY = chunkMinY + (sectionIndex << 4); + section.acquire(); - for (int surface : terrainSurfaces) { - if (surface < min) { - min = surface; + try { + for (int worldY = startY; worldY <= endY; worldY++) { + states.getAndSetUnchecked(localX, worldY - sectionBottomY, localZ, worldY < deepslateStart ? deepslate : stone); } + } finally { + section.release(); } - return min == Integer.MAX_VALUE ? 0 : min; + touchedSections[sectionIndex] = true; } - private EarthChunkGenerator.HeightGridBuildResult buildHeightGrid( - ChunkPos pos, int step, int gridSize, boolean allowCacheReuse, boolean useLocalTerrainInputs + private static void fillColumnConstant( + LevelChunkSection[] sections, boolean[] touchedSections, int chunkMinY, int localX, int localZ, int startY, int endY, BlockState state ) { - int[] heightGrid = new int[gridSize * gridSize]; - int cacheHits = 0; - int cacheMisses = 0; - boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); - if (reusableLayout) { - Arrays.fill(heightGrid, Integer.MIN_VALUE); - cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, false); + if (endY < startY) { + return; } - int gridMinX = pos.getMinBlockX() - step; - int gridMinZ = pos.getMinBlockZ() - step; - for (int dz = 0; dz < gridSize; dz++) { - int worldZ = gridMinZ + dz; - int row = dz * gridSize; + int currentY = startY; + while (currentY <= endY) { + int sectionIndex = (currentY - chunkMinY) >> 4; + if (sectionIndex < 0 || sectionIndex >= sections.length) { + break; + } - for (int dx = 0; dx < gridSize; dx++) { - int index = row + dx; - if (!reusableLayout || heightGrid[index] == Integer.MIN_VALUE) { - int worldX = gridMinX + dx; - heightGrid[index] = useLocalTerrainInputs ? this.sampleSurfaceHeightLocalOnly(worldX, worldZ) : this.sampleSurfaceHeight(worldX, worldZ); - cacheMisses++; + LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); + PalettedContainer states = section.getStates(); + int sectionBottomY = chunkMinY + (sectionIndex << 4); + int localStartY = Math.max(0, currentY - sectionBottomY); + int localEndY = Math.min(15, endY - sectionBottomY); + section.acquire(); + + try { + for (int localY = localStartY; localY <= localEndY; localY++) { + states.getAndSetUnchecked(localX, localY, localZ, state); } + } finally { + section.release(); } - } - if (reusableLayout) { - this.heightGridCache.put(pos, step, gridSize, heightGrid, false); + touchedSections[sectionIndex] = true; + currentY = sectionBottomY + 16; } - - return new EarthChunkGenerator.HeightGridBuildResult(heightGrid, cacheHits, cacheMisses); } - private EarthChunkGenerator.TerrainShellHeightGridResult buildTerrainShellHeightGrid(ChunkPos pos, int step, int gridSize, boolean allowCacheReuse) { - int[] heightGrid = new int[gridSize * gridSize]; - Arrays.fill(heightGrid, Integer.MIN_VALUE); - int cacheHits = 0; - boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); - if (reusableLayout) { - cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, true); + private static void recalcFilledSections(LevelChunkSection[] sections, boolean[] solidSections, boolean[] touchedSections) { + for (int i = 0; i < sections.length && i < touchedSections.length; i++) { + if (touchedSections[i] && !solidSections[i]) { + Objects.requireNonNull(sections[i], "section").recalcBlockCounts(); + } } + } - int initialMisses = 0; - int gridMinX = pos.getMinBlockX() - step; - int gridMinZ = pos.getMinBlockZ() - step; - for (int dz = 0; dz < gridSize; dz++) { - int worldZ = gridMinZ + dz; - int row = dz * gridSize; + private void filterVillageStarts(RegistryAccess registryAccess, ChunkAccess chunk) { + Map starts = chunk.getAllStarts(); + if (!starts.isEmpty()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - for (int dx = 0; dx < gridSize; dx++) { - int index = row + dx; - if (heightGrid[index] == Integer.MIN_VALUE) { - int worldX = gridMinX + dx; - int sampled = this.sampleSurfaceHeightMemoryOnly(worldX, worldZ); - if (sampled != Integer.MIN_VALUE) { - heightGrid[index] = sampled; - } else { - initialMisses++; + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.isVillageStructure(registry, structure) && this.isVillageStartTooSteep(start)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); } } } } - - boolean usedFallback = initialMisses > 0; - if (usedFallback) { - this.fillMissingTerrainShellHeights(heightGrid, gridSize); - } - - if (reusableLayout) { - this.heightGridCache.put(pos, step, gridSize, heightGrid, usedFallback); - } - - return new EarthChunkGenerator.TerrainShellHeightGridResult(heightGrid, cacheHits, initialMisses, usedFallback); } - private void fillMissingTerrainShellHeights(int[] heightGrid, int gridSize) { - int[] anchors = buildShellAnchorCoordinates(gridSize); - int[][] coarse = new int[anchors.length][anchors.length]; - for (int z = 0; z < coarse.length; z++) { - Arrays.fill(coarse[z], Integer.MIN_VALUE); - } + private boolean isVillageStartTooSteep(StructureStart start) { + return this.isStructureStartTooSteep(start, 4, 4, 6); + } - for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { - for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { - coarse[anchorZIndex][anchorXIndex] = nearestKnownTerrainHeight(heightGrid, gridSize, anchors[anchorXIndex], anchors[anchorZIndex], 2); - } - } + private void filterWoodlandMansionStarts(RegistryAccess registryAccess, ChunkAccess chunk) { + Map starts = chunk.getAllStarts(); + if (!starts.isEmpty()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - int defaultHeight = this.seaLevel; - int knownAnchorCount = 0; - long knownAnchorSum = 0L; - for (int[] coarseRow : coarse) { - for (int coarseHeight : coarseRow) { - if (coarseHeight != Integer.MIN_VALUE) { - knownAnchorSum += coarseHeight; - knownAnchorCount++; + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.isWoodlandMansionStructure(registry, structure) && this.isWoodlandMansionStartTooSteep(start)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + } } } } + } - if (knownAnchorCount > 0) { - defaultHeight = Mth.floor((double)knownAnchorSum / knownAnchorCount); - } + private boolean isWoodlandMansionStartTooSteep(StructureStart start) { + return this.isStructureStartTooSteep(start, 8, 6, 8); + } - defaultHeight = Mth.clamp(defaultHeight, this.minY, this.minY + this.height - 1); - for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { - for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { - if (coarse[anchorZIndex][anchorXIndex] == Integer.MIN_VALUE) { - int replacement = nearestKnownAnchorHeight(coarse, anchorXIndex, anchorZIndex); - coarse[anchorZIndex][anchorXIndex] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; + private void filterStartsCollidingWithOsm(RegistryAccess registryAccess, ChunkAccess chunk) { + double worldScale = this.settings.worldScale(); + 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()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); + EarthChunkGenerator.RoadWidths roadWidths = roadsActive ? resolveRoadWidths(worldScale) : null; + + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.shouldAvoidOsmCollision(registry, structure) + && this.doesStructureStartCollideWithOsm(start, roadsActive, buildingsActive, roadWidths)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + } + } } } } + } - for (int z = 0; z < gridSize; z++) { - for (int x = 0; x < gridSize; x++) { - int index = z * gridSize + x; - if (heightGrid[index] != Integer.MIN_VALUE) { - continue; - } + private boolean doesStructureStartCollideWithOsm( + StructureStart start, boolean roadsActive, boolean buildingsActive, EarthChunkGenerator.RoadWidths roadWidths + ) { + BoundingBox box = start.getBoundingBox(); + if (buildingsActive && this.structureCollidesWithBuildings(box)) { + return true; + } else { + return roadsActive && roadWidths != null ? this.structureCollidesWithRoads(box, roadWidths) : false; + } + } - int lowAnchorX = lowerAnchorIndex(anchors, x); - int highAnchorX = upperAnchorIndex(anchors, x); - int lowAnchorZ = lowerAnchorIndex(anchors, z); - int highAnchorZ = upperAnchorIndex(anchors, z); - int h00 = coarse[lowAnchorZ][lowAnchorX]; - int h10 = coarse[lowAnchorZ][highAnchorX]; - int h01 = coarse[highAnchorZ][lowAnchorX]; - int h11 = coarse[highAnchorZ][highAnchorX]; - heightGrid[index] = bilinearInterpolateHeight( - anchors[lowAnchorX], anchors[highAnchorX], anchors[lowAnchorZ], anchors[highAnchorZ], h00, h10, h01, h11, x, z - ); - } + private boolean structureCollidesWithBuildings(BoundingBox box) { + int marginBlocks = 2; + double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); + double minX = structureFootprintMinX(box); + double maxX = structureFootprintMaxX(box); + double minZ = structureFootprintMinZ(box); + double maxZ = structureFootprintMaxZ(box); + OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; + EarthChunkGenerator.OsmBuildingQueryResult query = this.fetchOsmBuildingsForAreaDetailed( + box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode + ); + if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { + EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); + return false; } - for (int z = 0; z < gridSize; z++) { - for (int x = 0; x < gridSize; x++) { - int index = z * gridSize + x; - if (heightGrid[index] == Integer.MIN_VALUE) { - int replacement = nearestKnownTerrainHeight(heightGrid, gridSize, x, z, gridSize); - heightGrid[index] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; - } + for (OsmBuildingFeature building : query.features()) { + if (this.structureIntersectsBuilding(box, building, blocksPerDegree, minX, minZ, maxX, maxZ)) { + return true; } } + + return false; } - private static int[] buildShellAnchorCoordinates(int gridSize) { - IntArrayList coords = new IntArrayList(); - for (int index = 0; index < gridSize; index += 4) { - coords.add(index); + private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { + 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 + ); + if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { + EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); + return false; } - if (coords.isEmpty() || coords.getInt(coords.size() - 1) != gridSize - 1) { - coords.add(gridSize - 1); + double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); + double worldScale = this.settings.worldScale(); + + for (RoadFeature road : query.features()) { + if (road.mode() != RoadMode.TUNNEL && this.structureIntersectsRoad(box, road, roadWidths, blocksPerDegree, worldScale)) { + return true; + } } - return coords.toIntArray(); + return false; } - private static int nearestKnownAnchorHeight(int[][] anchors, int centerX, int centerZ) { - int maxRadius = Math.max(anchors.length, anchors[0].length); - for (int radius = 1; radius <= maxRadius; radius++) { - long sum = 0L; - int count = 0; - for (int z = Math.max(0, centerZ - radius); z <= Math.min(anchors.length - 1, centerZ + radius); z++) { - for (int x = Math.max(0, centerX - radius); x <= Math.min(anchors[z].length - 1, centerX + radius); x++) { - int value = anchors[z][x]; - if (value != Integer.MIN_VALUE) { - sum += value; - count++; + private boolean structureIntersectsBuilding( + BoundingBox box, OsmBuildingFeature building, double blocksPerDegree, double minX, double minZ, double maxX, double maxZ + ) { + if (building.maxBlockX(blocksPerDegree) < minX + || building.minBlockX(blocksPerDegree) > maxX + || building.maxBlockZ(this.settings.worldScale()) < minZ + || building.minBlockZ(this.settings.worldScale()) > maxZ) { + return false; + } else if (building.containsWorld(minX, minZ, this.settings.worldScale()) + || building.containsWorld(minX, maxZ, this.settings.worldScale()) + || building.containsWorld(maxX, minZ, this.settings.worldScale()) + || building.containsWorld(maxX, maxZ, this.settings.worldScale())) { + return true; + } else { + for (int part = 0; part < building.partCount(); part++) { + int points = building.pointCount(part); + if (points >= 2) { + double previousX = building.lonAt(part, points - 1) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(building.latAt(part, points - 1), this.settings.worldScale()); + + for (int i = 0; i < points; i++) { + double currentX = building.lonAt(part, i) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(building.latAt(part, i), this.settings.worldScale()); + if (pointInRect(currentX, currentZ, minX, minZ, maxX, maxZ) + || segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { + return true; + } + + previousX = currentX; + previousZ = currentZ; } } } - if (count > 0) { - return Mth.floor((double)sum / count); - } + return false; } - - return Integer.MIN_VALUE; } - private static int nearestKnownTerrainHeight(int[] heightGrid, int gridSize, int centerX, int centerZ, int maxRadius) { - if (centerX >= 0 && centerX < gridSize && centerZ >= 0 && centerZ < gridSize) { - int center = heightGrid[centerZ * gridSize + centerX]; - if (center != Integer.MIN_VALUE) { - return center; - } - } - - for (int radius = 1; radius <= maxRadius; radius++) { - long sum = 0L; - int count = 0; - for (int z = Math.max(0, centerZ - radius); z <= Math.min(gridSize - 1, centerZ + radius); z++) { - for (int x = Math.max(0, centerX - radius); x <= Math.min(gridSize - 1, centerX + radius); x++) { - int value = heightGrid[z * gridSize + x]; - if (value != Integer.MIN_VALUE) { - sum += value; - count++; + private boolean structureIntersectsRoad( + BoundingBox box, RoadFeature road, EarthChunkGenerator.RoadWidths roadWidths, double blocksPerDegree, double worldScale + ) { + 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; + double minZ = structureFootprintMinZ(box) - halfWidth; + double maxZ = structureFootprintMaxZ(box) + halfWidth; + int pointCount = road.pointCount(); + if (pointCount < 2) { + return false; + } else { + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + if (pointInRect(previousX, previousZ, minX, minZ, maxX, maxZ)) { + return true; + } else { + for (int i = 1; i < pointCount; i++) { + double currentX = road.lonAt(i) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(i), worldScale); + if (segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { + return true; } + + previousX = currentX; + previousZ = currentZ; } - } - if (count > 0) { - return Mth.floor((double)sum / count); + return false; } } - - return Integer.MIN_VALUE; } - private static int lowerAnchorIndex(int[] anchors, int value) { - int best = 0; - for (int index = 0; index < anchors.length; index++) { - if (anchors[index] > value) { - break; - } - - best = index; + private boolean shouldAvoidOsmCollision(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + if (key == null) { + return false; + } else { + String path = key.getPath(); + return path.startsWith("village") + || path.equals("woodland_mansion") + || path.equals("desert_pyramid") + || path.equals("desert_temple") + || path.equals("jungle_pyramid") + || path.equals("jungle_temple") + || path.equals("pillager_outpost") + || path.equals("igloo") + || path.equals("swamp_hut") + || path.equals("witch_hut") + || path.startsWith("ruined_portal") + || path.startsWith("trail_ruins"); } - - return best; } - private static int upperAnchorIndex(int[] anchors, int value) { - for (int index = 0; index < anchors.length; index++) { - if (anchors[index] >= value) { - return index; - } - } + private static double structureFootprintMinX(BoundingBox box) { + return box.minX() - 0.5; + } - return anchors.length - 1; + private static double structureFootprintMaxX(BoundingBox box) { + return box.maxX() + 0.5; } - private static int bilinearInterpolateHeight( - int x0, int x1, int z0, int z1, int h00, int h10, int h01, int h11, int x, int z - ) { - if (x0 == x1 && z0 == z1) { - return h00; - } else if (x0 == x1) { - double tz = (z - z0) / (double)Math.max(1, z1 - z0); - return Mth.floor(Mth.lerp(tz, h00, h01)); - } else if (z0 == z1) { - double tx = (x - x0) / (double)Math.max(1, x1 - x0); - return Mth.floor(Mth.lerp(tx, h00, h10)); - } else { - double tx = (x - x0) / (double)Math.max(1, x1 - x0); - double tz = (z - z0) / (double)Math.max(1, z1 - z0); - double low = Mth.lerp(tx, h00, h10); - double high = Mth.lerp(tx, h01, h11); - return Mth.floor(Mth.lerp(tz, low, high)); - } + private static double structureFootprintMinZ(BoundingBox box) { + return box.minZ() - 0.5; } - private static boolean isReusableHeightGridLayout(int step, int gridSize) { - return step == 4 && gridSize == 24; + private static double structureFootprintMaxZ(BoundingBox box) { + return box.maxZ() + 0.5; } - private static String sampleKoppenCode(int blockX, int blockZ, double worldScale) { - String koppen = KOPPEN_SOURCE.sampleDitheredCode(blockX, blockZ, worldScale); - return koppen != null ? koppen : KOPPEN_SOURCE.findNearestCode(blockX, blockZ, worldScale); + private static boolean pointInRect(double x, double z, double minX, double minZ, double maxX, double maxZ) { + return x >= minX && x <= maxX && z >= minZ && z <= maxZ; } - private void repairAnomalousChunkTerrain( - int[] terrainSurfaces, int[] waterSurfaces, boolean[] waterFlags, int[] coverClasses, int[] heightGrid, int gridSize, int step, int minY, int maxY - ) { - int[] repaired = (int[])terrainSurfaces.clone(); + private static boolean segmentIntersectsRect(double x1, double z1, double x2, double z2, double minX, double minZ, double maxX, double maxZ) { + return pointInRect(x1, z1, minX, minZ, maxX, maxZ) + || pointInRect(x2, z2, minX, minZ, maxX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, minX, minZ, maxX, minZ) + || segmentsIntersect(x1, z1, x2, z2, maxX, minZ, maxX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, maxX, maxZ, minX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, minX, maxZ, minX, minZ); + } - for (int pass = 0; pass < 4; pass++) { - int[] source = (int[])repaired.clone(); - boolean changed = false; + private static boolean segmentsIntersect(double ax, double az, double bx, double bz, double cx, double cz, double dx, double dz) { + double abx = bx - ax; + double abz = bz - az; + double acx = cx - ax; + double acz = cz - az; + double adx = dx - ax; + double adz = dz - az; + double cdx = dx - cx; + double cdz = dz - cz; + double cax = ax - cx; + double caz = az - cz; + double cbx = bx - cx; + double cbz = bz - cz; + double cross1 = cross(abx, abz, acx, acz); + double cross2 = cross(abx, abz, adx, adz); + double cross3 = cross(cdx, cdz, cax, caz); + double cross4 = cross(cdx, cdz, cbx, cbz); + double epsilon = 1.0E-7; + if (Math.abs(cross1) <= epsilon && onSegment(ax, az, bx, bz, cx, cz)) { + return true; + } else if (Math.abs(cross2) <= epsilon && onSegment(ax, az, bx, bz, dx, dz)) { + return true; + } else if (Math.abs(cross3) <= epsilon && onSegment(cx, cz, dx, dz, ax, az)) { + return true; + } else if (Math.abs(cross4) <= epsilon && onSegment(cx, cz, dx, dz, bx, bz)) { + return true; + } else { + return cross1 > 0.0 != cross2 > 0.0 && cross3 > 0.0 != cross4 > 0.0; + } + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localX = 0; localX < 16; localX++) { - int index = chunkIndex(localX, localZ); - int surface = source[index]; - if (this.shouldRepairTerrainAnomaly(coverClasses[index], surface, waterFlags[index])) { - int gridIndex = (localZ + step) * gridSize + localX + step; - int east = sampleNeighborHeight(source, localX + 1, localZ, heightGrid, gridIndex + 1); - int west = sampleNeighborHeight(source, localX - 1, localZ, heightGrid, gridIndex - 1); - int north = sampleNeighborHeight(source, localX, localZ - 1, heightGrid, gridIndex - gridSize); - int south = sampleNeighborHeight(source, localX, localZ + 1, heightGrid, gridIndex + gridSize); - int northEast = sampleNeighborHeight(source, localX + 1, localZ - 1, heightGrid, gridIndex - gridSize + 1); - int northWest = sampleNeighborHeight(source, localX - 1, localZ - 1, heightGrid, gridIndex - gridSize - 1); - int southEast = sampleNeighborHeight(source, localX + 1, localZ + 1, heightGrid, gridIndex + gridSize + 1); - int southWest = sampleNeighborHeight(source, localX - 1, localZ + 1, heightGrid, gridIndex + gridSize - 1); - int repairedHeight = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); - if (repairedHeight != surface) { - repaired[index] = Mth.clamp(repairedHeight, minY, maxY); - changed = true; - } - } - } - } + private static double cross(double ax, double az, double bx, double bz) { + return ax * bz - az * bx; + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localXx = 0; localXx < 16; localXx++) { - int index = chunkIndex(localXx, localZ); - int gridIndex = (localZ + step) * gridSize + localXx + step; - heightGrid[gridIndex] = repaired[index]; - } - } + private static boolean onSegment(double ax, double az, double bx, double bz, double px, double pz) { + return px >= Math.min(ax, bx) - 1.0E-7 + && px <= Math.max(ax, bx) + 1.0E-7 + && pz >= Math.min(az, bz) - 1.0E-7 + && pz <= Math.max(az, bz) + 1.0E-7; + } - if (!changed) { - break; - } - } + private boolean isStructureStartTooSteep(StructureStart start, int margin, int sampleStep, int maxHeightDelta) { + BoundingBox box = start.getBoundingBox(); + int minX = box.minX() - margin; + int maxX = box.maxX() + margin; + int minZ = box.minZ() - margin; + int maxZ = box.maxZ() + margin; + int minHeight = Integer.MAX_VALUE; + int maxHeight = Integer.MIN_VALUE; + int stride = Math.max(1, sampleStep); - System.arraycopy(repaired, 0, terrainSurfaces, 0, repaired.length); + for (int z = minZ; z <= maxZ; z += stride) { + for (int x = minX; x <= maxX; x += stride) { + int surface = this.sampleSurfaceHeight(x, z); + if (surface < minHeight) { + minHeight = surface; + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localXx = 0; localXx < 16; localXx++) { - int index = chunkIndex(localXx, localZ); - if (!waterFlags[index]) { - waterSurfaces[index] = terrainSurfaces[index]; - } else if (terrainSurfaces[index] >= waterSurfaces[index]) { - waterFlags[index] = false; - waterSurfaces[index] = terrainSurfaces[index]; + if (surface > maxHeight) { + maxHeight = surface; } - int gridIndex = (localZ + step) * gridSize + localXx + step; - heightGrid[gridIndex] = terrainSurfaces[index]; + if (maxHeight - minHeight > maxHeightDelta) { + return true; + } } } - } - private static int sampleNeighborHeight(int[] chunkSurfaces, int localX, int localZ, int[] heightGrid, int fallbackGridIndex) { - return localX >= 0 && localX < 16 && localZ >= 0 && localZ < 16 ? chunkSurfaces[chunkIndex(localX, localZ)] : heightGrid[fallbackGridIndex]; + return maxHeight - minHeight > maxHeightDelta; } - private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY) { - return this.repairAnomalousSurfaceHeight(worldX, worldZ, surface, coverClass, minY, maxY, false); + private boolean isVillageStructure(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + return key != null && key.getPath().startsWith("village"); } - private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY, boolean hasWater) { - if (!this.shouldRepairTerrainAnomaly(coverClass, surface, hasWater)) { - return Mth.clamp(surface, minY, maxY); - } else { - int east = this.sampleSurfaceHeight(worldX + 1, worldZ); - int west = this.sampleSurfaceHeight(worldX - 1, worldZ); - int north = this.sampleSurfaceHeight(worldX, worldZ - 1); - int south = this.sampleSurfaceHeight(worldX, worldZ + 1); - int northEast = this.sampleSurfaceHeight(worldX + 1, worldZ - 1); - int northWest = this.sampleSurfaceHeight(worldX - 1, worldZ - 1); - int southEast = this.sampleSurfaceHeight(worldX + 1, worldZ + 1); - int southWest = this.sampleSurfaceHeight(worldX - 1, worldZ + 1); - int repaired = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); - return Mth.clamp(repaired, minY, maxY); - } + private boolean isWoodlandMansionStructure(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + return key != null && key.getPath().equals("woodland_mansion"); } - private boolean shouldRepairTerrainAnomaly(int coverClass, int surface, boolean hasWater) { - int heightAboveSea = surface - this.seaLevel; - return heightAboveSea < 50 ? false : !hasWater && !isWaterCoverClass(coverClass) || heightAboveSea >= 90; + public int getGenDepth() { + return this.height; } - private static boolean isWaterCoverClass(int coverClass) { - return coverClass == 80 || coverClass == 95; + public int getSeaLevel() { + return this.seaLevel; } - private static int repairAnomalousTerrainHeightFromNeighbors( - int center, int east, int west, int north, int south, int northEast, int northWest, int southEast, int southWest - ) { - return TerrainAnomalyRepair.repairHeightFromNeighbors(center, east, west, north, south, northEast, northWest, southEast, southWest); + public int getMinY() { + return this.minY; } - private static int resolveSolidSectionMaxIndex(ChunkAccess chunk, int chunkMinY, int minSurface, int sectionCount) { - if (sectionCount == 0) { - return -1; + public int getBaseHeight(int x, int z, Types heightmapType, LevelHeightAccessor heightAccessor, RandomState random) { + if (this.isFastSpawnMode()) { + int maxY = heightAccessor.getMaxBuildHeight() - 1; + return Mth.clamp(this.seaLevel + 1, heightAccessor.getMinBuildHeight(), maxY); } else { - int sectionIndex = chunk.getSectionIndex(minSurface); - int sectionBottom = chunkMinY + (sectionIndex << 4); - int sectionTop = sectionBottom + 15; - int solidMaxIndex = minSurface >= sectionTop ? sectionIndex : sectionIndex - 1; - return solidMaxIndex < 0 ? -1 : Math.min(solidMaxIndex, sectionCount - 1); + int coverClass = this.sampleCoverClass(x, z); + EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights( + x, z, heightAccessor.getMinBuildHeight(), heightAccessor.getMaxBuildHeight(), coverClass + ); + int surface = column.terrainSurface(); + if (heightmapType == Types.OCEAN_FLOOR_WG || heightmapType == Types.OCEAN_FLOOR) { + return surface + 1; + } else { + return column.hasWater() ? Math.max(surface, column.waterSurface()) + 1 : surface + 1; + } } } - private static void fillSolidSections( - LevelChunkSection[] sections, - boolean[] solidSections, - int[] sectionTopYs, - int solidMaxIndex, - BlockState stone, - BlockState deepslate, - int deepslateStart, - EarthChunkGenerator.SolidSectionFillProfiler profiler - ) { - int sectionCount = sections.length; - long sectionScanStartNs = beginFullChunkProfiling(); - for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { - int topY = sectionTopYs[i]; - int bottomY = topY - 15; - if (bottomY >= 0 || topY < 0) { - BlockState fill = topY < deepslateStart ? deepslate : stone; - long sectionAccessStartNs = beginFullChunkProfiling(); - LevelChunkSection section = Objects.requireNonNull(sections[i], "section"); - profiler.sectionAccessNs += elapsedFullChunkProfilingSince(sectionAccessStartNs); - fillSection(section, fill, profiler); - solidSections[i] = true; + public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState random) { + int minY = heightAccessor.getMinBuildHeight(); + int height = heightAccessor.getHeight(); + BlockState[] states = new BlockState[height]; + Arrays.fill(states, AIR_STATE); + int coverClass = this.sampleCoverClass(x, z); + EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights(x, z, minY, minY + height, coverClass); + int surface = column.terrainSurface(); + int surfaceIndex = surface - minY; + + for (int i = 0; i <= surfaceIndex; i++) { + if (i >= 0 && i < states.length) { + int y = minY + i; + states[i] = y < 0 ? DEEPSLATE_STATE : STONE_STATE; } } - long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); - long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; - profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); + if (column.hasWater()) { + int waterTop = column.waterSurface(); + int waterIndex = waterTop - minY; + + for (int ix = surfaceIndex + 1; ix <= waterIndex; ix++) { + states[ix] = WATER_STATE; + } + } + + int bedrockIndex = this.minY - minY; + if (bedrockIndex >= 0 && bedrockIndex < states.length) { + states[bedrockIndex] = BEDROCK_STATE; + } + + return Objects.requireNonNull(new NoiseColumn(minY, states), "noiseColumn"); } - private static void fillSolidSections( - EarthChunkGenerator.ChunkSectionWriter writer, - boolean[] solidSections, - int[] sectionTopYs, - int solidMaxIndex, - BlockState stone, - BlockState deepslate, - int deepslateStart, - EarthChunkGenerator.SolidSectionFillProfiler profiler - ) { - int sectionCount = sectionTopYs.length; - long sectionScanStartNs = beginFullChunkProfiling(); + public void addDebugScreenInfo( List info, RandomState random, BlockPos pos) { + info.add(String.format("Tellus scale: %.1f", this.settings.worldScale())); + } - for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { - int topY = sectionTopYs[i]; - int bottomY = topY - 15; - if (bottomY >= 0 || topY < 0) { - BlockState fill = topY < deepslateStart ? deepslate : stone; - writer.fillSectionConstant(i, fill, profiler); - solidSections[i] = true; + private boolean isFastSpawnMode() { + return this.fastSpawnMode.get(); + } + + private void disableFastSpawnMode() { + if (this.fastSpawnMode.compareAndSet(true, false)) { + if (this.biomeSource instanceof EarthBiomeSource earthBiomeSource) { + earthBiomeSource.setFastSpawnMode(false); } } - - long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); - long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; - profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private static void fillSection(LevelChunkSection section, BlockState fill, EarthChunkGenerator.SolidSectionFillProfiler profiler) { - PalettedContainer states = section.getStates(); - long sectionWriteStartNs = beginFullChunkProfiling(); - section.acquire(); + private void placeTrees(WorldGenLevel level, ChunkAccess chunk) { + ChunkPos pos = chunk.getPos(); + long chunkKey = ChunkPos.asLong(pos.x, pos.z); + EarthChunkGenerator.ChunkDecorationContext decorationContext = this.chunkDecorationContexts.get(chunkKey); + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings = this.preparedChunkBuildings.get(ChunkPos.asLong(pos.x, pos.z)); + 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 = level.getSeed(); + + 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) { + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } + + int coverClass = decorationContext != null ? decorationContext.coverClass(localX, localZ) : this.sampleCoverClass(worldX, worldZ); + boolean nearWater = false; + if (shorelineBlendRadius > 0) { + nearWater = decorationContext != null && decorationContext.canResolveNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) + ? decorationContext.isNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) + : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + } + + if (coverClass == 10 && !nearWater) { + int expectedSurface = decorationContext != null ? decorationContext.terrainSurface(localX, localZ) : this.sampleSurfaceHeight(worldX, worldZ); + if (expectedSurface >= this.seaLevel) { + int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (topY >= level.getMinBuildHeight() + && topY >= this.seaLevel + && expectedSurface - topY <= TREE_MAX_SURFACE_DROP + && topY - expectedSurface <= TREE_MAX_SURFACE_RISE) { + 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)) { + BlockPos position = ground.above(); + Holder biome = decorationContext != null ? decorationContext.biome(localX, localZ) : level.getBiome(position); + if (!biome.is(Biomes.MANGROVE_SWAMP)) { + List> features = treeFeaturesForBiome(biome); + if (!features.isEmpty()) { + if (!groundState.is(BlockTags.DIRT)) { + level.setBlock(ground, GRASS_BLOCK_STATE, 260); + } - try { - for (int y = 0; y < 16; y++) { - for (int z = 0; z < 16; z++) { - for (int x = 0; x < 16; x++) { - states.getAndSetUnchecked(x, y, z, fill); + ArnisTreeType treeType = wildTreeTypeForBiome(biome, seed); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); + } + } + } + } + } } } } - } finally { - section.release(); } - profiler.sectionWriteNs += elapsedFullChunkProfilingSince(sectionWriteStartNs); - - long sectionRecalcStartNs = beginFullChunkProfiling(); - section.recalcBlockCounts(); - profiler.recalcNs += elapsedFullChunkProfilingSince(sectionRecalcStartNs); } - private static void fillStoneColumnSpan( - LevelChunkSection[] sections, - boolean[] touchedSections, - int chunkMinY, - int localX, - int localZ, - int startY, - int endY, - int deepslateStart, - BlockState stone, - BlockState deepslate + private List prepareDeferredTreePlacements( + EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings ) { - if (endY < startY) { - return; - } - - int sectionIndex = (startY - chunkMinY) >> 4; - if (sectionIndex < 0 || sectionIndex >= sections.length) { - return; - } - - LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); - PalettedContainer states = section.getStates(); - int sectionBottomY = chunkMinY + (sectionIndex << 4); - section.acquire(); + 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<>(); - try { - for (int worldY = startY; worldY <= endY; worldY++) { - states.getAndSetUnchecked(localX, worldY - sectionBottomY, localZ, worldY < deepslateStart ? deepslate : stone); - } - } finally { - section.release(); - } + 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; + } - touchedSections[sectionIndex] = true; - } + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } - private static void fillColumnConstant( - LevelChunkSection[] sections, boolean[] touchedSections, int chunkMinY, int localX, int localZ, int startY, int endY, BlockState state - ) { - if (endY < startY) { - return; - } + int index = chunkIndex(localX, localZ); + int coverClass = context.coverClasses()[index]; + if (coverClass != 10) { + continue; + } - int currentY = startY; - while (currentY <= endY) { - int sectionIndex = (currentY - chunkMinY) >> 4; - if (sectionIndex < 0 || sectionIndex >= sections.length) { - break; - } + 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); + } - LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); - PalettedContainer states = section.getStates(); - int sectionBottomY = chunkMinY + (sectionIndex << 4); - int localStartY = Math.max(0, currentY - sectionBottomY); - int localEndY = Math.min(15, endY - sectionBottomY); - section.acquire(); + if (nearWater) { + continue; + } - try { - for (int localY = localStartY; localY <= localEndY; localY++) { - states.getAndSetUnchecked(localX, localY, localZ, state); + int expectedSurface = context.terrainSurfaces()[index]; + if (expectedSurface < this.seaLevel) { + continue; } - } finally { - section.release(); - } - touchedSections[sectionIndex] = true; - currentY = sectionBottomY + 16; - } - } + Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); + if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { + continue; + } - private static void recalcFilledSections(LevelChunkSection[] sections, boolean[] solidSections, boolean[] touchedSections) { - for (int i = 0; i < sections.length && i < touchedSections.length; i++) { - if (touchedSections[i] && !solidSections[i]) { - Objects.requireNonNull(sections[i], "section").recalcBlockCounts(); + placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); } } - } - - private void filterVillageStarts(RegistryAccess registryAccess, ChunkAccess chunk) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.isVillageStructure(registry, structure) && this.isVillageStartTooSteep(start)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } - } + return placements.isEmpty() ? List.of() : List.copyOf(placements); } - private boolean isVillageStartTooSteep(StructureStart start) { - return this.isStructureStartTooSteep(start, 4, 4, 6); + private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { + for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { + this.applyPreparedTreePlacement(level, placement); + } } - private void filterWoodlandMansionStarts(RegistryAccess registryAccess, ChunkAccess chunk) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.isWoodlandMansionStructure(registry, structure) && this.isWoodlandMansionStartTooSteep(start)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } + 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 boolean isWoodlandMansionStartTooSteep(StructureStart start) { - return this.isStructureStartTooSteep(start, 8, 6, 8); - } + 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 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; - if (roadsActive || buildingsActive) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - EarthChunkGenerator.RoadWidths roadWidths = roadsActive ? resolveRoadWidths(worldScale) : null; + Holder biome = placement.biome(); + if (biome.is(Biomes.MANGROVE_SWAMP)) { + return; + } - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.shouldAvoidOsmCollision(registry, structure) - && this.doesStructureStartCollideWithOsm(start, roadsActive, buildingsActive, roadWidths)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } - } + List> features = treeFeaturesForBiome(biome); + if (features.isEmpty()) { + return; } - } - private boolean doesStructureStartCollideWithOsm( - StructureStart start, boolean roadsActive, boolean buildingsActive, EarthChunkGenerator.RoadWidths roadWidths - ) { - BoundingBox box = start.getBoundingBox(); - if (buildingsActive && this.structureCollidesWithBuildings(box)) { - return true; - } else { - return roadsActive && roadWidths != null ? this.structureCollidesWithRoads(box, roadWidths) : false; + 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 boolean structureCollidesWithBuildings(BoundingBox box) { - int marginBlocks = 2; - double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); - double minX = structureFootprintMinX(box); - double maxX = structureFootprintMaxX(box); - double minZ = structureFootprintMinZ(box); - double maxZ = structureFootprintMaxZ(box); - OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; - EarthChunkGenerator.OsmBuildingQueryResult query = this.fetchOsmBuildingsForAreaDetailed( - box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode - ); - if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { - EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); - return false; + 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; } - for (OsmBuildingFeature building : query.features()) { - if (this.structureIntersectsBuilding(box, building, blocksPerDegree, minX, minZ, maxX, maxZ)) { - return true; - } + 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); } - return false; - } + 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); + } - private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { - int marginBlocks = 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 + List points = EXTERNAL_FEATURE_SOURCE.cityPointsForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN ); - if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { - EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); - return false; + if (!points.isEmpty()) { + this.applyExternalCityPoints(level, chunk, points, worldScale); } + } - double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); - double worldScale = this.settings.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 (RoadFeature road : query.features()) { - if (road.mode() != RoadMode.TUNNEL && this.structureIntersectsRoad(box, road, roadWidths, blocksPerDegree, worldScale)) { - return true; + for (ExternalAreaFeature area : areas) { + int partCount = area.rings().size(); + if (partCount == 0) { + continue; } - } - return false; + 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 boolean structureIntersectsBuilding( - BoundingBox box, OsmBuildingFeature building, double blocksPerDegree, double minX, double minZ, double maxX, double maxZ + private void decorateExternalAreaFeature( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY ) { - if (building.maxBlockX(blocksPerDegree) < minX - || building.minBlockX(blocksPerDegree) > maxX - || building.maxBlockZ(this.settings.worldScale()) < minZ - || building.minBlockZ(this.settings.worldScale()) > maxZ) { - return false; - } else if (building.containsWorld(minX, minZ, this.settings.worldScale()) - || building.containsWorld(minX, maxZ, this.settings.worldScale()) - || building.containsWorld(maxX, minZ, this.settings.worldScale()) - || building.containsWorld(maxX, maxZ, this.settings.worldScale())) { - return true; - } else { - for (int part = 0; part < building.partCount(); part++) { - int points = building.pointCount(part); - if (points >= 2) { - double previousX = building.lonAt(part, points - 1) * blocksPerDegree; - double previousZ = EarthProjection.latToBlockZ(building.latAt(part, points - 1), this.settings.worldScale()); + if (surfaceY + 1 > maxY) { + return; + } - for (int i = 0; i < points; i++) { - double currentX = building.lonAt(part, i) * blocksPerDegree; - double currentZ = EarthProjection.latToBlockZ(building.latAt(part, i), this.settings.worldScale()); - if (pointInRect(currentX, currentZ, minX, minZ, maxX, maxZ) - || segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { - return true; - } + 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); - previousX = currentX; - previousZ = currentZ; + 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 -> { } } } - - return false; - } - } - - 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(); - }; - double halfWidth = Math.max(0.5, (roadWidth - 1) * 0.5); - double minX = structureFootprintMinX(box) - halfWidth; - double maxX = structureFootprintMaxX(box) + halfWidth; - double minZ = structureFootprintMinZ(box) - halfWidth; - double maxZ = structureFootprintMaxZ(box) + halfWidth; - int pointCount = road.pointCount(); - if (pointCount < 2) { - return false; - } else { - double previousX = road.lonAt(0) * blocksPerDegree; - double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); - if (pointInRect(previousX, previousZ, minX, minZ, maxX, maxZ)) { - return true; - } else { - for (int i = 1; i < pointCount; i++) { - double currentX = road.lonAt(i) * blocksPerDegree; - double currentZ = EarthProjection.latToBlockZ(road.latAt(i), worldScale); - if (segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { - return true; + 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 -> { } - - previousX = currentX; - previousZ = currentZ; } - - return false; + } + 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 boolean shouldAvoidOsmCollision(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - if (key == null) { - return false; - } else { - String path = key.getPath(); - return path.startsWith("village") - || path.equals("woodland_mansion") - || path.equals("desert_pyramid") - || path.equals("desert_temple") - || path.equals("jungle_pyramid") - || path.equals("jungle_temple") - || path.equals("pillager_outpost") - || path.equals("igloo") - || path.equals("swamp_hut") - || path.equals("witch_hut") - || path.startsWith("ruined_portal") - || path.startsWith("trail_ruins"); + 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; } - } - - private static double structureFootprintMinX(BoundingBox box) { - return box.minX() - 0.5; - } - - private static double structureFootprintMaxX(BoundingBox box) { - return box.maxX() + 0.5; - } - - private static double structureFootprintMinZ(BoundingBox box) { - return box.minZ() - 0.5; - } - - private static double structureFootprintMaxZ(BoundingBox box) { - return box.maxZ() + 0.5; - } - - private static boolean pointInRect(double x, double z, double minX, double minZ, double maxX, double maxZ) { - return x >= minX && x <= maxX && z >= minZ && z <= maxZ; - } - private static boolean segmentIntersectsRect(double x1, double z1, double x2, double z2, double minX, double minZ, double maxX, double maxZ) { - return pointInRect(x1, z1, minX, minZ, maxX, maxZ) - || pointInRect(x2, z2, minX, minZ, maxX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, minX, minZ, maxX, minZ) - || segmentsIntersect(x1, z1, x2, z2, maxX, minZ, maxX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, maxX, maxZ, minX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, minX, maxZ, minX, minZ); - } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canDecorateAreaVegetationSurface(area, type, ground)) { + return; + } - private static boolean segmentsIntersect(double ax, double az, double bx, double bz, double cx, double cz, double dx, double dz) { - double abx = bx - ax; - double abz = bz - az; - double acx = cx - ax; - double acz = cz - az; - double adx = dx - ax; - double adz = dz - az; - double cdx = dx - cx; - double cdz = dz - cz; - double cax = ax - cx; - double caz = az - cz; - double cbx = bx - cx; - double cbz = bz - cz; - double cross1 = cross(abx, abz, acx, acz); - double cross2 = cross(abx, abz, adx, adz); - double cross3 = cross(cdx, cdz, cax, caz); - double cross4 = cross(cdx, cdz, cbx, cbz); - double epsilon = 1.0E-7; - if (Math.abs(cross1) <= epsilon && onSegment(ax, az, bx, bz, cx, cz)) { - return true; - } else if (Math.abs(cross2) <= epsilon && onSegment(ax, az, bx, bz, dx, dz)) { - return true; - } else if (Math.abs(cross3) <= epsilon && onSegment(cx, cz, dx, dz, ax, az)) { - return true; - } else if (Math.abs(cross4) <= epsilon && onSegment(cx, cz, dx, dz, bx, bz)) { - return true; - } else { - return cross1 > 0.0 != cross2 > 0.0 && cross3 > 0.0 != cross4 > 0.0; + 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; } - } - private static double cross(double ax, double az, double bx, double bz) { - return ax * bz - az * bx; + 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 onSegment(double ax, double az, double bx, double bz, double px, double pz) { - return px >= Math.min(ax, bx) - 1.0E-7 - && px <= Math.max(ax, bx) + 1.0E-7 - && pz >= Math.min(az, bz) - 1.0E-7 - && pz <= Math.max(az, bz) + 1.0E-7; + 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 boolean isStructureStartTooSteep(StructureStart start, int margin, int sampleStep, int maxHeightDelta) { - BoundingBox box = start.getBoundingBox(); - int minX = box.minX() - margin; - int maxX = box.maxX() + margin; - int minZ = box.minZ() - margin; - int maxZ = box.maxZ() + margin; - int minHeight = Integer.MAX_VALUE; - int maxHeight = Integer.MIN_VALUE; - int stride = Math.max(1, sampleStep); - - for (int z = minZ; z <= maxZ; z += stride) { - for (int x = minX; x <= maxX; x += stride) { - int surface = this.sampleSurfaceHeight(x, z); - if (surface < minHeight) { - minHeight = surface; - } - - if (surface > maxHeight) { - maxHeight = surface; - } - - if (maxHeight - minHeight > maxHeightDelta) { - return true; - } - } + 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; } - - return maxHeight - minHeight > maxHeightDelta; + 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 boolean isVillageStructure(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - return key != null && key.getPath().startsWith("village"); + 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 boolean isWoodlandMansionStructure(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - return key != null && key.getPath().equals("woodland_mansion"); + 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; + }; } - public int getGenDepth() { - return this.height; + 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; + }; } - public int getSeaLevel() { - return this.seaLevel; + 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; + }; } - public int getMinY() { - return this.minY; + 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; } - public int getBaseHeight(int x, int z, Types heightmapType, LevelHeightAccessor heightAccessor, RandomState random) { - if (this.isFastSpawnMode()) { - int maxY = heightAccessor.getMaxBuildHeight() - 1; - return Mth.clamp(this.seaLevel + 1, heightAccessor.getMinBuildHeight(), maxY); - } else { - int coverClass = this.sampleCoverClass(x, z); - EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights( - x, z, heightAccessor.getMinBuildHeight(), heightAccessor.getMaxBuildHeight(), coverClass - ); - int surface = column.terrainSurface(); - if (heightmapType == Types.OCEAN_FLOOR_WG || heightmapType == Types.OCEAN_FLOOR) { - return surface + 1; - } else { - return column.hasWater() ? Math.max(surface, column.waterSurface()) + 1 : surface + 1; + 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; + } - public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState random) { - int minY = heightAccessor.getMinBuildHeight(); - int height = heightAccessor.getHeight(); - BlockState[] states = new BlockState[height]; - Arrays.fill(states, AIR_STATE); - int coverClass = this.sampleCoverClass(x, z); - EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights(x, z, minY, minY + height, coverClass); - int surface = column.terrainSurface(); - int surfaceIndex = surface - minY; - - for (int i = 0; i <= surfaceIndex; i++) { - if (i >= 0 && i < states.length) { - int y = minY + i; - states[i] = y < 0 ? DEEPSLATE_STATE : STONE_STATE; + 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 (column.hasWater()) { - int waterTop = column.waterSurface(); - int waterIndex = waterTop - minY; - - for (int ix = surfaceIndex + 1; ix <= waterIndex; ix++) { - states[ix] = WATER_STATE; + if (line.kind() == ExternalLineKind.WATERWAY) { + cursor.set(worldX, surfaceY, worldZ); + if (canReplaceCitySurface(level.getBlockState(cursor))) { + level.setBlock(cursor, WATER_STATE, this.detailApplyFlags(level)); } + return; } - - int bedrockIndex = this.minY - minY; - if (bedrockIndex >= 0 && bedrockIndex < states.length) { - states[bedrockIndex] = BEDROCK_STATE; + if (line.kind() != ExternalLineKind.BARRIER) { + return; } - return Objects.requireNonNull(new NoiseColumn(minY, states), "noiseColumn"); + 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)); + } + } } - public void addDebugScreenInfo( List info, RandomState random, BlockPos pos) { - info.add(String.format("Tellus scale: %.1f", this.settings.worldScale())); + 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 boolean isFastSpawnMode() { - return this.fastSpawnMode.get(); + 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 disableFastSpawnMode() { - if (this.fastSpawnMode.compareAndSet(true, false)) { - if (this.biomeSource instanceof EarthBiomeSource earthBiomeSource) { - earthBiomeSource.setFastSpawnMode(false); - } + 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 placeTrees(WorldGenLevel level, ChunkAccess chunk) { - ChunkPos pos = chunk.getPos(); - long chunkKey = ChunkPos.asLong(pos.x, pos.z); - EarthChunkGenerator.ChunkDecorationContext decorationContext = this.chunkDecorationContexts.get(chunkKey); - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings = this.preparedChunkBuildings.get(ChunkPos.asLong(pos.x, pos.z)); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); + 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; - 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 = level.getSeed(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; - 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) { - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + 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 coverClass = decorationContext != null ? decorationContext.coverClass(localX, localZ) : this.sampleCoverClass(worldX, worldZ); - boolean nearWater = false; - if (shorelineBlendRadius > 0) { - nearWater = decorationContext != null && decorationContext.canResolveNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) - ? decorationContext.isNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) - : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + 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; + } - if (coverClass == 10 && !nearWater) { - int expectedSurface = decorationContext != null ? decorationContext.terrainSurface(localX, localZ) : this.sampleSurfaceHeight(worldX, worldZ); - if (expectedSurface >= this.seaLevel) { - int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; - if (topY >= level.getMinBuildHeight() - && topY >= this.seaLevel - && expectedSurface - topY <= TREE_MAX_SURFACE_DROP - && topY - expectedSurface <= TREE_MAX_SURFACE_RISE) { - 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)) { - BlockPos position = ground.above(); - Holder biome = decorationContext != null ? decorationContext.biome(localX, localZ) : level.getBiome(position); - if (!biome.is(Biomes.MANGROVE_SWAMP)) { - List> features = treeFeaturesForBiome(biome); - if (!features.isEmpty()) { - if (!groundState.is(BlockTags.DIRT)) { - level.setBlock(ground, GRASS_BLOCK_STATE, 260); - } + 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; + } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); - } - } - } - } + 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 List prepareDeferredTreePlacements( - EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + 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 +6947,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 +7215,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 +7353,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 +7431,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 +7468,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 +7517,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 +7923,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 +8104,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 +8160,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 +8306,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 +8516,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 +10240,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 +10926,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 +10944,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 +10966,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 +10984,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 +10999,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 +11020,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 +12264,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"), 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/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..516733dae 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,14 @@ import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.yucareux.tellus.Tellus; +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 +35,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 +155,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 +172,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 +197,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(); @@ -554,7 +591,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 +608,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 +727,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); @@ -915,7 +966,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 +1080,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 +1350,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 +1388,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1360,12 +1419,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1387,12 +1450,16 @@ private void applyOsmRoadOverlay( edgeColumnCache, resolvedClass, resolvedMode, + resolvedSurface, resolvedDeckY, + resolvedWidth, resolvedTunnelCarve, blockedByHigherClass, bridgeOverlayPresent, bridgeOverlayDeckY, bridgeOverlayClass, + bridgeOverlaySurface, + bridgeOverlayWidth, scratch, extArea ); @@ -1480,10 +1547,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 +1569,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 +1594,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 +1697,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 +1755,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 +1814,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 +1862,16 @@ private void rasterizeRoadClassPass( edgeColumnCache, bridgeCandidatePresent, bridgeCandidateDeckY, + bridgeCandidateWidth, null, + bridgeCandidateSurface, null ); } else { this.rasterizeRoadFeature( road, - roadWidth, + featureRoadWidth, + featureSurface, blocksPerDegree, extMinX, extMinZ, @@ -1738,7 +1886,9 @@ private void rasterizeRoadClassPass( edgeColumnCache, candidatePresent, candidateDeckY, + candidateWidth, candidateMode, + candidateSurface, candidateTunnelCarve ); } @@ -1748,7 +1898,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 +1920,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 +1951,7 @@ private void rasterizeRoadClassPass( private void rasterizeRoadFeature( RoadFeature road, int roadWidth, + byte roadSurface, double blocksPerDegree, int extMinX, int extMinZ, @@ -1802,7 +1966,9 @@ private void rasterizeRoadFeature( Long2ObjectOpenHashMap edgeColumnCache, boolean[] candidatePresent, int[] candidateDeckY, + int[] candidateWidth, byte[] candidateMode, + byte[] candidateSurface, boolean[] candidateTunnelCarve ) { int pointCount = road.pointCount(); @@ -1924,6 +2090,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,2076 +2110,3842 @@ 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(level.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(level.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); - return Math.max(baseline + rise, localRoadSurface + clearance); - } - - private static int bridgeClearanceAtStation(double station, double totalLength, RoadClass roadClass, double worldScale) { - int targetClearance = bridgeTargetClearanceBlocks(roadClass, worldScale); - if (targetClearance <= 0 || totalLength <= 1.0E-6) { - return 0; - } else { - double clampedStation = Mth.clamp(station, 0.0, totalLength); - double rampLength = targetClearance * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; - double clearance; - if (totalLength >= rampLength * 2.0) { - if (clampedStation < rampLength) { - clearance = targetClearance * (clampedStation / rampLength); - } else if (clampedStation > totalLength - rampLength) { - clearance = targetClearance * ((totalLength - clampedStation) / rampLength); - } else { - clearance = targetClearance; - } - } else { - double half = totalLength * 0.5; - if (half <= 1.0E-6) { - clearance = targetClearance; - } else if (clampedStation <= half) { - clearance = targetClearance * (clampedStation / half); - } else { - clearance = targetClearance * ((totalLength - clampedStation) / half); + 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; } - } - - return Math.max(0, (int)Math.round(Mth.clamp(clearance, 0.0, targetClearance))); - } - } - - private static int bridgeTargetClearanceBlocks(RoadClass roadClass, double worldScale) { - double safeScale = worldScale > 0.0 ? worldScale : 1.0; - double clearanceMeters = switch (roadClass) { - case MAIN -> 6.0; - case NORMAL -> 5.0; - case DIRT -> 3.0; - }; - return Math.max(1, (int)Math.ceil(clearanceMeters / safeScale)); - } - private static int tunnelDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { - return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); - } + int deckY = tunnelCarveDeckY[chunkIndex]; + if (deckY < chunkMinY || deckY >= chunkMaxY) { + continue; + } - private static int interpolateDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { - if (totalLength <= 1.0E-6) { - return startSurface; - } else { - double progress = Mth.clamp(station / totalLength, 0.0, 1.0); - double interpolated = startSurface + (endSurface - startSurface) * progress; - return (int)Math.round(interpolated); - } - } + 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; + } - private static boolean shouldReplaceBridgeCandidate(boolean existingPresent, int existingDeckY, int newDeckY) { - return !existingPresent || newDeckY > existingDeckY; - } + 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)); + } + } + } - private static boolean shouldReplaceRoadCandidate( - boolean existingPresent, int existingDeckY, byte existingModeId, boolean existingTunnelCarve, int newDeckY, RoadMode newMode, boolean newTunnelCarve - ) { - if (!existingPresent) { - return true; - } else if (newDeckY != existingDeckY) { - return newDeckY > existingDeckY; - } else { - int existingModePriority = modePriority(existingModeId); - if (newMode.priority() != existingModePriority) { - return newMode.priority() > existingModePriority; - } else { - return newTunnelCarve != existingTunnelCarve ? newTunnelCarve : false; + 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 int modePriority(byte modeId) { - if (modeId <= 0) { - return -1; - } else { - int index = modeId - 1; - return index >= 0 && index < RoadMode.values().length ? RoadMode.values()[index].priority() : -1; + 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(); } - } - - private static void mergeBridgeOverlay( - int index, int classId, int deckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, byte[] bridgeOverlayClass - ) { - if (!bridgeOverlayPresent[index]) { - bridgeOverlayPresent[index] = true; - bridgeOverlayDeckY[index] = deckY; - bridgeOverlayClass[index] = (byte)classId; - } else { - int existingDeck = bridgeOverlayDeckY[index]; - int existingClass = bridgeOverlayClass[index]; - if (deckY > existingDeck || deckY == existingDeck && classId < existingClass) { - bridgeOverlayDeckY[index] = deckY; - bridgeOverlayClass[index] = (byte)classId; - } + if (roll < 16) { + return Blocks.MOSSY_STONE_BRICKS.defaultBlockState(); } + return Blocks.STONE_BRICKS.defaultBlockState(); } - private void rasterizeBridgeSupports( + private void paintRoadLaneMarkings( + WorldGenLevel level, + ChunkAccess chunk, List roads, - int roadWidth, - double blocksPerDegree, - int extMinX, - int extMinZ, - int extSide, + EarthChunkGenerator.RoadWidths widths, int chunkMinX, int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, int chunkMinY, int chunkMaxY, - Long2ObjectOpenHashMap edgeColumnCache, - byte[] resolvedClass, - int[] resolvedDeckY, + int padding, + int extSide, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY, boolean[] bridgeOverlayPresent, int[] bridgeOverlayDeckY, - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings, - boolean[] shaftPresent, - int[] shaftBottomY, - int[] shaftTopY, - boolean[] capPresent, - int[] capBottomY, - int[] capTopY + byte[] bridgeOverlayClass ) { - if (roads.isEmpty() || roadWidth <= 0) { - return; - } + double worldScale = this.settings.worldScale(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); - double worldScale = EarthProjection.worldScaleFromBlocksPerDegree(blocksPerDegree); for (RoadFeature road : roads) { - EarthChunkGenerator.RoadColumnSample startColumn = this.sampleRoadColumnForOverlay( - Mth.floor(road.lonAt(0) * blocksPerDegree), - Mth.floor(EarthProjection.latToBlockZ(road.latAt(0), worldScale)), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - EarthChunkGenerator.RoadColumnSample endColumn = this.sampleRoadColumnForOverlay( - Mth.floor(road.lonAt(road.pointCount() - 1) * blocksPerDegree), - Mth.floor(EarthProjection.latToBlockZ(road.latAt(road.pointCount() - 1), worldScale)), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - int startSurface = startColumn.roadSurface(); - int endSurface = endColumn.roadSurface(); - BridgeSupportLayout.SupportStyle style = BridgeSupportLayout.styleFor(road.roadClass(), roadWidth); - BridgeSupportLayout.forEachSupport(road, blocksPerDegree, worldScale, roadWidth, placement -> { - IntArrayList capCells = new IntArrayList(); - IntArrayList[] shaftCells = new IntArrayList[style.shaftCount()]; - int[] minTerrain = new int[style.shaftCount()]; - int[] maxTerrain = new int[style.shaftCount()]; + int lanes = road.laneCount(); + if (lanes < 2 || road.roadClass() == RoadClass.DIRT || road.isUnpavedSurface() || road.mode() == RoadMode.TUNNEL || road.pointCount() < 2) { + continue; + } - for (int i = 0; i < style.shaftCount(); i++) { - shaftCells[i] = new IntArrayList(); - minTerrain[i] = Integer.MAX_VALUE; - maxTerrain[i] = Integer.MIN_VALUE; - } + int roadWidth = roadWidthForFeature(road, roadWidthForClass(road.roadClass(), widths)); + if (roadWidth < 3) { + continue; + } - EarthChunkGenerator.RoadColumnSample supportCenterColumn = this.sampleRoadColumnForOverlay( - Mth.floor(placement.centerX()), - Mth.floor(placement.centerZ()), - chunkMinX, - chunkMinZ, - terrainSurfaces, - waterSurfaces, - waterFlags, - edgeColumnCache - ); - int deckY = bridgeDeckYAtStation( - placement.station(), - placement.totalLength(), - startSurface, - endSurface, - supportCenterColumn.roadSurface(), - road.bridgeLevel(), - road.roadClass(), - worldScale - ); - int capTop = Math.min(chunkMaxY, deckY - 1); - int capBottom = Math.max(chunkMinY, capTop - style.capThickness() + 1); - if (capTop < capBottom) { - return; - } + int roadClassId = roadClassId(road.roadClass()); + int roadModeId = road.mode().ordinal() + 1; + double laneWidth = roadWidth / (double)lanes; + if (laneWidth < 1.0) { + continue; + } - double radius = style.maxFootprintRadius() + 1.0; - int minLocalX = Mth.clamp((int)Math.floor(placement.centerX() - radius) - extMinX, 0, extSide - 1); - int maxLocalX = Mth.clamp((int)Math.ceil(placement.centerX() + radius) - extMinX, 0, extSide - 1); - int minLocalZ = Mth.clamp((int)Math.floor(placement.centerZ() - radius) - extMinZ, 0, extSide - 1); - int maxLocalZ = Mth.clamp((int)Math.ceil(placement.centerZ() + radius) - extMinZ, 0, extSide - 1); + 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; + } - for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { - int worldZ = extMinZ + localZ; + 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; + } - for (int localX = minLocalX; localX <= maxLocalX; localX++) { - int worldX = extMinX + localX; - double deltaX = worldX - placement.centerX(); - double deltaZ = worldZ - placement.centerZ(); - double along = deltaX * placement.tangentX() + deltaZ * placement.tangentZ(); - double across = deltaX * placement.normalX() + deltaZ * placement.normalZ(); - int index = extIndex(localX, localZ, extSide); - if (Math.abs(along) <= style.capHalfAlong() && Math.abs(across) <= style.capHalfAcross()) { - capCells.add(index); + 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; } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - double shaftAcross = style.shaftCount() == 1 ? 0.0 : (shaftIndex == 0 ? -style.shaftOffset() : style.shaftOffset()); - if (Math.abs(along) <= style.shaftHalfAlong() && Math.abs(across - shaftAcross) <= style.shaftHalfAcross()) { - EarthChunkGenerator.RoadColumnSample column = this.sampleRoadColumnForOverlay( - worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - int terrainSurface = column.terrainSurface(); - if (!BridgeSupportRoadMask.overlapsRoad( - index, terrainSurface, capBottom - 1, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY - )) { - shaftCells[shaftIndex].add(index); - minTerrain[shaftIndex] = Math.min(minTerrain[shaftIndex], terrainSurface); - maxTerrain[shaftIndex] = Math.max(maxTerrain[shaftIndex], terrainSurface); - } - } + 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); } } - // Nearby roads only remove directly overlapping support columns instead of suppressing the whole support. - BridgeSupportRoadMask.retainRoadFreeSupportCells( - capCells, index -> capBottom, capTop, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY - ); + 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); + } + + private static int bridgeClearanceAtStation(double station, double totalLength, RoadClass roadClass, double worldScale) { + int targetClearance = bridgeTargetClearanceBlocks(roadClass, worldScale); + if (targetClearance <= 0 || totalLength <= 1.0E-6) { + return 0; + } else { + double clampedStation = Mth.clamp(station, 0.0, totalLength); + double rampLength = targetClearance * OSM_ROAD_BRIDGE_RAMP_HORIZONTAL_PER_VERTICAL; + double clearance; + if (totalLength >= rampLength * 2.0) { + if (clampedStation < rampLength) { + clearance = targetClearance * (clampedStation / rampLength); + } else if (clampedStation > totalLength - rampLength) { + clearance = targetClearance * ((totalLength - clampedStation) / rampLength); + } else { + clearance = targetClearance; + } + } else { + double half = totalLength * 0.5; + if (half <= 1.0E-6) { + clearance = targetClearance; + } else if (clampedStation <= half) { + clearance = targetClearance * (clampedStation / half); + } else { + clearance = targetClearance * ((totalLength - clampedStation) / half); + } + } + + return Math.max(0, (int)Math.round(Mth.clamp(clearance, 0.0, targetClearance))); + } + } + + private static int bridgeTargetClearanceBlocks(RoadClass roadClass, double worldScale) { + double safeScale = worldScale > 0.0 ? worldScale : 1.0; + double clearanceMeters = switch (roadClass) { + case MAIN -> 6.0; + case NORMAL -> 5.0; + case DIRT -> 3.0; + }; + return Math.max(1, (int)Math.ceil(clearanceMeters / safeScale)); + } + + private static int tunnelDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { + return interpolateDeckAtStation(station, totalLength, startSurface, endSurface); + } + + private static int interpolateDeckAtStation(double station, double totalLength, int startSurface, int endSurface) { + if (totalLength <= 1.0E-6) { + return startSurface; + } else { + double progress = Mth.clamp(station / totalLength, 0.0, 1.0); + double interpolated = startSurface + (endSurface - startSurface) * progress; + return (int)Math.round(interpolated); + } + } + + private static boolean shouldReplaceBridgeCandidate(boolean existingPresent, int existingDeckY, int newDeckY) { + return !existingPresent || newDeckY > existingDeckY; + } + + private static boolean shouldReplaceRoadCandidate( + boolean existingPresent, int existingDeckY, byte existingModeId, boolean existingTunnelCarve, int newDeckY, RoadMode newMode, boolean newTunnelCarve + ) { + if (!existingPresent) { + return true; + } else if (newDeckY != existingDeckY) { + return newDeckY > existingDeckY; + } else { + int existingModePriority = modePriority(existingModeId); + if (newMode.priority() != existingModePriority) { + return newMode.priority() > existingModePriority; + } else { + return newTunnelCarve != existingTunnelCarve ? newTunnelCarve : false; + } + } + } + + private static int modePriority(byte modeId) { + if (modeId <= 0) { + return -1; + } else { + int index = modeId - 1; + return index >= 0 && index < RoadMode.values().length ? RoadMode.values()[index].priority() : -1; + } + } + + private static void mergeBridgeOverlay( + 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; + } + } + } + + private void rasterizeBridgeSupports( + List roads, + int roadWidth, + double blocksPerDegree, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + int chunkMinY, + int chunkMaxY, + Long2ObjectOpenHashMap edgeColumnCache, + byte[] resolvedClass, + int[] resolvedDeckY, + boolean[] bridgeOverlayPresent, + int[] bridgeOverlayDeckY, + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings, + boolean[] shaftPresent, + int[] shaftBottomY, + int[] shaftTopY, + boolean[] capPresent, + int[] capBottomY, + int[] capTopY + ) { + if (roads.isEmpty() || roadWidth <= 0) { + return; + } + + 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)), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + EarthChunkGenerator.RoadColumnSample endColumn = this.sampleRoadColumnForOverlay( + Mth.floor(road.lonAt(road.pointCount() - 1) * blocksPerDegree), + Mth.floor(EarthProjection.latToBlockZ(road.latAt(road.pointCount() - 1), worldScale)), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + int startSurface = startColumn.roadSurface(); + int endSurface = endColumn.roadSurface(); + 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()]; + int[] maxTerrain = new int[style.shaftCount()]; + + for (int i = 0; i < style.shaftCount(); i++) { + shaftCells[i] = new IntArrayList(); + minTerrain[i] = Integer.MAX_VALUE; + maxTerrain[i] = Integer.MIN_VALUE; + } + + EarthChunkGenerator.RoadColumnSample supportCenterColumn = this.sampleRoadColumnForOverlay( + Mth.floor(placement.centerX()), + Mth.floor(placement.centerZ()), + chunkMinX, + chunkMinZ, + terrainSurfaces, + waterSurfaces, + waterFlags, + edgeColumnCache + ); + int deckY = bridgeDeckYAtStation( + placement.station(), + placement.totalLength(), + startSurface, + endSurface, + supportCenterColumn.roadSurface(), + road.bridgeLevel(), + road.roadClass(), + worldScale + ); + int capTop = Math.min(chunkMaxY, deckY - 1); + int capBottom = Math.max(chunkMinY, capTop - style.capThickness() + 1); + if (capTop < capBottom) { + return; + } + + double radius = style.maxFootprintRadius() + 1.0; + int minLocalX = Mth.clamp((int)Math.floor(placement.centerX() - radius) - extMinX, 0, extSide - 1); + int maxLocalX = Mth.clamp((int)Math.ceil(placement.centerX() + radius) - extMinX, 0, extSide - 1); + int minLocalZ = Mth.clamp((int)Math.floor(placement.centerZ() - radius) - extMinZ, 0, extSide - 1); + int maxLocalZ = Mth.clamp((int)Math.ceil(placement.centerZ() + radius) - extMinZ, 0, extSide - 1); + + for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { + int worldZ = extMinZ + localZ; + + for (int localX = minLocalX; localX <= maxLocalX; localX++) { + int worldX = extMinX + localX; + double deltaX = worldX - placement.centerX(); + double deltaZ = worldZ - placement.centerZ(); + double along = deltaX * placement.tangentX() + deltaZ * placement.tangentZ(); + double across = deltaX * placement.normalX() + deltaZ * placement.normalZ(); + int index = extIndex(localX, localZ, extSide); + if (Math.abs(along) <= style.capHalfAlong() && Math.abs(across) <= style.capHalfAcross()) { + capCells.add(index); + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + double shaftAcross = style.shaftCount() == 1 ? 0.0 : (shaftIndex == 0 ? -style.shaftOffset() : style.shaftOffset()); + if (Math.abs(along) <= style.shaftHalfAlong() && Math.abs(across - shaftAcross) <= style.shaftHalfAcross()) { + EarthChunkGenerator.RoadColumnSample column = this.sampleRoadColumnForOverlay( + worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + int terrainSurface = column.terrainSurface(); + if (!BridgeSupportRoadMask.overlapsRoad( + index, terrainSurface, capBottom - 1, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY + )) { + shaftCells[shaftIndex].add(index); + minTerrain[shaftIndex] = Math.min(minTerrain[shaftIndex], terrainSurface); + maxTerrain[shaftIndex] = Math.max(maxTerrain[shaftIndex], terrainSurface); + } + } + } + } + } + + // Nearby roads only remove directly overlapping support columns instead of suppressing the whole support. + BridgeSupportRoadMask.retainRoadFreeSupportCells( + capCells, index -> capBottom, capTop, resolvedClass, resolvedDeckY, bridgeOverlayPresent, bridgeOverlayDeckY + ); if (capCells.isEmpty()) { return; } - int[] supportTops = new int[style.shaftCount()]; - boolean[] activeShafts = new boolean[style.shaftCount()]; - int activeShaftCount = 0; - int requiredClearance = Math.max(1, Math.min(style.minClearance(), bridgeTargetClearanceBlocks(road.roadClass(), worldScale))); - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (shaftCells[shaftIndex].isEmpty() || minTerrain[shaftIndex] == Integer.MAX_VALUE || maxTerrain[shaftIndex] == Integer.MIN_VALUE) { - continue; + int[] supportTops = new int[style.shaftCount()]; + boolean[] activeShafts = new boolean[style.shaftCount()]; + int activeShaftCount = 0; + int requiredClearance = Math.max(1, Math.min(style.minClearance(), bridgeTargetClearanceBlocks(road.roadClass(), worldScale))); + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (shaftCells[shaftIndex].isEmpty() || minTerrain[shaftIndex] == Integer.MAX_VALUE || maxTerrain[shaftIndex] == Integer.MIN_VALUE) { + continue; + } + + if (capBottom - maxTerrain[shaftIndex] < requiredClearance) { + continue; + } + + supportTops[shaftIndex] = capBottom - 1; + if (supportTops[shaftIndex] < minTerrain[shaftIndex]) { + continue; + } + + activeShafts[shaftIndex] = true; + activeShaftCount++; + } + + if (activeShaftCount == 0) { + return; + } + + for (int i = 0; i < capCells.size(); i++) { + int index = capCells.getInt(i); + if (this.bridgeSupportConflictsBuilding( + index, + capBottom, + capTop, + extMinX, + extMinZ, + extSide, + chunkMinX, + chunkMinZ, + preparedBuildings + )) { + return; + } + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (!activeShafts[shaftIndex]) { + continue; + } + + for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { + int index = shaftCells[shaftIndex].getInt(i); + int localBottom = this.bridgeSupportTerrainBottom( + index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + if (supportTops[shaftIndex] < localBottom) { + continue; + } + + if (this.bridgeSupportConflictsBuilding( + index, + localBottom, + supportTops[shaftIndex], + extMinX, + extMinZ, + extSide, + chunkMinX, + chunkMinZ, + preparedBuildings + )) { + return; + } + } + } + + for (int i = 0; i < capCells.size(); i++) { + mergeBridgeSupportColumn(capCells.getInt(i), capBottom, capTop, capPresent, capBottomY, capTopY); + } + + for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { + if (!activeShafts[shaftIndex]) { + continue; + } + + for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { + int index = shaftCells[shaftIndex].getInt(i); + int localBottom = this.bridgeSupportTerrainBottom( + index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ); + if (supportTops[shaftIndex] < localBottom) { + continue; + } + + mergeBridgeSupportColumn( + index, + localBottom, + supportTops[shaftIndex], + shaftPresent, + shaftBottomY, + shaftTopY + ); + } + } + }); + } + } + + private boolean bridgeSupportConflictsBuilding( + int extIndex, + int bottomY, + int topY, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + ) { + if (topY < bottomY) { + return false; + } + + if (preparedBuildings != null) { + int localX = extIndex % extSide; + int localZ = extIndex / extSide; + int worldX = extMinX + localX; + int worldZ = extMinZ + localZ; + int chunkLocalX = worldX - chunkMinX; + int chunkLocalZ = worldZ - chunkMinZ; + if (chunkLocalX >= 0 + && chunkLocalX < CHUNK_SIDE + && chunkLocalZ >= 0 + && chunkLocalZ < CHUNK_SIDE + && preparedBuildings.intersectsSpan(chunkLocalX, chunkLocalZ, bottomY, topY)) { + return true; + } + } + + return false; + } + + private RoadColumnSample bridgeSupportColumnSample( + int extIndex, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + int localX = extIndex % extSide; + int localZ = extIndex / extSide; + int worldX = extMinX + localX; + int worldZ = extMinZ + localZ; + return this.sampleRoadColumnForOverlay(worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache); + } + + private int bridgeSupportTerrainBottom( + int extIndex, + int extMinX, + int extMinZ, + int extSide, + int chunkMinX, + int chunkMinZ, + int[] terrainSurfaces, + int[] waterSurfaces, + boolean[] waterFlags, + Long2ObjectOpenHashMap edgeColumnCache + ) { + return this.bridgeSupportColumnSample( + extIndex, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache + ) + .terrainSurface(); + } + + private static void mergeBridgeSupportColumn(int index, int bottomY, int topY, boolean[] present, int[] bottoms, int[] tops) { + if (topY < bottomY) { + return; + } + + if (!present[index]) { + present[index] = true; + bottoms[index] = bottomY; + tops[index] = topY; + } else { + bottoms[index] = Math.min(bottoms[index], bottomY); + tops[index] = Math.max(tops[index], topY); + } + } + + private static int extIndex(int localX, int localZ, int side) { + return localZ * side + localX; + } + + private static long packColumn(int worldX, int worldZ) { + return (long)worldX << 32 ^ worldZ & 4294967295L; + } + + + private static BlockState roadStateForClass(RoadClass roadClass) { + return switch (roadClass) { + case MAIN -> ROAD_MAIN_STATE; + case NORMAL -> ROAD_NORMAL_STATE; + case DIRT -> ROAD_DIRT_STATE; + }; + } + + 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) { + case 1 -> RoadClass.MAIN; + case 2 -> RoadClass.NORMAL; + default -> RoadClass.DIRT; + }; + } + + private static int roadClassId(RoadClass roadClass) { + return switch (roadClass) { + case MAIN -> 1; + case NORMAL -> 2; + case DIRT -> 3; + }; + } + + private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.RoadWidths widths) { + return switch (roadClass) { + case MAIN -> widths.main(); + case NORMAL -> widths.normal(); + case DIRT -> widths.dirt(); + }; + } + + 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; + } else { + return Mth.clamp((int)Math.round(ROAD_LIGHT_BASE_SPACING_METERS / worldScale), 3, 40); + } + } + + private static int roadLightMinimumSpacingBlocks(int spacingBlocks) { + return Math.max(3, (int)Math.round(spacingBlocks * 0.75)); + } + + private static int roadLightFenceCount(double worldScale) { + if (worldScale <= 3.0) { + return 3; + } else { + return worldScale <= 8.0 ? 2 : 1; + } + } + + private static EarthChunkGenerator.SampledRoadStation sampleRoadStation( + double[] worldXs, double[] worldZs, double[] segmentStarts, double[] segmentLengths, double station + ) { + for (int i = 0; i < segmentLengths.length; i++) { + double segmentLength = segmentLengths[i]; + if (!(segmentLength <= 1.0E-6)) { + double segmentStart = segmentStarts[i]; + double segmentEnd = segmentStart + segmentLength; + if (station <= segmentEnd + 1.0E-6 || i == segmentLengths.length - 1) { + double dx = worldXs[i + 1] - worldXs[i]; + double dz = worldZs[i + 1] - worldZs[i]; + double t = Mth.clamp((station - segmentStart) / segmentLength, 0.0, 1.0); + return new EarthChunkGenerator.SampledRoadStation(worldXs[i] + dx * t, worldZs[i] + dz * t, dx / segmentLength, dz / segmentLength); + } + } + } + + return null; + } + + private static EarthChunkGenerator.RoadLightAnchor findRoadLightAnchor( + EarthChunkGenerator.SampledRoadStation sampled, + boolean placeLeft, + int roadWidth, + int roadClassId, + int roadModeId, + int chunkMinX, + int chunkMinZ, + byte[] chunkRoadClass, + byte[] chunkRoadMode, + int[] chunkRoadDeckY + ) { + double normalX = placeLeft ? -sampled.tangentZ() : sampled.tangentZ(); + double normalZ = placeLeft ? sampled.tangentX() : -sampled.tangentX(); + double scanRadius = Math.max(2.0, roadWidth + 2.0); + double alongTolerance = Math.max(1.25, roadWidth * 0.45); + int minLocalX = Math.max(0, quantizeRoadCoordinate(sampled.worldX() - scanRadius) - chunkMinX); + int maxLocalX = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldX() + scanRadius) - chunkMinX); + int minLocalZ = Math.max(0, quantizeRoadCoordinate(sampled.worldZ() - scanRadius) - chunkMinZ); + int maxLocalZ = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldZ() + scanRadius) - chunkMinZ); + EarthChunkGenerator.RoadLightAnchor bestAnchor = null; + double bestLateral = Double.NEGATIVE_INFINITY; + double bestAlong = Double.POSITIVE_INFINITY; + double bestDistanceSq = Double.POSITIVE_INFINITY; + double minLateral = Double.POSITIVE_INFINITY; + double maxLateral = Double.NEGATIVE_INFINITY; + + for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { + for (int localX = minLocalX; localX <= maxLocalX; localX++) { + int index = chunkIndex(localX, localZ); + if (chunkRoadClass[index] == roadClassId && chunkRoadMode[index] == roadModeId) { + double dx = chunkMinX + localX + 0.5 - sampled.worldX(); + double dz = chunkMinZ + localZ + 0.5 - sampled.worldZ(); + double along = dx * sampled.tangentX() + dz * sampled.tangentZ(); + if (!(Math.abs(along) > alongTolerance)) { + double lateral = dx * normalX + dz * normalZ; + minLateral = Math.min(minLateral, lateral); + maxLateral = Math.max(maxLateral, lateral); + if (!(lateral <= 0.05)) { + double distanceSq = dx * dx + dz * dz; + double absAlong = Math.abs(along); + if (lateral > bestLateral + 1.0E-6 + || Math.abs(lateral - bestLateral) <= 1.0E-6 && absAlong < bestAlong - 1.0E-6 + || Math.abs(lateral - bestLateral) <= 1.0E-6 && Math.abs(absAlong - bestAlong) <= 1.0E-6 && distanceSq < bestDistanceSq) { + bestLateral = lateral; + bestAlong = absAlong; + bestDistanceSq = distanceSq; + bestAnchor = new EarthChunkGenerator.RoadLightAnchor(localX, localZ, chunkRoadDeckY[index], index); + } + } + } + } + } + } + + if (bestAnchor == null || minLateral == Double.POSITIVE_INFINITY || maxLateral == Double.NEGATIVE_INFINITY) { + return null; + } + + double span = maxLateral - minLateral; + if (span < 0.75) { + return null; + } + + if (span > roadWidth + 0.75) { + return null; + } + + return bestAnchor; + } + + private static boolean hasNearbyPreparedRoadLight( + int localX, int localZ, int minSpacingBlocks, EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights + ) { + if (preparedRoadLights == null || preparedRoadLights.isEmpty()) { + return false; + } else { + int minSpacingSq = minSpacingBlocks * minSpacingBlocks; + + for (EarthChunkGenerator.PreparedRoadLight light : preparedRoadLights.lights()) { + int dx = light.localX() - localX; + int dz = light.localZ() - localZ; + if (dx * dx + dz * dz < minSpacingSq) { + return true; + } + } + + return false; + } + } + + private static boolean intersectsRoadLightBridgeSupport( + int localX, + int localZ, + int minY, + int maxY, + boolean[] bridgeSupportShaftPresent, + int[] bridgeSupportShaftBottomY, + int[] bridgeSupportShaftTopY, + boolean[] bridgeSupportCapPresent, + int[] bridgeSupportCapBottomY, + int[] bridgeSupportCapTopY + ) { + int index = chunkIndex(localX, localZ); + return bridgeSupportShaftPresent[index] && spansOverlap(minY, maxY, bridgeSupportShaftBottomY[index], bridgeSupportShaftTopY[index]) + || bridgeSupportCapPresent[index] && spansOverlap(minY, maxY, bridgeSupportCapBottomY[index], bridgeSupportCapTopY[index]); + } + + private static boolean spansOverlap(int minY, int maxY, int otherMinY, int otherMaxY) { + return maxY >= otherMinY && minY <= otherMaxY; + } + + private static int quantizeRoadCoordinate(double value) { + return Mth.floor(value + 0.5); + } + + private static Direction dominantHorizontalDirection(double tangentX, double tangentZ) { + if (Math.abs(tangentX) >= Math.abs(tangentZ)) { + return tangentX >= 0.0 ? Direction.EAST : Direction.WEST; + } else { + return tangentZ >= 0.0 ? Direction.SOUTH : Direction.NORTH; + } + } + + private static BlockState roadLightTrapdoorState(Direction facing) { + return (BlockState)ROAD_LIGHT_TRAPDOOR_BASE_STATE.setValue(BlockStateProperties.HORIZONTAL_FACING, facing); + } + + private static boolean isRoadDeckState(BlockState state) { + 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) { + return state.isAir() + || state.is(Blocks.SNOW) + || state.is(Blocks.POWDER_SNOW) + || state.getFluidState().isEmpty() && state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + } + + private static boolean isTunnelCarveReplaceable(BlockState state) { + return isReplaceableCaveBlock(state) && !isRoadDeckState(state) && !state.is(BRIDGE_SUPPORT_SHAFT_STATE.getBlock()) && !state.is(BRIDGE_SUPPORT_CAP_STATE.getBlock()); + } + + private static boolean[] computeFloodGuardColumns(boolean[] waterFlags) { + boolean[] result = new boolean[CHUNK_AREA]; + + for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { + for (int localX = 0; localX < CHUNK_SIDE; localX++) { + boolean nearWater = false; + + for (int dz = -2; dz <= 2 && !nearWater; dz++) { + int z = localZ + dz; + if (z >= 0 && z < CHUNK_SIDE) { + for (int dx = -2; dx <= 2; dx++) { + int x = localX + dx; + if (x >= 0 && x < CHUNK_SIDE && waterFlags[chunkIndex(x, z)]) { + nearWater = true; + break; + } + } } + } - if (capBottom - maxTerrain[shaftIndex] < requiredClearance) { - continue; + result[chunkIndex(localX, localZ)] = nearWater; + } + } + + return result; + } + + private static boolean isReplaceableCaveBlock(BlockState state) { + return isSolidCaveAnchor(state) && !state.is(Blocks.BEDROCK); + } + + private static boolean isSolidCaveAnchor(BlockState state) { + return !state.isAir() && state.getFluidState().isEmpty() && !state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + } + + private static int chunkIndex(int localX, int localZ) { + return localZ * CHUNK_SIDE + localX; + } + + @SuppressWarnings("unchecked") + private static Holder[] newBiomeCache(int size) { + return (Holder[])new Holder[size]; + } + + private void carveStructureClearanceVolumes(StructureManager structures, ChunkAccess chunk) { + List starts = structures.startsForStructure( + chunk.getPos(), structure -> shouldApplyStructureTerrainAdjustment(structure.terrainAdaptation()) + ); + if (!starts.isEmpty()) { + ChunkPos pos = chunk.getPos(); + int chunkMinX = pos.getMinBlockX(); + int chunkMinZ = pos.getMinBlockZ(); + int chunkMaxX = chunkMinX + 15; + int chunkMaxZ = chunkMinZ + 15; + int chunkMinY = chunk.getMinBuildHeight(); + int chunkMaxY = chunkMinY + chunk.getHeight() - 1; + MutableBlockPos cursor = new MutableBlockPos(); + + for (StructureStart start : starts) { + if (start != null && start.isValid()) { + for (StructurePiece piece : start.getPieces()) { + BoundingBox box = piece.getBoundingBox(); + if (box.intersects(chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ)) { + int centerX = box.minX() + box.maxX() >> 1; + int centerZ = box.minZ() + box.maxZ() >> 1; + int terrainSurface = this.resolveAuxWaterColumn(centerX, centerZ).terrainSurface(); + if (box.maxY() <= terrainSurface - 20) { + int coreMinX = box.minX() - 1; + int coreMaxX = box.maxX() + 1; + int coreMinZ = box.minZ() - 1; + int coreMaxZ = box.maxZ() + 1; + int coreMinY = box.minY() - 0; + int coreMaxY = box.maxY() + 0; + int minX = Math.max(chunkMinX, coreMinX - 6); + int maxX = Math.min(chunkMaxX, coreMaxX + 6); + int minZ = Math.max(chunkMinZ, coreMinZ - 6); + int maxZ = Math.min(chunkMaxZ, coreMaxZ + 6); + int minY = Math.max(chunkMinY + 1, coreMinY - 0); + int maxY = Math.min(chunkMaxY - 1, coreMaxY + 4); + if (maxY >= minY && maxX >= minX && maxZ >= minZ) { + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + double nx = axisDistanceNormalized(x, coreMinX, coreMaxX, 6); + double nz = axisDistanceNormalized(z, coreMinZ, coreMaxZ, 6); + double ny = axisDistanceNormalized(y, coreMinY, coreMaxY, 0, 4); + double distance = Math.sqrt(nx * nx + ny * ny + nz * nz); + double threshold = 1.0 + this.structureClearanceNoiseJitter(x, y, z) * 0.22; + if (!(distance > threshold)) { + cursor.set(x, y, z); + BlockState state = chunk.getBlockState(cursor); + if (isReplaceableCaveBlock(state)) { + chunk.setBlockState(cursor, CAVE_AIR_STATE, false); + } + } + } + } + } + } + } + } } + } + } + } + } + + private double structureClearanceNoiseJitter(int x, int y, int z) { + long seed = seedFromCoords(x, y, z) ^ this.worldSeed ^ 7951840804584193857L; + double t = Math.floorMod(seed, 2048L) / 2047.0; + return t * 2.0 - 1.0; + } + + private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadius) { + if (value < coreMin) { + return (double)(coreMin - value) / Math.max(1, shellRadius); + } else { + return value > coreMax ? (double)(value - coreMax) / Math.max(1, shellRadius) : 0.0; + } + } + + private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadiusBelow, int shellRadiusAbove) { + if (value < coreMin) { + return shellRadiusBelow <= 0 ? Double.POSITIVE_INFINITY : (double)(coreMin - value) / shellRadiusBelow; + } else if (value > coreMax) { + return shellRadiusAbove <= 0 ? Double.POSITIVE_INFINITY : (double)(value - coreMax) / shellRadiusAbove; + } else { + return 0.0; + } + } + + private static boolean shouldApplyStructureTerrainAdjustment(TerrainAdjustment adjustment) { + return adjustment == TerrainAdjustment.BEARD_THIN || adjustment == TerrainAdjustment.BEARD_BOX; + } + + private static int minSurfaceHeight(int[] terrainSurfaces) { + int min = Integer.MAX_VALUE; + + for (int surface : terrainSurfaces) { + if (surface < min) { + min = surface; + } + } + + return min == Integer.MAX_VALUE ? 0 : min; + } + + private EarthChunkGenerator.HeightGridBuildResult buildHeightGrid( + ChunkPos pos, int step, int gridSize, boolean allowCacheReuse, boolean useLocalTerrainInputs + ) { + int[] heightGrid = new int[gridSize * gridSize]; + int cacheHits = 0; + int cacheMisses = 0; + boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); + if (reusableLayout) { + Arrays.fill(heightGrid, Integer.MIN_VALUE); + cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, false); + } + + int gridMinX = pos.getMinBlockX() - step; + int gridMinZ = pos.getMinBlockZ() - step; + for (int dz = 0; dz < gridSize; dz++) { + int worldZ = gridMinZ + dz; + int row = dz * gridSize; + + for (int dx = 0; dx < gridSize; dx++) { + int index = row + dx; + if (!reusableLayout || heightGrid[index] == Integer.MIN_VALUE) { + int worldX = gridMinX + dx; + heightGrid[index] = useLocalTerrainInputs ? this.sampleSurfaceHeightLocalOnly(worldX, worldZ) : this.sampleSurfaceHeight(worldX, worldZ); + cacheMisses++; + } + } + } + + if (reusableLayout) { + this.heightGridCache.put(pos, step, gridSize, heightGrid, false); + } - supportTops[shaftIndex] = capBottom - 1; - if (supportTops[shaftIndex] < minTerrain[shaftIndex]) { - continue; - } + return new EarthChunkGenerator.HeightGridBuildResult(heightGrid, cacheHits, cacheMisses); + } - activeShafts[shaftIndex] = true; - activeShaftCount++; - } + private EarthChunkGenerator.TerrainShellHeightGridResult buildTerrainShellHeightGrid(ChunkPos pos, int step, int gridSize, boolean allowCacheReuse) { + int[] heightGrid = new int[gridSize * gridSize]; + Arrays.fill(heightGrid, Integer.MIN_VALUE); + int cacheHits = 0; + boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); + if (reusableLayout) { + cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, true); + } - if (activeShaftCount == 0) { - return; - } + int initialMisses = 0; + int gridMinX = pos.getMinBlockX() - step; + int gridMinZ = pos.getMinBlockZ() - step; + for (int dz = 0; dz < gridSize; dz++) { + int worldZ = gridMinZ + dz; + int row = dz * gridSize; - for (int i = 0; i < capCells.size(); i++) { - int index = capCells.getInt(i); - if (this.bridgeSupportConflictsBuilding( - index, - capBottom, - capTop, - extMinX, - extMinZ, - extSide, - chunkMinX, - chunkMinZ, - preparedBuildings - )) { - return; + for (int dx = 0; dx < gridSize; dx++) { + int index = row + dx; + if (heightGrid[index] == Integer.MIN_VALUE) { + int worldX = gridMinX + dx; + int sampled = this.sampleSurfaceHeightMemoryOnly(worldX, worldZ); + if (sampled != Integer.MIN_VALUE) { + heightGrid[index] = sampled; + } else { + initialMisses++; } } + } + } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (!activeShafts[shaftIndex]) { - continue; - } + boolean usedFallback = initialMisses > 0; + if (usedFallback) { + this.fillMissingTerrainShellHeights(heightGrid, gridSize); + } - for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { - int index = shaftCells[shaftIndex].getInt(i); - int localBottom = this.bridgeSupportTerrainBottom( - index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - if (supportTops[shaftIndex] < localBottom) { - continue; - } + if (reusableLayout) { + this.heightGridCache.put(pos, step, gridSize, heightGrid, usedFallback); + } - if (this.bridgeSupportConflictsBuilding( - index, - localBottom, - supportTops[shaftIndex], - extMinX, - extMinZ, - extSide, - chunkMinX, - chunkMinZ, - preparedBuildings - )) { - return; - } - } + return new EarthChunkGenerator.TerrainShellHeightGridResult(heightGrid, cacheHits, initialMisses, usedFallback); + } + + private void fillMissingTerrainShellHeights(int[] heightGrid, int gridSize) { + int[] anchors = buildShellAnchorCoordinates(gridSize); + int[][] coarse = new int[anchors.length][anchors.length]; + for (int z = 0; z < coarse.length; z++) { + Arrays.fill(coarse[z], Integer.MIN_VALUE); + } + + for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { + for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { + coarse[anchorZIndex][anchorXIndex] = nearestKnownTerrainHeight(heightGrid, gridSize, anchors[anchorXIndex], anchors[anchorZIndex], 2); + } + } + + int defaultHeight = this.seaLevel; + int knownAnchorCount = 0; + long knownAnchorSum = 0L; + for (int[] coarseRow : coarse) { + for (int coarseHeight : coarseRow) { + if (coarseHeight != Integer.MIN_VALUE) { + knownAnchorSum += coarseHeight; + knownAnchorCount++; } + } + } - for (int i = 0; i < capCells.size(); i++) { - mergeBridgeSupportColumn(capCells.getInt(i), capBottom, capTop, capPresent, capBottomY, capTopY); + if (knownAnchorCount > 0) { + defaultHeight = Mth.floor((double)knownAnchorSum / knownAnchorCount); + } + + defaultHeight = Mth.clamp(defaultHeight, this.minY, this.minY + this.height - 1); + for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { + for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { + if (coarse[anchorZIndex][anchorXIndex] == Integer.MIN_VALUE) { + int replacement = nearestKnownAnchorHeight(coarse, anchorXIndex, anchorZIndex); + coarse[anchorZIndex][anchorXIndex] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; } + } + } - for (int shaftIndex = 0; shaftIndex < style.shaftCount(); shaftIndex++) { - if (!activeShafts[shaftIndex]) { - continue; - } + for (int z = 0; z < gridSize; z++) { + for (int x = 0; x < gridSize; x++) { + int index = z * gridSize + x; + if (heightGrid[index] != Integer.MIN_VALUE) { + continue; + } - for (int i = 0; i < shaftCells[shaftIndex].size(); i++) { - int index = shaftCells[shaftIndex].getInt(i); - int localBottom = this.bridgeSupportTerrainBottom( - index, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ); - if (supportTops[shaftIndex] < localBottom) { - continue; - } + int lowAnchorX = lowerAnchorIndex(anchors, x); + int highAnchorX = upperAnchorIndex(anchors, x); + int lowAnchorZ = lowerAnchorIndex(anchors, z); + int highAnchorZ = upperAnchorIndex(anchors, z); + int h00 = coarse[lowAnchorZ][lowAnchorX]; + int h10 = coarse[lowAnchorZ][highAnchorX]; + int h01 = coarse[highAnchorZ][lowAnchorX]; + int h11 = coarse[highAnchorZ][highAnchorX]; + heightGrid[index] = bilinearInterpolateHeight( + anchors[lowAnchorX], anchors[highAnchorX], anchors[lowAnchorZ], anchors[highAnchorZ], h00, h10, h01, h11, x, z + ); + } + } - mergeBridgeSupportColumn( - index, - localBottom, - supportTops[shaftIndex], - shaftPresent, - shaftBottomY, - shaftTopY - ); - } + for (int z = 0; z < gridSize; z++) { + for (int x = 0; x < gridSize; x++) { + int index = z * gridSize + x; + if (heightGrid[index] == Integer.MIN_VALUE) { + int replacement = nearestKnownTerrainHeight(heightGrid, gridSize, x, z, gridSize); + heightGrid[index] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; } - }); + } } } - private boolean bridgeSupportConflictsBuilding( - int extIndex, - int bottomY, - int topY, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings - ) { - if (topY < bottomY) { - return false; + private static int[] buildShellAnchorCoordinates(int gridSize) { + IntArrayList coords = new IntArrayList(); + for (int index = 0; index < gridSize; index += 4) { + coords.add(index); } - if (preparedBuildings != null) { - int localX = extIndex % extSide; - int localZ = extIndex / extSide; - int worldX = extMinX + localX; - int worldZ = extMinZ + localZ; - int chunkLocalX = worldX - chunkMinX; - int chunkLocalZ = worldZ - chunkMinZ; - if (chunkLocalX >= 0 - && chunkLocalX < CHUNK_SIDE - && chunkLocalZ >= 0 - && chunkLocalZ < CHUNK_SIDE - && preparedBuildings.intersectsSpan(chunkLocalX, chunkLocalZ, bottomY, topY)) { - return true; - } + if (coords.isEmpty() || coords.getInt(coords.size() - 1) != gridSize - 1) { + coords.add(gridSize - 1); } - return false; - } - - private RoadColumnSample bridgeSupportColumnSample( - int extIndex, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache - ) { - int localX = extIndex % extSide; - int localZ = extIndex / extSide; - int worldX = extMinX + localX; - int worldZ = extMinZ + localZ; - return this.sampleRoadColumnForOverlay(worldX, worldZ, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache); + return coords.toIntArray(); } - private int bridgeSupportTerrainBottom( - int extIndex, - int extMinX, - int extMinZ, - int extSide, - int chunkMinX, - int chunkMinZ, - int[] terrainSurfaces, - int[] waterSurfaces, - boolean[] waterFlags, - Long2ObjectOpenHashMap edgeColumnCache - ) { - return this.bridgeSupportColumnSample( - extIndex, extMinX, extMinZ, extSide, chunkMinX, chunkMinZ, terrainSurfaces, waterSurfaces, waterFlags, edgeColumnCache - ) - .terrainSurface(); - } + private static int nearestKnownAnchorHeight(int[][] anchors, int centerX, int centerZ) { + int maxRadius = Math.max(anchors.length, anchors[0].length); + for (int radius = 1; radius <= maxRadius; radius++) { + long sum = 0L; + int count = 0; + for (int z = Math.max(0, centerZ - radius); z <= Math.min(anchors.length - 1, centerZ + radius); z++) { + for (int x = Math.max(0, centerX - radius); x <= Math.min(anchors[z].length - 1, centerX + radius); x++) { + int value = anchors[z][x]; + if (value != Integer.MIN_VALUE) { + sum += value; + count++; + } + } + } - private static void mergeBridgeSupportColumn(int index, int bottomY, int topY, boolean[] present, int[] bottoms, int[] tops) { - if (topY < bottomY) { - return; + if (count > 0) { + return Mth.floor((double)sum / count); + } } - if (!present[index]) { - present[index] = true; - bottoms[index] = bottomY; - tops[index] = topY; - } else { - bottoms[index] = Math.min(bottoms[index], bottomY); - tops[index] = Math.max(tops[index], topY); - } + return Integer.MIN_VALUE; } - private static int extIndex(int localX, int localZ, int side) { - return localZ * side + localX; - } + private static int nearestKnownTerrainHeight(int[] heightGrid, int gridSize, int centerX, int centerZ, int maxRadius) { + if (centerX >= 0 && centerX < gridSize && centerZ >= 0 && centerZ < gridSize) { + int center = heightGrid[centerZ * gridSize + centerX]; + if (center != Integer.MIN_VALUE) { + return center; + } + } - private static long packColumn(int worldX, int worldZ) { - return (long)worldX << 32 ^ worldZ & 4294967295L; - } + for (int radius = 1; radius <= maxRadius; radius++) { + long sum = 0L; + int count = 0; + for (int z = Math.max(0, centerZ - radius); z <= Math.min(gridSize - 1, centerZ + radius); z++) { + for (int x = Math.max(0, centerX - radius); x <= Math.min(gridSize - 1, centerX + radius); x++) { + int value = heightGrid[z * gridSize + x]; + if (value != Integer.MIN_VALUE) { + sum += value; + count++; + } + } + } + if (count > 0) { + return Mth.floor((double)sum / count); + } + } - private static BlockState roadStateForClass(RoadClass roadClass) { - return switch (roadClass) { - case MAIN -> ROAD_MAIN_STATE; - case NORMAL -> ROAD_NORMAL_STATE; - case DIRT -> ROAD_DIRT_STATE; - }; + return Integer.MIN_VALUE; } + private static int lowerAnchorIndex(int[] anchors, int value) { + int best = 0; + for (int index = 0; index < anchors.length; index++) { + if (anchors[index] > value) { + break; + } - private static RoadClass roadClassFromId(int classId) { - return switch (classId) { - case 1 -> RoadClass.MAIN; - case 2 -> RoadClass.NORMAL; - default -> RoadClass.DIRT; - }; - } - - private static int roadClassId(RoadClass roadClass) { - return switch (roadClass) { - case MAIN -> 1; - case NORMAL -> 2; - case DIRT -> 3; - }; - } + best = index; + } - private static int roadWidthForClass(RoadClass roadClass, EarthChunkGenerator.RoadWidths widths) { - return switch (roadClass) { - case MAIN -> widths.main(); - case NORMAL -> widths.normal(); - case DIRT -> widths.dirt(); - }; + return best; } - private static int roadLightSpacingBlocks(double worldScale) { - if (!(worldScale > 0.0)) { - return 40; - } else { - return Mth.clamp((int)Math.round(ROAD_LIGHT_BASE_SPACING_METERS / worldScale), 3, 40); + private static int upperAnchorIndex(int[] anchors, int value) { + for (int index = 0; index < anchors.length; index++) { + if (anchors[index] >= value) { + return index; + } } - } - private static int roadLightMinimumSpacingBlocks(int spacingBlocks) { - return Math.max(3, (int)Math.round(spacingBlocks * 0.75)); + return anchors.length - 1; } - private static int roadLightFenceCount(double worldScale) { - if (worldScale <= 3.0) { - return 3; + private static int bilinearInterpolateHeight( + int x0, int x1, int z0, int z1, int h00, int h10, int h01, int h11, int x, int z + ) { + if (x0 == x1 && z0 == z1) { + return h00; + } else if (x0 == x1) { + double tz = (z - z0) / (double)Math.max(1, z1 - z0); + return Mth.floor(Mth.lerp(tz, h00, h01)); + } else if (z0 == z1) { + double tx = (x - x0) / (double)Math.max(1, x1 - x0); + return Mth.floor(Mth.lerp(tx, h00, h10)); } else { - return worldScale <= 8.0 ? 2 : 1; + double tx = (x - x0) / (double)Math.max(1, x1 - x0); + double tz = (z - z0) / (double)Math.max(1, z1 - z0); + double low = Mth.lerp(tx, h00, h10); + double high = Mth.lerp(tx, h01, h11); + return Mth.floor(Mth.lerp(tz, low, high)); } } - private static EarthChunkGenerator.SampledRoadStation sampleRoadStation( - double[] worldXs, double[] worldZs, double[] segmentStarts, double[] segmentLengths, double station - ) { - for (int i = 0; i < segmentLengths.length; i++) { - double segmentLength = segmentLengths[i]; - if (!(segmentLength <= 1.0E-6)) { - double segmentStart = segmentStarts[i]; - double segmentEnd = segmentStart + segmentLength; - if (station <= segmentEnd + 1.0E-6 || i == segmentLengths.length - 1) { - double dx = worldXs[i + 1] - worldXs[i]; - double dz = worldZs[i + 1] - worldZs[i]; - double t = Mth.clamp((station - segmentStart) / segmentLength, 0.0, 1.0); - return new EarthChunkGenerator.SampledRoadStation(worldXs[i] + dx * t, worldZs[i] + dz * t, dx / segmentLength, dz / segmentLength); - } - } - } + private static boolean isReusableHeightGridLayout(int step, int gridSize) { + return step == 4 && gridSize == 24; + } - return null; + private static String sampleKoppenCode(int blockX, int blockZ, double worldScale) { + String koppen = KOPPEN_SOURCE.sampleDitheredCode(blockX, blockZ, worldScale); + return koppen != null ? koppen : KOPPEN_SOURCE.findNearestCode(blockX, blockZ, worldScale); } - private static EarthChunkGenerator.RoadLightAnchor findRoadLightAnchor( - EarthChunkGenerator.SampledRoadStation sampled, - boolean placeLeft, - int roadWidth, - int roadClassId, - int roadModeId, - int chunkMinX, - int chunkMinZ, - byte[] chunkRoadClass, - byte[] chunkRoadMode, - int[] chunkRoadDeckY + private void repairAnomalousChunkTerrain( + int[] terrainSurfaces, int[] waterSurfaces, boolean[] waterFlags, int[] coverClasses, int[] heightGrid, int gridSize, int step, int minY, int maxY ) { - double normalX = placeLeft ? -sampled.tangentZ() : sampled.tangentZ(); - double normalZ = placeLeft ? sampled.tangentX() : -sampled.tangentX(); - double scanRadius = Math.max(2.0, roadWidth + 2.0); - double alongTolerance = Math.max(1.25, roadWidth * 0.45); - int minLocalX = Math.max(0, quantizeRoadCoordinate(sampled.worldX() - scanRadius) - chunkMinX); - int maxLocalX = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldX() + scanRadius) - chunkMinX); - int minLocalZ = Math.max(0, quantizeRoadCoordinate(sampled.worldZ() - scanRadius) - chunkMinZ); - int maxLocalZ = Math.min(CHUNK_MASK, quantizeRoadCoordinate(sampled.worldZ() + scanRadius) - chunkMinZ); - EarthChunkGenerator.RoadLightAnchor bestAnchor = null; - double bestLateral = Double.NEGATIVE_INFINITY; - double bestAlong = Double.POSITIVE_INFINITY; - double bestDistanceSq = Double.POSITIVE_INFINITY; - double minLateral = Double.POSITIVE_INFINITY; - double maxLateral = Double.NEGATIVE_INFINITY; + int[] repaired = (int[])terrainSurfaces.clone(); - for (int localZ = minLocalZ; localZ <= maxLocalZ; localZ++) { - for (int localX = minLocalX; localX <= maxLocalX; localX++) { - int index = chunkIndex(localX, localZ); - if (chunkRoadClass[index] == roadClassId && chunkRoadMode[index] == roadModeId) { - double dx = chunkMinX + localX + 0.5 - sampled.worldX(); - double dz = chunkMinZ + localZ + 0.5 - sampled.worldZ(); - double along = dx * sampled.tangentX() + dz * sampled.tangentZ(); - if (!(Math.abs(along) > alongTolerance)) { - double lateral = dx * normalX + dz * normalZ; - minLateral = Math.min(minLateral, lateral); - maxLateral = Math.max(maxLateral, lateral); - if (!(lateral <= 0.05)) { - double distanceSq = dx * dx + dz * dz; - double absAlong = Math.abs(along); - if (lateral > bestLateral + 1.0E-6 - || Math.abs(lateral - bestLateral) <= 1.0E-6 && absAlong < bestAlong - 1.0E-6 - || Math.abs(lateral - bestLateral) <= 1.0E-6 && Math.abs(absAlong - bestAlong) <= 1.0E-6 && distanceSq < bestDistanceSq) { - bestLateral = lateral; - bestAlong = absAlong; - bestDistanceSq = distanceSq; - bestAnchor = new EarthChunkGenerator.RoadLightAnchor(localX, localZ, chunkRoadDeckY[index], index); - } + for (int pass = 0; pass < 4; pass++) { + int[] source = (int[])repaired.clone(); + boolean changed = false; + + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int index = chunkIndex(localX, localZ); + int surface = source[index]; + if (this.shouldRepairTerrainAnomaly(coverClasses[index], surface, waterFlags[index])) { + int gridIndex = (localZ + step) * gridSize + localX + step; + int east = sampleNeighborHeight(source, localX + 1, localZ, heightGrid, gridIndex + 1); + int west = sampleNeighborHeight(source, localX - 1, localZ, heightGrid, gridIndex - 1); + int north = sampleNeighborHeight(source, localX, localZ - 1, heightGrid, gridIndex - gridSize); + int south = sampleNeighborHeight(source, localX, localZ + 1, heightGrid, gridIndex + gridSize); + int northEast = sampleNeighborHeight(source, localX + 1, localZ - 1, heightGrid, gridIndex - gridSize + 1); + int northWest = sampleNeighborHeight(source, localX - 1, localZ - 1, heightGrid, gridIndex - gridSize - 1); + int southEast = sampleNeighborHeight(source, localX + 1, localZ + 1, heightGrid, gridIndex + gridSize + 1); + int southWest = sampleNeighborHeight(source, localX - 1, localZ + 1, heightGrid, gridIndex + gridSize - 1); + int repairedHeight = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); + if (repairedHeight != surface) { + repaired[index] = Mth.clamp(repairedHeight, minY, maxY); + changed = true; } } } } - } - - if (bestAnchor == null || minLateral == Double.POSITIVE_INFINITY || maxLateral == Double.NEGATIVE_INFINITY) { - return null; - } - double span = maxLateral - minLateral; - if (span < 0.75) { - return null; - } + for (int localZ = 0; localZ < 16; localZ++) { + for (int localXx = 0; localXx < 16; localXx++) { + int index = chunkIndex(localXx, localZ); + int gridIndex = (localZ + step) * gridSize + localXx + step; + heightGrid[gridIndex] = repaired[index]; + } + } - if (span > roadWidth + 0.75) { - return null; + if (!changed) { + break; + } } - return bestAnchor; - } - - private static boolean hasNearbyPreparedRoadLight( - int localX, int localZ, int minSpacingBlocks, EarthChunkGenerator.PreparedChunkRoadLights preparedRoadLights - ) { - if (preparedRoadLights == null || preparedRoadLights.isEmpty()) { - return false; - } else { - int minSpacingSq = minSpacingBlocks * minSpacingBlocks; + System.arraycopy(repaired, 0, terrainSurfaces, 0, repaired.length); - for (EarthChunkGenerator.PreparedRoadLight light : preparedRoadLights.lights()) { - int dx = light.localX() - localX; - int dz = light.localZ() - localZ; - if (dx * dx + dz * dz < minSpacingSq) { - return true; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localXx = 0; localXx < 16; localXx++) { + int index = chunkIndex(localXx, localZ); + if (!waterFlags[index]) { + waterSurfaces[index] = terrainSurfaces[index]; + } else if (terrainSurfaces[index] >= waterSurfaces[index]) { + waterFlags[index] = false; + waterSurfaces[index] = terrainSurfaces[index]; } - } - return false; + int gridIndex = (localZ + step) * gridSize + localXx + step; + heightGrid[gridIndex] = terrainSurfaces[index]; + } } } - private static boolean intersectsRoadLightBridgeSupport( - int localX, - int localZ, - int minY, - int maxY, - boolean[] bridgeSupportShaftPresent, - int[] bridgeSupportShaftBottomY, - int[] bridgeSupportShaftTopY, - boolean[] bridgeSupportCapPresent, - int[] bridgeSupportCapBottomY, - int[] bridgeSupportCapTopY - ) { - int index = chunkIndex(localX, localZ); - return bridgeSupportShaftPresent[index] && spansOverlap(minY, maxY, bridgeSupportShaftBottomY[index], bridgeSupportShaftTopY[index]) - || bridgeSupportCapPresent[index] && spansOverlap(minY, maxY, bridgeSupportCapBottomY[index], bridgeSupportCapTopY[index]); - } - - private static boolean spansOverlap(int minY, int maxY, int otherMinY, int otherMaxY) { - return maxY >= otherMinY && minY <= otherMaxY; + private static int sampleNeighborHeight(int[] chunkSurfaces, int localX, int localZ, int[] heightGrid, int fallbackGridIndex) { + return localX >= 0 && localX < 16 && localZ >= 0 && localZ < 16 ? chunkSurfaces[chunkIndex(localX, localZ)] : heightGrid[fallbackGridIndex]; } - private static int quantizeRoadCoordinate(double value) { - return Mth.floor(value + 0.5); + private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY) { + return this.repairAnomalousSurfaceHeight(worldX, worldZ, surface, coverClass, minY, maxY, false); } - private static Direction dominantHorizontalDirection(double tangentX, double tangentZ) { - if (Math.abs(tangentX) >= Math.abs(tangentZ)) { - return tangentX >= 0.0 ? Direction.EAST : Direction.WEST; + private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY, boolean hasWater) { + if (!this.shouldRepairTerrainAnomaly(coverClass, surface, hasWater)) { + return Mth.clamp(surface, minY, maxY); } else { - return tangentZ >= 0.0 ? Direction.SOUTH : Direction.NORTH; + int east = this.sampleSurfaceHeight(worldX + 1, worldZ); + int west = this.sampleSurfaceHeight(worldX - 1, worldZ); + int north = this.sampleSurfaceHeight(worldX, worldZ - 1); + int south = this.sampleSurfaceHeight(worldX, worldZ + 1); + int northEast = this.sampleSurfaceHeight(worldX + 1, worldZ - 1); + int northWest = this.sampleSurfaceHeight(worldX - 1, worldZ - 1); + int southEast = this.sampleSurfaceHeight(worldX + 1, worldZ + 1); + int southWest = this.sampleSurfaceHeight(worldX - 1, worldZ + 1); + int repaired = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); + return Mth.clamp(repaired, minY, maxY); } } - private static BlockState roadLightTrapdoorState(Direction facing) { - return (BlockState)ROAD_LIGHT_TRAPDOOR_BASE_STATE.setValue(BlockStateProperties.HORIZONTAL_FACING, facing); + private boolean shouldRepairTerrainAnomaly(int coverClass, int surface, boolean hasWater) { + int heightAboveSea = surface - this.seaLevel; + return heightAboveSea < 50 ? false : !hasWater && !isWaterCoverClass(coverClass) || heightAboveSea >= 90; } - private static boolean isRoadDeckState(BlockState state) { - return state.is(Blocks.GRAY_CONCRETE) || state.is(Blocks.CYAN_TERRACOTTA) || state.is(Blocks.DIRT_PATH); + private static boolean isWaterCoverClass(int coverClass) { + return coverClass == 80 || coverClass == 95; } - private static boolean isRoadLightReplaceable(BlockState state) { - return state.isAir() - || state.is(Blocks.SNOW) - || state.is(Blocks.POWDER_SNOW) - || state.getFluidState().isEmpty() && state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); + private static int repairAnomalousTerrainHeightFromNeighbors( + int center, int east, int west, int north, int south, int northEast, int northWest, int southEast, int southWest + ) { + return TerrainAnomalyRepair.repairHeightFromNeighbors(center, east, west, north, south, northEast, northWest, southEast, southWest); } - private static boolean isTunnelCarveReplaceable(BlockState state) { - return isReplaceableCaveBlock(state) && !isRoadDeckState(state) && !state.is(BRIDGE_SUPPORT_SHAFT_STATE.getBlock()) && !state.is(BRIDGE_SUPPORT_CAP_STATE.getBlock()); + private static int resolveSolidSectionMaxIndex(ChunkAccess chunk, int chunkMinY, int minSurface, int sectionCount) { + if (sectionCount == 0) { + return -1; + } else { + int sectionIndex = chunk.getSectionIndex(minSurface); + int sectionBottom = chunkMinY + (sectionIndex << 4); + int sectionTop = sectionBottom + 15; + int solidMaxIndex = minSurface >= sectionTop ? sectionIndex : sectionIndex - 1; + return solidMaxIndex < 0 ? -1 : Math.min(solidMaxIndex, sectionCount - 1); + } } - private static boolean[] computeFloodGuardColumns(boolean[] waterFlags) { - boolean[] result = new boolean[CHUNK_AREA]; - - for (int localZ = 0; localZ < CHUNK_SIDE; localZ++) { - for (int localX = 0; localX < CHUNK_SIDE; localX++) { - boolean nearWater = false; - - for (int dz = -2; dz <= 2 && !nearWater; dz++) { - int z = localZ + dz; - if (z >= 0 && z < CHUNK_SIDE) { - for (int dx = -2; dx <= 2; dx++) { - int x = localX + dx; - if (x >= 0 && x < CHUNK_SIDE && waterFlags[chunkIndex(x, z)]) { - nearWater = true; - break; - } - } - } - } + private static void fillSolidSections( + LevelChunkSection[] sections, + boolean[] solidSections, + int[] sectionTopYs, + int solidMaxIndex, + BlockState stone, + BlockState deepslate, + int deepslateStart, + EarthChunkGenerator.SolidSectionFillProfiler profiler + ) { + int sectionCount = sections.length; + long sectionScanStartNs = beginFullChunkProfiling(); - result[chunkIndex(localX, localZ)] = nearWater; + for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { + int topY = sectionTopYs[i]; + int bottomY = topY - 15; + if (bottomY >= 0 || topY < 0) { + BlockState fill = topY < deepslateStart ? deepslate : stone; + long sectionAccessStartNs = beginFullChunkProfiling(); + LevelChunkSection section = Objects.requireNonNull(sections[i], "section"); + profiler.sectionAccessNs += elapsedFullChunkProfilingSince(sectionAccessStartNs); + fillSection(section, fill, profiler); + solidSections[i] = true; } } - return result; - } - - private static boolean isReplaceableCaveBlock(BlockState state) { - return isSolidCaveAnchor(state) && !state.is(Blocks.BEDROCK); + long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); + long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; + profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private static boolean isSolidCaveAnchor(BlockState state) { - return !state.isAir() && state.getFluidState().isEmpty() && !state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO).isEmpty(); - } + private static void fillSolidSections( + EarthChunkGenerator.ChunkSectionWriter writer, + boolean[] solidSections, + int[] sectionTopYs, + int solidMaxIndex, + BlockState stone, + BlockState deepslate, + int deepslateStart, + EarthChunkGenerator.SolidSectionFillProfiler profiler + ) { + int sectionCount = sectionTopYs.length; + long sectionScanStartNs = beginFullChunkProfiling(); - private static int chunkIndex(int localX, int localZ) { - return localZ * CHUNK_SIDE + localX; - } + for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { + int topY = sectionTopYs[i]; + int bottomY = topY - 15; + if (bottomY >= 0 || topY < 0) { + BlockState fill = topY < deepslateStart ? deepslate : stone; + writer.fillSectionConstant(i, fill, profiler); + solidSections[i] = true; + } + } - @SuppressWarnings("unchecked") - private static Holder[] newBiomeCache(int size) { - return (Holder[])new Holder[size]; + long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); + long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; + profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private void carveStructureClearanceVolumes(StructureManager structures, ChunkAccess chunk) { - List starts = structures.startsForStructure( - chunk.getPos(), structure -> shouldApplyStructureTerrainAdjustment(structure.terrainAdaptation()) - ); - if (!starts.isEmpty()) { - ChunkPos pos = chunk.getPos(); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); - int chunkMaxX = chunkMinX + 15; - int chunkMaxZ = chunkMinZ + 15; - int chunkMinY = chunk.getMinBuildHeight(); - int chunkMaxY = chunkMinY + chunk.getHeight() - 1; - MutableBlockPos cursor = new MutableBlockPos(); + private static void fillSection(LevelChunkSection section, BlockState fill, EarthChunkGenerator.SolidSectionFillProfiler profiler) { + PalettedContainer states = section.getStates(); + long sectionWriteStartNs = beginFullChunkProfiling(); + section.acquire(); - for (StructureStart start : starts) { - if (start != null && start.isValid()) { - for (StructurePiece piece : start.getPieces()) { - BoundingBox box = piece.getBoundingBox(); - if (box.intersects(chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ)) { - int centerX = box.minX() + box.maxX() >> 1; - int centerZ = box.minZ() + box.maxZ() >> 1; - int terrainSurface = this.resolveAuxWaterColumn(centerX, centerZ).terrainSurface(); - if (box.maxY() <= terrainSurface - 20) { - int coreMinX = box.minX() - 1; - int coreMaxX = box.maxX() + 1; - int coreMinZ = box.minZ() - 1; - int coreMaxZ = box.maxZ() + 1; - int coreMinY = box.minY() - 0; - int coreMaxY = box.maxY() + 0; - int minX = Math.max(chunkMinX, coreMinX - 6); - int maxX = Math.min(chunkMaxX, coreMaxX + 6); - int minZ = Math.max(chunkMinZ, coreMinZ - 6); - int maxZ = Math.min(chunkMaxZ, coreMaxZ + 6); - int minY = Math.max(chunkMinY + 1, coreMinY - 0); - int maxY = Math.min(chunkMaxY - 1, coreMaxY + 4); - if (maxY >= minY && maxX >= minX && maxZ >= minZ) { - for (int z = minZ; z <= maxZ; z++) { - for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - double nx = axisDistanceNormalized(x, coreMinX, coreMaxX, 6); - double nz = axisDistanceNormalized(z, coreMinZ, coreMaxZ, 6); - double ny = axisDistanceNormalized(y, coreMinY, coreMaxY, 0, 4); - double distance = Math.sqrt(nx * nx + ny * ny + nz * nz); - double threshold = 1.0 + this.structureClearanceNoiseJitter(x, y, z) * 0.22; - if (!(distance > threshold)) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (isReplaceableCaveBlock(state)) { - chunk.setBlockState(cursor, CAVE_AIR_STATE, false); - } - } - } - } - } - } - } - } + try { + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + states.getAndSetUnchecked(x, y, z, fill); } } } + } finally { + section.release(); } - } + profiler.sectionWriteNs += elapsedFullChunkProfilingSince(sectionWriteStartNs); - private double structureClearanceNoiseJitter(int x, int y, int z) { - long seed = seedFromCoords(x, y, z) ^ this.worldSeed ^ 7951840804584193857L; - double t = Math.floorMod(seed, 2048L) / 2047.0; - return t * 2.0 - 1.0; + long sectionRecalcStartNs = beginFullChunkProfiling(); + section.recalcBlockCounts(); + profiler.recalcNs += elapsedFullChunkProfilingSince(sectionRecalcStartNs); } - private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadius) { - if (value < coreMin) { - return (double)(coreMin - value) / Math.max(1, shellRadius); - } else { - return value > coreMax ? (double)(value - coreMax) / Math.max(1, shellRadius) : 0.0; + private static void fillStoneColumnSpan( + LevelChunkSection[] sections, + boolean[] touchedSections, + int chunkMinY, + int localX, + int localZ, + int startY, + int endY, + int deepslateStart, + BlockState stone, + BlockState deepslate + ) { + if (endY < startY) { + return; } - } - private static double axisDistanceNormalized(int value, int coreMin, int coreMax, int shellRadiusBelow, int shellRadiusAbove) { - if (value < coreMin) { - return shellRadiusBelow <= 0 ? Double.POSITIVE_INFINITY : (double)(coreMin - value) / shellRadiusBelow; - } else if (value > coreMax) { - return shellRadiusAbove <= 0 ? Double.POSITIVE_INFINITY : (double)(value - coreMax) / shellRadiusAbove; - } else { - return 0.0; + int sectionIndex = (startY - chunkMinY) >> 4; + if (sectionIndex < 0 || sectionIndex >= sections.length) { + return; } - } - - private static boolean shouldApplyStructureTerrainAdjustment(TerrainAdjustment adjustment) { - return adjustment == TerrainAdjustment.BEARD_THIN || adjustment == TerrainAdjustment.BEARD_BOX; - } - private static int minSurfaceHeight(int[] terrainSurfaces) { - int min = Integer.MAX_VALUE; + LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); + PalettedContainer states = section.getStates(); + int sectionBottomY = chunkMinY + (sectionIndex << 4); + section.acquire(); - for (int surface : terrainSurfaces) { - if (surface < min) { - min = surface; + try { + for (int worldY = startY; worldY <= endY; worldY++) { + states.getAndSetUnchecked(localX, worldY - sectionBottomY, localZ, worldY < deepslateStart ? deepslate : stone); } + } finally { + section.release(); } - return min == Integer.MAX_VALUE ? 0 : min; + touchedSections[sectionIndex] = true; } - private EarthChunkGenerator.HeightGridBuildResult buildHeightGrid( - ChunkPos pos, int step, int gridSize, boolean allowCacheReuse, boolean useLocalTerrainInputs + private static void fillColumnConstant( + LevelChunkSection[] sections, boolean[] touchedSections, int chunkMinY, int localX, int localZ, int startY, int endY, BlockState state ) { - int[] heightGrid = new int[gridSize * gridSize]; - int cacheHits = 0; - int cacheMisses = 0; - boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); - if (reusableLayout) { - Arrays.fill(heightGrid, Integer.MIN_VALUE); - cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, false); + if (endY < startY) { + return; } - int gridMinX = pos.getMinBlockX() - step; - int gridMinZ = pos.getMinBlockZ() - step; - for (int dz = 0; dz < gridSize; dz++) { - int worldZ = gridMinZ + dz; - int row = dz * gridSize; + int currentY = startY; + while (currentY <= endY) { + int sectionIndex = (currentY - chunkMinY) >> 4; + if (sectionIndex < 0 || sectionIndex >= sections.length) { + break; + } - for (int dx = 0; dx < gridSize; dx++) { - int index = row + dx; - if (!reusableLayout || heightGrid[index] == Integer.MIN_VALUE) { - int worldX = gridMinX + dx; - heightGrid[index] = useLocalTerrainInputs ? this.sampleSurfaceHeightLocalOnly(worldX, worldZ) : this.sampleSurfaceHeight(worldX, worldZ); - cacheMisses++; + LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); + PalettedContainer states = section.getStates(); + int sectionBottomY = chunkMinY + (sectionIndex << 4); + int localStartY = Math.max(0, currentY - sectionBottomY); + int localEndY = Math.min(15, endY - sectionBottomY); + section.acquire(); + + try { + for (int localY = localStartY; localY <= localEndY; localY++) { + states.getAndSetUnchecked(localX, localY, localZ, state); } + } finally { + section.release(); } - } - if (reusableLayout) { - this.heightGridCache.put(pos, step, gridSize, heightGrid, false); + touchedSections[sectionIndex] = true; + currentY = sectionBottomY + 16; } - - return new EarthChunkGenerator.HeightGridBuildResult(heightGrid, cacheHits, cacheMisses); } - private EarthChunkGenerator.TerrainShellHeightGridResult buildTerrainShellHeightGrid(ChunkPos pos, int step, int gridSize, boolean allowCacheReuse) { - int[] heightGrid = new int[gridSize * gridSize]; - Arrays.fill(heightGrid, Integer.MIN_VALUE); - int cacheHits = 0; - boolean reusableLayout = allowCacheReuse && isReusableHeightGridLayout(step, gridSize); - if (reusableLayout) { - cacheHits = this.heightGridCache.copyOverlaps(pos, step, gridSize, heightGrid, true); + private static void recalcFilledSections(LevelChunkSection[] sections, boolean[] solidSections, boolean[] touchedSections) { + for (int i = 0; i < sections.length && i < touchedSections.length; i++) { + if (touchedSections[i] && !solidSections[i]) { + Objects.requireNonNull(sections[i], "section").recalcBlockCounts(); + } } + } - int initialMisses = 0; - int gridMinX = pos.getMinBlockX() - step; - int gridMinZ = pos.getMinBlockZ() - step; - for (int dz = 0; dz < gridSize; dz++) { - int worldZ = gridMinZ + dz; - int row = dz * gridSize; + private void filterVillageStarts(RegistryAccess registryAccess, ChunkAccess chunk) { + Map starts = chunk.getAllStarts(); + if (!starts.isEmpty()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - for (int dx = 0; dx < gridSize; dx++) { - int index = row + dx; - if (heightGrid[index] == Integer.MIN_VALUE) { - int worldX = gridMinX + dx; - int sampled = this.sampleSurfaceHeightMemoryOnly(worldX, worldZ); - if (sampled != Integer.MIN_VALUE) { - heightGrid[index] = sampled; - } else { - initialMisses++; + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.isVillageStructure(registry, structure) && this.isVillageStartTooSteep(start)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); } } } } - - boolean usedFallback = initialMisses > 0; - if (usedFallback) { - this.fillMissingTerrainShellHeights(heightGrid, gridSize); - } - - if (reusableLayout) { - this.heightGridCache.put(pos, step, gridSize, heightGrid, usedFallback); - } - - return new EarthChunkGenerator.TerrainShellHeightGridResult(heightGrid, cacheHits, initialMisses, usedFallback); } - private void fillMissingTerrainShellHeights(int[] heightGrid, int gridSize) { - int[] anchors = buildShellAnchorCoordinates(gridSize); - int[][] coarse = new int[anchors.length][anchors.length]; - for (int z = 0; z < coarse.length; z++) { - Arrays.fill(coarse[z], Integer.MIN_VALUE); - } + private boolean isVillageStartTooSteep(StructureStart start) { + return this.isStructureStartTooSteep(start, 4, 4, 6); + } - for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { - for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { - coarse[anchorZIndex][anchorXIndex] = nearestKnownTerrainHeight(heightGrid, gridSize, anchors[anchorXIndex], anchors[anchorZIndex], 2); - } - } + private void filterWoodlandMansionStarts(RegistryAccess registryAccess, ChunkAccess chunk) { + Map starts = chunk.getAllStarts(); + if (!starts.isEmpty()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - int defaultHeight = this.seaLevel; - int knownAnchorCount = 0; - long knownAnchorSum = 0L; - for (int[] coarseRow : coarse) { - for (int coarseHeight : coarseRow) { - if (coarseHeight != Integer.MIN_VALUE) { - knownAnchorSum += coarseHeight; - knownAnchorCount++; + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.isWoodlandMansionStructure(registry, structure) && this.isWoodlandMansionStartTooSteep(start)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + } } } } + } - if (knownAnchorCount > 0) { - defaultHeight = Mth.floor((double)knownAnchorSum / knownAnchorCount); - } + private boolean isWoodlandMansionStartTooSteep(StructureStart start) { + return this.isStructureStartTooSteep(start, 8, 6, 8); + } - defaultHeight = Mth.clamp(defaultHeight, this.minY, this.minY + this.height - 1); - for (int anchorZIndex = 0; anchorZIndex < anchors.length; anchorZIndex++) { - for (int anchorXIndex = 0; anchorXIndex < anchors.length; anchorXIndex++) { - if (coarse[anchorZIndex][anchorXIndex] == Integer.MIN_VALUE) { - int replacement = nearestKnownAnchorHeight(coarse, anchorXIndex, anchorZIndex); - coarse[anchorZIndex][anchorXIndex] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; + private void filterStartsCollidingWithOsm(RegistryAccess registryAccess, ChunkAccess chunk) { + double worldScale = this.settings.worldScale(); + 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()) { + Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); + EarthChunkGenerator.RoadWidths roadWidths = roadsActive ? resolveRoadWidths(worldScale) : null; + + for (Entry entry : starts.entrySet()) { + StructureStart start = entry.getValue(); + if (start != null && start.isValid()) { + Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); + if (this.shouldAvoidOsmCollision(registry, structure) + && this.doesStructureStartCollideWithOsm(start, roadsActive, buildingsActive, roadWidths)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + } + } } } } + } - for (int z = 0; z < gridSize; z++) { - for (int x = 0; x < gridSize; x++) { - int index = z * gridSize + x; - if (heightGrid[index] != Integer.MIN_VALUE) { - continue; - } + private boolean doesStructureStartCollideWithOsm( + StructureStart start, boolean roadsActive, boolean buildingsActive, EarthChunkGenerator.RoadWidths roadWidths + ) { + BoundingBox box = start.getBoundingBox(); + if (buildingsActive && this.structureCollidesWithBuildings(box)) { + return true; + } else { + return roadsActive && roadWidths != null ? this.structureCollidesWithRoads(box, roadWidths) : false; + } + } - int lowAnchorX = lowerAnchorIndex(anchors, x); - int highAnchorX = upperAnchorIndex(anchors, x); - int lowAnchorZ = lowerAnchorIndex(anchors, z); - int highAnchorZ = upperAnchorIndex(anchors, z); - int h00 = coarse[lowAnchorZ][lowAnchorX]; - int h10 = coarse[lowAnchorZ][highAnchorX]; - int h01 = coarse[highAnchorZ][lowAnchorX]; - int h11 = coarse[highAnchorZ][highAnchorX]; - heightGrid[index] = bilinearInterpolateHeight( - anchors[lowAnchorX], anchors[highAnchorX], anchors[lowAnchorZ], anchors[highAnchorZ], h00, h10, h01, h11, x, z - ); - } + private boolean structureCollidesWithBuildings(BoundingBox box) { + int marginBlocks = 2; + double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); + double minX = structureFootprintMinX(box); + double maxX = structureFootprintMaxX(box); + double minZ = structureFootprintMinZ(box); + double maxZ = structureFootprintMaxZ(box); + OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; + EarthChunkGenerator.OsmBuildingQueryResult query = this.fetchOsmBuildingsForAreaDetailed( + box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode + ); + if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { + EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); + return false; } - for (int z = 0; z < gridSize; z++) { - for (int x = 0; x < gridSize; x++) { - int index = z * gridSize + x; - if (heightGrid[index] == Integer.MIN_VALUE) { - int replacement = nearestKnownTerrainHeight(heightGrid, gridSize, x, z, gridSize); - heightGrid[index] = replacement != Integer.MIN_VALUE ? replacement : defaultHeight; - } + for (OsmBuildingFeature building : query.features()) { + if (this.structureIntersectsBuilding(box, building, blocksPerDegree, minX, minZ, maxX, maxZ)) { + return true; } } + + return false; } - private static int[] buildShellAnchorCoordinates(int gridSize) { - IntArrayList coords = new IntArrayList(); - for (int index = 0; index < gridSize; index += 4) { - coords.add(index); + private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { + 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 + ); + if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { + EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); + return false; } - if (coords.isEmpty() || coords.getInt(coords.size() - 1) != gridSize - 1) { - coords.add(gridSize - 1); + double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); + double worldScale = this.settings.worldScale(); + + for (RoadFeature road : query.features()) { + if (road.mode() != RoadMode.TUNNEL && this.structureIntersectsRoad(box, road, roadWidths, blocksPerDegree, worldScale)) { + return true; + } } - return coords.toIntArray(); + return false; } - private static int nearestKnownAnchorHeight(int[][] anchors, int centerX, int centerZ) { - int maxRadius = Math.max(anchors.length, anchors[0].length); - for (int radius = 1; radius <= maxRadius; radius++) { - long sum = 0L; - int count = 0; - for (int z = Math.max(0, centerZ - radius); z <= Math.min(anchors.length - 1, centerZ + radius); z++) { - for (int x = Math.max(0, centerX - radius); x <= Math.min(anchors[z].length - 1, centerX + radius); x++) { - int value = anchors[z][x]; - if (value != Integer.MIN_VALUE) { - sum += value; - count++; + private boolean structureIntersectsBuilding( + BoundingBox box, OsmBuildingFeature building, double blocksPerDegree, double minX, double minZ, double maxX, double maxZ + ) { + if (building.maxBlockX(blocksPerDegree) < minX + || building.minBlockX(blocksPerDegree) > maxX + || building.maxBlockZ(this.settings.worldScale()) < minZ + || building.minBlockZ(this.settings.worldScale()) > maxZ) { + return false; + } else if (building.containsWorld(minX, minZ, this.settings.worldScale()) + || building.containsWorld(minX, maxZ, this.settings.worldScale()) + || building.containsWorld(maxX, minZ, this.settings.worldScale()) + || building.containsWorld(maxX, maxZ, this.settings.worldScale())) { + return true; + } else { + for (int part = 0; part < building.partCount(); part++) { + int points = building.pointCount(part); + if (points >= 2) { + double previousX = building.lonAt(part, points - 1) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(building.latAt(part, points - 1), this.settings.worldScale()); + + for (int i = 0; i < points; i++) { + double currentX = building.lonAt(part, i) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(building.latAt(part, i), this.settings.worldScale()); + if (pointInRect(currentX, currentZ, minX, minZ, maxX, maxZ) + || segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { + return true; + } + + previousX = currentX; + previousZ = currentZ; } } } - if (count > 0) { - return Mth.floor((double)sum / count); - } + return false; } - - return Integer.MIN_VALUE; } - private static int nearestKnownTerrainHeight(int[] heightGrid, int gridSize, int centerX, int centerZ, int maxRadius) { - if (centerX >= 0 && centerX < gridSize && centerZ >= 0 && centerZ < gridSize) { - int center = heightGrid[centerZ * gridSize + centerX]; - if (center != Integer.MIN_VALUE) { - return center; - } - } - - for (int radius = 1; radius <= maxRadius; radius++) { - long sum = 0L; - int count = 0; - for (int z = Math.max(0, centerZ - radius); z <= Math.min(gridSize - 1, centerZ + radius); z++) { - for (int x = Math.max(0, centerX - radius); x <= Math.min(gridSize - 1, centerX + radius); x++) { - int value = heightGrid[z * gridSize + x]; - if (value != Integer.MIN_VALUE) { - sum += value; - count++; + private boolean structureIntersectsRoad( + BoundingBox box, RoadFeature road, EarthChunkGenerator.RoadWidths roadWidths, double blocksPerDegree, double worldScale + ) { + 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; + double minZ = structureFootprintMinZ(box) - halfWidth; + double maxZ = structureFootprintMaxZ(box) + halfWidth; + int pointCount = road.pointCount(); + if (pointCount < 2) { + return false; + } else { + double previousX = road.lonAt(0) * blocksPerDegree; + double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); + if (pointInRect(previousX, previousZ, minX, minZ, maxX, maxZ)) { + return true; + } else { + for (int i = 1; i < pointCount; i++) { + double currentX = road.lonAt(i) * blocksPerDegree; + double currentZ = EarthProjection.latToBlockZ(road.latAt(i), worldScale); + if (segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { + return true; } + + previousX = currentX; + previousZ = currentZ; } - } - if (count > 0) { - return Mth.floor((double)sum / count); + return false; } } - - return Integer.MIN_VALUE; } - private static int lowerAnchorIndex(int[] anchors, int value) { - int best = 0; - for (int index = 0; index < anchors.length; index++) { - if (anchors[index] > value) { - break; - } - - best = index; + private boolean shouldAvoidOsmCollision(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + if (key == null) { + return false; + } else { + String path = key.getPath(); + return path.startsWith("village") + || path.equals("woodland_mansion") + || path.equals("desert_pyramid") + || path.equals("desert_temple") + || path.equals("jungle_pyramid") + || path.equals("jungle_temple") + || path.equals("pillager_outpost") + || path.equals("igloo") + || path.equals("swamp_hut") + || path.equals("witch_hut") + || path.startsWith("ruined_portal") + || path.startsWith("trail_ruins"); } - - return best; } - private static int upperAnchorIndex(int[] anchors, int value) { - for (int index = 0; index < anchors.length; index++) { - if (anchors[index] >= value) { - return index; - } - } + private static double structureFootprintMinX(BoundingBox box) { + return box.minX() - 0.5; + } - return anchors.length - 1; + private static double structureFootprintMaxX(BoundingBox box) { + return box.maxX() + 0.5; } - private static int bilinearInterpolateHeight( - int x0, int x1, int z0, int z1, int h00, int h10, int h01, int h11, int x, int z - ) { - if (x0 == x1 && z0 == z1) { - return h00; - } else if (x0 == x1) { - double tz = (z - z0) / (double)Math.max(1, z1 - z0); - return Mth.floor(Mth.lerp(tz, h00, h01)); - } else if (z0 == z1) { - double tx = (x - x0) / (double)Math.max(1, x1 - x0); - return Mth.floor(Mth.lerp(tx, h00, h10)); - } else { - double tx = (x - x0) / (double)Math.max(1, x1 - x0); - double tz = (z - z0) / (double)Math.max(1, z1 - z0); - double low = Mth.lerp(tx, h00, h10); - double high = Mth.lerp(tx, h01, h11); - return Mth.floor(Mth.lerp(tz, low, high)); - } + private static double structureFootprintMinZ(BoundingBox box) { + return box.minZ() - 0.5; } - private static boolean isReusableHeightGridLayout(int step, int gridSize) { - return step == 4 && gridSize == 24; + private static double structureFootprintMaxZ(BoundingBox box) { + return box.maxZ() + 0.5; } - private static String sampleKoppenCode(int blockX, int blockZ, double worldScale) { - String koppen = KOPPEN_SOURCE.sampleDitheredCode(blockX, blockZ, worldScale); - return koppen != null ? koppen : KOPPEN_SOURCE.findNearestCode(blockX, blockZ, worldScale); + private static boolean pointInRect(double x, double z, double minX, double minZ, double maxX, double maxZ) { + return x >= minX && x <= maxX && z >= minZ && z <= maxZ; } - private void repairAnomalousChunkTerrain( - int[] terrainSurfaces, int[] waterSurfaces, boolean[] waterFlags, int[] coverClasses, int[] heightGrid, int gridSize, int step, int minY, int maxY - ) { - int[] repaired = (int[])terrainSurfaces.clone(); + private static boolean segmentIntersectsRect(double x1, double z1, double x2, double z2, double minX, double minZ, double maxX, double maxZ) { + return pointInRect(x1, z1, minX, minZ, maxX, maxZ) + || pointInRect(x2, z2, minX, minZ, maxX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, minX, minZ, maxX, minZ) + || segmentsIntersect(x1, z1, x2, z2, maxX, minZ, maxX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, maxX, maxZ, minX, maxZ) + || segmentsIntersect(x1, z1, x2, z2, minX, maxZ, minX, minZ); + } - for (int pass = 0; pass < 4; pass++) { - int[] source = (int[])repaired.clone(); - boolean changed = false; + private static boolean segmentsIntersect(double ax, double az, double bx, double bz, double cx, double cz, double dx, double dz) { + double abx = bx - ax; + double abz = bz - az; + double acx = cx - ax; + double acz = cz - az; + double adx = dx - ax; + double adz = dz - az; + double cdx = dx - cx; + double cdz = dz - cz; + double cax = ax - cx; + double caz = az - cz; + double cbx = bx - cx; + double cbz = bz - cz; + double cross1 = cross(abx, abz, acx, acz); + double cross2 = cross(abx, abz, adx, adz); + double cross3 = cross(cdx, cdz, cax, caz); + double cross4 = cross(cdx, cdz, cbx, cbz); + double epsilon = 1.0E-7; + if (Math.abs(cross1) <= epsilon && onSegment(ax, az, bx, bz, cx, cz)) { + return true; + } else if (Math.abs(cross2) <= epsilon && onSegment(ax, az, bx, bz, dx, dz)) { + return true; + } else if (Math.abs(cross3) <= epsilon && onSegment(cx, cz, dx, dz, ax, az)) { + return true; + } else if (Math.abs(cross4) <= epsilon && onSegment(cx, cz, dx, dz, bx, bz)) { + return true; + } else { + return cross1 > 0.0 != cross2 > 0.0 && cross3 > 0.0 != cross4 > 0.0; + } + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localX = 0; localX < 16; localX++) { - int index = chunkIndex(localX, localZ); - int surface = source[index]; - if (this.shouldRepairTerrainAnomaly(coverClasses[index], surface, waterFlags[index])) { - int gridIndex = (localZ + step) * gridSize + localX + step; - int east = sampleNeighborHeight(source, localX + 1, localZ, heightGrid, gridIndex + 1); - int west = sampleNeighborHeight(source, localX - 1, localZ, heightGrid, gridIndex - 1); - int north = sampleNeighborHeight(source, localX, localZ - 1, heightGrid, gridIndex - gridSize); - int south = sampleNeighborHeight(source, localX, localZ + 1, heightGrid, gridIndex + gridSize); - int northEast = sampleNeighborHeight(source, localX + 1, localZ - 1, heightGrid, gridIndex - gridSize + 1); - int northWest = sampleNeighborHeight(source, localX - 1, localZ - 1, heightGrid, gridIndex - gridSize - 1); - int southEast = sampleNeighborHeight(source, localX + 1, localZ + 1, heightGrid, gridIndex + gridSize + 1); - int southWest = sampleNeighborHeight(source, localX - 1, localZ + 1, heightGrid, gridIndex + gridSize - 1); - int repairedHeight = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); - if (repairedHeight != surface) { - repaired[index] = Mth.clamp(repairedHeight, minY, maxY); - changed = true; - } - } - } - } + private static double cross(double ax, double az, double bx, double bz) { + return ax * bz - az * bx; + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localXx = 0; localXx < 16; localXx++) { - int index = chunkIndex(localXx, localZ); - int gridIndex = (localZ + step) * gridSize + localXx + step; - heightGrid[gridIndex] = repaired[index]; - } - } + private static boolean onSegment(double ax, double az, double bx, double bz, double px, double pz) { + return px >= Math.min(ax, bx) - 1.0E-7 + && px <= Math.max(ax, bx) + 1.0E-7 + && pz >= Math.min(az, bz) - 1.0E-7 + && pz <= Math.max(az, bz) + 1.0E-7; + } - if (!changed) { - break; - } - } + private boolean isStructureStartTooSteep(StructureStart start, int margin, int sampleStep, int maxHeightDelta) { + BoundingBox box = start.getBoundingBox(); + int minX = box.minX() - margin; + int maxX = box.maxX() + margin; + int minZ = box.minZ() - margin; + int maxZ = box.maxZ() + margin; + int minHeight = Integer.MAX_VALUE; + int maxHeight = Integer.MIN_VALUE; + int stride = Math.max(1, sampleStep); - System.arraycopy(repaired, 0, terrainSurfaces, 0, repaired.length); + for (int z = minZ; z <= maxZ; z += stride) { + for (int x = minX; x <= maxX; x += stride) { + int surface = this.sampleSurfaceHeight(x, z); + if (surface < minHeight) { + minHeight = surface; + } - for (int localZ = 0; localZ < 16; localZ++) { - for (int localXx = 0; localXx < 16; localXx++) { - int index = chunkIndex(localXx, localZ); - if (!waterFlags[index]) { - waterSurfaces[index] = terrainSurfaces[index]; - } else if (terrainSurfaces[index] >= waterSurfaces[index]) { - waterFlags[index] = false; - waterSurfaces[index] = terrainSurfaces[index]; + if (surface > maxHeight) { + maxHeight = surface; } - int gridIndex = (localZ + step) * gridSize + localXx + step; - heightGrid[gridIndex] = terrainSurfaces[index]; + if (maxHeight - minHeight > maxHeightDelta) { + return true; + } } } - } - private static int sampleNeighborHeight(int[] chunkSurfaces, int localX, int localZ, int[] heightGrid, int fallbackGridIndex) { - return localX >= 0 && localX < 16 && localZ >= 0 && localZ < 16 ? chunkSurfaces[chunkIndex(localX, localZ)] : heightGrid[fallbackGridIndex]; + return maxHeight - minHeight > maxHeightDelta; } - private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY) { - return this.repairAnomalousSurfaceHeight(worldX, worldZ, surface, coverClass, minY, maxY, false); + private boolean isVillageStructure(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + return key != null && key.getPath().startsWith("village"); } - private int repairAnomalousSurfaceHeight(int worldX, int worldZ, int surface, int coverClass, int minY, int maxY, boolean hasWater) { - if (!this.shouldRepairTerrainAnomaly(coverClass, surface, hasWater)) { - return Mth.clamp(surface, minY, maxY); - } else { - int east = this.sampleSurfaceHeight(worldX + 1, worldZ); - int west = this.sampleSurfaceHeight(worldX - 1, worldZ); - int north = this.sampleSurfaceHeight(worldX, worldZ - 1); - int south = this.sampleSurfaceHeight(worldX, worldZ + 1); - int northEast = this.sampleSurfaceHeight(worldX + 1, worldZ - 1); - int northWest = this.sampleSurfaceHeight(worldX - 1, worldZ - 1); - int southEast = this.sampleSurfaceHeight(worldX + 1, worldZ + 1); - int southWest = this.sampleSurfaceHeight(worldX - 1, worldZ + 1); - int repaired = repairAnomalousTerrainHeightFromNeighbors(surface, east, west, north, south, northEast, northWest, southEast, southWest); - return Mth.clamp(repaired, minY, maxY); - } + private boolean isWoodlandMansionStructure(Registry registry, Structure structure) { + ResourceLocation key = registry.getKey(structure); + return key != null && key.getPath().equals("woodland_mansion"); } - private boolean shouldRepairTerrainAnomaly(int coverClass, int surface, boolean hasWater) { - int heightAboveSea = surface - this.seaLevel; - return heightAboveSea < 50 ? false : !hasWater && !isWaterCoverClass(coverClass) || heightAboveSea >= 90; + public int getGenDepth() { + return this.height; } - private static boolean isWaterCoverClass(int coverClass) { - return coverClass == 80 || coverClass == 95; + public int getSeaLevel() { + return this.seaLevel; } - private static int repairAnomalousTerrainHeightFromNeighbors( - int center, int east, int west, int north, int south, int northEast, int northWest, int southEast, int southWest - ) { - return TerrainAnomalyRepair.repairHeightFromNeighbors(center, east, west, north, south, northEast, northWest, southEast, southWest); + public int getMinY() { + return this.minY; } - private static int resolveSolidSectionMaxIndex(ChunkAccess chunk, int chunkMinY, int minSurface, int sectionCount) { - if (sectionCount == 0) { - return -1; + public int getBaseHeight(int x, int z, Types heightmapType, LevelHeightAccessor heightAccessor, RandomState random) { + if (this.isFastSpawnMode()) { + int maxY = heightAccessor.getMaxBuildHeight() - 1; + return Mth.clamp(this.seaLevel + 1, heightAccessor.getMinBuildHeight(), maxY); } else { - int sectionIndex = chunk.getSectionIndex(minSurface); - int sectionBottom = chunkMinY + (sectionIndex << 4); - int sectionTop = sectionBottom + 15; - int solidMaxIndex = minSurface >= sectionTop ? sectionIndex : sectionIndex - 1; - return solidMaxIndex < 0 ? -1 : Math.min(solidMaxIndex, sectionCount - 1); + int coverClass = this.sampleCoverClass(x, z); + EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights( + x, z, heightAccessor.getMinBuildHeight(), heightAccessor.getMaxBuildHeight(), coverClass + ); + int surface = column.terrainSurface(); + if (heightmapType == Types.OCEAN_FLOOR_WG || heightmapType == Types.OCEAN_FLOOR) { + return surface + 1; + } else { + return column.hasWater() ? Math.max(surface, column.waterSurface()) + 1 : surface + 1; + } } } - private static void fillSolidSections( - LevelChunkSection[] sections, - boolean[] solidSections, - int[] sectionTopYs, - int solidMaxIndex, - BlockState stone, - BlockState deepslate, - int deepslateStart, - EarthChunkGenerator.SolidSectionFillProfiler profiler - ) { - int sectionCount = sections.length; - long sectionScanStartNs = beginFullChunkProfiling(); - for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { - int topY = sectionTopYs[i]; - int bottomY = topY - 15; - if (bottomY >= 0 || topY < 0) { - BlockState fill = topY < deepslateStart ? deepslate : stone; - long sectionAccessStartNs = beginFullChunkProfiling(); - LevelChunkSection section = Objects.requireNonNull(sections[i], "section"); - profiler.sectionAccessNs += elapsedFullChunkProfilingSince(sectionAccessStartNs); - fillSection(section, fill, profiler); - solidSections[i] = true; + public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState random) { + int minY = heightAccessor.getMinBuildHeight(); + int height = heightAccessor.getHeight(); + BlockState[] states = new BlockState[height]; + Arrays.fill(states, AIR_STATE); + int coverClass = this.sampleCoverClass(x, z); + EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights(x, z, minY, minY + height, coverClass); + int surface = column.terrainSurface(); + int surfaceIndex = surface - minY; + + for (int i = 0; i <= surfaceIndex; i++) { + if (i >= 0 && i < states.length) { + int y = minY + i; + states[i] = y < 0 ? DEEPSLATE_STATE : STONE_STATE; } } - long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); - long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; - profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); + if (column.hasWater()) { + int waterTop = column.waterSurface(); + int waterIndex = waterTop - minY; + + for (int ix = surfaceIndex + 1; ix <= waterIndex; ix++) { + states[ix] = WATER_STATE; + } + } + + int bedrockIndex = this.minY - minY; + if (bedrockIndex >= 0 && bedrockIndex < states.length) { + states[bedrockIndex] = BEDROCK_STATE; + } + + return Objects.requireNonNull(new NoiseColumn(minY, states), "noiseColumn"); } - private static void fillSolidSections( - EarthChunkGenerator.ChunkSectionWriter writer, - boolean[] solidSections, - int[] sectionTopYs, - int solidMaxIndex, - BlockState stone, - BlockState deepslate, - int deepslateStart, - EarthChunkGenerator.SolidSectionFillProfiler profiler - ) { - int sectionCount = sectionTopYs.length; - long sectionScanStartNs = beginFullChunkProfiling(); + public void addDebugScreenInfo( List info, RandomState random, BlockPos pos) { + info.add(String.format("Tellus scale: %.1f", this.settings.worldScale())); + } - for (int i = 0; i <= solidMaxIndex && i < sectionCount; i++) { - int topY = sectionTopYs[i]; - int bottomY = topY - 15; - if (bottomY >= 0 || topY < 0) { - BlockState fill = topY < deepslateStart ? deepslate : stone; - writer.fillSectionConstant(i, fill, profiler); - solidSections[i] = true; + private boolean isFastSpawnMode() { + return this.fastSpawnMode.get(); + } + + private void disableFastSpawnMode() { + if (this.fastSpawnMode.compareAndSet(true, false)) { + if (this.biomeSource instanceof EarthBiomeSource earthBiomeSource) { + earthBiomeSource.setFastSpawnMode(false); } } - - long totalSectionScanNs = elapsedFullChunkProfilingSince(sectionScanStartNs); - long attributedNs = profiler.sectionAccessNs + profiler.sectionWriteNs + profiler.recalcNs; - profiler.scanNs += Math.max(0L, totalSectionScanNs - attributedNs); } - private static void fillSection(LevelChunkSection section, BlockState fill, EarthChunkGenerator.SolidSectionFillProfiler profiler) { - PalettedContainer states = section.getStates(); - long sectionWriteStartNs = beginFullChunkProfiling(); - section.acquire(); + private void placeTrees(WorldGenLevel level, ChunkAccess chunk) { + ChunkPos pos = chunk.getPos(); + long chunkKey = ChunkPos.asLong(pos.x, pos.z); + EarthChunkGenerator.ChunkDecorationContext decorationContext = this.chunkDecorationContexts.get(chunkKey); + EarthChunkGenerator.PreparedChunkBuildings preparedBuildings = this.preparedChunkBuildings.get(ChunkPos.asLong(pos.x, pos.z)); + 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 = level.getSeed(); + + 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) { + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } + + int coverClass = decorationContext != null ? decorationContext.coverClass(localX, localZ) : this.sampleCoverClass(worldX, worldZ); + boolean nearWater = false; + if (shorelineBlendRadius > 0) { + nearWater = decorationContext != null && decorationContext.canResolveNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) + ? decorationContext.isNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) + : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + } + + if (coverClass == 10 && !nearWater) { + int expectedSurface = decorationContext != null ? decorationContext.terrainSurface(localX, localZ) : this.sampleSurfaceHeight(worldX, worldZ); + if (expectedSurface >= this.seaLevel) { + int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; + if (topY >= level.getMinBuildHeight() + && topY >= this.seaLevel + && expectedSurface - topY <= TREE_MAX_SURFACE_DROP + && topY - expectedSurface <= TREE_MAX_SURFACE_RISE) { + 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)) { + BlockPos position = ground.above(); + Holder biome = decorationContext != null ? decorationContext.biome(localX, localZ) : level.getBiome(position); + if (!biome.is(Biomes.MANGROVE_SWAMP)) { + List> features = treeFeaturesForBiome(biome); + if (!features.isEmpty()) { + if (!groundState.is(BlockTags.DIRT)) { + level.setBlock(ground, GRASS_BLOCK_STATE, 260); + } - try { - for (int y = 0; y < 16; y++) { - for (int z = 0; z < 16; z++) { - for (int x = 0; x < 16; x++) { - states.getAndSetUnchecked(x, y, z, fill); + ArnisTreeType treeType = wildTreeTypeForBiome(biome, seed); + ArnisTreeGenerator.place(level, position, treeType, level.getMinBuildHeight(), level.getMaxBuildHeight() - 1, this.detailApplyFlags(level)); + } + } + } + } + } } } } - } finally { - section.release(); } - profiler.sectionWriteNs += elapsedFullChunkProfilingSince(sectionWriteStartNs); - - long sectionRecalcStartNs = beginFullChunkProfiling(); - section.recalcBlockCounts(); - profiler.recalcNs += elapsedFullChunkProfilingSince(sectionRecalcStartNs); } - private static void fillStoneColumnSpan( - LevelChunkSection[] sections, - boolean[] touchedSections, - int chunkMinY, - int localX, - int localZ, - int startY, - int endY, - int deepslateStart, - BlockState stone, - BlockState deepslate + private List prepareDeferredTreePlacements( + EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings ) { - if (endY < startY) { - return; - } - - int sectionIndex = (startY - chunkMinY) >> 4; - if (sectionIndex < 0 || sectionIndex >= sections.length) { - return; - } - - LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); - PalettedContainer states = section.getStates(); - int sectionBottomY = chunkMinY + (sectionIndex << 4); - section.acquire(); + 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<>(); - try { - for (int worldY = startY; worldY <= endY; worldY++) { - states.getAndSetUnchecked(localX, worldY - sectionBottomY, localZ, worldY < deepslateStart ? deepslate : stone); - } - } finally { - section.release(); - } + 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; + } - touchedSections[sectionIndex] = true; - } + int localX = worldX - chunkMinX; + int localZ = worldZ - chunkMinZ; + if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + continue; + } - private static void fillColumnConstant( - LevelChunkSection[] sections, boolean[] touchedSections, int chunkMinY, int localX, int localZ, int startY, int endY, BlockState state - ) { - if (endY < startY) { - return; - } + int index = chunkIndex(localX, localZ); + int coverClass = context.coverClasses()[index]; + if (coverClass != 10) { + continue; + } - int currentY = startY; - while (currentY <= endY) { - int sectionIndex = (currentY - chunkMinY) >> 4; - if (sectionIndex < 0 || sectionIndex >= sections.length) { - break; - } + 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); + } - LevelChunkSection section = Objects.requireNonNull(sections[sectionIndex], "section"); - PalettedContainer states = section.getStates(); - int sectionBottomY = chunkMinY + (sectionIndex << 4); - int localStartY = Math.max(0, currentY - sectionBottomY); - int localEndY = Math.min(15, endY - sectionBottomY); - section.acquire(); + if (nearWater) { + continue; + } - try { - for (int localY = localStartY; localY <= localEndY; localY++) { - states.getAndSetUnchecked(localX, localY, localZ, state); + int expectedSurface = context.terrainSurfaces()[index]; + if (expectedSurface < this.seaLevel) { + continue; } - } finally { - section.release(); - } - touchedSections[sectionIndex] = true; - currentY = sectionBottomY + 16; - } - } + Holder biome = context.sampleBiome(worldX, worldZ, expectedSurface + 1); + if (biome.is(Biomes.MANGROVE_SWAMP) || treeFeaturesForBiome(biome).isEmpty()) { + continue; + } - private static void recalcFilledSections(LevelChunkSection[] sections, boolean[] solidSections, boolean[] touchedSections) { - for (int i = 0; i < sections.length && i < touchedSections.length; i++) { - if (touchedSections[i] && !solidSections[i]) { - Objects.requireNonNull(sections[i], "section").recalcBlockCounts(); + placements.add(new EarthChunkGenerator.PreparedTreePlacement(worldX, worldZ, expectedSurface, biome, seed)); } } - } - - private void filterVillageStarts(RegistryAccess registryAccess, ChunkAccess chunk) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.isVillageStructure(registry, structure) && this.isVillageStartTooSteep(start)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } - } + return placements.isEmpty() ? List.of() : List.copyOf(placements); } - private boolean isVillageStartTooSteep(StructureStart start) { - return this.isStructureStartTooSteep(start, 4, 4, 6); + private void applyPreparedTreePlacements(WorldGenLevel level, ChunkAccess chunk, List placements) { + for (EarthChunkGenerator.PreparedTreePlacement placement : placements) { + this.applyPreparedTreePlacement(level, placement); + } } - private void filterWoodlandMansionStarts(RegistryAccess registryAccess, ChunkAccess chunk) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.isWoodlandMansionStructure(registry, structure) && this.isWoodlandMansionStartTooSteep(start)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } + 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 boolean isWoodlandMansionStartTooSteep(StructureStart start) { - return this.isStructureStartTooSteep(start, 8, 6, 8); - } + 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 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; - if (roadsActive || buildingsActive) { - Map starts = chunk.getAllStarts(); - if (!starts.isEmpty()) { - Registry registry = registryAccess.registryOrThrow(Registries.STRUCTURE); - EarthChunkGenerator.RoadWidths roadWidths = roadsActive ? resolveRoadWidths(worldScale) : null; + Holder biome = placement.biome(); + if (biome.is(Biomes.MANGROVE_SWAMP)) { + return; + } - for (Entry entry : starts.entrySet()) { - StructureStart start = entry.getValue(); - if (start != null && start.isValid()) { - Structure structure = Objects.requireNonNull(entry.getKey(), "structure"); - if (this.shouldAvoidOsmCollision(registry, structure) - && this.doesStructureStartCollideWithOsm(start, roadsActive, buildingsActive, roadWidths)) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - } - } - } - } + List> features = treeFeaturesForBiome(biome); + if (features.isEmpty()) { + return; } - } - private boolean doesStructureStartCollideWithOsm( - StructureStart start, boolean roadsActive, boolean buildingsActive, EarthChunkGenerator.RoadWidths roadWidths - ) { - BoundingBox box = start.getBoundingBox(); - if (buildingsActive && this.structureCollidesWithBuildings(box)) { - return true; - } else { - return roadsActive && roadWidths != null ? this.structureCollidesWithRoads(box, roadWidths) : false; + 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 boolean structureCollidesWithBuildings(BoundingBox box) { - int marginBlocks = 2; - double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); - double minX = structureFootprintMinX(box); - double maxX = structureFootprintMaxX(box); - double minZ = structureFootprintMinZ(box); - double maxZ = structureFootprintMaxZ(box); - OsmQueryMode queryMode = this.shouldUseStructureOsmSyncFallback() ? OsmQueryMode.BLOCKING : OsmQueryMode.NON_BLOCKING; - EarthChunkGenerator.OsmBuildingQueryResult query = this.fetchOsmBuildingsForAreaDetailed( - box.minX(), box.minZ(), box.maxX(), box.maxZ(), marginBlocks, queryMode - ); - if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { - EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); - return false; + 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; } - for (OsmBuildingFeature building : query.features()) { - if (this.structureIntersectsBuilding(box, building, blocksPerDegree, minX, minZ, maxX, maxZ)) { - return true; - } + 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); } - return false; - } + 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); + } - private boolean structureCollidesWithRoads(BoundingBox box, EarthChunkGenerator.RoadWidths roadWidths) { - int marginBlocks = 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 + List points = EXTERNAL_FEATURE_SOURCE.cityPointsForArea( + chunkMinX, chunkMinZ, chunkMaxX, chunkMaxZ, worldScale, OSM_CITY_DETAIL_QUERY_MARGIN ); - if (queryMode == OsmQueryMode.NON_BLOCKING && query.hadCacheMisses() && query.features().isEmpty()) { - EarthChunkGenerator.ChunkDetailPerf.recordSkippedBlockingFallback(); - return false; + if (!points.isEmpty()) { + this.applyExternalCityPoints(level, chunk, points, worldScale); } + } - double blocksPerDegree = blocksPerDegree(this.settings.worldScale()); - double worldScale = this.settings.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 (RoadFeature road : query.features()) { - if (road.mode() != RoadMode.TUNNEL && this.structureIntersectsRoad(box, road, roadWidths, blocksPerDegree, worldScale)) { - return true; + for (ExternalAreaFeature area : areas) { + int partCount = area.rings().size(); + if (partCount == 0) { + continue; } - } - return false; + 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 boolean structureIntersectsBuilding( - BoundingBox box, OsmBuildingFeature building, double blocksPerDegree, double minX, double minZ, double maxX, double maxZ + private void decorateExternalAreaFeature( + WorldGenLevel level, MutableBlockPos cursor, ExternalAreaFeature area, int worldX, int surfaceY, int worldZ, int maxY ) { - if (building.maxBlockX(blocksPerDegree) < minX - || building.minBlockX(blocksPerDegree) > maxX - || building.maxBlockZ(this.settings.worldScale()) < minZ - || building.minBlockZ(this.settings.worldScale()) > maxZ) { - return false; - } else if (building.containsWorld(minX, minZ, this.settings.worldScale()) - || building.containsWorld(minX, maxZ, this.settings.worldScale()) - || building.containsWorld(maxX, minZ, this.settings.worldScale()) - || building.containsWorld(maxX, maxZ, this.settings.worldScale())) { - return true; - } else { - for (int part = 0; part < building.partCount(); part++) { - int points = building.pointCount(part); - if (points >= 2) { - double previousX = building.lonAt(part, points - 1) * blocksPerDegree; - double previousZ = EarthProjection.latToBlockZ(building.latAt(part, points - 1), this.settings.worldScale()); + if (surfaceY + 1 > maxY) { + return; + } - for (int i = 0; i < points; i++) { - double currentX = building.lonAt(part, i) * blocksPerDegree; - double currentZ = EarthProjection.latToBlockZ(building.latAt(part, i), this.settings.worldScale()); - if (pointInRect(currentX, currentZ, minX, minZ, maxX, maxZ) - || segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { - return true; - } + 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); - previousX = currentX; - previousZ = currentZ; + 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 -> { } } } - - return false; - } - } - - 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(); - }; - double halfWidth = Math.max(0.5, (roadWidth - 1) * 0.5); - double minX = structureFootprintMinX(box) - halfWidth; - double maxX = structureFootprintMaxX(box) + halfWidth; - double minZ = structureFootprintMinZ(box) - halfWidth; - double maxZ = structureFootprintMaxZ(box) + halfWidth; - int pointCount = road.pointCount(); - if (pointCount < 2) { - return false; - } else { - double previousX = road.lonAt(0) * blocksPerDegree; - double previousZ = EarthProjection.latToBlockZ(road.latAt(0), worldScale); - if (pointInRect(previousX, previousZ, minX, minZ, maxX, maxZ)) { - return true; - } else { - for (int i = 1; i < pointCount; i++) { - double currentX = road.lonAt(i) * blocksPerDegree; - double currentZ = EarthProjection.latToBlockZ(road.latAt(i), worldScale); - if (segmentIntersectsRect(previousX, previousZ, currentX, currentZ, minX, minZ, maxX, maxZ)) { - return true; + 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 -> { } - - previousX = currentX; - previousZ = currentZ; } - - return false; + } + 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 boolean shouldAvoidOsmCollision(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - if (key == null) { - return false; - } else { - String path = key.getPath(); - return path.startsWith("village") - || path.equals("woodland_mansion") - || path.equals("desert_pyramid") - || path.equals("desert_temple") - || path.equals("jungle_pyramid") - || path.equals("jungle_temple") - || path.equals("pillager_outpost") - || path.equals("igloo") - || path.equals("swamp_hut") - || path.equals("witch_hut") - || path.startsWith("ruined_portal") - || path.startsWith("trail_ruins"); + 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; } - } - - private static double structureFootprintMinX(BoundingBox box) { - return box.minX() - 0.5; - } - - private static double structureFootprintMaxX(BoundingBox box) { - return box.maxX() + 0.5; - } - - private static double structureFootprintMinZ(BoundingBox box) { - return box.minZ() - 0.5; - } - - private static double structureFootprintMaxZ(BoundingBox box) { - return box.maxZ() + 0.5; - } - - private static boolean pointInRect(double x, double z, double minX, double minZ, double maxX, double maxZ) { - return x >= minX && x <= maxX && z >= minZ && z <= maxZ; - } - private static boolean segmentIntersectsRect(double x1, double z1, double x2, double z2, double minX, double minZ, double maxX, double maxZ) { - return pointInRect(x1, z1, minX, minZ, maxX, maxZ) - || pointInRect(x2, z2, minX, minZ, maxX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, minX, minZ, maxX, minZ) - || segmentsIntersect(x1, z1, x2, z2, maxX, minZ, maxX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, maxX, maxZ, minX, maxZ) - || segmentsIntersect(x1, z1, x2, z2, minX, maxZ, minX, minZ); - } + cursor.set(worldX, surfaceY, worldZ); + BlockState ground = level.getBlockState(cursor); + if (!canDecorateAreaVegetationSurface(area, type, ground)) { + return; + } - private static boolean segmentsIntersect(double ax, double az, double bx, double bz, double cx, double cz, double dx, double dz) { - double abx = bx - ax; - double abz = bz - az; - double acx = cx - ax; - double acz = cz - az; - double adx = dx - ax; - double adz = dz - az; - double cdx = dx - cx; - double cdz = dz - cz; - double cax = ax - cx; - double caz = az - cz; - double cbx = bx - cx; - double cbz = bz - cz; - double cross1 = cross(abx, abz, acx, acz); - double cross2 = cross(abx, abz, adx, adz); - double cross3 = cross(cdx, cdz, cax, caz); - double cross4 = cross(cdx, cdz, cbx, cbz); - double epsilon = 1.0E-7; - if (Math.abs(cross1) <= epsilon && onSegment(ax, az, bx, bz, cx, cz)) { - return true; - } else if (Math.abs(cross2) <= epsilon && onSegment(ax, az, bx, bz, dx, dz)) { - return true; - } else if (Math.abs(cross3) <= epsilon && onSegment(cx, cz, dx, dz, ax, az)) { - return true; - } else if (Math.abs(cross4) <= epsilon && onSegment(cx, cz, dx, dz, bx, bz)) { - return true; - } else { - return cross1 > 0.0 != cross2 > 0.0 && cross3 > 0.0 != cross4 > 0.0; + 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; } - } - private static double cross(double ax, double az, double bx, double bz) { - return ax * bz - az * bx; + 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 onSegment(double ax, double az, double bx, double bz, double px, double pz) { - return px >= Math.min(ax, bx) - 1.0E-7 - && px <= Math.max(ax, bx) + 1.0E-7 - && pz >= Math.min(az, bz) - 1.0E-7 - && pz <= Math.max(az, bz) + 1.0E-7; + 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 boolean isStructureStartTooSteep(StructureStart start, int margin, int sampleStep, int maxHeightDelta) { - BoundingBox box = start.getBoundingBox(); - int minX = box.minX() - margin; - int maxX = box.maxX() + margin; - int minZ = box.minZ() - margin; - int maxZ = box.maxZ() + margin; - int minHeight = Integer.MAX_VALUE; - int maxHeight = Integer.MIN_VALUE; - int stride = Math.max(1, sampleStep); - - for (int z = minZ; z <= maxZ; z += stride) { - for (int x = minX; x <= maxX; x += stride) { - int surface = this.sampleSurfaceHeight(x, z); - if (surface < minHeight) { - minHeight = surface; - } - - if (surface > maxHeight) { - maxHeight = surface; - } - - if (maxHeight - minHeight > maxHeightDelta) { - return true; - } - } + 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; } - - return maxHeight - minHeight > maxHeightDelta; + 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 boolean isVillageStructure(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - return key != null && key.getPath().startsWith("village"); + 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 boolean isWoodlandMansionStructure(Registry registry, Structure structure) { - ResourceLocation key = registry.getKey(structure); - return key != null && key.getPath().equals("woodland_mansion"); + 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; + }; } - public int getGenDepth() { - return this.height; + 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; + }; } - public int getSeaLevel() { - return this.seaLevel; + 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; + }; } - public int getMinY() { - return this.minY; + 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; } - public int getBaseHeight(int x, int z, Types heightmapType, LevelHeightAccessor heightAccessor, RandomState random) { - if (this.isFastSpawnMode()) { - int maxY = heightAccessor.getMaxBuildHeight() - 1; - return Mth.clamp(this.seaLevel + 1, heightAccessor.getMinBuildHeight(), maxY); - } else { - int coverClass = this.sampleCoverClass(x, z); - EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights( - x, z, heightAccessor.getMinBuildHeight(), heightAccessor.getMaxBuildHeight(), coverClass - ); - int surface = column.terrainSurface(); - if (heightmapType == Types.OCEAN_FLOOR_WG || heightmapType == Types.OCEAN_FLOOR) { - return surface + 1; - } else { - return column.hasWater() ? Math.max(surface, column.waterSurface()) + 1 : surface + 1; + 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; + } - public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState random) { - int minY = heightAccessor.getMinBuildHeight(); - int height = heightAccessor.getHeight(); - BlockState[] states = new BlockState[height]; - Arrays.fill(states, AIR_STATE); - int coverClass = this.sampleCoverClass(x, z); - EarthChunkGenerator.ColumnHeights column = this.resolveFastColumnHeights(x, z, minY, minY + height, coverClass); - int surface = column.terrainSurface(); - int surfaceIndex = surface - minY; - - for (int i = 0; i <= surfaceIndex; i++) { - if (i >= 0 && i < states.length) { - int y = minY + i; - states[i] = y < 0 ? DEEPSLATE_STATE : STONE_STATE; + 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 (column.hasWater()) { - int waterTop = column.waterSurface(); - int waterIndex = waterTop - minY; - - for (int ix = surfaceIndex + 1; ix <= waterIndex; ix++) { - states[ix] = WATER_STATE; + if (line.kind() == ExternalLineKind.WATERWAY) { + cursor.set(worldX, surfaceY, worldZ); + if (canReplaceCitySurface(level.getBlockState(cursor))) { + level.setBlock(cursor, WATER_STATE, this.detailApplyFlags(level)); } + return; } - - int bedrockIndex = this.minY - minY; - if (bedrockIndex >= 0 && bedrockIndex < states.length) { - states[bedrockIndex] = BEDROCK_STATE; + if (line.kind() != ExternalLineKind.BARRIER) { + return; } - return Objects.requireNonNull(new NoiseColumn(minY, states), "noiseColumn"); + 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)); + } + } } - public void addDebugScreenInfo( List info, RandomState random, BlockPos pos) { - info.add(String.format("Tellus scale: %.1f", this.settings.worldScale())); + 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 boolean isFastSpawnMode() { - return this.fastSpawnMode.get(); + 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 disableFastSpawnMode() { - if (this.fastSpawnMode.compareAndSet(true, false)) { - if (this.biomeSource instanceof EarthBiomeSource earthBiomeSource) { - earthBiomeSource.setFastSpawnMode(false); - } + 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 placeTrees(WorldGenLevel level, ChunkAccess chunk) { - ChunkPos pos = chunk.getPos(); - long chunkKey = ChunkPos.asLong(pos.x, pos.z); - EarthChunkGenerator.ChunkDecorationContext decorationContext = this.chunkDecorationContexts.get(chunkKey); - EarthChunkGenerator.PreparedChunkBuildings preparedBuildings = this.preparedChunkBuildings.get(ChunkPos.asLong(pos.x, pos.z)); - int chunkMinX = pos.getMinBlockX(); - int chunkMinZ = pos.getMinBlockZ(); + 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; - 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 = level.getSeed(); + double blocksPerDegree = blocksPerDegree(worldScale); + MutableBlockPos cursor = new MutableBlockPos(); + int minY = level.getMinBuildHeight(); + int maxY = level.getMaxBuildHeight() - 1; - 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) { - int localX = worldX - chunkMinX; - int localZ = worldZ - chunkMinZ; - if (preparedBuildings != null && preparedBuildings.suppressesTrees(localX, localZ)) { + 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 coverClass = decorationContext != null ? decorationContext.coverClass(localX, localZ) : this.sampleCoverClass(worldX, worldZ); - boolean nearWater = false; - if (shorelineBlendRadius > 0) { - nearWater = decorationContext != null && decorationContext.canResolveNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) - ? decorationContext.isNearWaterWithinChunk(localX, localZ, shorelineBlendRadius) - : this.isNearWater(worldX, worldZ, shorelineBlendRadius); + 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; + } - if (coverClass == 10 && !nearWater) { - int expectedSurface = decorationContext != null ? decorationContext.terrainSurface(localX, localZ) : this.sampleSurfaceHeight(worldX, worldZ); - if (expectedSurface >= this.seaLevel) { - int topY = level.getHeight(Types.MOTION_BLOCKING_NO_LEAVES, worldX, worldZ) - 1; - if (topY >= level.getMinBuildHeight() - && topY >= this.seaLevel - && expectedSurface - topY <= TREE_MAX_SURFACE_DROP - && topY - expectedSurface <= TREE_MAX_SURFACE_RISE) { - 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)) { - BlockPos position = ground.above(); - Holder biome = decorationContext != null ? decorationContext.biome(localX, localZ) : level.getBiome(position); - if (!biome.is(Biomes.MANGROVE_SWAMP)) { - List> features = treeFeaturesForBiome(biome); - if (!features.isEmpty()) { - if (!groundState.is(BlockTags.DIRT)) { - level.setBlock(ground, GRASS_BLOCK_STATE, 260); - } + 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; + } - ConfiguredFeature feature = features.get(random.nextInt(features.size())); - feature.place(level, this, random, position); - } - } - } - } + 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 List prepareDeferredTreePlacements( - EarthChunkGenerator.ChunkGenerationContext context, EarthChunkGenerator.PreparedChunkBuildings preparedBuildings + 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 +6947,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 +7215,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 +7353,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 +7431,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 +7468,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 +7517,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 +7923,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 +8104,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 +8160,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 +8306,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 +8516,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 +10240,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 +10986,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 +11004,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 +11026,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 +11044,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 +11059,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 +11080,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 +12324,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"), 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/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java index 59ee9e80c..28db2d96a 100644 --- a/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java +++ b/mc261/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java @@ -911,7 +911,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 +5007,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 +5275,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())) { 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/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..c8f213bde --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -0,0 +1,1463 @@ +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.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.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +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"; + 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://lz4.overpass-api.de/api/interpreter", + "https://z.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", 60, 5, 360); + private static final int CONNECT_TIMEOUT_MS = intProperty("tellus.arnis.overpass.connectTimeoutMs", 7000, 1, 120000); + private static final int READ_TIMEOUT_MS = intProperty("tellus.arnis.overpass.readTimeoutMs", 60000, 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", 60000L, 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 final boolean enabled; + private final boolean networkEnabled; + private final Path cacheRoot; + private final URI[] endpoints; + private final ConcurrentMap memoryCache = new ConcurrentHashMap<>(); + private final ConcurrentMap failedUntilMs = 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, URI[] endpoints) { + this.enabled = enabled; + this.networkEnabled = networkEnabled; + this.cacheRoot = cacheRoot; + this.endpoints = endpoints; + } + + public static OverpassExternalFeatureSource createDefault() { + boolean enabled = Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); + if (!enabled) { + return disabled(); + } + String networkMode = normalizedNetworkMode(System.getProperty(NETWORK_MODE_PROPERTY, NETWORK_CACHE_FIRST)); + if (NETWORK_OFF.equals(networkMode)) { + return disabled(); + } + Path cacheRoot = defaultCacheRoot(); + String endpoints = System.getProperty(ENDPOINTS_PROPERTY, DEFAULT_ENDPOINTS); + return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, parseEndpoints(endpoints)); + } + + 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("."), 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); + 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); + if (this.tileReadyForPrefetch(cachePath, requireCityDetails)) { + cachedAfterAttempt++; + } + } + + CacheEstimate after = estimateCache(bounds, this.cacheRoot, this.networkEnabled); + 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"))) { + 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)) { + 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); + 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) { + TileFeatures cached = this.memoryCache.get(key); + if (cached != null && (!requireCityDetails || cached.cityDetailsLoaded())) { + return cached; + } + + Long failedUntil = this.failedUntilMs.get(key); + if (failedUntil != null && System.currentTimeMillis() < failedUntil) { + return TileFeatures.empty(key.bounds()); + } + + TileFeatures loaded = this.loadTile(key, requireCityDetails, cached); + this.memoryCache.put(key, loaded); + return loaded; + } + + private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatures fallback) { + Path cachePath = this.cachePathFor(key); + if (Files.exists(cachePath)) { + try { + TileFeatures parsed = this.parseTile(key, this.readCompressed(cachePath), this.cacheHasCityProfile(cachePath)); + if (!requireCityDetails || parsed.cityDetailsLoaded()) { + return parsed; + } + fallback = parsed; + } catch (IOException | RuntimeException error) { + LOGGER.debug("Invalid Arnis Overpass cache tile {}, refetching", key, error); + try { + Files.deleteIfExists(cachePath); + } catch (IOException deleteError) { + LOGGER.debug("Failed to delete invalid Arnis Overpass cache tile {}", cachePath, deleteError); + } + } + } + + if (!this.networkEnabled) { + 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 + ); + } + 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); + return parsed; + } catch (IOException | RuntimeException error) { + LOGGER.warn("Arnis Overpass tile unavailable {}", key, error); + this.failedUntilMs.put(key, System.currentTimeMillis() + FAILURE_COOLDOWN_MS); + return fallback != null ? fallback : TileFeatures.empty(key.bounds()); + } + } + + 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(); + for (int attempt = 0; attempt < this.endpoints.length; attempt++) { + URI endpoint = this.endpoints[Math.floorMod(startEndpoint + attempt, this.endpoints.length)]; + try { + return this.executeQuery(endpoint, query); + } catch (IOException error) { + lastError = error; + } + } + + throw new IOException("all Arnis Overpass endpoints failed", lastError); + } + + 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(); + 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")) { + return new String(input.readAllBytes(), 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); + } + return new EndpointProbeResult(endpoint.toString(), true, status, System.currentTimeMillis() - startMs, "ok"); + } + return new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, "HTTP " + status); + } finally { + connection.disconnect(); + } + } catch (IOException | RuntimeException error) { + return new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, shortError(error)); + } + } + + 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()); + } + + 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)); + } + } catch (IOException error) { + LOGGER.debug("Failed to cache Arnis Overpass tile {}", path, 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 cachePathFor(Path cacheRoot, TileKey key) { + return cacheRoot.resolve(Integer.toString(key.zoom())).resolve(Integer.toString(key.x())).resolve(key.y() + ".json.gz"); + } + + 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 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); + } + } + + 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 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/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/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/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 + ); + } +} From 2dbf51664ed682a580df79e0c025d9be2709d344 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 20:05:44 +0800 Subject: [PATCH 2/9] Add dedicated diagnostics logs --- .../tellus/worldgen/EarthChunkGenerator.java | 85 +++++++++----- .../tellus/worldgen/EarthChunkGenerator.java | 85 +++++++++----- .../main/java/com/yucareux/tellus/Tellus.java | 5 +- .../data/osm/TellusOsmBuildingSource.java | 9 +- .../tellus/worldgen/EarthChunkGenerator.java | 81 ++++++++----- .../tellus/util/TellusDiagnostics.java | 100 ++++++++++++++++ .../OverpassExternalFeatureSource.java | 110 +++++++++++++++++- .../tellus/world/data/mask/PmTilesReader.java | 22 +++- .../world/data/osm/OverpassRoadClient.java | 19 +++ .../world/data/osm/PmTilesRangeReader.java | 38 ++++-- 10 files changed, 440 insertions(+), 114 deletions(-) create mode 100644 src/main/java/com/yucareux/tellus/util/TellusDiagnostics.java 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 956946034..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,7 @@ 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; @@ -425,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) { @@ -867,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; @@ -2279,11 +2304,11 @@ private void paintBridgeEdgeRails( int worldX = chunkMinX + localX; int worldZ = chunkMinZ + localZ; cursor.set(worldX, railY, worldZ); - if (isRoadLightReplaceable(level.getBlockState(cursor))) { + 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(level.getBlockState(cursor))) { + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { this.setChunkBlock(level, chunk, cursor, Blocks.STONE_BRICK_SLAB.defaultBlockState()); } } @@ -12473,7 +12498,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -12580,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() ); } } @@ -12693,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/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java b/mc1211/src/main/java/com/yucareux/tellus/worldgen/EarthChunkGenerator.java index 516733dae..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,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.integration.ExternalAreaFeature; import com.yucareux.tellus.world.data.integration.ExternalAreaKind; import com.yucareux.tellus.world.data.integration.TellusExternalFeatureSource; @@ -425,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) { @@ -867,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; @@ -2279,11 +2304,11 @@ private void paintBridgeEdgeRails( int worldX = chunkMinX + localX; int worldZ = chunkMinZ + localZ; cursor.set(worldX, railY, worldZ); - if (isRoadLightReplaceable(level.getBlockState(cursor))) { + 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(level.getBlockState(cursor))) { + if (isRoadLightReplaceable(chunk.getBlockState(cursor))) { this.setChunkBlock(level, chunk, cursor, Blocks.STONE_BRICK_SLAB.defaultBlockState()); } } @@ -12533,7 +12558,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -12640,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() ); } } @@ -12753,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/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 28db2d96a..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; @@ -10452,7 +10477,7 @@ private static void logGroup(String label, long[] totals, long[] calls, EarthChu } } - Tellus.LOGGER.info(message.toString()); + TellusDiagnostics.worldgen(message.toString()); } } } @@ -10559,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() ); } } @@ -10672,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/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/OverpassExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java index c8f213bde..9e9f5e863 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -4,6 +4,7 @@ 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; @@ -89,15 +90,28 @@ private OverpassExternalFeatureSource(boolean enabled, boolean networkEnabled, P 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(); String endpoints = System.getProperty(ENDPOINTS_PROPERTY, DEFAULT_ENDPOINTS); - return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, parseEndpoints(endpoints)); + URI[] parsedEndpoints = parseEndpoints(endpoints); + TellusDiagnostics.traffic( + "Overpass source ready networkMode=%s networkEnabled=%s cacheRoot=%s endpoints=%d queryZoom=%d cityDetails=%s maxNetworkTiles=%d", + networkMode, + !NETWORK_CACHE_ONLY.equals(networkMode), + cacheRoot, + parsedEndpoints.length, + QUERY_ZOOM, + cityDetailsEnabled(), + MAX_NETWORK_TILES_PER_SESSION + ); + return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, parsedEndpoints); } public static CacheEstimate estimateConfiguredCache(GeoBounds bounds) { @@ -142,6 +156,16 @@ public PrefetchResult prefetchBounds(GeoBounds bounds, int maxMissingTiles) { } 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); } @@ -167,6 +191,16 @@ public PrefetchResult prefetchBounds(GeoBounds bounds, int maxMissingTiles) { } 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)); } @@ -179,11 +213,13 @@ private boolean tileReadyForPrefetch(Path cachePath, boolean requireCityDetails) 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")); } @@ -268,6 +304,13 @@ private List tilesForBounds(GeoBounds bounds, boolean requireCityD 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(); } @@ -304,11 +347,23 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu 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; } + TellusDiagnostics.traffic("Overpass cache missing city profile tile=%s; refetching with city details", key); fallback = parsed; } 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) { @@ -318,6 +373,7 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu } 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()) { @@ -329,6 +385,14 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu 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()); } @@ -338,9 +402,21 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu 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.warn("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()); } @@ -371,9 +447,11 @@ private String fetchTile(TileKey key, boolean includeCityDetails) throws IOExcep for (int attempt = 0; attempt < this.endpoints.length; attempt++) { URI endpoint = this.endpoints[Math.floorMod(startEndpoint + attempt, this.endpoints.length)]; try { + TellusDiagnostics.traffic("Overpass request start tile=%s endpoint=%s cityDetails=%s attempt=%d", key, endpoint, includeCityDetails, attempt + 1); return this.executeQuery(endpoint, query); } catch (IOException error) { lastError = error; + TellusDiagnostics.traffic("Overpass request failed tile=%s endpoint=%s attempt=%d error=%s", key, endpoint, attempt + 1, shortError(error)); } } @@ -437,6 +515,7 @@ private static String overpassQuery(GeoBounds bounds, boolean includeCityDetails 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)); @@ -452,7 +531,15 @@ private String executeQuery(URI endpoint, String query) throws IOException { throw new IOException("Arnis Overpass HTTP " + status + " (" + endpoint.getHost() + ")"); } try (InputStream input = Objects.requireNonNull(connection.getInputStream(), "overpassResponse")) { - return new String(input.readAllBytes(), StandardCharsets.UTF_8); + 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(); @@ -479,14 +566,20 @@ private static EndpointProbeResult probeEndpoint(URI endpoint) { try (InputStream input = Objects.requireNonNull(connection.getInputStream(), "overpassProbeResponse")) { input.readNBytes(256); } - return new EndpointProbeResult(endpoint.toString(), true, status, System.currentTimeMillis() - startMs, "ok"); + 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; } - return new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, "HTTP " + status); + 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) { - return new EndpointProbeResult(endpoint.toString(), false, status, System.currentTimeMillis() - startMs, shortError(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; } } @@ -1061,8 +1154,15 @@ private void cacheTile(Path path, String response, boolean cityDetailsLoaded) { } 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)); } } 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/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/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; } } From af8d93489654a8da82ffe2454045c486b867b668 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 20:35:14 +0800 Subject: [PATCH 3/9] Defer Overpass city detail fetches --- .../OverpassExternalFeatureSource.java | 90 ++++++++++++++++--- 1 file changed, 79 insertions(+), 11 deletions(-) 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 index 9e9f5e863..8bbc0c1e8 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -23,8 +23,11 @@ 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; @@ -39,6 +42,7 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc 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"; 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"; @@ -67,12 +71,22 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc 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 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 Semaphore requestGuard = new Semaphore(1, true); private final AtomicLong nextAllowedRequestMs = new AtomicLong(0L); @@ -102,13 +116,14 @@ public static OverpassExternalFeatureSource createDefault() { String endpoints = System.getProperty(ENDPOINTS_PROPERTY, DEFAULT_ENDPOINTS); URI[] parsedEndpoints = parseEndpoints(endpoints); TellusDiagnostics.traffic( - "Overpass source ready networkMode=%s networkEnabled=%s cacheRoot=%s endpoints=%d queryZoom=%d cityDetails=%s maxNetworkTiles=%d", + "Overpass source ready networkMode=%s networkEnabled=%s cacheRoot=%s endpoints=%d queryZoom=%d cityDetails=%s blockingCityDetails=%s maxNetworkTiles=%d", networkMode, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, parsedEndpoints.length, QUERY_ZOOM, cityDetailsEnabled(), + blockingCityDetailsNetwork(), MAX_NETWORK_TILES_PER_SESSION ); return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, parsedEndpoints); @@ -184,7 +199,7 @@ public PrefetchResult prefetchBounds(GeoBounds bounds, int maxMissingTiles) { break; } attempted++; - this.tileForKey(key, requireCityDetails); + this.tileForKey(key, requireCityDetails, true); if (this.tileReadyForPrefetch(cachePath, requireCityDetails)) { cachedAfterAttempt++; } @@ -326,22 +341,38 @@ private TileFeatures tileForKey(TileKey key) { } 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; } - Long failedUntil = this.failedUntilMs.get(key); - if (failedUntil != null && System.currentTimeMillis() < failedUntil) { - return TileFeatures.empty(key.bounds()); - } + 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); - this.memoryCache.put(key, loaded); - return loaded; + 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) { + private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatures fallback, boolean allowBlockingNetwork) { Path cachePath = this.cachePathFor(key); if (Files.exists(cachePath)) { try { @@ -359,8 +390,13 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu ); return parsed; } - TellusDiagnostics.traffic("Overpass cache missing city profile tile=%s; refetching with city details", key); fallback = parsed; + 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)); @@ -372,6 +408,12 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu } } + if (requireCityDetails && !allowBlockingNetwork) { + 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 (!this.networkEnabled) { TellusDiagnostics.traffic("Overpass cache miss tile=%s networkEnabled=false fallback=%s", key, fallback != null); return fallback != null ? fallback : TileFeatures.empty(key.bounds()); @@ -422,6 +464,28 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu } } + 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; @@ -1384,6 +1448,10 @@ 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 int intProperty(String key, int defaultValue, int minInclusive, int maxInclusive) { String value = System.getProperty(key); if (value == null) { From d6afe6181a102e8b685d644adc969cce21022cf0 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 21:27:25 +0800 Subject: [PATCH 4/9] Fix repeated Overpass city detail fetches --- docs/wlb-arnis-integration.md | 6 +- .../client/widget/map/SlippyMapTileCache.java | 60 +++++++++++++++--- .../client/widget/map/SlippyMapTileCache.java | 60 +++++++++++++++--- .../client/widget/map/SlippyMapTileCache.java | 62 ++++++++++++++++--- .../OverpassExternalFeatureSource.java | 60 +++++++++++++++--- 5 files changed, 215 insertions(+), 33 deletions(-) diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md index 12f28b98c..bea272f00 100644 --- a/docs/wlb-arnis-integration.md +++ b/docs/wlb-arnis-integration.md @@ -112,7 +112,8 @@ Useful runtime switches: -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://lz4.overpass-api.de/api/interpreter +-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 ``` @@ -123,6 +124,9 @@ Network behavior is intentionally conservative to avoid burning VPN traffic: - `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. 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/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/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/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java index 8bbc0c1e8..e3c438b36 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -51,19 +51,21 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc private static final String DEFAULT_ENDPOINTS = String.join( ",", "https://overpass-api.de/api/interpreter", - "https://lz4.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", 60, 5, 360); - private static final int CONNECT_TIMEOUT_MS = intProperty("tellus.arnis.overpass.connectTimeoutMs", 7000, 1, 120000); - private static final int READ_TIMEOUT_MS = intProperty("tellus.arnis.overpass.readTimeoutMs", 60000, 1, 360000); + 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", 60000L, 0L, 600000L); + 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); @@ -88,6 +90,7 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc 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); @@ -457,7 +460,7 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu ); return parsed; } catch (IOException | RuntimeException error) { - LOGGER.warn("Arnis Overpass tile unavailable {}", key, 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()); @@ -508,20 +511,53 @@ private String fetchTile(TileKey key, boolean includeCityDetails) throws IOExcep 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 { - TellusDiagnostics.traffic("Overpass request start tile=%s endpoint=%s cityDetails=%s attempt=%d", key, endpoint, includeCityDetails, attempt + 1); - return this.executeQuery(endpoint, query); + 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; - TellusDiagnostics.traffic("Overpass request failed tile=%s endpoint=%s attempt=%d error=%s", key, endpoint, attempt + 1, shortError(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\"];") @@ -681,7 +717,7 @@ private TileFeatures parseTile(TileKey key, String response, boolean cityDetails JsonArray elements = parsed.getAsJsonObject().getAsJsonArray("elements"); if (elements == null || elements.isEmpty()) { - return TileFeatures.empty(key.bounds()); + return TileFeatures.empty(key.bounds(), cityDetailsLoaded); } List roads = new ArrayList<>(); @@ -1534,6 +1570,10 @@ 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(); From 2f1da3ac1e51ef6f4e1168842c96c6c09214f9b1 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 22:07:54 +0800 Subject: [PATCH 5/9] Add local OSM PBF feature source --- docs/wlb-arnis-integration.md | 4 + mc1201/build.gradle | 6 + .../client/screen/EarthCustomizeScreen.java | 15 +- mc1211/build.gradle | 6 + .../client/screen/EarthCustomizeScreen.java | 15 +- mc261/build.gradle | 6 + .../client/screen/EarthCustomizeScreen.java | 7 + .../integration/PbfExternalFeatureSource.java | 830 ++++++++++++++++++ .../TellusExternalFeatureSource.java | 33 +- 9 files changed, 909 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md index bea272f00..ab304a97f 100644 --- a/docs/wlb-arnis-integration.md +++ b/docs/wlb-arnis-integration.md @@ -114,6 +114,9 @@ Useful runtime switches: -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.arnis.pbf.enabled=true +-Dtellus.arnis.pbf.directory=/path/to/osm-pbf-folder +-Dtellus.arnis.pbf.paths=/path/to/new-york.osm.pbf,/path/to/new-jersey.osm.pbf -Dtellus.external.features.prefer=false ``` @@ -127,6 +130,7 @@ Network behavior is intentionally conservative to avoid burning VPN traffic: - 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. +- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details without live Overpass traffic. 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. diff --git a/mc1201/build.gradle b/mc1201/build.gradle index 74853112d..59123e6ee 100644 --- a/mc1201/build.gradle +++ b/mc1201/build.gradle @@ -100,6 +100,12 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:23.2.0' implementation 'com.google.protobuf:protobuf-java:3.23.4' include 'com.google.protobuf:protobuf-java:3.23.4' + implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' + include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 d3fbab103..980f435a2 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 @@ -12,6 +12,7 @@ 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.world.data.integration.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; @@ -1069,10 +1070,16 @@ private List dataSourcesEntries() { 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(new EarthCustomizeScreen.OverpassCacheStatusDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.ESTIMATE)); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.PREFETCH)); + PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); + entries.add(infoSpacer()); + entries.add(infoHeader("Local OSM PBF")); + entries.add(infoLine("Local extracts are loaded before Overpass to avoid live network traffic.")); + entries.add(infoSubtle("Path: " + pbfSummary.location())); + entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); + 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)")); diff --git a/mc1211/build.gradle b/mc1211/build.gradle index 5f991ddfc..a3285aad6 100644 --- a/mc1211/build.gradle +++ b/mc1211/build.gradle @@ -74,6 +74,12 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:24.1.1' implementation 'com.google.protobuf:protobuf-java:4.28.2' include 'com.google.protobuf:protobuf-java:4.28.2' + implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' + include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 562a93bb4..38999d959 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 @@ -11,6 +11,7 @@ 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.world.data.integration.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; @@ -1088,10 +1089,16 @@ private List dataSourcesEntries() { 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(new EarthCustomizeScreen.OverpassCacheStatusDefinition()); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.ESTIMATE)); + entries.add(new EarthCustomizeScreen.OverpassCacheActionDefinition(this, EarthCustomizeScreen.OverpassCacheAction.PREFETCH)); + PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); + entries.add(infoSpacer()); + entries.add(infoHeader("Local OSM PBF")); + entries.add(infoLine("Local extracts are loaded before Overpass to avoid live network traffic.")); + entries.add(infoSubtle("Path: " + pbfSummary.location())); + entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); + 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)")); diff --git a/mc261/build.gradle b/mc261/build.gradle index a057bd5e3..53bc2f758 100644 --- a/mc261/build.gradle +++ b/mc261/build.gradle @@ -73,6 +73,12 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:24.1.1' implementation 'com.google.protobuf:protobuf-java:4.28.2' include 'com.google.protobuf:protobuf-java:4.28.2' + implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' + implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' + implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' + include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 cb122b465..140db4f8f 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 @@ -9,6 +9,7 @@ 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.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; import java.io.IOException; @@ -1024,6 +1025,12 @@ private static EarthCustomizeScreen.CacheActionDefinition cacheActionButton( Com private static List dataSourcesEntries() { List entries = new ArrayList<>(); + PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); + entries.add(infoHeader("Local OSM PBF")); + entries.add(infoLine("Local extracts provide roads/buildings/city details without live OSM traffic.")); + entries.add(infoSubtle("Path: " + pbfSummary.location())); + entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); + 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)")); diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java new file mode 100644 index 000000000..16e52eea9 --- /dev/null +++ b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java @@ -0,0 +1,830 @@ +package com.yucareux.tellus.world.data.integration; + +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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import net.fabricmc.loader.api.FabricLoader; +import org.openstreetmap.osmosis.pbf2.v0_6.PbfReader; +import org.openstreetmap.osmosis.core.container.v0_6.EntityContainer; +import org.openstreetmap.osmosis.core.container.v0_6.NodeContainer; +import org.openstreetmap.osmosis.core.container.v0_6.RelationContainer; +import org.openstreetmap.osmosis.core.container.v0_6.WayContainer; +import org.openstreetmap.osmosis.core.domain.v0_6.Entity; +import org.openstreetmap.osmosis.core.domain.v0_6.EntityType; +import org.openstreetmap.osmosis.core.domain.v0_6.Node; +import org.openstreetmap.osmosis.core.domain.v0_6.Relation; +import org.openstreetmap.osmosis.core.domain.v0_6.RelationMember; +import org.openstreetmap.osmosis.core.domain.v0_6.Tag; +import org.openstreetmap.osmosis.core.domain.v0_6.Way; +import org.openstreetmap.osmosis.core.domain.v0_6.WayNode; +import org.openstreetmap.osmosis.core.task.v0_6.Sink; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class PbfExternalFeatureSource implements ExternalFeatureSource { + public static final String ENABLED_PROPERTY = "tellus.arnis.pbf.enabled"; + public static final String PATHS_PROPERTY = "tellus.arnis.pbf.paths"; + public static final String DIRECTORY_PROPERTY = "tellus.arnis.pbf.directory"; + public static final String DEFAULT_RELATIVE_DIRECTORY = "tellus/cache/osm-pbf"; + private static final Logger LOGGER = LoggerFactory.getLogger("tellus"); + private static final String SOURCE = "arnis-pbf"; + + private final boolean enabled; + private final List paths; + private final JsonExternalFeatureSource delegate; + + private PbfExternalFeatureSource(boolean enabled, List paths, JsonExternalFeatureSource delegate) { + this.enabled = enabled; + this.paths = paths == null ? List.of() : List.copyOf(paths); + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + public static PbfExternalFeatureSource createDefault() { + if (!Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true"))) { + TellusDiagnostics.traffic("PBF source disabled by %s=false", ENABLED_PROPERTY); + return disabled(); + } + + List paths = configuredPaths(); + if (paths.isEmpty()) { + TellusDiagnostics.traffic("PBF source ready but no .osm.pbf files are configured"); + return disabled(); + } + + try { + ParsedFeatures parsed = ParsedFeatures.empty(); + for (Path path : paths) { + ParsedFeatures fileFeatures = parseFile(path); + parsed = parsed.merge(fileFeatures); + } + JsonExternalFeatureSource delegate = parsed.toSource(); + LOGGER.info( + "Loaded Tellus PBF features from {} file(s) (roads={}, buildings={}, areas={}, lines={}, points={})", + paths.size(), + delegate.roads().size(), + delegate.buildings().size(), + delegate.areas().size(), + delegate.lines().size(), + delegate.points().size() + ); + TellusDiagnostics.traffic( + "PBF source loaded files=%d roads=%d buildings=%d areas=%d lines=%d points=%d", + paths.size(), + delegate.roads().size(), + delegate.buildings().size(), + delegate.areas().size(), + delegate.lines().size(), + delegate.points().size() + ); + return new PbfExternalFeatureSource(true, paths, delegate); + } catch (IOException | RuntimeException error) { + LOGGER.warn("Failed to load Tellus PBF features from {}", paths, error); + TellusDiagnostics.traffic("PBF source unavailable paths=%s error=%s", paths, shortError(error)); + return disabled(); + } + } + + public static PbfExternalFeatureSource disabled() { + return new PbfExternalFeatureSource(false, List.of(), new JsonExternalFeatureSource(List.of(), List.of())); + } + + public boolean available() { + return this.enabled + && (!this.delegate.roads().isEmpty() + || !this.delegate.buildings().isEmpty() + || !this.delegate.areas().isEmpty() + || !this.delegate.lines().isEmpty() + || !this.delegate.points().isEmpty()); + } + + public boolean roadsAvailable() { + return this.enabled && !this.delegate.roads().isEmpty(); + } + + public boolean buildingsAvailable() { + return this.enabled && !this.delegate.buildings().isEmpty(); + } + + public boolean cityDetailsAvailable() { + return this.enabled && (!this.delegate.areas().isEmpty() || !this.delegate.lines().isEmpty() || !this.delegate.points().isEmpty()); + } + + public List paths() { + return this.paths; + } + + public static FileSummary summarizeConfiguredFiles() { + boolean enabled = Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); + if (!enabled) { + return new FileSummary(false, configuredLocationLabel(), 0, 0L, List.of()); + } + List paths = configuredPaths(); + long bytes = 0L; + for (Path path : paths) { + try { + bytes += Files.size(path); + } catch (IOException error) { + LOGGER.debug("Failed to stat Tellus PBF path {}", path, error); + } + } + return new FileSummary(true, configuredLocationLabel(), paths.size(), bytes, paths); + } + + public static String configuredLocationLabel() { + String configuredPaths = System.getProperty(PATHS_PROPERTY); + if (configuredPaths != null && !configuredPaths.isBlank()) { + return configuredPaths.trim(); + } + return configuredDirectoryPath().toString(); + } + + @Override + public List roadsForBounds(GeoBounds bounds) { + return this.enabled ? this.delegate.roadsForBounds(bounds) : List.of(); + } + + @Override + public List buildingsForBounds(GeoBounds bounds) { + return this.enabled ? this.delegate.buildingsForBounds(bounds) : List.of(); + } + + @Override + public List areasForBounds(GeoBounds bounds) { + return this.enabled ? this.delegate.areasForBounds(bounds) : List.of(); + } + + @Override + public List linesForBounds(GeoBounds bounds) { + return this.enabled ? this.delegate.linesForBounds(bounds) : List.of(); + } + + @Override + public List pointsForBounds(GeoBounds bounds) { + return this.enabled ? this.delegate.pointsForBounds(bounds) : List.of(); + } + + private static List configuredPaths() { + String configuredPaths = System.getProperty(PATHS_PROPERTY); + if (configuredPaths != null && !configuredPaths.isBlank()) { + List paths = new ArrayList<>(); + for (String part : configuredPaths.split(",")) { + String trimmed = part == null ? "" : part.trim(); + if (!trimmed.isEmpty()) { + Path path = Path.of(trimmed).toAbsolutePath().normalize(); + if (Files.isRegularFile(path)) { + paths.add(path); + } else { + LOGGER.warn("Ignoring missing Tellus PBF path {}", path); + TellusDiagnostics.traffic("PBF configured path missing path=%s", path); + } + } + } + paths.sort(Comparator.naturalOrder()); + return List.copyOf(paths); + } + + Path directory = configuredDirectoryPath(); + if (!Files.isDirectory(directory)) { + return List.of(); + } + + try { + List paths = new ArrayList<>(); + try (var stream = Files.list(directory)) { + stream.filter(Files::isRegularFile) + .filter(PbfExternalFeatureSource::isPbfPath) + .sorted() + .forEach(paths::add); + } + return List.copyOf(paths); + } catch (IOException error) { + LOGGER.warn("Failed to list Tellus PBF directory {}", directory, error); + TellusDiagnostics.traffic("PBF directory list failed path=%s error=%s", directory, shortError(error)); + return List.of(); + } + } + + private static boolean isPbfPath(Path path) { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".osm.pbf") || name.endsWith(".pbf"); + } + + private static Path configuredDirectoryPath() { + String configuredDirectory = System.getProperty(DIRECTORY_PROPERTY); + Path directory = configuredDirectory == null || configuredDirectory.isBlank() + ? FabricLoader.getInstance().getGameDir().resolve(DEFAULT_RELATIVE_DIRECTORY) + : Path.of(configuredDirectory.trim()); + return directory.toAbsolutePath().normalize(); + } + + private static ParsedFeatures parseFile(Path path) throws IOException { + long startMs = System.currentTimeMillis(); + PbfSink sink = new PbfSink(); + PbfReader reader = new PbfReader(path.toFile(), 1); + reader.setSink(sink); + reader.run(); + ParsedFeatures parsed = sink.toParsedFeatures(); + TellusDiagnostics.traffic( + "PBF file loaded path=%s bytes=%d elapsedMs=%d nodes=%d ways=%d relations=%d roads=%d buildings=%d areas=%d lines=%d points=%d", + path, + Files.size(path), + System.currentTimeMillis() - startMs, + sink.nodeCount, + sink.wayCount, + sink.relationCount, + parsed.roads().size(), + parsed.buildings().size(), + parsed.areas().size(), + parsed.lines().size(), + parsed.points().size() + ); + return parsed; + } + + private static ExternalRoadFeature parseRoad(String id, Map tags, List points) { + String highway = tags.get("highway"); + RoadClass roadClass = RoadClass.fromHighwayTag(highway); + if (roadClass == null || 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, id, roadClass, mode, bridgeLevel, highway, points, tags); + } + + private static ExternalBuildingFeature parseBuilding(String id, Map tags, List points) { + List ring = closedRing(points); + 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, id, kind, height, minHeight, floorCount, List.of(ring), tags); + } + + private static ExternalBuildingFeature parseBuildingRelation(String id, Map tags, List> rings) { + if (rings.isEmpty()) { + 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, id, kind, height, minHeight, floorCount, rings, tags); + } + + private static ExternalAreaFeature parseArea(String id, Map tags, List points) { + ExternalAreaKind kind = areaKind(tags); + if (kind == null || tags.containsKey("building") || tags.containsKey("building:part")) { + return null; + } + List ring = closedRing(points); + if (ring.size() < 4) { + return null; + } + return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), List.of(ring), tags); + } + + private static ExternalAreaFeature parseAreaRelation(String id, Map tags, List> rings) { + ExternalAreaKind kind = areaKind(tags); + if (kind == null || tags.containsKey("building") || tags.containsKey("building:part") || rings.isEmpty()) { + return null; + } + return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), rings, tags); + } + + private static ExternalLineFeature parseLine(String id, Map tags, List points) { + ExternalLineKind kind = lineKind(tags); + if (kind == null || points.size() < 2) { + return null; + } + return new ExternalLineFeature(SOURCE, id, kind, lineTypeTag(kind, tags), points, tags); + } + + private static ExternalPointFeature parsePointFeature(String id, Map tags, GeoPoint point) { + ExternalPointKind kind = pointKind(tags); + if (kind == null) { + return null; + } + return new ExternalPointFeature(SOURCE, id, kind, pointTypeTag(kind, tags), point, tags); + } + + private static Map tags(Entity entity) { + Map tags = new LinkedHashMap<>(); + for (Tag tag : entity.getTags()) { + if (tag.getKey() != null && !tag.getKey().isBlank() && tag.getValue() != null) { + tags.put(tag.getKey(), tag.getValue()); + } + } + return tags.isEmpty() ? Map.of() : Map.copyOf(tags); + } + + private static List pointsForWay(Way way, Map nodes) { + List points = new ArrayList<>(way.getWayNodes().size()); + GeoPoint previous = null; + for (WayNode wayNode : way.getWayNodes()) { + GeoPoint point = nodes.get(wayNode.getNodeId()); + if (point != null && !point.equals(previous)) { + points.add(point); + previous = point; + } + } + return List.copyOf(points); + } + + private static List> relationRings(Relation relation, Map> wayPointsById) { + List> outerSegments = new ArrayList<>(); + List> innerSegments = new ArrayList<>(); + for (RelationMember member : relation.getMembers()) { + if (member.getMemberType() != EntityType.Way) { + continue; + } + List segment = wayPointsById.get(member.getMemberId()); + if (segment == null || segment.size() < 2) { + continue; + } + String role = Objects.toString(member.getMemberRole(), "").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 List.of(); + } + rings.addAll(mergeSegmentsToRings(innerSegments)); + return List.copyOf(rings); + } + + 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 List closedRing(List points) { + if (points.size() < 3) { + return List.of(); + } + List ring = new ArrayList<>(points); + closeRing(ring); + return ring.size() >= 4 && isClosedRing(ring) ? List.copyOf(ring) : List.of(); + } + + 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 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 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")); + return minHeight == null || minHeight < 0.0 ? 0.0 : minHeight; + } + + 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 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 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 shortError(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() ? error.getClass().getSimpleName() : message; + } + + public record FileSummary(boolean enabled, String location, int fileCount, long bytes, List paths) { + public FileSummary { + location = location == null ? "" : location; + paths = paths == null ? List.of() : List.copyOf(paths); + } + } + + private static final class PbfSink implements Sink { + private final Map nodes = new HashMap<>(); + private final Map> wayPointsById = new HashMap<>(); + private final List roads = new ArrayList<>(); + private final List buildings = new ArrayList<>(); + private final List areas = new ArrayList<>(); + private final List lines = new ArrayList<>(); + private final List points = new ArrayList<>(); + private long nodeCount; + private long wayCount; + private long relationCount; + + @Override + public void initialize(Map metaData) { + } + + @Override + public void process(EntityContainer entityContainer) { + if (entityContainer instanceof NodeContainer nodeContainer) { + this.processNode(nodeContainer.getEntity()); + } else if (entityContainer instanceof WayContainer wayContainer) { + this.processWay(wayContainer.getEntity()); + } else if (entityContainer instanceof RelationContainer relationContainer) { + this.processRelation(relationContainer.getEntity()); + } + } + + @Override + public void complete() { + } + + @Override + public void close() { + } + + private void processNode(Node node) { + this.nodeCount++; + GeoPoint point = new GeoPoint(node.getLatitude(), node.getLongitude()); + this.nodes.put(node.getId(), point); + Map tags = tags(node); + if (!tags.isEmpty()) { + ExternalPointFeature pointFeature = parsePointFeature("node/" + node.getId(), tags, point); + if (pointFeature != null) { + this.points.add(pointFeature); + } + } + } + + private void processWay(Way way) { + this.wayCount++; + List points = pointsForWay(way, this.nodes); + if (points.size() >= 2) { + this.wayPointsById.put(way.getId(), points); + } + + Map tags = tags(way); + if (tags.isEmpty()) { + return; + } + + ExternalRoadFeature road = parseRoad("way/" + way.getId(), tags, points); + if (road != null) { + this.roads.add(road); + } + if (tags.containsKey("building") || tags.containsKey("building:part")) { + ExternalBuildingFeature building = parseBuilding("way/" + way.getId(), tags, points); + if (building != null) { + this.buildings.add(building); + } + } else { + ExternalAreaFeature area = parseArea("way/" + way.getId(), tags, points); + if (area != null) { + this.areas.add(area); + } + } + ExternalLineFeature line = parseLine("way/" + way.getId(), tags, points); + if (line != null) { + this.lines.add(line); + } + } + + private void processRelation(Relation relation) { + this.relationCount++; + Map tags = tags(relation); + if (tags.isEmpty()) { + return; + } + if (!"multipolygon".equals(tags.get("type")) && !tags.containsKey("building") && !tags.containsKey("building:part") && areaKind(tags) == null) { + return; + } + + List> rings = relationRings(relation, this.wayPointsById); + if (tags.containsKey("building") || tags.containsKey("building:part")) { + ExternalBuildingFeature building = parseBuildingRelation("relation/" + relation.getId(), tags, rings); + if (building != null) { + this.buildings.add(building); + } + } else { + ExternalAreaFeature area = parseAreaRelation("relation/" + relation.getId(), tags, rings); + if (area != null) { + this.areas.add(area); + } + } + } + + private ParsedFeatures toParsedFeatures() { + return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points); + } + } + + private record ParsedFeatures( + List roads, + List buildings, + List areas, + List lines, + List points + ) { + private ParsedFeatures { + 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 ParsedFeatures empty() { + return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of()); + } + + private ParsedFeatures merge(ParsedFeatures other) { + List mergedRoads = new ArrayList<>(this.roads); + mergedRoads.addAll(other.roads); + List mergedBuildings = new ArrayList<>(this.buildings); + mergedBuildings.addAll(other.buildings); + List mergedAreas = new ArrayList<>(this.areas); + mergedAreas.addAll(other.areas); + List mergedLines = new ArrayList<>(this.lines); + mergedLines.addAll(other.lines); + List mergedPoints = new ArrayList<>(this.points); + mergedPoints.addAll(other.points); + return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints); + } + + private JsonExternalFeatureSource toSource() { + return new JsonExternalFeatureSource(this.roads, this.buildings, this.areas, this.lines, this.points); + } + } +} 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 index 838c73143..f17a31377 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java @@ -22,6 +22,7 @@ public final class TellusExternalFeatureSource { private static final long REFRESH_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(2L); private final Path path; + private final PbfExternalFeatureSource pbfSource; private final OverpassExternalFeatureSource overpassSource; private final boolean preferExternalFeatures; private final Object refreshLock = new Object(); @@ -30,15 +31,27 @@ public final class TellusExternalFeatureSource { private volatile long nextRefreshAtNanos; public TellusExternalFeatureSource(Path path) { - this(path, OverpassExternalFeatureSource.createDefault(), Boolean.parseBoolean(System.getProperty(PREFER_EXTERNAL_PROPERTY, "true"))); + this( + path, + PbfExternalFeatureSource.createDefault(), + OverpassExternalFeatureSource.createDefault(), + Boolean.parseBoolean(System.getProperty(PREFER_EXTERNAL_PROPERTY, "true")) + ); } public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource) { - this(path, overpassSource, false); + this(path, PbfExternalFeatureSource.disabled(), overpassSource, false); } public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource, boolean preferExternalFeatures) { + this(path, PbfExternalFeatureSource.disabled(), overpassSource, preferExternalFeatures); + } + + public TellusExternalFeatureSource( + Path path, PbfExternalFeatureSource pbfSource, OverpassExternalFeatureSource overpassSource, boolean preferExternalFeatures + ) { this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + this.pbfSource = Objects.requireNonNull(pbfSource, "pbfSource"); this.overpassSource = Objects.requireNonNull(overpassSource, "overpassSource"); this.preferExternalFeatures = preferExternalFeatures; } @@ -62,20 +75,25 @@ public boolean available() { || !current.areas().isEmpty() || !current.lines().isEmpty() || !current.points().isEmpty() + || this.pbfSource.available() || this.overpassSource.available(); } public boolean roadsAvailable() { - return !this.currentSource().roads().isEmpty() || this.overpassSource.available(); + return !this.currentSource().roads().isEmpty() || this.pbfSource.roadsAvailable() || this.overpassSource.available(); } public boolean buildingsAvailable() { - return !this.currentSource().buildings().isEmpty() || this.overpassSource.available(); + return !this.currentSource().buildings().isEmpty() || this.pbfSource.buildingsAvailable() || this.overpassSource.available(); } public boolean cityDetailsAvailable() { JsonExternalFeatureSource current = this.currentSource(); - return !current.areas().isEmpty() || !current.lines().isEmpty() || !current.points().isEmpty() || this.overpassSource.available(); + return !current.areas().isEmpty() + || !current.lines().isEmpty() + || !current.points().isEmpty() + || this.pbfSource.cityDetailsAvailable() + || this.overpassSource.available(); } public boolean preferExternalRoads() { @@ -95,6 +113,7 @@ public List roadsForArea(int minBlockX, int minBlockZ, int maxBlock try { List externalRoads = new ArrayList<>(); externalRoads.addAll(this.currentSource().roadsForBounds(bounds)); + externalRoads.addAll(this.pbfSource.roadsForBounds(bounds)); externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); if (externalRoads.isEmpty()) { return List.of(); @@ -121,6 +140,7 @@ public List buildingsForArea( try { List externalBuildings = new ArrayList<>(); externalBuildings.addAll(this.currentSource().buildingsForBounds(bounds)); + externalBuildings.addAll(this.pbfSource.buildingsForBounds(bounds)); externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); if (externalBuildings.isEmpty()) { return List.of(); @@ -147,6 +167,7 @@ public List cityAreasForArea( try { List areas = new ArrayList<>(); areas.addAll(this.currentSource().areasForBounds(bounds)); + areas.addAll(this.pbfSource.areasForBounds(bounds)); areas.addAll(this.overpassSource.areasForBounds(bounds)); return areas.isEmpty() ? List.of() : List.copyOf(areas); } catch (RuntimeException error) { @@ -166,6 +187,7 @@ public List cityLinesForArea( try { List lines = new ArrayList<>(); lines.addAll(this.currentSource().linesForBounds(bounds)); + lines.addAll(this.pbfSource.linesForBounds(bounds)); lines.addAll(this.overpassSource.linesForBounds(bounds)); return lines.isEmpty() ? List.of() : List.copyOf(lines); } catch (RuntimeException error) { @@ -185,6 +207,7 @@ public List cityPointsForArea( try { List points = new ArrayList<>(); points.addAll(this.currentSource().pointsForBounds(bounds)); + points.addAll(this.pbfSource.pointsForBounds(bounds)); points.addAll(this.overpassSource.pointsForBounds(bounds)); return points.isEmpty() ? List.of() : List.copyOf(points); } catch (RuntimeException error) { From 99ac8fa4f908031133093287e6bb0c250e4a2ff2 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 22:10:18 +0800 Subject: [PATCH 6/9] Skip Overpass for covered PBF tiles --- docs/wlb-arnis-integration.md | 2 +- .../integration/PbfExternalFeatureSource.java | 65 ++++++++++++++++--- .../TellusExternalFeatureSource.java | 20 ++++-- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md index ab304a97f..c7cbc0c90 100644 --- a/docs/wlb-arnis-integration.md +++ b/docs/wlb-arnis-integration.md @@ -130,7 +130,7 @@ Network behavior is intentionally conservative to avoid burning VPN traffic: - 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. -- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details without live Overpass traffic. +- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details. When a generated tile is inside the loaded PBF coverage, Tellus skips live Overpass requests for that tile to avoid VPN traffic. 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. diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java index 16e52eea9..9dbc2661f 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java @@ -42,11 +42,13 @@ public final class PbfExternalFeatureSource implements ExternalFeatureSource { private final boolean enabled; private final List paths; + private final GeoBounds coverage; private final JsonExternalFeatureSource delegate; - private PbfExternalFeatureSource(boolean enabled, List paths, JsonExternalFeatureSource delegate) { + private PbfExternalFeatureSource(boolean enabled, List paths, GeoBounds coverage, JsonExternalFeatureSource delegate) { this.enabled = enabled; this.paths = paths == null ? List.of() : List.copyOf(paths); + this.coverage = coverage; this.delegate = Objects.requireNonNull(delegate, "delegate"); } @@ -79,15 +81,16 @@ public static PbfExternalFeatureSource createDefault() { delegate.points().size() ); TellusDiagnostics.traffic( - "PBF source loaded files=%d roads=%d buildings=%d areas=%d lines=%d points=%d", + "PBF source loaded files=%d coverage=%s roads=%d buildings=%d areas=%d lines=%d points=%d", paths.size(), + parsed.coverage(), delegate.roads().size(), delegate.buildings().size(), delegate.areas().size(), delegate.lines().size(), delegate.points().size() ); - return new PbfExternalFeatureSource(true, paths, delegate); + return new PbfExternalFeatureSource(true, paths, parsed.coverage(), delegate); } catch (IOException | RuntimeException error) { LOGGER.warn("Failed to load Tellus PBF features from {}", paths, error); TellusDiagnostics.traffic("PBF source unavailable paths=%s error=%s", paths, shortError(error)); @@ -96,7 +99,7 @@ public static PbfExternalFeatureSource createDefault() { } public static PbfExternalFeatureSource disabled() { - return new PbfExternalFeatureSource(false, List.of(), new JsonExternalFeatureSource(List.of(), List.of())); + return new PbfExternalFeatureSource(false, List.of(), null, new JsonExternalFeatureSource(List.of(), List.of())); } public boolean available() { @@ -120,6 +123,17 @@ public boolean cityDetailsAvailable() { return this.enabled && (!this.delegate.areas().isEmpty() || !this.delegate.lines().isEmpty() || !this.delegate.points().isEmpty()); } + public boolean coversBounds(GeoBounds bounds) { + Objects.requireNonNull(bounds, "bounds"); + if (!this.enabled || this.coverage == null) { + return false; + } + return bounds.south() >= this.coverage.south() + && bounds.north() <= this.coverage.north() + && bounds.west() >= this.coverage.west() + && bounds.east() <= this.coverage.east(); + } + public List paths() { return this.paths; } @@ -688,6 +702,10 @@ private static final class PbfSink implements Sink { private final List areas = new ArrayList<>(); private final List lines = new ArrayList<>(); private final List points = new ArrayList<>(); + private double south = Double.POSITIVE_INFINITY; + private double west = Double.POSITIVE_INFINITY; + private double north = Double.NEGATIVE_INFINITY; + private double east = Double.NEGATIVE_INFINITY; private long nodeCount; private long wayCount; private long relationCount; @@ -718,6 +736,7 @@ public void close() { private void processNode(Node node) { this.nodeCount++; GeoPoint point = new GeoPoint(node.getLatitude(), node.getLongitude()); + this.expandCoverage(point); this.nodes.put(node.getId(), point); Map tags = tags(node); if (!tags.isEmpty()) { @@ -785,8 +804,22 @@ private void processRelation(Relation relation) { } } + private void expandCoverage(GeoPoint point) { + this.south = Math.min(this.south, point.latitude()); + this.north = Math.max(this.north, point.latitude()); + this.west = Math.min(this.west, point.longitude()); + this.east = Math.max(this.east, point.longitude()); + } + + private GeoBounds coverage() { + if (!Double.isFinite(this.south) || !Double.isFinite(this.west) || !Double.isFinite(this.north) || !Double.isFinite(this.east)) { + return null; + } + return new GeoBounds(this.south, this.west, this.north, this.east); + } + private ParsedFeatures toParsedFeatures() { - return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points); + return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points, this.coverage()); } } @@ -795,7 +828,8 @@ private record ParsedFeatures( List buildings, List areas, List lines, - List points + List points, + GeoBounds coverage ) { private ParsedFeatures { roads = roads == null ? List.of() : List.copyOf(roads); @@ -806,7 +840,7 @@ private record ParsedFeatures( } private static ParsedFeatures empty() { - return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of()); + return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of(), null); } private ParsedFeatures merge(ParsedFeatures other) { @@ -820,11 +854,26 @@ private ParsedFeatures merge(ParsedFeatures other) { mergedLines.addAll(other.lines); List mergedPoints = new ArrayList<>(this.points); mergedPoints.addAll(other.points); - return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints); + return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints, mergeCoverage(this.coverage, other.coverage)); } private JsonExternalFeatureSource toSource() { return new JsonExternalFeatureSource(this.roads, this.buildings, this.areas, this.lines, this.points); } + + private static GeoBounds mergeCoverage(GeoBounds first, GeoBounds second) { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + return new GeoBounds( + Math.min(first.south(), second.south()), + Math.min(first.west(), second.west()), + Math.max(first.north(), second.north()), + Math.max(first.east(), second.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 index f17a31377..9d4adf1ce 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java @@ -114,7 +114,9 @@ public List roadsForArea(int minBlockX, int minBlockZ, int maxBlock List externalRoads = new ArrayList<>(); externalRoads.addAll(this.currentSource().roadsForBounds(bounds)); externalRoads.addAll(this.pbfSource.roadsForBounds(bounds)); - externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); + if (!this.pbfSource.coversBounds(bounds)) { + externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); + } if (externalRoads.isEmpty()) { return List.of(); } @@ -141,7 +143,9 @@ public List buildingsForArea( List externalBuildings = new ArrayList<>(); externalBuildings.addAll(this.currentSource().buildingsForBounds(bounds)); externalBuildings.addAll(this.pbfSource.buildingsForBounds(bounds)); - externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); + if (!this.pbfSource.coversBounds(bounds)) { + externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); + } if (externalBuildings.isEmpty()) { return List.of(); } @@ -168,7 +172,9 @@ public List cityAreasForArea( List areas = new ArrayList<>(); areas.addAll(this.currentSource().areasForBounds(bounds)); areas.addAll(this.pbfSource.areasForBounds(bounds)); - areas.addAll(this.overpassSource.areasForBounds(bounds)); + if (!this.pbfSource.coversBounds(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); @@ -188,7 +194,9 @@ public List cityLinesForArea( List lines = new ArrayList<>(); lines.addAll(this.currentSource().linesForBounds(bounds)); lines.addAll(this.pbfSource.linesForBounds(bounds)); - lines.addAll(this.overpassSource.linesForBounds(bounds)); + if (!this.pbfSource.coversBounds(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); @@ -208,7 +216,9 @@ public List cityPointsForArea( List points = new ArrayList<>(); points.addAll(this.currentSource().pointsForBounds(bounds)); points.addAll(this.pbfSource.pointsForBounds(bounds)); - points.addAll(this.overpassSource.pointsForBounds(bounds)); + if (!this.pbfSource.coversBounds(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); From 420e3c7c7be434d50241fdf462eed49ff0db0d9d Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 22:42:17 +0800 Subject: [PATCH 7/9] Revert "Skip Overpass for covered PBF tiles" This reverts commit 99ac8fa4f908031133093287e6bb0c250e4a2ff2. --- docs/wlb-arnis-integration.md | 2 +- .../integration/PbfExternalFeatureSource.java | 65 +++---------------- .../TellusExternalFeatureSource.java | 20 ++---- 3 files changed, 14 insertions(+), 73 deletions(-) diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md index c7cbc0c90..ab304a97f 100644 --- a/docs/wlb-arnis-integration.md +++ b/docs/wlb-arnis-integration.md @@ -130,7 +130,7 @@ Network behavior is intentionally conservative to avoid burning VPN traffic: - 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. -- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details. When a generated tile is inside the loaded PBF coverage, Tellus skips live Overpass requests for that tile to avoid VPN traffic. +- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details without live Overpass traffic. 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. diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java index 9dbc2661f..16e52eea9 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java @@ -42,13 +42,11 @@ public final class PbfExternalFeatureSource implements ExternalFeatureSource { private final boolean enabled; private final List paths; - private final GeoBounds coverage; private final JsonExternalFeatureSource delegate; - private PbfExternalFeatureSource(boolean enabled, List paths, GeoBounds coverage, JsonExternalFeatureSource delegate) { + private PbfExternalFeatureSource(boolean enabled, List paths, JsonExternalFeatureSource delegate) { this.enabled = enabled; this.paths = paths == null ? List.of() : List.copyOf(paths); - this.coverage = coverage; this.delegate = Objects.requireNonNull(delegate, "delegate"); } @@ -81,16 +79,15 @@ public static PbfExternalFeatureSource createDefault() { delegate.points().size() ); TellusDiagnostics.traffic( - "PBF source loaded files=%d coverage=%s roads=%d buildings=%d areas=%d lines=%d points=%d", + "PBF source loaded files=%d roads=%d buildings=%d areas=%d lines=%d points=%d", paths.size(), - parsed.coverage(), delegate.roads().size(), delegate.buildings().size(), delegate.areas().size(), delegate.lines().size(), delegate.points().size() ); - return new PbfExternalFeatureSource(true, paths, parsed.coverage(), delegate); + return new PbfExternalFeatureSource(true, paths, delegate); } catch (IOException | RuntimeException error) { LOGGER.warn("Failed to load Tellus PBF features from {}", paths, error); TellusDiagnostics.traffic("PBF source unavailable paths=%s error=%s", paths, shortError(error)); @@ -99,7 +96,7 @@ public static PbfExternalFeatureSource createDefault() { } public static PbfExternalFeatureSource disabled() { - return new PbfExternalFeatureSource(false, List.of(), null, new JsonExternalFeatureSource(List.of(), List.of())); + return new PbfExternalFeatureSource(false, List.of(), new JsonExternalFeatureSource(List.of(), List.of())); } public boolean available() { @@ -123,17 +120,6 @@ public boolean cityDetailsAvailable() { return this.enabled && (!this.delegate.areas().isEmpty() || !this.delegate.lines().isEmpty() || !this.delegate.points().isEmpty()); } - public boolean coversBounds(GeoBounds bounds) { - Objects.requireNonNull(bounds, "bounds"); - if (!this.enabled || this.coverage == null) { - return false; - } - return bounds.south() >= this.coverage.south() - && bounds.north() <= this.coverage.north() - && bounds.west() >= this.coverage.west() - && bounds.east() <= this.coverage.east(); - } - public List paths() { return this.paths; } @@ -702,10 +688,6 @@ private static final class PbfSink implements Sink { private final List areas = new ArrayList<>(); private final List lines = new ArrayList<>(); private final List points = new ArrayList<>(); - private double south = Double.POSITIVE_INFINITY; - private double west = Double.POSITIVE_INFINITY; - private double north = Double.NEGATIVE_INFINITY; - private double east = Double.NEGATIVE_INFINITY; private long nodeCount; private long wayCount; private long relationCount; @@ -736,7 +718,6 @@ public void close() { private void processNode(Node node) { this.nodeCount++; GeoPoint point = new GeoPoint(node.getLatitude(), node.getLongitude()); - this.expandCoverage(point); this.nodes.put(node.getId(), point); Map tags = tags(node); if (!tags.isEmpty()) { @@ -804,22 +785,8 @@ private void processRelation(Relation relation) { } } - private void expandCoverage(GeoPoint point) { - this.south = Math.min(this.south, point.latitude()); - this.north = Math.max(this.north, point.latitude()); - this.west = Math.min(this.west, point.longitude()); - this.east = Math.max(this.east, point.longitude()); - } - - private GeoBounds coverage() { - if (!Double.isFinite(this.south) || !Double.isFinite(this.west) || !Double.isFinite(this.north) || !Double.isFinite(this.east)) { - return null; - } - return new GeoBounds(this.south, this.west, this.north, this.east); - } - private ParsedFeatures toParsedFeatures() { - return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points, this.coverage()); + return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points); } } @@ -828,8 +795,7 @@ private record ParsedFeatures( List buildings, List areas, List lines, - List points, - GeoBounds coverage + List points ) { private ParsedFeatures { roads = roads == null ? List.of() : List.copyOf(roads); @@ -840,7 +806,7 @@ private record ParsedFeatures( } private static ParsedFeatures empty() { - return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of(), null); + return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of()); } private ParsedFeatures merge(ParsedFeatures other) { @@ -854,26 +820,11 @@ private ParsedFeatures merge(ParsedFeatures other) { mergedLines.addAll(other.lines); List mergedPoints = new ArrayList<>(this.points); mergedPoints.addAll(other.points); - return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints, mergeCoverage(this.coverage, other.coverage)); + return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints); } private JsonExternalFeatureSource toSource() { return new JsonExternalFeatureSource(this.roads, this.buildings, this.areas, this.lines, this.points); } - - private static GeoBounds mergeCoverage(GeoBounds first, GeoBounds second) { - if (first == null) { - return second; - } - if (second == null) { - return first; - } - return new GeoBounds( - Math.min(first.south(), second.south()), - Math.min(first.west(), second.west()), - Math.max(first.north(), second.north()), - Math.max(first.east(), second.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 index 9d4adf1ce..f17a31377 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java @@ -114,9 +114,7 @@ public List roadsForArea(int minBlockX, int minBlockZ, int maxBlock List externalRoads = new ArrayList<>(); externalRoads.addAll(this.currentSource().roadsForBounds(bounds)); externalRoads.addAll(this.pbfSource.roadsForBounds(bounds)); - if (!this.pbfSource.coversBounds(bounds)) { - externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); - } + externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); if (externalRoads.isEmpty()) { return List.of(); } @@ -143,9 +141,7 @@ public List buildingsForArea( List externalBuildings = new ArrayList<>(); externalBuildings.addAll(this.currentSource().buildingsForBounds(bounds)); externalBuildings.addAll(this.pbfSource.buildingsForBounds(bounds)); - if (!this.pbfSource.coversBounds(bounds)) { - externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); - } + externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); if (externalBuildings.isEmpty()) { return List.of(); } @@ -172,9 +168,7 @@ public List cityAreasForArea( List areas = new ArrayList<>(); areas.addAll(this.currentSource().areasForBounds(bounds)); areas.addAll(this.pbfSource.areasForBounds(bounds)); - if (!this.pbfSource.coversBounds(bounds)) { - areas.addAll(this.overpassSource.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); @@ -194,9 +188,7 @@ public List cityLinesForArea( List lines = new ArrayList<>(); lines.addAll(this.currentSource().linesForBounds(bounds)); lines.addAll(this.pbfSource.linesForBounds(bounds)); - if (!this.pbfSource.coversBounds(bounds)) { - lines.addAll(this.overpassSource.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); @@ -216,9 +208,7 @@ public List cityPointsForArea( List points = new ArrayList<>(); points.addAll(this.currentSource().pointsForBounds(bounds)); points.addAll(this.pbfSource.pointsForBounds(bounds)); - if (!this.pbfSource.coversBounds(bounds)) { - points.addAll(this.overpassSource.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); From 23619a1787d4d76b9a5a8ecf600f6b7386c8040f Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Mon, 4 May 2026 22:42:17 +0800 Subject: [PATCH 8/9] Revert "Add local OSM PBF feature source" This reverts commit 2f1da3ac1e51ef6f4e1168842c96c6c09214f9b1. --- docs/wlb-arnis-integration.md | 4 - mc1201/build.gradle | 6 - .../client/screen/EarthCustomizeScreen.java | 15 +- mc1211/build.gradle | 6 - .../client/screen/EarthCustomizeScreen.java | 15 +- mc261/build.gradle | 6 - .../client/screen/EarthCustomizeScreen.java | 7 - .../integration/PbfExternalFeatureSource.java | 830 ------------------ .../TellusExternalFeatureSource.java | 33 +- 9 files changed, 13 insertions(+), 909 deletions(-) delete mode 100644 src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java diff --git a/docs/wlb-arnis-integration.md b/docs/wlb-arnis-integration.md index ab304a97f..bea272f00 100644 --- a/docs/wlb-arnis-integration.md +++ b/docs/wlb-arnis-integration.md @@ -114,9 +114,6 @@ Useful runtime switches: -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.arnis.pbf.enabled=true --Dtellus.arnis.pbf.directory=/path/to/osm-pbf-folder --Dtellus.arnis.pbf.paths=/path/to/new-york.osm.pbf,/path/to/new-jersey.osm.pbf -Dtellus.external.features.prefer=false ``` @@ -130,7 +127,6 @@ Network behavior is intentionally conservative to avoid burning VPN traffic: - 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. -- Local `.osm.pbf` extracts are loaded before Overpass. By default Tellus scans `/tellus/cache/osm-pbf/`; configured files provide roads, buildings, landuse/natural/leisure/water areas, barriers/rail/waterway/power lines, and point details without live Overpass traffic. 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. diff --git a/mc1201/build.gradle b/mc1201/build.gradle index 59123e6ee..74853112d 100644 --- a/mc1201/build.gradle +++ b/mc1201/build.gradle @@ -100,12 +100,6 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:23.2.0' implementation 'com.google.protobuf:protobuf-java:3.23.4' include 'com.google.protobuf:protobuf-java:3.23.4' - implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' - include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 980f435a2..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 @@ -12,7 +12,6 @@ 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.world.data.integration.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; @@ -1070,16 +1069,10 @@ private List dataSourcesEntries() { 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)); - PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); - entries.add(infoSpacer()); - entries.add(infoHeader("Local OSM PBF")); - entries.add(infoLine("Local extracts are loaded before Overpass to avoid live network traffic.")); - entries.add(infoSubtle("Path: " + pbfSummary.location())); - entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); - entries.add(infoSpacer()); + 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)")); diff --git a/mc1211/build.gradle b/mc1211/build.gradle index a3285aad6..5f991ddfc 100644 --- a/mc1211/build.gradle +++ b/mc1211/build.gradle @@ -74,12 +74,6 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:24.1.1' implementation 'com.google.protobuf:protobuf-java:4.28.2' include 'com.google.protobuf:protobuf-java:4.28.2' - implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' - include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 38999d959..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 @@ -11,7 +11,6 @@ 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.world.data.integration.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthProjection; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; @@ -1089,16 +1088,10 @@ private List dataSourcesEntries() { 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)); - PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); - entries.add(infoSpacer()); - entries.add(infoHeader("Local OSM PBF")); - entries.add(infoLine("Local extracts are loaded before Overpass to avoid live network traffic.")); - entries.add(infoSubtle("Path: " + pbfSummary.location())); - entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); - entries.add(infoSpacer()); + 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)")); diff --git a/mc261/build.gradle b/mc261/build.gradle index 53bc2f758..a057bd5e3 100644 --- a/mc261/build.gradle +++ b/mc261/build.gradle @@ -73,12 +73,6 @@ dependencies { include 'io.github.sebasbaumh:mapbox-vector-tile-java:24.1.1' implementation 'com.google.protobuf:protobuf-java:4.28.2' include 'com.google.protobuf:protobuf-java:4.28.2' - implementation 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-pbf2:0.49.2' - implementation 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - include 'org.openstreetmap.osmosis:osmosis-core:0.49.2' - implementation 'org.openstreetmap.pbf:osmpbf:1.5.0' - include 'org.openstreetmap.pbf:osmpbf:1.5.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' 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 140db4f8f..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 @@ -9,7 +9,6 @@ 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.PbfExternalFeatureSource; import com.yucareux.tellus.worldgen.EarthChunkGenerator; import com.yucareux.tellus.worldgen.EarthGeneratorSettings; import java.io.IOException; @@ -1025,12 +1024,6 @@ private static EarthCustomizeScreen.CacheActionDefinition cacheActionButton( Com private static List dataSourcesEntries() { List entries = new ArrayList<>(); - PbfExternalFeatureSource.FileSummary pbfSummary = PbfExternalFeatureSource.summarizeConfiguredFiles(); - entries.add(infoHeader("Local OSM PBF")); - entries.add(infoLine("Local extracts provide roads/buildings/city details without live OSM traffic.")); - entries.add(infoSubtle("Path: " + pbfSummary.location())); - entries.add(infoSubtle("Files: " + pbfSummary.fileCount() + " (" + formatBytes(pbfSummary.bytes()) + ")")); - 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)")); diff --git a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java b/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java deleted file mode 100644 index 16e52eea9..000000000 --- a/src/main/java/com/yucareux/tellus/world/data/integration/PbfExternalFeatureSource.java +++ /dev/null @@ -1,830 +0,0 @@ -package com.yucareux.tellus.world.data.integration; - -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.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import net.fabricmc.loader.api.FabricLoader; -import org.openstreetmap.osmosis.pbf2.v0_6.PbfReader; -import org.openstreetmap.osmosis.core.container.v0_6.EntityContainer; -import org.openstreetmap.osmosis.core.container.v0_6.NodeContainer; -import org.openstreetmap.osmosis.core.container.v0_6.RelationContainer; -import org.openstreetmap.osmosis.core.container.v0_6.WayContainer; -import org.openstreetmap.osmosis.core.domain.v0_6.Entity; -import org.openstreetmap.osmosis.core.domain.v0_6.EntityType; -import org.openstreetmap.osmosis.core.domain.v0_6.Node; -import org.openstreetmap.osmosis.core.domain.v0_6.Relation; -import org.openstreetmap.osmosis.core.domain.v0_6.RelationMember; -import org.openstreetmap.osmosis.core.domain.v0_6.Tag; -import org.openstreetmap.osmosis.core.domain.v0_6.Way; -import org.openstreetmap.osmosis.core.domain.v0_6.WayNode; -import org.openstreetmap.osmosis.core.task.v0_6.Sink; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public final class PbfExternalFeatureSource implements ExternalFeatureSource { - public static final String ENABLED_PROPERTY = "tellus.arnis.pbf.enabled"; - public static final String PATHS_PROPERTY = "tellus.arnis.pbf.paths"; - public static final String DIRECTORY_PROPERTY = "tellus.arnis.pbf.directory"; - public static final String DEFAULT_RELATIVE_DIRECTORY = "tellus/cache/osm-pbf"; - private static final Logger LOGGER = LoggerFactory.getLogger("tellus"); - private static final String SOURCE = "arnis-pbf"; - - private final boolean enabled; - private final List paths; - private final JsonExternalFeatureSource delegate; - - private PbfExternalFeatureSource(boolean enabled, List paths, JsonExternalFeatureSource delegate) { - this.enabled = enabled; - this.paths = paths == null ? List.of() : List.copyOf(paths); - this.delegate = Objects.requireNonNull(delegate, "delegate"); - } - - public static PbfExternalFeatureSource createDefault() { - if (!Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true"))) { - TellusDiagnostics.traffic("PBF source disabled by %s=false", ENABLED_PROPERTY); - return disabled(); - } - - List paths = configuredPaths(); - if (paths.isEmpty()) { - TellusDiagnostics.traffic("PBF source ready but no .osm.pbf files are configured"); - return disabled(); - } - - try { - ParsedFeatures parsed = ParsedFeatures.empty(); - for (Path path : paths) { - ParsedFeatures fileFeatures = parseFile(path); - parsed = parsed.merge(fileFeatures); - } - JsonExternalFeatureSource delegate = parsed.toSource(); - LOGGER.info( - "Loaded Tellus PBF features from {} file(s) (roads={}, buildings={}, areas={}, lines={}, points={})", - paths.size(), - delegate.roads().size(), - delegate.buildings().size(), - delegate.areas().size(), - delegate.lines().size(), - delegate.points().size() - ); - TellusDiagnostics.traffic( - "PBF source loaded files=%d roads=%d buildings=%d areas=%d lines=%d points=%d", - paths.size(), - delegate.roads().size(), - delegate.buildings().size(), - delegate.areas().size(), - delegate.lines().size(), - delegate.points().size() - ); - return new PbfExternalFeatureSource(true, paths, delegate); - } catch (IOException | RuntimeException error) { - LOGGER.warn("Failed to load Tellus PBF features from {}", paths, error); - TellusDiagnostics.traffic("PBF source unavailable paths=%s error=%s", paths, shortError(error)); - return disabled(); - } - } - - public static PbfExternalFeatureSource disabled() { - return new PbfExternalFeatureSource(false, List.of(), new JsonExternalFeatureSource(List.of(), List.of())); - } - - public boolean available() { - return this.enabled - && (!this.delegate.roads().isEmpty() - || !this.delegate.buildings().isEmpty() - || !this.delegate.areas().isEmpty() - || !this.delegate.lines().isEmpty() - || !this.delegate.points().isEmpty()); - } - - public boolean roadsAvailable() { - return this.enabled && !this.delegate.roads().isEmpty(); - } - - public boolean buildingsAvailable() { - return this.enabled && !this.delegate.buildings().isEmpty(); - } - - public boolean cityDetailsAvailable() { - return this.enabled && (!this.delegate.areas().isEmpty() || !this.delegate.lines().isEmpty() || !this.delegate.points().isEmpty()); - } - - public List paths() { - return this.paths; - } - - public static FileSummary summarizeConfiguredFiles() { - boolean enabled = Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); - if (!enabled) { - return new FileSummary(false, configuredLocationLabel(), 0, 0L, List.of()); - } - List paths = configuredPaths(); - long bytes = 0L; - for (Path path : paths) { - try { - bytes += Files.size(path); - } catch (IOException error) { - LOGGER.debug("Failed to stat Tellus PBF path {}", path, error); - } - } - return new FileSummary(true, configuredLocationLabel(), paths.size(), bytes, paths); - } - - public static String configuredLocationLabel() { - String configuredPaths = System.getProperty(PATHS_PROPERTY); - if (configuredPaths != null && !configuredPaths.isBlank()) { - return configuredPaths.trim(); - } - return configuredDirectoryPath().toString(); - } - - @Override - public List roadsForBounds(GeoBounds bounds) { - return this.enabled ? this.delegate.roadsForBounds(bounds) : List.of(); - } - - @Override - public List buildingsForBounds(GeoBounds bounds) { - return this.enabled ? this.delegate.buildingsForBounds(bounds) : List.of(); - } - - @Override - public List areasForBounds(GeoBounds bounds) { - return this.enabled ? this.delegate.areasForBounds(bounds) : List.of(); - } - - @Override - public List linesForBounds(GeoBounds bounds) { - return this.enabled ? this.delegate.linesForBounds(bounds) : List.of(); - } - - @Override - public List pointsForBounds(GeoBounds bounds) { - return this.enabled ? this.delegate.pointsForBounds(bounds) : List.of(); - } - - private static List configuredPaths() { - String configuredPaths = System.getProperty(PATHS_PROPERTY); - if (configuredPaths != null && !configuredPaths.isBlank()) { - List paths = new ArrayList<>(); - for (String part : configuredPaths.split(",")) { - String trimmed = part == null ? "" : part.trim(); - if (!trimmed.isEmpty()) { - Path path = Path.of(trimmed).toAbsolutePath().normalize(); - if (Files.isRegularFile(path)) { - paths.add(path); - } else { - LOGGER.warn("Ignoring missing Tellus PBF path {}", path); - TellusDiagnostics.traffic("PBF configured path missing path=%s", path); - } - } - } - paths.sort(Comparator.naturalOrder()); - return List.copyOf(paths); - } - - Path directory = configuredDirectoryPath(); - if (!Files.isDirectory(directory)) { - return List.of(); - } - - try { - List paths = new ArrayList<>(); - try (var stream = Files.list(directory)) { - stream.filter(Files::isRegularFile) - .filter(PbfExternalFeatureSource::isPbfPath) - .sorted() - .forEach(paths::add); - } - return List.copyOf(paths); - } catch (IOException error) { - LOGGER.warn("Failed to list Tellus PBF directory {}", directory, error); - TellusDiagnostics.traffic("PBF directory list failed path=%s error=%s", directory, shortError(error)); - return List.of(); - } - } - - private static boolean isPbfPath(Path path) { - String name = path.getFileName().toString().toLowerCase(Locale.ROOT); - return name.endsWith(".osm.pbf") || name.endsWith(".pbf"); - } - - private static Path configuredDirectoryPath() { - String configuredDirectory = System.getProperty(DIRECTORY_PROPERTY); - Path directory = configuredDirectory == null || configuredDirectory.isBlank() - ? FabricLoader.getInstance().getGameDir().resolve(DEFAULT_RELATIVE_DIRECTORY) - : Path.of(configuredDirectory.trim()); - return directory.toAbsolutePath().normalize(); - } - - private static ParsedFeatures parseFile(Path path) throws IOException { - long startMs = System.currentTimeMillis(); - PbfSink sink = new PbfSink(); - PbfReader reader = new PbfReader(path.toFile(), 1); - reader.setSink(sink); - reader.run(); - ParsedFeatures parsed = sink.toParsedFeatures(); - TellusDiagnostics.traffic( - "PBF file loaded path=%s bytes=%d elapsedMs=%d nodes=%d ways=%d relations=%d roads=%d buildings=%d areas=%d lines=%d points=%d", - path, - Files.size(path), - System.currentTimeMillis() - startMs, - sink.nodeCount, - sink.wayCount, - sink.relationCount, - parsed.roads().size(), - parsed.buildings().size(), - parsed.areas().size(), - parsed.lines().size(), - parsed.points().size() - ); - return parsed; - } - - private static ExternalRoadFeature parseRoad(String id, Map tags, List points) { - String highway = tags.get("highway"); - RoadClass roadClass = RoadClass.fromHighwayTag(highway); - if (roadClass == null || 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, id, roadClass, mode, bridgeLevel, highway, points, tags); - } - - private static ExternalBuildingFeature parseBuilding(String id, Map tags, List points) { - List ring = closedRing(points); - 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, id, kind, height, minHeight, floorCount, List.of(ring), tags); - } - - private static ExternalBuildingFeature parseBuildingRelation(String id, Map tags, List> rings) { - if (rings.isEmpty()) { - 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, id, kind, height, minHeight, floorCount, rings, tags); - } - - private static ExternalAreaFeature parseArea(String id, Map tags, List points) { - ExternalAreaKind kind = areaKind(tags); - if (kind == null || tags.containsKey("building") || tags.containsKey("building:part")) { - return null; - } - List ring = closedRing(points); - if (ring.size() < 4) { - return null; - } - return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), List.of(ring), tags); - } - - private static ExternalAreaFeature parseAreaRelation(String id, Map tags, List> rings) { - ExternalAreaKind kind = areaKind(tags); - if (kind == null || tags.containsKey("building") || tags.containsKey("building:part") || rings.isEmpty()) { - return null; - } - return new ExternalAreaFeature(SOURCE, id, kind, areaTypeTag(kind, tags), rings, tags); - } - - private static ExternalLineFeature parseLine(String id, Map tags, List points) { - ExternalLineKind kind = lineKind(tags); - if (kind == null || points.size() < 2) { - return null; - } - return new ExternalLineFeature(SOURCE, id, kind, lineTypeTag(kind, tags), points, tags); - } - - private static ExternalPointFeature parsePointFeature(String id, Map tags, GeoPoint point) { - ExternalPointKind kind = pointKind(tags); - if (kind == null) { - return null; - } - return new ExternalPointFeature(SOURCE, id, kind, pointTypeTag(kind, tags), point, tags); - } - - private static Map tags(Entity entity) { - Map tags = new LinkedHashMap<>(); - for (Tag tag : entity.getTags()) { - if (tag.getKey() != null && !tag.getKey().isBlank() && tag.getValue() != null) { - tags.put(tag.getKey(), tag.getValue()); - } - } - return tags.isEmpty() ? Map.of() : Map.copyOf(tags); - } - - private static List pointsForWay(Way way, Map nodes) { - List points = new ArrayList<>(way.getWayNodes().size()); - GeoPoint previous = null; - for (WayNode wayNode : way.getWayNodes()) { - GeoPoint point = nodes.get(wayNode.getNodeId()); - if (point != null && !point.equals(previous)) { - points.add(point); - previous = point; - } - } - return List.copyOf(points); - } - - private static List> relationRings(Relation relation, Map> wayPointsById) { - List> outerSegments = new ArrayList<>(); - List> innerSegments = new ArrayList<>(); - for (RelationMember member : relation.getMembers()) { - if (member.getMemberType() != EntityType.Way) { - continue; - } - List segment = wayPointsById.get(member.getMemberId()); - if (segment == null || segment.size() < 2) { - continue; - } - String role = Objects.toString(member.getMemberRole(), "").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 List.of(); - } - rings.addAll(mergeSegmentsToRings(innerSegments)); - return List.copyOf(rings); - } - - 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 List closedRing(List points) { - if (points.size() < 3) { - return List.of(); - } - List ring = new ArrayList<>(points); - closeRing(ring); - return ring.size() >= 4 && isClosedRing(ring) ? List.copyOf(ring) : List.of(); - } - - 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 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 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")); - return minHeight == null || minHeight < 0.0 ? 0.0 : minHeight; - } - - 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 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 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 shortError(Throwable error) { - String message = error.getMessage(); - return message == null || message.isBlank() ? error.getClass().getSimpleName() : message; - } - - public record FileSummary(boolean enabled, String location, int fileCount, long bytes, List paths) { - public FileSummary { - location = location == null ? "" : location; - paths = paths == null ? List.of() : List.copyOf(paths); - } - } - - private static final class PbfSink implements Sink { - private final Map nodes = new HashMap<>(); - private final Map> wayPointsById = new HashMap<>(); - private final List roads = new ArrayList<>(); - private final List buildings = new ArrayList<>(); - private final List areas = new ArrayList<>(); - private final List lines = new ArrayList<>(); - private final List points = new ArrayList<>(); - private long nodeCount; - private long wayCount; - private long relationCount; - - @Override - public void initialize(Map metaData) { - } - - @Override - public void process(EntityContainer entityContainer) { - if (entityContainer instanceof NodeContainer nodeContainer) { - this.processNode(nodeContainer.getEntity()); - } else if (entityContainer instanceof WayContainer wayContainer) { - this.processWay(wayContainer.getEntity()); - } else if (entityContainer instanceof RelationContainer relationContainer) { - this.processRelation(relationContainer.getEntity()); - } - } - - @Override - public void complete() { - } - - @Override - public void close() { - } - - private void processNode(Node node) { - this.nodeCount++; - GeoPoint point = new GeoPoint(node.getLatitude(), node.getLongitude()); - this.nodes.put(node.getId(), point); - Map tags = tags(node); - if (!tags.isEmpty()) { - ExternalPointFeature pointFeature = parsePointFeature("node/" + node.getId(), tags, point); - if (pointFeature != null) { - this.points.add(pointFeature); - } - } - } - - private void processWay(Way way) { - this.wayCount++; - List points = pointsForWay(way, this.nodes); - if (points.size() >= 2) { - this.wayPointsById.put(way.getId(), points); - } - - Map tags = tags(way); - if (tags.isEmpty()) { - return; - } - - ExternalRoadFeature road = parseRoad("way/" + way.getId(), tags, points); - if (road != null) { - this.roads.add(road); - } - if (tags.containsKey("building") || tags.containsKey("building:part")) { - ExternalBuildingFeature building = parseBuilding("way/" + way.getId(), tags, points); - if (building != null) { - this.buildings.add(building); - } - } else { - ExternalAreaFeature area = parseArea("way/" + way.getId(), tags, points); - if (area != null) { - this.areas.add(area); - } - } - ExternalLineFeature line = parseLine("way/" + way.getId(), tags, points); - if (line != null) { - this.lines.add(line); - } - } - - private void processRelation(Relation relation) { - this.relationCount++; - Map tags = tags(relation); - if (tags.isEmpty()) { - return; - } - if (!"multipolygon".equals(tags.get("type")) && !tags.containsKey("building") && !tags.containsKey("building:part") && areaKind(tags) == null) { - return; - } - - List> rings = relationRings(relation, this.wayPointsById); - if (tags.containsKey("building") || tags.containsKey("building:part")) { - ExternalBuildingFeature building = parseBuildingRelation("relation/" + relation.getId(), tags, rings); - if (building != null) { - this.buildings.add(building); - } - } else { - ExternalAreaFeature area = parseAreaRelation("relation/" + relation.getId(), tags, rings); - if (area != null) { - this.areas.add(area); - } - } - } - - private ParsedFeatures toParsedFeatures() { - return new ParsedFeatures(this.roads, this.buildings, this.areas, this.lines, this.points); - } - } - - private record ParsedFeatures( - List roads, - List buildings, - List areas, - List lines, - List points - ) { - private ParsedFeatures { - 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 ParsedFeatures empty() { - return new ParsedFeatures(List.of(), List.of(), List.of(), List.of(), List.of()); - } - - private ParsedFeatures merge(ParsedFeatures other) { - List mergedRoads = new ArrayList<>(this.roads); - mergedRoads.addAll(other.roads); - List mergedBuildings = new ArrayList<>(this.buildings); - mergedBuildings.addAll(other.buildings); - List mergedAreas = new ArrayList<>(this.areas); - mergedAreas.addAll(other.areas); - List mergedLines = new ArrayList<>(this.lines); - mergedLines.addAll(other.lines); - List mergedPoints = new ArrayList<>(this.points); - mergedPoints.addAll(other.points); - return new ParsedFeatures(mergedRoads, mergedBuildings, mergedAreas, mergedLines, mergedPoints); - } - - private JsonExternalFeatureSource toSource() { - return new JsonExternalFeatureSource(this.roads, this.buildings, this.areas, this.lines, this.points); - } - } -} 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 index f17a31377..838c73143 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/TellusExternalFeatureSource.java @@ -22,7 +22,6 @@ public final class TellusExternalFeatureSource { private static final long REFRESH_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(2L); private final Path path; - private final PbfExternalFeatureSource pbfSource; private final OverpassExternalFeatureSource overpassSource; private final boolean preferExternalFeatures; private final Object refreshLock = new Object(); @@ -31,27 +30,15 @@ public final class TellusExternalFeatureSource { private volatile long nextRefreshAtNanos; public TellusExternalFeatureSource(Path path) { - this( - path, - PbfExternalFeatureSource.createDefault(), - OverpassExternalFeatureSource.createDefault(), - Boolean.parseBoolean(System.getProperty(PREFER_EXTERNAL_PROPERTY, "true")) - ); + this(path, OverpassExternalFeatureSource.createDefault(), Boolean.parseBoolean(System.getProperty(PREFER_EXTERNAL_PROPERTY, "true"))); } public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource) { - this(path, PbfExternalFeatureSource.disabled(), overpassSource, false); + this(path, overpassSource, false); } public TellusExternalFeatureSource(Path path, OverpassExternalFeatureSource overpassSource, boolean preferExternalFeatures) { - this(path, PbfExternalFeatureSource.disabled(), overpassSource, preferExternalFeatures); - } - - public TellusExternalFeatureSource( - Path path, PbfExternalFeatureSource pbfSource, OverpassExternalFeatureSource overpassSource, boolean preferExternalFeatures - ) { this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); - this.pbfSource = Objects.requireNonNull(pbfSource, "pbfSource"); this.overpassSource = Objects.requireNonNull(overpassSource, "overpassSource"); this.preferExternalFeatures = preferExternalFeatures; } @@ -75,25 +62,20 @@ public boolean available() { || !current.areas().isEmpty() || !current.lines().isEmpty() || !current.points().isEmpty() - || this.pbfSource.available() || this.overpassSource.available(); } public boolean roadsAvailable() { - return !this.currentSource().roads().isEmpty() || this.pbfSource.roadsAvailable() || this.overpassSource.available(); + return !this.currentSource().roads().isEmpty() || this.overpassSource.available(); } public boolean buildingsAvailable() { - return !this.currentSource().buildings().isEmpty() || this.pbfSource.buildingsAvailable() || this.overpassSource.available(); + 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.pbfSource.cityDetailsAvailable() - || this.overpassSource.available(); + return !current.areas().isEmpty() || !current.lines().isEmpty() || !current.points().isEmpty() || this.overpassSource.available(); } public boolean preferExternalRoads() { @@ -113,7 +95,6 @@ public List roadsForArea(int minBlockX, int minBlockZ, int maxBlock try { List externalRoads = new ArrayList<>(); externalRoads.addAll(this.currentSource().roadsForBounds(bounds)); - externalRoads.addAll(this.pbfSource.roadsForBounds(bounds)); externalRoads.addAll(this.overpassSource.roadsForBounds(bounds)); if (externalRoads.isEmpty()) { return List.of(); @@ -140,7 +121,6 @@ public List buildingsForArea( try { List externalBuildings = new ArrayList<>(); externalBuildings.addAll(this.currentSource().buildingsForBounds(bounds)); - externalBuildings.addAll(this.pbfSource.buildingsForBounds(bounds)); externalBuildings.addAll(this.overpassSource.buildingsForBounds(bounds)); if (externalBuildings.isEmpty()) { return List.of(); @@ -167,7 +147,6 @@ public List cityAreasForArea( try { List areas = new ArrayList<>(); areas.addAll(this.currentSource().areasForBounds(bounds)); - areas.addAll(this.pbfSource.areasForBounds(bounds)); areas.addAll(this.overpassSource.areasForBounds(bounds)); return areas.isEmpty() ? List.of() : List.copyOf(areas); } catch (RuntimeException error) { @@ -187,7 +166,6 @@ public List cityLinesForArea( try { List lines = new ArrayList<>(); lines.addAll(this.currentSource().linesForBounds(bounds)); - lines.addAll(this.pbfSource.linesForBounds(bounds)); lines.addAll(this.overpassSource.linesForBounds(bounds)); return lines.isEmpty() ? List.of() : List.copyOf(lines); } catch (RuntimeException error) { @@ -207,7 +185,6 @@ public List cityPointsForArea( try { List points = new ArrayList<>(); points.addAll(this.currentSource().pointsForBounds(bounds)); - points.addAll(this.pbfSource.pointsForBounds(bounds)); points.addAll(this.overpassSource.pointsForBounds(bounds)); return points.isEmpty() ? List.of() : List.copyOf(points); } catch (RuntimeException error) { From eab89f50827bf81126dd7b5d302ffa645dff9227 Mon Sep 17 00:00:00 2001 From: amithyst <2986723251@qq.com> Date: Tue, 5 May 2026 11:32:26 +0800 Subject: [PATCH 9/9] Prefer completed offline Overpass regions --- .../OverpassExternalFeatureSource.java | 134 +++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) 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 index e3c438b36..19e048585 100644 --- a/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java +++ b/src/main/java/com/yucareux/tellus/world/data/integration/OverpassExternalFeatureSource.java @@ -43,6 +43,7 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc 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"; @@ -85,6 +86,7 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc 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<>(); @@ -97,10 +99,11 @@ public final class OverpassExternalFeatureSource implements ExternalFeatureSourc private final AtomicLong networkTilesReserved = new AtomicLong(0L); private final AtomicLong skippedNetworkTiles = new AtomicLong(0L); - private OverpassExternalFeatureSource(boolean enabled, boolean networkEnabled, Path cacheRoot, URI[] endpoints) { + 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; } @@ -116,20 +119,22 @@ public static OverpassExternalFeatureSource createDefault() { 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 endpoints=%d queryZoom=%d cityDetails=%s blockingCityDetails=%s maxNetworkTiles=%d", + "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, parsedEndpoints); + return new OverpassExternalFeatureSource(true, !NETWORK_CACHE_ONLY.equals(networkMode), cacheRoot, offlineRegions, parsedEndpoints); } public static CacheEstimate estimateConfiguredCache(GeoBounds bounds) { @@ -160,7 +165,7 @@ public static PrefetchResult prefetchConfiguredBounds(GeoBounds bounds, int maxM } public static OverpassExternalFeatureSource disabled() { - return new OverpassExternalFeatureSource(false, false, Path.of("."), new URI[0]); + return new OverpassExternalFeatureSource(false, false, Path.of("."), List.of(), new URI[0]); } public boolean available() { @@ -377,6 +382,7 @@ private TileFeatures tileForKey(TileKey key, boolean requireCityDetails, boolean 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)); @@ -394,6 +400,10 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu 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); @@ -412,11 +422,20 @@ private TileFeatures loadTile(TileKey key, boolean requireCityDetails, TileFeatu } 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()); @@ -1291,10 +1310,85 @@ 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; @@ -1488,6 +1582,29 @@ 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) { @@ -1522,6 +1639,15 @@ private GeoBounds bounds() { } } + 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) { }