142 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
Louis Erbkamm
489e571a42 Merge pull request #745 from louis-e/security-updates-dependabot
security: Update time to 0.3.47 and reqwest to 0.13.2 (Dependabot fixes)
2026-02-09 14:05:18 +01:00
louis-e
67e22a574a security: Update time to 0.3.47 and reqwest to 0.13.2
- Update time from 0.3.37 to 0.3.47
  - Fixes stack exhaustion DoS vulnerability in RFC 2822 parsing (CVE-2026-XXXX)
  - Adds recursion depth tracking to prevent unbounded recursion

- Update reqwest from 0.12.15 to 0.13.2
  - Switch TLS backend from native-tls to rustls with aws-lc crypto provider
  - Improves TLS security and platform compatibility
  - Add 'query' feature to maintain URL query parameter functionality

- Update related dependencies:
  - time-core 0.1.2 -> 0.1.8
  - time-macros 0.2.19 -> 0.2.27
  - deranged 0.3.11 -> 0.5.5
  - num-conv 0.1.0 -> 0.2.0
  - hyper 1.5.2 -> 1.8.1
  - hyper-util 0.1.10 -> 0.1.19
  - rustls 0.23.20 -> 0.23.36

Resolves Dependabot PRs #743 and #733
Tested: cargo check and cargo check --release pass successfully
2026-02-09 14:03:31 +01:00
Louis Erbkamm
c094db8464 Merge pull request #744 from louis-e/logic-changes-and-fixes
Logic changes and fixes
2026-02-09 12:41:40 +01:00
louis-e
9be9104c8d Address code review feedback 2026-02-09 12:38:10 +01:00
louis-e
8c0f0cc366 Address code review feedback 2026-02-09 11:55:53 +01:00
louis-e
403469dcb5 Address code review feedback 2026-02-09 11:44:40 +01:00
louis-e
e0674823fd Address code review feedback 2026-02-09 00:19:23 +01:00
louis-e
438328ec28 Address code review feedback 2026-02-08 23:09:23 +01:00
louis-e
d2a8f09487 Refactor world saving logic 2026-02-08 22:59:16 +01:00
louis-e
318ab1e26c Add floodfill limit 2026-02-08 21:56:12 +01:00
Louis Erbkamm
6adb8d050e Merge pull request #742 from louis-e/buildings-improvement
Buildings improvement
2026-02-08 19:43:17 +01:00
louis-e
0cc32e70b9 Fix minor details 2026-02-08 19:43:04 +01:00
louis-e
ed07de68a6 Fix balcony chair facing direction 2026-02-08 19:35:48 +01:00
louis-e
0fda04f2be Address code review feedback 2026-02-08 18:29:22 +01:00
louis-e
8598a1847b Address code review feedback 2026-02-08 18:19:03 +01:00
Louis Erbkamm
2422786607 Merge pull request #738 from louis-e/dependabot/cargo/cargo-f6ecf5c85a
Bump bytes from 1.9.0 to 1.11.1 in the cargo group across 1 directory
2026-02-08 18:17:32 +01:00
louis-e
4e2d886077 Address code review feedback 2026-02-08 17:22:53 +01:00
louis-e
4299863410 Address code review feedback 2026-02-08 16:57:54 +01:00
louis-e
b316f95030 Add balconies 2026-02-08 16:37:18 +01:00
louis-e
7cc53434a7 Window sills and shutters 2026-02-08 15:20:56 +01:00
louis-e
6bd17c937d Add more building structs and flat roof variety 2026-02-07 20:42:16 +01:00
louis-e
cf198f9e93 Add more tree variations 2026-02-07 17:20:17 +01:00
louis-e
bedf2b763a Address code review feedback 2026-02-07 15:50:43 +01:00
louis-e
02823134df Fix cargo fmt and clippy 2026-02-07 14:18:32 +01:00
louis-e
36e1c04e6f Add more predefined building structs 2026-02-07 14:11:02 +01:00
louis-e
333ed52e28 Add place tag handling 2026-02-07 01:02:11 +01:00
louis-e
e9c8f203a7 Don't use oak planks for building material 2026-02-07 00:56:57 +01:00
louis-e
87069665fe Improve coverage of roof generation 2026-02-07 00:40:29 +01:00
louis-e
5bfb8606e2 Initial changes for improving buildings 2026-02-06 23:12:01 +01:00
Louis Erbkamm
44023f99e2 Merge pull request #734 from louis-e/dependabot/cargo/tauri-plugin-shell-2.3.0 2026-02-04 20:49:44 +01:00
dependabot[bot]
3aad272c20 Bump bytes from 1.9.0 to 1.11.1 in the cargo group across 1 directory
Bumps the cargo group with 1 update in the / directory: [bytes](https://github.com/tokio-rs/bytes).


Updates `bytes` from 1.9.0 to 1.11.1
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.9.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:17:25 +00:00
louis-e
9f47b0269b Add chimneys 2026-02-02 19:51:38 +01:00
Louis Erbkamm
c7e1fec02c Merge pull request #736 from louis-e/more-processors
More processors
2026-02-01 20:24:49 +01:00
louis-e
fb05e2f2b8 Implement UrbanGroundLookup 2026-02-01 19:42:01 +01:00
louis-e
7015cfff5f Correct comments 2026-02-01 19:30:10 +01:00
louis-e
78ca5a49ce Address code review feedback 2026-02-01 19:19:51 +01:00
louis-e
e265f8fa7e Address code review feedback 2026-02-01 19:07:33 +01:00
louis-e
552f4ab013 Abandon boundary element and add rubber band detection 2026-02-01 19:01:15 +01:00
louis-e
319eb656ee GUI option to disable boundary element 2026-02-01 16:40:47 +01:00
louis-e
11a756ab06 Fix overflow 2026-02-01 16:21:39 +01:00
louis-e
0f93853dcb Enhance urban densitiy calculation speed 2026-02-01 16:13:25 +01:00
louis-e
b4c47f559c Address code review feedback 2026-02-01 16:03:26 +01:00
louis-e
a86e23129b Address code review feedback 2026-02-01 15:52:15 +01:00
louis-e
69b30ef59f Fix underground building:part and cargo fmt 2026-02-01 15:51:21 +01:00
louis-e
1733f5d664 Enhance urban density grid 2026-02-01 15:39:01 +01:00
louis-e
e6b6de27ff Determine boundary validity 2026-02-01 14:12:33 +01:00
louis-e
ac0fc275dc Enhance OSM query 2026-02-01 13:46:52 +01:00
louis-e
de1f52bfaf Fix power base 2026-02-01 13:15:13 +01:00
dependabot[bot]
851aec71d0 Bump tauri-plugin-shell from 2.2.1 to 2.3.0
Bumps [tauri-plugin-shell](https://github.com/tauri-apps/plugins-workspace) from 2.2.1 to 2.3.0.
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/os-v2.2.1...os-v2.3.0)

---
updated-dependencies:
- dependency-name: tauri-plugin-shell
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 02:48:30 +00:00
louis-e
03cc86f3e2 Add more element processors 2026-02-01 03:14:45 +01:00
Louis Erbkamm
d4af5ce7ef Merge pull request #732 from louis-e/copy-coords
Copy coordinates on right click
2026-02-01 01:09:08 +01:00
louis-e
382ab19a0d Remove keyframe animation 2026-02-01 01:08:51 +01:00
louis-e
38678deefc Copy coordinates on right click 2026-02-01 01:04:37 +01:00
Louis Erbkamm
674a2d9656 Merge pull request #731 from louis-e/add-boundaries
Add boundaries for city ground blocks
2026-02-01 00:23:16 +01:00
louis-e
c4ad3dd61a Fix cargo fmt 2026-02-01 00:21:11 +01:00
louis-e
e42bc121fa Address code review feedback 2026-02-01 00:18:14 +01:00
louis-e
19da1fe55d Address code review feedback 2026-02-01 00:02:11 +01:00
louis-e
663394f3b5 Remove debug files 2026-01-31 23:55:03 +01:00
louis-e
bef7bd3965 Fix wrong clipping of boundaries 2026-01-31 23:54:24 +01:00
Louis Erbkamm
d8e1b29146 Merge branch 'main' into add-boundaries 2026-01-31 18:56:46 +01:00
louis-e
a9f53b2cd6 Add boundaries for city ground blocks 2026-01-31 18:56:25 +01:00
Louis Erbkamm
516b9ecf33 Merge pull request #730 from louis-e/save-faster
Improve saving
2026-01-31 18:42:14 +01:00
louis-e
babe610cca Remove unused vars 2026-01-31 18:21:36 +01:00
louis-e
20922a3be6 Improve saving 2026-01-31 18:18:20 +01:00
Louis Erbkamm
f1dc3b8ffb Merge pull request #675 from wielandb/entities
Add recycling containers (and ability to add entities during world generation)
2026-01-27 18:23:14 +01:00
Louis Erbkamm
602767c1d1 Remove unreachable wheat seeds in amenities 2026-01-27 17:41:49 +01:00
Louis Erbkamm
96e6d9e129 Fix Bedrock LevelDB lock error 2026-01-27 17:32:37 +01:00
Louis Erbkamm
c722ea689f Fix typos in interior comments 2026-01-27 17:31:51 +01:00
Louis Erbkamm
f473e980a2 Merge pull request #662 from amir16yp/enable_structures
set generate_features to 1 in level.dat nbt template
2026-01-27 16:46:43 +01:00
Wieland
1901c21049 Merge branch 'wielandb-modified' into entities 2026-01-26 00:07:30 +01:00
Wieland
bc41838671 Resolve merge conflicts 2026-01-25 23:57:20 +01:00
Wieland
11de6cfd85 Resolve merge conflict in wkt.parser.js 2026-01-25 23:45:27 +01:00
Wieland
92f629fc96 Merge pull request #2 from wielandb/buildings-enhancements
[to modified] Buildings enhancements
2026-01-25 23:37:18 +01:00
Wieland
880d86971d Refactor abandoned building checks and comments
Replaces map_or with is_some_and for abandoned tag check in generate_buildings for clarity. Also updates a doc comment to a regular comment in buildings_interior.rs for consistency.
2026-01-25 23:31:04 +01:00
Wieland
1421247ea4 run cargo fmt 2026-01-25 23:24:59 +01:00
Wieland
1b21dec366 Merge origin/main into buildings-enhancements 2026-01-25 23:20:18 +01:00
Wieland
c9a9d55f76 Merge pull request #1 from wielandb/trees
[to modified] Change what trees are generated based on tag content
2026-01-25 23:10:40 +01:00
Wieland
53846a7b5a cargo fmt check 2026-01-25 22:58:45 +01:00
Wieland
0593615909 Merge branch 'wielandb-modified' into trees 2026-01-25 22:52:29 +01:00
Louis Erbkamm
f79b610c0d Merge pull request #721 from louis-e/minor-improvements
Minor fixes and improvements
2026-01-20 20:57:25 +01:00
louis-e
c62600e972 Fix jumping progress bar 2026-01-20 20:56:46 +01:00
louis-e
225cb79381 Feature gate send_log 2026-01-20 20:35:48 +01:00
louis-e
9fd1868d41 Parallelize terrain processing 2026-01-20 19:47:16 +01:00
louis-e
ceb0c80fba Log unexpected request errors to telemetry 2026-01-20 19:33:34 +01:00
louis-e
6444a4498a Save Bedrock .mcworld to Desktop, enable Info-level logging 2026-01-20 19:33:23 +01:00
louis-e
6ef8169d45 Add tile download retry, optimize outlier filtering, add cache cleanup 2026-01-20 19:33:04 +01:00
louis-e
568a6063f7 UI improvements and tile cache max age 2026-01-20 19:06:50 +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
Wieland
ceec7cc190 Use roof:orientation if available 2025-12-17 14:17:22 +01:00
Wieland
d876f5ce60 Corrections on abandoned building interiors 2025-12-17 14:02:15 +01:00
Wieland
d3a416754d Fix min_level and level interaction and make special interior for abandoned buildings 2025-12-17 00:39:26 +01:00
Wieland
fc6c2a255f First steps for generic abandoned buildings 2025-12-16 20:44:42 +01:00
Wieland
3b70694167 Make single-item recycling containers show a sign of what is thrown away there 2025-12-15 17:38:16 +01:00
Wieland
e5f0b1050a Add generation of recycling containers 2025-12-15 10:46:20 +01:00
Wieland
a0fd0c12e2 Update from linting suggestions 2025-12-14 09:08:30 +01:00
Wieland
9b87e3538a Change what trees are generated based on tag content 2025-12-14 08:30:32 +01:00
Wieland
46959365df Add ability to add entities 2025-12-13 22:50:16 +01:00
Amir Y. Perehodnik
f57d14b200 set generate_features to 1 in level.dat nbt 2025-12-07 18:47:09 +02:00
63 changed files with 12168 additions and 4175 deletions

3143
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

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"
@@ -14,8 +14,8 @@ overflow-checks = true
[features]
default = ["gui"]
gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "dirs", "tauri-build", "bedrock"]
bedrock = ["bedrockrs_level", "bedrockrs_shared", "nbtx", "zip", "byteorder", "vek"]
gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "tauri-build", "bedrock"]
bedrock = ["bedrockrs_level", "bedrockrs_shared", "nbtx", "zip", "byteorder", "vek", "rusty-leveldb"]
[build-dependencies]
tauri-build = {version = "2", optional = true}
@@ -23,9 +23,9 @@ 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 = {version = "6.0.0", optional = true }
dirs = "6.0.0"
fastanvil = "0.32.0"
fastnbt = "2.6.0"
flate2 = "1.1"
@@ -35,12 +35,13 @@ 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.12.15", features = ["blocking", "json"] }
reqwest = { version = "0.13.1", features = ["blocking", "json", "query"] }
rfd = { version = "0.16.0", optional = true }
semver = "1.0.27"
serde = { version = "1.0", features = ["derive"] }
@@ -54,9 +55,10 @@ bedrockrs_shared = { git = "https://github.com/bedrock-crustaceans/bedrock-rs",
nbtx = { git = "https://github.com/bedrock-crustaceans/nbtx", optional = true }
vek = { version = "0.17", optional = true }
zip = { version = "0.6", default-features = false, features = ["deflate"], optional = true }
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">

View File

Binary file not shown.

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

@@ -19,9 +19,14 @@ pub struct Args {
#[arg(long, group = "location")]
pub save_json_file: Option<String>,
/// Path to the Minecraft world (required)
#[arg(long, value_parser = validate_minecraft_world_path)]
pub path: PathBuf,
/// Output directory for the generated world (required for Java, optional for Bedrock).
/// Use --output-dir (or the deprecated --path alias) to specify where the world is created.
#[arg(long = "output-dir", alias = "path")]
pub path: Option<PathBuf>,
/// Generate a Bedrock Edition world (.mcworld) instead of Java Edition
#[arg(long)]
pub bedrock: bool,
/// Downloader method (requests/curl/wget) (optional)
#[arg(long, default_value = "requests")]
@@ -40,17 +45,23 @@ pub struct Args {
pub terrain: bool,
/// Enable interior generation (optional)
#[arg(long, default_value_t = true, action = clap::ArgAction::SetTrue)]
#[arg(long, default_value_t = true)]
pub interior: bool,
/// Enable roof generation (optional)
#[arg(long, default_value_t = true, action = clap::ArgAction::SetTrue)]
#[arg(long, default_value_t = true)]
pub roof: bool,
/// Enable filling ground (optional)
#[arg(long, default_value_t = false, action = clap::ArgAction::SetFalse)]
#[arg(long, default_value_t = false)]
pub fillground: bool,
/// 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)]
pub city_boundaries: bool,
/// Enable debug mode (optional)
#[arg(long)]
pub debug: bool,
@@ -60,19 +71,41 @@ pub struct Args {
pub timeout: Option<Duration>,
}
fn validate_minecraft_world_path(path: &str) -> Result<PathBuf, String> {
let mc_world_path = PathBuf::from(path);
if !mc_world_path.exists() {
return Err(format!("Path does not exist: {path}"));
/// Validates CLI arguments after parsing.
/// For Java Edition: `--path` is required and must point to an existing directory
/// where a new world will be created automatically.
/// For Bedrock Edition (`--bedrock`): `--path` is optional (defaults to Desktop output).
pub fn validate_args(args: &Args) -> Result<(), String> {
if args.bedrock {
// Bedrock: path is optional; if provided, it must be an existing directory
if let Some(ref path) = args.path {
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Path is not a directory: {}", path.display()));
}
}
} else {
// Java: path is required and must be an existing directory
match &args.path {
None => {
return Err(
"The --output-dir argument is required for Java Edition. Provide the directory where the world should be created. Use --bedrock for Bedrock Edition output."
.to_string(),
);
}
Some(ref path) => {
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Path is not a directory: {}", path.display()));
}
}
}
}
if !mc_world_path.is_dir() {
return Err(format!("Path is not a directory: {path}"));
}
let region = mc_world_path.join("region");
if !region.is_dir() {
return Err(format!("No Minecraft world found at {region:?}"));
}
Ok(mc_world_path)
Ok(())
}
fn parse_duration(arg: &str) -> Result<std::time::Duration, std::num::ParseIntError> {
@@ -84,22 +117,15 @@ fn parse_duration(arg: &str) -> Result<std::time::Duration, std::num::ParseIntEr
mod tests {
use super::*;
fn minecraft_tmpdir() -> tempfile::TempDir {
let tmpdir = tempfile::tempdir().unwrap();
// create a `region` directory in the tempdir
let region_path = tmpdir.path().join("region");
std::fs::create_dir(&region_path).unwrap();
tmpdir
}
#[test]
fn test_flags() {
let tmpdir = minecraft_tmpdir();
let tmpdir = tempfile::tempdir().unwrap();
let tmp_path = tmpdir.path().to_str().unwrap();
// Test that terrain/debug are SetTrue
let cmd = [
"arnis",
"--path",
"--output-dir",
tmp_path,
"--bbox",
"1,2,3,4",
@@ -110,24 +136,81 @@ mod tests {
assert!(args.debug);
assert!(args.terrain);
let cmd = ["arnis", "--path", tmp_path, "--bbox", "1,2,3,4"];
let cmd = ["arnis", "--output-dir", tmp_path, "--bbox", "1,2,3,4"];
let args = Args::parse_from(cmd.iter());
assert!(!args.debug);
assert!(!args.terrain);
assert!(!args.bedrock);
}
#[test]
fn test_bedrock_flag() {
// Bedrock mode doesn't require --output-dir
let cmd = ["arnis", "--bedrock", "--bbox", "1,2,3,4"];
let args = Args::parse_from(cmd.iter());
assert!(args.bedrock);
assert!(args.path.is_none());
assert!(validate_args(&args).is_ok());
}
#[test]
fn test_java_requires_path() {
let cmd = ["arnis", "--bbox", "1,2,3,4"];
let args = Args::parse_from(cmd.iter());
assert!(!args.bedrock);
assert!(args.path.is_none());
assert!(validate_args(&args).is_err());
}
#[test]
fn test_java_path_must_exist() {
let cmd = [
"arnis",
"--output-dir",
"/nonexistent/path",
"--bbox",
"1,2,3,4",
];
let args = Args::parse_from(cmd.iter());
let result = validate_args(&args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_bedrock_path_must_exist() {
let cmd = [
"arnis",
"--bedrock",
"--output-dir",
"/nonexistent/path",
"--bbox",
"1,2,3,4",
];
let args = Args::parse_from(cmd.iter());
let result = validate_args(&args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_required_options() {
let tmpdir = minecraft_tmpdir();
let tmpdir = tempfile::tempdir().unwrap();
let tmp_path = tmpdir.path().to_str().unwrap();
let cmd = ["arnis"];
assert!(Args::try_parse_from(cmd.iter()).is_err());
let cmd = ["arnis", "--path", tmp_path, "--bbox", "1,2,3,4"];
assert!(Args::try_parse_from(cmd.iter()).is_ok());
let cmd = ["arnis", "--output-dir", tmp_path, "--bbox", "1,2,3,4"];
let args = Args::try_parse_from(cmd.iter()).unwrap();
assert!(validate_args(&args).is_ok());
let cmd = ["arnis", "--path", tmp_path, "--file", ""];
// Verify --path still works as a deprecated alias
let cmd = ["arnis", "--path", tmp_path, "--bbox", "1,2,3,4"];
let args = Args::try_parse_from(cmd.iter()).unwrap();
assert!(validate_args(&args).is_ok());
let cmd = ["arnis", "--output-dir", tmp_path, "--file", ""];
assert!(Args::try_parse_from(cmd.iter()).is_err());
// The --gui flag isn't used here, ugh. TODO clean up main.rs and its argparse usage.

View File

@@ -129,6 +129,81 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
)],
),
// Dark oak log with axis
"dark_oak_log" => BedrockBlock::with_states(
"dark_oak_log",
vec![(
"pillar_axis",
BedrockBlockStateValue::String("y".to_string()),
)],
),
// Jungle log with axis
"jungle_log" => BedrockBlock::with_states(
"jungle_log",
vec![(
"pillar_axis",
BedrockBlockStateValue::String("y".to_string()),
)],
),
// Acacia log with axis
"acacia_log" => BedrockBlock::with_states(
"acacia_log",
vec![(
"pillar_axis",
BedrockBlockStateValue::String("y".to_string()),
)],
),
// Spruce leaves with persistence
"spruce_leaves" => BedrockBlock::with_states(
"leaves",
vec![
(
"old_leaf_type",
BedrockBlockStateValue::String("spruce".to_string()),
),
("persistent_bit", BedrockBlockStateValue::Bool(true)),
],
),
// Dark oak leaves with persistence
"dark_oak_leaves" => BedrockBlock::with_states(
"leaves2",
vec![
(
"new_leaf_type",
BedrockBlockStateValue::String("dark_oak".to_string()),
),
("persistent_bit", BedrockBlockStateValue::Bool(true)),
],
),
// Jungle leaves with persistence
"jungle_leaves" => BedrockBlock::with_states(
"leaves",
vec![
(
"old_leaf_type",
BedrockBlockStateValue::String("jungle".to_string()),
),
("persistent_bit", BedrockBlockStateValue::Bool(true)),
],
),
// Acacia leaves with persistence
"acacia_leaves" => BedrockBlock::with_states(
"leaves2",
vec![
(
"new_leaf_type",
BedrockBlockStateValue::String("acacia".to_string()),
),
("persistent_bit", BedrockBlockStateValue::Bool(true)),
],
),
// Stone slab (bottom half by default)
"stone_slab" => BedrockBlock::with_states(
"stone_block_slab",
@@ -215,6 +290,13 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
BedrockBlockStateValue::String("stone_brick".to_string()),
)],
),
"brick_wall" => BedrockBlock::with_states(
"cobblestone_wall",
vec![(
"wall_block_type",
BedrockBlockStateValue::String("brick".to_string()),
)],
),
// Flowers - poppy is just "red_flower" in Bedrock
"poppy" => BedrockBlock::with_states(
@@ -321,6 +403,10 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
"concrete",
vec![("color", BedrockBlockStateValue::String("brown".to_string()))],
),
"green_concrete" => BedrockBlock::with_states(
"concrete",
vec![("color", BedrockBlockStateValue::String("green".to_string()))],
),
// Terracotta colors
"white_terracotta" => BedrockBlock::with_states(
@@ -372,6 +458,13 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
"stained_hardened_clay",
vec![("color", BedrockBlockStateValue::String("black".to_string()))],
),
"light_gray_terracotta" => BedrockBlock::with_states(
"stained_hardened_clay",
vec![(
"color",
BedrockBlockStateValue::String("silver".to_string()),
)],
),
// Plain terracotta
"terracotta" => BedrockBlock::simple("hardened_clay"),
@@ -403,6 +496,17 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
BedrockBlockStateValue::String("yellow".to_string()),
)],
),
"orange_wool" => BedrockBlock::with_states(
"wool",
vec![(
"color",
BedrockBlockStateValue::String("orange".to_string()),
)],
),
"blue_wool" => BedrockBlock::with_states(
"wool",
vec![("color", BedrockBlockStateValue::String("blue".to_string()))],
),
// Carpets
"white_carpet" => BedrockBlock::with_states(
@@ -434,6 +538,54 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
"stained_glass",
vec![("color", BedrockBlockStateValue::String("brown".to_string()))],
),
"cyan_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![("color", BedrockBlockStateValue::String("cyan".to_string()))],
),
"blue_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![("color", BedrockBlockStateValue::String("blue".to_string()))],
),
"light_blue_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![(
"color",
BedrockBlockStateValue::String("light_blue".to_string()),
)],
),
"red_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![("color", BedrockBlockStateValue::String("red".to_string()))],
),
"yellow_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![(
"color",
BedrockBlockStateValue::String("yellow".to_string()),
)],
),
"purple_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![(
"color",
BedrockBlockStateValue::String("purple".to_string()),
)],
),
"orange_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![(
"color",
BedrockBlockStateValue::String("orange".to_string()),
)],
),
"magenta_stained_glass" => BedrockBlock::with_states(
"stained_glass",
vec![(
"color",
BedrockBlockStateValue::String("magenta".to_string()),
)],
),
"daylight_detector" => BedrockBlock::simple("daylight_detector"),
// Planks - Bedrock uses single "planks" block with wood_type state
"oak_planks" => BedrockBlock::with_states(
@@ -539,8 +691,34 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock {
// Oak items mapped to dark_oak in Bedrock (or generic equivalents)
"oak_pressure_plate" => BedrockBlock::simple("wooden_pressure_plate"),
"oak_door" => BedrockBlock::simple("wooden_door"),
"spruce_door" => BedrockBlock::simple("spruce_door"),
"dark_oak_door" => BedrockBlock::simple("dark_oak_door"),
"oak_trapdoor" => BedrockBlock::simple("trapdoor"),
// Vegetation with different Bedrock names
"fern" => BedrockBlock::with_states(
"tallgrass",
vec![(
"tall_grass_type",
BedrockBlockStateValue::String("fern".to_string()),
)],
),
"large_fern" => BedrockBlock::with_states(
"double_plant",
vec![(
"double_plant_type",
BedrockBlockStateValue::String("fern".to_string()),
)],
),
"cobweb" => BedrockBlock::simple("web"),
// Potted plants (Bedrock uses "flower_pot" for all variants;
// the contained plant is a block entity, not a block state)
"potted_poppy" => BedrockBlock::simple("flower_pot"),
"potted_red_tulip" => BedrockBlock::simple("flower_pot"),
"potted_dandelion" => BedrockBlock::simple("flower_pot"),
"potted_blue_orchid" => BedrockBlock::simple("flower_pot"),
// Bed (Bedrock uses single "bed" block with color state)
"red_bed" => BedrockBlock::with_states(
"bed",
@@ -564,8 +742,14 @@ pub fn to_bedrock_block_with_properties(
) -> BedrockBlock {
let java_name = block.name();
// If no stored properties were passed, fall back to block.properties()
// so that blocks placed via set_block_absolute (e.g. doors with half=upper/lower)
// still get their default properties forwarded to the Bedrock converter.
let fallback_props = block.properties();
let effective_properties = java_properties.or(fallback_props.as_ref());
// Extract Java properties as a map if present
let props_map = java_properties.and_then(|v| {
let props_map = effective_properties.and_then(|v| {
if let fastnbt::Value::Compound(map) = v {
Some(map)
} else {
@@ -578,6 +762,11 @@ pub fn to_bedrock_block_with_properties(
return convert_stairs(java_name, props_map);
}
// Handle barrel facing direction
if java_name == "barrel" {
return convert_barrel(java_name, props_map);
}
// Handle slabs with type property (top/bottom/double)
if java_name.ends_with("_slab") {
return convert_slab(java_name, props_map);
@@ -588,6 +777,16 @@ pub fn to_bedrock_block_with_properties(
return convert_log(java_name, props_map);
}
// Handle doors with half property (upper/lower → upper_block_bit)
if java_name.ends_with("_door") && java_name != "iron_door" {
return convert_door(java_name, props_map);
}
// Handle trapdoors with facing/open/half properties
if java_name.ends_with("_trapdoor") {
return convert_trapdoor(java_name, props_map);
}
// Fall back to basic conversion without properties
to_bedrock_block(block)
}
@@ -650,6 +849,46 @@ fn convert_stairs(
}
}
/// Convert Java barrel to Bedrock format with facing direction.
fn convert_barrel(
java_name: &str,
props: Option<&std::collections::HashMap<String, fastnbt::Value>>,
) -> BedrockBlock {
let mut states = HashMap::new();
if let Some(props) = props {
if let Some(fastnbt::Value::String(facing)) = props.get("facing") {
let facing_direction = match facing.as_str() {
"down" => 0,
"up" => 1,
"north" => 2,
"south" => 3,
"west" => 4,
"east" => 5,
_ => 1,
};
states.insert(
"facing_direction".to_string(),
BedrockBlockStateValue::Int(facing_direction),
);
}
}
if !states.contains_key("facing_direction") {
states.insert(
"facing_direction".to_string(),
BedrockBlockStateValue::Int(1),
);
}
states.insert("open_bit".to_string(), BedrockBlockStateValue::Bool(false));
BedrockBlock {
name: format!("minecraft:{java_name}"),
states,
}
}
/// Convert Java slab block to Bedrock format with proper type.
fn convert_slab(
java_name: &str,
@@ -750,6 +989,152 @@ fn convert_log(
}
}
/// Convert Java door block to Bedrock format with upper_block_bit.
///
/// Java doors use `half=upper/lower`, Bedrock uses `upper_block_bit` (bool).
/// Also maps door names: `oak_door` → `wooden_door`, others keep their names.
fn convert_door(
java_name: &str,
props: Option<&std::collections::HashMap<String, fastnbt::Value>>,
) -> BedrockBlock {
let bedrock_name = match java_name {
"oak_door" => "wooden_door",
_ => java_name, // spruce_door, dark_oak_door, etc. keep their name
};
let mut states = HashMap::new();
if let Some(props) = props {
// Convert half: Java "upper"/"lower" → Bedrock upper_block_bit true/false
if let Some(fastnbt::Value::String(half)) = props.get("half") {
let is_upper = half == "upper";
states.insert(
"upper_block_bit".to_string(),
BedrockBlockStateValue::Bool(is_upper),
);
}
// Convert facing if present
if let Some(fastnbt::Value::String(facing)) = props.get("facing") {
let direction = match facing.as_str() {
"east" => 0,
"south" => 1,
"west" => 2,
"north" => 3,
_ => 0,
};
states.insert(
"direction".to_string(),
BedrockBlockStateValue::Int(direction),
);
}
// Convert hinge if present
if let Some(fastnbt::Value::String(hinge)) = props.get("hinge") {
let door_hinge = hinge == "right";
states.insert(
"door_hinge_bit".to_string(),
BedrockBlockStateValue::Bool(door_hinge),
);
}
// Convert open if present
if let Some(fastnbt::Value::String(open)) = props.get("open") {
let is_open = open == "true";
states.insert(
"open_bit".to_string(),
BedrockBlockStateValue::Bool(is_open),
);
}
}
// Defaults if no properties were set
if !states.contains_key("upper_block_bit") {
states.insert(
"upper_block_bit".to_string(),
BedrockBlockStateValue::Bool(false),
);
}
if !states.contains_key("direction") {
states.insert("direction".to_string(), BedrockBlockStateValue::Int(0));
}
BedrockBlock {
name: format!("minecraft:{bedrock_name}"),
states,
}
}
/// Convert Java trapdoor block to Bedrock format with facing/open/half states.
fn convert_trapdoor(
java_name: &str,
props: Option<&std::collections::HashMap<String, fastnbt::Value>>,
) -> BedrockBlock {
// Map Java trapdoor names to Bedrock equivalents
let bedrock_name = match java_name {
"oak_trapdoor" => "trapdoor",
"iron_trapdoor" => "iron_trapdoor",
_ => java_name, // spruce_trapdoor, dark_oak_trapdoor, birch_trapdoor, etc.
};
let mut states = HashMap::new();
if let Some(props) = props {
// Convert facing: Java "north/south/east/west" → Bedrock "direction" (0-3)
// Bedrock trapdoor: 0=south, 1=north, 2=east, 3=west
if let Some(fastnbt::Value::String(facing)) = props.get("facing") {
let direction = match facing.as_str() {
"south" => 0,
"north" => 1,
"east" => 2,
"west" => 3,
_ => 0,
};
states.insert(
"direction".to_string(),
BedrockBlockStateValue::Int(direction),
);
}
// Convert open: Java "true"/"false" → Bedrock open_bit
if let Some(fastnbt::Value::String(open)) = props.get("open") {
let is_open = open == "true";
states.insert(
"open_bit".to_string(),
BedrockBlockStateValue::Bool(is_open),
);
}
// Convert half: Java "top"/"bottom" → Bedrock upside_down_bit
if let Some(fastnbt::Value::String(half)) = props.get("half") {
let upside_down = half == "top";
states.insert(
"upside_down_bit".to_string(),
BedrockBlockStateValue::Bool(upside_down),
);
}
}
// Defaults if no properties were set
if !states.contains_key("direction") {
states.insert("direction".to_string(), BedrockBlockStateValue::Int(0));
}
if !states.contains_key("open_bit") {
states.insert("open_bit".to_string(), BedrockBlockStateValue::Bool(false));
}
if !states.contains_key("upside_down_bit") {
states.insert(
"upside_down_bit".to_string(),
BedrockBlockStateValue::Bool(false),
);
}
BedrockBlock {
name: format!("minecraft:{bedrock_name}"),
states,
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -266,7 +266,67 @@ impl Block {
185 => "quartz_stairs",
186 => "polished_andesite_stairs",
187 => "nether_brick_stairs",
188 => "fern",
188 => "barrel",
189 => "fern",
190 => "cobweb",
191 => "chiseled_bookshelf",
192 => "chiseled_bookshelf",
193 => "chiseled_bookshelf",
194 => "chiseled_bookshelf",
195 => "chipped_anvil",
196 => "damaged_anvil",
197 => "large_fern",
198 => "large_fern",
199 => "chain",
200 => "end_rod",
201 => "lightning_rod",
202 => "gold_block",
203 => "sea_lantern",
204 => "orange_concrete",
205 => "orange_wool",
206 => "blue_wool",
207 => "green_concrete",
208 => "brick_wall",
209 => "redstone_block",
210 => "chain",
211 => "chain",
212 => "spruce_door",
213 => "spruce_door",
214 => "smooth_stone_slab",
215 => "glass_pane",
216 => "light_gray_terracotta",
217 => "oak_slab",
218 => "oak_door",
219 => "dark_oak_log",
220 => "dark_oak_leaves",
221 => "jungle_log",
222 => "jungle_leaves",
223 => "acacia_log",
224 => "acacia_leaves",
225 => "spruce_leaves",
226 => "cyan_stained_glass",
227 => "blue_stained_glass",
228 => "light_blue_stained_glass",
229 => "daylight_detector",
230 => "red_stained_glass",
231 => "yellow_stained_glass",
232 => "purple_stained_glass",
233 => "orange_stained_glass",
234 => "magenta_stained_glass",
235 => "potted_poppy",
236 => "oak_trapdoor",
237 => "oak_trapdoor",
238 => "oak_trapdoor",
239 => "oak_trapdoor",
240 => "quartz_slab",
241 => "dark_oak_trapdoor",
242 => "spruce_trapdoor",
243 => "birch_trapdoor",
244 => "mud_brick_slab",
245 => "brick_slab",
246 => "potted_red_tulip",
247 => "potted_dandelion",
248 => "potted_blue_orchid",
_ => panic!("Invalid id"),
}
}
@@ -325,6 +385,13 @@ impl Block {
map
})),
// Oak door lower
159 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("lower".to_string()));
map
})),
116 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert(
@@ -464,6 +531,140 @@ impl Block {
map.insert("half".to_string(), Value::String("top".to_string()));
map
})),
191 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("north".to_string()));
map
})),
192 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("east".to_string()));
map
})),
193 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("south".to_string()));
map
})),
194 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("west".to_string()));
map
})),
197 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("lower".to_string()));
map
})),
198 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("upper".to_string()));
map
})),
210 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("axis".to_string(), Value::String("x".to_string()));
map
})),
211 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("axis".to_string(), Value::String("z".to_string()));
map
})),
// Spruce door lower
212 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("lower".to_string()));
map
})),
// Spruce door upper
213 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("upper".to_string()));
map
})),
// Smooth stone slab (bottom by default)
214 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("type".to_string(), Value::String("bottom".to_string()));
map
})),
// Oak slab top
217 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("type".to_string(), Value::String("top".to_string()));
map
})),
// Oak door upper
218 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("half".to_string(), Value::String("upper".to_string()));
map
})),
// Dark oak leaves
220 => Some(Value::Compound({
let mut map: HashMap<String, Value> = HashMap::new();
map.insert("persistent".to_string(), Value::String("true".to_string()));
map
})),
// Jungle leaves
222 => Some(Value::Compound({
let mut map: HashMap<String, Value> = HashMap::new();
map.insert("persistent".to_string(), Value::String("true".to_string()));
map
})),
// Acacia leaves
224 => Some(Value::Compound({
let mut map: HashMap<String, Value> = HashMap::new();
map.insert("persistent".to_string(), Value::String("true".to_string()));
map
})),
// Spruce leaves
225 => Some(Value::Compound({
let mut map: HashMap<String, Value> = HashMap::new();
map.insert("persistent".to_string(), Value::String("true".to_string()));
map
})),
// Quartz slab (top half) used as window sill
240 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("type".to_string(), Value::String("top".to_string()));
map
})),
// Open oak trapdoor facing north (hangs flat against wall, looks like shutter)
236 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("north".to_string()));
map.insert("open".to_string(), Value::String("true".to_string()));
map.insert("half".to_string(), Value::String("top".to_string()));
map
})),
// Open oak trapdoor facing south
237 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("south".to_string()));
map.insert("open".to_string(), Value::String("true".to_string()));
map.insert("half".to_string(), Value::String("top".to_string()));
map
})),
// Open oak trapdoor facing east
238 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("east".to_string()));
map.insert("open".to_string(), Value::String("true".to_string()));
map.insert("half".to_string(), Value::String("top".to_string()));
map
})),
// Open oak trapdoor facing west
239 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("facing".to_string(), Value::String("west".to_string()));
map.insert("open".to_string(), Value::String("true".to_string()));
map.insert("half".to_string(), Value::String("top".to_string()));
map
})),
_ => None,
}
}
@@ -698,7 +899,69 @@ pub const SMOOTH_SANDSTONE_STAIRS: Block = Block::new(184);
pub const QUARTZ_STAIRS: Block = Block::new(185);
pub const POLISHED_ANDESITE_STAIRS: Block = Block::new(186);
pub const NETHER_BRICK_STAIRS: Block = Block::new(187);
pub const FERN: Block = Block::new(188);
pub const BARREL: Block = Block::new(188);
pub const FERN: Block = Block::new(189);
pub const COBWEB: Block = Block::new(190);
pub const CHISELLED_BOOKSHELF_NORTH: Block = Block::new(191);
pub const CHISELLED_BOOKSHELF_EAST: Block = Block::new(192);
pub const CHISELLED_BOOKSHELF_SOUTH: Block = Block::new(193);
pub const CHISELLED_BOOKSHELF_WEST: Block = Block::new(194);
// Backwards-compatible alias (defaults to north-facing)
pub const CHISELLED_BOOKSHELF: Block = CHISELLED_BOOKSHELF_NORTH;
pub const CHIPPED_ANVIL: Block = Block::new(195);
pub const DAMAGED_ANVIL: Block = Block::new(196);
pub const LARGE_FERN_LOWER: Block = Block::new(197);
pub const LARGE_FERN_UPPER: Block = Block::new(198);
pub const CHAIN: Block = Block::new(199);
pub const END_ROD: Block = Block::new(200);
pub const LIGHTNING_ROD: Block = Block::new(201);
pub const GOLD_BLOCK: Block = Block::new(202);
pub const SEA_LANTERN: Block = Block::new(203);
pub const ORANGE_CONCRETE: Block = Block::new(204);
pub const ORANGE_WOOL: Block = Block::new(205);
pub const BLUE_WOOL: Block = Block::new(206);
pub const GREEN_CONCRETE: Block = Block::new(207);
pub const BRICK_WALL: Block = Block::new(208);
pub const REDSTONE_BLOCK: Block = Block::new(209);
pub const CHAIN_X: Block = Block::new(210);
pub const CHAIN_Z: Block = Block::new(211);
pub const SPRUCE_DOOR_LOWER: Block = Block::new(212);
pub const SPRUCE_DOOR_UPPER: Block = Block::new(213);
pub const SMOOTH_STONE_SLAB: Block = Block::new(214);
pub const GLASS_PANE: Block = Block::new(215);
pub const LIGHT_GRAY_TERRACOTTA: Block = Block::new(216);
pub const OAK_SLAB_TOP: Block = Block::new(217);
pub const OAK_DOOR_UPPER: Block = Block::new(218);
pub const DARK_OAK_LOG: Block = Block::new(219);
pub const DARK_OAK_LEAVES: Block = Block::new(220);
pub const JUNGLE_LOG: Block = Block::new(221);
pub const JUNGLE_LEAVES: Block = Block::new(222);
pub const ACACIA_LOG: Block = Block::new(223);
pub const ACACIA_LEAVES: Block = Block::new(224);
pub const SPRUCE_LEAVES: Block = Block::new(225);
pub const CYAN_STAINED_GLASS: Block = Block::new(226);
pub const BLUE_STAINED_GLASS: Block = Block::new(227);
pub const LIGHT_BLUE_STAINED_GLASS: Block = Block::new(228);
pub const DAYLIGHT_DETECTOR: Block = Block::new(229);
pub const RED_STAINED_GLASS: Block = Block::new(230);
pub const YELLOW_STAINED_GLASS: Block = Block::new(231);
pub const PURPLE_STAINED_GLASS: Block = Block::new(232);
pub const ORANGE_STAINED_GLASS: Block = Block::new(233);
pub const MAGENTA_STAINED_GLASS: Block = Block::new(234);
pub const FLOWER_POT: Block = Block::new(235);
pub const OAK_TRAPDOOR_OPEN_NORTH: Block = Block::new(236);
pub const OAK_TRAPDOOR_OPEN_SOUTH: Block = Block::new(237);
pub const OAK_TRAPDOOR_OPEN_EAST: Block = Block::new(238);
pub const OAK_TRAPDOOR_OPEN_WEST: Block = Block::new(239);
pub const QUARTZ_SLAB_TOP: Block = Block::new(240);
pub const DARK_OAK_TRAPDOOR: Block = Block::new(241);
pub const SPRUCE_TRAPDOOR: Block = Block::new(242);
pub const BIRCH_TRAPDOOR: Block = Block::new(243);
pub const MUD_BRICK_SLAB: Block = Block::new(244);
pub const BRICK_SLAB: Block = Block::new(245);
pub const POTTED_RED_TULIP: Block = Block::new(246);
pub const POTTED_DANDELION: Block = Block::new(247);
pub const POTTED_BLUE_ORCHID: Block = Block::new(248);
/// Maps a block to its corresponding stair variant
#[inline]
@@ -750,58 +1013,80 @@ pub static WINDOW_VARIATIONS: [Block; 7] = [
TINTED_GLASS,
];
// Window types for different building styles
// Residential window options
pub static RESIDENTIAL_WINDOW_OPTIONS: [Block; 4] = [
GLASS,
WHITE_STAINED_GLASS,
LIGHT_GRAY_STAINED_GLASS,
BROWN_STAINED_GLASS,
];
// Institutional window options (hospital, school, etc.)
pub static INSTITUTIONAL_WINDOW_OPTIONS: [Block; 3] =
[GLASS, WHITE_STAINED_GLASS, LIGHT_GRAY_STAINED_GLASS];
// Hospitality window options (hotel, restaurant)
pub static HOSPITALITY_WINDOW_OPTIONS: [Block; 2] = [GLASS, WHITE_STAINED_GLASS];
// Industrial window options
pub static INDUSTRIAL_WINDOW_OPTIONS: [Block; 4] = [
GLASS,
GRAY_STAINED_GLASS,
LIGHT_GRAY_STAINED_GLASS,
BROWN_STAINED_GLASS,
];
// 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)
}
/// Deterministic window block selection using provided RNG
pub fn get_window_block_for_building_type_with_rng(
building_type: &str,
rng: &mut impl rand::Rng,
) -> Block {
match building_type {
"residential" | "house" | "apartment" => {
let residential_windows = [
GLASS,
WHITE_STAINED_GLASS,
LIGHT_GRAY_STAINED_GLASS,
BROWN_STAINED_GLASS,
];
residential_windows[rng.gen_range(0..residential_windows.len())]
"residential" | "house" | "apartment" | "apartments" => {
RESIDENTIAL_WINDOW_OPTIONS[rng.random_range(0..RESIDENTIAL_WINDOW_OPTIONS.len())]
}
"hospital" | "school" | "university" => {
let institutional_windows = [GLASS, WHITE_STAINED_GLASS, LIGHT_GRAY_STAINED_GLASS];
institutional_windows[rng.gen_range(0..institutional_windows.len())]
INSTITUTIONAL_WINDOW_OPTIONS[rng.random_range(0..INSTITUTIONAL_WINDOW_OPTIONS.len())]
}
"hotel" | "restaurant" => {
let hospitality_windows = [GLASS, WHITE_STAINED_GLASS];
hospitality_windows[rng.gen_range(0..hospitality_windows.len())]
HOSPITALITY_WINDOW_OPTIONS[rng.random_range(0..HOSPITALITY_WINDOW_OPTIONS.len())]
}
"industrial" | "warehouse" => {
let industrial_windows = [
GLASS,
GRAY_STAINED_GLASS,
LIGHT_GRAY_STAINED_GLASS,
BROWN_STAINED_GLASS,
];
industrial_windows[rng.gen_range(0..industrial_windows.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())],
}
}
// Random floor block selection
// Floor block options for buildings
pub static FLOOR_BLOCK_OPTIONS: [Block; 8] = [
WHITE_CONCRETE,
GRAY_CONCRETE,
LIGHT_GRAY_CONCRETE,
POLISHED_ANDESITE,
SMOOTH_STONE,
STONE_BRICKS,
MUD_BRICKS,
OAK_PLANKS,
];
// 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();
let mut rng = rand::rng();
FLOOR_BLOCK_OPTIONS[rng.random_range(0..FLOOR_BLOCK_OPTIONS.len())]
}
let floor_options = [
WHITE_CONCRETE,
GRAY_CONCRETE,
LIGHT_GRAY_CONCRETE,
POLISHED_ANDESITE,
SMOOTH_STONE,
STONE_BRICKS,
MUD_BRICKS,
OAK_PLANKS,
];
floor_options[rng.gen_range(0..floor_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.random_range(0..FLOOR_BLOCK_OPTIONS.len())]
}
// Define all predefined colors with their blocks
@@ -936,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
@@ -944,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()
@@ -954,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,
@@ -983,15 +1268,14 @@ pub fn get_fallback_building_block() -> Block {
STONE_BRICKS,
WHITE_CONCRETE,
WHITE_TERRACOTTA,
OAK_PLANKS,
];
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,
@@ -1005,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

@@ -14,6 +14,9 @@ pub fn clip_way_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<Process
return Vec::new();
}
// Get way ID for ID generation
let way_id = nodes.first().map(|n| n.id).unwrap_or(0);
let is_closed = is_closed_polygon(nodes);
if !is_closed {
@@ -54,12 +57,13 @@ pub fn clip_way_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<Process
}
let polygon = insert_bbox_corners(polygon, min_x, min_z, max_x, max_z);
let polygon = remove_consecutive_duplicates(polygon);
if polygon.len() < 3 {
return Vec::new();
}
let way_id = nodes.first().map(|n| n.id).unwrap_or(0);
assign_node_ids_preserving_endpoints(nodes, polygon, way_id)
}
@@ -496,12 +500,15 @@ fn find_bbox_intersections(
/// Returns which bbox edge a point lies on: 0=bottom, 1=right, 2=top, 3=left, -1=interior.
fn get_bbox_edge(point: (f64, f64), min_x: f64, min_z: f64, max_x: f64, max_z: f64) -> i32 {
let eps = 0.5;
// Use a slightly larger epsilon to handle floating-point errors from Sutherland-Hodgman.
// Points should be clamped to bbox before this function is called, so any point
// at or very near the boundary should be considered ON that edge.
let eps = 1.0;
let on_left = (point.0 - min_x).abs() < eps;
let on_right = (point.0 - max_x).abs() < eps;
let on_bottom = (point.1 - min_z).abs() < eps;
let on_top = (point.1 - max_z).abs() < eps;
let on_left = (point.0 - min_x).abs() <= eps;
let on_right = (point.0 - max_x).abs() <= eps;
let on_bottom = (point.1 - min_z).abs() <= eps;
let on_top = (point.1 - max_z).abs() <= eps;
// Handle corners (assign to edge in counter-clockwise order)
if on_bottom && on_left {
@@ -556,20 +563,21 @@ fn get_corners_between_edges(
let ccw_dist = ((edge2 - edge1 + 4) % 4) as usize;
let cw_dist = ((edge1 - edge2 + 4) % 4) as usize;
// Opposite edges: don't insert corners
if ccw_dist == 2 && cw_dist == 2 {
return Vec::new();
}
// For opposite edges (distance = 2), we need to pick a direction.
// Use counter-clockwise by default to ensure corners are inserted.
// This prevents diagonal lines when polygon spans opposite bbox edges.
let mut result = Vec::new();
if ccw_dist <= cw_dist {
// Go counter-clockwise
let mut current = edge1;
for _ in 0..ccw_dist {
result.push(corners[current as usize]);
current = (current + 1) % 4;
}
} else {
// Go clockwise
let mut current = edge1;
for _ in 0..cw_dist {
current = (current + 4 - 1) % 4;
@@ -580,6 +588,12 @@ fn get_corners_between_edges(
result
}
/// Checks if two points are approximately equal (within epsilon tolerance).
fn points_approx_equal(p1: (f64, f64), p2: (f64, f64)) -> bool {
let eps = 1.0;
(p1.0 - p2.0).abs() <= eps && (p1.1 - p2.1).abs() <= eps
}
/// Inserts bbox corners where polygon transitions between different bbox edges.
fn insert_bbox_corners(
polygon: Vec<(f64, f64)>,
@@ -604,8 +618,13 @@ fn insert_bbox_corners(
let edge2 = get_bbox_edge(next, min_x, min_z, max_x, max_z);
if edge1 >= 0 && edge2 >= 0 && edge1 != edge2 {
for corner in get_corners_between_edges(edge1, edge2, min_x, min_z, max_x, max_z) {
result.push(corner);
let corners = get_corners_between_edges(edge1, edge2, min_x, min_z, max_x, max_z);
// Filter out corners that match the current point or the next point
for corner in corners {
if !points_approx_equal(corner, current) && !points_approx_equal(corner, next) {
result.push(corner);
}
}
}
}

View File

@@ -1,18 +1,20 @@
use crate::args::Args;
use crate::block_definitions::{BEDROCK, DIRT, GRASS_BLOCK, STONE};
use crate::block_definitions::{BEDROCK, DIRT, GRASS_BLOCK, SMOOTH_STONE, STONE};
use crate::coordinate_system::cartesian::XZBBox;
use crate::coordinate_system::geographic::LLBBox;
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};
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;
@@ -27,23 +29,6 @@ pub struct GenerationOptions {
pub spawn_point: Option<(i32, i32)>,
}
pub fn generate_world(
elements: Vec<ProcessedElement>,
xzbbox: XZBBox,
llbbox: LLBBox,
ground: Ground,
args: &Args,
) -> Result<(), String> {
// Default to Java format when called from CLI
let options = GenerationOptions {
path: args.path.clone(),
format: WorldFormat::JavaAnvil,
level_name: None,
spawn_point: None,
};
generate_world_with_options(elements, xzbbox, llbbox, ground, args, options).map(|_| ())
}
/// Generate world with explicit format options (used by GUI for Bedrock support)
pub fn generate_world_with_options(
elements: Vec<ProcessedElement>,
@@ -85,9 +70,16 @@ pub fn generate_world_with_options(
// Uses a memory-efficient bitmap (~1 bit per coordinate) instead of a HashSet (~24 bytes per coordinate)
let building_footprints = flood_fill_cache.collect_building_footprints(&elements, &xzbbox);
// Process data
// Collect building centroids for urban ground generation (only if enabled)
// This must be done before the processing loop clears the flood fill cache
let building_centroids = if args.city_boundaries {
flood_fill_cache.collect_building_centroids(&elements)
} else {
Vec::new()
};
// Process all elements (no longer need to partition boundaries)
let elements_count: usize = elements.len();
let mut elements = elements; // Take ownership for consuming
let process_pb: ProgressBar = ProgressBar::new(elements_count as u64);
process_pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:45.white/black}] {pos}/{len} elements ({eta}) {msg}")
@@ -98,8 +90,35 @@ pub fn generate_world_with_options(
let mut current_progress_prcs: f64 = 25.0;
let mut last_emitted_progress: f64 = current_progress_prcs;
// Process elements by draining in insertion order
for element in elements.drain(..) {
// 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);
current_progress_prcs += progress_increment_prcs;
if (current_progress_prcs - last_emitted_progress).abs() > 0.25 {
@@ -120,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,
@@ -175,8 +205,14 @@ 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") {
power::generate_power(&mut editor, &element);
} else if way.tags.contains_key("place") {
landuse::generate_place(&mut editor, way, args, &flood_fill_cache);
}
// Release flood fill cache entry for this way
flood_fill_cache.remove_way(way.id);
@@ -210,15 +246,27 @@ pub fn generate_world_with_options(
tourisms::generate_tourisms(&mut editor, node);
} else if node.tags.contains_key("man_made") {
man_made::generate_man_made_nodes(&mut editor, node);
} else if node.tags.contains_key("power") {
power::generate_power_nodes(&mut editor, node);
} else if node.tags.contains_key("historic") {
historic::generate_historic(&mut editor, node);
} else if node.tags.contains_key("emergency") {
emergency::generate_emergency(&mut editor, node);
} else if node.tags.contains_key("advertising") {
advertising::generate_advertising(&mut editor, node);
}
}
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
@@ -265,6 +313,16 @@ pub fn generate_world_with_options(
process_pb.finish();
// Compute urban ground lookup (if enabled)
// Uses a compact cell-based representation instead of storing all coordinates.
// Memory usage: ~270 KB vs ~560 MB for coordinate-based approach.
let urban_lookup = if args.city_boundaries && !building_centroids.is_empty() {
urban_ground::compute_urban_ground_lookup(building_centroids, &xzbbox)
} else {
urban_ground::UrbanGroundLookup::empty()
};
let has_urban_ground = !urban_lookup.is_empty();
// Drop remaining caches
drop(highway_connectivity);
drop(flood_fill_cache);
@@ -321,26 +379,31 @@ pub fn generate_world_with_options(
args.ground_level
};
// Check if this coordinate is in an urban area (O(1) lookup)
let is_urban = has_urban_ground && urban_lookup.is_urban(x, z);
// Add default dirt and grass layer if there isn't a stone layer already
if !editor.check_for_block_absolute(x, ground_y, z, Some(&[STONE]), None) {
editor.set_block_absolute(GRASS_BLOCK, x, ground_y, z, None, None);
editor.set_block_absolute(DIRT, x, ground_y - 1, z, None, None);
editor.set_block_absolute(DIRT, x, ground_y - 2, z, None, None);
if is_urban {
// Urban area: smooth stone ground
editor.set_block_if_absent_absolute(SMOOTH_STONE, x, ground_y, z);
} else {
// Rural/natural area: grass and dirt
editor.set_block_if_absent_absolute(GRASS_BLOCK, x, ground_y, z);
}
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
@@ -397,16 +460,18 @@ pub fn generate_world_with_options(
);
// Always update spawn Y since we now always set a spawn point (user-selected or default)
if let Err(e) = update_player_spawn_y_after_generation(
&args.path,
bbox_string,
args.scale,
ground.as_ref(),
) {
let warning_msg = format!("Failed to update spawn point Y coordinate: {}", e);
eprintln!("Warning: {}", warning_msg);
#[cfg(feature = "gui")]
send_log(LogLevel::Warning, &warning_msg);
if let Some(ref world_path) = args.path {
if let Err(e) = update_player_spawn_y_after_generation(
world_path,
bbox_string,
args.scale,
ground.as_ref(),
) {
let warning_msg = format!("Failed to update spawn point Y coordinate: {}", e);
eprintln!("Warning: {}", warning_msg);
#[cfg(feature = "gui")]
send_log(LogLevel::Warning, &warning_msg);
}
}
}

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

@@ -0,0 +1,120 @@
//! Processing of advertising elements.
//!
//! This module handles advertising-related OSM elements including:
//! - `advertising=column` - Cylindrical advertising columns (Litfaßsäule)
//! - `advertising=flag` - Advertising flags on poles
//! - `advertising=poster_box` - Illuminated poster display boxes
use crate::block_definitions::*;
use crate::deterministic_rng::element_rng;
use crate::osm_parser::ProcessedNode;
use crate::world_editor::WorldEditor;
use rand::Rng;
/// Generate advertising structures from node elements
pub fn generate_advertising(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(advertising_type) = node.tags.get("advertising") {
match advertising_type.as_str() {
"column" => generate_advertising_column(editor, node),
"flag" => generate_advertising_flag(editor, node),
"poster_box" => generate_poster_box(editor, node),
_ => {}
}
}
}
/// Generate an advertising column (Litfaßsäule)
///
/// Creates a simple advertising column.
fn generate_advertising_column(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Two green concrete blocks stacked
editor.set_block(GREEN_CONCRETE, x, 1, z, None, None);
editor.set_block(GREEN_CONCRETE, x, 2, z, None, None);
// Stone brick slab on top
editor.set_block(STONE_BRICK_SLAB, x, 3, z, None, None);
}
/// Generate an advertising flag
///
/// Creates a flagpole with a banner/flag for advertising.
fn generate_advertising_flag(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Use deterministic RNG for flag color
let mut rng = element_rng(node.id);
// Get height from tags or default
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(6)
.clamp(4, 12);
// Flagpole
for y in 1..=height {
editor.set_block(IRON_BARS, x, y, z, None, None);
}
// Flag/banner at top (using colored wool)
// Random bright advertising colors
let flag_colors = [
RED_WOOL,
YELLOW_WOOL,
BLUE_WOOL,
GREEN_WOOL,
ORANGE_WOOL,
WHITE_WOOL,
];
let flag_block = flag_colors[rng.random_range(0..flag_colors.len())];
// Flag extends to one side (2-3 blocks)
let flag_length = 3;
for dx in 1..=flag_length {
editor.set_block(flag_block, x + dx, height, z, None, None);
editor.set_block(flag_block, x + dx, height - 1, z, None, None);
}
// Finial at top
editor.set_block(IRON_BLOCK, x, height + 1, z, None, None);
}
/// Generate a poster box (city light / lollipop display)
///
/// Creates an illuminated poster display box on a pole.
fn generate_poster_box(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Y=1: Two iron bars next to each other
editor.set_block(IRON_BARS, x, 1, z, None, None);
editor.set_block(IRON_BARS, x + 1, 1, z, None, None);
// Y=2 and Y=3: Two sea lanterns
editor.set_block(SEA_LANTERN, x, 2, z, None, None);
editor.set_block(SEA_LANTERN, x + 1, 2, z, None, None);
editor.set_block(SEA_LANTERN, x, 3, z, None, None);
editor.set_block(SEA_LANTERN, x + 1, 3, z, None, None);
// Y=4: Two polished stone brick slabs
editor.set_block(STONE_BRICK_SLAB, x, 4, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 4, z, None, None);
}

View File

@@ -7,7 +7,12 @@ use crate::floodfill::flood_fill_area; // Needed for inline amenity flood fills
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::ProcessedElement;
use crate::world_editor::WorldEditor;
use rand::Rng;
use fastnbt::Value;
use rand::{
prelude::{IndexedRandom, SliceRandom},
Rng,
};
use std::collections::{HashMap, HashSet};
pub fn generate_amenities(
editor: &mut WorldEditor,
@@ -34,6 +39,49 @@ pub fn generate_amenities(
.map(|n: &crate::osm_parser::ProcessedNode| XZPoint::new(n.x, n.z))
.next();
match amenity_type.as_str() {
"recycling" => {
let is_container = element
.tags()
.get("recycling_type")
.is_some_and(|value| value == "container");
if !is_container {
return;
}
if let Some(pt) = first_node {
let mut rng = rand::rng();
let loot_pool = build_recycling_loot_pool(element.tags());
let items = build_recycling_items(&loot_pool, &mut rng);
let properties = Value::Compound(recycling_barrel_properties());
let barrel_block = BlockWithProperties::new(BARREL, Some(properties));
let absolute_y = editor.get_absolute_y(pt.x, 1, pt.z);
editor.set_block_entity_with_items(
barrel_block,
pt.x,
1,
pt.z,
"minecraft:barrel",
items,
);
if let Some(category) = single_loot_category(&loot_pool) {
if let Some(display_item) =
build_display_item_for_category(category, &mut rng)
{
place_item_frame_on_random_side(
editor,
pt.x,
absolute_y,
pt.z,
display_item,
);
}
}
}
}
"waste_disposal" | "waste_basket" => {
// Place a cauldron for waste disposal or waste basket
if let Some(pt) = first_node {
@@ -87,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);
@@ -263,3 +311,423 @@ pub fn generate_amenities(
}
}
}
#[derive(Clone, Copy)]
enum RecyclingLootKind {
GlassBottle,
Paper,
GlassBlock,
GlassPane,
LeatherArmor,
EmptyBucket,
LeatherBoots,
ScrapMetal,
GreenWaste,
}
#[derive(Clone, Copy)]
enum LeatherPiece {
Helmet,
Chestplate,
Leggings,
Boots,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum LootCategory {
GlassBottle,
Paper,
Glass,
Leather,
EmptyBucket,
ScrapMetal,
GreenWaste,
}
fn recycling_barrel_properties() -> HashMap<String, Value> {
let mut props = HashMap::new();
props.insert("facing".to_string(), Value::String("up".to_string()));
props
}
fn build_recycling_loot_pool(tags: &HashMap<String, String>) -> Vec<RecyclingLootKind> {
let mut loot_pool: Vec<RecyclingLootKind> = Vec::new();
if tag_enabled(tags, "recycling:glass_bottles") {
loot_pool.push(RecyclingLootKind::GlassBottle);
}
if tag_enabled(tags, "recycling:paper") {
loot_pool.push(RecyclingLootKind::Paper);
}
if tag_enabled(tags, "recycling:glass") {
loot_pool.push(RecyclingLootKind::GlassBlock);
loot_pool.push(RecyclingLootKind::GlassPane);
}
if tag_enabled(tags, "recycling:clothes") {
loot_pool.push(RecyclingLootKind::LeatherArmor);
}
if tag_enabled(tags, "recycling:cans") {
loot_pool.push(RecyclingLootKind::EmptyBucket);
}
if tag_enabled(tags, "recycling:shoes") {
loot_pool.push(RecyclingLootKind::LeatherBoots);
}
if tag_enabled(tags, "recycling:scrap_metal") {
loot_pool.push(RecyclingLootKind::ScrapMetal);
}
if tag_enabled(tags, "recycling:green_waste") {
loot_pool.push(RecyclingLootKind::GreenWaste);
}
loot_pool
}
fn build_recycling_items(
loot_pool: &[RecyclingLootKind],
rng: &mut impl Rng,
) -> Vec<HashMap<String, Value>> {
if loot_pool.is_empty() {
return Vec::new();
}
let mut items = Vec::new();
for slot in 0..27 {
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);
}
}
}
items
}
fn kind_to_category(kind: RecyclingLootKind) -> LootCategory {
match kind {
RecyclingLootKind::GlassBottle => LootCategory::GlassBottle,
RecyclingLootKind::Paper => LootCategory::Paper,
RecyclingLootKind::GlassBlock | RecyclingLootKind::GlassPane => LootCategory::Glass,
RecyclingLootKind::LeatherArmor | RecyclingLootKind::LeatherBoots => LootCategory::Leather,
RecyclingLootKind::EmptyBucket => LootCategory::EmptyBucket,
RecyclingLootKind::ScrapMetal => LootCategory::ScrapMetal,
RecyclingLootKind::GreenWaste => LootCategory::GreenWaste,
}
}
fn single_loot_category(loot_pool: &[RecyclingLootKind]) -> Option<LootCategory> {
let mut categories: HashSet<LootCategory> = HashSet::new();
for kind in loot_pool {
categories.insert(kind_to_category(*kind));
if categories.len() > 1 {
return None;
}
}
categories.iter().next().copied()
}
fn build_display_item_for_category(
category: LootCategory,
rng: &mut impl Rng,
) -> 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.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)),
LootCategory::ScrapMetal => {
let metals = [
"minecraft:copper_ingot",
"minecraft:iron_ingot",
"minecraft:gold_ingot",
];
let metal = metals.choose(rng)?;
Some(make_display_item(metal, rng.random_range(1..=2)))
}
LootCategory::GreenWaste => {
let options = [
"minecraft:oak_sapling",
"minecraft:birch_sapling",
"minecraft:tall_grass",
"minecraft:sweet_berries",
"minecraft:wheat_seeds",
];
let choice = options.choose(rng)?;
Some(make_display_item(choice, rng.random_range(1..=3)))
}
}
}
fn place_item_frame_on_random_side(
editor: &mut WorldEditor,
x: i32,
barrel_absolute_y: i32,
z: i32,
item: HashMap<String, Value>,
) {
let mut rng = rand::rng();
let mut directions = [
((0, 0, -1), 2), // North
((0, 0, 1), 3), // South
((-1, 0, 0), 4), // West
((1, 0, 0), 5), // East
];
directions.shuffle(&mut rng);
let (min_x, min_z) = editor.get_min_coords();
let (max_x, max_z) = editor.get_max_coords();
let ((dx, _dy, dz), facing) = directions
.into_iter()
.find(|((dx, _dy, dz), _)| {
let target_x = x + dx;
let target_z = z + dz;
target_x >= min_x && target_x <= max_x && target_z >= min_z && target_z <= max_z
})
.unwrap_or(((0, 0, 1), 3)); // Fallback south if all directions are out of bounds
let target_x = x + dx;
let target_y = barrel_absolute_y;
let target_z = z + dz;
let ground_y = editor.get_absolute_y(target_x, 0, target_z);
let mut extra = HashMap::new();
extra.insert("Facing".to_string(), Value::Byte(facing)); // 2=north, 3=south, 4=west, 5=east
extra.insert("ItemRotation".to_string(), Value::Byte(0));
extra.insert("Item".to_string(), Value::Compound(item));
extra.insert("ItemDropChance".to_string(), Value::Float(1.0));
extra.insert(
"block_pos".to_string(),
Value::List(vec![
Value::Int(target_x),
Value::Int(target_y),
Value::Int(target_z),
]),
);
extra.insert("TileX".to_string(), Value::Int(target_x));
extra.insert("TileY".to_string(), Value::Int(target_y));
extra.insert("TileZ".to_string(), Value::Int(target_z));
extra.insert("Fixed".to_string(), Value::Byte(1));
let relative_y = target_y - ground_y;
editor.add_entity(
"minecraft:item_frame",
target_x,
relative_y,
target_z,
Some(extra),
);
}
fn make_display_item(id: &str, count: i8) -> HashMap<String, Value> {
let mut item = HashMap::new();
item.insert("id".to_string(), Value::String(id.to_string()));
item.insert("Count".to_string(), Value::Byte(count));
item
}
fn build_leather_display_item(rng: &mut impl Rng) -> HashMap<String, Value> {
let mut item = make_display_item("minecraft:leather_chestplate", 1);
let damage = biased_damage(80, rng);
let mut tag = HashMap::new();
tag.insert("Damage".to_string(), Value::Int(damage));
if let Some(color) = maybe_leather_color(rng) {
let mut display = HashMap::new();
display.insert("color".to_string(), Value::Int(color));
tag.insert("display".to_string(), Value::Compound(display));
}
item.insert("tag".to_string(), Value::Compound(tag));
let mut components = HashMap::new();
components.insert("minecraft:damage".to_string(), Value::Int(damage));
item.insert("components".to_string(), Value::Compound(components));
item
}
fn build_item_for_kind(
kind: RecyclingLootKind,
slot: i8,
rng: &mut impl Rng,
) -> Option<HashMap<String, Value>> {
match kind {
RecyclingLootKind::GlassBottle => Some(make_basic_item(
"minecraft:glass_bottle",
slot,
rng.random_range(1..=4),
)),
RecyclingLootKind::Paper => Some(make_basic_item(
"minecraft:paper",
slot,
rng.random_range(1..=10),
)),
RecyclingLootKind::GlassBlock => Some(build_glass_item(false, slot, rng)),
RecyclingLootKind::GlassPane => Some(build_glass_item(true, slot, rng)),
RecyclingLootKind::LeatherArmor => {
Some(build_leather_item(random_leather_piece(rng), slot, rng))
}
RecyclingLootKind::EmptyBucket => Some(make_basic_item("minecraft:bucket", slot, 1)),
RecyclingLootKind::LeatherBoots => Some(build_leather_item(LeatherPiece::Boots, slot, rng)),
RecyclingLootKind::ScrapMetal => Some(build_scrap_metal_item(slot, rng)),
RecyclingLootKind::GreenWaste => Some(build_green_waste_item(slot, rng)),
}
}
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.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.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.random_bool(0.25) {
match rng.random_range(0..4) {
0 => "minecraft:wheat_seeds",
1 => "minecraft:pumpkin_seeds",
2 => "minecraft:melon_seeds",
_ => "minecraft:beetroot_seeds",
}
} else {
id
};
make_basic_item(id, slot, count)
}
fn build_glass_item(is_pane: bool, slot: i8, rng: &mut impl Rng) -> HashMap<String, Value> {
const GLASS_COLORS: &[&str] = &[
"white",
"orange",
"magenta",
"light_blue",
"yellow",
"lime",
"pink",
"gray",
"light_gray",
"cyan",
"purple",
"blue",
"brown",
"green",
"red",
"black",
];
let use_colorless = rng.random_bool(0.7);
let id = if use_colorless {
if is_pane {
"minecraft:glass_pane".to_string()
} else {
"minecraft:glass".to_string()
}
} else {
let color = GLASS_COLORS
.choose(rng)
.expect("glass color array is non-empty");
if is_pane {
format!("minecraft:{color}_stained_glass_pane")
} else {
format!("minecraft:{color}_stained_glass")
}
};
let count = if is_pane {
rng.random_range(4..=16)
} else {
rng.random_range(1..=6)
};
make_basic_item(&id, slot, count)
}
fn build_leather_item(piece: LeatherPiece, slot: i8, rng: &mut impl Rng) -> HashMap<String, Value> {
let (id, max_damage) = match piece {
LeatherPiece::Helmet => ("minecraft:leather_helmet", 55),
LeatherPiece::Chestplate => ("minecraft:leather_chestplate", 80),
LeatherPiece::Leggings => ("minecraft:leather_leggings", 75),
LeatherPiece::Boots => ("minecraft:leather_boots", 65),
};
let mut item = make_basic_item(id, slot, 1);
let damage = biased_damage(max_damage, rng);
let mut tag = HashMap::new();
tag.insert("Damage".to_string(), Value::Int(damage));
if let Some(color) = maybe_leather_color(rng) {
let mut display = HashMap::new();
display.insert("color".to_string(), Value::Int(color));
tag.insert("display".to_string(), Value::Compound(display));
}
item.insert("tag".to_string(), Value::Compound(tag));
let mut components = HashMap::new();
components.insert("minecraft:damage".to_string(), Value::Int(damage));
item.insert("components".to_string(), Value::Compound(components));
item
}
fn biased_damage(max_damage: i32, rng: &mut impl Rng) -> i32 {
let safe_max = max_damage.max(1);
let upper = safe_max.saturating_sub(1);
let lower = (safe_max / 2).min(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.random_bool(0.3) {
Some(rng.random_range(0..=0x00FF_FFFF))
} else {
None
}
}
fn random_leather_piece(rng: &mut impl Rng) -> LeatherPiece {
match rng.random_range(0..4) {
0 => LeatherPiece::Helmet,
1 => LeatherPiece::Chestplate,
2 => LeatherPiece::Leggings,
_ => LeatherPiece::Boots,
}
}
fn make_basic_item(id: &str, slot: i8, count: i8) -> HashMap<String, Value> {
let mut item = HashMap::new();
item.insert("id".to_string(), Value::String(id.to_string()));
item.insert("Slot".to_string(), Value::Byte(slot));
item.insert("Count".to_string(), Value::Byte(count));
item
}
fn tag_enabled(tags: &HashMap<String, String>, key: &str) -> bool {
tags.get(key).is_some_and(|value| value == "yes")
}

View File

@@ -69,7 +69,7 @@ pub fn generate_barriers(editor: &mut WorldEditor, element: &ProcessedElement) {
barrier_material = LIGHT_GRAY_CONCRETE;
}
if barrier_mat == "metal" {
barrier_material = STONE_BRICK_WALL; // IRON_BARS
barrier_material = STONE_BRICK_WALL;
}
}
@@ -80,7 +80,8 @@ pub fn generate_barriers(editor: &mut WorldEditor, element: &ProcessedElement) {
.get("height")
.and_then(|height: &String| height.parse::<f32>().ok())
.map(|height: f32| height.round() as i32)
.unwrap_or(barrier_height);
.unwrap_or(barrier_height)
.max(2); // Minimum height of 2
// Process nodes to create the barrier wall
for i in 1..way.nodes.len() {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
//! Processing of emergency infrastructure elements.
//!
//! This module handles emergency-related OSM elements including:
//! - `emergency=fire_hydrant` - Fire hydrants
use crate::block_definitions::*;
use crate::osm_parser::ProcessedNode;
use crate::world_editor::WorldEditor;
/// Generate emergency infrastructure from node elements
pub fn generate_emergency(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(emergency_type) = node.tags.get("emergency") {
if emergency_type.as_str() == "fire_hydrant" {
generate_fire_hydrant(editor, node)
}
}
}
/// Generate a fire hydrant
///
/// Creates a simple fire hydrant structure using brick wall with redstone block on top.
/// Skips underground, wall-mounted, and pond hydrant types.
fn generate_fire_hydrant(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Get hydrant type - skip underground, wall, and pond types
let hydrant_type = node
.tags
.get("fire_hydrant:type")
.map(|s| s.as_str())
.unwrap_or("pillar");
// Skip non-visible hydrant types
if matches!(hydrant_type, "underground" | "wall" | "pond") {
return;
}
// Simple hydrant: brick wall with redstone block on top
editor.set_block(BRICK_WALL, x, 1, z, None, None);
editor.set_block(REDSTONE_BLOCK, x, 2, z, None, None);
}

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

@@ -0,0 +1,340 @@
//! Processing of historic elements.
//!
//! 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::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedNode, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::Rng;
/// Generate historic structures from node elements
pub fn generate_historic(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(historic_type) = node.tags.get("historic") {
match historic_type.as_str() {
"memorial" => generate_memorial(editor, node),
"monument" => generate_monument(editor, node),
"wayside_cross" => generate_wayside_cross(editor, node),
_ => {}
}
}
}
/// Generate a memorial structure
///
/// Memorials come in many forms. We determine the type from the `memorial` tag:
/// - plaque: Simple wall-mounted or standing plaque
/// - statue: A statue on a pedestal
/// - sculpture: Artistic sculpture
/// - stone/stolperstein: Memorial stone
/// - bench: Memorial bench (already handled by amenity=bench typically)
/// - cross: Memorial cross
/// - obelisk: Tall pointed pillar
/// - stele: Upright stone slab
/// - bust: Bust on a pedestal
/// - Default: A general monument/pillar
fn generate_memorial(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Use deterministic RNG for consistent results
let mut rng = element_rng(node.id);
// Get memorial subtype
let memorial_type = node
.tags
.get("memorial")
.map(|s| s.as_str())
.unwrap_or("yes");
match memorial_type {
"plaque" => {
// Simple plaque on a small stand
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x, 2, z, None, None);
}
"statue" | "sculpture" | "bust" => {
// Statue on a pedestal
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(CHISELED_STONE_BRICKS, x, 2, z, None, None);
// Use polished andesite for bronze/metal statue appearance
let statue_block = if rng.random_bool(0.5) {
POLISHED_ANDESITE
} else {
POLISHED_DIORITE
};
editor.set_block(statue_block, x, 3, z, None, None);
editor.set_block(statue_block, x, 4, z, None, None);
editor.set_block(STONE_BRICK_WALL, x, 5, z, None, None);
}
"stone" | "stolperstein" => {
// Simple memorial stone embedded in ground
let stone_block = if memorial_type == "stolperstein" {
GOLD_BLOCK // Stolpersteine are brass/gold colored
} else {
STONE
};
editor.set_block(stone_block, x, 0, z, None, None);
}
"cross" | "war_memorial" => {
// Memorial cross
generate_cross(editor, x, z, 5);
}
"obelisk" => {
// Tall pointed pillar with fixed height
// Base layer at Y=1
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 1, z + dz, None, None);
}
}
// Second base layer at Y=2
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 2, z + dz, None, None);
}
}
// Stone brick slabs on the 4 corners at Y=3 (on top of corner blocks)
editor.set_block(STONE_BRICK_SLAB, x - 1, 3, z - 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 3, z - 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x - 1, 3, z + 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 3, z + 1, None, None);
// Main shaft, fixed height of 4 blocks (Y=3 to Y=6)
for y in 3..=6 {
editor.set_block(SMOOTH_QUARTZ, x, y, z, None, None);
}
editor.set_block(STONE_BRICK_SLAB, x, 7, z, None, None);
}
"stele" => {
// Upright stone slab
// Base
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
// Upright slab (using wall blocks for thin appearance)
for y in 2..=4 {
editor.set_block(STONE_BRICK_WALL, x, y, z, None, None);
}
editor.set_block(STONE_BRICK_SLAB, x, 5, z, None, None);
}
_ => {
// Default: simple stone pillar monument
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(STONE_BRICKS, x, 2, z, None, None);
editor.set_block(CHISELED_STONE_BRICKS, x, 3, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x, 4, z, None, None);
}
}
}
/// Generate a monument (larger than memorial)
fn generate_monument(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Monuments are typically larger structures
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(5, 20);
// Large base platform
for dx in -2..=2 {
for dz in -2..=2 {
editor.set_block(STONE_BRICKS, x + dx, 1, z + dz, None, None);
}
}
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 2, z + dz, None, None);
}
}
// Main structure
for y in 3..height {
editor.set_block(POLISHED_ANDESITE, x, y, z, None, None);
}
// Decorative top
editor.set_block(CHISELED_STONE_BRICKS, x, height, z, None, None);
}
/// Generate a wayside cross
fn generate_wayside_cross(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Simple roadside cross
generate_cross(editor, x, z, 4);
}
/// Helper function to generate a cross structure
fn generate_cross(editor: &mut WorldEditor, x: i32, z: i32, height: i32) {
// Base
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
// Vertical beam
for y in 2..=height {
editor.set_block(STONE_BRICK_WALL, x, y, z, None, None);
}
// Horizontal beam (cross arm) at approximately 2/3 height, but at least 2 and at most height-1
let arm_y = ((height * 2 + 2) / 3).clamp(2, height - 1);
// Only place horizontal arms if height allows for them (height >= 3)
if height >= 3 {
editor.set_block(STONE_BRICK_WALL, x - 1, arm_y, z, None, None);
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,10 +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;
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::IndexedRandom;
use rand::Rng;
pub fn generate_landuse(
@@ -58,11 +60,34 @@ pub fn generate_landuse(
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
let trees_ok_to_generate: Vec<TreeType> = {
let mut trees: Vec<TreeType> = vec![];
if let Some(leaf_type) = element.tags.get("leaf_type") {
match leaf_type.as_str() {
"broadleaved" => {
trees.push(TreeType::Oak);
trees.push(TreeType::Birch);
}
"needleleaved" => trees.push(TreeType::Spruce),
_ => {
trees.push(TreeType::Oak);
trees.push(TreeType::Spruce);
trees.push(TreeType::Birch);
}
}
} else {
trees.push(TreeType::Oak);
trees.push(TreeType::Spruce);
trees.push(TreeType::Birch);
}
trees
};
for (x, z) in floor_area {
// 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 {
@@ -74,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 {
@@ -86,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 {
@@ -110,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);
@@ -134,25 +159,39 @@ pub fn generate_landuse(
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if random_choice < 35 {
editor.set_block(OAK_LEAVES, x, 1, z, None, None);
} else if random_choice < 37 {
editor.set_block(FERN, x, 1, z, None, None);
} else if random_choice < 41 {
editor.set_block(LARGE_FERN_LOWER, x, 1, z, None, None);
editor.set_block(LARGE_FERN_UPPER, x, 2, z, None, None);
}
}
}
"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 {
Tree::create(editor, (x, 1, z), Some(building_footprints));
let tree_type = *trees_ok_to_generate
.choose(&mut rng)
.unwrap_or(&TreeType::Oak);
Tree::create_of_type(
editor,
(x, 1, z),
tree_type,
Some(building_footprints),
);
} else if random_choice == 2 {
let flower_block: Block = match rng.gen_range(1..=5) {
let flower_block: Block = match rng.random_range(1..=6) {
1 => OAK_LEAVES,
2 => RED_FLOWER,
3 => BLUE_FLOWER,
4 => YELLOW_FLOWER,
5 => FERN,
_ => WHITE_FLOWER,
};
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);
@@ -166,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 {
@@ -176,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 {
@@ -219,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,
@@ -256,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),
@@ -266,17 +305,17 @@ 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..=17 => editor.set_block(GRASS, x, 1, z, None, None),
3..=16 => editor.set_block(GRASS, x, 1, z, None, None),
_ => {}
}
}
}
"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 {
@@ -285,7 +324,10 @@ pub fn generate_landuse(
editor.set_block(OAK_LEAVES, x, 1, z, None, None);
} else if random_choice < 40 {
editor.set_block(FERN, x, 1, z, None, None);
} else if random_choice < 800 {
} else if random_choice < 65 {
editor.set_block(LARGE_FERN_LOWER, x, 1, z, None, None);
editor.set_block(LARGE_FERN_UPPER, x, 2, z, None, None);
} else if random_choice < 825 {
editor.set_block(GRASS, x, 1, z, None, None);
}
}
@@ -294,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),
@@ -316,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);
}
@@ -325,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(
@@ -335,44 +398,53 @@ pub fn generate_landuse_from_relation(
building_footprints: &BuildingFootprintBitmap,
) {
if rel.tags.contains_key("landuse") {
// Generate individual ways with their original tags
// Process each outer member way individually using cached flood fill.
// We intentionally do not combine all outer nodes into one mega-way,
// because that creates a nonsensical polygon spanning the whole relation
// extent, misses the flood fill cache, and can cause multi-GB allocations.
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
// Use relation tags so the member inherits the relation's landuse=* type
let way_with_rel_tags = ProcessedWay {
id: member.way.id,
nodes: member.way.nodes.clone(),
tags: rel.tags.clone(),
};
generate_landuse(
editor,
&member.way.clone(),
&way_with_rel_tags,
args,
flood_fill_cache,
building_footprints,
);
}
}
// Combine all outer ways into one with relation tags
let mut combined_nodes = Vec::new();
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
combined_nodes.extend(member.way.nodes.clone());
}
}
// Only process if we have nodes
if !combined_nodes.is_empty() {
// Create combined way with relation tags
let combined_way = ProcessedWay {
id: rel.id,
nodes: combined_nodes,
tags: rel.tags.clone(),
};
// Generate landuse area from combined way
generate_landuse(
editor,
&combined_way,
args,
flood_fill_cache,
building_footprints,
);
}
}
}
/// Generates ground blocks for place=* areas (squares, neighbourhoods, etc.)
pub fn generate_place(
editor: &mut WorldEditor,
element: &ProcessedWay,
args: &Args,
flood_fill_cache: &FloodFillCache,
) {
let binding = String::new();
let place_tag = element.tags.get("place").unwrap_or(&binding);
// Determine block type based on place tag
let block_type = match place_tag.as_str() {
"square" => STONE_BRICKS,
"neighbourhood" | "city_block" | "quarter" | "suburb" => SMOOTH_STONE,
_ => return,
};
// Get the area using flood fill cache
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
// Place ground blocks
for (x, z) in floor_area {
editor.set_block(block_type, x, 0, z, None, None);
}
}

