45 Commits

Author SHA1 Message Date
Louis Erbkamm
ffbdd22fe9 Merge pull request #756 from louis-e/multipolygon-buildings-and-indoor-highways
Support multipolygon building holes and fix indoor highway bridges
2026-02-14 22:15:19 +01:00
louis-e
1e6ab65b7a Address code review feedback 2026-02-14 22:10:21 +01:00
louis-e
58cc17a174 Support multipolygon building holes and fix indoor highway bridges 2026-02-14 21:58:21 +01:00
Louis Erbkamm
d84fa1243d Merge pull request #755 from louis-e/prepare-2.5.0-release
Prepare release v2.5.0
2026-02-14 21:21:08 +01:00
louis-e
c2f6632c93 Prepare release v2.5.0 2026-02-14 21:16:52 +01:00
Louis Erbkamm
2ef79875cc Merge pull request #754 from louis-e/remove-elev-warning-telemetry
Remove elevation warning telemetry msg
2026-02-14 21:15:53 +01:00
louis-e
21bd94c142 Remove elevation warning telemetry msg 2026-02-14 21:15:01 +01:00
Louis Erbkamm
1d170d88bc Merge pull request #753 from louis-e/scanline-water-rasterization
Replace water inverse floodfill with scanline rasterization
2026-02-14 20:32:25 +01:00
louis-e
b4af81c49a Address code review feedback 2026-02-14 20:16:15 +01:00
louis-e
3ee0f27a53 Replace water inverse floodfill with scanline rasterization 2026-02-14 19:46:24 +01:00
Louis Erbkamm
8dcc8a3cb9 Merge pull request #752 from louis-e/increase-feature-param
Increase chances for building feature generation
2026-02-14 19:45:44 +01:00
louis-e
1d5d9116ae Increase chances for building feature generation 2026-02-14 18:51:50 +01:00
Louis Erbkamm
6f5b1229e0 Merge pull request #751 from louis-e/remove-boundary-relation-support
Remove boundary relation support
2026-02-14 17:24:57 +01:00
Louis Erbkamm
f563d4dd26 Update src/osm_parser.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-14 17:24:50 +01:00
louis-e
e888163bd4 Remove boundary relation support 2026-02-14 17:01:41 +01:00
Louis Erbkamm
8b3eb516b8 Merge pull request #749 from louis-e/fix/building-multipolygon-ring-assembly
Fix building multipolygon ring assembly and roof rendering
2026-02-10 22:23:38 +01:00
louis-e
5257a045b2 Address code review 2026-02-10 22:21:45 +01:00
louis-e
9582da2081 Widen synthetic ring ID to 16-bit index and clarify dome base_height
- Use 16-bit ring index mask (0xFFFF) instead of 8-bit, supporting up to 65536 rings per relation
- Add comment explaining why base_height uses start_y_offset directly in roof-only dome path (no walls underneath, unlike from_roof_area)
2026-02-10 22:02:17 +01:00
louis-e
4787e07e05 Fix pillar coordinate mismatch and clip merged rings to bbox
- Use relative base (0) for pillar_base when terrain is disabled, matching slab_y's coordinate system; previously used args.ground_level (absolute) which made the range empty
- Clip assembled outer rings to the world bounding box after merge_way_segments, preventing oversized flood fills and out-of-bounds block placement
- Pass xzbbox to generate_building_from_relation for post-merge clipping
2026-02-10 21:51:20 +01:00
louis-e
09a6d2135c Fix floating pillars and synthetic way ID collisions
- Extend roof-only pillars from ground level (terrain-aware) to slab height instead of from start_y_offset, preventing floating roofs when raised by layer/min_height tags
- Use synthetic IDs with bit-63 flag + relation ID + ring index for merged ways, avoiding FloodFillCache and deterministic RNG collisions with real way IDs
2026-02-10 21:41:14 +01:00
louis-e
c369e9bae5 Fix building multipolygon ring assembly and roof rendering
- Merge open outer-way segments into closed rings for building multipolygon relations (same pattern as water_areas), fixing empty flood fills that produced wall-only outlines
- Preserve building multipolygon member ways unclipped in osm_parser so ring assembly can operate on complete geometry
- Rewrite generate_roof_only_structure to respect height tags (min_height, building:min_level, layer) and roof:shape, using dome renderer for curved shells
- Map circular and spherical roof:shape values to RoofType::Dome
- Route building:part=roof elements to the roof-only generator before the building tag match
2026-02-10 21:29:52 +01:00
Louis Erbkamm
38adb1f589 Merge pull request #748 from louis-e/building-parts
Building parts
2026-02-10 20:26:33 +01:00
louis-e
1e87ff53ca Address code review 2026-02-10 20:26:13 +01:00
louis-e
6c67610bd0 Address code review 2026-02-10 20:02:35 +01:00
louis-e
cd7e3363e7 Add fence around cemetery 2026-02-10 19:32:48 +01:00
louis-e
614da8da7c Address code review feedback 2026-02-10 19:20:52 +01:00
louis-e
7d907208d1 Remove artifact 2026-02-09 23:17:29 +01:00
louis-e
f9c009c173 Add pyramids 2026-02-09 23:13:53 +01:00
louis-e
dfe799bac0 Fix cargo fmt 2026-02-09 22:56:33 +01:00
louis-e
57a44500f4 Fix building part handling for type=building vs type=multipolygon relations
- Restrict Part role parsing to type=building relations only; multipolygon
  relations now always render their Outer members normally (osm_parser.rs)
- Suppress outline ways only for type=building relations with actual Part
  members, not for multipolygon building relations (data_processing.rs)
- Match the same type=building guard in generate_building_from_relation
  so multipolygon outlines are no longer incorrectly skipped (buildings.rs)
- Normalize polygon winding order in flood fill to fix undefined
  geo::Contains results that caused empty building interiors (floodfill.rs)
2026-02-09 22:54:45 +01:00
louis-e
8514a07fca Properly parse building parts 2026-02-09 21:25:46 +01:00
Louis Erbkamm
ea9f11d427 Merge pull request #747 from louis-e/memory-optimization-blockstorage-floodfill
perf: reduce peak RAM via BlockStorage enum, flood fill bitmap, and ground generation optimization
2026-02-09 20:11:01 +01:00
louis-e
882b18410e fix: clear stale properties in SectionToModify::set_block
set_block now removes any leftover per-index properties, matching
the behaviour of set_block_with_properties and preventing stale
NBT data from leaking into serialization or blocking compaction.
2026-02-09 19:50:05 +01:00
louis-e
0c083b3b82 fix: remove dead fill_blocks_absolute and unused compacted counter
- Delete fill_blocks_absolute (zero call sites, was lint-suppressed)
- Simplify compact_sections by removing the dead counter
2026-02-09 19:43:32 +01:00
louis-e
674591945f fix: gate Uniform fast path on empty properties, remove eprintln, reword doc comment
- to_section() Uniform fast path now gated on self.properties.is_empty()
  so per-index properties are never silently dropped
- Remove compact_sections() eprintln! that polluted stderr in normal operation
- Reword set_block_if_absent_absolute doc to clarify it checks the
  in-memory overlay, not the on-disk world
2026-02-09 19:34:21 +01:00
louis-e
c5e5239062 fix: correct doc comment and clear stale properties in fast paths
- Change 'three cases' to 'two cases' in BlockStorage doc comment
  (only Uniform and Full variants exist)
- Clear stale properties via properties.remove(&idx) in
  set_block_if_absent() and fill_column() after writing a block,
  so leftover NBT metadata from a previous block does not persist
2026-02-09 19:23:34 +01:00
louis-e
e22a1b4f73 refactor: use MIN_Y/MAX_Y constants instead of hardcoded -64/319 in set_block_if_absent and fill_column 2026-02-09 19:10:41 +01:00
louis-e
67a14a7f4b perf: reduce peak RAM via BlockStorage enum, flood fill bitmap, and ground generation optimization
- Replace inline [Block; 4096] section arrays with BlockStorage enum:
  Uniform(Block) for homogeneous sections (1 byte vs 4 KiB),
  Full(Vec<Block>) for mixed sections (heap-allocated, small inline footprint).
  Saves ~200-500 MB for typical worlds.

- Call compact_sections() before save to collapse Full sections that
  became uniform (e.g. all-STONE from --fillground) back to Uniform.

- Replace HashSet<(i32,i32)> in flood fill with FloodBitmap (1 bit per coord).
  ~400x memory reduction: e.g. 48 MB -> 122 KB for 1000x1000 polygon.

- Add set_block_if_absent() to avoid double HashMap traversal in
  ground generation (get_block + set_block -> single pass).

- Add fill_column() for efficient underground fill: resolves region/chunk
  once per column instead of per Y-level (~6x fewer HashMap lookups).

- Wire optimized methods into ground generation loop in data_processing.rs.
2026-02-09 18:58:35 +01:00
Louis Erbkamm
230d233737 Merge pull request #689 from MysaaJava/main
Fixed nix flake
2026-02-09 16:30:33 +01:00
Louis Erbkamm
b975ea19d7 Merge pull request #746 from louis-e/security-updates-dependabot
chore: merge all dependency upgrades and fix critical security vulnerability
2026-02-09 15:25:52 +01:00
louis-e
e4939dc4bb fix: resolve rand 0.9 deprecation warnings
- Replace deprecated gen::<T>() calls with random::<T>() in deterministic_rng tests
- Suppress dead_code warnings for bedrock-only fields with conditional compilation
- Note: bedrockrs dependencies currently pull jsonwebtoken 9.3.1 transitively
  - This will be resolved when bedrockrs updates their jsonwebtoken dependency
  - Direct dependency on jsonwebtoken 10.3.0 is in place for non-bedrock code paths
2026-02-09 15:06:52 +01:00
louis-e
11d624e734 fix: resolve rand 0.9 deprecation warnings and fix jsonwebtoken CVE
- Replace deprecated gen::<T>() calls with random::<T>() in deterministic_rng tests
- Disable bedrock feature by default to prevent transitive jsonwebtoken 9.3.1 dependency
- This ensures jsonwebtoken CVE-2026-25537 fix is complete (no version downgrade via bedrock deps)
- Users can still enable bedrock feature with: cargo build --release --features bedrock
2026-02-09 14:43:49 +01:00
louis-e
d1d3bf22c5 chore: upgrade dependencies and fix CVE-2026-25537
- Upgrade jsonwebtoken from 9.3.1 to 10.3.0 (fixes CVE-2026-25537 Type Confusion vulnerability allowing authorization bypass)
- Upgrade rand from 0.8.5 to 0.9.1 with updated API calls
- Upgrade rand_chacha to 0.9 for compatibility with rand 0.9
- Upgrade clap to 4.5.53
- Upgrade windows to 0.62.0
- Update all rand API usages: gen() -> random(), gen_range() -> random_range(), gen_bool() -> random_bool()
- Update trait imports to use IndexedRandom and SliceRandom as needed
- Enable std and std_rng features for rand
2026-02-09 14:31:39 +01:00
Mysaa
32695555aa Fix missing backquote
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 15:19:41 +01:00
Mysaa Java
ee2356d734 Fixed nix flake 2026-01-04 22:15:27 +01:00
29 changed files with 1590 additions and 550 deletions

109
Cargo.lock generated
View File