View File

@@ -96,18 +96,20 @@ 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 => {
// Flowers
let flower_choice = match random_choice {
0..10 => RED_FLOWER,
10..20 => YELLOW_FLOWER,
20..30 => BLUE_FLOWER,
_ => WHITE_FLOWER,
// Plants
let plant_choice = match random_choice {
0..5 => RED_FLOWER,
5..10 => YELLOW_FLOWER,
10..16 => BLUE_FLOWER,
16..22 => WHITE_FLOWER,
22..30 => FERN,
_ => unreachable!(),
};
editor.set_block(flower_choice, x, 1, z, None, None);
editor.set_block(plant_choice, x, 1, z, None, None);
}
30..90 => {
// Grass
@@ -127,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 => {
@@ -183,41 +185,26 @@ pub fn generate_leisure_from_relation(
building_footprints: &BuildingFootprintBitmap,
) {
if rel.tags.get("leisure") == Some(&"park".to_string()) {
// First generate individual ways with their original tags
// Process each outer member way individually using cached flood fill.
// We intentionally do not combine all outer nodes into one mega-way,
// because that creates a nonsensical polygon spanning the whole relation
// extent, misses the flood fill cache, and can cause multi-GB allocations.
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
// Use relation tags so the member inherits the relation's leisure=* type
let way_with_rel_tags = ProcessedWay {
id: member.way.id,
nodes: member.way.nodes.clone(),
tags: rel.tags.clone(),
};
generate_leisure(
editor,
&member.way,
&way_with_rel_tags,
args,
flood_fill_cache,
building_footprints,
);
}
}
// Then combine all outer ways into one
let mut combined_nodes = Vec::new();
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
combined_nodes.extend(member.way.nodes.clone());
}
}
// Create combined way with relation tags
let combined_way = ProcessedWay {
id: rel.id,
nodes: combined_nodes,
tags: rel.tags.clone(),
};
// Generate leisure area from combined way
generate_leisure(
editor,
&combined_way,
args,
flood_fill_cache,
building_footprints,
);
}
}

View File

@@ -1,16 +1,122 @@
pub mod advertising;
pub mod amenities;
pub mod barriers;
pub mod bridges;
pub mod buildings;
pub mod doors;
pub mod emergency;
pub mod highways;
pub mod historic;
pub mod landuse;
pub mod leisure;
pub mod man_made;
pub mod natural;
pub mod power;
pub mod railways;
pub mod subprocessor;
pub mod tourisms;
pub mod tree;
pub mod water_areas;
pub mod waterways;
use crate::osm_parser::ProcessedNode;
/// Merges way segments that share endpoints into closed rings.
/// Used by water_areas.rs and boundaries.rs for assembling relation members.
pub fn merge_way_segments(rings: &mut Vec<Vec<ProcessedNode>>) {
let mut removed: Vec<usize> = vec![];
let mut merged: Vec<Vec<ProcessedNode>> = vec![];
// Match nodes by ID or proximity (handles synthetic nodes from bbox clipping)
let nodes_match = |a: &ProcessedNode, b: &ProcessedNode| -> bool {
if a.id == b.id {
return true;
}
let dx = (a.x - b.x).abs();
let dz = (a.z - b.z).abs();
dx <= 1 && dz <= 1
};
for i in 0..rings.len() {
for j in 0..rings.len() {
if i == j {
continue;
}
if removed.contains(&i) || removed.contains(&j) {
continue;
}
let x: &Vec<ProcessedNode> = &rings[i];
let y: &Vec<ProcessedNode> = &rings[j];
// Skip empty rings (can happen after clipping)
if x.is_empty() || y.is_empty() {
continue;
}
let x_first = &x[0];
let x_last = x.last().unwrap();
let y_first = &y[0];
let y_last = y.last().unwrap();
// Skip already-closed rings
if nodes_match(x_first, x_last) {
continue;
}
if nodes_match(y_first, y_last) {
continue;
}
if nodes_match(x_first, y_first) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.reverse();
x.extend(y.iter().skip(1).cloned());
merged.push(x);
} else if nodes_match(x_last, y_last) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.extend(y.iter().rev().skip(1).cloned());
merged.push(x);
} else if nodes_match(x_first, y_last) {
removed.push(i);
removed.push(j);
let mut y: Vec<ProcessedNode> = y.clone();
y.extend(x.iter().skip(1).cloned());
merged.push(y);
} else if nodes_match(x_last, y_first) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.extend(y.iter().skip(1).cloned());
merged.push(x);
}
}
}
removed.sort();
for r in removed.iter().rev() {
rings.remove(*r);
}
let merged_len: usize = merged.len();
for m in merged {
rings.push(m);
}
if merged_len > 0 {
merge_way_segments(rings);
}
}

View File

@@ -2,11 +2,11 @@ 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;
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::Rng;
use rand::{prelude::IndexedRandom, Rng};
pub fn generate_natural(
editor: &mut WorldEditor,
@@ -21,7 +21,66 @@ pub fn generate_natural(
let x: i32 = node.x;
let z: i32 = node.z;
Tree::create(editor, (x, 1, z), Some(building_footprints));
let mut trees_ok_to_generate: Vec<TreeType> = vec![];
if let Some(species) = element.tags().get("species") {
if species.contains("Betula") {
trees_ok_to_generate.push(TreeType::Birch);
}
if species.contains("Quercus") {
trees_ok_to_generate.push(TreeType::Oak);
}
if species.contains("Picea") {
trees_ok_to_generate.push(TreeType::Spruce);
}
} else if let Some(genus_wikidata) = element.tags().get("genus:wikidata") {
match genus_wikidata.as_str() {
"Q12004" => trees_ok_to_generate.push(TreeType::Birch),
"Q26782" => trees_ok_to_generate.push(TreeType::Oak),
"Q25243" => trees_ok_to_generate.push(TreeType::Spruce),
_ => {
trees_ok_to_generate.push(TreeType::Oak);
trees_ok_to_generate.push(TreeType::Spruce);
trees_ok_to_generate.push(TreeType::Birch);
}
}
} else if let Some(genus) = element.tags().get("genus") {
match genus.as_str() {
"Betula" => trees_ok_to_generate.push(TreeType::Birch),
"Quercus" => trees_ok_to_generate.push(TreeType::Oak),
"Picea" => trees_ok_to_generate.push(TreeType::Spruce),
_ => trees_ok_to_generate.push(TreeType::Oak),
}
} else if let Some(leaf_type) = element.tags().get("leaf_type") {
match leaf_type.as_str() {
"broadleaved" => {
trees_ok_to_generate.push(TreeType::Oak);
trees_ok_to_generate.push(TreeType::Birch);
}
"needleleaved" => trees_ok_to_generate.push(TreeType::Spruce),
_ => {
trees_ok_to_generate.push(TreeType::Oak);
trees_ok_to_generate.push(TreeType::Spruce);
trees_ok_to_generate.push(TreeType::Birch);
}
}
} else {
trees_ok_to_generate.push(TreeType::Oak);
trees_ok_to_generate.push(TreeType::Spruce);
trees_ok_to_generate.push(TreeType::Birch);
}
if trees_ok_to_generate.is_empty() {
trees_ok_to_generate.push(TreeType::Oak);
trees_ok_to_generate.push(TreeType::Spruce);
trees_ok_to_generate.push(TreeType::Birch);
}
let mut rng = element_rng(element.id());
let tree_type = *trees_ok_to_generate
.choose(&mut rng)
.unwrap_or(&TreeType::Oak);
Tree::create_of_type(editor, (x, 1, z), tree_type, Some(building_footprints));
}
} else {
let mut previous_node: Option<(i32, i32)> = None;
@@ -81,6 +140,29 @@ pub fn generate_natural(
let filled_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(way, args.timeout.as_ref());
let trees_ok_to_generate: Vec<TreeType> = {
let mut trees: Vec<TreeType> = vec![];
if let Some(leaf_type) = element.tags().get("leaf_type") {
match leaf_type.as_str() {
"broadleaved" => {
trees.push(TreeType::Oak);
trees.push(TreeType::Birch);
}
"needleleaved" => trees.push(TreeType::Spruce),
_ => {
trees.push(TreeType::Oak);
trees.push(TreeType::Spruce);
trees.push(TreeType::Birch);
}
}
} else {
trees.push(TreeType::Oak);
trees.push(TreeType::Spruce);
trees.push(TreeType::Birch);
}
trees
};
// Use deterministic RNG seeded by element ID for consistent results across region boundaries
let mut rng = element_rng(way.id);
@@ -110,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);
}
}
@@ -118,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);
@@ -133,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,
@@ -162,11 +244,19 @@ 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 {
Tree::create(editor, (x, 1, z), Some(building_footprints));
let tree_type = *trees_ok_to_generate
.choose(&mut rng)
.unwrap_or(&TreeType::Oak);
Tree::create_of_type(
editor,
(x, 1, z),
tree_type,
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,
@@ -179,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);
}
}
@@ -193,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,
@@ -221,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,
@@ -233,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,
@@ -243,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);
}
}
@@ -256,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;
}
@@ -265,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,
@@ -280,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) {
@@ -293,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,
@@ -305,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(
@@ -335,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);
@@ -345,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);
}
@@ -353,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,
@@ -366,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);
@@ -386,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);
@@ -401,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,
@@ -421,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,
@@ -460,44 +551,26 @@ pub fn generate_natural_from_relation(
building_footprints: &BuildingFootprintBitmap,
) {
if rel.tags.contains_key("natural") {
// Generate individual ways with their original tags
// Process each outer member way individually using cached flood fill.
// We intentionally do not combine all outer nodes into one mega-way,
// because that creates a nonsensical polygon spanning the whole relation
// extent, misses the flood fill cache, and can cause multi-GB allocations.
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
// Use relation tags so the member inherits the relation's natural=* type
let way_with_rel_tags = ProcessedWay {
id: member.way.id,
nodes: member.way.nodes.clone(),
tags: rel.tags.clone(),
};
generate_natural(
editor,
&ProcessedElement::Way((*member.way).clone()),
&ProcessedElement::Way(way_with_rel_tags),
args,
flood_fill_cache,
building_footprints,
);
}
}
// Combine all outer ways into one with relation tags
let mut combined_nodes = Vec::new();
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
combined_nodes.extend(member.way.nodes.clone());
}
}
// Only process if we have nodes
if !combined_nodes.is_empty() {
// Create combined way with relation tags
let combined_way = ProcessedWay {
id: rel.id,
nodes: combined_nodes,
tags: rel.tags.clone(),
};
// Generate natural area from combined way
generate_natural(
editor,
&ProcessedElement::Way(combined_way),
args,
flood_fill_cache,
building_footprints,
);
}
}
}

View File

@@ -0,0 +1,385 @@
//! Processing of power infrastructure elements.
//!
//! This module handles power-related OSM elements including:
//! - `power=tower` - Large electricity pylons
//! - `power=pole` - Smaller wooden/concrete poles
//! - `power=line` - Power lines connecting towers/poles
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::osm_parser::{ProcessedElement, ProcessedNode, ProcessedWay};
use crate::world_editor::WorldEditor;
/// Generate power infrastructure from way elements (power lines)
pub fn generate_power(editor: &mut WorldEditor, element: &ProcessedElement) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = element.tags().get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = element.tags().get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
// Skip underground power infrastructure
if element
.tags()
.get("location")
.map(|v| v == "underground" || v == "underwater")
.unwrap_or(false)
{
return;
}
if element
.tags()
.get("tunnel")
.map(|v| v == "yes")
.unwrap_or(false)
{
return;
}
if let Some(power_type) = element.tags().get("power") {
match power_type.as_str() {
"line" | "minor_line" => {
if let ProcessedElement::Way(way) = element {
generate_power_line(editor, way);
}
}
"tower" => generate_power_tower(editor, element),
"pole" => generate_power_pole(editor, element),
_ => {}
}
}
}
/// Generate power infrastructure from node elements
pub fn generate_power_nodes(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
// Skip underground power infrastructure
if node
.tags
.get("location")
.map(|v| v == "underground" || v == "underwater")
.unwrap_or(false)
{
return;
}
if node.tags.get("tunnel").map(|v| v == "yes").unwrap_or(false) {
return;
}
if let Some(power_type) = node.tags.get("power") {
match power_type.as_str() {
"tower" => generate_power_tower_from_node(editor, node),
"pole" => generate_power_pole_from_node(editor, node),
_ => {}
}
}
}
/// Generate a high-voltage transmission tower (pylon) from a ProcessedElement
fn generate_power_tower(editor: &mut WorldEditor, element: &ProcessedElement) {
let Some(first_node) = element.nodes().next() else {
return;
};
let height = element
.tags()
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(25)
.clamp(15, 40);
generate_power_tower_impl(editor, first_node.x, first_node.z, height);
}
/// Generate a high-voltage transmission tower (pylon) from a ProcessedNode
fn generate_power_tower_from_node(editor: &mut WorldEditor, node: &ProcessedNode) {
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(25)
.clamp(15, 40);
generate_power_tower_impl(editor, node.x, node.z, height);
}
/// Generate a high-voltage transmission tower (pylon)
///
/// Creates a realistic lattice tower structure using iron bars and iron blocks.
/// The design is a tapered lattice tower with cross-bracing and insulators.
fn generate_power_tower_impl(editor: &mut WorldEditor, x: i32, z: i32, height: i32) {
// Tower design constants
let base_width = 3; // Half-width at base (so 7x7 footprint)
let top_width = 1; // Half-width at top (so 3x3)
let arm_height = height - 4; // Height where arms extend
let arm_length = 5; // How far arms extend horizontally
// Build the four corner legs with tapering
for y in 1..=height {
// Calculate taper: legs get closer together as we go up
let progress = y as f32 / height as f32;
let current_width = base_width - ((base_width - top_width) as f32 * progress) as i32;
// Four corner positions
let corners = [
(x - current_width, z - current_width),
(x + current_width, z - current_width),
(x - current_width, z + current_width),
(x + current_width, z + current_width),
];
for (cx, cz) in corners {
editor.set_block(IRON_BLOCK, cx, y, cz, None, None);
}
// Add horizontal cross-bracing every 5 blocks
if y % 5 == 0 && y < height - 2 {
// Connect corners horizontally
for dx in -current_width..=current_width {
editor.set_block(IRON_BLOCK, x + dx, y, z - current_width, None, None);
editor.set_block(IRON_BLOCK, x + dx, y, z + current_width, None, None);
}
for dz in -current_width..=current_width {
editor.set_block(IRON_BLOCK, x - current_width, y, z + dz, None, None);
editor.set_block(IRON_BLOCK, x + current_width, y, z + dz, None, None);
}
}
// Add diagonal bracing between cross-brace levels
if y % 5 >= 1 && y % 5 <= 4 && y > 1 && y < height - 2 {
let prev_width = base_width
- ((base_width - top_width) as f32 * ((y - 1) as f32 / height as f32)) as i32;
// Only add center vertical support if the width changed
if current_width != prev_width || y % 5 == 2 {
editor.set_block(IRON_BARS, x, y, z, None, None);
}
}
}
// Create the cross-arms at arm_height for holding power lines
// These extend outward in two directions (perpendicular to typical line direction)
for arm_offset in [-arm_length, arm_length] {
// Main arm beam (iron blocks for strength)
for dx in 0..=arm_length {
let arm_x = if arm_offset < 0 { x - dx } else { x + dx };
editor.set_block(IRON_BLOCK, arm_x, arm_height, z, None, None);
// Add second arm perpendicular
editor.set_block(
IRON_BLOCK,
x,
arm_height,
z + if arm_offset < 0 { -dx } else { dx },
None,
None,
);
}
// Insulators hanging from arm ends (end rods to simulate ceramic insulators)
let end_x = if arm_offset < 0 {
x - arm_length
} else {
x + arm_length
};
editor.set_block(END_ROD, end_x, arm_height - 1, z, None, None);
editor.set_block(END_ROD, x, arm_height - 1, z + arm_offset, None, None);
}
// Add a second, smaller arm set lower for additional circuits
let lower_arm_height = arm_height - 6;
if lower_arm_height > 5 {
let lower_arm_length = arm_length - 1;
for arm_offset in [-lower_arm_length, lower_arm_length] {
for dx in 0..=lower_arm_length {
let arm_x = if arm_offset < 0 { x - dx } else { x + dx };
editor.set_block(IRON_BLOCK, arm_x, lower_arm_height, z, None, None);
}
let end_x = if arm_offset < 0 {
x - lower_arm_length
} else {
x + lower_arm_length
};
editor.set_block(END_ROD, end_x, lower_arm_height - 1, z, None, None);
}
}
// Top finial/lightning rod
editor.set_block(IRON_BLOCK, x, height, z, None, None);
editor.set_block(LIGHTNING_ROD, x, height + 1, z, None, None);
// Concrete foundation at base
for dx in -3..=3 {
for dz in -3..=3 {
editor.set_block(GRAY_CONCRETE, x + dx, 0, z + dz, None, None);
}
}
}
/// Generate a wooden/concrete power pole from a ProcessedElement
fn generate_power_pole(editor: &mut WorldEditor, element: &ProcessedElement) {
let Some(first_node) = element.nodes().next() else {
return;
};
let height = element
.tags()
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(6, 15);
let pole_material = element
.tags()
.get("material")
.map(|m| m.as_str())
.unwrap_or("wood");
generate_power_pole_impl(editor, first_node.x, first_node.z, height, pole_material);
}
/// Generate a wooden/concrete power pole from a ProcessedNode
fn generate_power_pole_from_node(editor: &mut WorldEditor, node: &ProcessedNode) {
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(6, 15);
let pole_material = node
.tags
.get("material")
.map(|m| m.as_str())
.unwrap_or("wood");
generate_power_pole_impl(editor, node.x, node.z, height, pole_material);
}
/// Generate a wooden/concrete power pole
///
/// Creates a simpler single-pole structure for lower voltage distribution lines.
fn generate_power_pole_impl(
editor: &mut WorldEditor,
x: i32,
z: i32,
height: i32,
pole_material: &str,
) {
let pole_block = match pole_material {
"concrete" => LIGHT_GRAY_CONCRETE,
"steel" | "metal" => IRON_BLOCK,
_ => OAK_LOG, // Default to wood
};
// Build the main pole
for y in 1..=height {
editor.set_block(pole_block, x, y, z, None, None);
}
// Cross-arm at top (perpendicular beam for wires)
let arm_length = 2;
for dx in -arm_length..=arm_length {
editor.set_block(OAK_FENCE, x + dx, height, z, None, None);
}
// Insulators at arm ends
editor.set_block(END_ROD, x - arm_length, height + 1, z, None, None);
editor.set_block(END_ROD, x + arm_length, height + 1, z, None, None);
editor.set_block(END_ROD, x, height + 1, z, None, None); // Center insulator
}
/// Generate power lines connecting towers/poles
///
/// Creates a catenary-like curve (simplified) between nodes to simulate
/// the natural sag of power cables.
fn generate_power_line(editor: &mut WorldEditor, way: &ProcessedWay) {
if way.nodes.len() < 2 {
return;
}
// Determine line height based on voltage (higher voltage = taller structures)
let base_height = way
.tags
.get("voltage")
.and_then(|v| v.parse::<i32>().ok())
.map(|voltage| {
if voltage >= 220000 {
22 // High voltage transmission
} else if voltage >= 110000 {
18
} else if voltage >= 33000 {
14
} else {
10 // Distribution lines
}
})
.unwrap_or(15);
// Process consecutive node pairs
for i in 1..way.nodes.len() {
let start = &way.nodes[i - 1];
let end = &way.nodes[i];
// Calculate distance between nodes
let dx = (end.x - start.x) as f64;
let dz = (end.z - start.z) as f64;
let distance = (dx * dx + dz * dz).sqrt();
// Calculate sag based on span length (longer spans = more sag)
let max_sag = (distance / 15.0).clamp(1.0, 6.0) as i32;
// Determine chain orientation based on line direction
// If the line runs more along X-axis, use CHAIN_X; if more along Z-axis, use CHAIN_Z
let chain_block = if dx.abs() >= dz.abs() {
CHAIN_X // Line runs primarily along X-axis
} else {
CHAIN_Z // Line runs primarily along Z-axis
};
// Generate points along the line using Bresenham
let line_points = bresenham_line(start.x, 0, start.z, end.x, 0, end.z);
for (idx, (lx, _, lz)) in line_points.iter().enumerate() {
// Calculate position along the span (0.0 to 1.0)
// Use len-1 as denominator so last point reaches t=1.0
let denom = (line_points.len().saturating_sub(1)).max(1) as f64;
let t = idx as f64 / denom;
// Catenary approximation: sag is maximum at center, zero at ends
// Using parabola: sag = 4 * max_sag * t * (1 - t)
let sag = (4.0 * max_sag as f64 * t * (1.0 - t)) as i32;
// Ensure wire doesn't go underground (minimum height of 3 blocks above ground)
let wire_y = (base_height - sag).max(3);
// Place the wire block (chain aligned with line direction)
editor.set_block(chain_block, *lx, wire_y, *lz, None, None);
// For high voltage lines, add parallel wires offset to sides
if base_height >= 18 {
// Three-phase power: 3 parallel lines
// Offset perpendicular to the line direction
if dx.abs() >= dz.abs() {
// Line runs along X, offset in Z
editor.set_block(chain_block, *lx, wire_y, *lz + 1, None, None);
editor.set_block(chain_block, *lx, wire_y, *lz - 1, None, None);
} else {
// Line runs along Z, offset in X
editor.set_block(chain_block, *lx + 1, wire_y, *lz, None, None);
editor.set_block(chain_block, *lx - 1, wire_y, *lz, None, None);
}
}
}
}
}

View File