@@ -177,7 +177,7 @@ dependencies = [
[[package]]
name = "arnis"
version = "2.4.1"
version = "2.5.0"
dependencies = [
"base64 0.22.1",
"bedrockrs_level",
@@ -195,11 +195,12 @@ dependencies = [
"image 0.25.9",
"indicatif",
"itertools 0.14.0",
"jsonwebtoken 10.3.0",
"log",
"nbtx",
"once_cell",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rayon",
"reqwest",
"rfd",
@@ -214,7 +215,7 @@ dependencies = [
"tempfile",
"tokio",
"vek",
"windows",
"windows 0.62.2",
"zip",
]
@@ -563,7 +564,7 @@ dependencies = [
"base64 0.22.1",
"bedrockrs_macros",
"byteorder",
"jsonwebtoken",
"jsonwebtoken 9.3.1",
"nbtx",
"paste",
"seq-macro",
@@ -2924,6 +2925,22 @@ dependencies = [
"simple_asn1",
]
[[package]]
name = "jsonwebtoken"
version = "10.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature",
"simple_asn1",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -5208,6 +5225,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -5511,7 +5537,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -5598,7 +5624,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -5746,7 +5772,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -5772,7 +5798,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -6698,7 +6724,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -6722,7 +6748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -6784,11 +6810,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -6800,6 +6838,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -6834,7 +6881,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -6881,6 +6939,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -7030,6 +7098,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@@ -7256,7 +7333,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",

View File

@@ -1,6 +1,6 @@
[package]
name = "arnis"
version = "2.4.1"
version = "2.5.0"
edition = "2021"
description = "Arnis - Generate real life cities in Minecraft"
homepage = "https://github.com/louis-e/arnis"
@@ -23,7 +23,7 @@ tauri-build = {version = "2", optional = true}
[dependencies]
base64 = "0.22.1"
byteorder = { version = "1.5", optional = true }
clap = { version = "4.5", features = ["derive", "env"] }
clap = { version = "4.5.53", features = ["derive", "env"] }
colored = "3.0.0"
dirs = "6.0.0"
fastanvil = "0.32.0"
@@ -35,10 +35,11 @@ geo = "0.31.0"
image = "0.25"
indicatif = "0.17.11"
itertools = "0.14.0"
jsonwebtoken = "10.3.0"
log = "0.4.27"
once_cell = "1.21.3"
rand = "0.8.5"
rand_chacha = "0.3"
rand = { version = "0.9.1", features = ["std", "std_rng"] }
rand_chacha = "0.9"
rayon = "1.10.0"
reqwest = { version = "0.13.1", features = ["blocking", "json", "query"] }
rfd = { version = "0.16.0", optional = true }
@@ -57,7 +58,7 @@ zip = { version = "0.6", default-features = false, features = ["deflate"], optio
rusty-leveldb = { version = "3", optional = true }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.1", features = ["Win32_System_Console"] }
windows = { version = "0.62.0", features = ["Win32_System_Console"] }
[dev-dependencies]
tempfile = "3.23.0"

View File

@@ -39,6 +39,8 @@ GUI Build: ```cargo run```<br>
After your pull request was merged, I will take care of regularly creating update releases which will include your changes.
If you are using Nix, you can run the program directly with `nix run github:louis-e/arnis -- --terrain --path=YOUR_PATH/.minecraft/saves/worldname --bbox="min_lat,min_lng,max_lat,max_lng"`
## :star: Star History
<a href="https://star-history.com/#louis-e/arnis&Date">

34
flake.lock generated
View File

@@ -1,23 +1,5 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1755615617,
@@ -35,24 +17,8 @@
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@@ -1,36 +1,64 @@
{
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "nixpkgs/nixos-unstable";
};
outputs =
{
flake-utils,
nixpkgs,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
outputs = { self, nixpkgs }: {
stdenv = if pkgs.stdenv.isLinux then pkgs.stdenvAdapters.useMoldLinker pkgs.stdenv else pkgs.stdenv;
packages = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
toml = lib.importTOML ./Cargo.toml;
in
{
devShell = pkgs.mkShell.override { inherit stdenv; } {
default = self.packages.${system}.arnis;
arnis = pkgs.rustPlatform.buildRustPackage {
pname = "arnis";
version = toml.package.version;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"bedrockrs_core-0.1.0" = "sha256-0HP6p2x6sulZ2u8FzEfAiNAeyaUjQQWgGyK/kPo0PuQ=";
"nbtx-0.1.0" = "sha256-JoNSL1vrUbxX6hKWB4i/DX02+hsQemANJhQaEELlT2o=";
};
};
# Checks use internet connection, so we disable them in nix sandboxed environment
doCheck = false;
buildInputs = with pkgs; [
openssl.dev
pkg-config
wayland
glib
gdk-pixbuf
pango
gtk3
libsoup_3.dev
webkitgtk_4_1.dev
];
nativeBuildInputs = with pkgs; [
gtk3
pango
gdk-pixbuf
glib
wayland
pkg-config
];
meta = {
description = "Generate any location from the real world in Minecraft Java Edition with a high level of detail.";
homepage = toml.package.homepage;
license = lib.licenses.asl20;
maintainers = [ ];
mainProgram = "arnis";
};
};
}
);
});
apps = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (system: {
default = self.apps.${system}.arnis;
arnis = {
type = "app";
program = "${self.packages.${system}.arnis}/bin/arnis";
};
});
};
}

View File

@@ -56,7 +56,7 @@ pub struct Args {
#[arg(long, default_value_t = false)]
pub fillground: bool,
/// Enable city boundary ground generation (optional)
/// Enable city ground generation (optional)
/// When enabled, detects building clusters and places stone ground in urban areas.
/// Isolated buildings in rural areas will keep grass around them.
#[arg(long, default_value_t = true)]

View File

@@ -1039,7 +1039,7 @@ pub static INDUSTRIAL_WINDOW_OPTIONS: [Block; 4] = [
// Window types for different building styles (non-deterministic, for backwards compatibility)
pub fn get_window_block_for_building_type(building_type: &str) -> Block {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
get_window_block_for_building_type_with_rng(building_type, &mut rng)
}
@@ -1050,18 +1050,18 @@ pub fn get_window_block_for_building_type_with_rng(
) -> Block {
match building_type {
"residential" | "house" | "apartment" | "apartments" => {
RESIDENTIAL_WINDOW_OPTIONS[rng.gen_range(0..RESIDENTIAL_WINDOW_OPTIONS.len())]
RESIDENTIAL_WINDOW_OPTIONS[rng.random_range(0..RESIDENTIAL_WINDOW_OPTIONS.len())]
}
"hospital" | "school" | "university" => {
INSTITUTIONAL_WINDOW_OPTIONS[rng.gen_range(0..INSTITUTIONAL_WINDOW_OPTIONS.len())]
INSTITUTIONAL_WINDOW_OPTIONS[rng.random_range(0..INSTITUTIONAL_WINDOW_OPTIONS.len())]
}
"hotel" | "restaurant" => {
HOSPITALITY_WINDOW_OPTIONS[rng.gen_range(0..HOSPITALITY_WINDOW_OPTIONS.len())]
HOSPITALITY_WINDOW_OPTIONS[rng.random_range(0..HOSPITALITY_WINDOW_OPTIONS.len())]
}
"industrial" | "warehouse" => {
INDUSTRIAL_WINDOW_OPTIONS[rng.gen_range(0..INDUSTRIAL_WINDOW_OPTIONS.len())]
INDUSTRIAL_WINDOW_OPTIONS[rng.random_range(0..INDUSTRIAL_WINDOW_OPTIONS.len())]
}
_ => WINDOW_VARIATIONS[rng.gen_range(0..WINDOW_VARIATIONS.len())],
_ => WINDOW_VARIATIONS[rng.random_range(0..WINDOW_VARIATIONS.len())],
}
}
@@ -1080,13 +1080,13 @@ pub static FLOOR_BLOCK_OPTIONS: [Block; 8] = [
// Random floor block selection (non-deterministic, for backwards compatibility)
pub fn get_random_floor_block() -> Block {
use rand::Rng;
let mut rng = rand::thread_rng();
FLOOR_BLOCK_OPTIONS[rng.gen_range(0..FLOOR_BLOCK_OPTIONS.len())]
let mut rng = rand::rng();
FLOOR_BLOCK_OPTIONS[rng.random_range(0..FLOOR_BLOCK_OPTIONS.len())]
}
/// Deterministic floor block selection using provided RNG
pub fn get_floor_block_with_rng(rng: &mut impl rand::Rng) -> Block {
FLOOR_BLOCK_OPTIONS[rng.gen_range(0..FLOOR_BLOCK_OPTIONS.len())]
FLOOR_BLOCK_OPTIONS[rng.random_range(0..FLOOR_BLOCK_OPTIONS.len())]
}
// Define all predefined colors with their blocks
@@ -1221,7 +1221,7 @@ static DEFINED_COLORS: &[ColorBlockMapping] = &[
// Function to randomly select building wall block with alternatives
pub fn get_building_wall_block_for_color(color: RGBTuple) -> Block {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
// Find the closest color match
let closest_color = DEFINED_COLORS
@@ -1229,7 +1229,7 @@ pub fn get_building_wall_block_for_color(color: RGBTuple) -> Block {
.min_by_key(|(defined_color, _)| crate::colors::rgb_distance(&color, defined_color));
if let Some((_, options)) = closest_color {
options[rng.gen_range(0..options.len())]
options[rng.random_range(0..options.len())]
} else {
// This should never happen, but fallback just in case
get_fallback_building_block()
@@ -1239,7 +1239,7 @@ pub fn get_building_wall_block_for_color(color: RGBTuple) -> Block {
// Function to get a random fallback building block when no color attribute is specified
pub fn get_fallback_building_block() -> Block {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
let fallback_options = [
BLACKSTONE,
@@ -1269,13 +1269,13 @@ pub fn get_fallback_building_block() -> Block {
WHITE_CONCRETE,
WHITE_TERRACOTTA,
];
fallback_options[rng.gen_range(0..fallback_options.len())]
fallback_options[rng.random_range(0..fallback_options.len())]
}
// Function to get a random castle wall block
pub fn get_castle_wall_block() -> Block {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
let castle_wall_options = [
STONE_BRICKS,
@@ -1289,5 +1289,5 @@ pub fn get_castle_wall_block() -> Block {
SMOOTH_STONE,
BRICK,
];
castle_wall_options[rng.gen_range(0..castle_wall_options.len())]
castle_wall_options[rng.random_range(0..castle_wall_options.len())]
}

View File

@@ -6,7 +6,7 @@ use crate::element_processing::*;
use crate::floodfill_cache::FloodFillCache;
use crate::ground::Ground;
use crate::map_renderer;
use crate::osm_parser::ProcessedElement;
use crate::osm_parser::{ProcessedElement, ProcessedMemberRole};
use crate::progress::{emit_gui_progress_update, emit_map_preview_ready, emit_open_mcworld_file};
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
@@ -14,6 +14,7 @@ use crate::urban_ground;
use crate::world_editor::{WorldEditor, WorldFormat};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
@@ -89,6 +90,33 @@ pub fn generate_world_with_options(
let mut current_progress_prcs: f64 = 25.0;
let mut last_emitted_progress: f64 = current_progress_prcs;
// Pre-scan: detect building relation outlines that should be suppressed.
// Only applies to type=building relations (NOT type=multipolygon).
// When a type=building relation has "part" members, the outline way should not
// render as a standalone building, the individual parts render instead.
let suppressed_building_outlines: HashSet<u64> = {
let mut outlines = HashSet::new();
for element in &elements {
if let ProcessedElement::Relation(rel) = element {
let is_building_type = rel.tags.get("type").map(|t| t.as_str()) == Some("building");
if is_building_type {
let has_parts = rel
.members
.iter()
.any(|m| m.role == ProcessedMemberRole::Part);
if has_parts {
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
outlines.insert(member.way.id);
}
}
}
}
}
}
outlines
};
// Process all elements
for element in elements.into_iter() {
process_pb.inc(1);
@@ -111,7 +139,18 @@ pub fn generate_world_with_options(
match &element {
ProcessedElement::Way(way) => {
if way.tags.contains_key("building") || way.tags.contains_key("building:part") {
buildings::generate_buildings(&mut editor, way, args, None, &flood_fill_cache);
// Skip building outlines that are suppressed by building relations with parts.
// The individual building:part ways will render instead.
if !suppressed_building_outlines.contains(&way.id) {
buildings::generate_buildings(
&mut editor,
way,
args,
None,
None,
&flood_fill_cache,
);
}
} else if way.tags.contains_key("highway") {
highways::generate_highways(
&mut editor,
@@ -166,6 +205,8 @@ pub fn generate_world_with_options(
highways::generate_aeroway(&mut editor, way, args);
} else if way.tags.get("service") == Some(&"siding".to_string()) {
highways::generate_siding(&mut editor, way);
} else if way.tags.get("tomb") == Some(&"pyramid".to_string()) {
historic::generate_pyramid(&mut editor, way, args, &flood_fill_cache);
} else if way.tags.contains_key("man_made") {
man_made::generate_man_made(&mut editor, &element, args);
} else if way.tags.contains_key("power") {
@@ -216,12 +257,16 @@ pub fn generate_world_with_options(
}
}
ProcessedElement::Relation(rel) => {
if rel.tags.contains_key("building") || rel.tags.contains_key("building:part") {
let is_building_relation = rel.tags.contains_key("building")
|| rel.tags.contains_key("building:part")
|| rel.tags.get("type").map(|t| t.as_str()) == Some("building");
if is_building_relation {
buildings::generate_building_from_relation(
&mut editor,
rel,
args,
&flood_fill_cache,
&xzbbox,
);
} else if rel.tags.contains_key("water")
|| rel
@@ -341,28 +386,24 @@ pub fn generate_world_with_options(
if !editor.check_for_block_absolute(x, ground_y, z, Some(&[STONE]), None) {
if is_urban {
// Urban area: smooth stone ground
editor.set_block_absolute(SMOOTH_STONE, x, ground_y, z, None, None);
editor.set_block_if_absent_absolute(SMOOTH_STONE, x, ground_y, z);
} else {
// Rural/natural area: grass and dirt
editor.set_block_absolute(GRASS_BLOCK, x, ground_y, z, None, None);
editor.set_block_if_absent_absolute(GRASS_BLOCK, x, ground_y, z);
}
editor.set_block_absolute(DIRT, x, ground_y - 1, z, None, None);
editor.set_block_absolute(DIRT, x, ground_y - 2, z, None, None);
editor.set_block_if_absent_absolute(DIRT, x, ground_y - 1, z);
editor.set_block_if_absent_absolute(DIRT, x, ground_y - 2, z);
}
// Fill underground with stone
if args.fillground {
// Fill from bedrock+1 to 3 blocks below ground with stone
editor.fill_blocks_absolute(
editor.fill_column_absolute(
STONE,
x,
z,
MIN_Y + 1,
z,
x,
ground_y - 3,
z,
None,
None,
true, // skip_existing: don't overwrite blocks placed by element processing
);
}
// Generate a bedrock level at MIN_Y

View File

@@ -8,7 +8,7 @@
//! # Example
//! ```ignore
//! let mut rng = element_rng(element_id);
//! let color = rng.gen_bool(0.5); // Always same result for same element_id
//! let color = rng.random_bool(0.5); // Always same result for same element_id
//! ```
use rand::SeedableRng;
@@ -77,7 +77,7 @@ mod tests {
// Same seed should produce same sequence
for _ in 0..100 {
assert_eq!(rng1.gen::<u64>(), rng2.gen::<u64>());
assert_eq!(rng1.random::<u64>(), rng2.random::<u64>());
}
}
@@ -87,8 +87,8 @@ mod tests {
let mut rng2 = element_rng(12346);
// Different seeds should (almost certainly) produce different values
let v1: u64 = rng1.gen();
let v2: u64 = rng2.gen();
let v1: u64 = rng1.random();
let v2: u64 = rng2.random();
assert_ne!(v1, v2);
}
@@ -97,8 +97,8 @@ mod tests {
let mut rng1 = element_rng(12345);
let mut rng2 = element_rng_salted(12345, 1);
let v1: u64 = rng1.gen();
let v2: u64 = rng2.gen();
let v1: u64 = rng1.random();
let v2: u64 = rng2.random();
assert_ne!(v1, v2);
}
@@ -107,7 +107,7 @@ mod tests {
let mut rng1 = coord_rng(100, 200, 12345);
let mut rng2 = coord_rng(100, 200, 12345);
assert_eq!(rng1.gen::<u64>(), rng2.gen::<u64>());
assert_eq!(rng1.random::<u64>(), rng2.random::<u64>());
}
#[test]
@@ -116,12 +116,12 @@ mod tests {
let mut rng1 = coord_rng(-100, -200, 12345);
let mut rng2 = coord_rng(-100, -200, 12345);
assert_eq!(rng1.gen::<u64>(), rng2.gen::<u64>());
assert_eq!(rng1.random::<u64>(), rng2.random::<u64>());
// Ensure different negative coords produce different seeds
let mut rng3 = coord_rng(-100, -200, 12345);
let mut rng4 = coord_rng(-101, -200, 12345);
assert_ne!(rng3.gen::<u64>(), rng4.gen::<u64>());
assert_ne!(rng3.random::<u64>(), rng4.random::<u64>());
}
}

View File

@@ -84,7 +84,7 @@ fn generate_advertising_flag(editor: &mut WorldEditor, node: &ProcessedNode) {
ORANGE_WOOL,
WHITE_WOOL,
];
let flag_block = flag_colors[rng.gen_range(0..flag_colors.len())];
let flag_block = flag_colors[rng.random_range(0..flag_colors.len())];
// Flag extends to one side (2-3 blocks)
let flag_length = 3;

View File

@@ -8,7 +8,10 @@ use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::ProcessedElement;
use crate::world_editor::WorldEditor;
use fastnbt::Value;
use rand::{seq::SliceRandom, Rng};
use rand::{
prelude::{IndexedRandom, SliceRandom},
Rng,
};
use std::collections::{HashMap, HashSet};
pub fn generate_amenities(
@@ -47,7 +50,7 @@ pub fn generate_amenities(
}
if let Some(pt) = first_node {
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
let loot_pool = build_recycling_loot_pool(element.tags());
let items = build_recycling_items(&loot_pool, &mut rng);
@@ -132,7 +135,7 @@ pub fn generate_amenities(
// Use deterministic RNG for consistent bench orientation across region boundaries
let mut rng = element_rng(element.id());
// 50% chance to 90 degrees rotate the bench
if rng.gen_bool(0.5) {
if rng.random_bool(0.5) {
editor.set_block(SMOOTH_STONE, pt.x, 1, pt.z, None, None);
editor.set_block(OAK_LOG, pt.x + 1, 1, pt.z, None, None);
editor.set_block(OAK_LOG, pt.x - 1, 1, pt.z, None, None);
@@ -389,8 +392,8 @@ fn build_recycling_items(
let mut items = Vec::new();
for slot in 0..27 {
if rng.gen_bool(0.2) {
let kind = loot_pool[rng.gen_range(0..loot_pool.len())];
if rng.random_bool(0.2) {
let kind = loot_pool[rng.random_range(0..loot_pool.len())];
if let Some(item) = build_item_for_kind(kind, slot as i8, rng) {
items.push(item);
}
@@ -429,7 +432,10 @@ fn build_display_item_for_category(
) -> Option<HashMap<String, Value>> {
match category {
LootCategory::GlassBottle => Some(make_display_item("minecraft:glass_bottle", 1)),
LootCategory::Paper => Some(make_display_item("minecraft:paper", rng.gen_range(1..=4))),
LootCategory::Paper => Some(make_display_item(
"minecraft:paper",
rng.random_range(1..=4),
)),
LootCategory::Glass => Some(make_display_item("minecraft:glass", 1)),
LootCategory::Leather => Some(build_leather_display_item(rng)),
LootCategory::EmptyBucket => Some(make_display_item("minecraft:bucket", 1)),
@@ -440,7 +446,7 @@ fn build_display_item_for_category(
"minecraft:gold_ingot",
];
let metal = metals.choose(rng)?;
Some(make_display_item(metal, rng.gen_range(1..=2)))
Some(make_display_item(metal, rng.random_range(1..=2)))
}
LootCategory::GreenWaste => {
let options = [
@@ -451,7 +457,7 @@ fn build_display_item_for_category(
"minecraft:wheat_seeds",
];
let choice = options.choose(rng)?;
Some(make_display_item(choice, rng.gen_range(1..=3)))
Some(make_display_item(choice, rng.random_range(1..=3)))
}
}
}
@@ -463,7 +469,7 @@ fn place_item_frame_on_random_side(
z: i32,
item: HashMap<String, Value>,
) {
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
let mut directions = [
((0, 0, -1), 2), // North
((0, 0, 1), 3), // South
@@ -556,12 +562,12 @@ fn build_item_for_kind(
RecyclingLootKind::GlassBottle => Some(make_basic_item(
"minecraft:glass_bottle",
slot,
rng.gen_range(1..=4),
rng.random_range(1..=4),
)),
RecyclingLootKind::Paper => Some(make_basic_item(
"minecraft:paper",
slot,
rng.gen_range(1..=10),
rng.random_range(1..=10),
)),
RecyclingLootKind::GlassBlock => Some(build_glass_item(false, slot, rng)),
RecyclingLootKind::GlassPane => Some(build_glass_item(true, slot, rng)),
@@ -578,26 +584,26 @@ fn build_item_for_kind(
fn build_scrap_metal_item(slot: i8, rng: &mut impl Rng) -> HashMap<String, Value> {
let metals = ["copper_ingot", "iron_ingot", "gold_ingot"];
let metal = metals.choose(rng).expect("scrap metal list is non-empty");
let count = rng.gen_range(1..=3);
let count = rng.random_range(1..=3);
make_basic_item(&format!("minecraft:{metal}"), slot, count)
}
fn build_green_waste_item(slot: i8, rng: &mut impl Rng) -> HashMap<String, Value> {
#[allow(clippy::match_same_arms)]
let (id, count) = match rng.gen_range(0..8) {
0 => ("minecraft:tall_grass", rng.gen_range(1..=4)),
1 => ("minecraft:sweet_berries", rng.gen_range(2..=6)),
2 => ("minecraft:oak_sapling", rng.gen_range(1..=2)),
3 => ("minecraft:birch_sapling", rng.gen_range(1..=2)),
4 => ("minecraft:spruce_sapling", rng.gen_range(1..=2)),
5 => ("minecraft:jungle_sapling", rng.gen_range(1..=2)),
6 => ("minecraft:acacia_sapling", rng.gen_range(1..=2)),
_ => ("minecraft:dark_oak_sapling", rng.gen_range(1..=2)),
let (id, count) = match rng.random_range(0..8) {
0 => ("minecraft:tall_grass", rng.random_range(1..=4)),
1 => ("minecraft:sweet_berries", rng.random_range(2..=6)),
2 => ("minecraft:oak_sapling", rng.random_range(1..=2)),
3 => ("minecraft:birch_sapling", rng.random_range(1..=2)),
4 => ("minecraft:spruce_sapling", rng.random_range(1..=2)),
5 => ("minecraft:jungle_sapling", rng.random_range(1..=2)),
6 => ("minecraft:acacia_sapling", rng.random_range(1..=2)),
_ => ("minecraft:dark_oak_sapling", rng.random_range(1..=2)),
};
// 25% chance to replace with seeds instead
let id = if rng.gen_bool(0.25) {
match rng.gen_range(0..4) {
let id = if rng.random_bool(0.25) {
match rng.random_range(0..4) {
0 => "minecraft:wheat_seeds",
1 => "minecraft:pumpkin_seeds",
2 => "minecraft:melon_seeds",
@@ -630,7 +636,7 @@ fn build_glass_item(is_pane: bool, slot: i8, rng: &mut impl Rng) -> HashMap<Stri
"black",
];
let use_colorless = rng.gen_bool(0.7);
let use_colorless = rng.random_bool(0.7);
let id = if use_colorless {
if is_pane {
@@ -650,9 +656,9 @@ fn build_glass_item(is_pane: bool, slot: i8, rng: &mut impl Rng) -> HashMap<Stri
};
let count = if is_pane {
rng.gen_range(4..=16)
rng.random_range(4..=16)
} else {
rng.gen_range(1..=6)
rng.random_range(1..=6)
};
make_basic_item(&id, slot, count)
@@ -692,21 +698,21 @@ fn biased_damage(max_damage: i32, rng: &mut impl Rng) -> i32 {
let upper = safe_max.saturating_sub(1);
let lower = (safe_max / 2).min(upper);
let heavy_wear = rng.gen_range(lower..=upper);
let random_wear = rng.gen_range(0..=upper);
let heavy_wear = rng.random_range(lower..=upper);
let random_wear = rng.random_range(0..=upper);
heavy_wear.max(random_wear)
}
fn maybe_leather_color(rng: &mut impl Rng) -> Option<i32> {
if rng.gen_bool(0.3) {
Some(rng.gen_range(0..=0x00FF_FFFF))
if rng.random_bool(0.3) {
Some(rng.random_range(0..=0x00FF_FFFF))
} else {
None
}
}
fn random_leather_piece(rng: &mut impl Rng) -> LeatherPiece {
match rng.gen_range(0..4) {
match rng.random_range(0..4) {
0 => LeatherPiece::Helmet,
1 => LeatherPiece::Chestplate,
2 => LeatherPiece::Leggings,

View File

@@ -1,12 +1,14 @@
use crate::args::Args;
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::clipping::clip_way_to_bbox;
use crate::colors::color_text_to_rgb_tuple;
use crate::coordinate_system::cartesian::XZPoint;
use crate::deterministic_rng::{coord_rng, element_rng};
use crate::element_processing::historic;
use crate::element_processing::subprocessor::buildings_interior::generate_building_interior;
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedMemberRole, ProcessedRelation, ProcessedWay};
use crate::osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
use fastnbt::Value;
use rand::Rng;
@@ -24,6 +26,12 @@ pub(crate) enum RoofType {
Flat, // Default flat roof
}
#[derive(Clone)]
pub(crate) struct HolePolygon {
way: ProcessedWay,
add_walls: bool,
}
// ============================================================================
// Building Style System
// ============================================================================
@@ -703,7 +711,7 @@ impl BuildingStyle {
) {
const SKYSCRAPER_ROOF_CAP_OPTIONS: [Block; 3] =
[POLISHED_ANDESITE, BLACKSTONE, NETHER_BRICK];
SKYSCRAPER_ROOF_CAP_OPTIONS[rng.gen_range(0..SKYSCRAPER_ROOF_CAP_OPTIONS.len())]
SKYSCRAPER_ROOF_CAP_OPTIONS[rng.random_range(0..SKYSCRAPER_ROOF_CAP_OPTIONS.len())]
} else {
get_floor_block_with_rng(rng)
}
@@ -720,7 +728,7 @@ impl BuildingStyle {
let accent_block = preset.accent_block.unwrap_or_else(|| {
if category == BuildingCategory::GlassySkyscraper {
const GLASSY_ACCENT_OPTIONS: [Block; 2] = [WHITE_STAINED_GLASS, BLACKSTONE];
GLASSY_ACCENT_OPTIONS[rng.gen_range(0..GLASSY_ACCENT_OPTIONS.len())]
GLASSY_ACCENT_OPTIONS[rng.random_range(0..GLASSY_ACCENT_OPTIONS.len())]
} else if category == BuildingCategory::ModernSkyscraper {
const MODERN_ACCENT_OPTIONS: [Block; 5] = [
POLISHED_ANDESITE,
@@ -729,9 +737,9 @@ impl BuildingStyle {
NETHER_BRICK,
STONE_BRICKS,
];
MODERN_ACCENT_OPTIONS[rng.gen_range(0..MODERN_ACCENT_OPTIONS.len())]
MODERN_ACCENT_OPTIONS[rng.random_range(0..MODERN_ACCENT_OPTIONS.len())]
} else {
ACCENT_BLOCK_OPTIONS[rng.gen_range(0..ACCENT_BLOCK_OPTIONS.len())]
ACCENT_BLOCK_OPTIONS[rng.random_range(0..ACCENT_BLOCK_OPTIONS.len())]
}
});
@@ -739,7 +747,7 @@ impl BuildingStyle {
let use_vertical_windows = preset
.use_vertical_windows
.unwrap_or_else(|| rng.gen_bool(0.7));
.unwrap_or_else(|| rng.random_bool(0.7));
// Horizontal windows: full-width bands, used by modern skyscrapers
let use_horizontal_windows = preset
@@ -750,7 +758,7 @@ impl BuildingStyle {
let use_accent_roof_line = preset
.use_accent_roof_line
.unwrap_or_else(|| rng.gen_bool(0.25));
.unwrap_or_else(|| rng.random_bool(0.25));
// Accent lines only for multi-floor buildings
// Glassy skyscrapers get 60% chance, Modern skyscrapers always have them
@@ -758,16 +766,16 @@ impl BuildingStyle {
if category == BuildingCategory::ModernSkyscraper {
true // Stone bands always present on modern skyscrapers
} else if category == BuildingCategory::GlassySkyscraper {
rng.gen_bool(0.6)
rng.random_bool(0.6)
} else {
has_multiple_floors && rng.gen_bool(0.2)
has_multiple_floors && rng.random_bool(0.2)
}
});
// Vertical accent: only if no accent lines and multi-floor
let use_vertical_accent = preset
.use_vertical_accent
.unwrap_or_else(|| has_multiple_floors && !use_accent_lines && rng.gen_bool(0.1));
.unwrap_or_else(|| has_multiple_floors && !use_accent_lines && rng.random_bool(0.1));
// === Roof ===
@@ -784,7 +792,7 @@ impl BuildingStyle {
} else if qualifies_for_auto_gabled_roof(building_type) {
// Auto-generate gabled roof for residential buildings
const MAX_FOOTPRINT_FOR_GABLED: usize = 800;
if footprint_size <= MAX_FOOTPRINT_FOR_GABLED && rng.gen_bool(0.9) {
if footprint_size <= MAX_FOOTPRINT_FOR_GABLED && rng.random_bool(0.9) {
(RoofType::Gabled, true)
} else {
(RoofType::Flat, false)
@@ -811,7 +819,7 @@ impl BuildingStyle {
let suitable_roof = matches!(roof_type, RoofType::Gabled | RoofType::Hipped);
let suitable_size = (30..=400).contains(&footprint_size);
is_residential && suitable_roof && suitable_size && rng.gen_bool(0.55)
is_residential && suitable_roof && suitable_size && rng.random_bool(0.55)
});
// Roof block: specific material for roofs
@@ -995,28 +1003,28 @@ fn determine_wall_block(
fn get_wall_block_for_category(category: BuildingCategory, rng: &mut impl Rng) -> Block {
match category {
BuildingCategory::House | BuildingCategory::Residential => {
RESIDENTIAL_WALL_OPTIONS[rng.gen_range(0..RESIDENTIAL_WALL_OPTIONS.len())]
RESIDENTIAL_WALL_OPTIONS[rng.random_range(0..RESIDENTIAL_WALL_OPTIONS.len())]
}
BuildingCategory::Commercial | BuildingCategory::Office | BuildingCategory::Hotel => {
COMMERCIAL_WALL_OPTIONS[rng.gen_range(0..COMMERCIAL_WALL_OPTIONS.len())]
COMMERCIAL_WALL_OPTIONS[rng.random_range(0..COMMERCIAL_WALL_OPTIONS.len())]
}
BuildingCategory::Industrial | BuildingCategory::Warehouse => {
INDUSTRIAL_WALL_OPTIONS[rng.gen_range(0..INDUSTRIAL_WALL_OPTIONS.len())]
INDUSTRIAL_WALL_OPTIONS[rng.random_range(0..INDUSTRIAL_WALL_OPTIONS.len())]
}
BuildingCategory::Religious => {
RELIGIOUS_WALL_OPTIONS[rng.gen_range(0..RELIGIOUS_WALL_OPTIONS.len())]
RELIGIOUS_WALL_OPTIONS[rng.random_range(0..RELIGIOUS_WALL_OPTIONS.len())]
}
BuildingCategory::School | BuildingCategory::Hospital => {
INSTITUTIONAL_WALL_OPTIONS[rng.gen_range(0..INSTITUTIONAL_WALL_OPTIONS.len())]
INSTITUTIONAL_WALL_OPTIONS[rng.random_range(0..INSTITUTIONAL_WALL_OPTIONS.len())]
}
BuildingCategory::Farm => FARM_WALL_OPTIONS[rng.gen_range(0..FARM_WALL_OPTIONS.len())],
BuildingCategory::Farm => FARM_WALL_OPTIONS[rng.random_range(0..FARM_WALL_OPTIONS.len())],
BuildingCategory::Historic => {
HISTORIC_WALL_OPTIONS[rng.gen_range(0..HISTORIC_WALL_OPTIONS.len())]
HISTORIC_WALL_OPTIONS[rng.random_range(0..HISTORIC_WALL_OPTIONS.len())]
}
BuildingCategory::Garage => {
GARAGE_WALL_OPTIONS[rng.gen_range(0..GARAGE_WALL_OPTIONS.len())]
GARAGE_WALL_OPTIONS[rng.random_range(0..GARAGE_WALL_OPTIONS.len())]
}
BuildingCategory::Shed => SHED_WALL_OPTIONS[rng.gen_range(0..SHED_WALL_OPTIONS.len())],
BuildingCategory::Shed => SHED_WALL_OPTIONS[rng.random_range(0..SHED_WALL_OPTIONS.len())],
BuildingCategory::Tower => {
const TOWER_WALL_OPTIONS: [Block; 8] = [
STONE_BRICKS,
@@ -1028,14 +1036,14 @@ fn get_wall_block_for_category(category: BuildingCategory, rng: &mut impl Rng) -
DEEPSLATE_BRICKS,
SMOOTH_STONE,
];
TOWER_WALL_OPTIONS[rng.gen_range(0..TOWER_WALL_OPTIONS.len())]
TOWER_WALL_OPTIONS[rng.random_range(0..TOWER_WALL_OPTIONS.len())]
}
BuildingCategory::Greenhouse => {
GREENHOUSE_WALL_OPTIONS[rng.gen_range(0..GREENHOUSE_WALL_OPTIONS.len())]
GREENHOUSE_WALL_OPTIONS[rng.random_range(0..GREENHOUSE_WALL_OPTIONS.len())]
}
BuildingCategory::TallBuilding => {
// Tall buildings use commercial palette (glass, concrete, stone)
COMMERCIAL_WALL_OPTIONS[rng.gen_range(0..COMMERCIAL_WALL_OPTIONS.len())]
COMMERCIAL_WALL_OPTIONS[rng.random_range(0..COMMERCIAL_WALL_OPTIONS.len())]
}
BuildingCategory::ModernSkyscraper => {
// Modern skyscrapers use clean concrete/stone wall materials
@@ -1047,7 +1055,8 @@ fn get_wall_block_for_category(category: BuildingCategory, rng: &mut impl Rng) -
SMOOTH_STONE,
QUARTZ_BLOCK,
];
MODERN_SKYSCRAPER_WALL_OPTIONS[rng.gen_range(0..MODERN_SKYSCRAPER_WALL_OPTIONS.len())]
MODERN_SKYSCRAPER_WALL_OPTIONS
[rng.random_range(0..MODERN_SKYSCRAPER_WALL_OPTIONS.len())]
}
BuildingCategory::GlassySkyscraper => {
// Glass-facade skyscrapers use stained glass as wall material
@@ -1057,7 +1066,7 @@ fn get_wall_block_for_category(category: BuildingCategory, rng: &mut impl Rng) -
BLUE_STAINED_GLASS,
LIGHT_BLUE_STAINED_GLASS,
];
GLASSY_WALL_OPTIONS[rng.gen_range(0..GLASSY_WALL_OPTIONS.len())]
GLASSY_WALL_OPTIONS[rng.random_range(0..GLASSY_WALL_OPTIONS.len())]
}
BuildingCategory::Default => get_fallback_building_block(),
}
@@ -1251,30 +1260,180 @@ fn generate_roof_only_structure(
editor: &mut WorldEditor,
element: &ProcessedWay,
cached_floor_area: &[(i32, i32)],
args: &Args,
) {
let roof_height: i32 = 5;
let mut previous_node: Option<(i32, i32)> = None;
let scale_factor = args.scale;
let abs_terrain_offset = if !args.terrain { args.ground_level } else { 0 };
for node in &element.nodes {
let x = node.x;
let z = node.z;
// Determine where the roof structure starts vertically.
// Priority: min_height → building:min_level → layer hint → default.
let min_level_offset = if let Some(mh) = element.tags.get("min_height") {
// min_height is in meters; convert via scale factor.
mh.trim_end_matches('m')
.trim()
.parse::<f64>()
.ok()
.map(|h| (h * scale_factor) as i32)
.unwrap_or(0)
} else if let Some(ml) = element.tags.get("building:min_level") {
ml.parse::<i32>()
.ok()
.map(|l| multiply_scale(l * 4, scale_factor))
.unwrap_or(0)
} else if let Some(layer) = element.tags.get("layer") {
// For building:part=roof elements without explicit height tags, interpret
// the layer tag as a coarse vertical-placement hint. Each layer maps to
// 4 blocks, producing reasonable stacking for multi-shell roof structures.
layer
.parse::<i32>()
.ok()
.filter(|&l| l > 0)
.map(|l| multiply_scale(l * 4, scale_factor))
.unwrap_or(0)
} else {
0
};
if let Some(prev) = previous_node {
let bresenham_points = bresenham_line(prev.0, roof_height, prev.1, x, roof_height, z);
for (bx, _, bz) in bresenham_points {
editor.set_block(STONE_BRICK_SLAB, bx, roof_height, bz, None, None);
let start_y_offset = calculate_start_y_offset(editor, element, args, min_level_offset);
// Determine roof thickness / height.
let roof_thickness: i32 = if let Some(h) = element.tags.get("height") {
let total = h
.trim_end_matches('m')
.trim()
.parse::<f64>()
.ok()
.map(|v| (v * scale_factor) as i32)
.unwrap_or(5);
// If we already applied a min_height offset, the thickness is just
// the difference. Otherwise keep the parsed value.
if element.tags.contains_key("min_height") {
(total - min_level_offset).max(3)
} else {
total.max(3)
}
} else if let Some(levels) = element.tags.get("building:levels") {
levels
.parse::<i32>()
.ok()
.map(|l| multiply_scale(l * 4 + 2, scale_factor).max(3))
.unwrap_or(5)
} else {
5 // Default thickness for thin roof / canopy structures
};
// Pick a block for the roof surface.
let roof_block = if element
.tags
.get("material")
.or_else(|| element.tags.get("roof:material"))
.map(|s| s.as_str())
== Some("glass")
{
GLASS
} else if element.tags.get("colour").map(|s| s.as_str()) == Some("white")
|| element.tags.get("building:colour").map(|s| s.as_str()) == Some("white")
{
SMOOTH_QUARTZ
} else {
STONE_BRICK_SLAB
};
// Determine the roof shape from tags.
let roof_type = element
.tags
.get("roof:shape")
.map(|s| parse_roof_type(s))
.unwrap_or(RoofType::Flat);
match roof_type {
RoofType::Dome | RoofType::Hipped | RoofType::Pyramidal => {
// Standalone roof parts with curved or sloped shapes are rendered
// as domes. Without supporting walls, the dome approximation
// produces the best visual result for shell-like roof structures.
if !cached_floor_area.is_empty() {
let (min_x, max_x, min_z, max_z) = cached_floor_area.iter().fold(
(i32::MAX, i32::MIN, i32::MAX, i32::MIN),
|(min_x, max_x, min_z, max_z), &(x, z)| {
(min_x.min(x), max_x.max(x), min_z.min(z), max_z.max(z))
},
);
// For roof-only structures, base_height is the elevation where
// the dome starts (not on top of walls as in from_roof_area),
// since there is no building body underneath.
let config = RoofConfig {
min_x,
max_x,
min_z,
max_z,
center_x: (min_x + max_x) >> 1,
center_z: (min_z + max_z) >> 1,
base_height: start_y_offset,
abs_terrain_offset,
roof_block,
};
generate_dome_roof(editor, cached_floor_area, &config);
}
}
_ => {
// Flat / unsupported shape: pillars at outline nodes + slab fill.
let slab_y = start_y_offset + roof_thickness;
for y in 1..=(roof_height - 1) {
editor.set_block(COBBLESTONE_WALL, x, y, z, None, None);
// Outline pillars and edge slabs.
let mut previous_node: Option<(i32, i32)> = None;
for node in &element.nodes {
let x = node.x;
let z = node.z;
if let Some(prev) = previous_node {
let pts = bresenham_line(prev.0, slab_y, prev.1, x, slab_y, z);
for (bx, _, bz) in pts {
editor.set_block_absolute(
roof_block,
bx,
slab_y + abs_terrain_offset,
bz,
None,
None,
);
}
}
// Determine the pillar base in the same coordinate system as
// slab_y. When terrain is enabled, both values are absolute
// world coordinates. When terrain is disabled, both are
// relative to ground (abs_terrain_offset is added separately).
let pillar_base = if args.terrain {
editor.get_ground_level(x, z)
} else {
0
};
for y in (pillar_base + 1)..slab_y {
editor.set_block_absolute(
COBBLESTONE_WALL,
x,
y + abs_terrain_offset,
z,
None,
None,
);
}
previous_node = Some((x, z));
}
// Slab fill across the floor area.
for &(x, z) in cached_floor_area {
editor.set_block_absolute(
roof_block,
x,
slab_y + abs_terrain_offset,
z,
None,
None,
);
}
}
previous_node = Some((x, z));
}
for &(x, z) in cached_floor_area {
editor.set_block(STONE_BRICK_SLAB, x, roof_height, z, None, None);
}
}
@@ -1282,11 +1441,11 @@ fn generate_roof_only_structure(
// Building Component Generators
// ============================================================================
/// Generates the walls of a building including foundations, windows, and accent blocks
/// Builds a wall ring (outer shell or inner courtyard) for a set of nodes.
#[allow(clippy::too_many_arguments)]
fn generate_building_walls(
fn build_wall_ring(
editor: &mut WorldEditor,
element: &ProcessedWay,
nodes: &[ProcessedNode],
config: &BuildingConfig,
args: &Args,
has_sloped_roof: bool,
@@ -1295,7 +1454,7 @@ fn generate_building_walls(
let mut corner_addup: (i32, i32, i32) = (0, 0, 0);
let mut current_building: Vec<(i32, i32)> = Vec::new();
for node in &element.nodes {
for node in nodes {
let x = node.x;
let z = node.z;
@@ -1468,7 +1627,7 @@ fn generate_special_doors(
// Place a single oak door somewhere on the wall
// Pick a random position from the wall outline
if !wall_outline.is_empty() {
let door_idx = rng.gen_range(0..wall_outline.len());
let door_idx = rng.random_range(0..wall_outline.len());
let (door_x, door_z) = wall_outline[door_idx];
// Place single oak door (empty blacklist to overwrite wall blocks)
@@ -1619,8 +1778,9 @@ fn generate_residential_window_decorations(
// --- Per-building random material choices ---
let mut rng = element_rng(element.id);
let trapdoor_base = SHUTTER_TRAPDOOR_OPTIONS[rng.gen_range(0..SHUTTER_TRAPDOOR_OPTIONS.len())];
let sill_base = SILL_SLAB_OPTIONS[rng.gen_range(0..SILL_SLAB_OPTIONS.len())];
let trapdoor_base =
SHUTTER_TRAPDOOR_OPTIONS[rng.random_range(0..SHUTTER_TRAPDOOR_OPTIONS.len())];
let sill_base = SILL_SLAB_OPTIONS[rng.random_range(0..SILL_SLAB_OPTIONS.len())];
let sill_block = make_top_slab(sill_base);
// We need the building centroid so we can figure out which side of
@@ -1700,8 +1860,8 @@ fn generate_residential_window_decorations(
if mod6 == 3 || mod6 == 5 {
let centre_sum = if mod6 == 3 { bx + bz - 2 } else { bx + bz + 2 };
let shutter_roll =
coord_rng(centre_sum, centre_sum, element.id).gen_range(0u32..100);
if shutter_roll < 12 {
coord_rng(centre_sum, centre_sum, element.id).random_range(0u32..100);
if shutter_roll < 25 {
for h in (config.start_y_offset + 1)
..=(config.start_y_offset + config.building_height)
{
@@ -1746,11 +1906,11 @@ fn generate_residential_window_decorations(
centre_sum.wrapping_add(floor_idx * 5),
element.id,
)
.gen_range(0u32..100);
.random_range(0u32..100);
let abs_y = h + config.abs_terrain_offset;
if decoration_roll < 6 {
if decoration_roll < 15 {
// ── Window sill ──
let lx = bx + out_nx;
let lz = bz + out_nz;
@@ -1767,13 +1927,13 @@ fn generate_residential_window_decorations(
let mut pot_rng =
coord_rng(bx, bz.wrapping_add(floor_idx), element.id);
let pot_here = if mod6 == 1 {
pot_rng.gen_range(0u32..100) < 70
pot_rng.random_range(0u32..100) < 70
} else {
pot_rng.gen_range(0u32..100) < 25
pot_rng.random_range(0u32..100) < 25
};
if pot_here {
let plant = POTTED_PLANT_OPTIONS
[pot_rng.gen_range(0..POTTED_PLANT_OPTIONS.len())];
[pot_rng.random_range(0..POTTED_PLANT_OPTIONS.len())];
editor.set_block_absolute(
plant,
lx,
@@ -1783,7 +1943,7 @@ fn generate_residential_window_decorations(
None,
);
}
} else if decoration_roll < 10 && mod6 == 1 {
} else if decoration_roll < 23 && mod6 == 1 {
// ── Balcony (placed once from centre col) ──
// A small 3-wide × 2-deep platform with
// open-trapdoor railing around the outer
@@ -1876,12 +2036,12 @@ fn generate_residential_window_decorations(
bz.wrapping_add(floor_idx * 17),
element.id,
);
let furniture_roll = furn_rng.gen_range(0u32..100);
let furniture_roll = furn_rng.random_range(0u32..100);
if furniture_roll < 30 {
// Cauldron "planter" with a leaf block
// on top, placed at depth 1 on one side
let side = if furn_rng.gen_bool(0.5) { -1i32 } else { 1 };
let side = if furn_rng.random_bool(0.5) { -1i32 } else { 1 };
let cx = bx + tan_x * side + out_nx;
let cz = bz + tan_z * side + out_nz;
editor.set_block_absolute(
@@ -1902,7 +2062,7 @@ fn generate_residential_window_decorations(
);
} else if furniture_roll < 55 {
// Stair "chair" facing outward
let side = if furn_rng.gen_bool(0.5) { -1i32 } else { 1 };
let side = if furn_rng.random_bool(0.5) { -1i32 } else { 1 };
let sx = bx + tan_x * side + out_nx;
let sz = bz + tan_z * side + out_nz;
let stair_facing = match facing_for_normal(-out_nx, -out_nz) {
@@ -2115,7 +2275,7 @@ fn parse_roof_type(roof_shape: &str) -> RoofType {
"hipped" | "half-hipped" | "gambrel" | "mansard" | "round" => RoofType::Hipped,
"skillion" => RoofType::Skillion,
"pyramidal" => RoofType::Pyramidal,
"dome" | "onion" | "cone" => RoofType::Dome,
"dome" | "onion" | "cone" | "circular" | "spherical" => RoofType::Dome,
_ => RoofType::Flat,
}
}
@@ -2138,6 +2298,7 @@ pub fn generate_buildings(
element: &ProcessedWay,
args: &Args,
relation_levels: Option<i32>,
hole_polygons: Option<&[HolePolygon]>,
flood_fill_cache: &FloodFillCache,
) {
// Early return for underground buildings
@@ -2145,6 +2306,12 @@ pub fn generate_buildings(
return;
}
// Intercept tomb=pyramid: generate a sandstone pyramid instead of a building
if element.tags.get("tomb").map(|v| v.as_str()) == Some("pyramid") {
historic::generate_pyramid(editor, element, args, flood_fill_cache);
return;
}
// Parse min_level from tags
let min_level = element
.tags
@@ -2157,9 +2324,43 @@ pub fn generate_buildings(
let min_level_offset = multiply_scale(min_level * 4, scale_factor);
// Get cached floor area
let cached_floor_area: Vec<(i32, i32)> =
let mut cached_floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
if let Some(holes) = hole_polygons {
if !holes.is_empty() {
let outer_area: HashSet<(i32, i32)> = cached_floor_area.iter().copied().collect();
let mut hole_points: HashSet<(i32, i32)> = HashSet::new();
for hole in holes {
if hole.way.nodes.len() < 3 {
continue;
}
let hole_area = flood_fill_cache.get_or_compute(&hole.way, args.timeout.as_ref());
if hole_area.is_empty() {
continue;
}
if !hole_area.iter().any(|pt| outer_area.contains(pt)) {
continue;
}
for point in hole_area {
hole_points.insert(point);
}
}
if !hole_points.is_empty() {
cached_floor_area.retain(|point| !hole_points.contains(point));
}
}
}
let cached_footprint_size = cached_floor_area.len();
if cached_footprint_size == 0 {
return;
}
// Calculate start Y offset
let start_y_offset = calculate_start_y_offset(editor, element, args, min_level_offset);
@@ -2181,6 +2382,15 @@ pub fn generate_buildings(
return;
}
// Route building:part="roof" to the roof-only structure generator.
// This must be checked before the "building" tag match below, since elements
// with building:part="roof" (but no "building" tag) would otherwise fall
// through to the full building pipeline and render as small boxy buildings.
if element.tags.get("building:part").map(|v| v.as_str()) == Some("roof") {
generate_roof_only_structure(editor, element, &cached_floor_area, args);
return;
}
// Handle special building types with early returns
if let Some(btype) = element.tags.get("building") {
match btype.as_str() {
@@ -2195,7 +2405,7 @@ pub fn generate_buildings(
return;
}
"roof" => {
generate_roof_only_structure(editor, element, &cached_floor_area);
generate_roof_only_structure(editor, element, &cached_floor_area, args);
return;
}
"bridge" => {
@@ -2274,7 +2484,15 @@ pub fn generate_buildings(
// Generate walls, pass whether this building will have a sloped roof
let has_sloped_roof = args.roof && style.generate_roof && style.roof_type != RoofType::Flat;
let (wall_outline, corner_addup) =
generate_building_walls(editor, element, &config, args, has_sloped_roof);
build_wall_ring(editor, &element.nodes, &config, args, has_sloped_roof);
if let Some(holes) = hole_polygons {
for hole in holes {
if hole.add_walls {
let _ = build_wall_ring(editor, &hole.way.nodes, &config, args, has_sloped_roof);
}
}
}
// Generate special doors (garage doors, shed doors)
if config.has_garage_door || config.has_single_door {
@@ -2457,7 +2675,7 @@ fn generate_chimney(
let center_z = (min_z + max_z) / 2;
// Choose which quadrant to place the chimney (deterministically)
let quadrant = rng.gen_range(0..4);
let quadrant = rng.random_range(0..4);
// Filter floor area points to the chosen quadrant and find one that's
// offset from the edge (so it's actually on the roof, not at the wall)
@@ -2493,7 +2711,7 @@ fn generate_chimney(
}
// Pick a point from candidates
let (chimney_x, chimney_z) = final_candidates[rng.gen_range(0..final_candidates.len())];
let (chimney_x, chimney_z) = final_candidates[rng.random_range(0..final_candidates.len())];
// Chimney starts 2 blocks below roof peak to replace roof blocks properly
// Height is exactly 4 brick blocks with a slab cap on top
@@ -2596,7 +2814,7 @@ fn generate_roof_terrace(
for &(x, z) in &interior {
// Deterministic per-position decision using coord_rng
let mut rng = coord_rng(x, z, element.id);
let roll: u32 = rng.gen_range(0..100);
let roll: u32 = rng.random_range(0..100);
// ~85% of interior tiles are empty (open terrace space)
if roll >= 15 {
@@ -2621,7 +2839,7 @@ fn generate_roof_terrace(
// Planter: leaf block on top of cauldron
editor.set_block_absolute(CAULDRON, x, terrace_y, z, None, Some(replace_any));
// Vary the leaf type
let leaf = match rng.gen_range(0..3) {
let leaf = match rng.random_range(0..3) {
0 => OAK_LEAVES,
1 => BIRCH_LEAVES,
_ => SPRUCE_LEAVES,
@@ -2775,7 +2993,7 @@ fn generate_rooftop_equipment(
}
let mut rng = coord_rng(x, z, element.id);
let roll: u32 = rng.gen_range(0..1200);
let roll: u32 = rng.random_range(0..1200);
// ~99.4% of tiles are empty, very sparse
if roll >= 7 {
@@ -2937,8 +3155,8 @@ impl RoofConfig {
// 90% wall block, 10% accent block for variety (deterministic based on element ID)
let mut rng = element_rng(element_id);
// Advance RNG state to get different value than other style choices
let _ = rng.gen::<u32>();
let roof_block = if rng.gen_bool(0.1) {
let _ = rng.random::<u32>();
let roof_block = if rng.random_bool(0.1) {
accent_block
} else {
wall_block
@@ -3758,6 +3976,7 @@ pub fn generate_building_from_relation(
relation: &ProcessedRelation,
args: &Args,
flood_fill_cache: &FloodFillCache,
xzbbox: &crate::coordinate_system::cartesian::XZBBox,
) {
// Skip underground buildings/building parts
// Check layer tag
@@ -3792,33 +4011,159 @@ pub fn generate_building_from_relation(
.and_then(|l: &String| l.parse::<i32>().ok())
.unwrap_or(2); // Default to 2 levels
// Process the outer way to create the building walls
for member in &relation.members {
if member.role == ProcessedMemberRole::Outer {
// Check if this is a type=building relation with part members.
// Only type=building relations use Part roles; type=multipolygon relations
// should always render their Outer members normally.
let is_building_type = relation.tags.get("type").map(|t| t.as_str()) == Some("building");
let has_parts = is_building_type
&& relation
.members
.iter()
.any(|m| m.role == ProcessedMemberRole::Part);
if !has_parts {
// Collect outer member node lists and merge open segments into closed rings.
// Multipolygon relations commonly split the outline across many short way
// segments that share endpoints. Without merging, each segment is processed
// individually, producing degenerate polygons and empty flood fills (only
// wall outlines, no filled floors/ceilings/roofs).
let mut outer_rings: Vec<Vec<ProcessedNode>> = relation
.members
.iter()
.filter(|m| m.role == ProcessedMemberRole::Outer)
.map(|m| m.way.nodes.clone())
.collect();
super::merge_way_segments(&mut outer_rings);
// Clip assembled rings to the world bounding box. Because member ways
// were kept unclipped during parsing (to allow ring assembly), the
// merged rings may extend beyond the requested area. Clipping prevents
// oversized flood fills and unnecessary block placement.
outer_rings = outer_rings
.into_iter()
.map(|ring| clip_way_to_bbox(&ring, xzbbox))
.filter(|ring| ring.len() >= 4)
.collect();
// Close rings that are nearly closed (endpoints within 1 block)
for ring in &mut outer_rings {
if ring.len() >= 3 {
let first = &ring[0];
let last = ring.last().unwrap();
if first.id != last.id {
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
if dx <= 1 && dz <= 1 {
let close_node = ring[0].clone();
ring.push(close_node);
}
}
}
}
// Discard rings that are still open or too small
outer_rings.retain(|ring| {
if ring.len() < 4 {
return false;
}
let first = &ring[0];
let last = ring.last().unwrap();
first.id == last.id || ((first.x - last.x).abs() <= 1 && (first.z - last.z).abs() <= 1)
});
// Collect and assemble inner rings for courtyards/holes.
let mut inner_rings: Vec<Vec<ProcessedNode>> = relation
.members
.iter()
.filter(|m| m.role == ProcessedMemberRole::Inner)
.map(|m| m.way.nodes.clone())
.collect();
super::merge_way_segments(&mut inner_rings);
inner_rings = inner_rings
.into_iter()
.map(|ring| clip_way_to_bbox(&ring, xzbbox))
.filter(|ring| ring.len() >= 4)
.collect();
// Close rings that are nearly closed (endpoints within 1 block)
for ring in &mut inner_rings {
if ring.len() >= 3 {
let first = &ring[0];
let last = ring.last().unwrap();
if first.id != last.id {
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
if dx <= 1 && dz <= 1 {
let close_node = ring[0].clone();
ring.push(close_node);
}
}
}
}
// Discard rings that are still open or too small
inner_rings.retain(|ring| {
if ring.len() < 4 {
return false;
}
let first = &ring[0];
let last = ring.last().unwrap();
first.id == last.id || ((first.x - last.x).abs() <= 1 && (first.z - last.z).abs() <= 1)
});
let hole_polygons: Option<Vec<HolePolygon>> = if inner_rings.is_empty() {
None
} else {
Some(
inner_rings
.into_iter()
.enumerate()
.map(|(ring_idx, ring)| {
// Use a different index range from outer rings to avoid cache collisions.
let ring_slot = 0x8000u64 | (ring_idx as u64 & 0x7FFF);
let synthetic_id = (1u64 << 63) | (relation.id << 16) | ring_slot;
HolePolygon {
way: ProcessedWay {
id: synthetic_id,
tags: HashMap::new(),
nodes: ring,
},
add_walls: true,
}
})
.collect(),
)
};
// Build a synthetic ProcessedWay for each assembled ring and render it.
// The relation tags are applied so that building type, levels, and roof
// shape from the relation are honoured.
//
// Synthetic IDs use bit 63 as a flag combined with the relation ID and a
// ring index. This prevents collisions with real way IDs in the flood
// fill cache and the deterministic RNG seeded by element ID.
for (ring_idx, ring) in outer_rings.into_iter().enumerate() {
let synthetic_id = (1u64 << 63) | (relation.id << 16) | (ring_idx as u64 & 0xFFFF);
let merged_way = ProcessedWay {
id: synthetic_id,
tags: relation.tags.clone(),
nodes: ring,
};
generate_buildings(
editor,
&member.way,
&merged_way,
args,
Some(relation_levels),
hole_polygons.as_deref(),
flood_fill_cache,
);
}
}
// Handle inner ways (holes, courtyards, etc.)
/*for member in &relation.members {
if member.role == ProcessedMemberRole::Inner {
let polygon_coords: Vec<(i32, i32)> =
member.way.nodes.iter().map(|n| (n.x, n.z)).collect();
let hole_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
for (x, z) in hole_area {
// Remove blocks in the inner area to create a hole
editor.set_block(AIR, x, ground_level, z, None, Some(&[SPONGE]));
}
}
}*/
// When has_parts: parts are rendered as standalone ways from the elements list.
// The outline way is suppressed in data_processing to avoid overlaying the parts.
}
/// Generates a bridge structure, paying attention to the "level" tag.

View File

@@ -166,17 +166,28 @@ fn generate_highways_internal(
// Check if this is a bridge - bridges need special elevation handling
// to span across valleys instead of following terrain
// Accept any bridge tag value except "no" (e.g., "yes", "viaduct", "aqueduct", etc.)
let is_bridge = element.tags().get("bridge").is_some_and(|v| v != "no");
// Indoor highways are never treated as bridges (indoor corridors should not
// generate elevated decks or support pillars).
let is_indoor = element.tags().get("indoor").is_some_and(|v| v == "yes");
let is_bridge = !is_indoor && element.tags().get("bridge").is_some_and(|v| v != "no");
// Parse the layer value for elevation calculation
let layer_value = element
let mut layer_value = element
.tags()
.get("layer")
.and_then(|layer| layer.parse::<i32>().ok())
.unwrap_or(0);
// Treat negative layers as ground level (0)
let layer_value = if layer_value < 0 { 0 } else { layer_value };
if layer_value < 0 {
layer_value = 0;
}
// If the way is indoor, treat it as ground level to avoid creating
// bridges/supports inside buildings (indoor=yes should not produce bridges)
if is_indoor {
layer_value = 0;
}
// Skip if 'level' is negative in the tags (indoor mapping)
if let Some(level) = element.tags().get("level") {

View File

@@ -3,9 +3,11 @@
//! This module handles historic OSM elements including:
//! - `historic=memorial` - Memorials, monuments, and commemorative structures
use crate::args::Args;
use crate::block_definitions::*;
use crate::deterministic_rng::element_rng;
use crate::osm_parser::ProcessedNode;
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedNode, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::Rng;
@@ -73,7 +75,7 @@ fn generate_memorial(editor: &mut WorldEditor, node: &ProcessedNode) {
editor.set_block(CHISELED_STONE_BRICKS, x, 2, z, None, None);
// Use polished andesite for bronze/metal statue appearance
let statue_block = if rng.gen_bool(0.5) {
let statue_block = if rng.random_bool(0.5) {
POLISHED_ANDESITE
} else {
POLISHED_DIORITE
@@ -205,3 +207,134 @@ fn generate_cross(editor: &mut WorldEditor, x: i32, z: i32, height: i32) {
editor.set_block(STONE_BRICK_WALL, x + 1, arm_y, z, None, None);
}
}
// ============================================================================
// Pyramid Generation (tomb=pyramid)
// ============================================================================
/// Generates a solid sandstone pyramid from a way outline.
///
/// The pyramid is built by flood-filling the footprint at ground level,
/// then shrinking the filled area inward by one block per layer until
/// only a single apex block (or nothing) remains.
pub fn generate_pyramid(
editor: &mut WorldEditor,
element: &ProcessedWay,
args: &Args,
flood_fill_cache: &FloodFillCache,
) {
if element.nodes.len() < 3 {
return;
}
// Get the footprint via flood fill
let footprint: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
if footprint.is_empty() {
return;
}
// Determine base Y from terrain or ground level
// Use the MINIMUM ground level so the pyramid sits on the lowest point
// and doesn't float in areas with elevation differences
let base_y = if args.terrain {
footprint
.iter()
.map(|&(x, z)| editor.get_ground_level(x, z))
.min()
.unwrap_or(args.ground_level)
} else {
args.ground_level
};
// Bounding box of the footprint
let min_x = footprint.iter().map(|&(x, _)| x).min().unwrap();
let max_x = footprint.iter().map(|&(x, _)| x).max().unwrap();
let min_z = footprint.iter().map(|&(_, z)| z).min().unwrap();
let max_z = footprint.iter().map(|&(_, z)| z).max().unwrap();
let center_x = (min_x + max_x) as f64 / 2.0;
let center_z = (min_z + max_z) as f64 / 2.0;
// The pyramid height is half the shorter side of the bounding box (classic proportions)
let width = (max_x - min_x + 1) as f64;
let length = (max_z - min_z + 1) as f64;
let half_base = width.min(length) / 2.0;
// Height = half the shorter side (classic pyramid proportions).
// Footprint is already in scaled Minecraft coordinates, so no extra scale factor needed.
let pyramid_height = half_base.max(3.0) as i32;
// Build the pyramid layer by layer.
// For each layer, only place blocks whose Chebyshev distance from the
// footprint centre is within the shrinking radius AND that were in the
// original footprint.
let mut last_placed_layer: Option<i32> = None;
for layer in 0..pyramid_height {
// The allowed radius shrinks linearly from half_base at layer 0 to 0
let radius = half_base * (1.0 - layer as f64 / pyramid_height as f64);
if radius < 0.0 {
break;
}
let y = base_y + 1 + layer;
let mut placed = false;
for &(x, z) in &footprint {
let dx = (x as f64 - center_x).abs();
let dz = (z as f64 - center_z).abs();
// Use Chebyshev distance (max of dx, dz) for a square-footprint pyramid
if dx <= radius && dz <= radius {
// Allow overwriting common terrain blocks so the pyramid is
// solid even when it intersects higher ground.
editor.set_block_absolute(
SANDSTONE,
x,
y,
z,
Some(&[
GRASS_BLOCK,
DIRT,
STONE,
SAND,
GRAVEL,
COARSE_DIRT,
PODZOL,
DIRT_PATH,
SANDSTONE,
]),
None,
);
placed = true;
}
}
if placed {
last_placed_layer = Some(y);
} else {
break; // Nothing placed, we've reached the apex
}
}
// Cap with smooth sandstone one block above the last placed layer
if let Some(top_y) = last_placed_layer {
editor.set_block_absolute(
SMOOTH_SANDSTONE,
center_x.round() as i32,
top_y + 1,
center_z.round() as i32,
Some(&[
GRASS_BLOCK,
DIRT,
STONE,
SAND,
GRAVEL,
COARSE_DIRT,
PODZOL,
DIRT_PATH,
SANDSTONE,
]),
None,
);
}
}

View File

@@ -1,11 +1,12 @@
use crate::args::Args;
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::deterministic_rng::element_rng;
use crate::element_processing::tree::{Tree, TreeType};
use crate::floodfill_cache::{BuildingFootprintBitmap, FloodFillCache};
use crate::osm_parser::{ProcessedMemberRole, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::prelude::SliceRandom;
use rand::prelude::IndexedRandom;
use rand::Rng;
pub fn generate_landuse(
@@ -86,7 +87,7 @@ pub fn generate_landuse(
// Apply per-block randomness for certain landuse types
let actual_block = if landuse_tag == "residential" && block_type == STONE_BRICKS {
// Urban residential: mix of stone bricks, cracked stone bricks, stone, cobblestone
let random_value = rng.gen_range(0..100);
let random_value = rng.random_range(0..100);
if random_value < 72 {
STONE_BRICKS
} else if random_value < 87 {
@@ -98,7 +99,7 @@ pub fn generate_landuse(
}
} else if landuse_tag == "commercial" {
// Commercial: mix of smooth stone, stone, cobblestone, stone bricks
let random_value = rng.gen_range(0..100);
let random_value = rng.random_range(0..100);
if random_value < 40 {
SMOOTH_STONE
} else if random_value < 70 {
@@ -110,7 +111,7 @@ pub fn generate_landuse(
}
} else if landuse_tag == "industrial" {
// Industrial: primarily stone, with some stone bricks and smooth stone
let random_value = rng.gen_range(0..100);
let random_value = rng.random_range(0..100);
if random_value < 70 {
STONE
} else if random_value < 90 {
@@ -134,11 +135,11 @@ pub fn generate_landuse(
match landuse_tag.as_str() {
"cemetery" => {
if (x % 3 == 0) && (z % 3 == 0) {
let random_choice: i32 = rng.gen_range(0..100);
let random_choice: i32 = rng.random_range(0..100);
if random_choice < 15 {
// Place graves
if editor.check_for_block(x, 0, z, Some(&[PODZOL])) {
if rng.gen_bool(0.5) {
if rng.random_bool(0.5) {
editor.set_block(COBBLESTONE, x - 1, 1, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x - 1, 2, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x, 1, z, None, None);
@@ -168,7 +169,7 @@ pub fn generate_landuse(
}
"forest" => {
if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
let random_choice: i32 = rng.gen_range(0..30);
let random_choice: i32 = rng.random_range(0..30);
if random_choice == 20 {
let tree_type = *trees_ok_to_generate
.choose(&mut rng)
@@ -180,7 +181,7 @@ pub fn generate_landuse(
Some(building_footprints),
);
} else if random_choice == 2 {
let flower_block: Block = match rng.gen_range(1..=6) {
let flower_block: Block = match rng.random_range(1..=6) {
1 => OAK_LEAVES,
2 => RED_FLOWER,
3 => BLUE_FLOWER,
@@ -190,7 +191,7 @@ pub fn generate_landuse(
};
editor.set_block(flower_block, x, 1, z, None, None);
} else if random_choice <= 12 {
if rng.gen_range(0..100) < 12 {
if rng.random_range(0..100) < 12 {
editor.set_block(FERN, x, 1, z, None, None);
} else {
editor.set_block(GRASS, x, 1, z, None, None);
@@ -204,8 +205,8 @@ pub fn generate_landuse(
if x % 9 == 0 && z % 9 == 0 {
// Place water in dot pattern
editor.set_block(WATER, x, 0, z, Some(&[FARMLAND]), None);
} else if rng.gen_range(0..76) == 0 {
let special_choice: i32 = rng.gen_range(1..=10);
} else if rng.random_range(0..76) == 0 {
let special_choice: i32 = rng.random_range(1..=10);
if special_choice <= 4 {
editor.set_block(HAY_BALE, x, 1, z, None, Some(&[SPONGE]));
} else {
@@ -214,14 +215,14 @@ pub fn generate_landuse(
} else {
// Set crops only if the block below is farmland
if editor.check_for_block(x, 0, z, Some(&[FARMLAND])) {
let crop_choice = [WHEAT, CARROTS, POTATOES][rng.gen_range(0..3)];
let crop_choice = [WHEAT, CARROTS, POTATOES][rng.random_range(0..3)];
editor.set_block(crop_choice, x, 1, z, None, None);
}
}
}
}
"construction" => {
let random_choice: i32 = rng.gen_range(0..1501);
let random_choice: i32 = rng.random_range(0..1501);
if random_choice < 15 {
editor.set_block(SCAFFOLDING, x, 1, z, None, None);
if random_choice < 2 {
@@ -257,7 +258,7 @@ pub fn generate_landuse(
FURNACE,
];
editor.set_block(
construction_items[rng.gen_range(0..construction_items.len())],
construction_items[rng.random_range(0..construction_items.len())],
x,
1,
z,
@@ -294,7 +295,7 @@ pub fn generate_landuse(
}
"grass" => {
if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
match rng.gen_range(0..200) {
match rng.random_range(0..200) {
0 => editor.set_block(OAK_LEAVES, x, 1, z, None, None),
1..=8 => editor.set_block(FERN, x, 1, z, None, None),
9..=170 => editor.set_block(GRASS, x, 1, z, None, None),
@@ -304,7 +305,7 @@ pub fn generate_landuse(
}
"greenfield" => {
if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
match rng.gen_range(0..200) {
match rng.random_range(0..200) {
0 => editor.set_block(OAK_LEAVES, x, 1, z, None, None),
1..=2 => editor.set_block(FERN, x, 1, z, None, None),
3..=16 => editor.set_block(GRASS, x, 1, z, None, None),
@@ -314,7 +315,7 @@ pub fn generate_landuse(
}
"meadow" => {
if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
let random_choice: i32 = rng.gen_range(0..1001);
let random_choice: i32 = rng.random_range(0..1001);
if random_choice < 5 {
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if random_choice < 6 {
@@ -335,7 +336,7 @@ pub fn generate_landuse(
if x % 18 == 0 && z % 10 == 0 {
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
match rng.gen_range(0..100) {
match rng.random_range(0..100) {
0 => editor.set_block(OAK_LEAVES, x, 1, z, None, None),
1..=2 => editor.set_block(FERN, x, 1, z, None, None),
3..=20 => editor.set_block(GRASS, x, 1, z, None, None),
@@ -357,7 +358,8 @@ pub fn generate_landuse(
"clay" | "kaolinite" => CLAY,
_ => STONE,
};
let random_choice: i32 = rng.gen_range(0..100 + editor.get_absolute_y(x, 0, z)); // The deeper it is the more resources are there
let random_choice: i32 =
rng.random_range(0..100 + editor.get_absolute_y(x, 0, z)); // The deeper it is the more resources are there
if random_choice < 5 {
editor.set_block(ore_block, x, 0, z, Some(&[STONE]), None);
}
@@ -366,6 +368,26 @@ pub fn generate_landuse(
_ => {}
}
}
// Generate a stone brick wall fence around cemeteries
if landuse_tag == "cemetery" {
generate_cemetery_fence(editor, element);
}
}
/// Draws a stone-brick wall fence (with slab cap) along the outline of a
/// cemetery way.
fn generate_cemetery_fence(editor: &mut WorldEditor, element: &ProcessedWay) {
for i in 1..element.nodes.len() {
let prev = &element.nodes[i - 1];
let cur = &element.nodes[i];
let points = bresenham_line(prev.x, 0, prev.z, cur.x, 0, cur.z);
for (bx, _, bz) in points {
editor.set_block(STONE_BRICK_WALL, bx, 1, bz, None, None);
editor.set_block(STONE_BRICK_SLAB, bx, 2, bz, None, None);
}
}
}
pub fn generate_landuse_from_relation(

View File

@@ -96,7 +96,7 @@ pub fn generate_leisure(
if matches!(leisure_type.as_str(), "park" | "garden" | "nature_reserve")
&& editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK]))
{
let random_choice: i32 = rng.gen_range(0..1000);
let random_choice: i32 = rng.random_range(0..1000);
match random_choice {
0..30 => {
@@ -129,7 +129,7 @@ pub fn generate_leisure(
// Add playground or recreation ground features
if matches!(leisure_type.as_str(), "playground" | "recreation_ground") {
let random_choice: i32 = rng.gen_range(0..5000);
let random_choice: i32 = rng.random_range(0..5000);
match random_choice {
0..10 => {

View File

@@ -6,8 +6,7 @@ use crate::element_processing::tree::{Tree, TreeType};
use crate::floodfill_cache::{BuildingFootprintBitmap, FloodFillCache};
use crate::osm_parser::{ProcessedElement, ProcessedMemberRole, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::prelude::SliceRandom;
use rand::Rng;
use rand::{prelude::IndexedRandom, Rng};
pub fn generate_natural(
editor: &mut WorldEditor,
@@ -193,7 +192,7 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
if rng.gen_bool(0.6) {
if rng.random_bool(0.6) {
editor.set_block(GRASS, x, 1, z, None, None);
}
}
@@ -201,7 +200,7 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
let random_choice = rng.gen_range(0..500);
let random_choice = rng.random_range(0..500);
if random_choice < 33 {
if random_choice <= 2 {
editor.set_block(COBBLESTONE, x, 0, z, None, None);
@@ -216,11 +215,11 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
let random_choice = rng.gen_range(0..500);
let random_choice = rng.random_range(0..500);
if random_choice == 0 {
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if random_choice == 1 {
let flower_block = match rng.gen_range(1..=4) {
let flower_block = match rng.random_range(1..=4) {
1 => RED_FLOWER,
2 => BLUE_FLOWER,
3 => YELLOW_FLOWER,
@@ -245,7 +244,7 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
let random_choice: i32 = rng.gen_range(0..30);
let random_choice: i32 = rng.random_range(0..30);
if random_choice == 0 {
let tree_type = *trees_ok_to_generate
.choose(&mut rng)
@@ -257,7 +256,7 @@ pub fn generate_natural(
Some(building_footprints),
);
} else if random_choice == 1 {
let flower_block = match rng.gen_range(1..=4) {
let flower_block = match rng.random_range(1..=4) {
1 => RED_FLOWER,
2 => BLUE_FLOWER,
3 => YELLOW_FLOWER,
@@ -270,13 +269,13 @@ pub fn generate_natural(
}
"sand" => {
if editor.check_for_block(x, 0, z, Some(&[SAND]))
&& rng.gen_range(0..100) == 1
&& rng.random_range(0..100) == 1
{
editor.set_block(DEAD_BUSH, x, 1, z, None, None);
}
}
"shoal" => {
if rng.gen_bool(0.05) {
if rng.random_bool(0.05) {
editor.set_block(WATER, x, 0, z, Some(&[SAND, GRAVEL]), None);
}
}
@@ -284,14 +283,14 @@ pub fn generate_natural(
if let Some(wetland_type) = element.tags().get("wetland") {
// Wetland without water blocks
if matches!(wetland_type.as_str(), "wet_meadow" | "fen") {
if rng.gen_bool(0.3) {
if rng.random_bool(0.3) {
editor.set_block(GRASS_BLOCK, x, 0, z, Some(&[MUD]), None);
}
editor.set_block(GRASS, x, 1, z, None, None);
continue;
}
// All the other types of wetland
if rng.gen_bool(0.3) {
if rng.random_bool(0.3) {
editor.set_block(
WATER,
x,
@@ -312,7 +311,7 @@ pub fn generate_natural(
}
"swamp" | "mangrove" => {
// TODO implement mangrove
let random_choice: i32 = rng.gen_range(0..40);
let random_choice: i32 = rng.random_range(0..40);
if random_choice == 0 {
Tree::create(
editor,
@@ -324,7 +323,7 @@ pub fn generate_natural(
}
}
"bog" => {
if rng.gen_bool(0.2) {
if rng.random_bool(0.2) {
editor.set_block(
MOSS_BLOCK,
x,
@@ -334,7 +333,7 @@ pub fn generate_natural(
None,
);
}
if rng.gen_bool(0.15) {
if rng.random_bool(0.15) {
editor.set_block(GRASS, x, 1, z, None, None);
}
}
@@ -347,7 +346,7 @@ pub fn generate_natural(
}
} else {
// Generic natural=wetland without wetland=... tag
if rng.gen_bool(0.3) {
if rng.random_bool(0.3) {
editor.set_block(WATER, x, 0, z, Some(&[MUD]), None);
continue;
}
@@ -356,11 +355,11 @@ pub fn generate_natural(
}
"mountain_range" => {
// Create block clusters instead of random placement
let cluster_chance = rng.gen_range(0..1000);
let cluster_chance = rng.random_range(0..1000);
if cluster_chance < 50 {
// 5% chance to start a new cluster
let cluster_block = match rng.gen_range(0..7) {
let cluster_block = match rng.random_range(0..7) {
0 => DIRT,
1 => STONE,
2 => GRAVEL,
@@ -371,7 +370,7 @@ pub fn generate_natural(
};
// Generate cluster size (5-10 blocks radius)
let cluster_size = rng.gen_range(5..=10);
let cluster_size = rng.random_range(5..=10);
// Create cluster around current position
for dx in -(cluster_size as i32)..=(cluster_size as i32) {
@@ -384,7 +383,7 @@ pub fn generate_natural(
if distance <= cluster_size as f32 {
// Probability decreases with distance from center
let place_prob = 1.0 - (distance / cluster_size as f32);
if rng.gen::<f32>() < place_prob {
if rng.random::<f32>() < place_prob {
editor.set_block(
cluster_block,
cluster_x,
@@ -396,7 +395,8 @@ pub fn generate_natural(
// Add vegetation on grass blocks
if cluster_block == GRASS_BLOCK {
let vegetation_chance = rng.gen_range(0..100);
let vegetation_chance =
rng.random_range(0..100);
if vegetation_chance == 0 {
// 1% chance for rare trees
Tree::create(
@@ -426,7 +426,7 @@ pub fn generate_natural(
}
"saddle" => {
// Saddle areas - lowest point between peaks, mix of stone and grass
let terrain_chance = rng.gen_range(0..100);
let terrain_chance = rng.random_range(0..100);
if terrain_chance < 30 {
// 30% chance for exposed stone
editor.set_block(STONE, x, 0, z, None, None);
@@ -436,7 +436,7 @@ pub fn generate_natural(
} else {
// 50% chance for grass
editor.set_block(GRASS_BLOCK, x, 0, z, None, None);
if rng.gen_bool(0.4) {
if rng.random_bool(0.4) {
// 40% chance for grass on top
editor.set_block(GRASS, x, 1, z, None, None);
}
@@ -444,10 +444,10 @@ pub fn generate_natural(
}
"ridge" => {
// Ridge areas - elevated crest, mostly rocky with some vegetation
let ridge_chance = rng.gen_range(0..100);
let ridge_chance = rng.random_range(0..100);
if ridge_chance < 60 {
// 60% chance for stone/rocky terrain
let rock_type = match rng.gen_range(0..4) {
let rock_type = match rng.random_range(0..4) {
0 => STONE,
1 => COBBLESTONE,
2 => GRANITE,
@@ -457,7 +457,7 @@ pub fn generate_natural(
} else {
// 40% chance for grass with sparse vegetation
editor.set_block(GRASS_BLOCK, x, 0, z, None, None);
let vegetation_chance = rng.gen_range(0..100);
let vegetation_chance = rng.random_range(0..100);
if vegetation_chance < 20 {
// 20% chance for grass
editor.set_block(GRASS, x, 1, z, None, None);
@@ -477,7 +477,7 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
let tundra_chance = rng.gen_range(0..100);
let tundra_chance = rng.random_range(0..100);
if tundra_chance < 40 {
// 40% chance for grass (sedges, grasses)
editor.set_block(GRASS, x, 1, z, None, None);
@@ -492,10 +492,10 @@ pub fn generate_natural(
}
"cliff" => {
// Cliff areas - predominantly stone with minimal vegetation
let cliff_chance = rng.gen_range(0..100);
let cliff_chance = rng.random_range(0..100);
if cliff_chance < 90 {
// 90% chance for stone variants
let stone_type = match rng.gen_range(0..4) {
let stone_type = match rng.random_range(0..4) {
0 => STONE,
1 => COBBLESTONE,
2 => ANDESITE,
@@ -512,13 +512,13 @@ pub fn generate_natural(
if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) {
continue;
}
let hill_chance = rng.gen_range(0..1000);
let hill_chance = rng.random_range(0..1000);
if hill_chance == 0 {
// 0.1% chance for rare trees
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if hill_chance < 50 {
// 5% chance for flowers
let flower_block = match rng.gen_range(1..=4) {
let flower_block = match rng.random_range(1..=4) {
1 => RED_FLOWER,
2 => BLUE_FLOWER,
3 => YELLOW_FLOWER,

View File

@@ -156,7 +156,7 @@ impl Tree<'_> {
// The element_id of 0 is used as a salt for tree-specific randomness
let mut rng = coord_rng(x, z, 0);
let tree_type = match rng.gen_range(1..=10) {
let tree_type = match rng.random_range(1..=10) {
1..=3 => TreeType::Oak,
4..=5 => TreeType::Spruce,
6..=7 => TreeType::Birch,

View File

@@ -1,6 +1,3 @@
use geo::orient::{Direction, Orient};
use geo::{Contains, Intersects, LineString, Point, Polygon, Rect};
use crate::clipping::clip_water_ring_to_bbox;
use crate::{
block_definitions::WATER,
@@ -54,6 +51,7 @@ pub fn generate_water_areas_from_relation(
match mem.role {
ProcessedMemberRole::Outer => outers.push(mem.way.nodes.clone()),
ProcessedMemberRole::Inner => inners.push(mem.way.nodes.clone()),
ProcessedMemberRole::Part => {} // Not applicable to water areas
}
}
@@ -163,7 +161,7 @@ fn generate_water_areas(
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
.collect();
inverse_floodfill(min_x, min_z, max_x, max_z, outers_xz, inners_xz, editor);
scanline_fill_water(min_x, min_z, max_x, max_z, &outers_xz, &inners_xz, editor);
}
/// Verifies all rings are properly closed (first node matches last).
@@ -189,156 +187,248 @@ fn verify_closed_rings(rings: &[Vec<ProcessedNode>]) -> bool {
valid
}
// Water areas are absolutely huge. We can't easily flood fill the entire thing.
// Instead, we'll iterate over all the blocks in our MC world, and check if each
// one is in the river or not
// ============================================================================
// Scanline rasterization for water area filling
// ============================================================================
//
// For each row (z coordinate) in the fill area, computes polygon edge
// crossings to determine which x-ranges are inside the outer polygons but
// outside the inner polygons, then fills those ranges with water blocks.
//
// Complexity: O(E * H + A) where E = total edges, H = height of fill area,
// A = total filled area. This is dramatically faster than the previous
// quadtree + per-block point-in-polygon approach O(A * V * P) for large or
// complex water bodies (e.g. the Venetian Lagoon with dozens of inner island
// rings).
/// A polygon edge segment for scanline intersection testing.
struct ScanlineEdge {
x1: f64,
z1: f64,
x2: f64,
z2: f64,
}
/// Collects all non-horizontal edges from a single polygon ring.
///
/// If the ring is not perfectly closed (last point != first point),
/// the closing edge is added explicitly.
fn collect_ring_edges(ring: &[XZPoint]) -> Vec<ScanlineEdge> {
let mut edges = Vec::new();
if ring.len() < 2 {
return edges;
}
for i in 0..ring.len() - 1 {
let a = &ring[i];
let b = &ring[i + 1];
// Skip horizontal edges, they produce no scanline crossings
if a.z != b.z {
edges.push(ScanlineEdge {
x1: a.x as f64,
z1: a.z as f64,
x2: b.x as f64,
z2: b.z as f64,
});
}
}
// Add closing edge if the ring isn't perfectly closed by coordinates
let first = ring.first().unwrap();
let last = ring.last().unwrap();
if first.z != last.z {
edges.push(ScanlineEdge {
x1: last.x as f64,
z1: last.z as f64,
x2: first.x as f64,
z2: first.z as f64,
});
}
edges
}
/// Collects edges from multiple rings into a single list.
/// Used for inner rings where even-odd on combined edges is correct
/// (inner rings of a valid multipolygon do not overlap).
fn collect_all_ring_edges(rings: &[Vec<XZPoint>]) -> Vec<ScanlineEdge> {
let mut edges = Vec::new();
for ring in rings {
edges.extend(collect_ring_edges(ring));
}
edges
}
/// Computes the integer x-spans that are "inside" the polygon rings at
/// scanline `z`, using the even-odd (parity) rule.
///
/// The crossing test uses the same convention as `geo::Contains`:
/// an edge crosses the scanline when one endpoint is strictly above `z`
/// and the other is at or below.
fn compute_scanline_spans(
edges: &[ScanlineEdge],
z: f64,
min_x: i32,
max_x: i32,
) -> Vec<(i32, i32)> {
let mut xs: Vec<f64> = Vec::new();
for edge in edges {
// Crossing test: (z1 > z) != (z2 > z)
// Matches geo's convention (bottom-inclusive, top-exclusive).
if (edge.z1 > z) != (edge.z2 > z) {
let t = (z - edge.z1) / (edge.z2 - edge.z1);
xs.push(edge.x1 + t * (edge.x2 - edge.x1));
}
}
if xs.is_empty() {
return Vec::new();
}
xs.sort_unstable_by(|a, b| {
a.partial_cmp(b)
.expect("NaN encountered while sorting scanline intersections")
});
debug_assert!(
xs.len().is_multiple_of(2),
"Odd number of scanline crossings ({}) at z={}, possible malformed polygon",
xs.len(),
z
);
// Pair consecutive crossings into fill spans (even-odd rule)
let mut spans = Vec::with_capacity(xs.len() / 2);
let mut i = 0;
while i + 1 < xs.len() {
let start = (xs[i].ceil() as i32).max(min_x);
let end = (xs[i + 1].floor() as i32).min(max_x);
if start <= end {
spans.push((start, end));
}
i += 2;
}
spans
}
/// Merges two sorted, non-overlapping span lists into their union.
fn union_spans(a: &[(i32, i32)], b: &[(i32, i32)]) -> Vec<(i32, i32)> {
if a.is_empty() {
return b.to_vec();
}
if b.is_empty() {
return a.to_vec();
}
// Merge both sorted lists and combine overlapping/adjacent spans
let mut all: Vec<(i32, i32)> = Vec::with_capacity(a.len() + b.len());
all.extend_from_slice(a);
all.extend_from_slice(b);
all.sort_unstable_by_key(|&(start, _)| start);
let mut result: Vec<(i32, i32)> = Vec::new();
let mut current = all[0];
for &(start, end) in &all[1..] {
if start <= current.1 + 1 {
// Overlapping or adjacent, extend
current.1 = current.1.max(end);
} else {
result.push(current);
current = (start, end);
}
}
result.push(current);
result
}
/// Subtracts spans in `b` from spans in `a`.
///
/// Both inputs must be sorted and non-overlapping.
/// Returns sorted, non-overlapping spans representing `a \ b`.
fn subtract_spans(a: &[(i32, i32)], b: &[(i32, i32)]) -> Vec<(i32, i32)> {
if b.is_empty() {
return a.to_vec();
}
let mut result = Vec::new();
let mut bi = 0;
for &(a_start, a_end) in a {
let mut pos = a_start;
// Skip B spans that end before this A span starts
while bi < b.len() && b[bi].1 < a_start {
bi += 1;
}
// Walk through B spans that overlap with [pos .. a_end]
let mut j = bi;
while j < b.len() && b[j].0 <= a_end {
if b[j].0 > pos {
result.push((pos, (b[j].0 - 1).min(a_end)));
}
pos = pos.max(b[j].1 + 1);
j += 1;
}
if pos <= a_end {
result.push((pos, a_end));
}
}
result
}
/// Fills water blocks using scanline rasterization.
///
/// For each row z in [min_z, max_z], computes which x positions are inside
/// any outer polygon ring but outside all inner polygon rings, and places
/// water blocks at those positions.
#[allow(clippy::too_many_arguments)]
fn inverse_floodfill(
fn scanline_fill_water(
min_x: i32,
min_z: i32,
max_x: i32,
max_z: i32,
outers: Vec<Vec<XZPoint>>,
inners: Vec<Vec<XZPoint>>,
outers: &[Vec<XZPoint>],
inners: &[Vec<XZPoint>],
editor: &mut WorldEditor,
) {
// Convert to geo Polygons with normalized winding order
let inners: Vec<_> = inners
.into_iter()
.map(|x| {
Polygon::new(
LineString::from(
x.iter()
.map(|pt| (pt.x as f64, pt.z as f64))
.collect::<Vec<_>>(),
),
vec![],
)
.orient(Direction::Default)
})
.collect();
// Collect edges per outer ring so we can union their spans correctly,
// even if multiple outer rings happen to overlap (invalid OSM, but
// we handle it gracefully).
let outer_edge_groups: Vec<Vec<ScanlineEdge>> =
outers.iter().map(|ring| collect_ring_edges(ring)).collect();
let inner_edges = collect_all_ring_edges(inners);
let outers: Vec<_> = outers
.into_iter()
.map(|x| {
Polygon::new(
LineString::from(
x.iter()
.map(|pt| (pt.x as f64, pt.z as f64))
.collect::<Vec<_>>(),
),
vec![],
)
.orient(Direction::Default)
})
.collect();
for z in min_z..=max_z {
let z_f = z as f64;
inverse_floodfill_recursive((min_x, min_z), (max_x, max_z), &outers, &inners, editor);
}
fn inverse_floodfill_recursive(
min: (i32, i32),
max: (i32, i32),
outers: &[Polygon],
inners: &[Polygon],
editor: &mut WorldEditor,
) {
// Check if we've exceeded 40 seconds
// if start_time.elapsed().as_secs() > 40 {
// println!("Water area generation exceeded 40 seconds, continuing anyway");
// }
const ITERATIVE_THRES: i64 = 10_000;
if min.0 > max.0 || min.1 > max.1 {
return;
}
// Multiply as i64 to avoid overflow; in release builds where unchecked math is
// enabled, this could cause the rest of this code to end up in an infinite loop.
if ((max.0 - min.0) as i64) * ((max.1 - min.1) as i64) < ITERATIVE_THRES {
inverse_floodfill_iterative(min, max, 0, outers, inners, editor);
return;
}
let center_x: i32 = (min.0 + max.0) / 2;
let center_z: i32 = (min.1 + max.1) / 2;
let quadrants: [(i32, i32, i32, i32); 4] = [
(min.0, center_x, min.1, center_z),
(center_x, max.0, min.1, center_z),
(min.0, center_x, center_z, max.1),
(center_x, max.0, center_z, max.1),
];
for (min_x, max_x, min_z, max_z) in quadrants {
let rect: Rect = Rect::new(
Point::new(min_x as f64, min_z as f64),
Point::new(max_x as f64, max_z as f64),
);
if outers.iter().any(|outer: &Polygon| outer.contains(&rect))
&& !inners.iter().any(|inner: &Polygon| inner.intersects(&rect))
{
rect_fill(min_x, max_x, min_z, max_z, 0, editor);
// Compute spans for each outer ring and union them together
let mut outer_spans: Vec<(i32, i32)> = Vec::new();
for ring_edges in &outer_edge_groups {
let ring_spans = compute_scanline_spans(ring_edges, z_f, min_x, max_x);
if !ring_spans.is_empty() {
outer_spans = union_spans(&outer_spans, &ring_spans);
}
}
if outer_spans.is_empty() {
continue;
}
let outers_intersects: Vec<_> = outers
.iter()
.filter(|poly| poly.intersects(&rect))
.cloned()
.collect();
let inners_intersects: Vec<_> = inners
.iter()
.filter(|poly| poly.intersects(&rect))
.cloned()
.collect();
let fill_spans = if inner_edges.is_empty() {
outer_spans
} else {
let inner_spans = compute_scanline_spans(&inner_edges, z_f, min_x, max_x);
if inner_spans.is_empty() {
outer_spans
} else {
subtract_spans(&outer_spans, &inner_spans)
}
};
if !outers_intersects.is_empty() {
inverse_floodfill_recursive(
(min_x, min_z),
(max_x, max_z),
&outers_intersects,
&inners_intersects,
editor,
);
}
}
}
// once we "zoom in" enough, it's more efficient to switch to iteration
fn inverse_floodfill_iterative(
min: (i32, i32),
max: (i32, i32),
ground_level: i32,
outers: &[Polygon],
inners: &[Polygon],
editor: &mut WorldEditor,
) {
for x in min.0..max.0 {
for z in min.1..max.1 {
let p: Point = Point::new(x as f64, z as f64);
if outers.iter().any(|poly: &Polygon| poly.contains(&p))
&& inners.iter().all(|poly: &Polygon| !poly.contains(&p))
{
editor.set_block(WATER, x, ground_level, z, None, None);
for (start, end) in fill_spans {
for x in start..=end {
editor.set_block(WATER, x, 0, z, None, None);
}
}
}
}
fn rect_fill(
min_x: i32,
max_x: i32,
min_z: i32,
max_z: i32,
ground_level: i32,
editor: &mut WorldEditor,
) {
for x in min_x..max_x {
for z in min_z..max_z {
editor.set_block(WATER, x, ground_level, z, None, None);
}
}
}

View File

@@ -326,11 +326,6 @@ pub fn fetch_elevation_data(
Ok(tile_data) => successful_tiles.push(tile_data),
Err(e) => {
eprintln!("Warning: Failed to download tile: {e}");
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
&format!("Failed to download elevation tile: {e}"),
);
}
}
}

View File

@@ -1,13 +1,64 @@
use geo::orient::{Direction, Orient};
use geo::{Contains, LineString, Point, Polygon};
use itertools::Itertools;
use std::collections::{HashSet, VecDeque};
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// Maximum bounding box area (in blocks) for flood fill.
/// Polygons exceeding this are skipped to prevent multi-GB memory allocations.
/// 25 million blocks ≈ 5000×5000; HashSet would use ~700 MB at this size.
/// Polygons exceeding this are skipped to prevent excessive memory allocations.
/// 25 million blocks ≈ 5000×5000; bitmap uses only ~3 MB at this size.
const MAX_FLOOD_FILL_AREA: i64 = 25_000_000;
/// A compact bitmap for visited-coordinate tracking during flood fill.
///
/// Uses 1 bit per coordinate instead of ~48 bytes per entry in a `HashSet`.
/// For a 5000×5000 bounding box this is ~3 MB instead of ~1.2 GB.
struct FloodBitmap {
bits: Vec<u8>,
min_x: i32,
min_z: i32,
width: usize,
}
impl FloodBitmap {
#[inline]
fn new(min_x: i32, max_x: i32, min_z: i32, max_z: i32) -> Self {
let width = (max_x - min_x + 1) as usize;
let height = (max_z - min_z + 1) as usize;
let num_bytes = (width * height).div_ceil(8);
Self {
bits: vec![0u8; num_bytes],
min_x,
min_z,
width,
}
}
/// Mark (x, z) as visited. Returns `true` if it was NOT already visited
/// (i.e. this is the first visit).
#[inline]
fn insert(&mut self, x: i32, z: i32) -> bool {
let idx = (z - self.min_z) as usize * self.width + (x - self.min_x) as usize;
let byte = idx / 8;
let bit = idx % 8;
let mask = 1u8 << bit;
if self.bits[byte] & mask != 0 {
false // already visited
} else {
self.bits[byte] |= mask;
true
}
}
#[inline]
fn contains(&self, x: i32, z: i32) -> bool {
let idx = (z - self.min_z) as usize * self.width + (x - self.min_x) as usize;
let byte = idx / 8;
let bit = idx % 8;
(self.bits[byte] >> bit) & 1 == 1
}
}
/// Main flood fill function with automatic algorithm selection
/// Chooses the best algorithm based on polygon size and complexity
pub fn flood_fill_area(
@@ -62,15 +113,16 @@ fn optimized_flood_fill_area(
let start_time = Instant::now();
let mut filled_area = Vec::new();
let mut global_visited = HashSet::new();
let mut visited = FloodBitmap::new(min_x, max_x, min_z, max_z);
// Create polygon for containment testing
// Create polygon for containment testing, with normalized winding order
// to avoid "polygon had no winding order" warnings from geo::Contains
let exterior_coords: Vec<(f64, f64)> = polygon_coords
.iter()
.map(|&(x, z)| (x as f64, z as f64))
.collect();
let exterior = LineString::from(exterior_coords);
let polygon = Polygon::new(exterior, vec![]);
let polygon = Polygon::new(exterior, vec![]).orient(Direction::Default);
// Optimized step sizes: larger steps for efficiency, but still catch U-shapes
let width = max_x - min_x + 1;
@@ -93,16 +145,14 @@ fn optimized_flood_fill_area(
}
// Skip if already visited or not inside polygon
if global_visited.contains(&(x, z))
|| !polygon.contains(&Point::new(x as f64, z as f64))
{
if visited.contains(x, z) || !polygon.contains(&Point::new(x as f64, z as f64)) {
continue;
}
// Start flood fill from this seed point
queue.clear(); // Reuse queue instead of creating new one
queue.push_back((x, z));
global_visited.insert((x, z));
visited.insert(x, z);
while let Some((curr_x, curr_z)) = queue.pop_front() {
// Add current point to filled area
@@ -116,17 +166,16 @@ fn optimized_flood_fill_area(
(curr_x, curr_z + 1),
];
for (nx, nz) in neighbors.iter() {
if *nx >= min_x
&& *nx <= max_x
&& *nz >= min_z
&& *nz <= max_z
&& !global_visited.contains(&(*nx, *nz))
for &(nx, nz) in &neighbors {
if nx >= min_x
&& nx <= max_x
&& nz >= min_z
&& nz <= max_z
&& visited.insert(nx, nz)
{
// Only check polygon containment for unvisited points
if polygon.contains(&Point::new(*nx as f64, *nz as f64)) {
global_visited.insert((*nx, *nz));
queue.push_back((*nx, *nz));
if polygon.contains(&Point::new(nx as f64, nz as f64)) {
queue.push_back((nx, nz));
}
}
}
@@ -148,15 +197,16 @@ fn original_flood_fill_area(
) -> Vec<(i32, i32)> {
let start_time = Instant::now();
let mut filled_area: Vec<(i32, i32)> = Vec::new();
let mut global_visited: HashSet<(i32, i32)> = HashSet::new();
let mut visited = FloodBitmap::new(min_x, max_x, min_z, max_z);
// Convert input to a geo::Polygon for efficient point-in-polygon testing
// Convert input to a geo::Polygon for efficient point-in-polygon testing,
// with normalized winding order to avoid undefined Contains results
let exterior_coords: Vec<(f64, f64)> = polygon_coords
.iter()
.map(|&(x, z)| (x as f64, z as f64))
.collect::<Vec<_>>();
let exterior: LineString = LineString::from(exterior_coords);
let polygon: Polygon<f64> = Polygon::new(exterior, vec![]);
let polygon: Polygon<f64> = Polygon::new(exterior, vec![]).orient(Direction::Default);
// Optimized step sizes for large polygons - coarser sampling for speed
let width = max_x - min_x + 1;
@@ -180,16 +230,14 @@ fn original_flood_fill_area(
}
// Skip if already processed or not inside polygon
if global_visited.contains(&(x, z))
|| !polygon.contains(&Point::new(x as f64, z as f64))
{
if visited.contains(x, z) || !polygon.contains(&Point::new(x as f64, z as f64)) {
continue;
}
// Start flood-fill from this seed point
queue.clear(); // Reuse queue
queue.push_back((x, z));
global_visited.insert((x, z));
visited.insert(x, z);
while let Some((curr_x, curr_z)) = queue.pop_front() {
// Only check polygon containment once per point when adding to filled_area
@@ -204,15 +252,14 @@ fn original_flood_fill_area(
(curr_x, curr_z + 1),
];
for (nx, nz) in neighbors.iter() {
if *nx >= min_x
&& *nx <= max_x
&& *nz >= min_z
&& *nz <= max_z
&& !global_visited.contains(&(*nx, *nz))
for &(nx, nz) in &neighbors {
if nx >= min_x
&& nx <= max_x
&& nz >= min_z
&& nz <= max_z
&& visited.insert(nx, nz)
{
global_visited.insert((*nx, *nz));
queue.push_back((*nx, *nz));
queue.push_back((nx, nz));
}
}
}

View File

@@ -330,7 +330,6 @@ impl FloodFillCache {
fn way_needs_flood_fill(way: &ProcessedWay) -> bool {
way.tags.contains_key("building")
|| way.tags.contains_key("building:part")
|| way.tags.contains_key("boundary")
|| way.tags.contains_key("landuse")
|| way.tags.contains_key("leisure")
|| way.tags.contains_key("amenity")
@@ -342,6 +341,8 @@ impl FloodFillCache {
// Highway areas (like pedestrian plazas) use flood fill when area=yes
|| (way.tags.contains_key("highway")
&& way.tags.get("area").map(|v| v == "yes").unwrap_or(false))
// Historic tomb polygons (e.g. tomb=pyramid)
|| way.tags.get("tomb").map(|v| v == "pyramid").unwrap_or(false)
}
/// Collects all building footprint coordinates from the pre-computed cache.
@@ -370,7 +371,10 @@ impl FloodFillCache {
}
}
ProcessedElement::Relation(rel) => {
if rel.tags.contains_key("building") || rel.tags.contains_key("building:part") {
let is_building = rel.tags.contains_key("building")
|| rel.tags.contains_key("building:part")
|| rel.tags.get("type").map(|t| t.as_str()) == Some("building");
if is_building {
for member in &rel.members {
// Only treat outer members as building footprints.
// Inner members represent courtyards/holes where trees can spawn.
@@ -413,7 +417,10 @@ impl FloodFillCache {
}
}
ProcessedElement::Relation(rel) => {
if rel.tags.contains_key("building") || rel.tags.contains_key("building:part") {
let is_building = rel.tags.contains_key("building")
|| rel.tags.contains_key("building:part")
|| rel.tags.get("type").map(|t| t.as_str()) == Some("building");
if is_building {
// For building relations, compute centroid from outer ways
let mut all_coords = Vec::new();
for member in &rel.members {

2
src/gui/index.html vendored
View File

@@ -182,7 +182,7 @@
<div class="settings-row">
<label for="save-path-input">
<span data-localize="save_path">Save Path</span>
<span class="tooltip-icon" data-tooltip="Directory where new Minecraft worlds are created">?</span>
<span class="tooltip-icon" data-tooltip="Directory where new Minecraft Java worlds are created">?</span>
</label>
<div class="settings-control save-path-control">
<input type="text" id="save-path-input" name="save-path-input" class="save-path-input" placeholder="Minecraft saves directory">

View File

@@ -111,6 +111,7 @@ pub struct ProcessedWay {
pub enum ProcessedMemberRole {
Outer,
Inner,
Part,
}
#[derive(Debug, Clone, PartialEq)]
@@ -272,17 +273,24 @@ pub fn parse_osm_data(
continue;
};
// Process multipolygons and boundary relations
// Process multipolygons and building relations
let relation_type = tags.get("type").map(|x: &String| x.as_str());
if relation_type != Some("multipolygon") && relation_type != Some("boundary") {
if relation_type != Some("multipolygon") && relation_type != Some("building") {
continue;
};
let is_building_relation = relation_type == Some("building")
|| tags.contains_key("building")
|| tags.contains_key("building:part");
// Water relations require unclipped ways for ring merging in water_areas.rs
// Boundary relations also require unclipped ways for proper ring assembly
// Building multipolygon relations also need unclipped ways so that
// open outer-way segments can be merged into closed rings before clipping
let is_water_relation = is_water_element(tags);
let is_boundary_relation = tags.contains_key("boundary");
let keep_unclipped = is_water_relation || is_boundary_relation;
let is_building_multipolygon = (tags.contains_key("building")
|| tags.contains_key("building:part"))
&& relation_type == Some("multipolygon");
let keep_unclipped = is_water_relation || is_building_multipolygon;
let members: Vec<ProcessedMember> = element
.members
@@ -293,10 +301,25 @@ pub fn parse_osm_data(
return None;
}
let role = match mem.role.as_str() {
"outer" => ProcessedMemberRole::Outer,
"inner" => ProcessedMemberRole::Inner,
_ => return None,
let trimmed_role = mem.role.trim();
let role = if trimmed_role.eq_ignore_ascii_case("outer")
|| trimmed_role.eq_ignore_ascii_case("outline")
{
ProcessedMemberRole::Outer
} else if trimmed_role.eq_ignore_ascii_case("inner") {
ProcessedMemberRole::Inner
} else if trimmed_role.eq_ignore_ascii_case("part") {
if relation_type == Some("building") {
// "part" role only applies to type=building relations.
ProcessedMemberRole::Part
} else {
// For multipolygon relations, "part" is not a valid role, skip.
return None;
}
} else if is_building_relation {
ProcessedMemberRole::Outer
} else {
return None;
};
// Check if the way exists in ways_map
@@ -308,8 +331,8 @@ pub fn parse_osm_data(
}
};
// Water and boundary relations: keep unclipped for ring merging
// Other relations: clip member ways now
// If keep_unclipped is true (e.g., certain water or building multipolygon
// relations), keep member ways unclipped for ring merging; otherwise clip now.
let final_way = if keep_unclipped {
way
} else {

View File

@@ -4,7 +4,7 @@ use crate::progress::{emit_gui_error, emit_gui_progress_update, is_running_with_
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use colored::Colorize;
use rand::seq::SliceRandom;
use rand::prelude::IndexedRandom;
use reqwest::blocking::Client;
use reqwest::blocking::ClientBuilder;
use serde::Deserialize;
@@ -117,7 +117,7 @@ pub fn fetch_data_from_overpass(
];
let fallback_api_servers: Vec<&str> =
vec!["https://maps.mail.ru/osm/tools/overpass/api/interpreter"];
let mut url: &&str = api_servers.choose(&mut rand::thread_rng()).unwrap();
let mut url: &&str = api_servers.choose(&mut rand::rng()).unwrap();
// Generate Overpass API query for bounding box
let query: String = format!(
@@ -139,7 +139,6 @@ pub fn fetch_data_from_overpass(
nwr["barrier"];
nwr["entrance"];
nwr["door"];
nwr["boundary"];
nwr["power"];
nwr["historic"];
nwr["emergency"];
@@ -186,9 +185,7 @@ pub fn fetch_data_from_overpass(
}
println!("Request failed. Switching to fallback url...");
url = fallback_api_servers
.choose(&mut rand::thread_rng())
.unwrap();
url = fallback_api_servers.choose(&mut rand::rng()).unwrap();
attempt += 1;
}
}

View File

@@ -663,7 +663,7 @@ impl BedrockWriter {
for z in 0..16usize {
for y in 0..16usize {
let internal_idx = y * 256 + z * 16 + x;
let block = section.blocks[internal_idx];
let block = section.get_block_at_index(internal_idx);
// Get stored properties for this block position (if any)
let properties = section.properties.get(&internal_idx);

View File

@@ -54,9 +54,118 @@ pub(crate) struct PaletteItem {
pub properties: Option<Value>,
}
/// Block storage strategy for a 16×16×16 section.
///
/// **Memory optimisation**: instead of always allocating a 4 096-byte array,
/// we distinguish two cases:
///
/// * `Uniform(block)` every position holds the same block (1 byte).
/// This covers freshly-created (all-AIR) sections, and sections that were
/// entirely filled with one type (e.g. STONE underground with `--fillground`).
///
/// * `Full(Vec<Block>)` the general case, equivalent to the old `[Block; 4096]`
/// but heap-allocated via `Vec` so the *inline* size inside the parent
/// `FnvHashMap` entry is only 24 bytes (pointer + length + capacity) instead
/// of 4 096 bytes. This eliminates huge HashMap-slot waste from unused
/// capacity slots.
pub(crate) enum BlockStorage {
/// Every position is the same block (commonly AIR).
Uniform(Block),
/// Mixed blocks always exactly 4 096 entries.
Full(Vec<Block>),
}
impl BlockStorage {
/// Read block at flat `index` (0..4095).
#[inline(always)]
pub fn get(&self, index: usize) -> Block {
match self {
BlockStorage::Uniform(b) => *b,
BlockStorage::Full(v) => v[index],
}
}
/// Write block at flat `index`.
/// Promotes `Uniform` → `Full` on the first differing write.
#[inline]
pub fn set(&mut self, index: usize, block: Block) {
match self {
BlockStorage::Uniform(b) if *b == block => {
// No-op writing the same value.
}
BlockStorage::Uniform(base) => {
let base = *base;
let mut v = vec![base; 4096];
v[index] = block;
*self = BlockStorage::Full(v);
}
BlockStorage::Full(v) => {
v[index] = block;
}
}
}
/// Iterate over all 4 096 blocks.
#[inline]
pub fn iter(&self) -> BlockStorageIter<'_> {
match self {
BlockStorage::Uniform(b) => BlockStorageIter::Uniform(*b, 0),
BlockStorage::Full(v) => BlockStorageIter::Full(v.iter()),
}
}
/// Try to collapse a `Full` vec back to `Uniform` if every entry
/// is the same block. Frees the 4 KiB heap allocation.
pub fn try_compact(&mut self) {
if let BlockStorage::Full(v) = self {
if let Some(&first) = v.first() {
if v.iter().all(|&b| b == first) {
*self = BlockStorage::Uniform(first);
}
}
}
}
}
/// Iterator returned by [`BlockStorage::iter`].
pub(crate) enum BlockStorageIter<'a> {
Uniform(Block, usize),
Full(std::slice::Iter<'a, Block>),
}
impl<'a> Iterator for BlockStorageIter<'a> {
type Item = Block;
#[inline]
fn next(&mut self) -> Option<Block> {
match self {
BlockStorageIter::Uniform(b, count) => {
if *count < 4096 {
*count += 1;
Some(*b)
} else {
None
}
}
BlockStorageIter::Full(it) => it.next().copied(),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let rem = match self {
BlockStorageIter::Uniform(_, c) => 4096 - *c,
BlockStorageIter::Full(it) => it.len(),
};
(rem, Some(rem))
}
}
impl ExactSizeIterator for BlockStorageIter<'_> {}
/// A section being modified (16x16x16 blocks)
pub(crate) struct SectionToModify {
pub blocks: [Block; 4096],
pub storage: BlockStorage,
/// Store properties for blocks that have them, indexed by the same index as blocks array
pub properties: FnvHashMap<usize, Value>,
}
@@ -64,7 +173,7 @@ pub(crate) struct SectionToModify {
impl SectionToModify {
#[inline]
pub fn get_block(&self, x: u8, y: u8, z: u8) -> Option<Block> {
let b = self.blocks[Self::index(x, y, z)];
let b = self.storage.get(Self::index(x, y, z));
if b == AIR {
return None;
}
@@ -73,7 +182,9 @@ impl SectionToModify {
#[inline]
pub fn set_block(&mut self, x: u8, y: u8, z: u8, block: Block) {
self.blocks[Self::index(x, y, z)] = block;
let index = Self::index(x, y, z);
self.storage.set(index, block);
self.properties.remove(&index);
}
#[inline]
@@ -85,7 +196,7 @@ impl SectionToModify {
block_with_props: BlockWithProperties,
) {
let index = Self::index(x, y, z);
self.blocks[index] = block_with_props.block;
self.storage.set(index, block_with_props.block);
// Store properties if they exist
if let Some(props) = block_with_props.properties {
@@ -96,20 +207,56 @@ impl SectionToModify {
}
}
/// Read block at a raw flat index (used by Bedrock serialiser).
#[inline(always)]
pub fn get_block_at_index(&self, index: usize) -> Block {
self.storage.get(index)
}
/// Calculate index from coordinates (YZX order)
#[inline(always)]
pub fn index(x: u8, y: u8, z: u8) -> usize {
usize::from(y) % 16 * 256 + usize::from(z) * 16 + usize::from(x)
}
/// Try to collapse the block array back to `Uniform` if every entry
/// is the same block and there are no properties.
pub fn compact(&mut self) {
if self.properties.is_empty() {
self.storage.try_compact();
}
}
/// Convert to Java Edition section format
pub fn to_section(&self, y: i8) -> Section {
// Fast path: Uniform section → single palette entry, no data array needed.
// Only valid when no per-index properties exist, otherwise we must
// fall through to the general path so every index is checked.
if self.properties.is_empty() {
if let BlockStorage::Uniform(block) = &self.storage {
let palette_item = PaletteItem {
name: format!("{}:{}", block.namespace(), block.name()),
properties: block.properties(),
};
return Section {
block_states: Blockstates {
palette: vec![palette_item],
data: None,
other: FnvHashMap::default(),
},
y,
other: FnvHashMap::default(),
};
}
}
// General path: mixed blocks.
// Create a map of unique block+properties combinations to palette indices
let mut unique_blocks: Vec<(Block, Option<Value>)> = Vec::new();
let mut palette_lookup: FnvHashMap<(Block, Option<String>), usize> = FnvHashMap::default();
// Build unique block combinations and lookup table
for (i, &block) in self.blocks.iter().enumerate() {
for (i, block) in self.storage.iter().enumerate() {
let properties = self.properties.get(&i).cloned();
// Create a key for the lookup (block + properties hash)
@@ -132,7 +279,7 @@ impl SectionToModify {
let mut cur = 0;
let mut cur_idx = 0;
for (i, &block) in self.blocks.iter().enumerate() {
for (i, block) in self.storage.iter().enumerate() {
let properties = self.properties.get(&i).cloned();
let props_key = properties.as_ref().map(|p| format!("{p:?}"));
let lookup_key = (block, props_key);
@@ -175,7 +322,7 @@ impl SectionToModify {
impl Default for SectionToModify {
fn default() -> Self {
Self {
blocks: [AIR; 4096],
storage: BlockStorage::Uniform(AIR),
properties: FnvHashMap::default(),
}
}
@@ -245,7 +392,7 @@ impl RegionToModify {
}
}
/// The entire world being modified
/// The entire world being modified.
#[derive(Default)]
pub(crate) struct WorldToModify {
pub regions: FnvHashMap<(i32, i32), RegionToModify>,
@@ -271,7 +418,6 @@ impl WorldToModify {
let region: &RegionToModify = self.get_region(region_x, region_z)?;
let chunk: &ChunkToModify = region.get_chunk(chunk_x & 31, chunk_z & 31)?;
chunk.get_block(
(x & 15).try_into().unwrap(),
y,
@@ -288,7 +434,6 @@ impl WorldToModify {
let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z);
let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
chunk.set_block(
(x & 15).try_into().unwrap(),
y,
@@ -312,7 +457,6 @@ impl WorldToModify {
let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z);
let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
chunk.set_block_with_properties(
(x & 15).try_into().unwrap(),
y,
@@ -320,4 +464,100 @@ impl WorldToModify {
block_with_props,
);
}
/// Set a block only if the position is currently empty (AIR / absent).
///
/// This avoids the double HashMap traversal of `get_block()` + `set_block()`
/// which is the hot path in ground generation and many element processors.
#[inline]
pub fn set_block_if_absent(&mut self, x: i32, y: i32, z: i32, block: Block) {
let chunk_x: i32 = x >> 4;
let chunk_z: i32 = z >> 4;
let region_x: i32 = chunk_x >> 5;
let region_z: i32 = chunk_z >> 5;
let region = self.regions.entry((region_x, region_z)).or_default();
let chunk = region
.chunks
.entry((chunk_x & 31, chunk_z & 31))
.or_default();
// Clamp Y
let y = y.clamp(MIN_Y, MAX_Y);
let section_idx: i8 = (y >> 4) as i8;
let section = chunk.sections.entry(section_idx).or_default();
let local_x = (x & 15) as u8;
let local_y = (y & 15) as u8;
let local_z = (z & 15) as u8;
let idx = SectionToModify::index(local_x, local_y, local_z);
// Only write if the current block is AIR
if section.storage.get(idx) == AIR {
section.storage.set(idx, block);
// Clear any stale properties from a previous block at this position
section.properties.remove(&idx);
}
}
/// Fill an entire column (single x, z) from y_min to y_max with the same block,
/// resolving region/chunk only once. Used by ground generation.
#[inline]
pub fn fill_column(
&mut self,
x: i32,
z: i32,
y_min: i32,
y_max: i32,
block: Block,
skip_existing: bool,
) {
let chunk_x: i32 = x >> 4;
let chunk_z: i32 = z >> 4;
let region_x: i32 = chunk_x >> 5;
let region_z: i32 = chunk_z >> 5;
let region = self.regions.entry((region_x, region_z)).or_default();
let chunk = region
.chunks
.entry((chunk_x & 31, chunk_z & 31))
.or_default();
let local_x = (x & 15) as u8;
let local_z = (z & 15) as u8;
let y_min = y_min.clamp(MIN_Y, MAX_Y);
let y_max = y_max.clamp(MIN_Y, MAX_Y);
for y in y_min..=y_max {
let section_idx: i8 = (y >> 4) as i8;
let section = chunk.sections.entry(section_idx).or_default();
let local_y = (y & 15) as u8;
let idx = SectionToModify::index(local_x, local_y, local_z);
if skip_existing {
if section.storage.get(idx) == AIR {
section.storage.set(idx, block);
section.properties.remove(&idx);
}
} else {
section.storage.set(idx, block);
section.properties.remove(&idx);
}
}
}
/// Scan every section and collapse any that are entirely one block type
/// from `Full(Vec)` back to `Uniform(Block)`, freeing the 4 KiB allocation.
pub fn compact_sections(&mut self) {
for region in self.regions.values_mut() {
for chunk in region.chunks.values_mut() {
for section in chunk.sections.values_mut() {
if matches!(&section.storage, BlockStorage::Full(_)) {
section.compact();
}
}
}
}
}
}

View File

@@ -75,8 +75,10 @@ pub struct WorldEditor<'a> {
ground: Option<Arc<Ground>>,
format: WorldFormat,
/// Optional level name for Bedrock worlds (e.g., "Arnis World: New York City")
#[cfg(feature = "bedrock")]
bedrock_level_name: Option<String>,
/// Optional spawn point for Bedrock worlds (x, z coordinates)
#[cfg(feature = "bedrock")]
bedrock_spawn_point: Option<(i32, i32)>,
}
@@ -93,7 +95,9 @@ impl<'a> WorldEditor<'a> {
llbbox,
ground: None,
format: WorldFormat::JavaAnvil,
#[cfg(feature = "bedrock")]
bedrock_level_name: None,
#[cfg(feature = "bedrock")]
bedrock_spawn_point: None,
}
}
@@ -107,8 +111,12 @@ impl<'a> WorldEditor<'a> {
xzbbox: &'a XZBBox,
llbbox: LLBBox,
format: WorldFormat,
bedrock_level_name: Option<String>,
bedrock_spawn_point: Option<(i32, i32)>,
#[cfg_attr(not(feature = "bedrock"), allow(unused_variables))] bedrock_level_name: Option<
String,
>,
#[cfg_attr(not(feature = "bedrock"), allow(unused_variables))] bedrock_spawn_point: Option<
(i32, i32),
>,
) -> Self {
Self {
world_dir,
@@ -117,7 +125,9 @@ impl<'a> WorldEditor<'a> {
llbbox,
ground: None,
format,
#[cfg(feature = "bedrock")]
bedrock_level_name,
#[cfg(feature = "bedrock")]
bedrock_spawn_point,
}
}
@@ -604,45 +614,6 @@ impl<'a> WorldEditor<'a> {
}
}
/// Fills a cuboid area with the specified block between two coordinates using absolute Y values.
#[allow(clippy::too_many_arguments)]
#[inline]
pub fn fill_blocks_absolute(
&mut self,
block: Block,
x1: i32,
y1_absolute: i32,
z1: i32,
x2: i32,
y2_absolute: i32,
z2: i32,
override_whitelist: Option<&[Block]>,
override_blacklist: Option<&[Block]>,
) {
let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) };
let (min_y, max_y) = if y1_absolute < y2_absolute {
(y1_absolute, y2_absolute)
} else {
(y2_absolute, y1_absolute)
};
let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) };
for x in min_x..=max_x {
for absolute_y in min_y..=max_y {
for z in min_z..=max_z {
self.set_block_absolute(
block,
x,
absolute_y,
z,
override_whitelist,
override_blacklist,
);
}
}
}
}
/// Checks for a block at the given coordinates.
#[inline]
pub fn check_for_block(&self, x: i32, y: i32, z: i32, whitelist: Option<&[Block]>) -> bool {
@@ -706,6 +677,40 @@ impl<'a> WorldEditor<'a> {
self.world.get_block(x, absolute_y, z).is_some()
}
/// Sets a block only if no modification has been recorded yet at this
/// position (i.e. the in-memory overlay still holds AIR).
///
/// This is faster than `set_block_absolute` with `None` whitelists/blacklists
/// because it avoids the double HashMap traversal.
#[inline]
pub fn set_block_if_absent_absolute(&mut self, block: Block, x: i32, absolute_y: i32, z: i32) {
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
self.world.set_block_if_absent(x, absolute_y, z, block);
}
/// Fills an entire column from y_min to y_max with one block type.
///
/// Resolves region/chunk once instead of per-Y-level, making underground
/// fill (`--fillground`) dramatically faster.
#[inline]
pub fn fill_column_absolute(
&mut self,
block: Block,
x: i32,
z: i32,
y_min: i32,
y_max: i32,
skip_existing: bool,
) {
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
self.world
.fill_column(x, z, y_min, y_max, block, skip_existing);
}
/// Saves all changes made to the world by writing to the appropriate format.
pub fn save(&mut self) {
println!(
@@ -716,6 +721,10 @@ impl<'a> WorldEditor<'a> {
}
);
// Compact sections before saving: collapses uniform Full(Vec) sections
// (e.g. all-STONE from --fillground) back to Uniform, freeing ~4 KiB each.
self.world.compact_sections();
match self.format {
WorldFormat::JavaAnvil => self.save_java(),
WorldFormat::BedrockMcWorld => self.save_bedrock(),

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Arnis",
"version": "2.4.1",
"version": "2.5.0",
"identifier": "com.louisdev.arnis",
"build": {
"frontendDist": "src/gui"