@@ -58,7 +58,7 @@ const INTERIOR1_LAYER2: [[char; 23]; 23] = [
['W', 'W', 'D', 'W', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'B', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
];
/// Interior layout for building level floors (1nd layer above floor)
/// Interior layout for building level floors (1st layer above floor)
#[rustfmt::skip]
const INTERIOR2_LAYER1: [[char; 23]; 23] = [
['W', 'W', 'W', 'D', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W',],
@@ -114,6 +114,119 @@ const INTERIOR2_LAYER2: [[char; 23]; 23] = [
['P', 'P', ' ', ' ', ' ', 'E', 'B', 'B', 'B', ' ', ' ', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'B', ' ', 'B', ' ', 'D',],
];
// Generic Abandoned Building Interiors
/// Interior layout for building ground floors (1st layer above floor)
#[rustfmt::skip]
const ABANDONED_INTERIOR1_LAYER1: [[char; 23]; 23] = [
['1', 'U', ' ', 'W', 'C', ' ', ' ', ' ', 'S', 'S', 'W', 'b', 'T', 'T', 'd', 'W', '7', '8', ' ', ' ', ' ', ' ', 'W',],
['2', ' ', ' ', 'W', 'F', ' ', ' ', ' ', 'U', 'U', 'W', 'b', 'T', 'T', 'd', 'W', '7', '8', ' ', ' ', ' ', 'B', 'W',],
[' ', ' ', ' ', 'W', 'F', ' ', ' ', ' ', ' ', ' ', 'W', 'b', 'T', 'T', 'd', 'W', 'W', 'W', 'D', 'W', 'W', 'W', 'W',],
['W', 'W', 'D', 'W', 'L', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'M', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'D',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'c', 'c', 'c', ' ', ' ', 'J', 'W', ' ', ' ', ' ', 'd', 'W', 'W', 'W',],
['W', 'W', 'W', 'W', 'D', 'W', ' ', ' ', 'W', 'T', 'S', 'S', 'T', ' ', ' ', 'W', 'S', 'S', ' ', 'd', 'W', 'W', 'W',],
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', 'T', 'T', 'T', 'T', ' ', ' ', 'W', 'U', 'U', ' ', 'd', 'W', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'D', 'T', 'T', 'T', 'T', ' ', 'B', 'W', ' ', ' ', ' ', 'd', 'W', ' ', ' ',],
['L', ' ', 'M', 'L', 'W', 'W', ' ', ' ', 'W', 'J', 'U', 'U', ' ', ' ', 'B', 'W', 'W', 'D', 'W', 'W', 'W', ' ', ' ',],
['W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'W', 'C', 'C', 'W', 'W',],
['c', 'c', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', 'W', ' ', ' ', 'W', 'W',],
[' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', 'D',],
[' ', '6', ' ', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'D', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['U', '5', ' ', 'W', ' ', ' ', 'W', 'C', 'F', 'F', ' ', ' ', 'W', ' ', ' ', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', 'W', 'L', ' ', 'W', 'M', ' ', 'b', 'W', ' ', ' ', 'W',],
['B', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' ', 'b', 'W', 'J', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', 'U', ' ', ' ', 'W', 'B', ' ', 'D',],
['J', ' ', ' ', 'C', 'a', 'a', 'W', 'L', 'F', ' ', 'W', 'F', ' ', 'W', 'L', 'W', '7', '8', ' ', 'W', 'B', ' ', 'W',],
['B', ' ', ' ', 'd', 'W', 'W', 'W', 'W', 'W', ' ', 'W', 'M', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'C', ' ', 'W',],
['B', ' ', ' ', 'd', 'W', ' ', ' ', ' ', 'D', ' ', 'W', 'C', ' ', ' ', 'W', 'W', 'c', 'c', 'c', 'c', 'W', 'D', 'W',],
['W', 'W', 'D', 'W', 'C', ' ', ' ', ' ', 'W', 'W', 'W', 'b', 'T', 'T', 'B', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
];
/// Interior layout for building ground floors (2nd layer above floor)
#[rustfmt::skip]
const ABANDONED_INTERIOR1_LAYER2: [[char; 23]; 23] = [
[' ', 'P', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'B', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'P', 'P', 'W', 'B', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', 'B', 'W',],
[' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'B', ' ', ' ', 'B', 'W', 'W', 'W', 'D', 'W', 'W', 'W', 'W',],
['W', 'W', 'D', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'D',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'B', 'B', 'B', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'B', 'W', 'W', 'W',],
['W', 'W', 'W', 'W', 'D', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'B', 'W', 'W', 'W',],
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'P', 'P', ' ', 'B', 'W', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', 'B', 'W', ' ', ' ', ' ', 'B', 'W', ' ', ' ',],
[' ', ' ', ' ', ' ', 'W', 'W', ' ', ' ', 'W', ' ', 'P', 'P', ' ', ' ', 'B', 'W', 'W', 'D', 'W', 'W', 'W', ' ', ' ',],
['W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'W', 'C', 'C', 'W', 'W',],
['B', 'B', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', 'W', ' ', ' ', 'W', 'W',],
[' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', 'D',],
[' ', ' ', ' ', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'D', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['P', ' ', ' ', 'W', ' ', ' ', 'W', 'N', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', 'W', 'D', 'W', 'W', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' ', 'B', 'W', ' ', ' ', 'W',],
['B', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' ', 'C', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', 'P', ' ', ' ', 'W', 'B', ' ', 'D',],
[' ', ' ', ' ', ' ', 'B', 'B', 'W', ' ', ' ', ' ', 'W', ' ', ' ', 'W', 'P', 'W', ' ', ' ', ' ', 'W', 'B', ' ', 'W',],
['B', ' ', ' ', 'B', 'W', 'W', 'W', 'W', 'W', ' ', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W',],
['B', ' ', ' ', 'B', 'W', ' ', ' ', ' ', 'D', ' ', 'W', 'N', ' ', ' ', 'W', 'W', 'B', 'B', 'B', 'B', 'W', 'D', 'W',],
['W', 'W', 'D', 'W', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'B', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
];
/// Interior layout for building level floors (1st layer above floor)
#[rustfmt::skip]
const ABANDONED_INTERIOR2_LAYER1: [[char; 23]; 23] = [
['W', 'W', 'W', 'D', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W',],
['U', ' ', ' ', ' ', ' ', ' ', 'C', 'W', 'L', ' ', ' ', 'L', 'W', 'M', 'M', 'W', ' ', ' ', ' ', ' ', ' ', 'L', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', 'W', 'W', 'W', ' ', ' ', 'Q', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', 'S', 'S', 'S', ' ', 'W',],
[' ', ' ', 'W', 'F', ' ', ' ', ' ', 'Q', 'C', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'J', ' ', 'U', 'U', 'U', ' ', 'D',],
['U', ' ', 'W', 'F', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',],
['U', ' ', 'W', 'F', ' ', ' ', ' ', 'D', ' ', ' ', 'T', 'T', 'W', ' ', ' ', ' ', ' ', ' ', 'U', 'W', ' ', 'L', 'W',],
[' ', ' ', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', 'T', 'J', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W', ' ', ' ', 'W', 'L', ' ', 'W',],
['J', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'C', ' ', ' ', ' ', 'B', 'W', ' ', ' ', 'W', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', 'W', 'L', ' ', ' ', ' ', ' ', 'W', 'C', ' ', ' ', ' ', 'B', 'W', ' ', ' ', 'W', 'W', 'D', 'W',],
[' ', 'M', 'c', 'B', 'W', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', 'd', 'W', 'L', ' ', ' ', ' ', ' ', 'W', 'L', ' ', ' ', 'B', 'W', 'W', 'B', 'B', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', 'd', 'W', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'D',],
[' ', ' ', ' ', ' ', 'D', ' ', ' ', 'U', ' ', ' ', ' ', 'D', ' ', ' ', 'F', 'F', 'W', 'M', 'M', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', 'W', ' ', ' ', 'U', ' ', ' ', 'W', 'W', ' ', ' ', ' ', ' ', 'C', ' ', ' ', 'W', ' ', ' ', 'W',],
['C', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', ' ', ' ', 'L', ' ', ' ', 'W', 'W', 'D', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['L', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'L', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'U', 'U', ' ', 'Q', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'U', 'U', ' ', 'Q', 'b', ' ', 'U', 'U', 'B', ' ', ' ', ' ', ' ', ' ', 'W',],
['S', 'S', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'Q', 'b', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'd', ' ', 'W',],
['U', 'U', ' ', ' ', ' ', 'L', 'a', 'a', 'a', ' ', ' ', 'Q', 'B', 'a', 'a', 'a', 'a', 'a', 'a', ' ', 'd', 'D', 'W',],
];
/// Interior layout for building level floors (2nd layer above floor)
#[rustfmt::skip]
const ABANDONED_INTERIOR2_LAYER2: [[char; 23]; 23] = [
['W', 'W', 'W', 'D', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W',],
['P', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'O', ' ', ' ', 'O', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', 'O', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', 'W', 'W', 'W', ' ', ' ', 'Q', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', 'W', 'F', ' ', ' ', ' ', 'Q', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'P', 'P', 'P', ' ', 'D',],
['P', ' ', 'W', 'F', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',],
['P', ' ', 'W', 'F', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', 'P', 'W', ' ', 'P', 'W',],
[' ', ' ', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'D', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'P', ' ', ' ', ' ', 'B', 'W', ' ', ' ', 'W', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', 'W', 'O', ' ', ' ', ' ', ' ', 'W', 'P', ' ', ' ', ' ', 'B', 'W', ' ', ' ', 'W', 'W', 'D', 'W',],
[' ', ' ', 'c', 'B', 'W', 'W', 'W', 'W', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'B', 'W', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', 'd', 'W', 'O', ' ', ' ', ' ', ' ', 'W', 'O', ' ', ' ', 'B', 'W', 'W', 'B', 'B', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', 'd', 'W', ' ', ' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'D',],
[' ', ' ', ' ', ' ', 'D', ' ', ' ', 'P', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', 'W', ' ', ' ', 'P', ' ', ' ', 'W', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', ' ', ' ', 'O', ' ', ' ', 'W', 'W', 'D', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'D', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['O', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'O', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W',],
['W', 'W', 'W', 'W', 'W', 'W', ' ', ' ', 'P', 'P', ' ', 'Q', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'P', 'P', ' ', 'Q', 'b', ' ', 'P', 'P', 'c', ' ', ' ', ' ', ' ', ' ', 'W',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'Q', 'b', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'd', ' ', 'W',],
['P', 'P', ' ', ' ', ' ', 'O', 'a', 'a', 'a', ' ', ' ', 'Q', 'b', 'a', 'a', 'a', 'a', 'a', 'a', ' ', 'd', ' ', 'D',],
];
/// Maps interior layout characters to actual block types for different floor layers
#[inline(always)]
pub fn get_interior_block(c: char, is_layer2: bool, wall_block: Block) -> Option<Block> {
@@ -145,12 +258,19 @@ pub fn get_interior_block(c: char, is_layer2: bool, wall_block: Block) -> Option
Some(DARK_OAK_DOOR_LOWER)
}
}
'J' => Some(NOTE_BLOCK), // Note block
'G' => Some(GLOWSTONE), // Glowstone
'N' => Some(BREWING_STAND), // Brewing Stand
'T' => Some(WHITE_CARPET), // White Carpet
'E' => Some(OAK_LEAVES), // Oak Leaves
_ => None, // Default case for unknown characters
'J' => Some(NOTE_BLOCK), // Note block
'G' => Some(GLOWSTONE), // Glowstone
'N' => Some(BREWING_STAND), // Brewing Stand
'T' => Some(WHITE_CARPET), // White Carpet
'E' => Some(OAK_LEAVES), // Oak Leaves
'O' => Some(COBWEB), // Cobweb
'a' => Some(CHISELLED_BOOKSHELF_NORTH), // Chiseled Bookshelf
'b' => Some(CHISELLED_BOOKSHELF_EAST), // Chiseled Bookshelf East
'c' => Some(CHISELLED_BOOKSHELF_SOUTH), // Chiseled Bookshelf South
'd' => Some(CHISELLED_BOOKSHELF_WEST), // Chiseled Bookshelf West
'M' => Some(DAMAGED_ANVIL), // Damaged Anvil
'Q' => Some(SCAFFOLDING), // Scaffolding
_ => None, // Default case for unknown characters
}
}
@@ -170,6 +290,7 @@ pub fn generate_building_interior(
args: &crate::args::Args,
element: &crate::osm_parser::ProcessedWay,
abs_terrain_offset: i32,
is_abandoned_building: bool,
) {
// Skip interior generation for very small buildings
let width = max_x - min_x + 1;
@@ -214,7 +335,13 @@ pub fn generate_building_interior(
};
// Choose the appropriate interior pattern based on floor number
let (layer1, layer2) = if floor_index == 0 {
let (layer1, layer2) = if is_abandoned_building {
if floor_index == 0 {
(&ABANDONED_INTERIOR1_LAYER1, &ABANDONED_INTERIOR1_LAYER2)
} else {
(&ABANDONED_INTERIOR2_LAYER1, &ABANDONED_INTERIOR2_LAYER2)
}
} else if floor_index == 0 {
// Ground floor uses INTERIOR1 patterns
(&INTERIOR1_LAYER1, &INTERIOR1_LAYER2)
} else {

View File

@@ -83,6 +83,33 @@ const BIRCH_LEAVES_FILL: [(Coord, Coord); 5] = [
((0, 7, 0), (0, 8, 0)),
];
/// Dark oak: short but wide canopy, leaves start at y=3 up to y=6 with a cap
const DARK_OAK_LEAVES_FILL: [(Coord, Coord); 5] = [
((-1, 3, 0), (-1, 6, 0)),
((1, 3, 0), (1, 6, 0)),
((0, 3, -1), (0, 6, -1)),
((0, 3, 1), (0, 6, 1)),
((0, 6, 0), (0, 7, 0)),
];
/// Jungle: tall tree with canopy only near the top, leaves from y=7 to y=11
const JUNGLE_LEAVES_FILL: [(Coord, Coord); 5] = [
((-1, 7, 0), (-1, 11, 0)),
((1, 7, 0), (1, 11, 0)),
((0, 7, -1), (0, 11, -1)),
((0, 7, 1), (0, 11, 1)),
((0, 11, 0), (0, 12, 0)),
];
/// Acacia: umbrella-shaped canopy with a gentle dome, leaves from y=5 to y=8
const ACACIA_LEAVES_FILL: [(Coord, Coord); 5] = [
((-1, 5, 0), (-1, 8, 0)),
((1, 5, 0), (1, 8, 0)),
((0, 5, -1), (0, 8, -1)),
((0, 5, 1), (0, 8, 1)),
((0, 8, 0), (0, 9, 0)),
];
//////////////////////////////////////////////////
/// Helper function to set blocks in various patterns.
@@ -92,10 +119,14 @@ fn round(editor: &mut WorldEditor, material: Block, (x, y, z): Coord, block_patt
}
}
#[derive(Clone, Copy)]
pub enum TreeType {
Oak,
Spruce,
Birch,
DarkOak,
Jungle,
Acacia,
}
// TODO what should be moved in, and what should be referenced?
@@ -120,6 +151,30 @@ impl Tree<'_> {
editor: &mut WorldEditor,
(x, y, z): Coord,
building_footprints: Option<&BuildingFootprintBitmap>,
) {
// Use deterministic RNG based on coordinates for consistent tree types across region boundaries
// 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.random_range(1..=10) {
1..=3 => TreeType::Oak,
4..=5 => TreeType::Spruce,
6..=7 => TreeType::Birch,
8 => TreeType::DarkOak,
9 => TreeType::Jungle,
10 => TreeType::Acacia,
_ => unreachable!(),
};
Self::create_of_type(editor, (x, y, z), tree_type, building_footprints);
}
/// Creates a tree of a specific type at the specified coordinates.
pub fn create_of_type(
editor: &mut WorldEditor,
(x, y, z): Coord,
tree_type: TreeType,
building_footprints: Option<&BuildingFootprintBitmap>,
) {
// Skip if this coordinate is inside a building
if let Some(footprints) = building_footprints {
@@ -135,16 +190,7 @@ impl Tree<'_> {
blacklist.extend(Self::get_functional_blocks());
blacklist.push(WATER);
// Use deterministic RNG based on coordinates for consistent tree types across region boundaries
// The element_id of 0 is used as a salt for tree-specific randomness
let mut rng = coord_rng(x, z, 0);
let tree = Self::get_tree(match rng.gen_range(1..=3) {
1 => TreeType::Oak,
2 => TreeType::Spruce,
3 => TreeType::Birch,
_ => unreachable!(),
});
let tree = Self::get_tree(tree_type);
// Build the logs
editor.fill_blocks(
@@ -201,9 +247,9 @@ impl Tree<'_> {
// kind,
log_block: SPRUCE_LOG,
log_height: 9,
leaves_block: BIRCH_LEAVES, // TODO Is this correct?
leaves_block: SPRUCE_LEAVES,
leaves_fill: &SPRUCE_LEAVES_FILL,
// TODO can I omit the third empty vec? May cause issues with iter zip
// Conical shape: wide at bottom, narrow at top
round_ranges: [vec![9, 7, 6, 4, 3], vec![6, 3], vec![]],
},
@@ -215,6 +261,44 @@ impl Tree<'_> {
leaves_fill: &BIRCH_LEAVES_FILL,
round_ranges: [(2..=6).rev().collect(), (2..=4).collect(), vec![]],
},
TreeType::DarkOak => Self {
// Short trunk with a very wide, bushy canopy
log_block: DARK_OAK_LOG,
log_height: 5,
leaves_block: DARK_OAK_LEAVES,
leaves_fill: &DARK_OAK_LEAVES_FILL,
// All 3 round patterns used for maximum width
round_ranges: [
(3..=6).rev().collect(),
(3..=5).rev().collect(),
(4..=5).rev().collect(),
],
},
TreeType::Jungle => Self {
// Tall trunk, canopy clustered at the top
log_block: JUNGLE_LOG,
log_height: 10,
leaves_block: JUNGLE_LEAVES,
leaves_fill: &JUNGLE_LEAVES_FILL,
// Canopy only near the top of the tree
round_ranges: [(7..=11).rev().collect(), (8..=10).rev().collect(), vec![]],
},
TreeType::Acacia => Self {
// Medium trunk with umbrella-shaped canopy, domed center
log_block: ACACIA_LOG,
log_height: 6,
leaves_block: ACACIA_LEAVES,
leaves_fill: &ACACIA_LEAVES_FILL,
// Inner rounds reach higher → gentle dome, outer stays low → wide brim
round_ranges: [
(5..=8).rev().collect(),
(5..=7).rev().collect(),
(6..=7).rev().collect(),
],
},
} // match
} // fn get_tree
@@ -350,6 +434,9 @@ impl Tree<'_> {
GRAY_STAINED_GLASS,
LIGHT_GRAY_STAINED_GLASS,
BROWN_STAINED_GLASS,
CYAN_STAINED_GLASS,
BLUE_STAINED_GLASS,
LIGHT_BLUE_STAINED_GLASS,
TINTED_GLASS,
// Carpets
WHITE_CARPET,

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,18 +51,19 @@ 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
}
}
// Preserve OSM-defined outer/inner roles without modification
merge_way_segments(&mut outers);
super::merge_way_segments(&mut outers);
// Clip assembled rings to bbox (must happen after merging to preserve ring connectivity)
outers = outers
.into_iter()
.filter_map(|ring| clip_water_ring_to_bbox(&ring, xzbbox))
.collect();
merge_way_segments(&mut inners);
super::merge_way_segments(&mut inners);
inners = inners
.into_iter()
.filter_map(|ring| clip_water_ring_to_bbox(&ring, xzbbox))
@@ -112,7 +110,7 @@ pub fn generate_water_areas_from_relation(
}
}
merge_way_segments(&mut inners);
super::merge_way_segments(&mut inners);
if !verify_closed_rings(&inners) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
@@ -163,106 +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);
}
/// Merges way segments that share endpoints into closed rings.
fn merge_way_segments(rings: &mut Vec<Vec<ProcessedNode>>) {
let mut removed: Vec<usize> = vec![];
let mut merged: Vec<Vec<ProcessedNode>> = vec![];
// Match nodes by ID or proximity (handles synthetic nodes from bbox clipping)
let nodes_match = |a: &ProcessedNode, b: &ProcessedNode| -> bool {
if a.id == b.id {
return true;
}
let dx = (a.x - b.x).abs();
let dz = (a.z - b.z).abs();
dx <= 1 && dz <= 1
};
for i in 0..rings.len() {
for j in 0..rings.len() {
if i == j {
continue;
}
if removed.contains(&i) || removed.contains(&j) {
continue;
}
let x: &Vec<ProcessedNode> = &rings[i];
let y: &Vec<ProcessedNode> = &rings[j];
// Skip empty rings (can happen after clipping)
if x.is_empty() || y.is_empty() {
continue;
}
let x_first = &x[0];
let x_last = x.last().unwrap();
let y_first = &y[0];
let y_last = y.last().unwrap();
// Skip already-closed rings
if nodes_match(x_first, x_last) {
continue;
}
if nodes_match(y_first, y_last) {
continue;
}
if nodes_match(x_first, y_first) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.reverse();
x.extend(y.iter().skip(1).cloned());
merged.push(x);
} else if nodes_match(x_last, y_last) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.extend(y.iter().rev().skip(1).cloned());
merged.push(x);
} else if nodes_match(x_first, y_last) {
removed.push(i);
removed.push(j);
let mut y: Vec<ProcessedNode> = y.clone();
y.extend(x.iter().skip(1).cloned());
merged.push(y);
} else if nodes_match(x_last, y_first) {
removed.push(i);
removed.push(j);
let mut x: Vec<ProcessedNode> = x.clone();
x.extend(y.iter().skip(1).cloned());
merged.push(x);
}
}
}
removed.sort();
for r in removed.iter().rev() {
rings.remove(*r);
}
let merged_len: usize = merged.len();
for m in merged {
rings.push(m);
}
if merged_len > 0 {
merge_way_segments(rings);
}
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).
@@ -288,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

@@ -1,6 +1,9 @@
use crate::coordinate_system::{geographic::LLBBox, transformation::geo_distance};
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use crate::{
coordinate_system::{geographic::LLBBox, transformation::geo_distance},
progress::emit_gui_progress_update,
};
use image::Rgb;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
@@ -18,6 +21,8 @@ const MIN_ZOOM: u8 = 10;
const MAX_ZOOM: u8 = 15;
/// Maximum concurrent tile downloads to be respectful to AWS
const MAX_CONCURRENT_DOWNLOADS: usize = 8;
/// Maximum age for cached tiles in days before they are cleaned up
const TILE_CACHE_MAX_AGE_DAYS: u64 = 7;
/// Holds processed elevation data and metadata
#[derive(Clone)]
@@ -35,6 +40,88 @@ type TileImage = image::ImageBuffer<Rgb<u8>, Vec<u8>>;
/// Result type for tile download operations: ((tile_x, tile_y), image) or error
type TileDownloadResult = Result<((u32, u32), TileImage), String>;
/// Cleans up old cached tiles from the tile cache directory.
/// Only deletes .png files within the arnis-tile-cache directory that are older than TILE_CACHE_MAX_AGE_DAYS.
/// This function is safe and will not delete files outside the cache directory or fail on errors.
pub fn cleanup_old_cached_tiles() {
let tile_cache_dir = PathBuf::from("./arnis-tile-cache");
if !tile_cache_dir.exists() || !tile_cache_dir.is_dir() {
return; // Nothing to clean up
}
let max_age = std::time::Duration::from_secs(TILE_CACHE_MAX_AGE_DAYS * 24 * 60 * 60);
let now = std::time::SystemTime::now();
let mut deleted_count = 0;
let mut error_count = 0;
// Read directory entries
let entries = match std::fs::read_dir(&tile_cache_dir) {
Ok(entries) => entries,
Err(_) => {
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
// Safety check: only process .png files within the cache directory
if !path.is_file() {
continue;
}
// Verify the file is a .png and follows our naming pattern (z{zoom}_x{x}_y{y}.png)
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};
if !file_name.ends_with(".png") || !file_name.starts_with('z') {
continue; // Skip files that don't match our tile naming pattern
}
// Check file age
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let modified = match metadata.modified() {
Ok(time) => time,
Err(_) => continue,
};
let age = match now.duration_since(modified) {
Ok(duration) => duration,
Err(_) => continue, // File modified in the future? Skip it.
};
if age > max_age {
match std::fs::remove_file(&path) {
Ok(()) => deleted_count += 1,
Err(e) => {
// Log but don't fail, this is a best-effort cleanup
if error_count == 0 {
eprintln!(
"Warning: Failed to delete old cached tile {}: {e}",
path.display()
);
}
error_count += 1;
}
}
}
}
if deleted_count > 0 {
println!("Cleaned up {deleted_count} old cached elevation tiles (older than {TILE_CACHE_MAX_AGE_DAYS} days)");
}
if error_count > 1 {
eprintln!("Warning: Failed to delete {error_count} old cached tiles");
}
}
/// Calculates appropriate zoom level for the given bounding box
fn calculate_zoom_level(bbox: &LLBBox) -> u8 {
let lat_diff: f64 = (bbox.max().lat() - bbox.min().lat()).abs();
@@ -52,7 +139,13 @@ fn lat_lng_to_tile(lat: f64, lng: f64, zoom: u8) -> (u32, u32) {
(x, y)
}
/// Downloads a tile from AWS Terrain Tiles service
/// Maximum number of retry attempts for tile downloads
const TILE_DOWNLOAD_MAX_RETRIES: u32 = 3;
/// Base delay in milliseconds for exponential backoff between retries
const TILE_DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 500;
/// Downloads a tile from AWS Terrain Tiles service with retry logic
fn download_tile(
client: &reqwest::blocking::Client,
tile_x: u32,
@@ -66,7 +159,51 @@ fn download_tile(
.replace("{x}", &tile_x.to_string())
.replace("{y}", &tile_y.to_string());
let response = client.get(&url).send().map_err(|e| e.to_string())?;
let mut last_error: String = String::new();
for attempt in 0..TILE_DOWNLOAD_MAX_RETRIES {
if attempt > 0 {
// Exponential backoff: 500ms, 1000ms, 2000ms...
let delay_ms = TILE_DOWNLOAD_RETRY_BASE_DELAY_MS * (1 << (attempt - 1));
eprintln!(
"Retry attempt {}/{} for tile x={},y={},z={} after {}ms delay",
attempt,
TILE_DOWNLOAD_MAX_RETRIES - 1,
tile_x,
tile_y,
zoom,
delay_ms
);
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
match download_tile_once(client, &url, tile_path) {
Ok(img) => return Ok(img),
Err(e) => {
last_error = e;
if attempt < TILE_DOWNLOAD_MAX_RETRIES - 1 {
eprintln!(
"Tile download failed for x={},y={},z={}: {}",
tile_x, tile_y, zoom, last_error
);
}
}
}
}
Err(format!(
"Failed to download tile x={},y={},z={} after {} attempts: {}",
tile_x, tile_y, zoom, TILE_DOWNLOAD_MAX_RETRIES, last_error
))
}
/// Single download attempt for a tile (no retries)
fn download_tile_once(
client: &reqwest::blocking::Client,
url: &str,
tile_path: &Path,
) -> Result<image::ImageBuffer<Rgb<u8>, Vec<u8>>, String> {
let response = client.get(url).send().map_err(|e| e.to_string())?;
response.error_for_status_ref().map_err(|e| e.to_string())?;
let bytes = response.bytes().map_err(|e| e.to_string())?;
std::fs::write(tile_path, &bytes).map_err(|e| e.to_string())?;
@@ -86,35 +223,6 @@ fn fetch_or_load_tile(
tile_path: &Path,
) -> Result<image::ImageBuffer<Rgb<u8>, Vec<u8>>, String> {
if tile_path.exists() {
// Check if the cached file has a reasonable size (PNG files should be at least a few KB)
let file_size = std::fs::metadata(tile_path).map(|m| m.len()).unwrap_or(0);
if file_size < 1000 {
eprintln!(
"Warning: Cached tile at {} appears to be too small ({} bytes). Refetching tile.",
tile_path.display(),
file_size
);
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
"Cached tile appears too small, refetching.",
);
// Remove the potentially corrupted file
if let Err(e) = std::fs::remove_file(tile_path) {
eprintln!("Warning: Failed to remove corrupted tile file: {e}");
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
"Failed to remove corrupted tile file during refetching.",
);
}
// Re-download the tile
return download_tile(client, tile_x, tile_y, zoom, tile_path);
}
// Try to load cached tile, but handle corruption gracefully
match image::open(tile_path) {
Ok(img) => {
@@ -218,16 +326,12 @@ 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}"),
);
}
}
}
println!("Processing {} elevation tiles...", successful_tiles.len());
emit_gui_progress_update(15.0, "Processing elevation...");
// Process tiles sequentially (writes to shared height_grid)
for ((tile_x, tile_y), rgb_img) in successful_tiles {
@@ -328,28 +432,32 @@ pub fn fetch_elevation_data(
// Release raw height grid
drop(height_grid);
let mut mc_heights: Vec<Vec<i32>> = Vec::with_capacity(blurred_heights.len());
// Find min/max in raw data
let mut min_height: f64 = f64::MAX;
let mut max_height: f64 = f64::MIN;
let mut extreme_low_count = 0;
let mut extreme_high_count = 0;
for row in &blurred_heights {
for &height in row {
min_height = min_height.min(height);
max_height = max_height.max(height);
// Count extreme values that might indicate data issues
if height < -1000.0 {
extreme_low_count += 1;
// Find min/max in raw data using parallel reduction
let (min_height, max_height, extreme_low_count, extreme_high_count) = blurred_heights
.par_iter()
.map(|row| {
let mut local_min = f64::MAX;
let mut local_max = f64::MIN;
let mut local_low = 0usize;
let mut local_high = 0usize;
for &height in row {
local_min = local_min.min(height);
local_max = local_max.max(height);
if height < -1000.0 {
local_low += 1;
}
if height > 10000.0 {
local_high += 1;
}
}
if height > 10000.0 {
extreme_high_count += 1;
}
}
}
(local_min, local_max, local_low, local_high)
})
.reduce(
|| (f64::MAX, f64::MIN, 0usize, 0usize),
|(min1, max1, low1, high1), (min2, max2, low2, high2)| {
(min1.min(min2), max1.max(max2), low1 + low2, high1 + high2)
},
);
//eprintln!("Height data range: {min_height} to {max_height} m");
if extreme_low_count > 0 {
@@ -399,27 +507,28 @@ pub fn fetch_elevation_data(
compressed_range
};
// Convert to scaled Minecraft Y coordinates
// Convert to scaled Minecraft Y coordinates (parallelized across rows)
// Lowest real elevation maps to ground_level, highest maps to ground_level + scaled_range
for row in blurred_heights {
let mc_row: Vec<i32> = row
.iter()
.map(|&h| {
// Calculate relative position within the elevation range (0.0 to 1.0)
let relative_height: f64 = if height_range > 0.0 {
(h - min_height) / height_range
} else {
0.0
};
// Scale to Minecraft blocks and add to ground level
let scaled_height: f64 = relative_height * scaled_range;
// Clamp to valid Minecraft Y range (leave buffer at top for structures)
((ground_level as f64 + scaled_height).round() as i32)
.clamp(ground_level, MAX_Y - TERRAIN_HEIGHT_BUFFER)
})
.collect();
mc_heights.push(mc_row);
}
let mc_heights: Vec<Vec<i32>> = blurred_heights
.par_iter()
.map(|row| {
row.iter()
.map(|&h| {
// Calculate relative position within the elevation range (0.0 to 1.0)
let relative_height: f64 = if height_range > 0.0 {
(h - min_height) / height_range
} else {
0.0
};
// Scale to Minecraft blocks and add to ground level
let scaled_height: f64 = relative_height * scaled_range;
// Clamp to valid Minecraft Y range (leave buffer at top for structures)
((ground_level as f64 + scaled_height).round() as i32)
.clamp(ground_level, MAX_Y - TERRAIN_HEIGHT_BUFFER)
})
.collect()
})
.collect();
let mut min_block_height: i32 = i32::MAX;
let mut max_block_height: i32 = i32::MIN;
@@ -456,48 +565,61 @@ fn apply_gaussian_blur(heights: &[Vec<f64>], sigma: f64) -> Vec<Vec<f64>> {
let kernel_size: usize = (sigma * 3.0).ceil() as usize * 2 + 1;
let kernel: Vec<f64> = create_gaussian_kernel(kernel_size, sigma);
// Apply blur
let mut blurred: Vec<Vec<f64>> = heights.to_owned();
let height_len = heights.len();
let width = heights[0].len();
// Horizontal pass
for row in blurred.iter_mut() {
let mut temp: Vec<f64> = row.clone();
for (i, val) in temp.iter_mut().enumerate() {
let mut sum: f64 = 0.0;
let mut weight_sum: f64 = 0.0;
for (j, k) in kernel.iter().enumerate() {
let idx: i32 = i as i32 + j as i32 - kernel_size as i32 / 2;
if idx >= 0 && idx < row.len() as i32 {
sum += row[idx as usize] * k;
weight_sum += k;
// Horizontal pass - parallelize across rows (each row is independent)
let after_horizontal: Vec<Vec<f64>> = heights
.par_iter()
.map(|row| {
let mut temp: Vec<f64> = vec![0.0; row.len()];
for (i, val) in temp.iter_mut().enumerate() {
let mut sum: f64 = 0.0;
let mut weight_sum: f64 = 0.0;
for (j, k) in kernel.iter().enumerate() {
let idx: i32 = i as i32 + j as i32 - kernel_size as i32 / 2;
if idx >= 0 && idx < row.len() as i32 {
sum += row[idx as usize] * k;
weight_sum += k;
}
}
*val = sum / weight_sum;
}
*val = sum / weight_sum;
}
*row = temp;
}
temp
})
.collect();
// Vertical pass
let height: usize = blurred.len();
let width: usize = blurred[0].len();
for x in 0..width {
let temp: Vec<_> = blurred
.iter()
.take(height)
.map(|row: &Vec<f64>| row[x])
.collect();
// Vertical pass - parallelize across columns (each column is independent)
// Process each column in parallel and collect results as column vectors
let blurred_columns: Vec<Vec<f64>> = (0..width)
.into_par_iter()
.map(|x| {
// Extract column from after_horizontal
let column: Vec<f64> = after_horizontal.iter().map(|row| row[x]).collect();
for (y, row) in blurred.iter_mut().enumerate().take(height) {
let mut sum: f64 = 0.0;
let mut weight_sum: f64 = 0.0;
for (j, k) in kernel.iter().enumerate() {
let idx: i32 = y as i32 + j as i32 - kernel_size as i32 / 2;
if idx >= 0 && idx < height as i32 {
sum += temp[idx as usize] * k;
weight_sum += k;
// Apply vertical blur to this column
let mut blurred_column: Vec<f64> = vec![0.0; height_len];
for (y, val) in blurred_column.iter_mut().enumerate() {
let mut sum: f64 = 0.0;
let mut weight_sum: f64 = 0.0;
for (j, k) in kernel.iter().enumerate() {
let idx: i32 = y as i32 + j as i32 - kernel_size as i32 / 2;
if idx >= 0 && idx < height_len as i32 {
sum += column[idx as usize] * k;
weight_sum += k;
}
}
*val = sum / weight_sum;
}
row[x] = sum / weight_sum;
blurred_column
})
.collect();
// Transpose columns back to row-major format
let mut blurred: Vec<Vec<f64>> = vec![vec![0.0; width]; height_len];
for (x, column) in blurred_columns.into_iter().enumerate() {
for (y, val) in column.into_iter().enumerate() {
blurred[y][x] = val;
}
}
@@ -579,15 +701,22 @@ fn filter_elevation_outliers(height_grid: &mut [Vec<f64>]) {
return;
}
// Sort to find percentiles
all_heights.sort_by(|a, b| a.partial_cmp(b).unwrap());
let len = all_heights.len();
// Use 1st and 99th percentiles to define reasonable bounds
// Using quickselect (select_nth_unstable) instead of full sort: O(n) vs O(n log n)
let p1_idx = (len as f64 * 0.01) as usize;
let p99_idx = (len as f64 * 0.99) as usize;
let min_reasonable = all_heights[p1_idx];
let max_reasonable = all_heights[p99_idx];
let p99_idx = ((len as f64 * 0.99) as usize).min(len - 1);
// Find p1 (1st percentile) - all elements before p1_idx will be <= p1
let (_, p1_val, _) =
all_heights.select_nth_unstable_by(p1_idx, |a, b| a.partial_cmp(b).unwrap());
let min_reasonable = *p1_val;
// Find p99 (99th percentile) - need to search in remaining slice or use separate call
let (_, p99_val, _) =
all_heights.select_nth_unstable_by(p99_idx, |a, b| a.partial_cmp(b).unwrap());
let max_reasonable = *p99_val;
//eprintln!("Filtering outliers outside range: {min_reasonable:.1}m to {max_reasonable:.1}m");

View File

@@ -1,8 +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 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(
@@ -29,6 +85,13 @@ pub fn flood_fill_area(
let area = (max_x - min_x + 1) as i64 * (max_z - min_z + 1) as i64;
// Safety cap: reject polygons whose bounding box is too large.
// This prevents multi-GB memory allocations when ocean-adjacent elements
// (e.g. natural=water, large landuse) produce huge clipped polygons.
if area > MAX_FLOOD_FILL_AREA {
return vec![];
}
// For small and medium areas, use optimized flood fill with span filling
if area < 50000 {
optimized_flood_fill_area(polygon_coords, timeout, min_x, max_x, min_z, max_z)
@@ -50,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;
@@ -81,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
@@ -104,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));
}
}
}
@@ -136,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;
@@ -168,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
@@ -192,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

@@ -11,13 +11,13 @@ use fnv::FnvHashMap;
use rayon::prelude::*;
use std::time::Duration;
/// A memory-efficient bitmap for storing building footprint coordinates.
/// A memory-efficient bitmap for storing coordinates.
///
/// Instead of storing each coordinate individually (~24 bytes per entry in a HashSet),
/// this uses 1 bit per coordinate in the world bounds, reducing memory usage by ~200x.
///
/// For a world of size W x H blocks, the bitmap uses only (W * H) / 8 bytes.
pub struct BuildingFootprintBitmap {
pub struct CoordinateBitmap {
/// The bitmap data, where each bit represents one (x, z) coordinate
bits: Vec<u8>,
/// Minimum x coordinate (offset for indexing)
@@ -27,12 +27,13 @@ pub struct BuildingFootprintBitmap {
/// Width of the world (max_x - min_x + 1)
width: usize,
/// Height of the world (max_z - min_z + 1)
#[allow(dead_code)]
height: usize,
/// Number of coordinates marked as building footprints
/// Number of coordinates marked
count: usize,
}
impl BuildingFootprintBitmap {
impl CoordinateBitmap {
/// Creates a new empty bitmap covering the given world bounds.
pub fn new(xzbbox: &XZBBox) -> Self {
let min_x = xzbbox.min_x();
@@ -44,7 +45,7 @@ impl BuildingFootprintBitmap {
// Calculate number of bytes needed (round up to nearest byte)
let total_bits = width
.checked_mul(height)
.expect("BuildingFootprintBitmap: world size too large (width * height overflowed)");
.expect("CoordinateBitmap: world size too large (width * height overflowed)");
let num_bytes = total_bits.div_ceil(8);
Self {
@@ -79,7 +80,7 @@ impl BuildingFootprintBitmap {
Some(local_z * self.width + local_x)
}
/// Sets a coordinate as part of a building footprint.
/// Sets a coordinate.
#[inline]
pub fn set(&mut self, x: i32, z: i32) {
if let Some(bit_index) = self.coord_to_index(x, z) {
@@ -96,7 +97,7 @@ impl BuildingFootprintBitmap {
}
}
/// Checks if a coordinate is part of a building footprint.
/// Checks if a coordinate is set.
#[inline]
pub fn contains(&self, x: i32, z: i32) -> bool {
if let Some(bit_index) = self.coord_to_index(x, z) {
@@ -111,12 +112,119 @@ impl BuildingFootprintBitmap {
/// Returns true if no coordinates are marked.
#[must_use]
#[allow(dead_code)] // Standard API method for collection-like types
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.count == 0
}
/// Returns the number of coordinates that are set.
#[inline]
#[allow(dead_code)]
pub fn count(&self) -> usize {
self.count
}
/// Counts how many coordinates from the given iterator are set in this bitmap.
#[inline]
#[allow(dead_code)]
pub fn count_contained<'a, I>(&self, coords: I) -> usize
where
I: Iterator<Item = &'a (i32, i32)>,
{
coords.filter(|(x, z)| self.contains(*x, *z)).count()
}
/// Counts the number of set bits in a rectangular range.
///
/// This is optimized to iterate row-by-row and use `count_ones()` on bytes
/// where possible, which is much faster than checking individual coordinates.
///
/// Returns `(urban_count, total_count)` for the given range.
#[inline]
#[allow(dead_code)]
pub fn count_in_range(&self, min_x: i32, min_z: i32, max_x: i32, max_z: i32) -> (usize, usize) {
let mut urban_count = 0usize;
let mut total_count = 0usize;
for z in min_z..=max_z {
// Calculate local z coordinate
let local_z = i64::from(z) - i64::from(self.min_z);
if local_z < 0 || local_z >= self.height as i64 {
// Row is out of bounds, still counts toward total
total_count += (i64::from(max_x) - i64::from(min_x) + 1) as usize;
continue;
}
let local_z = local_z as usize;
// Calculate x range in local coordinates
let local_min_x = (i64::from(min_x) - i64::from(self.min_x)).max(0) as usize;
let local_max_x =
((i64::from(max_x) - i64::from(self.min_x)) as usize).min(self.width - 1);
// Count out-of-bounds x coordinates toward total
let x_start_offset = (i64::from(self.min_x) - i64::from(min_x)).max(0) as usize;
let x_end_offset = (i64::from(max_x) - i64::from(self.min_x) - (self.width as i64 - 1))
.max(0) as usize;
total_count += x_start_offset + x_end_offset;
if local_min_x > local_max_x {
continue;
}
// Process this row
let row_start_bit = local_z * self.width + local_min_x;
let row_end_bit = local_z * self.width + local_max_x;
let num_bits = row_end_bit - row_start_bit + 1;
total_count += num_bits;
// Count set bits using byte-wise popcount where possible
let start_byte = row_start_bit / 8;
let end_byte = row_end_bit / 8;
let start_bit_in_byte = row_start_bit % 8;
let end_bit_in_byte = row_end_bit % 8;
if start_byte == end_byte {
// All bits are in the same byte
let byte = self.bits[start_byte];
// Create mask for bits from start_bit to end_bit (inclusive)
let num_bits_in_mask = end_bit_in_byte - start_bit_in_byte + 1;
let mask = if num_bits_in_mask >= 8 {
0xFFu8
} else {
((1u16 << num_bits_in_mask) - 1) as u8
};
let masked = (byte >> start_bit_in_byte) & mask;
urban_count += masked.count_ones() as usize;
} else {
// First partial byte
let first_byte = self.bits[start_byte];
let first_mask = !((1u8 << start_bit_in_byte) - 1); // bits from start_bit to 7
urban_count += (first_byte & first_mask).count_ones() as usize;
// Full bytes in between
for byte_idx in (start_byte + 1)..end_byte {
urban_count += self.bits[byte_idx].count_ones() as usize;
}
// Last partial byte
let last_byte = self.bits[end_byte];
// Handle case where end_bit_in_byte is 7 (would overflow 1u8 << 8)
let last_mask = if end_bit_in_byte >= 7 {
0xFFu8
} else {
(1u8 << (end_bit_in_byte + 1)) - 1
};
urban_count += (last_byte & last_mask).count_ones() as usize;
}
}
(urban_count, total_count)
}
}
/// Type alias for building footprint bitmap (for backwards compatibility).
pub type BuildingFootprintBitmap = CoordinateBitmap;
/// A cache of pre-computed flood fill results, keyed by element ID.
pub struct FloodFillCache {
/// Cached results: element_id -> filled coordinates
@@ -233,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.
@@ -261,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.
@@ -282,6 +395,64 @@ impl FloodFillCache {
footprints
}
/// Collects centroids of all buildings from the pre-computed cache.
///
/// This is used for urban ground detection - building clusters are identified
/// using their centroids, and a concave hull is computed around dense clusters
/// to determine where city ground (smooth stone) should be placed.
///
/// Returns a vector of (x, z) centroid coordinates for all buildings.
pub fn collect_building_centroids(&self, elements: &[ProcessedElement]) -> Vec<(i32, i32)> {
let mut centroids = Vec::new();
for element in elements {
match element {
ProcessedElement::Way(way) => {
if way.tags.contains_key("building") || way.tags.contains_key("building:part") {
if let Some(cached) = self.way_cache.get(&way.id) {
if let Some(centroid) = Self::compute_centroid(cached) {
centroids.push(centroid);
}
}
}
}
ProcessedElement::Relation(rel) => {
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 {
if member.role == ProcessedMemberRole::Outer {
if let Some(cached) = self.way_cache.get(&member.way.id) {
all_coords.extend(cached.iter().copied());
}
}
}
if let Some(centroid) = Self::compute_centroid(&all_coords) {
centroids.push(centroid);
}
}
}
_ => {}
}
}
centroids
}
/// Computes the centroid of a set of coordinates.
fn compute_centroid(coords: &[(i32, i32)]) -> Option<(i32, i32)> {
if coords.is_empty() {
return None;
}
let sum_x: i64 = coords.iter().map(|(x, _)| i64::from(*x)).sum();
let sum_z: i64 = coords.iter().map(|(_, z)| i64::from(*z)).sum();
let len = coords.len() as i64;
Some(((sum_x / len) as i32, (sum_z / len) as i32))
}
/// Removes a way's cached flood fill result, freeing memory.
///
/// Call this after processing an element to release its cached data.

View File

@@ -2,6 +2,8 @@ use crate::args::Args;
use crate::coordinate_system::{cartesian::XZPoint, geographic::LLBBox};
use crate::elevation_data::{fetch_elevation_data, ElevationData};
use crate::progress::emit_gui_progress_update;
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use colored::Colorize;
use image::{Rgb, RgbImage};
@@ -31,7 +33,11 @@ impl Ground {
},
Err(e) => {
eprintln!("Failed to fetch elevation data: {}", e);
emit_gui_progress_update(15.0, "Elevation unavailable, using flat ground");
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
"Elevation unavailable, using flat ground",
);
// Graceful fallback: disable elevation and keep provided ground_level
Self {
elevation_enabled: false,
@@ -141,7 +147,7 @@ impl Ground {
pub fn generate_ground_data(args: &Args) -> Ground {
if args.terrain {
println!("{} Fetching elevation...", "[3/7]".bold());
emit_gui_progress_update(15.0, "Fetching elevation...");
emit_gui_progress_update(14.0, "Fetching elevation...");
let ground = Ground::new_enabled(&args.bbox, args.scale, args.ground_level);
if args.debug {
ground.save_debug_image("elevation_debug");

View File

@@ -62,21 +62,13 @@ impl Drop for SessionLock {
}
}
/// Gets the area name for a given bounding box using the center point
fn get_area_name_for_bedrock(bbox: &LLBBox) -> String {
let center_lat = (bbox.min().lat() + bbox.max().lat()) / 2.0;
let center_lon = (bbox.min().lng() + bbox.max().lng()) / 2.0;
match retrieve_data::fetch_area_name(center_lat, center_lon) {
Ok(Some(name)) => name,
_ => "Unknown Location".to_string(),
}
}
pub fn run_gui() {
// Configure thread pool with 90% CPU cap to keep system responsive
crate::floodfill_cache::configure_rayon_thread_pool(0.9);
// Clean up old cached elevation tiles on startup
crate::elevation_data::cleanup_old_cached_tiles();
// Launch the UI
println!("Launching UI...");
@@ -102,7 +94,7 @@ pub fn run_gui() {
tauri::Builder::default()
.plugin(
LogBuilder::default()
.level(LevelFilter::Warn)
.level(LevelFilter::Info)
.targets([
Target::new(TargetKind::LogDir {
file_name: Some("arnis".into()),
@@ -113,7 +105,10 @@ pub fn run_gui() {
)
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
gui_select_world,
gui_create_world,
gui_get_default_save_path,
gui_set_save_path,
gui_pick_save_directory,
gui_start_generation,
gui_get_version,
gui_check_for_updates,
@@ -131,15 +126,17 @@ pub fn run_gui() {
.expect("Error while starting the application UI (Tauri)");
}
#[tauri::command]
fn gui_select_world(generate_new: bool) -> Result<String, i32> {
// Determine the default Minecraft 'saves' directory based on the OS
let default_dir: Option<PathBuf> = if cfg!(target_os = "windows") {
/// Detects the default Minecraft Java Edition saves directory for the current OS.
/// Checks standard install paths including Flatpak on Linux.
/// Falls back to Desktop, then current directory.
fn detect_minecraft_saves_directory() -> PathBuf {
// Try standard Minecraft saves directories per OS
let mc_saves: Option<PathBuf> = if cfg!(target_os = "windows") {
env::var("APPDATA")
.ok()
.map(|appdata: String| PathBuf::from(appdata).join(".minecraft").join("saves"))
.map(|appdata| PathBuf::from(appdata).join(".minecraft").join("saves"))
} else if cfg!(target_os = "macos") {
dirs::home_dir().map(|home: PathBuf| {
dirs::home_dir().map(|home| {
home.join("Library/Application Support/minecraft")
.join("saves")
})
@@ -156,174 +153,75 @@ fn gui_select_world(generate_new: bool) -> Result<String, i32> {
None
};
if generate_new {
// Handle new world generation
// Try Minecraft saves directory first, fall back to current directory
let target_path = if let Some(default_path) = &default_dir {
if default_path.exists() {
default_path.clone()
} else {
// Minecraft directory doesn't exist, use current directory
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
} else {
// No default directory configured, use current directory
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
};
create_new_world(&target_path).map_err(|_| 3) // Error code 3: Failed to create new world
} else {
// Handle existing world selection
// Open the directory picker dialog
let dialog: FileDialog = FileDialog::new();
let dialog: FileDialog = if let Some(start_dir) = default_dir.filter(|dir| dir.exists()) {
dialog.set_directory(start_dir)
} else {
dialog
};
if let Some(path) = dialog.pick_folder() {
// Check if the "region" folder exists within the selected directory
if path.join("region").exists() {
// Check the 'session.lock' file
let session_lock_path = path.join("session.lock");
if session_lock_path.exists() {
// Try to acquire a lock on the session.lock file
if let Ok(file) = fs::File::open(&session_lock_path) {
if fs2::FileExt::try_lock_shared(&file).is_err() {
return Err(2); // Error code 2: The selected world is currently in use
} else {
// Release the lock immediately
let _ = fs2::FileExt::unlock(&file);
}
}
}
return Ok(path.display().to_string());
} else {
// No Minecraft directory found, generating new world in custom user selected directory
return create_new_world(&path).map_err(|_| 3); // Error code 3: Failed to create new world
}
if let Some(saves_dir) = mc_saves {
if saves_dir.exists() {
return saves_dir;
}
}
// If no folder was selected, return an error message
Err(4) // Error code 4: No world selected
// Fallback to Desktop
if let Some(desktop) = dirs::desktop_dir() {
if desktop.exists() {
return desktop;
}
}
// Last resort: current directory
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Returns the default save path (auto-detected on first run).
/// The frontend stores/retrieves this via localStorage and passes it here for validation.
#[tauri::command]
fn gui_get_default_save_path() -> String {
detect_minecraft_saves_directory().display().to_string()
}
/// Validates and returns a user-provided save path.
/// Returns the path string if valid, or an error message.
#[tauri::command]
fn gui_set_save_path(path: String) -> Result<String, String> {
let p = PathBuf::from(&path);
if !p.exists() {
return Err("Path does not exist.".to_string());
}
if !p.is_dir() {
return Err("Path is not a directory.".to_string());
}
Ok(path)
}
/// Opens a native folder-picker dialog and returns the chosen path.
#[tauri::command]
fn gui_pick_save_directory(start_path: String) -> Result<String, String> {
let start = PathBuf::from(&start_path);
let mut dialog = FileDialog::new();
if start.is_dir() {
dialog = dialog.set_directory(&start);
}
match dialog.pick_folder() {
Some(folder) => Ok(folder.display().to_string()),
None => Ok(start_path),
}
}
fn create_new_world(base_path: &Path) -> Result<String, String> {
// Generate a unique world name with proper counter
// Check for both "Arnis World X" and "Arnis World X: Location" patterns
let mut counter: i32 = 1;
let unique_name: String = loop {
let candidate_name: String = format!("Arnis World {counter}");
let candidate_path: PathBuf = base_path.join(&candidate_name);
// Check for exact match (no location suffix)
let exact_match_exists = candidate_path.exists();
// Check for worlds with location suffix (Arnis World X: Location)
let location_pattern = format!("Arnis World {counter}: ");
let location_match_exists = fs::read_dir(base_path)
.map(|entries| {
entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.any(|name| name.starts_with(&location_pattern))
})
.unwrap_or(false);
if !exact_match_exists && !location_match_exists {
break candidate_name;
}
counter += 1;
};
let new_world_path: PathBuf = base_path.join(&unique_name);
// Create the new world directory structure
fs::create_dir_all(new_world_path.join("region"))
.map_err(|e| format!("Failed to create world directory: {e}"))?;
// Copy the region template file
const REGION_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/region.template");
let region_path = new_world_path.join("region").join("r.0.0.mca");
fs::write(&region_path, REGION_TEMPLATE)
.map_err(|e| format!("Failed to create region file: {e}"))?;
// Add the level.dat file
const LEVEL_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/level.dat");
// Decompress the gzipped level.template
let mut decoder = GzDecoder::new(LEVEL_TEMPLATE);
let mut decompressed_data = Vec::new();
decoder
.read_to_end(&mut decompressed_data)
.map_err(|e| format!("Failed to decompress level.template: {e}"))?;
// Parse the decompressed NBT data
let mut level_data: Value = fastnbt::from_bytes(&decompressed_data)
.map_err(|e| format!("Failed to parse level.dat template: {e}"))?;
// Modify the LevelName, LastPlayed and player position fields
if let Value::Compound(ref mut root) = level_data {
if let Some(Value::Compound(ref mut data)) = root.get_mut("Data") {
// Update LevelName
data.insert("LevelName".to_string(), Value::String(unique_name.clone()));
// Update LastPlayed to the current Unix time in milliseconds
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get current time: {e}"))?;
let current_time_millis = current_time.as_millis() as i64;
data.insert("LastPlayed".to_string(), Value::Long(current_time_millis));
// Update player position and rotation
if let Some(Value::Compound(ref mut player)) = data.get_mut("Player") {
if let Some(Value::List(ref mut pos)) = player.get_mut("Pos") {
if let Value::Double(ref mut x) = pos.get_mut(0).unwrap() {
*x = -5.0;
}
if let Value::Double(ref mut y) = pos.get_mut(1).unwrap() {
*y = -61.0;
}
if let Value::Double(ref mut z) = pos.get_mut(2).unwrap() {
*z = -5.0;
}
}
if let Some(Value::List(ref mut rot)) = player.get_mut("Rotation") {
if let Value::Float(ref mut x) = rot.get_mut(0).unwrap() {
*x = -45.0;
}
}
}
}
/// Creates a new Java Edition world in the given base save directory.
/// Called when the user clicks "Create World".
#[tauri::command]
fn gui_create_world(save_path: String) -> Result<String, i32> {
let trimmed = save_path.trim();
if trimmed.is_empty() {
return Err(3);
}
let base = PathBuf::from(trimmed);
if !base.is_dir() {
return Err(3); // Error code 3: Failed to create new world
}
create_new_world(&base).map_err(|_| 3)
}
// Serialize the updated NBT data back to bytes
let serialized_level_data: Vec<u8> = fastnbt::to_bytes(&level_data)
.map_err(|e| format!("Failed to serialize updated level.dat: {e}"))?;
// Compress the serialized data back to gzip
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder
.write_all(&serialized_level_data)
.map_err(|e| format!("Failed to compress updated level.dat: {e}"))?;
let compressed_level_data = encoder
.finish()
.map_err(|e| format!("Failed to finalize compression for level.dat: {e}"))?;
// Write the level.dat file
fs::write(new_world_path.join("level.dat"), compressed_level_data)
.map_err(|e| format!("Failed to create level.dat file: {e}"))?;
// Add the icon.png file
const ICON_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/icon.png");
fs::write(new_world_path.join("icon.png"), ICON_TEMPLATE)
.map_err(|e| format!("Failed to create icon.png file: {e}"))?;
Ok(new_world_path.display().to_string())
fn create_new_world(base_path: &Path) -> Result<String, String> {
crate::world_utils::create_new_world(base_path)
}
/// Adds localized area name to the world name in level.dat
@@ -419,6 +317,7 @@ fn add_localized_world_name(world_path: PathBuf, bbox: &LLBBox) -> PathBuf {
if let Ok(compressed_data) = encoder.finish() {
if let Err(e) = std::fs::write(&level_path, compressed_data) {
eprintln!("Failed to update level.dat with area name: {e}");
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
"Failed to update level.dat with area name",
@@ -706,12 +605,22 @@ fn gui_get_world_map_data(world_path: String) -> Result<Option<WorldMapData>, St
.as_f64()
.ok_or("Missing maxGeoLon in metadata")?;
// Extract Minecraft coordinate bounds
let min_mc_x = metadata["minMcX"].as_i64().unwrap_or(0) as i32;
let max_mc_x = metadata["maxMcX"].as_i64().unwrap_or(0) as i32;
let min_mc_z = metadata["minMcZ"].as_i64().unwrap_or(0) as i32;
let max_mc_z = metadata["maxMcZ"].as_i64().unwrap_or(0) as i32;
Ok(Some(WorldMapData {
image_base64: format!("data:image/png;base64,{}", base64_image),
min_lat,
max_lat,
min_lon,
max_lon,
min_mc_x,
max_mc_x,
min_mc_z,
max_mc_z,
}))
}
@@ -723,6 +632,11 @@ struct WorldMapData {
max_lat: f64,
min_lon: f64,
max_lon: f64,
// Minecraft coordinate bounds for coordinate copying
min_mc_x: i32,
max_mc_x: i32,
min_mc_z: i32,
max_mc_z: i32,
}
/// Opens the file with default application (Windows) or shows in file explorer (macOS/Linux)
@@ -789,6 +703,7 @@ fn gui_start_generation(
interior_enabled: bool,
roof_enabled: bool,
fillground_enabled: bool,
city_boundaries_enabled: bool,
is_new_world: bool,
spawn_point: Option<(f64, f64)>,
telemetry_consent: bool,
@@ -921,13 +836,10 @@ fn gui_start_generation(
(updated_path, None)
}
WorldFormat::BedrockMcWorld => {
// Bedrock: generate .mcworld in current directory with location-based name
let area_name = get_area_name_for_bedrock(&bbox);
let filename = format!("Arnis {}.mcworld", area_name);
let lvl_name = format!("Arnis World: {}", area_name);
let output_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(filename);
// Bedrock: generate .mcworld on Desktop with location-based name
let output_dir = crate::world_utils::get_bedrock_output_directory();
let (output_path, lvl_name) =
crate::world_utils::build_bedrock_output(&bbox, output_dir);
(output_path, Some(lvl_name))
}
};
@@ -970,11 +882,12 @@ fn gui_start_generation(
bbox,
file: None,
save_json_file: None,
path: if world_format == WorldFormat::JavaAnvil {
path: Some(if world_format == WorldFormat::JavaAnvil {
generation_path
} else {
world_path
},
}),
bedrock: world_format == WorldFormat::BedrockMcWorld,
downloader: "requests".to_string(),
scale: world_scale,
ground_level,
@@ -982,6 +895,7 @@ fn gui_start_generation(
interior: interior_enabled,
roof: roof_enabled,
fillground: fillground_enabled,
city_boundaries: city_boundaries_enabled,
debug: false,
timeout: Some(std::time::Duration::from_secs(40)),
};

40
src/gui/css/bbox.css vendored
View File

@@ -375,4 +375,42 @@ body,
accent-color: #3887BE;
display: block;
margin: 0;
}
}
/* Context menu for coordinate copying */
.coordinate-context-menu {
position: fixed;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 10000;
min-width: 160px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
}
.coordinate-context-menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: #333;
}
.coordinate-context-menu-item:hover {
background: #f0f0f0;
}
.coordinate-context-menu-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.coordinate-context-menu-separator {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}

View File

@@ -116,13 +116,23 @@ a:hover {
flex-direction: column;
}
.bbox-info-text {
.bbox-selection-text {
font-size: 0.9em;
color: #ffffff;
display: block;
font-weight: bold;
min-height: 2.5em;
line-height: 1.25em;
margin-top: 8px;
}
.progress-info-text {
font-size: 0.9em;
color: #ececec;
display: block;
font-weight: bold;
min-height: 1.5em;
line-height: 1.25em;
margin-bottom: 5px;
}
@@ -179,7 +189,6 @@ button:hover {
.progress-bar-container {
flex: 1;
max-width: 80%;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
@@ -408,6 +417,10 @@ button:hover {
margin: 15px 0;
}
#city-boundaries-toggle {
accent-color: #fecc44;
}
#telemetry-toggle {
accent-color: #fecc44;
}
@@ -565,6 +578,44 @@ button:hover {
border: 1px solid #fecc44;
}
/* Save Path Setting */
.save-path-control {
gap: 0;
}
.save-path-input {
max-width: 200px !important;
font-size: 0.85em;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
border-right: none !important;
}
.save-path-browse {
background: none;
border: 1px solid #fecc44;
border-radius: 0 4px 4px 0;
padding: 0 6px;
margin-top: 0;
box-shadow: none;
cursor: pointer;
display: flex;
align-items: center;
align-self: stretch;
justify-content: center;
transition: background 0.2s ease;
}
.save-path-browse:hover {
background: rgba(254, 204, 68, 0.15);
}
.save-path-browse svg {
width: 14px;
height: 14px;
fill: #fecc44;
}
.license-button-row {
justify-content: center;
margin-top: 5px;

45
src/gui/index.html vendored
View File

@@ -32,11 +32,11 @@
<!-- World Selection Container -->
<div class="world-selection-container">
<div class="tooltip" style="width: 100%;">
<button type="button" id="choose-world-btn" onclick="openWorldPicker()" class="choose-world-btn">
<span id="choose_world">Choose World</span>
<button type="button" id="choose-world-btn" onclick="createWorld()" class="choose-world-btn">
<span id="choose_world">Create World</span>
<br>
<span id="selected-world" style="font-size: 0.8em; color: #fecc44; display: block; margin-top: 4px;" data-localize="no_world_selected">
No world selected
No world created
</span>
</button>
</div>
@@ -57,10 +57,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
</div>
<span id="bbox-selection-info" class="bbox-selection-text" data-localize="select_area_prompt">Select an area on the map using the tools.</span>
</div>
<div class="progress-section">
<span id="bbox-info" class="bbox-info-text" data-localize="select_area_prompt">Select an area on the map using the tools.</span>
<span id="progress-info" class="progress-info-text"></span>
<div class="progress-row">
<div class="progress-bar-container">
<div class="progress-bar" id="progress-bar"></div>
@@ -72,17 +73,6 @@
</section>
</div>
<!-- World Picker Modal -->
<div id="world-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-button" onclick="closeWorldPicker()">&times;</span>
<h2 data-localize="choose_world_modal_title">Choose World</h2>
<button type="button" id="select-world-button" class="select-world-button" onclick="selectWorld(false)" data-localize="select_existing_world">Select existing world</button>
<button type="button" id="generate-world-button" class="generate-world-button" onclick="selectWorld(true)" data-localize="generate_new_world">Generate new world</button>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal" style="display: none;">
<div class="modal-content">
@@ -137,6 +127,17 @@
</div>
</div>
<!-- City Ground Toggle Button -->
<div class="settings-row">
<label for="city-boundaries-toggle">
<span data-localize="city_boundaries">City Ground</span>
<span class="tooltip-icon" data-tooltip="Detect urban areas and place smooth stone ground where cities are located.">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="city-boundaries-toggle" name="city-boundaries-toggle" checked>
</div>
</div>
<!-- World Scale Slider -->
<div class="settings-row">
<label for="scale-value-slider">
@@ -177,6 +178,20 @@
</div>
</div>
<!-- Save Path -->
<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 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">
<button type="button" id="save-path-browse" class="save-path-browse" title="Browse...">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>
</button>
</div>
</div>
<!-- Language Selector -->
<div class="settings-row">
<label for="language-select">

182
src/gui/js/bbox.js vendored
View File

@@ -749,6 +749,188 @@ $(document).ready(function () {
}
}
// ========== Context Menu for Coordinate Copying ==========
var contextMenuElement = null;
// Create the context menu element
function createContextMenu() {
if (contextMenuElement) return contextMenuElement;
contextMenuElement = document.createElement('div');
contextMenuElement.className = 'coordinate-context-menu';
contextMenuElement.style.display = 'none';
contextMenuElement.innerHTML = `
<div class="coordinate-context-menu-item" id="copy-coords-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span id="copy-coords-text">Copy coordinates</span>
</div>
`;
document.body.appendChild(contextMenuElement);
// Handle click on the copy coordinates item
var copyItem = contextMenuElement.querySelector('#copy-coords-item');
copyItem.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
copyMinecraftCoordinates();
hideContextMenu();
});
return contextMenuElement;
}
// Show context menu at position
function showContextMenu(x, y, latLng) {
if (!worldPreviewAvailable || !worldOverlayData) return;
var menu = createContextMenu();
// Position the menu, ensuring it stays within viewport
var menuWidth = 180;
var menuHeight = 40;
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
var posX = x;
var posY = y;
// Adjust if menu would go off-screen
if (x + menuWidth > viewportWidth) {
posX = viewportWidth - menuWidth - 10;
}
if (y + menuHeight > viewportHeight) {
posY = viewportHeight - menuHeight - 10;
}
menu.style.left = posX + 'px';
menu.style.top = posY + 'px';
menu.style.display = 'block';
// Store the latLng for copying
menu.dataset.lat = latLng.lat;
menu.dataset.lng = latLng.lng;
}
// Hide context menu
function hideContextMenu() {
if (contextMenuElement) {
contextMenuElement.style.display = 'none';
}
}
// Calculate Minecraft coordinates from lat/lng
function calculateMinecraftCoords(lat, lng) {
if (!worldOverlayData) return null;
var data = worldOverlayData;
// Check if Minecraft coordinate bounds are available (not all zeros)
if (data.min_mc_x === 0 && data.max_mc_x === 0 &&
data.min_mc_z === 0 && data.max_mc_z === 0) {
return null;
}
// Calculate the relative position within the geo bounds (0 to 1)
// Note: Latitude increases northward, but Minecraft Z increases southward
var relX = (lng - data.min_lon) / (data.max_lon - data.min_lon);
var relZ = (data.max_lat - lat) / (data.max_lat - data.min_lat);
// Clamp to 0-1 range
relX = Math.max(0, Math.min(1, relX));
relZ = Math.max(0, Math.min(1, relZ));
// Calculate Minecraft X and Z coordinates
var mcX = Math.round(data.min_mc_x + relX * (data.max_mc_x - data.min_mc_x));
var mcZ = Math.round(data.min_mc_z + relZ * (data.max_mc_z - data.min_mc_z));
// Default Y coordinate (ground level, typically around 64-70)
var mcY = 100;
return { x: mcX, y: mcY, z: mcZ };
}
// Copy Minecraft coordinates to clipboard
function copyMinecraftCoordinates() {
if (!contextMenuElement) return;
var lat = parseFloat(contextMenuElement.dataset.lat);
var lng = parseFloat(contextMenuElement.dataset.lng);
var coords = calculateMinecraftCoords(lat, lng);
if (!coords) return;
var tpCommand = '/tp ' + coords.x + ' ' + coords.y + ' ' + coords.z;
// Copy to clipboard using modern API with fallback
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(tpCommand).catch(function(err) {
// Fallback for clipboard API failure
fallbackCopyToClipboard(tpCommand);
});
} else {
// Fallback for older browsers
fallbackCopyToClipboard(tpCommand);
}
}
// Fallback clipboard copy method for older browsers
function fallbackCopyToClipboard(text) {
var textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (err) {
console.error('Failed to copy coordinates:', err);
}
document.body.removeChild(textArea);
}
// Check if Minecraft coordinate bounds are available
function hasMinecraftCoords() {
if (!worldOverlayData) return false;
var data = worldOverlayData;
return !(data.min_mc_x === 0 && data.max_mc_x === 0 &&
data.min_mc_z === 0 && data.max_mc_z === 0);
}
// Handle right-click on the map
map.on('contextmenu', function(e) {
// Only show context menu if world preview is available and has Minecraft coords
if (worldPreviewAvailable && worldOverlayData && hasMinecraftCoords()) {
// Check if the click is within the world bounds
var data = worldOverlayData;
var lat = e.latlng.lat;
var lng = e.latlng.lng;
if (lat >= data.min_lat && lat <= data.max_lat &&
lng >= data.min_lon && lng <= data.max_lon) {
showContextMenu(e.originalEvent.clientX, e.originalEvent.clientY, e.latlng);
}
}
});
// Hide context menu on any click or map interaction
document.addEventListener('click', function(e) {
if (contextMenuElement && !contextMenuElement.contains(e.target)) {
hideContextMenu();
}
});
map.on('movestart', hideContextMenu);
map.on('zoomstart', hideContextMenu);
// ========== End Context Menu ==========
// Listen for messages from parent window
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'changeTileTheme') {

267
src/gui/js/main.js vendored
View File

@@ -12,14 +12,14 @@ if (window.__TAURI__) {
const DEFAULT_LOCALE_PATH = `./locales/en.json`;
// Track current bbox-info localization key for language changes
let currentBboxInfoKey = "select_area_prompt";
let currentBboxInfoColor = "#ffffff";
// Track current bbox selection info localization key for language changes
let currentBboxSelectionKey = "select_area_prompt";
let currentBboxSelectionColor = "#ffffff";
// Helper function to set bbox-info text and track it for language changes
async function setBboxInfo(bboxInfoElement, localizationKey, color) {
currentBboxInfoKey = localizationKey;
currentBboxInfoColor = color;
// Helper function to set bbox selection info text and track it for language changes
async function setBboxSelectionInfo(bboxSelectionElement, localizationKey, color) {
currentBboxSelectionKey = localizationKey;
currentBboxSelectionColor = color;
// Ensure localization is available
let localization = window.localization;
@@ -27,18 +27,18 @@ async function setBboxInfo(bboxInfoElement, localizationKey, color) {
localization = await getLocalization();
}
localizeElement(localization, { element: bboxInfoElement }, localizationKey);
bboxInfoElement.style.color = color;
localizeElement(localization, { element: bboxSelectionElement }, localizationKey);
bboxSelectionElement.style.color = color;
}
// Initialize elements and start the demo progress
window.addEventListener("DOMContentLoaded", async () => {
registerMessageEvent();
window.selectWorld = selectWorld;
window.createWorld = createWorld;
window.startGeneration = startGeneration;
setupProgressListener();
await initSavePath();
initSettings();
initWorldPicker();
initTelemetryConsent();
handleBboxInput();
const localization = await getLocalization();
@@ -97,27 +97,26 @@ async function localizeElement(json, elementObject, localizedStringKey) {
async function applyLocalization(localization) {
const localizationElements = {
"span[id='choose_world']": "choose_world",
"span[id='choose_world']": "create_world",
"#selected-world": "no_world_selected",
"#start-button": "start_generation",
"h2[data-localize='choose_world_modal_title']": "choose_world_modal_title",
"button[data-localize='select_existing_world']": "select_existing_world",
"button[data-localize='generate_new_world']": "generate_new_world",
"h2[data-localize='customization_settings']": "customization_settings",
"label[data-localize='world_scale']": "world_scale",
"label[data-localize='custom_bounding_box']": "custom_bounding_box",
"span[data-localize='world_scale']": "world_scale",
"span[data-localize='custom_bounding_box']": "custom_bounding_box",
// DEPRECATED: Ground level localization removed
// "label[data-localize='ground_level']": "ground_level",
"label[data-localize='language']": "language",
"label[data-localize='generation_mode']": "generation_mode",
"span[data-localize='language']": "language",
"span[data-localize='generation_mode']": "generation_mode",
"option[data-localize='mode_geo_terrain']": "mode_geo_terrain",
"option[data-localize='mode_geo_only']": "mode_geo_only",
"option[data-localize='mode_terrain_only']": "mode_terrain_only",
"label[data-localize='terrain']": "terrain",
"label[data-localize='interior']": "interior",
"label[data-localize='roof']": "roof",
"label[data-localize='fillground']": "fillground",
"label[data-localize='map_theme']": "map_theme",
"span[data-localize='terrain']": "terrain",
"span[data-localize='interior']": "interior",
"span[data-localize='roof']": "roof",
"span[data-localize='fillground']": "fillground",
"span[data-localize='city_boundaries']": "city_boundaries",
"span[data-localize='map_theme']": "map_theme",
"span[data-localize='save_path']": "save_path",
".footer-link": "footer_text",
"button[data-localize='license_and_credits']": "license_and_credits",
"h2[data-localize='license_and_credits']": "license_and_credits",
@@ -132,11 +131,11 @@ async function applyLocalization(localization) {
localizeElement(localization, { selector: selector }, localizationElements[selector]);
}
// Re-apply current bbox-info text with new language
const bboxInfo = document.getElementById("bbox-info");
if (bboxInfo && currentBboxInfoKey) {
localizeElement(localization, { element: bboxInfo }, currentBboxInfoKey);
bboxInfo.style.color = currentBboxInfoColor;
// Re-apply current bbox selection info text with new language
const bboxSelectionInfo = document.getElementById("bbox-selection-info");
if (bboxSelectionInfo && currentBboxSelectionKey) {
localizeElement(localization, { element: bboxSelectionInfo }, currentBboxSelectionKey);
bboxSelectionInfo.style.color = currentBboxSelectionColor;
}
// Update error messages
@@ -210,7 +209,7 @@ function registerMessageEvent() {
// Function to set up the progress bar listener
function setupProgressListener() {
const progressBar = document.getElementById("progress-bar");
const bboxInfo = document.getElementById("bbox-info");
const progressInfo = document.getElementById("progress-info");
const progressDetail = document.getElementById("progress-detail");
window.__TAURI__.event.listen("progress-update", (event) => {
@@ -222,16 +221,16 @@ function setupProgressListener() {
}
if (message != "") {
bboxInfo.textContent = message;
progressInfo.textContent = message;
if (message.startsWith("Error!")) {
bboxInfo.style.color = "#fa7878";
progressInfo.style.color = "#fa7878";
generationButtonEnabled = true;
} else if (message.startsWith("Done!")) {
bboxInfo.style.color = "#7bd864";
progressInfo.style.color = "#7bd864";
generationButtonEnabled = true;
} else {
bboxInfo.style.color = "#ececec";
progressInfo.style.color = "#ececec";
}
}
});
@@ -297,6 +296,9 @@ function initSettings() {
// World format toggle (Java/Bedrock)
initWorldFormatToggle();
// Save path setting
initSavePathSetting();
// Language selector
const languageSelect = document.getElementById("language-select");
const availableOptions = Array.from(languageSelect.options).map(opt => opt.value);
@@ -335,6 +337,14 @@ function initSettings() {
// Reload localization with the new language
const localization = await fetchLanguage(selectedLanguage);
await applyLocalization(localization);
// Restore correct #selected-world text after localization overwrites it
updateFormatToggleUI(selectedWorldFormat);
// If a world was already created, show its name
if (worldPath) {
const lastSegment = worldPath.split(/[\\/]/).pop();
document.getElementById('selected-world').textContent = lastSegment;
}
});
// Tile theme selector
@@ -430,22 +440,22 @@ function updateFormatToggleUI(format) {
if (format === 'java') {
javaBtn.classList.add('format-active');
bedrockBtn.classList.remove('format-active');
// Enable Choose World button for Java
// Enable Create World button for Java
if (chooseWorldBtn) {
chooseWorldBtn.disabled = false;
chooseWorldBtn.style.opacity = '1';
chooseWorldBtn.style.cursor = 'pointer';
}
// Show default text (world was cleared when switching to Bedrock)
if (selectedWorldText) {
const noWorldText = window.localization?.no_world_selected || 'No world selected';
// Show appropriate text based on whether a world was already created
if (selectedWorldText && !worldPath) {
const noWorldText = window.localization?.no_world_selected || 'No world created';
selectedWorldText.textContent = noWorldText;
selectedWorldText.style.color = '#fecc44';
}
} else {
javaBtn.classList.remove('format-active');
bedrockBtn.classList.add('format-active');
// Disable Choose World button for Bedrock and clear any selected world
// Disable Create World button for Bedrock
if (chooseWorldBtn) {
chooseWorldBtn.disabled = true;
chooseWorldBtn.style.opacity = '0.5';
@@ -453,9 +463,8 @@ function updateFormatToggleUI(format) {
}
// Clear world selection and show Bedrock info message
worldPath = "";
isNewWorld = false;
if (selectedWorldText) {
const bedrockText = window.localization?.bedrock_use_java || 'Use Java to select worlds';
const bedrockText = window.localization?.bedrock_auto_generated || 'Bedrock world is auto-generated';
selectedWorldText.textContent = bedrockText;
selectedWorldText.style.color = '#fecc44';
}
@@ -508,24 +517,86 @@ function initTelemetryConsent() {
};
}
function initWorldPicker() {
// World Picker
const worldPickerModal = document.getElementById("world-modal");
/// Save path management
let savePath = "";
// Open world picker modal
function openWorldPicker() {
worldPickerModal.style.display = "flex";
worldPickerModal.style.justifyContent = "center";
worldPickerModal.style.alignItems = "center";
async function initSavePath() {
// Check if user has a saved path in localStorage
const saved = localStorage.getItem('arnis-save-path');
if (saved) {
// Validate the saved path still exists (handles upgrades / moved directories)
try {
const normalized = await invoke('gui_set_save_path', { path: saved });
savePath = normalized;
localStorage.setItem('arnis-save-path', savePath);
} catch (_) {
// Saved path is no longer valid re-detect
console.warn("Stored save path no longer valid, re-detecting...");
localStorage.removeItem('arnis-save-path');
try {
savePath = await invoke('gui_get_default_save_path');
localStorage.setItem('arnis-save-path', savePath);
} catch (error) {
console.error("Failed to detect save path:", error);
}
}
} else {
// Auto-detect on first run
try {
savePath = await invoke('gui_get_default_save_path');
localStorage.setItem('arnis-save-path', savePath);
} catch (error) {
console.error("Failed to detect save path:", error);
}
}
// Close world picker modal
function closeWorldPicker() {
worldPickerModal.style.display = "none";
// Populate the save path input in settings
const savePathInput = document.getElementById('save-path-input');
if (savePathInput) {
savePathInput.value = savePath;
}
}
window.openWorldPicker = openWorldPicker;
window.closeWorldPicker = closeWorldPicker;
function initSavePathSetting() {
const savePathInput = document.getElementById('save-path-input');
if (!savePathInput) return;
savePathInput.value = savePath;
// Manual text input validate on change, revert if invalid
savePathInput.addEventListener('change', async () => {
const newPath = savePathInput.value.trim();
if (!newPath) {
savePathInput.value = savePath;
return;
}
try {
const validated = await invoke('gui_set_save_path', { path: newPath });
savePath = validated;
localStorage.setItem('arnis-save-path', savePath);
} catch (_) {
// Invalid path silently revert to previous value
savePathInput.value = savePath;
}
});
// Folder picker button
const browseBtn = document.getElementById('save-path-browse');
if (browseBtn) {
browseBtn.addEventListener('click', async () => {
try {
const picked = await invoke('gui_pick_save_directory', { startPath: savePath });
if (picked) {
savePath = picked;
savePathInput.value = savePath;
localStorage.setItem('arnis-save-path', savePath);
}
} catch (error) {
console.error("Folder picker failed:", error);
}
});
}
}
/**
@@ -535,7 +606,7 @@ function initWorldPicker() {
*/
function handleBboxInput() {
const inputBox = document.getElementById("bbox-coords");
const bboxInfo = document.getElementById("bbox-info");
const bboxSelectionInfo = document.getElementById("bbox-selection-info");
inputBox.addEventListener("input", function () {
const input = inputBox.value.trim();
@@ -547,12 +618,12 @@ function handleBboxInput() {
// Clear the info text only if no map selection exists
if (!mapSelectedBBox) {
setBboxInfo(bboxInfo, "select_area_prompt", "#ffffff");
setBboxSelectionInfo(bboxSelectionInfo, "select_area_prompt", "#ffffff");
} else {
// Restore map selection info display but don't update input field
const [lng1, lat1, lng2, lat2] = mapSelectedBBox.split(" ").map(Number);
const selectedSize = calculateBBoxSize(lng1, lat1, lng2, lat2);
displayBboxSizeStatus(bboxInfo, selectedSize);
displayBboxSizeStatus(bboxSelectionInfo, selectedSize);
}
return;
}
@@ -588,7 +659,7 @@ function handleBboxInput() {
// Update the info text and mark custom input as valid
customBBoxValid = true;
selectedBBox = bboxText.replace(/,/g, ' '); // Convert to space format for consistency
setBboxInfo(bboxInfo, "custom_selection_confirmed", "#7bd864");
setBboxSelectionInfo(bboxSelectionInfo, "custom_selection_confirmed", "#7bd864");
} else {
// Valid numbers but invalid order or range
customBBoxValid = false;
@@ -598,7 +669,7 @@ function handleBboxInput() {
} else {
selectedBBox = mapSelectedBBox;
}
setBboxInfo(bboxInfo, "error_coordinates_out_of_range", "#fecc44");
setBboxSelectionInfo(bboxSelectionInfo, "error_coordinates_out_of_range", "#fecc44");
}
} else {
// Input doesn't match the required format
@@ -609,7 +680,7 @@ function handleBboxInput() {
} else {
selectedBBox = mapSelectedBBox;
}
setBboxInfo(bboxInfo, "invalid_format", "#fecc44");
setBboxSelectionInfo(bboxSelectionInfo, "invalid_format", "#fecc44");
}
});
}
@@ -660,16 +731,16 @@ let customBBoxValid = false; // Tracks if custom input is valid
/**
* Displays the appropriate bbox size status message based on area thresholds
* @param {HTMLElement} bboxInfo - The element to display the message in
* @param {HTMLElement} bboxSelectionElement - The element to display the message in
* @param {number} selectedSize - The calculated bbox area in square meters
*/
function displayBboxSizeStatus(bboxInfo, selectedSize) {
function displayBboxSizeStatus(bboxSelectionElement, selectedSize) {
if (selectedSize > threshold2) {
setBboxInfo(bboxInfo, "area_too_large", "#fa7878");
setBboxSelectionInfo(bboxSelectionElement, "area_too_large", "#fa7878");
} else if (selectedSize > threshold1) {
setBboxInfo(bboxInfo, "area_extensive", "#fecc44");
setBboxSelectionInfo(bboxSelectionElement, "area_extensive", "#fecc44");
} else {
setBboxInfo(bboxInfo, "selection_confirmed", "#7bd864");
setBboxSelectionInfo(bboxSelectionElement, "selection_confirmed", "#7bd864");
}
}
@@ -686,12 +757,12 @@ function displayBboxInfoText(bboxText) {
selectedBBox = mapSelectedBBox;
customBBoxValid = false;
const bboxInfo = document.getElementById("bbox-info");
const bboxSelectionInfo = document.getElementById("bbox-selection-info");
const bboxCoordsInput = document.getElementById("bbox-coords");
// Reset the info text if the bbox is 0,0,0,0
if (lng1 === 0 && lat1 === 0 && lng2 === 0 && lat2 === 0) {
setBboxInfo(bboxInfo, "select_area_prompt", "#ffffff");
setBboxSelectionInfo(bboxSelectionInfo, "select_area_prompt", "#ffffff");
bboxCoordsInput.value = "";
mapSelectedBBox = "";
if (!customBBoxValid) {
@@ -706,61 +777,35 @@ function displayBboxInfoText(bboxText) {
// Calculate the size of the selected bbox
const selectedSize = calculateBBoxSize(lng1, lat1, lng2, lat2);
displayBboxSizeStatus(bboxInfo, selectedSize);
displayBboxSizeStatus(bboxSelectionInfo, selectedSize);
}
let worldPath = "";
let isNewWorld = false;
async function selectWorld(generate_new_world) {
async function createWorld() {
// Don't create if format is Bedrock (button should be disabled)
if (selectedWorldFormat === 'bedrock') return;
// Don't create if save path hasn't been initialized
if (!savePath) {
console.warn("Cannot create world: save path not set");
return;
}
try {
const worldName = await invoke('gui_select_world', { generateNew: generate_new_world });
const worldName = await invoke('gui_create_world', { savePath: savePath });
if (worldName) {
worldPath = worldName;
isNewWorld = generate_new_world;
const lastSegment = worldName.split(/[\\/]/).pop();
document.getElementById('selected-world').textContent = lastSegment;
document.getElementById('selected-world').style.color = "#fecc44";
// Notify that world changed (reset preview)
notifyWorldChanged();
// If selecting an existing world, check for existing map data
if (!generate_new_world) {
await loadExistingWorldMapData();
}
}
} catch (error) {
handleWorldSelectionError(error);
}
closeWorldPicker();
}
/**
* Loads existing world map data if available (for existing worlds)
* This will zoom to the location and auto-enable the preview
*/
async function loadExistingWorldMapData() {
if (!worldPath) return;
try {
const mapData = await invoke('gui_get_world_map_data', { worldPath: worldPath });
if (mapData) {
currentWorldMapData = mapData;
// Send data to the map iframe with instruction to zoom and auto-enable
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'loadExistingWorldMap',
data: mapData
}, '*');
}
}
} catch (error) {
console.log("No existing world map data found:", error);
}
}
/**
@@ -796,15 +841,15 @@ async function startGeneration() {
}
if (!selectedBBox || selectedBBox == "0.000000 0.000000 0.000000 0.000000") {
const bboxInfo = document.getElementById('bbox-info');
setBboxInfo(bboxInfo, "select_location_first", "#fa7878");
const bboxSelectionInfo = document.getElementById('bbox-selection-info');
setBboxSelectionInfo(bboxSelectionInfo, "select_location_first", "#fa7878");
return;
}
// Only require world selection for Java format (Bedrock generates a new .mcworld file)
// Only require world creation for Java format (Bedrock generates a new .mcworld file)
if (selectedWorldFormat === 'java' && (!worldPath || worldPath === "")) {
const selectedWorld = document.getElementById('selected-world');
localizeElement(window.localization, { element: selectedWorld }, "select_minecraft_world_first");
localizeElement(window.localization, { element: selectedWorld }, "create_world_first");
selectedWorld.style.color = "#fa7878";
return;
}
@@ -832,6 +877,7 @@ async function startGeneration() {
var interior = document.getElementById("interior-toggle").checked;
var roof = document.getElementById("roof-toggle").checked;
var fill_ground = document.getElementById("fillground-toggle").checked;
var city_boundaries = document.getElementById("city-boundaries-toggle").checked;
var scale = parseFloat(document.getElementById("scale-value-slider").value);
// var ground_level = parseInt(document.getElementById("ground-level").value, 10);
// DEPRECATED: Ground level input removed from UI
@@ -854,7 +900,8 @@ async function startGeneration() {
interiorEnabled: interior,
roofEnabled: roof,
fillgroundEnabled: fill_ground,
isNewWorld: isNewWorld,
cityBoundariesEnabled: city_boundaries,
isNewWorld: true,
spawnPoint: spawnPoint,
telemetryConsent: telemetryConsent || false,
worldFormat: selectedWorldFormat

View File

@@ -195,11 +195,7 @@ Wkt.Wkt.prototype.toObject = function (config) {
* Absorbs the geometry of another Wkt.Wkt instance, merging it with its own,
* creating a collection (MULTI-geometry) based on their types, which must agree.
* For example, creates a MULTIPOLYGON from a POLYGON type merged with another
<<<<<<< HEAD
* POLYGON type.
=======
* POLYGON type, or adds a POLYGON instance to a MULTIPOLYGON instance.
>>>>>>> dev
* @memberof Wkt.Wkt
* @method
*/

View File

@@ -1,6 +1,6 @@
{
"choose_world": "اختيار عالم",
"no_world_selected": "لم يتم تحديد عالم",
"create_world": "إنشاء عالم",
"no_world_selected": "لم يتم إنشاء عالم",
"start_generation": "بدء البناء",
"custom_selection_confirmed": "تم تأكيد التحديد المخصص!",
"error_coordinates_out_of_range": "خطأ: الإحداثيات خارج النطاق أو مرتبة بشكل غير صحيح (مطلوب خط العرض قبل خط الطول).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "مربع الحدود المخصص",
"floodfill_timeout": "مهلة ملء الفيضان (ثواني)",
"ground_level": "مستوى الأرض",
"choose_world_modal_title": "اختيار عالم",
"select_existing_world": "اختيار عالم موجود مسبقًا",
"generate_new_world": "إنشاء عالم جديد",
"customization_settings": "إعدادات التخصيص",
"footer_text": "© {year} Arnis v{version} من louis-e",
"new_version_available": "هناك نسخة جديدة متاحة! انقر هنا لتنزيلها.",
@@ -21,7 +18,7 @@
"world_in_use": "العالم المحدد قيد الاستخدام حاليًا",
"failed_to_create_world": "حدث خطأ عند محاولة إنشاء عالم جديد",
"no_world_selected_error": "لم يتم تحديد عالم",
"select_minecraft_world_first": "يرجى تحديد عالم ماين كرافت أولاً!",
"create_world_first": "أنشئ عالمًا أولاً!",
"select_location_first": "يرجى اختيار موقع أولاً!",
"area_too_large": "تُعتبر هذه المنطقة كبيرة جدًا وقد تتجاوز حدود الحوسبة النموذجية.",
"area_extensive": "المنطقة واسعة جدًا وقد تتطلب الكثير من الوقت والموارد.",
@@ -42,5 +39,7 @@
"interior": "توليد الداخلية",
"roof": "توليد السقف",
"fillground": "ملء الأرض",
"bedrock_use_java": "استخدم Java لاختيار العوالم"
"city_boundaries": "أرضية المدينة",
"bedrock_auto_generated": "يتم إنشاء عالم Bedrock تلقائيًا",
"save_path": "مسار الحفظ"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Welt wählen",
"no_world_selected": "Keine Welt ausgewählt",
"create_world": "Welt erstellen",
"no_world_selected": "Keine Welt erstellt",
"start_generation": "Generierung starten",
"custom_selection_confirmed": "Benutzerdefinierte Auswahl bestätigt!",
"error_coordinates_out_of_range": "Fehler: Koordinaten sind außerhalb des Bereichs oder falsch geordnet (Lat vor Lng erforderlich).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Benutzerdefinierte BBOX",
"floodfill_timeout": "Floodfill-Timeout (Sek)",
"ground_level": "Bodenhöhe",
"choose_world_modal_title": "Welt wählen",
"select_existing_world": "Vorhandene Welt auswählen",
"generate_new_world": "Neue Welt generieren",
"customization_settings": "Einstellungen",
"footer_text": "© {year} Arnis v{version} von louis-e",
"new_version_available": "Eine neue Version ist verfügbar! Klicke hier, um sie herunterzuladen.",
@@ -21,7 +18,7 @@
"world_in_use": "Die ausgewählte Welt ist gerade in Benutzung",
"failed_to_create_world": "Neue Welt konnte nicht erstellt werden",
"no_world_selected_error": "Keine Welt ausgewählt",
"select_minecraft_world_first": "Wähle zuerst eine Minecraft Welt aus!",
"create_world_first": "Erstelle zuerst eine Welt!",
"select_location_first": "Wähle zuerst einen Standort aus!",
"area_too_large": "Dieses Gebiet ist sehr groß und könnte das Berechnungslimit überschreiten.",
"area_extensive": "Diese Gebietsgröße könnte längere Zeit für die Generierung benötigen.",
@@ -42,5 +39,7 @@
"interior": "Innenraum Generierung",
"roof": "Dach Generierung",
"fillground": "Boden füllen",
"bedrock_use_java": "Java für Weltauswahl nutzen"
"city_boundaries": "Stadtboden",
"bedrock_auto_generated": "Bedrock-Welt wird automatisch generiert",
"save_path": "Speicherpfad"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Choose World",
"no_world_selected": "No world selected",
"create_world": "Create World",
"no_world_selected": "No world created",
"start_generation": "Start Generation",
"custom_selection_confirmed": "Custom selection confirmed!",
"error_coordinates_out_of_range": "Error: Coordinates are out of range or incorrectly ordered (Lat before Lng required).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Custom Bounding Box",
"floodfill_timeout": "Floodfill Timeout (sec)",
"ground_level": "Ground Level",
"choose_world_modal_title": "Choose World",
"select_existing_world": "Select existing world",
"generate_new_world": "Generate new world",
"customization_settings": "Customization Settings",
"footer_text": "© {year} Arnis v{version} by louis-e",
"new_version_available": "There's a new version available! Click here to download it.",
@@ -21,7 +18,7 @@
"world_in_use": "The selected world is currently in use",
"failed_to_create_world": "Failed to create new world",
"no_world_selected_error": "No world selected",
"select_minecraft_world_first": "Select a Minecraft world first!",
"create_world_first": "Create a world first!",
"select_location_first": "Select a location first!",
"area_too_large": "This area is very large and could exceed typical computing limits.",
"area_extensive": "The area is quite extensive and may take significant time and resources.",
@@ -42,5 +39,7 @@
"interior": "Interior Generation",
"roof": "Roof Generation",
"fillground": "Fill Ground",
"bedrock_use_java": "Use Java to select worlds"
"city_boundaries": "City Ground",
"bedrock_auto_generated": "Bedrock world is auto-generated",
"save_path": "Save Path"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Elegir mundo",
"no_world_selected": "Ningún mundo seleccionado",
"create_world": "Crear mundo",
"no_world_selected": "Ningún mundo creado",
"start_generation": "Iniciar generación",
"custom_selection_confirmed": "¡Selección personalizada confirmada!",
"error_coordinates_out_of_range": "Error: Las coordenadas están fuera de rango o están ordenadas incorrectamente (Lat antes de Lng requerido).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Caja delimitadora personalizada",
"floodfill_timeout": "Tiempo de espera de relleno (seg)",
"ground_level": "Nivel del suelo",
"choose_world_modal_title": "Elegir mundo",
"select_existing_world": "Seleccionar mundo existente",
"generate_new_world": "Generar nuevo mundo",
"customization_settings": "Configuración de personalización",
"footer_text": "© {year} Arnis v{version} por louis-e",
"new_version_available": "¡Hay una nueva versión disponible! Haga clic aquí para descargarla.",
@@ -21,7 +18,7 @@
"world_in_use": "El mundo seleccionado está en uso",
"failed_to_create_world": "No se pudo crear un nuevo mundo",
"no_world_selected_error": "Ningún mundo seleccionado",
"select_minecraft_world_first": Seleccione un mundo de Minecraft primero!",
"create_world_first": Crea un mundo primero!",
"select_location_first": "¡Seleccione una ubicación primero!",
"area_too_large": "Esta área es muy grande y podría exceder los límites típicos de computación.",
"area_extensive": "El área es bastante extensa y puede requerir mucho tiempo y recursos.",
@@ -42,5 +39,7 @@
"interior": "Generación Interior",
"roof": "Generación de Tejado",
"fillground": "Rellenar Suelo",
"bedrock_use_java": "Usa Java para elegir mundos"
"city_boundaries": "Suelo Urbano",
"bedrock_auto_generated": "El mundo Bedrock se genera automáticamente",
"save_path": "Ruta de guardado"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Valitse maailma",
"no_world_selected": "Maailmaa ei valittu",
"create_world": "Luo maailma",
"no_world_selected": "Maailmaa ei luotu",
"start_generation": "Aloita generointi",
"custom_selection_confirmed": "Mukautettu valinta vahvistettu!",
"error_coordinates_out_of_range": "Virhe: Koordinaatit ovat kantaman ulkopuolella tai vääriin aseteltu (Lat ennen Lng vaadittu).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Mukautettu rajoituslaatikko",
"floodfill_timeout": "Täytön aikakatkaisu (sec)",
"ground_level": "Maataso",
"choose_world_modal_title": "Valitse maailma",
"select_existing_world": "Valitse olemassa oleva maailma",
"generate_new_world": "Luo uusi maailma",
"customization_settings": "Kustomisaatio-asetukset",
"footer_text": "© {year} Arnis v{version} tekijänä louis-e",
"new_version_available": "Uusi versio on saatavilla! Paina tästä ladataksesi sen.",
@@ -21,7 +18,7 @@
"world_in_use": "Valittu maailma käytössä.",
"failed_to_create_world": "Uuden maailman luonti epäonnistui",
"no_world_selected_error": "Maailmaa ei valittu",
"select_minecraft_world_first": "Valitse Minecraft-maailma ensin!",
"create_world_first": "Luo ensin maailma!",
"select_location_first": "Valitse paikka ensin!",
"area_too_large": "Tämä alue on todella iso ja voi ylittää tyypilliset laskentarajat.",
"area_extensive": "Alue on aika laaja ja voi viedä pitkän ajan ja resursseja.",
@@ -42,5 +39,7 @@
"interior": "Sisätilan luonti",
"roof": "Katon luonti",
"fillground": "Täytä maa",
"bedrock_use_java": "Käytä Javaa maailmojen valintaan"
"city_boundaries": "Kaupungin maa",
"bedrock_auto_generated": "Bedrock-maailma luodaan automaattisesti",
"save_path": "Tallennuspolku"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Choisir un monde",
"no_world_selected": "Aucun monde sélectionné",
"create_world": "Créer un monde",
"no_world_selected": "Aucun monde créé",
"start_generation": "Commencer la génération",
"custom_selection_confirmed": "Sélection personnalisée confirmée !",
"error_coordinates_out_of_range": "Erreur: Coordonnées hors de portée ou dans un ordre incorrect (besoin de la latitude avant la longitude).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Cadre de délimitation personnalisé",
"floodfill_timeout": "Expiration du délai de remplissage (en secondes)",
"ground_level": "Niveau du sol",
"choose_world_modal_title": "Choisir un monde",
"select_existing_world": "Sélectionner un monde existant",
"generate_new_world": "Générer un nouveau monde",
"customization_settings": "Paramètres de personnalisation",
"footer_text": "© {year} Arnis v{version} par louis-e",
"new_version_available": "Une nouvelle version est disponible ! Cliquez ici pour la télécharger.",
@@ -21,7 +18,7 @@
"world_in_use": "Le monde sélectionné est en cours d'utilisation",
"failed_to_create_world": "Échec de la création du nouveau monde",
"no_world_selected_error": "Aucun monde sélectionné",
"select_minecraft_world_first": "Sélectionnez d'abord un monde Minecraft !",
"create_world_first": "Créez d'abord un monde !",
"select_location_first": "Sélectionnez d'abord une localisation !",
"area_too_large": "Cette zone est très grande et pourrait dépasser les limites de calcul courantes.",
"area_extensive": "Cette zone est très étendue et pourrait nécessiter beaucoup de ressources et de temps.",
@@ -42,5 +39,7 @@
"interior": "Génération d'intérieur",
"roof": "Génération de toit",
"fillground": "Remplir le sol",
"bedrock_use_java": "Utilisez Java pour les mondes"
"city_boundaries": "Sol urbain",
"bedrock_auto_generated": "Le monde Bedrock est généré automatiquement",
"save_path": "Chemin de sauvegarde"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Világ kiválasztása",
"no_world_selected": "Nincs világ kiválasztva",
"create_world": "Világ létrehozása",
"no_world_selected": "Nincs világ létrehozva",
"start_generation": "Generálás indítása",
"custom_selection_confirmed": "Egyéni kiválasztás megerősítve",
"error_coordinates_out_of_range": "Hiba: A koordináták tartományon kívül vannak vagy hibásan rendezettek (a szélességi foknak a hosszúsági fok előtt kell lennie)",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Egyéni határoló keret",
"floodfill_timeout": "Floodfill Timeout (sec)",
"ground_level": "Földszint",
"choose_world_modal_title": "Világ kiválasztása",
"select_existing_world": "Már létező világ kiválasztása",
"generate_new_world": "Új világ generálása",
"customization_settings": "Testreszabási lehetőségek",
"footer_text": "© {year} Arnis v{version} by louis-e",
"new_version_available": "Egy új verzió elérhető kattints ide hogy letöltsd",
@@ -21,7 +18,7 @@
"world_in_use": "A kiválasztott világ már használatban van",
"failed_to_create_world": "Nem sikerült új világot létrehozni",
"no_world_selected_error": "Nincs kiválasztott világ",
"select_minecraft_world_first": "Válassz ki egy Minecraft világot először!",
"create_world_first": "Először hozz létre egy világot!",
"select_location_first": "Válassz egy helyet először!",
"area_too_large": "Ez a terület nagyon nagy, és meghaladhatja a szokásos számítási korlátokat.",
"area_extensive": "A terület meglehetősen kiterjedt, és jelentős időt és erőforrásokat igényelhet.",
@@ -42,5 +39,7 @@
"interior": "Belső generálás",
"roof": "Tető generálás",
"fillground": "Talaj feltöltése",
"bedrock_use_java": "Java világválasztáshoz"
"city_boundaries": "Városi talaj",
"bedrock_auto_generated": "A Bedrock világ automatikusan generálódik",
"save_path": "Mentési útvonal"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "세계 선택",
"no_world_selected": "선택된 세계 없음",
"create_world": "월드 만들기",
"no_world_selected": "생성된 월드 없음",
"start_generation": "생성 시작",
"custom_selection_confirmed": "사용자 지정 선택이 확인되었습니다!",
"error_coordinates_out_of_range": "오류: 좌표가 범위를 벗어나거나 잘못된 순서입니다 (Lat이 Lng보다 먼저 필요합니다).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "사용자 지정 경계 상자",
"floodfill_timeout": "채우기 시간 초과 (초)",
"ground_level": "지면 레벨",
"choose_world_modal_title": "세계 선택",
"select_existing_world": "이미 존재하는 세계 선택",
"generate_new_world": "새 세계 생성",
"customization_settings": "사용자 지정 설정",
"footer_text": "© {year} Arnis v{version} by louis-e",
"new_version_available": "새로운 버전이 있습니다! 여기를 클릭하여 다운로드하세요.",
@@ -21,7 +18,7 @@
"world_in_use": "선택한 세계가 현재 사용 중입니다",
"failed_to_create_world": "새 세계 생성에 실패했습니다",
"no_world_selected_error": "선택된 세계 없음 오류",
"select_minecraft_world_first": "먼저 마인크래프트 세계를 선택하세요!",
"create_world_first": "먼저 월드를 만드세요!",
"select_location_first": "먼저 위치를 선택하세요!",
"area_too_large": "이 지역은 매우 크고, 일반적인 계산 한계를 초과할 수 있습니다.",
"area_extensive": "이 지역은 꽤 광범위하여 상당한 시간과 자원이 필요할 수 있습니다.",
@@ -42,5 +39,7 @@
"interior": "내부 생성",
"roof": "지붕 생성",
"fillground": "지면 채우기",
"bedrock_use_java": "Java로 세계 선택"
"city_boundaries": "도시 지면",
"bedrock_auto_generated": "Bedrock 월드는 자동 생성됩니다",
"save_path": "저장 경로"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Pasirinkti pasaulį",
"no_world_selected": "Pasaulis nepasirinktas",
"create_world": "Sukurti pasaulį",
"no_world_selected": "Pasaulis nesukurtas",
"start_generation": "Pradėti generaciją",
"custom_selection_confirmed": "Rėmo pasirinkimas patvirtintas!",
"error_coordinates_out_of_range": "Klaida: Koordinatės yra už ribų arba neteisingai išdėstytos (plat turi būti prieš ilg).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Pasirinktinis ribos rėmas",
"floodfill_timeout": "Užpildymo laiko limitas (sek.)",
"ground_level": "Žemės lygis",
"choose_world_modal_title": "Pasaulio pasirinkimas",
"select_existing_world": "Pasirinkti esamą pasaulį",
"generate_new_world": "Sugeneruoti naują pasaulį",
"customization_settings": "Generacijos nustatymai",
"footer_text": "© {year} „Arnis“ v{version} sukurta louis-e",
"new_version_available": "Surasta nauja versija! Spauskite čia kad ją atsisiųstumėte.",
@@ -21,7 +18,7 @@
"world_in_use": "Pasirinktas pasaulis dabar užimtas",
"failed_to_create_world": "Klaida sukuriant naują pasaulį",
"no_world_selected_error": "Nėra pasirinktas pasaulis",
"select_minecraft_world_first": "Pirma pasirinkite „Minecraft“ pasaulį!",
"create_world_first": "Pirmiausia sukurkite pasaulį!",
"select_location_first": "Pirma pasirinkite vietą!",
"area_too_large": "Šis plotas yra labai didelis ir gali viršyti tipinius resursų limitus.",
"area_extensive": "Šis plotas yra pakankamai didelis kuriam reikėtų daug laiko ir resursų.",
@@ -42,5 +39,7 @@
"interior": "Interjero generavimas",
"roof": "Stogo generavimas",
"fillground": "Užpildyti pagrindą",
"bedrock_use_java": "Naudok Java pasauliams"
"city_boundaries": "Miesto žemė",
"bedrock_auto_generated": "Bedrock pasaulis generuojamas automatiškai",
"save_path": "Išsaugojimo kelias"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Izvēlēties pasauli",
"no_world_selected": "Pasaulē nav izvēlēta",
"create_world": "Izveidot pasauli",
"no_world_selected": "Pasaule nav izveidota",
"start_generation": "Sākt ģenerēšanu",
"custom_selection_confirmed": "Pielāgota izvēle apstiprināta!",
"error_coordinates_out_of_range": "Kļūda: koordinātas ir ārpus darbības zonas vai norādītas nepareizā secībā (vispirms platums, tad garums)",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Pielāgota ierobežojošā rāmja zona",
"floodfill_timeout": "Aizpildes noildze (sek.)",
"ground_level": "Zemes līmenis",
"choose_world_modal_title": "Izvēlēties pasauli",
"select_existing_world": "Izvēlēties esošu pasauli",
"generate_new_world": "Izveidot jaunu pasauli",
"customization_settings": "Personalizācijas iestatījumi",
"footer_text": "© {year} Arnis v{version} no louis-e",
"new_version_available": "Pieejama jauna versija! Noklikšķiniet šeit, lai lejupielādētu",
@@ -21,7 +18,7 @@
"world_in_use": "Izvēlētā pasaule jau tiek izmantota",
"failed_to_create_world": "Neizdevās izveidot jaunu pasauli",
"no_world_selected_error": "Pasaulē nav izvēlēta",
"select_minecraft_world_first": "Vispirms izvēlieties Minecraft pasauli!",
"create_world_first": "Vispirms izveidojiet pasauli!",
"select_location_first": "Vispirms izvēlieties atrašanās vietu!",
"area_too_large": "Šis apgabals ir pārāk liels un var pārsniegt tipiskos aprēķina ierobežojumus",
"area_extensive": "Apgabals ir diezgan plašs un var prasīt ievērojamu laiku un resursus",
@@ -42,5 +39,7 @@
"interior": "Interjera ģenerēšana",
"roof": "Jumta ģenerēšana",
"fillground": "Aizpildīt zemi",
"bedrock_use_java": "Izmanto Java pasaulēm"
"city_boundaries": "Pilsētas zeme",
"bedrock_auto_generated": "Bedrock pasaule tiek ģenerēta automātiski",
"save_path": "Saglabāšanas ceļš"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Wybierz świat",
"no_world_selected": "Nie wybrano świata",
"create_world": "Utwórz świat",
"no_world_selected": "Nie utworzono świata",
"start_generation": "Rozpocznij generowanie",
"custom_selection_confirmed": "Niestandardowy wybór potwierdzony!",
"error_coordinates_out_of_range": "Błąd: Współrzędne są poza zakresem lub w złej kolejności (wymagana szerokość przed długością).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Niestandardowy obszar",
"floodfill_timeout": "Limit czasu wypełniania (sek)",
"ground_level": "Wysokość obszaru",
"choose_world_modal_title": "Wybierz świat",
"select_existing_world": "Wybierz istniejący świat",
"generate_new_world": "Generuj nowy świat",
"customization_settings": "Ustawienia personalizacji",
"footer_text": "© {year} Arnis v{version} autorstwa louis-e",
"new_version_available": "Dostępna jest nowa wersja! Kliknij tutaj, aby ją pobrać.",
@@ -21,7 +18,7 @@
"world_in_use": "Wybrany świat jest obecnie używany",
"failed_to_create_world": "Nie udało się utworzyć świata",
"no_world_selected_error": "Nie wybrano świata",
"select_minecraft_world_first": "Najpierw wybierz świat Minecrafta!",
"create_world_first": "Najpierw utwórz świat!",
"select_location_first": "Najpierw wybierz lokalizację!",
"area_too_large": "Ten obszar jest bardzo duży i może przekroczyć limity obliczeniowe.",
"area_extensive": "Ten obszar jest rozległy i może pochłonąć dużo czasu oraz zasobów.",
@@ -42,5 +39,7 @@
"interior": "Generowanie wnętrza",
"roof": "Generowanie dachu",
"fillground": "Wypełnij podłoże",
"bedrock_use_java": "Użyj Java do wyboru światów"
"city_boundaries": "Podłoże miejskie",
"bedrock_auto_generated": "Świat Bedrock jest generowany automatycznie",
"save_path": "Ścieżka zapisu"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Выбрать мир",
"no_world_selected": "Мир не выбран",
"create_world": "Создать мир",
"no_world_selected": "Мир не создан",
"start_generation": "Начать генерацию",
"custom_selection_confirmed": "Пользовательский выбор подтвержден!",
"error_coordinates_out_of_range": "Ошибка: Координаты находятся вне зоны действия или указаны в неправильном порядке (сначала широта, затем долгота)",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Пользовательская ограничивающая рамка",
"floodfill_timeout": "Тайм-аут заливки (сек)",
"ground_level": "Уровень земли",
"choose_world_modal_title": "Выбрать мир",
"select_existing_world": "Выбрать существующий мир",
"generate_new_world": "Создать новый мир",
"customization_settings": "Настройки персонализации",
"footer_text": "© {year} Arnis v{version} от louis-e",
"new_version_available": "Доступна новая версия! Нажмите здесь, чтобы скачать",
@@ -21,7 +18,7 @@
"world_in_use": "Выбранный мир уже используется",
"failed_to_create_world": "Не удалось создать новый мир",
"no_world_selected_error": "Мир не выбран",
"select_minecraft_world_first": "Сначала выберите мир Minecraft!",
"create_world_first": "Сначала создайте мир!",
"select_location_first": "Сначала выберите местоположение!",
"area_too_large": "Эта область слишком велика и может превысить типичные вычислительные ограничения",
"area_extensive": "Область довольно обширна и может потребовать значительного времени и ресурсов",
@@ -42,5 +39,7 @@
"interior": "Генерация Интерьера",
"roof": "Генерация Крыши",
"fillground": "Заполнить Землю",
"bedrock_use_java": "Используйте Java для миров"
"city_boundaries": "Городской грунт",
"bedrock_auto_generated": "Мир Bedrock генерируется автоматически",
"save_path": "Путь сохранения"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Välj värld",
"no_world_selected": "Ingen värld vald",
"create_world": "Skapa värld",
"no_world_selected": "Ingen värld skapad",
"start_generation": "Starta generering",
"custom_selection_confirmed": "Anpassad markering bekräftad!",
"error_coordinates_out_of_range": "Fel: Koordinater är utanför området eller felaktigt ordnade (Lat före Lng krävs).",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Anpassad begränsningsram",
"floodfill_timeout": "Floodfill-tidsgräns (sek)",
"ground_level": "Marknivå",
"choose_world_modal_title": "Välj värld",
"select_existing_world": "Välj existerande värld",
"generate_new_world": "Generera ny värld",
"customization_settings": "Anpassningsinställningar",
"footer_text": "© {year} Arnis v{version} by louis-e",
"new_version_available": "Det finns en ny version tillgänglig! Klicka här för att ladda ner den.",
@@ -21,7 +18,7 @@
"world_in_use": "Den valda världen används just nu",
"failed_to_create_world": "Misslyckades att skapa ny värld",
"no_world_selected_error": "Ingen värld vald fel",
"select_minecraft_world_first": "Välj Minecraft-värld först!",
"create_world_first": "Skapa en värld först!",
"select_location_first": "Välj plats först!",
"area_too_large": "Detta område är mycket stort och kan överskrida vanliga beräkningsgränser.",
"area_extensive": "Området är ganska extensivt och kan ta betydande tid och resurser.",
@@ -42,5 +39,7 @@
"interior": "Interiörgenerering",
"roof": "Takgenerering",
"fillground": "Fyll mark",
"bedrock_use_java": "Använd Java för världar"
"city_boundaries": "Stadsmark",
"bedrock_auto_generated": "Bedrock-världen genereras automatiskt",
"save_path": "Sökväg"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "Обрати світ",
"no_world_selected": "Світ не обрано",
"create_world": "Створити світ",
"no_world_selected": "Світ не створено",
"start_generation": "Почати генерацію",
"custom_selection_confirmed": "Користувацький вибір підтверджено!",
"error_coordinates_out_of_range": "Помилка: Координати поза діапазоном або неправильно впорядковані (потрібно широта перед довгота)",
@@ -11,9 +11,6 @@
"custom_bounding_box": "Користувацька обмежувальна рамка",
"floodfill_timeout": "Тайм-аут заливки (сек)",
"ground_level": "Рівень землі",
"choose_world_modal_title": "Обрати світ",
"select_existing_world": "Обрати наявний світ",
"generate_new_world": "Створити новий світ",
"customization_settings": "Налаштування параметрів",
"footer_text": "© {year} Arnis v{version} від louis-e",
"new_version_available": "Доступна нова версія! Натисніть тут, щоб завантажити її",
@@ -21,7 +18,7 @@
"world_in_use": "Вибраний світ зараз використовується",
"failed_to_create_world": "Не вдалося створити новий світ",
"no_world_selected_error": "Світ не обрано",
"select_minecraft_world_first": "Спочатку виберіть світ Minecraft!",
"create_world_first": "Спочатку створіть світ!",
"select_location_first": "Спочатку виберіть місцезнаходження!",
"area_too_large": "Ця область дуже велика і може перевищити типові обчислювальні межі",
"area_extensive": "Область досить велика і може вимагати значного часу та ресурсів",
@@ -42,5 +39,7 @@
"interior": "Генерація інтер'єру",
"roof": "Генерація даху",
"fillground": "Заповнити землю",
"bedrock_use_java": "Використовуй Java для світів"
"city_boundaries": "Міська земля",
"bedrock_auto_generated": "Bedrock світ генерується автоматично",
"save_path": "Шлях збереження"
}

View File

@@ -1,6 +1,6 @@
{
"choose_world": "选择世界",
"no_world_selected": "未选择世界",
"create_world": "创建世界",
"no_world_selected": "未创建世界",
"start_generation": "开始生成",
"custom_selection_confirmed": "自定义选择已确认!",
"error_coordinates_out_of_range": "错误:坐标超出范围或顺序不正确(需要先纬度后经度)。",
@@ -11,9 +11,6 @@
"custom_bounding_box": "自定义边界框",
"floodfill_timeout": "填充超时(秒)",
"ground_level": "地面高度",
"choose_world_modal_title": "选择世界",
"select_existing_world": "选择现有世界",
"generate_new_world": "生成新世界",
"customization_settings": "自定义设置",
"footer_text": "© {year} Arnis v{version} 由 louis-e 提供",
"new_version_available": "有新版本可用!点击这里下载。",
@@ -21,7 +18,7 @@
"world_in_use": "所选世界正在使用中",
"failed_to_create_world": "无法创建新世界",
"no_world_selected_error": "未选择世界",
"select_minecraft_world_first": "请先选择一个 Minecraft 世界!",
"create_world_first": "请先创建一个世界!",
"select_location_first": "请先选择一个位置!",
"area_too_large": "该区域非常大,可能会超出典型的计算限制。",
"area_extensive": "该区域相当广泛,可能需要大量时间和资源。",
@@ -42,5 +39,7 @@
"interior": "内部生成",
"roof": "屋顶生成",
"fillground": "填充地面",
"bedrock_use_java": "使用Java选择世界"
"city_boundaries": "城市地面",
"bedrock_auto_generated": "Bedrock 世界自动生成",
"save_path": "存档路径"
}

View File

@@ -25,12 +25,15 @@ mod retrieve_data;
mod telemetry;
#[cfg(test)]
mod test_utilities;
mod urban_ground;
mod version_check;
mod world_editor;
mod world_utils;
use args::Args;
use clap::Parser;
use colored::*;
use std::path::PathBuf;
use std::{env, fs, io::Write};
#[cfg(feature = "gui")]
@@ -54,6 +57,9 @@ fn run_cli() {
// Configure thread pool with 90% CPU cap to keep system responsive
floodfill_cache::configure_rayon_thread_pool(0.9);
// Clean up old cached elevation tiles on startup
elevation_data::cleanup_old_cached_tiles();
let version: &str = env!("CARGO_PKG_VERSION");
let repository: &str = env!("CARGO_PKG_REPOSITORY");
println!(
@@ -87,6 +93,54 @@ fn run_cli() {
// Parse input arguments
let args: Args = Args::parse();
// Validate arguments (path requirements differ between Java and Bedrock)
if let Err(e) = args::validate_args(&args) {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
// Early guard: --bedrock requires the bedrock cargo feature
if args.bedrock && !cfg!(feature = "bedrock") {
eprintln!(
"{}: The --bedrock flag requires the 'bedrock' feature. Rebuild with: cargo build --features bedrock",
"Error".red().bold()
);
std::process::exit(1);
}
// Determine world format and output path
let world_format = if args.bedrock {
world_editor::WorldFormat::BedrockMcWorld
} else {
world_editor::WorldFormat::JavaAnvil
};
// Build the generation output path and level name
let (generation_path, level_name) = if args.bedrock {
// Bedrock: generate .mcworld file in user-specified path or Desktop
let output_dir = args
.path
.clone()
.unwrap_or_else(world_utils::get_bedrock_output_directory);
let (output_path, lvl_name) = world_utils::build_bedrock_output(&args.bbox, output_dir);
(output_path, Some(lvl_name))
} else {
// Java: create a new world in the provided output directory
let base_dir = args.path.clone().unwrap();
let world_path = match world_utils::create_new_world(&base_dir) {
Ok(path) => PathBuf::from(path),
Err(e) => {
eprintln!("{} {}", "Error:".red().bold(), e);
std::process::exit(1);
}
};
println!(
"Created new world at: {}",
world_path.display().to_string().bright_white().bold()
);
(world_path, None)
};
// Fetch data
let raw_data = match &args.file {
Some(file) => retrieve_data::fetch_data_from_file(file),
@@ -127,8 +181,37 @@ fn run_cli() {
// Transform map (parsed_elements). Operations are defined in a json file
map_transformation::transform_map(&mut parsed_elements, &mut xzbbox, &mut ground);
// Build generation options
let generation_options = data_processing::GenerationOptions {
path: generation_path.clone(),
format: world_format,
level_name,
spawn_point: None,
};
// Generate world
let _ = data_processing::generate_world(parsed_elements, xzbbox, args.bbox, ground, &args);
match data_processing::generate_world_with_options(
parsed_elements,
xzbbox,
args.bbox,
ground,
&args,
generation_options,
) {
Ok(_) => {
if args.bedrock {
println!(
"{} Bedrock world saved to: {}",
"Done!".green().bold(),
generation_path.display()
);
}
}
Err(e) => {
eprintln!("{} {}", "Error:".red().bold(), e);
std::process::exit(1);
}
}
}
fn main() {

View File

@@ -111,6 +111,7 @@ pub struct ProcessedWay {
pub enum ProcessedMemberRole {
Outer,
Inner,
Part,
}
#[derive(Debug, Clone, PartialEq)]
@@ -272,13 +273,24 @@ pub fn parse_osm_data(
continue;
};
// Only process multipolygons for now
if tags.get("type").map(|x: &String| x.as_str()) != Some("multipolygon") {
// 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("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
// 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_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
@@ -289,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
@@ -304,9 +331,9 @@ pub fn parse_osm_data(
}
};
// Water relations: keep unclipped for ring merging
// Non-water relations: clip member ways now
let final_way = if is_water_relation {
// 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 {
let clipped_nodes = clip_way_to_bbox(&way.nodes, &xzbbox);
@@ -336,7 +363,7 @@ pub fn parse_osm_data(
}
}
emit_gui_progress_update(15.0, "");
emit_gui_progress_update(14.0, "");
drop(nodes_map);
drop(ways_map);

View File

@@ -1,8 +1,10 @@
use crate::coordinate_system::geographic::LLBBox;
use crate::osm_parser::OsmData;
use crate::progress::{emit_gui_error, emit_gui_progress_update, is_running_with_gui};
#[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;
@@ -44,6 +46,11 @@ fn download_with_reqwest(url: &str, query: &str) -> Result<String, Box<dyn std::
eprintln!("{}", format!("Error! {msg}").red().bold());
Err(msg.into())
} else {
#[cfg(feature = "gui")]
send_log(
LogLevel::Error,
&format!("Request error in download_with_reqwest: {e}"),
);
eprintln!("{}", format!("Error! {e:.52}").red().bold());
Err(format!("{e:.52}").into())
}
@@ -110,13 +117,14 @@ 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!(
r#"[out:json][timeout:360][bbox:{},{},{},{}];
(
nwr["building"];
nwr["building:part"];
nwr["highway"];
nwr["landuse"];
nwr["natural"];
@@ -127,9 +135,17 @@ pub fn fetch_data_from_overpass(
nwr["tourism"];
nwr["bridge"];
nwr["railway"];
nwr["roller_coaster"];
nwr["barrier"];
nwr["entrance"];
nwr["door"];
nwr["power"];
nwr["historic"];
nwr["emergency"];
nwr["advertising"];
nwr["man_made"];
nwr["aeroway"];
way["place"];
way;
)->.relsinbbox;
(
@@ -169,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;
}
}

848
src/urban_ground.rs Normal file
View File

@@ -0,0 +1,848 @@
//! Urban ground detection and generation based on building clusters.
//!
//! This module computes urban areas by analyzing building density and clustering,
//! then generates appropriate ground blocks (smooth stone) for those areas.
//!
//! # Algorithm Overview
//!
//! 1. **Grid-based density analysis**: Divide the world into cells and count buildings per cell
//! 2. **Connected component detection**: Find clusters of dense cells using flood fill
//! 3. **Cluster filtering**: Only keep clusters with enough buildings to be considered "urban"
//! 4. **Concave hull computation**: Compute a tight-fitting boundary around each cluster
//! 5. **Ground filling**: Fill the hull area with stone blocks
//!
//! This approach handles various scenarios:
//! - Full city coverage: Large connected cluster
//! - Multiple cities: Separate clusters, each gets its own hull
//! - Rural areas: No clusters meet threshold, no stone placed
//! - Isolated buildings: Don't meet cluster threshold, remain on grass
use crate::coordinate_system::cartesian::XZBBox;
use crate::floodfill::flood_fill_area;
use geo::{ConcaveHull, ConvexHull, MultiPoint, Point, Polygon, Simplify};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Duration;
/// Configuration for urban ground detection.
///
/// These parameters control how building clusters are identified and
/// how the urban ground boundary is computed.
#[derive(Debug, Clone)]
pub struct UrbanGroundConfig {
/// Grid cell size for density analysis (in blocks).
/// Smaller = more precise but slower. Default: 64 blocks (4 chunks).
pub cell_size: i32,
/// Minimum buildings per cell to consider it potentially urban.
/// Cells below this threshold are ignored. Default: 1.
pub min_buildings_per_cell: usize,
/// Minimum total buildings in a connected cluster to be considered urban.
/// Small clusters (villages, isolated buildings) won't get stone ground. Default: 5.
pub min_buildings_for_cluster: usize,
/// Concavity parameter for hull computation (used in legacy hull-based method).
/// Lower = tighter fit to buildings (more concave), Higher = smoother (more convex).
/// Range: 1.0 (very tight) to 10.0 (almost convex). Default: 2.0.
pub concavity: f64,
/// Whether to expand the hull slightly beyond building boundaries (used in legacy method).
/// This creates a small buffer zone around the urban area. Default: true.
pub expand_hull: bool,
/// Base number of cells to expand the urban region.
/// This helps fill small gaps between buildings. Adaptive expansion may increase this.
/// Default: 2.
pub cell_expansion: i32,
}
impl Default for UrbanGroundConfig {
fn default() -> Self {
Self {
cell_size: 64, // Smaller cells for better granularity (4 chunks instead of 6)
min_buildings_per_cell: 1,
min_buildings_for_cluster: 5,
concavity: 2.0,
expand_hull: true,
cell_expansion: 2, // Larger expansion to connect spread-out buildings
}
}
}
/// Represents a detected urban cluster with its buildings and computed boundary.
#[derive(Debug)]
#[allow(dead_code)]
pub struct UrbanCluster {
/// Grid cells that belong to this cluster
cells: Vec<(i32, i32)>,
/// Building centroids within this cluster
building_centroids: Vec<(i32, i32)>,
/// Total number of buildings in the cluster
building_count: usize,
}
/// A compact lookup structure for checking if a coordinate is in an urban area.
///
/// Instead of storing millions of individual coordinates, this stores only
/// the cell indices (thousands) and performs O(1) lookups. This reduces
/// memory usage by ~4000x compared to storing all coordinates.
///
/// # Memory Usage
/// - 7.8 km² area: ~17K cells × 16 bytes = ~270 KB (vs ~560 MB for coordinates)
/// - 100 km² area: ~220K cells × 16 bytes = ~3.5 MB (vs ~7 GB for coordinates)
#[derive(Debug, Clone)]
pub struct UrbanGroundLookup {
/// Set of cell indices (cx, cz) that are urban
urban_cells: HashSet<(i32, i32)>,
/// Cell size used for coordinate-to-cell conversion
cell_size: i32,
/// Bounding box origin for coordinate conversion
bbox_min_x: i32,
bbox_min_z: i32,
}
impl UrbanGroundLookup {
/// Creates an empty lookup (no urban areas).
pub fn empty() -> Self {
Self {
urban_cells: HashSet::new(),
cell_size: 64,
bbox_min_x: 0,
bbox_min_z: 0,
}
}
/// Returns true if the given world coordinate is in an urban area.
#[inline]
pub fn is_urban(&self, x: i32, z: i32) -> bool {
if self.urban_cells.is_empty() {
return false;
}
let cx = (x - self.bbox_min_x) / self.cell_size;
let cz = (z - self.bbox_min_z) / self.cell_size;
self.urban_cells.contains(&(cx, cz))
}
/// Returns the number of urban cells.
#[allow(dead_code)]
pub fn cell_count(&self) -> usize {
self.urban_cells.len()
}
/// Returns true if there are no urban areas.
pub fn is_empty(&self) -> bool {
self.urban_cells.is_empty()
}
}
/// Computes urban ground areas from building locations.
pub struct UrbanGroundComputer {
config: UrbanGroundConfig,
building_centroids: Vec<(i32, i32)>,
xzbbox: XZBBox,
}
impl UrbanGroundComputer {
/// Creates a new urban ground computer with the given world bounds and configuration.
pub fn new(xzbbox: XZBBox, config: UrbanGroundConfig) -> Self {
Self {
config,
building_centroids: Vec::new(),
xzbbox,
}
}
/// Creates a new urban ground computer with default configuration.
pub fn with_defaults(xzbbox: XZBBox) -> Self {
Self::new(xzbbox, UrbanGroundConfig::default())
}
/// Adds a building centroid to be considered for urban area detection.
#[inline]
pub fn add_building_centroid(&mut self, x: i32, z: i32) {
// Only add if within bounds
if x >= self.xzbbox.min_x()
&& x <= self.xzbbox.max_x()
&& z >= self.xzbbox.min_z()
&& z <= self.xzbbox.max_z()
{
self.building_centroids.push((x, z));
}
}
/// Adds multiple building centroids from an iterator.
pub fn add_building_centroids<I>(&mut self, centroids: I)
where
I: IntoIterator<Item = (i32, i32)>,
{
for (x, z) in centroids {
self.add_building_centroid(x, z);
}
}
/// Returns the number of buildings added.
#[allow(dead_code)]
pub fn building_count(&self) -> usize {
self.building_centroids.len()
}
/// Computes all urban ground coordinates.
///
/// Returns a list of (x, z) coordinates that should have stone ground.
/// The coordinates are clipped to the world bounding box.
///
/// Performance: Uses cell-based filling for O(cells) complexity instead of
/// flood-filling complex hulls which would be O(area). For a city with 1000
/// buildings in 100 cells, this is ~100x faster than flood fill.
///
/// NOTE: For better performance and memory usage, prefer `compute_lookup()`.
#[allow(dead_code)]
pub fn compute(&self, _timeout: Option<&Duration>) -> Vec<(i32, i32)> {
// Not enough buildings for any urban area
if self.building_centroids.len() < self.config.min_buildings_for_cluster {
return Vec::new();
}
// Step 1: Create density grid (cell -> buildings in that cell)
let grid = self.create_density_grid();
// Step 2: Find connected urban regions and get their expanded cells
let clusters = self.find_urban_clusters(&grid);
if clusters.is_empty() {
return Vec::new();
}
// Step 3: Fill cells directly instead of using expensive flood fill on hulls
// This is much faster: O(cells × cell_size²) vs O(hull_area) for flood fill
let mut all_coords = Vec::new();
for cluster in clusters {
let coords = self.fill_cluster_cells(&cluster);
all_coords.extend(coords);
}
all_coords
}
/// Computes urban ground and returns a compact lookup structure.
///
/// This is the preferred method for production use. Instead of returning
/// millions of coordinates (high memory), it returns a lookup structure
/// that stores only cell indices (~4000x less memory) and provides O(1)
/// coordinate lookups.
///
/// # Memory Comparison
/// - `compute()`: ~560 MB for 7.8 km² area
/// - `compute_lookup()`: ~270 KB for same area
pub fn compute_lookup(&self) -> UrbanGroundLookup {
// Not enough buildings for any urban area
if self.building_centroids.len() < self.config.min_buildings_for_cluster {
return UrbanGroundLookup::empty();
}
// Step 1: Create density grid (cell -> buildings in that cell)
let grid = self.create_density_grid();
// Step 2: Find connected urban regions and get their expanded cells
let clusters = self.find_urban_clusters(&grid);
if clusters.is_empty() {
return UrbanGroundLookup::empty();
}
// Step 3: Collect all expanded cells from all clusters into a HashSet
let mut urban_cells = HashSet::new();
for cluster in clusters {
urban_cells.extend(cluster.cells.iter().copied());
}
UrbanGroundLookup {
urban_cells,
cell_size: self.config.cell_size,
bbox_min_x: self.xzbbox.min_x(),
bbox_min_z: self.xzbbox.min_z(),
}
}
/// Fills all cells in a cluster directly, returning coordinates.
/// This is much faster than computing a hull and flood-filling it.
fn fill_cluster_cells(&self, cluster: &UrbanCluster) -> Vec<(i32, i32)> {
let mut coords = Vec::new();
let cell_size = self.config.cell_size;
// Pre-calculate bounds once
let bbox_min_x = self.xzbbox.min_x();
let bbox_max_x = self.xzbbox.max_x();
let bbox_min_z = self.xzbbox.min_z();
let bbox_max_z = self.xzbbox.max_z();
for &(cx, cz) in &cluster.cells {
// Calculate cell bounds in world coordinates
let cell_min_x = (bbox_min_x + cx * cell_size).max(bbox_min_x);
let cell_max_x = (bbox_min_x + (cx + 1) * cell_size - 1).min(bbox_max_x);
let cell_min_z = (bbox_min_z + cz * cell_size).max(bbox_min_z);
let cell_max_z = (bbox_min_z + (cz + 1) * cell_size - 1).min(bbox_max_z);
// Skip if cell is entirely outside bbox
if cell_min_x > bbox_max_x
|| cell_max_x < bbox_min_x
|| cell_min_z > bbox_max_z
|| cell_max_z < bbox_min_z
{
continue;
}
// Fill all coordinates in this cell
for x in cell_min_x..=cell_max_x {
for z in cell_min_z..=cell_max_z {
coords.push((x, z));
}
}
}
coords
}
/// Creates a density grid mapping cell coordinates to buildings in that cell.
fn create_density_grid(&self) -> HashMap<(i32, i32), Vec<(i32, i32)>> {
let mut grid: HashMap<(i32, i32), Vec<(i32, i32)>> = HashMap::new();
for &(x, z) in &self.building_centroids {
let cell_x = (x - self.xzbbox.min_x()) / self.config.cell_size;
let cell_z = (z - self.xzbbox.min_z()) / self.config.cell_size;
grid.entry((cell_x, cell_z)).or_default().push((x, z));
}
grid
}
/// Finds connected clusters of urban cells.
fn find_urban_clusters(
&self,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
) -> Vec<UrbanCluster> {
// Step 1: Identify cells that meet minimum density threshold
let dense_cells: HashSet<(i32, i32)> = grid
.iter()
.filter(|(_, buildings)| buildings.len() >= self.config.min_buildings_per_cell)
.map(|(&cell, _)| cell)
.collect();
if dense_cells.is_empty() {
return Vec::new();
}
// Step 2: Calculate adaptive expansion based on building density
// For spread-out cities, we need more expansion to connect buildings
let adaptive_expansion = self.calculate_adaptive_expansion(&dense_cells, grid);
// Step 3: Expand dense cells to connect nearby clusters
let expanded_cells = self.expand_cells_adaptive(&dense_cells, adaptive_expansion);
// Step 4: Find connected components using flood fill
let mut visited = HashSet::new();
let mut clusters = Vec::new();
for &cell in &expanded_cells {
if visited.contains(&cell) {
continue;
}
// BFS to find connected component
let mut component_cells = Vec::new();
let mut queue = VecDeque::new();
queue.push_back(cell);
visited.insert(cell);
while let Some(current) = queue.pop_front() {
component_cells.push(current);
// Check 8-connected neighbors (including diagonals for better connectivity)
for dz in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dz == 0 {
continue;
}
let neighbor = (current.0 + dx, current.1 + dz);
if expanded_cells.contains(&neighbor) && !visited.contains(&neighbor) {
visited.insert(neighbor);
queue.push_back(neighbor);
}
}
}
}
// Collect buildings from the original dense cells only (not expanded empty cells)
let mut cluster_buildings = Vec::new();
for &cell in &component_cells {
if let Some(buildings) = grid.get(&cell) {
cluster_buildings.extend(buildings.iter().copied());
}
}
let building_count = cluster_buildings.len();
// Only keep clusters with enough buildings
if building_count >= self.config.min_buildings_for_cluster {
clusters.push(UrbanCluster {
cells: component_cells,
building_centroids: cluster_buildings,
building_count,
});
}
}
clusters
}
/// Calculates adaptive expansion based on building density.
///
/// For spread-out cities (low density), we need more expansion to connect
/// buildings that are farther apart. For dense cities, less expansion is needed.
fn calculate_adaptive_expansion(
&self,
dense_cells: &HashSet<(i32, i32)>,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
) -> i32 {
if dense_cells.is_empty() {
return self.config.cell_expansion;
}
// Calculate total buildings and average per occupied cell
let total_buildings: usize = dense_cells
.iter()
.filter_map(|cell| grid.get(cell))
.map(|buildings| buildings.len())
.sum();
let avg_buildings_per_cell = total_buildings as f64 / dense_cells.len() as f64;
// Calculate the "spread" of cells - how far apart are occupied cells?
// Find bounding box of occupied cells
if dense_cells.len() < 2 {
return self.config.cell_expansion;
}
let min_x = dense_cells.iter().map(|(x, _)| x).min().unwrap();
let max_x = dense_cells.iter().map(|(x, _)| x).max().unwrap();
let min_z = dense_cells.iter().map(|(_, z)| z).min().unwrap();
let max_z = dense_cells.iter().map(|(_, z)| z).max().unwrap();
let grid_span_x = (max_x - min_x + 1) as f64;
let grid_span_z = (max_z - min_z + 1) as f64;
let total_possible_cells = grid_span_x * grid_span_z;
// Cell occupancy ratio: what fraction of the bounding box has buildings?
let occupancy = dense_cells.len() as f64 / total_possible_cells;
// Adaptive expansion logic:
// - High density (many buildings per cell) AND high occupancy = dense city, use base expansion
// - Low density OR low occupancy = spread-out city, need more expansion
let base_expansion = self.config.cell_expansion;
// Scale factor: lower density = higher factor
// avg_buildings_per_cell < 2 → spread out
// occupancy < 0.3 → sparse grid with gaps
let density_factor = if avg_buildings_per_cell < 3.0 {
1.5
} else {
1.0
};
let occupancy_factor = if occupancy < 0.4 {
1.5
} else if occupancy < 0.6 {
1.25
} else {
1.0
};
let adaptive = (base_expansion as f64 * density_factor * occupancy_factor).ceil() as i32;
// Cap at reasonable maximum (4 cells = 256 blocks with 64-block cells)
adaptive.min(4).max(base_expansion)
}
/// Expands the set of cells by adding neighbors within expansion distance.
fn expand_cells_adaptive(
&self,
cells: &HashSet<(i32, i32)>,
expansion: i32,
) -> HashSet<(i32, i32)> {
if expansion <= 0 {
return cells.clone();
}
let mut expanded = cells.clone();
for &(cx, cz) in cells {
for dz in -expansion..=expansion {
for dx in -expansion..=expansion {
expanded.insert((cx + dx, cz + dz));
}
}
}
expanded
}
/// Expands the set of cells by adding neighbors within expansion distance.
#[allow(dead_code)]
fn expand_cells(&self, cells: &HashSet<(i32, i32)>) -> HashSet<(i32, i32)> {
self.expand_cells_adaptive(cells, self.config.cell_expansion)
}
/// Computes ground coordinates for a single urban cluster.
///
/// NOTE: This hull-based method is kept for reference but not used in production.
/// The cell-based `fill_cluster_cells` method is much faster.
#[allow(dead_code)]
fn compute_cluster_ground(
&self,
cluster: &UrbanCluster,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
// Need at least 3 points for a hull
if cluster.building_centroids.len() < 3 {
return Vec::new();
}
// Collect points for hull computation
// Include building centroids plus cell corner points for better coverage
let mut hull_points: Vec<(f64, f64)> = cluster
.building_centroids
.iter()
.map(|&(x, z)| (x as f64, z as f64))
.collect();
// Add cell boundary points if expand_hull is enabled
// This ensures the hull extends slightly beyond buildings
if self.config.expand_hull {
for &(cx, cz) in &cluster.cells {
// Only add corners for cells that actually have buildings
if grid.get(&(cx, cz)).map(|b| !b.is_empty()).unwrap_or(false) {
let base_x = (self.xzbbox.min_x() + cx * self.config.cell_size) as f64;
let base_z = (self.xzbbox.min_z() + cz * self.config.cell_size) as f64;
let size = self.config.cell_size as f64;
// Add cell corners with small padding
let pad = size * 0.1; // 10% padding
hull_points.push((base_x - pad, base_z - pad));
hull_points.push((base_x + size + pad, base_z - pad));
hull_points.push((base_x - pad, base_z + size + pad));
hull_points.push((base_x + size + pad, base_z + size + pad));
}
}
}
// Convert to geo MultiPoint
let multi_point: MultiPoint<f64> =
hull_points.iter().map(|&(x, z)| Point::new(x, z)).collect();
// Compute hull based on point count
let hull: Polygon<f64> = if hull_points.len() < 10 {
// Too few points for concave hull, use convex
multi_point.convex_hull()
} else {
// Use concave hull for better fit
multi_point.concave_hull(self.config.concavity)
};
// Simplify the hull to reduce vertex count (improves flood fill performance)
let hull = hull.simplify(2.0);
// Convert hull to integer coordinates for flood fill
self.fill_hull_polygon(&hull, timeout)
}
/// Fills a hull polygon and returns all interior coordinates.
///
/// NOTE: This method is kept for reference but not used in production.
/// The cell-based approach is much faster.
#[allow(dead_code)]
fn fill_hull_polygon(
&self,
polygon: &Polygon<f64>,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
// Convert polygon exterior to integer coordinates
let exterior: Vec<(i32, i32)> = polygon
.exterior()
.coords()
.map(|c| (c.x.round() as i32, c.y.round() as i32))
.collect();
if exterior.len() < 3 {
return Vec::new();
}
// Remove duplicate consecutive points (can cause flood fill issues)
let mut clean_exterior = Vec::with_capacity(exterior.len());
for point in exterior {
if clean_exterior.last() != Some(&point) {
clean_exterior.push(point);
}
}
// Ensure the polygon is closed
if clean_exterior.first() != clean_exterior.last() && !clean_exterior.is_empty() {
clean_exterior.push(clean_exterior[0]);
}
if clean_exterior.len() < 4 {
// Need at least 3 unique points + closing point
return Vec::new();
}
// Use existing flood fill, clipping to bbox
let filled = flood_fill_area(&clean_exterior, timeout);
// Filter to only include points within world bounds
filled
.into_iter()
.filter(|&(x, z)| {
x >= self.xzbbox.min_x()
&& x <= self.xzbbox.max_x()
&& z >= self.xzbbox.min_z()
&& z <= self.xzbbox.max_z()
})
.collect()
}
}
/// Computes the centroid of a set of coordinates.
///
/// Returns None if the slice is empty.
#[inline]
#[allow(dead_code)]
pub fn compute_centroid(coords: &[(i32, i32)]) -> Option<(i32, i32)> {
if coords.is_empty() {
return None;
}
let sum_x: i64 = coords.iter().map(|(x, _)| i64::from(*x)).sum();
let sum_z: i64 = coords.iter().map(|(_, z)| i64::from(*z)).sum();
let len = coords.len() as i64;
Some(((sum_x / len) as i32, (sum_z / len) as i32))
}
/// Convenience function to compute urban ground from building centroids.
///
/// NOTE: This function is kept for backward compatibility and tests.
/// For production use, prefer `compute_urban_ground_lookup` which uses
/// ~4000x less memory.
#[allow(dead_code)]
pub fn compute_urban_ground(
building_centroids: Vec<(i32, i32)>,
xzbbox: &XZBBox,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
let mut computer = UrbanGroundComputer::with_defaults(xzbbox.clone());
computer.add_building_centroids(building_centroids);
computer.compute(timeout)
}
/// Computes urban ground and returns a compact lookup structure.
///
/// This is the preferred entry point for production use. Returns a lookup
/// structure that uses ~270 KB instead of ~560 MB for a typical city area.
pub fn compute_urban_ground_lookup(
building_centroids: Vec<(i32, i32)>,
xzbbox: &XZBBox,
) -> UrbanGroundLookup {
let mut computer = UrbanGroundComputer::with_defaults(xzbbox.clone());
computer.add_building_centroids(building_centroids);
computer.compute_lookup()
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_bbox() -> XZBBox {
XZBBox::rect_from_xz_lengths(1000.0, 1000.0).unwrap()
}
#[test]
fn test_no_buildings() {
let computer = UrbanGroundComputer::with_defaults(create_test_bbox());
let result = computer.compute(None);
assert!(result.is_empty());
}
#[test]
fn test_few_scattered_buildings() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add a few scattered buildings (not enough for a cluster)
computer.add_building_centroid(100, 100);
computer.add_building_centroid(500, 500);
computer.add_building_centroid(900, 900);
let result = computer.compute(None);
assert!(
result.is_empty(),
"Scattered buildings should not form urban area"
);
}
#[test]
fn test_dense_cluster() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add a dense cluster of buildings
for i in 0..30 {
for j in 0..30 {
if (i + j) % 3 == 0 {
// Add building every 3rd position
computer.add_building_centroid(100 + i * 10, 100 + j * 10);
}
}
}
let result = computer.compute(None);
assert!(
!result.is_empty(),
"Dense cluster should produce urban area"
);
}
#[test]
fn test_compute_centroid() {
let coords = vec![(0, 0), (10, 0), (10, 10), (0, 10)];
let centroid = compute_centroid(&coords);
assert_eq!(centroid, Some((5, 5)));
}
#[test]
fn test_compute_centroid_empty() {
let coords: Vec<(i32, i32)> = vec![];
let centroid = compute_centroid(&coords);
assert_eq!(centroid, None);
}
#[test]
fn test_spread_out_buildings() {
// Simulate a spread-out city like Erding where buildings are farther apart
// This should still be detected as urban due to adaptive expansion
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add buildings spread across a larger area with gaps
// Buildings are ~100-150 blocks apart (would fail with small expansion)
let building_positions = [
(100, 100),
(250, 100),
(400, 100),
(100, 250),
(250, 250),
(400, 250),
(100, 400),
(250, 400),
(400, 400),
// Add a few more to ensure cluster threshold is met
(175, 175),
(325, 175),
(175, 325),
(325, 325),
];
for (x, z) in building_positions {
computer.add_building_centroid(x, z);
}
let result = computer.compute(None);
assert!(
!result.is_empty(),
"Spread-out buildings should still form urban area with adaptive expansion"
);
}
#[test]
fn test_adaptive_expansion_calculated() {
let bbox = create_test_bbox();
let computer = UrbanGroundComputer::with_defaults(bbox);
// Create a sparse grid with low occupancy
let mut dense_cells = HashSet::new();
// Only 4 cells in a 10x10 potential grid = 4% occupancy
dense_cells.insert((0, 0));
dense_cells.insert((5, 0));
dense_cells.insert((0, 5));
dense_cells.insert((5, 5));
let mut grid = HashMap::new();
// Only 1 building per cell (low density)
grid.insert((0, 0), vec![(10, 10)]);
grid.insert((5, 0), vec![(330, 10)]);
grid.insert((0, 5), vec![(10, 330)]);
grid.insert((5, 5), vec![(330, 330)]);
let expansion = computer.calculate_adaptive_expansion(&dense_cells, &grid);
// Should be higher than base (2) due to low occupancy and density
assert!(
expansion > 2,
"Sparse grid should trigger higher expansion, got {}",
expansion
);
}
#[test]
fn test_lookup_empty() {
let lookup = UrbanGroundLookup::empty();
assert!(lookup.is_empty());
assert!(!lookup.is_urban(100, 100));
assert_eq!(lookup.cell_count(), 0);
}
#[test]
fn test_lookup_membership() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Create a dense cluster of buildings
for x in 0..10 {
for z in 0..10 {
computer.add_building_centroid(100 + x * 10, 100 + z * 10);
}
}
let lookup = computer.compute_lookup();
assert!(!lookup.is_empty());
// Points inside the cluster should be urban
assert!(
lookup.is_urban(150, 150),
"Center of cluster should be urban"
);
// Points far outside the cluster should not be urban
assert!(
!lookup.is_urban(900, 900),
"Point far from cluster should not be urban"
);
}
#[test]
fn test_lookup_vs_compute_consistency() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Create a medium-sized cluster
for x in 0..5 {
for z in 0..5 {
computer.add_building_centroid(200 + x * 20, 200 + z * 20);
}
}
let coords = computer.compute(None);
let lookup = computer.compute_lookup();
// Every coordinate from compute() should be marked urban in lookup
for (x, z) in &coords {
assert!(
lookup.is_urban(*x, *z),
"Coordinate ({}, {}) should be urban in lookup",
x,
z
);
}
}
}

View File

@@ -14,11 +14,14 @@ use crate::ground::Ground;
use crate::progress::emit_gui_progress_update;
use bedrockrs_level::level::db_interface::bedrock_key::ChunkKey;
use bedrockrs_level::level::db_interface::rusty::RustyDBInterface;
use bedrockrs_level::level::db_interface::key_level::KeyTypeTag;
use bedrockrs_level::level::db_interface::rusty::{mcpe_options, RustyDBInterface};
use bedrockrs_level::level::file_interface::RawWorldTrait;
use bedrockrs_shared::world::dimension::Dimension;
use byteorder::{LittleEndian, WriteBytesExt};
use fastnbt::Value;
use indicatif::{ProgressBar, ProgressStyle};
use rusty_leveldb::DB;
use serde::Serialize;
use std::collections::HashMap as StdHashMap;
use std::fs::{self, File};
@@ -82,6 +85,8 @@ impl From<serde_json::Error> for BedrockSaveError {
}
}
const DEFAULT_BEDROCK_COMPRESSION_LEVEL: u8 = 6;
/// Metadata for Bedrock worlds
#[derive(Serialize)]
struct BedrockMetadata {
@@ -402,7 +407,7 @@ impl BedrockWriter {
// Open LevelDB with Bedrock-compatible options
let mut state = ();
let mut db: RustyDBInterface<()> =
RustyDBInterface::new(db_path.into_boxed_path(), true, &mut state)
RustyDBInterface::new(db_path.clone().into_boxed_path(), true, &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Count total chunks for progress
@@ -416,63 +421,128 @@ impl BedrockWriter {
return Ok(());
}
let progress_bar = ProgressBar::new(total_chunks as u64);
progress_bar.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:45.white/black}] {pos}/{len} chunks ({eta})")
.unwrap()
.progress_chars("█▓░"),
);
{
let progress_bar = ProgressBar::new(total_chunks as u64);
progress_bar.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:45.white/black}] {pos}/{len} chunks ({eta})")
.unwrap()
.progress_chars("█▓░"),
);
let mut chunks_processed: usize = 0;
let mut chunks_processed: usize = 0;
// Process each region and chunk
for ((region_x, region_z), region) in &world.regions {
for ((local_chunk_x, local_chunk_z), chunk) in &region.chunks {
// Calculate absolute chunk coordinates
let abs_chunk_x = region_x * 32 + local_chunk_x;
let abs_chunk_z = region_z * 32 + local_chunk_z;
let chunk_pos = Vec2::new(abs_chunk_x, abs_chunk_z);
// Write chunk version marker (42 is current Bedrock version as of 1.21+)
let version_key = ChunkKey::chunk_marker(chunk_pos, Dimension::Overworld);
db.set_subchunk_raw(version_key, &[42], &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Write Data3D (heightmap + biomes) - required for chunk to be valid
let data3d_key = ChunkKey::data3d(chunk_pos, Dimension::Overworld);
let data3d = self.create_data3d(chunk);
db.set_subchunk_raw(data3d_key, &data3d, &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Process each section (subchunk)
for (&section_y, section) in &chunk.sections {
// Encode the subchunk
let subchunk_bytes = self.encode_subchunk(section, section_y)?;
// Write to database
let subchunk_key =
ChunkKey::new_subchunk(chunk_pos, Dimension::Overworld, section_y);
db.set_subchunk_raw(subchunk_key, &subchunk_bytes, &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
}
chunks_processed += 1;
progress_bar.inc(1);
// Update GUI progress (92% to 97% range for chunk writing)
if chunks_processed.is_multiple_of(10) || chunks_processed == total_chunks {
let chunk_progress = chunks_processed as f64 / total_chunks as f64;
let gui_progress = 92.0 + (chunk_progress * 5.0); // 92% to 97%
emit_gui_progress_update(gui_progress, "");
}
}
}
progress_bar.finish_with_message("Chunks written to LevelDB");
}
// Ensure the RustyDBInterface handle is dropped before opening another DB for the same path.
drop(db);
self.write_chunk_entities(world, &db_path)?;
Ok(())
}
fn write_chunk_entities(
&self,
world: &WorldToModify,
db_path: &std::path::Path,
) -> Result<(), BedrockSaveError> {
let mut opts = mcpe_options(DEFAULT_BEDROCK_COMPRESSION_LEVEL);
opts.create_if_missing = true;
let mut db = DB::open(db_path.to_path_buf().into_boxed_path(), opts)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Process each region and chunk
for ((region_x, region_z), region) in &world.regions {
for ((local_chunk_x, local_chunk_z), chunk) in &region.chunks {
// Calculate absolute chunk coordinates
let abs_chunk_x = region_x * 32 + local_chunk_x;
let abs_chunk_z = region_z * 32 + local_chunk_z;
let chunk_pos = Vec2::new(abs_chunk_x, abs_chunk_z);
let chunk_pos =
Vec2::new(region_x * 32 + local_chunk_x, region_z * 32 + local_chunk_z);
// Write chunk version marker (42 is current Bedrock version as of 1.21+)
let version_key = ChunkKey::chunk_marker(chunk_pos, Dimension::Overworld);
db.set_subchunk_raw(version_key, &[42], &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Write Data3D (heightmap + biomes) - required for chunk to be valid
let data3d_key = ChunkKey::data3d(chunk_pos, Dimension::Overworld);
let data3d = self.create_data3d(chunk);
db.set_subchunk_raw(data3d_key, &data3d, &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
// Process each section (subchunk)
for (&section_y, section) in &chunk.sections {
// Encode the subchunk
let subchunk_bytes = self.encode_subchunk(section, section_y)?;
// Write to database
let subchunk_key =
ChunkKey::new_subchunk(chunk_pos, Dimension::Overworld, section_y);
db.set_subchunk_raw(subchunk_key, &subchunk_bytes, &mut state)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
}
chunks_processed += 1;
progress_bar.inc(1);
// Update GUI progress (92% to 97% range for chunk writing)
if chunks_processed.is_multiple_of(10) || chunks_processed == total_chunks {
let chunk_progress = chunks_processed as f64 / total_chunks as f64;
let gui_progress = 92.0 + (chunk_progress * 5.0); // 92% to 97%
emit_gui_progress_update(gui_progress, "");
}
self.write_compound_list_record(
&mut db,
chunk_pos,
KeyTypeTag::BlockEntity,
chunk.other.get("block_entities"),
)?;
self.write_compound_list_record(
&mut db,
chunk_pos,
KeyTypeTag::Entity,
chunk.other.get("entities"),
)?;
}
}
progress_bar.finish_with_message("Chunks written to LevelDB");
Ok(())
}
// LevelDB writes are flushed when the database is dropped
drop(db);
fn write_compound_list_record(
&self,
db: &mut DB,
chunk_pos: Vec2<i32>,
key_type: KeyTypeTag,
value: Option<&Value>,
) -> Result<(), BedrockSaveError> {
let Some(Value::List(values)) = value else {
return Ok(());
};
if values.is_empty() {
return Ok(());
}
let deduped = dedup_compound_list(values);
if deduped.is_empty() {
return Ok(());
}
let data = nbtx::to_le_bytes(&deduped).map_err(|e| BedrockSaveError::Nbt(e.to_string()))?;
let key = build_chunk_key_bytes(chunk_pos, Dimension::Overworld, key_type, None);
db.put(&key, &data)
.map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?;
Ok(())
}
@@ -593,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);
@@ -737,6 +807,91 @@ fn bedrock_bits_per_block(palette_count: u32) -> u8 {
16 // Maximum
}
fn build_chunk_key_bytes(
chunk_pos: Vec2<i32>,
dimension: Dimension,
key_type: KeyTypeTag,
y_index: Option<i8>,
) -> Vec<u8> {
let mut buffer = Vec::with_capacity(
9 + if dimension != Dimension::Overworld {
4
} else {
0
} + 1,
);
buffer.extend_from_slice(&chunk_pos.x.to_le_bytes());
buffer.extend_from_slice(&chunk_pos.y.to_le_bytes());
if dimension != Dimension::Overworld {
buffer.extend_from_slice(&i32::from(dimension).to_le_bytes());
}
buffer.push(key_type.to_byte());
if let Some(y) = y_index {
buffer.push(y as u8);
}
buffer
}
fn dedup_compound_list(values: &[Value]) -> Vec<Value> {
let mut coord_index: StdHashMap<(i32, i32, i32), usize> = StdHashMap::new();
let mut deduped: Vec<Value> = Vec::with_capacity(values.len());
for value in values {
if let Value::Compound(map) = value {
if let Some(coords) = get_entity_coords(map) {
if let Some(idx) = coord_index.get(&coords).copied() {
deduped[idx] = value.clone();
continue;
} else {
coord_index.insert(coords, deduped.len());
}
}
}
deduped.push(value.clone());
}
deduped
}
fn get_entity_coords(entity: &StdHashMap<String, Value>) -> Option<(i32, i32, i32)> {
if let Some(Value::List(pos)) = entity.get("Pos") {
if pos.len() == 3 {
if let (Some(x), Some(y), Some(z)) = (
value_to_i32(&pos[0]),
value_to_i32(&pos[1]),
value_to_i32(&pos[2]),
) {
return Some((x, y, z));
}
}
}
let (Some(x), Some(y), Some(z)) = (
entity.get("x").and_then(value_to_i32),
entity.get("y").and_then(value_to_i32),
entity.get("z").and_then(value_to_i32),
) else {
return None;
};
Some((x, y, z))
}
fn value_to_i32(value: &Value) -> Option<i32> {
match value {
Value::Byte(v) => Some(i32::from(*v)),
Value::Short(v) => Some(i32::from(*v)),
Value::Int(v) => Some(*v),
Value::Long(v) => i32::try_from(*v).ok(),
Value::Float(v) => Some(*v as i32),
Value::Double(v) => Some(*v as i32),
_ => None,
}
}
/// Level.dat structure for Bedrock Edition
/// This struct contains all required fields for a valid Bedrock world
#[derive(serde::Serialize)]

View File

@@ -27,7 +27,7 @@ pub(crate) struct Chunk {
}
/// Section within a chunk (16x16x16 blocks)
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct Section {
pub block_states: Blockstates,
#[serde(rename = "Y")]
@@ -37,7 +37,7 @@ pub(crate) struct Section {
}
/// Block states within a section
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct Blockstates {
pub palette: Vec<PaletteItem>,
pub data: Option<LongArray>,
@@ -46,7 +46,7 @@ pub(crate) struct Blockstates {
}
/// Palette item for block state encoding
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct PaletteItem {
#[serde(rename = "Name")]
pub name: String,
@@ -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

@@ -16,6 +16,24 @@ use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
/// Cached base chunk sections (grass at Y=-62)
/// Computed once on first use and reused for all empty chunks
static BASE_CHUNK_SECTIONS: OnceLock<Vec<Section>> = OnceLock::new();
/// Get or create the cached base chunk sections
fn get_base_chunk_sections() -> &'static [Section] {
BASE_CHUNK_SECTIONS.get_or_init(|| {
let mut chunk = ChunkToModify::default();
for x in 0..16 {
for z in 0..16 {
chunk.set_block(x, -62, z, GRASS_BLOCK);
}
}
chunk.sections().collect()
})
}
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
@@ -47,23 +65,18 @@ impl<'a> WorldEditor<'a> {
}
/// Helper function to create a base chunk with grass blocks at Y -62
/// Uses cached sections for efficiency - only serialization happens per chunk
pub(super) fn create_base_chunk(abs_chunk_x: i32, abs_chunk_z: i32) -> (Vec<u8>, bool) {
let mut chunk = ChunkToModify::default();
// Use cached sections (computed once on first call)
let sections = get_base_chunk_sections();
// Fill the bottom layer with grass blocks at Y -62
for x in 0..16 {
for z in 0..16 {
chunk.set_block(x, -62, z, GRASS_BLOCK);
}
}
// Prepare chunk data
// Prepare chunk data with cloned sections
let chunk_data = Chunk {
sections: chunk.sections().collect(),
sections: sections.to_vec(),
x_pos: abs_chunk_x,
z_pos: abs_chunk_z,
is_light_on: 0,
other: chunk.other,
other: FnvHashMap::default(),
};
// Create the Level wrapper
@@ -128,7 +141,8 @@ impl<'a> WorldEditor<'a> {
/// Saves a single region to disk.
///
/// This is extracted to allow streaming mode to save and release regions one at a time.
/// Optimized for new world creation, writes chunks directly without reading existing data.
/// This assumes we're creating a fresh world, not modifying an existing one.
fn save_single_region(
&self,
region_x: i32,
@@ -138,81 +152,19 @@ impl<'a> WorldEditor<'a> {
let mut region = self.create_region(region_x, region_z);
let mut ser_buffer = Vec::with_capacity(8192);
// First pass: write all chunks that have content
for (&(chunk_x, chunk_z), chunk_to_modify) in &region_to_modify.chunks {
if !chunk_to_modify.sections.is_empty() || !chunk_to_modify.other.is_empty() {
// Read existing chunk data if it exists
let existing_data = region
.read_chunk(chunk_x as usize, chunk_z as usize)
.unwrap()
.unwrap_or_default();
// Parse existing chunk or create new one
let mut chunk: Chunk = if !existing_data.is_empty() {
fastnbt::from_bytes(&existing_data).unwrap()
} else {
Chunk {
sections: Vec::new(),
x_pos: chunk_x + (region_x * 32),
z_pos: chunk_z + (region_z * 32),
is_light_on: 0,
other: FnvHashMap::default(),
}
// Create chunk directly, we're writing to a fresh region file
// so there's no existing data to preserve
let chunk = Chunk {
sections: chunk_to_modify.sections().collect(),
x_pos: chunk_x + (region_x * 32),
z_pos: chunk_z + (region_z * 32),
is_light_on: 0,
other: chunk_to_modify.other.clone(),
};
// Update sections while preserving existing data
let new_sections: Vec<Section> = chunk_to_modify.sections().collect();
for new_section in new_sections {
if let Some(existing_section) =
chunk.sections.iter_mut().find(|s| s.y == new_section.y)
{
// Merge block states
existing_section.block_states.palette = new_section.block_states.palette;
existing_section.block_states.data = new_section.block_states.data;
} else {
// Add new section if it doesn't exist
chunk.sections.push(new_section);
}
}
// Preserve existing block entities and merge with new ones
if let Some(existing_entities) = chunk.other.get_mut("block_entities") {
if let Some(new_entities) = chunk_to_modify.other.get("block_entities") {
if let (Value::List(existing), Value::List(new)) =
(existing_entities, new_entities)
{
// Remove old entities that are replaced by new ones
existing.retain(|e| {
if let Value::Compound(map) = e {
let (x, y, z) = get_entity_coords(map);
!new.iter().any(|new_e| {
if let Value::Compound(new_map) = new_e {
let (nx, ny, nz) = get_entity_coords(new_map);
x == nx && y == ny && z == nz
} else {
false
}
})
} else {
true
}
});
// Add new entities
existing.extend(new.clone());
}
}
} else {
// If no existing entities, just add the new ones
if let Some(new_entities) = chunk_to_modify.other.get("block_entities") {
chunk
.other
.insert("block_entities".to_string(), new_entities.clone());
}
}
// Update chunk coordinates and flags
chunk.x_pos = chunk_x + (region_x * 32);
chunk.z_pos = chunk_z + (region_z * 32);
// Create Level wrapper and save
let level_data = create_level_wrapper(&chunk);
ser_buffer.clear();
@@ -223,7 +175,7 @@ impl<'a> WorldEditor<'a> {
}
}
// Second pass: ensure all chunks exist
// Second pass: ensure all chunks exist (fill with base layer if not)
for chunk_x in 0..32 {
for chunk_z in 0..32 {
let abs_chunk_x = chunk_x + (region_x * 32);
@@ -245,88 +197,138 @@ impl<'a> WorldEditor<'a> {
}
/// Helper function to get entity coordinates
/// Note: Currently unused since we write directly without merging, but kept for potential future use
#[inline]
fn get_entity_coords(entity: &HashMap<String, Value>) -> (i32, i32, i32) {
let x = if let Value::Int(x) = entity.get("x").unwrap_or(&Value::Int(0)) {
*x
} else {
0
#[allow(dead_code)]
fn get_entity_coords(entity: &HashMap<String, Value>) -> Option<(i32, i32, i32)> {
if let Some(Value::List(pos)) = entity.get("Pos") {
if pos.len() == 3 {
if let (Some(x), Some(y), Some(z)) = (
value_to_i32(&pos[0]),
value_to_i32(&pos[1]),
value_to_i32(&pos[2]),
) {
return Some((x, y, z));
}
}
}
let (Some(x), Some(y), Some(z)) = (
entity.get("x").and_then(value_to_i32),
entity.get("y").and_then(value_to_i32),
entity.get("z").and_then(value_to_i32),
) else {
return None;
};
let y = if let Value::Int(y) = entity.get("y").unwrap_or(&Value::Int(0)) {
*y
} else {
0
};
let z = if let Value::Int(z) = entity.get("z").unwrap_or(&Value::Int(0)) {
*z
} else {
0
};
(x, y, z)
Some((x, y, z))
}
/// Creates a Level wrapper for chunk data (Java Edition format)
#[inline]
fn create_level_wrapper(chunk: &Chunk) -> HashMap<String, Value> {
HashMap::from([(
"Level".to_string(),
Value::Compound(HashMap::from([
("xPos".to_string(), Value::Int(chunk.x_pos)),
("zPos".to_string(), Value::Int(chunk.z_pos)),
(
"isLightOn".to_string(),
Value::Byte(i8::try_from(chunk.is_light_on).unwrap()),
),
(
"sections".to_string(),
Value::List(
chunk
.sections
.iter()
.map(|section| {
let mut block_states = HashMap::from([(
"palette".to_string(),
Value::List(
section
.block_states
.palette
.iter()
.map(|item| {
let mut palette_item = HashMap::from([(
"Name".to_string(),
Value::String(item.name.clone()),
)]);
if let Some(props) = &item.properties {
palette_item.insert(
"Properties".to_string(),
props.clone(),
);
}
Value::Compound(palette_item)
})
.collect(),
),
)]);
let mut level_map = HashMap::from([
("xPos".to_string(), Value::Int(chunk.x_pos)),
("zPos".to_string(), Value::Int(chunk.z_pos)),
(
"isLightOn".to_string(),
Value::Byte(i8::try_from(chunk.is_light_on).unwrap()),
),
(
"sections".to_string(),
Value::List(
chunk
.sections
.iter()
.map(|section| {
let mut block_states = HashMap::from([(
"palette".to_string(),
Value::List(
section
.block_states
.palette
.iter()
.map(|item| {
let mut palette_item = HashMap::from([(
"Name".to_string(),
Value::String(item.name.clone()),
)]);
if let Some(props) = &item.properties {
palette_item
.insert("Properties".to_string(), props.clone());
}
Value::Compound(palette_item)
})
.collect(),
),
)]);
// Only add the `data` attribute if it's non-empty
// to maintain compatibility with third-party tools like Dynmap
if let Some(data) = &section.block_states.data {
if !data.is_empty() {
block_states.insert(
"data".to_string(),
Value::LongArray(data.to_owned()),
);
}
// Only add the `data` attribute if it's non-empty
// to maintain compatibility with third-party tools like Dynmap
if let Some(data) = &section.block_states.data {
if !data.is_empty() {
block_states
.insert("data".to_string(), Value::LongArray(data.to_owned()));
}
}
Value::Compound(HashMap::from([
("Y".to_string(), Value::Byte(section.y)),
("block_states".to_string(), Value::Compound(block_states)),
]))
})
.collect(),
),
Value::Compound(HashMap::from([
("Y".to_string(), Value::Byte(section.y)),
("block_states".to_string(), Value::Compound(block_states)),
]))
})
.collect(),
),
])),
)])
),
]);
for (key, value) in &chunk.other {
level_map.insert(key.clone(), value.clone());
}
HashMap::from([("Level".to_string(), Value::Compound(level_map))])
}
/// Merge compound lists (entities, block_entities) from chunk_to_modify into chunk
/// Note: Currently unused since we write directly without merging, but kept for potential future use
#[allow(dead_code)]
fn merge_compound_list(chunk: &mut Chunk, chunk_to_modify: &ChunkToModify, key: &str) {
if let Some(existing_entities) = chunk.other.get_mut(key) {
if let Some(new_entities) = chunk_to_modify.other.get(key) {
if let (Value::List(existing), Value::List(new)) = (existing_entities, new_entities) {
existing.retain(|e| {
if let Value::Compound(map) = e {
if let Some((x, y, z)) = get_entity_coords(map) {
return !new.iter().any(|new_e| {
if let Value::Compound(new_map) = new_e {
get_entity_coords(new_map) == Some((x, y, z))
} else {
false
}
});
}
}
true
});
existing.extend(new.clone());
}
}
} else if let Some(new_entities) = chunk_to_modify.other.get(key) {
chunk.other.insert(key.to_string(), new_entities.clone());
}
}
/// Convert NBT Value to i32
/// Note: Currently unused since we write directly without merging, but kept for potential future use
#[allow(dead_code)]
fn value_to_i32(value: &Value) -> Option<i32> {
match value {
Value::Byte(v) => Some(i32::from(*v)),
Value::Short(v) => Some(i32::from(*v)),
Value::Int(v) => Some(*v),
Value::Long(v) => i32::try_from(*v).ok(),
Value::Float(v) => Some(*v as i32),
Value::Double(v) => Some(*v as i32),
_ => None,
}
}

View File

@@ -27,9 +27,9 @@ use crate::coordinate_system::geographic::LLBBox;
use crate::ground::Ground;
use crate::progress::emit_gui_progress_update;
use colored::Colorize;
use fastnbt::Value;
use fastnbt::{IntArray, Value};
use serde::Serialize;
use std::collections::HashMap;
use std::collections::{hash_map::Entry, HashMap};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
@@ -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,
}
}
@@ -243,6 +253,212 @@ impl<'a> WorldEditor<'a> {
self.set_block(SIGN, x, y, z, None, None);
}
/// Adds an entity at the given coordinates (Y is ground-relative).
#[allow(dead_code)]
pub fn add_entity(
&mut self,
id: &str,
x: i32,
y: i32,
z: i32,
extra_data: Option<HashMap<String, Value>>,
) {
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
let absolute_y = self.get_absolute_y(x, y, z);
let mut entity = HashMap::new();
entity.insert("id".to_string(), Value::String(id.to_string()));
entity.insert(
"Pos".to_string(),
Value::List(vec![
Value::Double(x as f64 + 0.5),
Value::Double(absolute_y as f64),
Value::Double(z as f64 + 0.5),
]),
);
entity.insert(
"Motion".to_string(),
Value::List(vec![
Value::Double(0.0),
Value::Double(0.0),
Value::Double(0.0),
]),
);
entity.insert(
"Rotation".to_string(),
Value::List(vec![Value::Float(0.0), Value::Float(0.0)]),
);
entity.insert("OnGround".to_string(), Value::Byte(1));
entity.insert("FallDistance".to_string(), Value::Float(0.0));
entity.insert("Fire".to_string(), Value::Short(-20));
entity.insert("Air".to_string(), Value::Short(300));
entity.insert("PortalCooldown".to_string(), Value::Int(0));
entity.insert(
"UUID".to_string(),
Value::IntArray(build_deterministic_uuid(id, x, absolute_y, z)),
);
if let Some(extra) = extra_data {
for (key, value) in extra {
entity.insert(key, value);
}
}
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.world.get_or_create_region(region_x, region_z);
let chunk = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
match chunk.other.entry("entities".to_string()) {
Entry::Occupied(mut entry) => {
if let Value::List(list) = entry.get_mut() {
list.push(Value::Compound(entity));
}
}
Entry::Vacant(entry) => {
entry.insert(Value::List(vec![Value::Compound(entity)]));
}
}
}
/// Places a chest with the provided items at the given coordinates (ground-relative Y).
#[allow(dead_code)]
pub fn set_chest_with_items(
&mut self,
x: i32,
y: i32,
z: i32,
items: Vec<HashMap<String, Value>>,
) {
let absolute_y = self.get_absolute_y(x, y, z);
self.set_chest_with_items_absolute(x, absolute_y, z, items);
}
/// Places a chest with the provided items at the given coordinates (absolute Y).
#[allow(dead_code)]
pub fn set_chest_with_items_absolute(
&mut self,
x: i32,
absolute_y: i32,
z: i32,
items: Vec<HashMap<String, Value>>,
) {
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
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 mut chest_data = HashMap::new();
chest_data.insert(
"id".to_string(),
Value::String("minecraft:chest".to_string()),
);
chest_data.insert("x".to_string(), Value::Int(x));
chest_data.insert("y".to_string(), Value::Int(absolute_y));
chest_data.insert("z".to_string(), Value::Int(z));
chest_data.insert(
"Items".to_string(),
Value::List(items.into_iter().map(Value::Compound).collect()),
);
chest_data.insert("keepPacked".to_string(), Value::Byte(0));
let region = self.world.get_or_create_region(region_x, region_z);
let chunk = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
match chunk.other.entry("block_entities".to_string()) {
Entry::Occupied(mut entry) => {
if let Value::List(list) = entry.get_mut() {
list.push(Value::Compound(chest_data));
}
}
Entry::Vacant(entry) => {
entry.insert(Value::List(vec![Value::Compound(chest_data)]));
}
}
self.set_block_absolute(CHEST, x, absolute_y, z, None, None);
}
/// Places a block entity with items at the given coordinates (ground-relative Y).
#[allow(dead_code)]
pub fn set_block_entity_with_items(
&mut self,
block_with_props: BlockWithProperties,
x: i32,
y: i32,
z: i32,
block_entity_id: &str,
items: Vec<HashMap<String, Value>>,
) {
let absolute_y = self.get_absolute_y(x, y, z);
self.set_block_entity_with_items_absolute(
block_with_props,
x,
absolute_y,
z,
block_entity_id,
items,
);
}
/// Places a block entity with items at the given coordinates (absolute Y).
#[allow(dead_code)]
pub fn set_block_entity_with_items_absolute(
&mut self,
block_with_props: BlockWithProperties,
x: i32,
absolute_y: i32,
z: i32,
block_entity_id: &str,
items: Vec<HashMap<String, Value>>,
) {
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
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 mut block_entity = HashMap::new();
block_entity.insert("id".to_string(), Value::String(block_entity_id.to_string()));
block_entity.insert("x".to_string(), Value::Int(x));
block_entity.insert("y".to_string(), Value::Int(absolute_y));
block_entity.insert("z".to_string(), Value::Int(z));
block_entity.insert(
"Items".to_string(),
Value::List(items.into_iter().map(Value::Compound).collect()),
);
block_entity.insert("keepPacked".to_string(), Value::Byte(0));
let region = self.world.get_or_create_region(region_x, region_z);
let chunk = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
match chunk.other.entry("block_entities".to_string()) {
Entry::Occupied(mut entry) => {
if let Value::List(list) = entry.get_mut() {
list.push(Value::Compound(block_entity));
}
}
Entry::Vacant(entry) => {
entry.insert(Value::List(vec![Value::Compound(block_entity)]));
}
}
self.set_block_with_properties_absolute(block_with_props, x, absolute_y, z, None, None);
}
/// Sets a block of the specified type at the given coordinates.
///
/// Y value is interpreted as an offset from ground level.
@@ -398,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 {
@@ -500,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!(
@@ -510,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(),
@@ -599,3 +814,30 @@ impl<'a> WorldEditor<'a> {
Ok(())
}
}
#[allow(dead_code)]
fn build_deterministic_uuid(id: &str, x: i32, y: i32, z: i32) -> IntArray {
let mut hash: i64 = 17;
for byte in id.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as i64);
}
let seed_a = hash ^ (x as i64).wrapping_shl(32) ^ (y as i64).wrapping_mul(17);
let seed_b = hash.rotate_left(7) ^ (z as i64).wrapping_mul(31) ^ (x as i64).wrapping_mul(13);
IntArray::new(vec![
(seed_a >> 32) as i32,
seed_a as i32,
(seed_b >> 32) as i32,
seed_b as i32,
])
}
#[allow(dead_code)]
fn single_item(id: &str, slot: i8, count: i8) -> HashMap<String, Value> {
let mut item = HashMap::new();
item.insert("id".to_string(), Value::String(id.to_string()));
item.insert("Slot".to_string(), Value::Byte(slot));
item.insert("Count".to_string(), Value::Byte(count));
item
}

207
src/world_utils.rs Normal file
View File

@@ -0,0 +1,207 @@
use crate::coordinate_system::geographic::LLBBox;
use crate::retrieve_data;
use fastnbt::Value;
use flate2::read::GzDecoder;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::{fs, io::Write};
/// Returns the Desktop directory for Bedrock .mcworld file output.
/// Falls back to home directory, then current directory.
pub fn get_bedrock_output_directory() -> PathBuf {
dirs::desktop_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
}
/// Gets the area name for a given bounding box using the center point.
pub fn get_area_name_for_bedrock(bbox: &LLBBox) -> String {
let center_lat = (bbox.min().lat() + bbox.max().lat()) / 2.0;
let center_lon = (bbox.min().lng() + bbox.max().lng()) / 2.0;
match retrieve_data::fetch_area_name(center_lat, center_lon) {
Ok(Some(name)) => name,
_ => "Unknown Location".to_string(),
}
}
/// Sanitizes an area name for safe use in filesystem paths.
/// Replaces characters that are invalid on Windows/macOS/Linux, trims whitespace,
/// and limits length to prevent excessively long filenames.
pub fn sanitize_for_filename(name: &str) -> String {
let invalid_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
let mut sanitized: String = name
.chars()
.map(|c| {
if c.is_control() || invalid_chars.contains(&c) {
'_'
} else {
c
}
})
.collect();
sanitized = sanitized.trim().to_string();
// Limit length to avoid excessively long filenames
const MAX_LEN: usize = 64;
if sanitized.len() > MAX_LEN {
// Find a valid UTF-8 char boundary at or before MAX_LEN bytes
let cutoff = sanitized
.char_indices()
.take_while(|(idx, _)| *idx < MAX_LEN)
.last()
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
sanitized.truncate(cutoff);
sanitized = sanitized.trim_end().to_string();
}
if sanitized.is_empty() {
"Unknown Location".to_string()
} else {
sanitized
}
}
/// Builds the Bedrock output path and level name for a given bounding box.
/// Combines area name lookup, sanitization, and path construction.
pub fn build_bedrock_output(bbox: &LLBBox, output_dir: PathBuf) -> (PathBuf, String) {
let area_name = get_area_name_for_bedrock(bbox);
let safe_name = sanitize_for_filename(&area_name);
let filename = format!("Arnis {safe_name}.mcworld");
let lvl_name = format!("Arnis World: {safe_name}");
(output_dir.join(&filename), lvl_name)
}
/// Creates a new Java Edition world in the given base directory.
///
/// Generates a unique "Arnis World N" name, creates the directory structure
/// (with a `region/` subdirectory), writes the region template, level.dat
/// (with updated name, timestamp, and spawn position), and icon.png.
///
/// Returns the full path to the newly created world directory.
pub fn create_new_world(base_path: &Path) -> Result<String, String> {
// Generate a unique world name with proper counter
// Check for both "Arnis World X" and "Arnis World X: Location" patterns
let mut counter: i32 = 1;
let unique_name: String = loop {
let candidate_name: String = format!("Arnis World {counter}");
let candidate_path: PathBuf = base_path.join(&candidate_name);
// Check for exact match (no location suffix)
let exact_match_exists = candidate_path.exists();
// Check for worlds with location suffix (Arnis World X: Location)
let location_pattern = format!("Arnis World {counter}: ");
let location_match_exists = fs::read_dir(base_path)
.map(|entries| {
entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.any(|name| name.starts_with(&location_pattern))
})
.unwrap_or(false);
if !exact_match_exists && !location_match_exists {
break candidate_name;
}
counter += 1;
};
let new_world_path: PathBuf = base_path.join(&unique_name);
// Create the new world directory structure
fs::create_dir_all(new_world_path.join("region"))
.map_err(|e| format!("Failed to create world directory: {e}"))?;
// Copy the region template file
const REGION_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/region.template");
let region_path = new_world_path.join("region").join("r.0.0.mca");
fs::write(&region_path, REGION_TEMPLATE)
.map_err(|e| format!("Failed to create region file: {e}"))?;
// Add the level.dat file
const LEVEL_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/level.dat");
// Decompress the gzipped level.template
let mut decoder = GzDecoder::new(LEVEL_TEMPLATE);
let mut decompressed_data = Vec::new();
decoder
.read_to_end(&mut decompressed_data)
.map_err(|e| format!("Failed to decompress level.template: {e}"))?;
// Parse the decompressed NBT data
let mut level_data: Value = fastnbt::from_bytes(&decompressed_data)
.map_err(|e| format!("Failed to parse level.dat template: {e}"))?;
// Modify the LevelName, LastPlayed and player position fields
if let Value::Compound(ref mut root) = level_data {
if let Some(Value::Compound(ref mut data)) = root.get_mut("Data") {
// Update LevelName
data.insert("LevelName".to_string(), Value::String(unique_name.clone()));
// Update LastPlayed to the current Unix time in milliseconds
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get current time: {e}"))?;
let current_time_millis = current_time.as_millis() as i64;
data.insert("LastPlayed".to_string(), Value::Long(current_time_millis));
// Update player position and rotation
if let Some(Value::Compound(ref mut player)) = data.get_mut("Player") {
if let Some(Value::List(ref mut pos)) = player.get_mut("Pos") {
if pos.len() < 3 {
return Err(
"Invalid level.dat template: Player Pos list has fewer than 3 elements"
.to_string(),
);
}
if let Value::Double(ref mut x) = pos[0] {
*x = -5.0;
}
if let Value::Double(ref mut y) = pos[1] {
*y = -61.0;
}
if let Value::Double(ref mut z) = pos[2] {
*z = -5.0;
}
}
if let Some(Value::List(ref mut rot)) = player.get_mut("Rotation") {
if rot.is_empty() {
return Err(
"Invalid level.dat template: Player Rotation list is empty".to_string()
);
}
if let Value::Float(ref mut x) = rot[0] {
*x = -45.0;
}
}
}
}
}
// Serialize the updated NBT data back to bytes
let serialized_level_data: Vec<u8> = fastnbt::to_bytes(&level_data)
.map_err(|e| format!("Failed to serialize updated level.dat: {e}"))?;
// Compress the serialized data back to gzip
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder
.write_all(&serialized_level_data)
.map_err(|e| format!("Failed to compress updated level.dat: {e}"))?;
let compressed_level_data = encoder
.finish()
.map_err(|e| format!("Failed to finalize compression for level.dat: {e}"))?;
// Write the level.dat file
fs::write(new_world_path.join("level.dat"), compressed_level_data)
.map_err(|e| format!("Failed to create level.dat file: {e}"))?;
// Add the icon.png file
const ICON_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/icon.png");
fs::write(new_world_path.join("icon.png"), ICON_TEMPLATE)
.map_err(|e| format!("Failed to create icon.png file: {e}"))?;
Ok(new_world_path.display().to_string())
}

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"