340 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
Louis Erbkamm
6cdebbed78 Merge pull request #714 from louis-e/revert-sequential-streaming
Revert sequential streaming saving since generation speed was affected
2026-01-11 14:36:44 +01:00
Louis Erbkamm
5291f72215 Add press assets link 2026-01-11 14:30:25 +01:00
louis-e
c24e22b790 Revert to sequential streaming saving since generation speed was affected 2026-01-11 14:19:35 +01:00
Louis Erbkamm
d4f324fd96 Migrate macOS 13 to macOS 15 Intel runner 2026-01-11 04:07:23 +01:00
Louis Erbkamm
e7e65d0e6f Merge pull request #712 from louis-e/ui-refactor
UI refactor
2026-01-11 03:58:48 +01:00
louis-e
927aaec22d Fix mixed language 2026-01-11 03:51:35 +01:00
louis-e
5ec942dbd1 Fall back to current dir if mc dir does not exist 2026-01-11 03:46:17 +01:00
louis-e
19bba3cc26 Fall back to current dir if mc dir does not exist 2026-01-11 03:40:39 +01:00
louis-e
17d6d323fc Refactor UI 2026-01-11 03:30:37 +01:00
Louis Erbkamm
236072dc42 Merge pull request #711 from louis-e/tree-building-overlap-fix
Lookup building footprint in tree generation
2026-01-11 02:07:46 +01:00
louis-e
7a8226923a Protect relation inner from tree spawning 2026-01-11 02:03:14 +01:00
louis-e
107ab70602 Fix overflow issues 2026-01-11 01:50:17 +01:00
louis-e
1364d96291 Address code review feedback 2026-01-11 01:39:36 +01:00
louis-e
b74b5c5ccb Lookup building footprint in tree generation 2026-01-11 01:20:27 +01:00
Louis Erbkamm
dd8004b159 Merge pull request #710 from louis-e/ui-enhancements
Add UI tooltips and move bbox info box
2026-01-11 00:51:44 +01:00
louis-e
b0845ce1df Merge branch 'ui-enhancements' of https://github.com/louis-e/arnis into ui-enhancements 2026-01-11 00:50:02 +01:00
louis-e
fc540db4cd Unify displayBboxSizeStatus function 2026-01-11 00:48:32 +01:00
Louis Erbkamm
1ecdffc039 Merge branch 'main' into ui-enhancements 2026-01-11 00:40:32 +01:00
louis-e
9ea34b9911 Add UI tooltips and move bbox info box 2026-01-11 00:40:04 +01:00
Louis Erbkamm
48248aad05 Merge pull request #708 from louis-e/spawn-point-improvement
Add default spawn point
2026-01-10 23:05:47 +01:00
louis-e
169545d937 Address code review feedback 2026-01-10 22:53:14 +01:00
louis-e
fba331232b Fix Bedrock spawn Y calc 2026-01-10 22:18:27 +01:00
louis-e
b02a2783c1 Address code review feedback 2026-01-10 19:19:20 +01:00
louis-e
dbc4741b78 Add minecraft prefix to blocks 2026-01-10 19:11:12 +01:00
louis-e
b52485badc Add ferns 2026-01-10 19:04:23 +01:00
Louis Erbkamm
447416f6ce Merge branch 'main' into spawn-point-improvement 2026-01-10 19:00:59 +01:00
louis-e
d26b23937e Add default spawn point 2026-01-10 18:59:35 +01:00
Louis Erbkamm
5e01abc5b6 Merge pull request #707 from louis-e/ui-improvements
UI improvements
2026-01-10 18:41:22 +01:00
louis-e
7c808ec352 Address code review feedback 2026-01-10 18:36:43 +01:00
louis-e
b757c5acf4 Better icons and map overlay fix 2026-01-10 17:56:37 +01:00
louis-e
ced5fc274e More landuse variations 2026-01-10 17:27:10 +01:00
Louis Erbkamm
295ca415d7 Merge pull request #660 from louis-e/copilot/fix-spawn-point-y-coordinate
Fix spawn point Y coordinate update for longitude values outside ±90°
2026-01-10 13:20:46 +01:00
Louis Erbkamm
e2b4ca8bdb Merge branch 'main' into copilot/fix-spawn-point-y-coordinate 2026-01-10 13:15:04 +01:00
Louis Erbkamm
07105f0208 Merge pull request #703 from louis-e/no-internet-warning
Send proper error for no internet
2026-01-08 23:36:36 +01:00
louis-e
ad57fdbc3a Send proper error for no internet 2026-01-08 23:32:39 +01:00
Louis Erbkamm
550870d9e0 Merge pull request #702 from louis-e/better-elevation
Implement more realistic elevation
2026-01-08 23:14:22 +01:00
louis-e
bd693ea007 Reduce log lines 2026-01-08 23:13:46 +01:00
louis-e
ce8f343414 Sample less points in bridge calc 2026-01-08 23:01:12 +01:00
louis-e
f882145780 Improve efficiency of ground generation 2026-01-08 22:42:06 +01:00
louis-e
b52d750935 Address code review feedback 2026-01-08 22:20:17 +01:00
louis-e
4d30899909 Remove snow again 2026-01-08 21:23:18 +01:00
louis-e
311610a717 Use less operations for better efficiency 2026-01-08 20:53:00 +01:00
louis-e
b4902ebc9e Add snow on top of mountains and address code review feedback 2026-01-08 20:39:56 +01:00
louis-e
e5bbb3e4a0 Address code review feedback 2026-01-08 20:03:15 +01:00
louis-e
0238cfe2d0 Implement more realistic elevation 2026-01-08 19:52:03 +01:00
Louis Erbkamm
2d9892fe7f Merge pull request #699 from louis-e/streaming-save-and-memory-reduction
Streaming save and memory reduction
2026-01-06 23:54:04 +01:00
louis-e
b858ce4691 Address code review feedback 2026-01-06 23:14:54 +01:00
louis-e
e031e53492 Address code review feedback 2026-01-06 23:05:20 +01:00
louis-e
6fb9b8943d Address code review feedback 2026-01-06 22:56:30 +01:00
louis-e
18266dd459 Comment out water area timeout warning 2026-01-06 22:39:32 +01:00
louis-e
b1940fa412 Add deterministic RNG for consistent element generation 2026-01-06 22:39:17 +01:00
louis-e
d57a732055 Drop elements and flood fill cache entries after processing 2026-01-06 22:38:55 +01:00
louis-e
4e52b38f5a stream regions during save to reduce peak memory 2026-01-06 22:38:40 +01:00
Louis Erbkamm
feb4317086 Merge pull request #698 from louis-e/floodfill-precompute-afterfix
Afterfixes for floodfill precomputation
2026-01-06 20:04:04 +01:00
louis-e
d02cbed997 Afterfixes for floodfill precomputation 2026-01-06 20:03:42 +01:00
Louis Erbkamm
99d1f8e117 Merge pull request #696 from louis-e/floodfill-precompute
Add multithreaded precomputation of floodfill
2026-01-06 20:00:13 +01:00
louis-e
6fa76bc381 Add clarifying comment 2026-01-06 19:43:52 +01:00
louis-e
0fef27e6af Address code review feedback 2026-01-06 19:36:33 +01:00
louis-e
fa3384cf86 Address code review feedback 2026-01-06 19:25:13 +01:00
louis-e
ffbc5e5788 Remove floodfill timeout parameter 2026-01-06 18:21:21 +01:00
louis-e
4215e7644c Remove floodfill timeout parameter and refine minor changes 2026-01-06 18:18:56 +01:00
louis-e
118335bad4 Add multithreaded precomputation of floodfill 2026-01-06 18:11:59 +01:00
Louis Erbkamm
7bbee28279 Merge pull request #695 from louis-e/ground-gen-cache-locality
perf: improve ground generation cache locality
2026-01-06 17:15:23 +01:00
louis-e
9cb35a3b13 perf: improve ground generation cache locality 2026-01-06 17:11:47 +01:00
Louis Erbkamm
4fecf98c54 Merge pull request #694 from louis-e/tiny-release-memory
Release memory on a few occasions
2026-01-06 17:01:48 +01:00
louis-e
47a7b81f99 Release memory on a few occasions 2026-01-06 17:01:27 +01:00
Louis Erbkamm
7ec90b4fef Merge pull request #692 from louis-e/codex/refactor-data-parsing-and-memory-usage
Stream OSM parsing and reduce cloning
2026-01-06 16:46:20 +01:00
louis-e
f1f3fb287a optimize tags cloning and fix Arc usage in OSM parser 2026-01-06 16:40:44 +01:00
Louis Erbkamm
b23658d5ef Merge branch 'main' into codex/refactor-data-parsing-and-memory-usage 2026-01-06 16:29:30 +01:00
Louis Erbkamm
cc89576828 Merge pull request #690 from louis-e/codex/change-ground-field-to-arcground
Use Arc<Ground> for WorldEditor ground reference
2026-01-06 16:09:31 +01:00
louis-e
809fa23941 Use Arc<Ground> insteaf of Box<Ground> in BedrockWriter 2026-01-06 16:00:32 +01:00
Louis Erbkamm
51ad1fef3f Stream OSM parsing and reduce cloning 2026-01-06 15:42:40 +01:00
Louis Erbkamm
8e8d8e0567 Use Arc for world editor ground 2026-01-06 15:42:34 +01:00
Mysaa Java
ee2356d734 Fixed nix flake 2026-01-04 22:15:27 +01:00
Louis Erbkamm
da6f23c0a2 Merge pull request #688 from louis-e/parallel-tile-download
Prallelize AWS terrain tile downloads
2026-01-02 14:15:24 +01:00
louis-e
d4a872989c Fix cargo fmt and clippy 2026-01-02 13:48:02 +01:00
louis-e
2a5a5230c5 Apply code review feedback 2026-01-02 13:43:38 +01:00
louis-e
9018584b1d Prallelize AWS terrain tile downloads 2026-01-02 13:33:03 +01:00
Louis Erbkamm
9eda39846c Merge pull request #687 from louis-e/disk-space-check
Disk space check
2026-01-01 22:18:36 +01:00
Louis Erbkamm
5e9d6795df Merge branch 'main' into disk-space-check 2026-01-01 22:14:31 +01:00
Louis Erbkamm
54a7a4f2a9 Merge pull request #686 from louis-e/world-editor-crash-robustness
fix: clamp Y coords and ensure region dir exists
2026-01-01 22:14:17 +01:00
Louis Erbkamm
d0d65643f5 Use idempotent create_dir_all() instead of exists() call
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-01 22:13:51 +01:00
louis-e
946fd43a5e fix: add 3GB disk space check before generation 2026-01-01 22:00:33 +01:00
Louis Erbkamm
05e5ffdd2a Merge branch 'main' into world-editor-crash-robustness 2026-01-01 17:43:52 +01:00
Louis Erbkamm
0b7e27df7f Merge pull request #685 from louis-e/bedrock-session-lock
fix: skip session lock for bedrock world generation
2026-01-01 17:42:21 +01:00
louis-e
613a410c93 fix: clamp Y coords and ensure region dir exists 2026-01-01 17:42:08 +01:00
louis-e
faefd29e30 fix: skip session lock for bedrock world generation 2026-01-01 17:32:03 +01:00
Louis Erbkamm
9ad6c75440 Merge pull request #682 from louis-e/dependabot/github_actions/actions/upload-artifact-6
build(deps): bump actions/upload-artifact from 4 to 6
2026-01-01 17:24:09 +01:00
Louis Erbkamm
e51f28f067 Merge pull request #683 from louis-e/dependabot/github_actions/actions/download-artifact-7
build(deps): bump actions/download-artifact from 5 to 7
2026-01-01 17:23:37 +01:00
Louis Erbkamm
47ddb9b211 Merge pull request #684 from louis-e/dependabot/cargo/rfd-0.16.0
build(deps): bump rfd from 0.15.4 to 0.16.0
2026-01-01 17:23:18 +01:00
dependabot[bot]
46415bb002 build(deps): bump rfd from 0.15.4 to 0.16.0
Bumps [rfd](https://github.com/PolyMeilex/rfd) from 0.15.4 to 0.16.0.
- [Release notes](https://github.com/PolyMeilex/rfd/releases)
- [Changelog](https://github.com/PolyMeilex/rfd/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PolyMeilex/rfd/compare/0.15.4...0.16.0)

---
updated-dependencies:
- dependency-name: rfd
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-01 02:10:17 +00:00
dependabot[bot]
0683dd3343 build(deps): bump actions/download-artifact from 5 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-01 02:09:53 +00:00
dependabot[bot]
4d304dc978 build(deps): bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-01 02:09:50 +00: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
Louis Erbkamm
5d97391820 Merge pull request #664 from louis-e/single-bbox 2025-12-07 20:32:41 +01:00
louis-e
bef3cfb090 Allow only one bbox selection at a time 2025-12-07 19:37:49 +01:00
Louis Erbkamm
5a898944f7 Merge pull request #663 from louis-e/fix-world-lock-during-map-preview
Fix world lock held during map preview generation
2025-12-07 19:24:40 +01:00
louis-e
9fdd960009 Fix world lock held during map preview generation 2025-12-07 18:18:12 +01:00
Amir Y. Perehodnik
f57d14b200 set generate_features to 1 in level.dat nbt 2025-12-07 18:47:09 +02:00
copilot-swe-agent[bot]
0a51b302ee Simplify bbox format comment
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:22:50 +00:00
copilot-swe-agent[bot]
93dc9f446c Improve comment explaining bbox format conversion
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:17:56 +00:00
copilot-swe-agent[bot]
e6430f2a04 Fix spawn point Y coordinate bbox format
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:11:36 +00:00
Louis Erbkamm
58e4a337d9 Merge pull request #661 from louis-e/disable-transparent
Disable transparent flag
2025-12-07 15:06:33 +01:00
louis-e
236a7e5af9 Disable transparent flag 2025-12-07 15:04:02 +01:00
copilot-swe-agent[bot]
5962decf44 Initial plan 2025-12-07 13:58:19 +00:00
Louis Erbkamm
9173e5b4de Merge pull request #654 from louis-e/prepare-v2.4.0
Prepare release v2.4.0
2025-12-05 18:01:58 +01:00
louis-e
1fd02d8005 Prepare release v2.4.0 2025-12-05 17:49:58 +01:00
Louis Erbkamm
438b2beceb Merge pull request #653 from louis-e/bedrock-support
Bedrock support
2025-12-05 17:34:03 +01:00
louis-e
a62e181c16 Only modify spawn position in level.dat for Java 2025-12-05 17:26:34 +01:00
louis-e
12abba3bc8 Remove telemetry line 2025-12-05 17:25:48 +01:00
louis-e
a8e31700d8 Remove comment 2025-12-05 16:56:17 +01:00
louis-e
7a109cce0b Mock emit_open_mcworld_file 2025-12-05 16:51:30 +01:00
louis-e
86543714af Mention Bedrock Edition in README 2025-12-05 16:51:00 +01:00
louis-e
b84a565210 Restore format selection earlier in call chain 2025-12-05 16:50:44 +01:00
louis-e
93becaae7f Include world_icon via include_bytes 2025-12-05 16:39:36 +01:00
louis-e
06e377ce29 Emit done msg after snowman lock is released 2025-12-05 01:00:39 +01:00
louis-e
e22380bdd3 Fix progress bar updates on Java gen with many regions 2025-12-05 00:45:13 +01:00
louis-e
35cac44209 Release snowman lock before map preview generation 2025-12-05 00:43:51 +01:00
louis-e
61af45d2f4 Skip map preview for large areas 2025-12-05 00:39:39 +01:00
louis-e
393f1f9bd8 Increase map loading failure count 2025-12-05 00:39:20 +01:00
louis-e
e6f8466177 Adjust area size thresholds 2025-12-05 00:38:52 +01:00
louis-e
02d3a32a03 Scale gaussian blur correctly with area size 2025-12-05 00:31:51 +01:00
louis-e
f00304ff3a Fix spawn_y spawn in Bedrock 2025-12-04 23:52:44 +01:00
louis-e
a93b908104 Fix bedrock progress bar 2025-12-04 22:57:58 +01:00
louis-e
7cbc4fa263 Fix incorrectly mapped Bedrock blocks 2025-12-04 22:24:56 +01:00
louis-e
7e7f7ed476 Support spawn position marker on Bedrock 2025-12-04 19:00:14 +01:00
louis-e
3c0ba60657 Adjust time in benchmark 2025-12-04 18:59:51 +01:00
louis-e
fb438c4a0f Preserver block properties in bedrock 2025-12-04 18:38:50 +01:00
louis-e
5015c8b9b4 Fix linter issues 2025-12-04 18:15:21 +01:00
louis-e
af0ace422f Add bedrock_use_java localization 2025-12-04 18:04:59 +01:00
louis-e
0bb39b7d9e Simplify comments 2025-12-04 17:32:53 +01:00
louis-e
5b5e93b89a Refactor world_editor into modular directory structure 2025-12-04 17:26:12 +01:00
louis-e
958dc2107e Remove console log line 2025-12-04 17:25:14 +01:00
louis-e
562a3bca66 Clean up temp directory after packaging mcworld 2025-12-04 16:52:07 +01:00
louis-e
f1b37fbbb6 Add Bedrock world icon asset 2025-12-04 16:46:50 +01:00
louis-e
b34cbf4307 Add bedrock-rs license credit 2025-12-04 16:46:22 +01:00
louis-e
a03318bb98 Add format toggle logic and mcworld file opening 2025-12-04 16:45:59 +01:00
louis-e
8bb779d6cc Add format toggle button styles 2025-12-04 16:45:47 +01:00
louis-e
6d164102ad Add Java/Bedrock format toggle UI 2025-12-04 16:45:35 +01:00
louis-e
127a0e5e68 Add GUI format toggle and show_in_folder command 2025-12-04 16:44:46 +01:00
louis-e
4a326c3dad Add emit_open_mcworld_file event 2025-12-04 16:44:37 +01:00
louis-e
d4fd9b9cd3 Add GenerationOptions and format-aware world generation 2025-12-04 16:44:19 +01:00
louis-e
ee0521f232 Add Bedrock world format support with LevelDB storage 2025-12-04 16:43:59 +01:00
louis-e
8b3a41b131 Add Java to Bedrock block mapping 2025-12-04 16:43:49 +01:00
louis-e
02594b1cae Add bedrock_block_map module import 2025-12-04 16:43:38 +01:00
louis-e
06ba4db97e Add bedrockrs and dependencies for Bedrock support 2025-12-04 16:43:27 +01:00
louis-e
59d31cfbb8 Add *.mcworld to gitignore 2025-12-04 16:43:13 +01:00
Louis Erbkamm
94388e4164 Merge pull request #651 from louis-e/fix-2x-sprite
fix: correct disabled icon positions for 7-sprite 2x sheet
2025-12-01 21:49:41 +01:00
louis-e
f8c9fd8f4c fix: correct disabled icon positions for 7-sprite 2x sheet 2025-12-01 21:42:45 +01:00
Louis Erbkamm
2ee2d48f6a Merge pull request #650 from louis-e/mock-emit-mappreview
Mock map preview emit for CLI builds
2025-12-01 18:50:04 +01:00
louis-e
56c2f2e5cd Mock mep preview emit for CLI builds 2025-12-01 18:49:40 +01:00
Louis Erbkamm
9d34bc8e92 Merge pull request #649 from louis-e/benchmark-datetime
Add date time in benchmark comment
2025-12-01 18:46:45 +01:00
louis-e
c95b78fdcd Add date time to benchmark comment 2025-12-01 18:46:09 +01:00
louis-e
6e52e08b8a Remove telemetry line 2025-12-01 18:45:55 +01:00
Louis Erbkamm
57a4a801cf Modify benchmark workflow for map preview and checkout
Updated the benchmark workflow to use actions/checkout@v5 and added steps to check for and upload a map preview artifact.
2025-12-01 18:04:08 +01:00
Louis Erbkamm
0c47e365bc Merge pull request #644 from louis-e/dependabot/cargo/semver-1.0.27
build(deps): bump semver from 1.0.26 to 1.0.27
2025-12-01 17:47:50 +01:00
Louis Erbkamm
dad3ab3b34 Merge pull request #643 from louis-e/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-01 17:47:40 +01:00
Louis Erbkamm
b8b63a2bc5 Merge pull request #647 from louis-e/map-preview
Display map preview in GUI
2025-12-01 17:44:05 +01:00
louis-e
cab20b5e50 Fix clippy lint 2025-12-01 17:43:43 +01:00
louis-e
0e879837fa Remove block category comments 2025-12-01 17:42:37 +01:00
louis-e
92be2ccf00 Improve map preview generation time 2025-12-01 17:38:10 +01:00
louis-e
3b76d707d9 Display map preview in GUI 2025-12-01 17:08:54 +01:00
dependabot[bot]
be8559dee7 build(deps): bump semver from 1.0.26 to 1.0.27
Bumps [semver](https://github.com/dtolnay/semver) from 1.0.26 to 1.0.27.
- [Release notes](https://github.com/dtolnay/semver/releases)
- [Commits](https://github.com/dtolnay/semver/compare/1.0.26...1.0.27)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 1.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 02:41:00 +00:00
dependabot[bot]
94eda2fad3 build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 02:27:47 +00:00
Louis Erbkamm
7d86854e3c Merge pull request #640 from louis-e/reintroduce-sutherland-hodgeman
Reintroduce Sutherland-Hodgman Clipping Algorithm
2025-11-28 11:28:25 +01:00
louis-e
cddaa89d35 Fix polyline clipping for segments crossing bbox with external endpoints 2025-11-28 11:12:17 +01:00
louis-e
453845977d Fix polyline clipping for segments crossing bbox with external endpoints 2025-11-28 11:10:43 +01:00
louis-e
4e196e51bd Move sutherland hodgman into own clipping file 2025-11-28 10:57:47 +01:00
louis-e
ea4dc5dc08 Remove unused dependencies 2025-11-28 10:40:06 +01:00
louis-e
c56ff83094 Fix cargo fmt 2025-11-28 01:43:55 +01:00
louis-e
2b40a520ff Reintroduce initial Sutherland-Hodgman clipping 2025-11-28 01:43:16 +01:00
louis-e
a192be981a Fix clippy 2025-11-26 15:02:12 +01:00
louis-e
eb77bca10d Refactor code comments 2025-11-26 14:49:41 +01:00
louis-e
4a891c3603 Clean up code 2025-11-26 14:40:36 +01:00
louis-e
84adfdd931 Remove floodfill abort artifacts 2025-11-26 14:32:24 +01:00
louis-e
823b6ba052 Remove debugging changes 2025-11-26 14:22:04 +01:00
louis-e
2ba8157ec9 Remove debug logging 2025-11-26 14:15:20 +01:00
louis-e
7235ba0be9 Merge remote changes, keeping our Sutherland-Hodgman implementation 2025-11-26 13:53:53 +01:00
louis-e
dee580c564 fix: cargo fmt 2025-11-26 13:48:32 +01:00
louis-e
41fc5662e0 fix: restore performance with Sutherland-Hodgman clipping and correct water rendering
- Fix O(n*m) performance regression in highway processing by building connectivity map once
- Store unclipped ways in ways_map for proper relation member merging (merge_loopy_loops)
- Use clipped ways for standalone way processing
- Add empty vector guard in merge_loopy_loops to prevent panic
- Expose build_highway_connectivity_map as public API
- Add debug_logging module for development diagnostics
2025-11-26 13:39:29 +01:00
louis-e
ac884b8c2a Correctly clip multipolygons 2025-11-23 18:34:10 +01:00
louis-e
7a9b792bee Restore node filtering for performance without breaking water features 2025-11-23 17:34:08 +01:00
Louis Erbkamm
83e9a634e5 Update baseline time and memory in benchmark workflow 2025-11-22 14:45:51 +01:00
Louis Erbkamm
56ddea57d0 Merge pull request #638 from louis-e/telemetry-nofeatures-fix
Fix no-default-features build
2025-11-22 14:37:34 +01:00
Louis Erbkamm
430a4970f5 Merge branch 'main' into telemetry-nofeatures-fix 2025-11-22 14:30:34 +01:00
Louis Erbkamm
74fbdabaee Update benchmark command with new bounding box 2025-11-22 14:30:25 +01:00
louis-e
2643155e9a Fix no-default-features build 2025-11-22 13:50:55 +01:00
Louis Erbkamm
d45c360074 Enable pr-benchmark workflow 2025-11-22 13:44:39 +01:00
Louis Erbkamm
6277a14d22 Merge pull request #618 from louis-e/dependabot/cargo/tokio-1.48.0
build(deps): bump tokio from 1.47.0 to 1.48.0
2025-11-17 21:57:36 +01:00
Louis Erbkamm
c355f243e3 Merge pull request #619 from louis-e/dependabot/cargo/geo-0.31.0
build(deps): bump geo from 0.30.0 to 0.31.0
2025-11-17 21:57:25 +01:00
Louis Erbkamm
2c31d2659c Merge pull request #630 from louis-e/crash-telemetry
WIP: Crash telemetry collection
2025-11-17 21:56:50 +01:00
louis-e
996e06ab2c Log panic to log file 2025-11-17 21:54:48 +01:00
louis-e
e11231ad0f Fix cargo fmt 2025-11-17 21:39:38 +01:00
louis-e
9adf31121e Send error telemetry data 2025-11-17 21:30:47 +01:00
louis-e
69da18fbfb Extend telemetry by more endpoints 2025-11-17 21:28:00 +01:00
louis-e
5976cc2868 Improve responsive UI layout 2025-11-17 19:15:04 +01:00
louis-e
a85eaed835 Add telemetry toggle and link to privacy policy 2025-11-17 19:09:16 +01:00
louis-e
37c3d85672 Fix clippy errors 2025-11-17 17:01:16 +01:00
louis-e
15b698a1eb WIP: Crash telemetry collection 2025-11-17 00:10:38 +01:00
dependabot[bot]
8fff2d2fb5 build(deps): bump geo from 0.30.0 to 0.31.0
Bumps [geo](https://github.com/georust/geo) from 0.30.0 to 0.31.0.
- [Changelog](https://github.com/georust/geo/blob/main/CHANGES.md)
- [Commits](https://github.com/georust/geo/compare/geo-0.30.0...geo-0.31.0)

---
updated-dependencies:
- dependency-name: geo
  dependency-version: 0.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-01 02:13:26 +00:00
dependabot[bot]
8c702a36ff build(deps): bump tokio from 1.47.0 to 1.48.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.47.0 to 1.48.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.47.0...tokio-1.48.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-01 02:13:08 +00:00
79 changed files with 20318 additions and 5587 deletions

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1
@@ -43,7 +43,7 @@ jobs:
- name: Run benchmark command with memory tracking
id: benchmark
run: |
/usr/bin/time -v ./target/release/arnis --path="./world" --terrain --bbox="48.101470,11.517792,48.168375,11.626968" 2> benchmark_log.txt
/usr/bin/time -v ./target/release/arnis --path="./world" --terrain --bbox="48.125768 11.552296 48.148565 11.593838" 2> benchmark_log.txt
grep "Maximum resident set size" benchmark_log.txt | awk '{print $6}' > peak_mem_kb.txt
peak_kb=$(cat peak_mem_kb.txt)
peak_mb=$((peak_kb / 1024))
@@ -65,7 +65,7 @@ jobs:
seconds=$((duration % 60))
peak_mem=${{ steps.benchmark.outputs.peak_memory }}
baseline_time=135
baseline_time=30
diff=$((duration - baseline_time))
abs_diff=${diff#-}
@@ -79,7 +79,7 @@ jobs:
verdict="🚨 This PR **drastically worsens generation time**."
fi
baseline_mem=5865
baseline_mem=935
mem_annotation=""
if [ "$peak_mem" -gt 2000 ]; then
mem_diff=$((peak_mem - baseline_mem))
@@ -87,6 +87,8 @@ jobs:
mem_annotation=" (↗ ${mem_percent}% more)"
fi
benchmark_time=$(date -u "+%Y-%m-%d %H:%M:%S UTC")
{
echo "summary<<EOF"
echo "⏱️ Benchmark run finished in **${minutes}m ${seconds}s**"
@@ -98,6 +100,8 @@ jobs:
echo ""
echo "${verdict}"
echo ""
echo "📅 **Last benchmark:** ${benchmark_time}"
echo ""
echo "_You can retrigger the benchmark by commenting \`retrigger-benchmark\`._"
echo "EOF"
} >> "$GITHUB_OUTPUT"
@@ -108,4 +112,4 @@ jobs:
message: ${{ steps.comment_body.outputs.summary }}
comment-tag: benchmark-report
env:
GITHUB_TOKEN: ${{ secrets.BENCHMARK_TOKEN }}
GITHUB_TOKEN: ${{ secrets.BENCHMARK_TOKEN }}

View File

@@ -17,7 +17,7 @@ jobs:
target: x86_64-unknown-linux-gnu
binary_name: arnis
asset_name: arnis-linux
- os: macos-13 # Intel runner for x86_64 builds
- os: macos-15-intel # Intel runner for x86_64 builds
target: x86_64-apple-darwin
binary_name: arnis
asset_name: arnis-mac-intel
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1
@@ -87,7 +87,7 @@ jobs:
shell: powershell
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.os }}-${{ matrix.target }}-build
path: target/release/${{ matrix.asset_name }}
@@ -97,13 +97,13 @@ jobs:
runs-on: macos-latest
steps:
- name: Download macOS Intel build
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: macos-13-x86_64-apple-darwin-build
name: macos-15-intel-x86_64-apple-darwin-build
path: ./intel
- name: Download macOS ARM64 build
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: macos-latest-aarch64-apple-darwin-build
path: ./arm64
@@ -114,7 +114,7 @@ jobs:
chmod +x arnis-mac-universal
- name: Upload universal binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: macos-universal-build
path: arnis-mac-universal
@@ -124,22 +124,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download Windows build artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: windows-latest-x86_64-pc-windows-msvc-build
path: ./builds/windows
- name: Download Linux build artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: ubuntu-latest-x86_64-unknown-linux-gnu-build
path: ./builds/linux
- name: Download macOS universal build artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: macos-universal-build
path: ./builds/macos
@@ -157,4 +157,4 @@ jobs:
builds/linux/arnis-linux
builds/macos/arnis-mac-universal
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/wiki
*.mcworld
# Environment files
.env

3498
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.3.1"
version = "2.5.0"
edition = "2021"
description = "Arnis - Generate real life cities in Minecraft"
homepage = "https://github.com/louis-e/arnis"
@@ -14,40 +14,51 @@ overflow-checks = true
[features]
default = ["gui"]
gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "dirs", "tauri-build"]
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}
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
base64 = "0.22.1"
byteorder = { version = "1.5", optional = true }
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"
fnv = "1.0.7"
fs2 = "0.4"
geo = "0.30.0"
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 = { version = "0.9.1", features = ["std", "std_rng"] }
rand_chacha = "0.9"
rayon = "1.10.0"
reqwest = { version = "0.12.15", features = ["blocking", "json"] }
rfd = { version = "0.15.4", optional = true }
semver = "1.0.26"
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"] }
serde_json = "1.0"
tauri = { version = "2", optional = true }
tauri-plugin-log = { version = "2.6.0", optional = true }
tauri-plugin-shell = { version = "2", optional = true }
tokio = { version = "1.47.0", features = ["full"], optional = true }
tokio = { version = "1.48.0", features = ["full"], optional = true }
bedrockrs_level = { git = "https://github.com/bedrock-crustaceans/bedrock-rs", package = "bedrockrs_level", optional = true }
bedrockrs_shared = { git = "https://github.com/bedrock-crustaceans/bedrock-rs", package = "bedrockrs_shared", optional = true }
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

@@ -2,7 +2,7 @@
# Arnis [![CI Build Status](https://github.com/louis-e/arnis/actions/workflows/ci-build.yml/badge.svg)](https://github.com/louis-e/arnis/actions) [<img alt="GitHub Release" src="https://img.shields.io/github/v/release/louis-e/arnis" />](https://github.com/louis-e/arnis/releases) [<img alt="GitHub Downloads (all assets, all releases" src="https://img.shields.io/github/downloads/louis-e/arnis/total" />](https://github.com/louis-e/arnis/releases) [![Download here](https://img.shields.io/badge/Download-here-green)](https://github.com/louis-e/arnis/releases) [![Discord](https://img.shields.io/discord/1326192999738249267?label=Discord&color=%237289da)](https://discord.gg/mA2g69Fhxq)
Arnis creates complex and accurate Minecraft Java Edition worlds that reflect real-world geography, topography, and architecture.
Arnis creates complex and accurate Minecraft Java Edition (1.17+) and Bedrock Edition worlds that reflect real-world geography, topography, and architecture.
This free and open source project is designed to handle large-scale geographic data from the real world and generate detailed Minecraft worlds. The algorithm processes geospatial data from OpenStreetMap as well as elevation data to create an accurate Minecraft representation of terrain and architecture.
Generate your hometown, big cities, and natural landscapes with ease!
@@ -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">
@@ -63,6 +65,8 @@ Arnis has been recognized in various academic and press publications after gaini
[XDA Developers: Hometown Minecraft Map: Arnis](https://www.xda-developers.com/hometown-minecraft-map-arnis/)
Free to use assets, including screenshots and logos, can be found [here](https://drive.google.com/file/d/1T1IsZSyT8oa6qAO_40hVF5KR8eEVCJjo/view?usp=sharing).
## :copyright: License Information
Copyright (c) 2022-2025 Louis Erbkamm (louis-e)

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

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,
@@ -58,25 +69,43 @@ pub struct Args {
/// Set floodfill timeout (seconds) (optional)
#[arg(long, value_parser = parse_duration)]
pub timeout: Option<Duration>,
/// Spawn point coordinates (lat, lng)
#[arg(skip)]
pub spawn_point: Option<(f64, f64)>,
}
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> {
@@ -88,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",
@@ -114,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.

1234
src/bedrock_block_map.rs Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -266,6 +266,67 @@ impl Block {
185 => "quartz_stairs",
186 => "polished_andesite_stairs",
187 => "nether_brick_stairs",
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"),
}
}
@@ -324,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(
@@ -463,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,
}
}
@@ -697,6 +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 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]
@@ -748,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
@@ -934,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
@@ -942,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()
@@ -952,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,
@@ -981,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,
@@ -1003,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())]
}

725
src/clipping.rs Normal file
View File

@@ -0,0 +1,725 @@
// Sutherland-Hodgman polygon clipping and related geometry utilities.
//
// Provides bbox clipping for polygons, polylines, and water rings with
// proper corner insertion for closed shapes.
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::osm_parser::ProcessedNode;
use std::collections::HashMap;
/// Clips a way to the bounding box using Sutherland-Hodgman for polygons or
/// simple line clipping for polylines. Preserves endpoint IDs for ring assembly.
pub fn clip_way_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<ProcessedNode> {
if nodes.is_empty() {
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 {
return clip_polyline_to_bbox(nodes, xzbbox);
}
// If all nodes are inside the bbox, return unchanged
let has_nodes_outside = nodes
.iter()
.any(|node| !xzbbox.contains(&XZPoint::new(node.x, node.z)));
if !has_nodes_outside {
return nodes.to_vec();
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
let mut polygon: Vec<(f64, f64)> = nodes.iter().map(|n| (n.x as f64, n.z as f64)).collect();
polygon = clip_polygon_sutherland_hodgman(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return Vec::new();
}
// Final clamping for floating-point errors
for p in &mut polygon {
p.0 = p.0.clamp(min_x, max_x);
p.1 = p.1.clamp(min_z, max_z);
}
let polygon = remove_consecutive_duplicates(polygon);
if polygon.len() < 3 {
return Vec::new();
}
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();
}
assign_node_ids_preserving_endpoints(nodes, polygon, way_id)
}
/// Clips a water polygon ring to bbox using Sutherland-Hodgman (post-ring-merge).
pub fn clip_water_ring_to_bbox(
ring: &[ProcessedNode],
xzbbox: &XZBBox,
) -> Option<Vec<ProcessedNode>> {
if ring.is_empty() {
return None;
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
// Check if entire ring is inside bbox
let all_inside = ring.iter().all(|n| {
n.x as f64 >= min_x && n.x as f64 <= max_x && n.z as f64 >= min_z && n.z as f64 <= max_z
});
if all_inside {
return Some(ring.to_vec());
}
// Check if entire ring is outside bbox
if is_ring_outside_bbox(ring, min_x, min_z, max_x, max_z) {
return None;
}
// Convert to f64 coordinates and ensure closed
let mut polygon: Vec<(f64, f64)> = ring.iter().map(|n| (n.x as f64, n.z as f64)).collect();
if !polygon.is_empty() && polygon.first() != polygon.last() {
polygon.push(polygon[0]);
}
// Clip with full-range clamping (water uses simpler approach)
polygon = clip_polygon_sutherland_hodgman_simple(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return None;
}
// Verify all points are within bbox
let all_points_inside = polygon
.iter()
.all(|&(x, z)| x >= min_x && x <= max_x && z >= min_z && z <= max_z);
if !all_points_inside {
eprintln!("ERROR: clip_water_ring_to_bbox produced points outside bbox!");
return None;
}
let polygon = insert_bbox_corners(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return None;
}
// Convert back to ProcessedNode with synthetic IDs
let mut result: Vec<ProcessedNode> = polygon
.iter()
.enumerate()
.map(|(i, &(x, z))| ProcessedNode {
id: 1_000_000_000 + i as u64,
tags: HashMap::new(),
x: x.clamp(min_x, max_x).round() as i32,
z: z.clamp(min_z, max_z).round() as i32,
})
.collect();
// Close the loop by matching first and last ID
if !result.is_empty() {
let first_id = result[0].id;
result.last_mut().unwrap().id = first_id;
}
Some(result)
}
// ============================================================================
// Internal helpers
// ============================================================================
/// Checks if a way forms a closed polygon.
fn is_closed_polygon(nodes: &[ProcessedNode]) -> bool {
if nodes.len() < 3 {
return false;
}
let first = nodes.first().unwrap();
let last = nodes.last().unwrap();
first.id == last.id || (first.x == last.x && first.z == last.z)
}
/// Checks if an entire ring is outside the bbox.
fn is_ring_outside_bbox(
ring: &[ProcessedNode],
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> bool {
let all_left = ring.iter().all(|n| (n.x as f64) < min_x);
let all_right = ring.iter().all(|n| (n.x as f64) > max_x);
let all_top = ring.iter().all(|n| (n.z as f64) < min_z);
let all_bottom = ring.iter().all(|n| (n.z as f64) > max_z);
all_left || all_right || all_top || all_bottom
}
/// Clips a polyline (open path) to the bounding box.
fn clip_polyline_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<ProcessedNode> {
if nodes.is_empty() {
return Vec::new();
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
let mut result = Vec::new();
for i in 0..nodes.len() {
let current = &nodes[i];
let current_point = (current.x as f64, current.z as f64);
let current_inside = point_in_bbox(current_point, min_x, min_z, max_x, max_z);
if current_inside {
result.push(current.clone());
}
if i + 1 < nodes.len() {
let next = &nodes[i + 1];
let next_point = (next.x as f64, next.z as f64);
let next_inside = point_in_bbox(next_point, min_x, min_z, max_x, max_z);
if current_inside != next_inside {
// One endpoint inside, one outside, find single intersection
let intersections =
find_bbox_intersections(current_point, next_point, min_x, min_z, max_x, max_z);
for intersection in intersections {
let synthetic_id = nodes[0]
.id
.wrapping_mul(10000000)
.wrapping_add(result.len() as u64);
result.push(ProcessedNode {
id: synthetic_id,
x: intersection.0.round() as i32,
z: intersection.1.round() as i32,
tags: HashMap::new(),
});
}
} else if !current_inside && !next_inside {
// Both endpoints outside, segment might still cross through bbox
let mut intersections =
find_bbox_intersections(current_point, next_point, min_x, min_z, max_x, max_z);
if intersections.len() >= 2 {
// Sort intersections by distance from current point
intersections.sort_by(|a, b| {
let dist_a =
(a.0 - current_point.0).powi(2) + (a.1 - current_point.1).powi(2);
let dist_b =
(b.0 - current_point.0).powi(2) + (b.1 - current_point.1).powi(2);
dist_a
.partial_cmp(&dist_b)
.unwrap_or(std::cmp::Ordering::Equal)
});
for intersection in intersections {
let synthetic_id = nodes[0]
.id
.wrapping_mul(10000000)
.wrapping_add(result.len() as u64);
result.push(ProcessedNode {
id: synthetic_id,
x: intersection.0.round() as i32,
z: intersection.1.round() as i32,
tags: HashMap::new(),
});
}
}
}
}
}
// Preserve endpoint IDs where possible
if result.len() >= 2 {
let tolerance = 50.0;
if let Some(first_orig) = nodes.first() {
if matches_endpoint(
(result[0].x as f64, result[0].z as f64),
first_orig,
tolerance,
) {
result[0].id = first_orig.id;
}
}
if let Some(last_orig) = nodes.last() {
let last_idx = result.len() - 1;
if matches_endpoint(
(result[last_idx].x as f64, result[last_idx].z as f64),
last_orig,
tolerance,
) {
result[last_idx].id = last_orig.id;
}
}
}
result
}
/// Sutherland-Hodgman polygon clipping with edge-specific clamping.
fn clip_polygon_sutherland_hodgman(
mut polygon: Vec<(f64, f64)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
// Edges: bottom, right, top, left (counter-clockwise traversal)
let bbox_edges = [
(min_x, min_z, max_x, min_z, 0), // Bottom: clamp z
(max_x, min_z, max_x, max_z, 1), // Right: clamp x
(max_x, max_z, min_x, max_z, 2), // Top: clamp z
(min_x, max_z, min_x, min_z, 3), // Left: clamp x
];
for (edge_x1, edge_z1, edge_x2, edge_z2, edge_idx) in bbox_edges {
if polygon.is_empty() {
break;
}
let mut clipped = Vec::new();
let is_closed = !polygon.is_empty() && polygon.first() == polygon.last();
let edge_count = if is_closed {
polygon.len().saturating_sub(1)
} else {
polygon.len()
};
for i in 0..edge_count {
let current = polygon[i];
let next = polygon.get(i + 1).copied().unwrap_or(polygon[0]);
let current_inside = point_inside_edge(current, edge_x1, edge_z1, edge_x2, edge_z2);
let next_inside = point_inside_edge(next, edge_x1, edge_z1, edge_x2, edge_z2);
if next_inside {
if !current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
// Clamp to current edge only
match edge_idx {
0 => intersection.1 = min_z,
1 => intersection.0 = max_x,
2 => intersection.1 = max_z,
3 => intersection.0 = min_x,
_ => {}
}
clipped.push(intersection);
}
}
clipped.push(next);
} else if current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
match edge_idx {
0 => intersection.1 = min_z,
1 => intersection.0 = max_x,
2 => intersection.1 = max_z,
3 => intersection.0 = min_x,
_ => {}
}
clipped.push(intersection);
}
}
}
polygon = clipped;
}
polygon
}
/// Sutherland-Hodgman with full bbox clamping (simpler, for water rings).
fn clip_polygon_sutherland_hodgman_simple(
mut polygon: Vec<(f64, f64)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
let bbox_edges = [
(min_x, min_z, max_x, min_z),
(max_x, min_z, max_x, max_z),
(max_x, max_z, min_x, max_z),
(min_x, max_z, min_x, min_z),
];
for (edge_x1, edge_z1, edge_x2, edge_z2) in bbox_edges {
if polygon.is_empty() {
break;
}
let mut clipped = Vec::new();
for i in 0..(polygon.len().saturating_sub(1)) {
let current = polygon[i];
let next = polygon[i + 1];
let current_inside = point_inside_edge(current, edge_x1, edge_z1, edge_x2, edge_z2);
let next_inside = point_inside_edge(next, edge_x1, edge_z1, edge_x2, edge_z2);
if next_inside {
if !current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
intersection.0 = intersection.0.clamp(min_x, max_x);
intersection.1 = intersection.1.clamp(min_z, max_z);
clipped.push(intersection);
}
}
clipped.push(next);
} else if current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
intersection.0 = intersection.0.clamp(min_x, max_x);
intersection.1 = intersection.1.clamp(min_z, max_z);
clipped.push(intersection);
}
}
}
polygon = clipped;
}
polygon
}
/// Checks if point is inside bbox.
fn point_in_bbox(point: (f64, f64), min_x: f64, min_z: f64, max_x: f64, max_z: f64) -> bool {
point.0 >= min_x && point.0 <= max_x && point.1 >= min_z && point.1 <= max_z
}
/// Checks if point is on the "inside" side of an edge (cross product test).
fn point_inside_edge(
point: (f64, f64),
edge_x1: f64,
edge_z1: f64,
edge_x2: f64,
edge_z2: f64,
) -> bool {
let edge_dx = edge_x2 - edge_x1;
let edge_dz = edge_z2 - edge_z1;
let point_dx = point.0 - edge_x1;
let point_dz = point.1 - edge_z1;
(edge_dx * point_dz - edge_dz * point_dx) >= 0.0
}
/// Finds intersection between a line segment and an edge.
#[allow(clippy::too_many_arguments)]
fn line_edge_intersection(
line_x1: f64,
line_z1: f64,
line_x2: f64,
line_z2: f64,
edge_x1: f64,
edge_z1: f64,
edge_x2: f64,
edge_z2: f64,
) -> Option<(f64, f64)> {
let line_dx = line_x2 - line_x1;
let line_dz = line_z2 - line_z1;
let edge_dx = edge_x2 - edge_x1;
let edge_dz = edge_z2 - edge_z1;
let denom = line_dx * edge_dz - line_dz * edge_dx;
if denom.abs() < 1e-10 {
return None;
}
let dx = edge_x1 - line_x1;
let dz = edge_z1 - line_z1;
let t = (dx * edge_dz - dz * edge_dx) / denom;
if (0.0..=1.0).contains(&t) {
Some((line_x1 + t * line_dx, line_z1 + t * line_dz))
} else {
None
}
}
/// Finds intersections between a line segment and bbox edges.
fn find_bbox_intersections(
start: (f64, f64),
end: (f64, f64),
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
let mut intersections = Vec::new();
let bbox_edges = [
(min_x, min_z, max_x, min_z),
(max_x, min_z, max_x, max_z),
(max_x, max_z, min_x, max_z),
(min_x, max_z, min_x, min_z),
];
for (edge_x1, edge_z1, edge_x2, edge_z2) in bbox_edges {
if let Some(intersection) = line_edge_intersection(
start.0, start.1, end.0, end.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
let on_edge = point_in_bbox(intersection, min_x, min_z, max_x, max_z)
&& ((intersection.0 == min_x || intersection.0 == max_x)
|| (intersection.1 == min_z || intersection.1 == max_z));
if on_edge {
intersections.push(intersection);
}
}
}
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 {
// 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;
// Handle corners (assign to edge in counter-clockwise order)
if on_bottom && on_left {
return 3;
}
if on_bottom && on_right {
return 0;
}
if on_top && on_right {
return 1;
}
if on_top && on_left {
return 2;
}
if on_bottom {
return 0;
}
if on_right {
return 1;
}
if on_top {
return 2;
}
if on_left {
return 3;
}
-1
}
/// Returns corners to insert when traversing from edge1 to edge2 via shorter path.
fn get_corners_between_edges(
edge1: i32,
edge2: i32,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
if edge1 == edge2 || edge1 < 0 || edge2 < 0 {
return Vec::new();
}
let corners = [
(max_x, min_z), // 0: bottom-right
(max_x, max_z), // 1: top-right
(min_x, max_z), // 2: top-left
(min_x, min_z), // 3: bottom-left
];
let ccw_dist = ((edge2 - edge1 + 4) % 4) as usize;
let cw_dist = ((edge1 - edge2 + 4) % 4) as usize;
// 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;
result.push(corners[current as usize]);
}
}
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)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
if polygon.len() < 3 {
return polygon;
}
let mut result = Vec::with_capacity(polygon.len() + 4);
for i in 0..polygon.len() {
let current = polygon[i];
let next = polygon[(i + 1) % polygon.len()];
result.push(current);
let edge1 = get_bbox_edge(current, min_x, min_z, max_x, max_z);
let edge2 = get_bbox_edge(next, min_x, min_z, max_x, max_z);
if edge1 >= 0 && edge2 >= 0 && edge1 != edge2 {
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);
}
}
}
}
result
}
/// Removes consecutive duplicate points (within epsilon tolerance).
fn remove_consecutive_duplicates(polygon: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
if polygon.is_empty() {
return polygon;
}
let eps = 0.1;
let mut result: Vec<(f64, f64)> = Vec::with_capacity(polygon.len());
for p in &polygon {
if let Some(last) = result.last() {
if (p.0 - last.0).abs() < eps && (p.1 - last.1).abs() < eps {
continue;
}
}
result.push(*p);
}
// Check first/last duplicates for closed polygons
if result.len() > 1 {
let first = result.first().unwrap();
let last = result.last().unwrap();
if (first.0 - last.0).abs() < eps && (first.1 - last.1).abs() < eps {
result.pop();
}
}
result
}
/// Checks if a clipped coordinate matches an original endpoint.
fn matches_endpoint(coord: (f64, f64), endpoint: &ProcessedNode, tolerance: f64) -> bool {
let dx = (coord.0 - endpoint.x as f64).abs();
let dz = (coord.1 - endpoint.z as f64).abs();
dx * dx + dz * dz < tolerance * tolerance
}
/// Assigns node IDs to clipped coordinates, preserving original endpoint IDs.
fn assign_node_ids_preserving_endpoints(
original_nodes: &[ProcessedNode],
clipped_coords: Vec<(f64, f64)>,
way_id: u64,
) -> Vec<ProcessedNode> {
if clipped_coords.is_empty() {
return Vec::new();
}
let original_first = original_nodes.first();
let original_last = original_nodes.last();
let tolerance = 50.0;
let last_index = clipped_coords.len() - 1;
clipped_coords
.into_iter()
.enumerate()
.map(|(i, coord)| {
let is_first = i == 0;
let is_last = i == last_index;
if is_first || is_last {
if let Some(first) = original_first {
if matches_endpoint(coord, first, tolerance) {
return ProcessedNode {
id: first.id,
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
};
}
}
if let Some(last) = original_last {
if matches_endpoint(coord, last, tolerance) {
return ProcessedNode {
id: last.id,
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
};
}
}
}
ProcessedNode {
id: way_id.wrapping_mul(10000000).wrapping_add(i as u64),
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
}
})
.collect()
}

View File

@@ -1,35 +1,84 @@
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::osm_parser::ProcessedElement;
use crate::progress::emit_gui_progress_update;
use crate::world_editor::WorldEditor;
use crate::map_renderer;
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;
pub const MIN_Y: i32 = -64;
pub fn generate_world(
/// Generation options that can be passed separately from CLI Args
#[derive(Clone)]
pub struct GenerationOptions {
pub path: PathBuf,
pub format: WorldFormat,
pub level_name: Option<String>,
pub spawn_point: Option<(i32, i32)>,
}
/// Generate world with explicit format options (used by GUI for Bedrock support)
pub fn generate_world_with_options(
elements: Vec<ProcessedElement>,
xzbbox: XZBBox,
llbbox: LLBBox,
ground: Ground,
args: &Args,
) -> Result<(), String> {
let mut editor: WorldEditor = WorldEditor::new(args.path.clone(), &xzbbox, llbbox);
options: GenerationOptions,
) -> Result<PathBuf, String> {
let output_path = options.path.clone();
let world_format = options.format;
// Create editor with appropriate format
let mut editor: WorldEditor = WorldEditor::new_with_format_and_name(
options.path,
&xzbbox,
llbbox,
options.format,
options.level_name.clone(),
options.spawn_point,
);
let ground = Arc::new(ground);
println!("{} Processing data...", "[4/7]".bold());
// Build highway connectivity map once before processing
let highway_connectivity = highways::build_highway_connectivity_map(&elements);
// Set ground reference in the editor to enable elevation-aware block placement
editor.set_ground(&ground);
editor.set_ground(Arc::clone(&ground));
println!("{} Processing terrain...", "[5/7]".bold());
emit_gui_progress_update(25.0, "Processing terrain...");
// Process data
// Pre-compute all flood fills in parallel for better CPU utilization
let mut flood_fill_cache = FloodFillCache::precompute(&elements, args.timeout.as_ref());
// Collect building footprints to prevent trees from spawning inside buildings
// 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);
// 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 process_pb: ProgressBar = ProgressBar::new(elements_count as u64);
process_pb.set_style(ProgressStyle::default_bar()
@@ -41,7 +90,35 @@ pub fn generate_world(
let mut current_progress_prcs: f64 = 25.0;
let mut last_emitted_progress: f64 = current_progress_prcs;
for element in &elements {
// 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 {
@@ -59,26 +136,61 @@ pub fn generate_world(
process_pb.set_message("");
}
match element {
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);
// 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, element, args, &elements);
highways::generate_highways(
&mut editor,
&element,
args,
&highway_connectivity,
&flood_fill_cache,
);
} else if way.tags.contains_key("landuse") {
landuse::generate_landuse(&mut editor, way, args);
landuse::generate_landuse(
&mut editor,
way,
args,
&flood_fill_cache,
&building_footprints,
);
} else if way.tags.contains_key("natural") {
natural::generate_natural(&mut editor, element, args);
natural::generate_natural(
&mut editor,
&element,
args,
&flood_fill_cache,
&building_footprints,
);
} else if way.tags.contains_key("amenity") {
amenities::generate_amenities(&mut editor, element, args);
amenities::generate_amenities(&mut editor, &element, args, &flood_fill_cache);
} else if way.tags.contains_key("leisure") {
leisure::generate_leisure(&mut editor, way, args);
leisure::generate_leisure(
&mut editor,
way,
args,
&flood_fill_cache,
&building_footprints,
);
} else if way.tags.contains_key("barrier") {
barriers::generate_barriers(&mut editor, element);
barriers::generate_barriers(&mut editor, &element);
} else if let Some(val) = way.tags.get("waterway") {
if val == "dock" {
// docks count as water areas
water_areas::generate_water_area_from_way(&mut editor, way);
water_areas::generate_water_area_from_way(&mut editor, way, &xzbbox);
} else {
waterways::generate_waterways(&mut editor, way);
}
@@ -93,9 +205,17 @@ pub fn generate_world(
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);
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);
}
ProcessedElement::Node(node) => {
if node.tags.contains_key("door") || node.tags.contains_key("entrance") {
@@ -103,22 +223,51 @@ pub fn generate_world(
} else if node.tags.contains_key("natural")
&& node.tags.get("natural") == Some(&"tree".to_string())
{
natural::generate_natural(&mut editor, element, args);
natural::generate_natural(
&mut editor,
&element,
args,
&flood_fill_cache,
&building_footprints,
);
} else if node.tags.contains_key("amenity") {
amenities::generate_amenities(&mut editor, element, args);
amenities::generate_amenities(&mut editor, &element, args, &flood_fill_cache);
} else if node.tags.contains_key("barrier") {
barriers::generate_barrier_nodes(&mut editor, node);
} else if node.tags.contains_key("highway") {
highways::generate_highways(&mut editor, element, args, &elements);
highways::generate_highways(
&mut editor,
&element,
args,
&highway_connectivity,
&flood_fill_cache,
);
} else if node.tags.contains_key("tourism") {
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") {
buildings::generate_building_from_relation(&mut editor, rel, args);
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
.tags
@@ -126,26 +275,58 @@ pub fn generate_world(
.map(|val| val == "water" || val == "bay")
.unwrap_or(false)
{
water_areas::generate_water_areas_from_relation(&mut editor, rel);
water_areas::generate_water_areas_from_relation(&mut editor, rel, &xzbbox);
} else if rel.tags.contains_key("natural") {
natural::generate_natural_from_relation(&mut editor, rel, args);
} else if rel.tags.contains_key("landuse") {
landuse::generate_landuse_from_relation(&mut editor, rel, args);
} else if rel.tags.get("leisure") == Some(&"park".to_string()) {
leisure::generate_leisure_from_relation(&mut editor, rel, args);
} else if rel.tags.contains_key("man_made") {
man_made::generate_man_made(
natural::generate_natural_from_relation(
&mut editor,
&ProcessedElement::Relation(rel.clone()),
rel,
args,
&flood_fill_cache,
&building_footprints,
);
} else if rel.tags.contains_key("landuse") {
landuse::generate_landuse_from_relation(
&mut editor,
rel,
args,
&flood_fill_cache,
&building_footprints,
);
} else if rel.tags.get("leisure") == Some(&"park".to_string()) {
leisure::generate_leisure_from_relation(
&mut editor,
rel,
args,
&flood_fill_cache,
&building_footprints,
);
} else if rel.tags.contains_key("man_made") {
man_made::generate_man_made(&mut editor, &element, args);
}
// Release flood fill cache entries for all ways in this relation
let way_ids: Vec<u64> = rel.members.iter().map(|m| m.way.id).collect();
flood_fill_cache.remove_relation_ways(&way_ids);
}
}
// Element is dropped here, freeing its memory immediately
}
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);
// Generate ground layer
let total_blocks: u64 = xzbbox.bounding_rect().total_blocks();
let desired_updates: u64 = 1500;
@@ -169,46 +350,77 @@ pub fn generate_world(
let total_iterations_grnd: f64 = total_blocks as f64;
let progress_increment_grnd: f64 = 20.0 / total_iterations_grnd;
let groundlayer_block = GRASS_BLOCK;
// Check if terrain elevation is enabled; when disabled, we can skip ground level lookups entirely
let terrain_enabled = ground.elevation_enabled;
for x in xzbbox.min_x()..=xzbbox.max_x() {
for z in xzbbox.min_z()..=xzbbox.max_z() {
// Add default dirt and grass layer if there isn't a stone layer already
if !editor.check_for_block(x, 0, z, Some(&[STONE])) {
editor.set_block(groundlayer_block, x, 0, z, None, None);
editor.set_block(DIRT, x, -1, z, None, None);
editor.set_block(DIRT, x, -2, z, None, None);
}
// Process ground generation chunk-by-chunk for better cache locality.
// This keeps the same region/chunk HashMap entries hot in CPU cache,
// rather than jumping between regions on every Z iteration.
let min_chunk_x = xzbbox.min_x() >> 4;
let max_chunk_x = xzbbox.max_x() >> 4;
let min_chunk_z = xzbbox.min_z() >> 4;
let max_chunk_z = xzbbox.max_z() >> 4;
// Fill underground with stone
if args.fillground {
// Fill from bedrock+1 to 3 blocks below ground with stone
editor.fill_blocks_absolute(
STONE,
x,
MIN_Y + 1,
z,
x,
editor.get_absolute_y(x, -3, z),
z,
None,
None,
);
}
// Generate a bedrock level at MIN_Y
editor.set_block_absolute(BEDROCK, x, MIN_Y, z, None, Some(&[BEDROCK]));
for chunk_x in min_chunk_x..=max_chunk_x {
for chunk_z in min_chunk_z..=max_chunk_z {
// Calculate the block range for this chunk, clamped to bbox
let chunk_min_x = (chunk_x << 4).max(xzbbox.min_x());
let chunk_max_x = ((chunk_x << 4) + 15).min(xzbbox.max_x());
let chunk_min_z = (chunk_z << 4).max(xzbbox.min_z());
let chunk_max_z = ((chunk_z << 4) + 15).min(xzbbox.max_z());
block_counter += 1;
// Use manual % check since is_multiple_of() is unstable on stable Rust
#[allow(clippy::manual_is_multiple_of)]
if block_counter % batch_size == 0 {
ground_pb.inc(batch_size);
}
for x in chunk_min_x..=chunk_max_x {
for z in chunk_min_z..=chunk_max_z {
// Get ground level, when terrain is enabled, look it up once per block
// When disabled, use constant ground_level (no function call overhead)
let ground_y = if terrain_enabled {
editor.get_ground_level(x, z)
} else {
args.ground_level
};
gui_progress_grnd += progress_increment_grnd;
if (gui_progress_grnd - last_emitted_progress).abs() > 0.25 {
emit_gui_progress_update(gui_progress_grnd, "");
last_emitted_progress = gui_progress_grnd;
// 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) {
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 {
editor.fill_column_absolute(
STONE,
x,
z,
MIN_Y + 1,
ground_y - 3,
true, // skip_existing: don't overwrite blocks placed by element processing
);
}
// Generate a bedrock level at MIN_Y
editor.set_block_absolute(BEDROCK, x, MIN_Y, z, None, Some(&[BEDROCK]));
block_counter += 1;
#[allow(clippy::manual_is_multiple_of)]
if block_counter % batch_size == 0 {
ground_pb.inc(batch_size);
}
gui_progress_grnd += progress_increment_grnd;
if (gui_progress_grnd - last_emitted_progress).abs() > 0.25 {
emit_gui_progress_update(gui_progress_grnd, "");
last_emitted_progress = gui_progress_grnd;
}
}
}
}
}
@@ -231,30 +443,111 @@ pub fn generate_world(
// Save world
editor.save();
emit_gui_progress_update(99.0, "Finalizing world...");
// Update player spawn Y coordinate based on terrain height after generation
#[cfg(feature = "gui")]
if let Some(spawn_coords) = &args.spawn_point {
if world_format == WorldFormat::JavaAnvil {
use crate::gui::update_player_spawn_y_after_generation;
// Reconstruct bbox string to match the format that GUI originally provided.
// This ensures LLBBox::from_str() can parse it correctly.
let bbox_string = format!(
"{},{},{},{}",
args.bbox.min().lng(),
args.bbox.min().lat(),
args.bbox.max().lng(),
args.bbox.max().lat()
args.bbox.min().lng(),
args.bbox.max().lat(),
args.bbox.max().lng()
);
if let Err(e) = update_player_spawn_y_after_generation(
&args.path,
Some(*spawn_coords),
bbox_string,
args.scale,
&ground,
) {
eprintln!("Warning: Failed to update spawn point Y coordinate: {e}");
// Always update spawn Y since we now always set a spawn point (user-selected or default)
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);
}
}
}
emit_gui_progress_update(100.0, "Done! World generation completed.");
println!("{}", "Done! World generation completed.".green().bold());
Ok(())
// For Bedrock format, emit event to open the mcworld file
if world_format == WorldFormat::BedrockMcWorld {
if let Some(path_str) = output_path.to_str() {
emit_open_mcworld_file(path_str);
}
}
Ok(output_path)
}
/// Information needed to generate a map preview after world generation is complete
#[derive(Clone)]
pub struct MapPreviewInfo {
pub world_path: PathBuf,
pub min_x: i32,
pub max_x: i32,
pub min_z: i32,
pub max_z: i32,
pub world_area: i64,
}
impl MapPreviewInfo {
/// Create MapPreviewInfo from world bounds
pub fn new(world_path: PathBuf, xzbbox: &XZBBox) -> Self {
let world_width = (xzbbox.max_x() - xzbbox.min_x()) as i64;
let world_height = (xzbbox.max_z() - xzbbox.min_z()) as i64;
Self {
world_path,
min_x: xzbbox.min_x(),
max_x: xzbbox.max_x(),
min_z: xzbbox.min_z(),
max_z: xzbbox.max_z(),
world_area: world_width * world_height,
}
}
}
/// Maximum area for which map preview generation is allowed (to avoid memory issues)
pub const MAX_MAP_PREVIEW_AREA: i64 = 6400 * 6900;
/// Start map preview generation in a background thread.
/// This should be called AFTER the world generation is complete, the session lock is released,
/// and the GUI has been notified of 100% completion.
///
/// For Java worlds only, and only if the world area is within limits.
pub fn start_map_preview_generation(info: MapPreviewInfo) {
if info.world_area > MAX_MAP_PREVIEW_AREA {
return;
}
std::thread::spawn(move || {
// Use catch_unwind to prevent any panic from affecting the application
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
map_renderer::render_world_map(
&info.world_path,
info.min_x,
info.max_x,
info.min_z,
info.max_z,
)
}));
match result {
Ok(Ok(_path)) => {
// Notify the GUI that the map preview is ready
emit_map_preview_ready();
}
Ok(Err(e)) => {
eprintln!("Warning: Failed to generate map preview: {}", e);
}
Err(_) => {
eprintln!("Warning: Map preview generation panicked unexpectedly");
}
}
});
}

127
src/deterministic_rng.rs Normal file
View File

@@ -0,0 +1,127 @@
//! Deterministic random number generation for consistent element processing.
//!
//! This module provides seeded RNG that ensures the same element always produces
//! the same random values, regardless of processing order. This is essential for
//! region-by-region streaming where the same element may be processed multiple times
//! (once for each region it touches).
//!
//! # Example
//! ```ignore
//! let mut rng = element_rng(element_id);
//! let color = rng.random_bool(0.5); // Always same result for same element_id
//! ```
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
/// Creates a deterministic RNG seeded from an element ID.
///
/// The same element ID will always produce the same sequence of random values,
/// ensuring consistent results when an element is processed multiple times
/// (e.g., once per region it touches during streaming).
///
/// # Arguments
/// * `element_id` - The unique OSM element ID (way ID, node ID, or relation ID)
///
/// # Returns
/// A seeded ChaCha8Rng that will produce deterministic random values
#[inline]
pub fn element_rng(element_id: u64) -> ChaCha8Rng {
ChaCha8Rng::seed_from_u64(element_id)
}
/// Creates a deterministic RNG seeded from an element ID with an additional salt.
///
/// Use this when you need multiple independent random sequences for the same element.
/// For example, one sequence for wall colors and another for roof style.
///
/// # Arguments
/// * `element_id` - The unique OSM element ID
/// * `salt` - Additional value to create a different sequence (e.g., use different
/// salt values for different purposes within the same element)
#[inline]
#[allow(dead_code)]
pub fn element_rng_salted(element_id: u64, salt: u64) -> ChaCha8Rng {
// Combine element_id and salt using XOR and bit rotation to avoid collisions
let combined = element_id ^ salt.rotate_left(32);
ChaCha8Rng::seed_from_u64(combined)
}
/// Creates a deterministic RNG seeded from coordinates.
///
/// Use this for per-block randomness that needs to be consistent regardless
/// of processing order (e.g., random flower placement within a natural area).
///
/// # Arguments
/// * `x` - X coordinate
/// * `z` - Z coordinate
/// * `element_id` - The element ID for additional uniqueness
#[inline]
pub fn coord_rng(x: i32, z: i32, element_id: u64) -> ChaCha8Rng {
// Combine coordinates and element_id into a seed.
// Cast through u32 to handle negative coordinates consistently.
let coord_part = ((x as u32 as i64) << 32) | (z as u32 as i64);
let seed = (coord_part as u64) ^ element_id;
ChaCha8Rng::seed_from_u64(seed)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::Rng;
#[test]
fn test_element_rng_deterministic() {
let mut rng1 = element_rng(12345);
let mut rng2 = element_rng(12345);
// Same seed should produce same sequence
for _ in 0..100 {
assert_eq!(rng1.random::<u64>(), rng2.random::<u64>());
}
}
#[test]
fn test_different_elements_different_values() {
let mut rng1 = element_rng(12345);
let mut rng2 = element_rng(12346);
// Different seeds should (almost certainly) produce different values
let v1: u64 = rng1.random();
let v2: u64 = rng2.random();
assert_ne!(v1, v2);
}
#[test]
fn test_salted_rng_different_from_base() {
let mut rng1 = element_rng(12345);
let mut rng2 = element_rng_salted(12345, 1);
let v1: u64 = rng1.random();
let v2: u64 = rng2.random();
assert_ne!(v1, v2);
}
#[test]
fn test_coord_rng_deterministic() {
let mut rng1 = coord_rng(100, 200, 12345);
let mut rng2 = coord_rng(100, 200, 12345);
assert_eq!(rng1.random::<u64>(), rng2.random::<u64>());
}
#[test]
fn test_coord_rng_negative_coordinates() {
// Negative coordinates are common in Minecraft worlds
let mut rng1 = coord_rng(-100, -200, 12345);
let mut rng2 = coord_rng(-100, -200, 12345);
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.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

@@ -2,11 +2,24 @@ use crate::args::Args;
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::coordinate_system::cartesian::XZPoint;
use crate::floodfill::flood_fill_area;
use crate::deterministic_rng::element_rng;
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 fastnbt::Value;
use rand::{
prelude::{IndexedRandom, SliceRandom},
Rng,
};
use std::collections::{HashMap, HashSet};
pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement, args: &Args) {
pub fn generate_amenities(
editor: &mut WorldEditor,
element: &ProcessedElement,
args: &Args,
flood_fill_cache: &FloodFillCache,
) {
// 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 {
@@ -26,6 +39,49 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
.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 {
@@ -42,18 +98,14 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
let ground_block: Block = OAK_PLANKS;
let roof_block: Block = STONE_BLOCK_SLAB;
let polygon_coords: Vec<(i32, i32)> = element
.nodes()
.map(|n: &crate::osm_parser::ProcessedNode| (n.x, n.z))
.collect();
// Use pre-computed flood fill from cache
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute_element(element, args.timeout.as_ref());
if polygon_coords.is_empty() {
if floor_area.is_empty() {
return;
}
let floor_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
// Fill the floor area
for (x, z) in floor_area.iter() {
editor.set_block(ground_block, *x, 0, *z, None, None);
@@ -80,8 +132,10 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
"bench" => {
// Place a bench
if let Some(pt) = first_node {
// 50% chance to 90 degrees rotate the bench using if
if rand::random::<bool>() {
// 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.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);
@@ -95,12 +149,9 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
"shelter" => {
let roof_block: Block = STONE_BRICK_SLAB;
let polygon_coords: Vec<(i32, i32)> = element
.nodes()
.map(|n: &crate::osm_parser::ProcessedNode| (n.x, n.z))
.collect();
// Use pre-computed flood fill from cache
let roof_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
flood_fill_cache.get_or_compute_element(element, args.timeout.as_ref());
// Place fences and roof slabs at each corner node directly
for node in element.nodes() {
@@ -260,3 +311,423 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
}
}
}
#[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

@@ -3,37 +3,97 @@ use crate::bresenham::bresenham_line;
use crate::osm_parser::ProcessedWay;
use crate::world_editor::WorldEditor;
// TODO FIX
// TODO FIX - This handles ways with bridge=yes tag (e.g., highway bridges)
#[allow(dead_code)]
pub fn generate_bridges(editor: &mut WorldEditor, element: &ProcessedWay) {
if let Some(_bridge_type) = element.tags.get("bridge") {
let bridge_height = 3; // Fixed height
let bridge_height = 3; // Height above the ground level
// Get start and end node elevations and use MAX for level bridge deck
// Using MAX ensures bridges don't dip when multiple bridge ways meet in a valley
let bridge_deck_ground_y = if element.nodes.len() >= 2 {
let start_node = &element.nodes[0];
let end_node = &element.nodes[element.nodes.len() - 1];
let start_y = editor.get_ground_level(start_node.x, start_node.z);
let end_y = editor.get_ground_level(end_node.x, end_node.z);
start_y.max(end_y)
} else {
return; // Need at least 2 nodes for a bridge
};
// Calculate total bridge length for ramp positioning
let total_length: f64 = element
.nodes
.windows(2)
.map(|pair| {
let dx = (pair[1].x - pair[0].x) as f64;
let dz = (pair[1].z - pair[0].z) as f64;
(dx * dx + dz * dz).sqrt()
})
.sum();
if total_length == 0.0 {
return;
}
let mut accumulated_length: f64 = 0.0;
for i in 1..element.nodes.len() {
let prev = &element.nodes[i - 1];
let cur = &element.nodes[i];
let segment_dx = (cur.x - prev.x) as f64;
let segment_dz = (cur.z - prev.z) as f64;
let segment_length = (segment_dx * segment_dx + segment_dz * segment_dz).sqrt();
let points = bresenham_line(prev.x, 0, prev.z, cur.x, 0, cur.z);
let total_length = points.len();
let ramp_length = 6; // Length of ramp at each end
let ramp_length = (total_length * 0.15).clamp(6.0, 20.0) as usize; // 15% of bridge, min 6, max 20 blocks
for (idx, (x, _, z)) in points.iter().enumerate() {
let height = if idx < ramp_length {
// Calculate progress along this segment
let segment_progress = if points.len() > 1 {
idx as f64 / (points.len() - 1) as f64
} else {
0.0
};
// Calculate overall progress along the entire bridge
let point_distance = accumulated_length + segment_progress * segment_length;
let overall_progress = (point_distance / total_length).clamp(0.0, 1.0);
let total_len_usize = total_length as usize;
let overall_idx = (overall_progress * total_len_usize as f64) as usize;
// Calculate ramp height offset
let ramp_offset = if overall_idx < ramp_length {
// Start ramp (rising)
(idx * bridge_height) / ramp_length
} else if idx >= total_length - ramp_length {
(overall_idx as f64 * bridge_height as f64 / ramp_length as f64) as i32
} else if overall_idx >= total_len_usize.saturating_sub(ramp_length) {
// End ramp (descending)
((total_length - idx) * bridge_height) / ramp_length
let dist_from_end = total_len_usize - overall_idx;
(dist_from_end as f64 * bridge_height as f64 / ramp_length as f64) as i32
} else {
// Middle section (constant height)
bridge_height
};
// Use fixed bridge deck height (max of endpoints) plus ramp offset
let bridge_y = bridge_deck_ground_y + ramp_offset;
// Place bridge blocks
for dx in -2..=2 {
editor.set_block(LIGHT_GRAY_CONCRETE, *x + dx, height as i32, *z, None, None);
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
*x + dx,
bridge_y,
*z,
None,
None,
);
}
}
accumulated_length += segment_length;
}
}
}

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

@@ -2,24 +2,36 @@ use crate::args::Args;
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::coordinate_system::cartesian::XZPoint;
use crate::floodfill::flood_fill_area;
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedElement, ProcessedWay};
use crate::world_editor::WorldEditor;
use std::collections::HashMap;
/// Type alias for highway connectivity map
pub type HighwayConnectivityMap = HashMap<(i32, i32), Vec<i32>>;
/// Minimum terrain dip (in blocks) below max endpoint elevation to classify a bridge as valley-spanning
const VALLEY_BRIDGE_THRESHOLD: i32 = 7;
/// Generates highways with elevation support based on layer tags and connectivity analysis
pub fn generate_highways(
editor: &mut WorldEditor,
element: &ProcessedElement,
args: &Args,
all_elements: &[ProcessedElement],
highway_connectivity: &HighwayConnectivityMap,
flood_fill_cache: &FloodFillCache,
) {
let highway_connectivity = build_highway_connectivity_map(all_elements);
generate_highways_internal(editor, element, args, &highway_connectivity);
generate_highways_internal(
editor,
element,
args,
highway_connectivity,
flood_fill_cache,
);
}
/// Build a connectivity map for highway endpoints to determine where slopes are needed
fn build_highway_connectivity_map(elements: &[ProcessedElement]) -> HashMap<(i32, i32), Vec<i32>> {
/// Build a connectivity map for highway endpoints to determine where slopes are needed.
pub fn build_highway_connectivity_map(elements: &[ProcessedElement]) -> HighwayConnectivityMap {
let mut connectivity_map: HashMap<(i32, i32), Vec<i32>> = HashMap::new();
for element in elements {
@@ -64,6 +76,7 @@ fn generate_highways_internal(
element: &ProcessedElement,
args: &Args,
highway_connectivity: &HashMap<(i32, i32), Vec<i32>>, // Maps node coordinates to list of layers that connect to this node
flood_fill_cache: &FloodFillCache,
) {
if let Some(highway_type) = element.tags().get("highway") {
if highway_type == "street_lamp" {
@@ -135,14 +148,9 @@ fn generate_highways_internal(
};
}
// Fill the area using flood fill or by iterating through the nodes
let polygon_coords: Vec<(i32, i32)> = way
.nodes
.iter()
.map(|n: &crate::osm_parser::ProcessedNode| (n.x, n.z))
.collect();
// Fill the area using flood fill cache
let filled_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
flood_fill_cache.get_or_compute(way, args.timeout.as_ref());
for (x, z) in filled_area {
editor.set_block(surface_block, x, 0, z, None, None);
@@ -155,15 +163,31 @@ fn generate_highways_internal(
let mut add_outline = false;
let scale_factor = args.scale;
// 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.)
// 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") {
@@ -244,6 +268,7 @@ fn generate_highways_internal(
let base_elevation = layer_value * LAYER_HEIGHT_STEP;
// Check if we need slopes at start and end
// This is used for overpasses that need ramps to ground-level roads
let needs_start_slope =
should_add_slope_at_node(&way.nodes[0], layer_value, highway_connectivity);
let needs_end_slope = should_add_slope_at_node(
@@ -252,10 +277,67 @@ fn generate_highways_internal(
highway_connectivity,
);
// Calculate total way length for slope distribution
// Calculate total way length for slope distribution (needed before valley bridge check)
let total_way_length = calculate_way_length(way);
// Check if this is a short isolated elevated segment - if so, treat as ground level
// For bridges: detect if this spans a valley by checking terrain profile
// A valley bridge has terrain that dips significantly below the endpoints
// Skip valley detection entirely if terrain is disabled (no valleys in flat terrain)
// Skip very short bridges (< 25 blocks) as they're unlikely to span significant valleys
let terrain_enabled = editor
.get_ground()
.map(|g| g.elevation_enabled)
.unwrap_or(false);
let (is_valley_bridge, bridge_deck_y) =
if is_bridge && terrain_enabled && way.nodes.len() >= 2 && total_way_length >= 25 {
let start_node = &way.nodes[0];
let end_node = &way.nodes[way.nodes.len() - 1];
let start_y = editor.get_ground_level(start_node.x, start_node.z);
let end_y = editor.get_ground_level(end_node.x, end_node.z);
let max_endpoint_y = start_y.max(end_y);
// Sample terrain at middle nodes only (excluding endpoints we already have)
// This avoids redundant get_ground_level() calls
let middle_nodes = &way.nodes[1..way.nodes.len().saturating_sub(1)];
let sampled_min = if middle_nodes.is_empty() {
// No middle nodes, just use endpoints
start_y.min(end_y)
} else {
// Sample up to 3 middle points (5 total with endpoints) for performance
// Valleys are wide terrain features, so sparse sampling is sufficient
let sample_count = middle_nodes.len().min(3);
let step = if sample_count > 1 {
(middle_nodes.len() - 1) / (sample_count - 1)
} else {
1
};
middle_nodes
.iter()
.step_by(step.max(1))
.map(|node| editor.get_ground_level(node.x, node.z))
.min()
.unwrap_or(max_endpoint_y)
};
// Include endpoint elevations in the minimum calculation
let min_terrain_y = sampled_min.min(start_y).min(end_y);
// If ANY sampled point along the bridge is significantly lower than the max endpoint,
// treat as valley bridge
let is_valley = min_terrain_y < max_endpoint_y - VALLEY_BRIDGE_THRESHOLD;
if is_valley {
(true, max_endpoint_y)
} else {
(false, 0)
}
} else {
(false, 0)
};
// Check if this is a short isolated elevated segment (layer > 0), if so, treat as ground level
let is_short_isolated_elevated =
needs_start_slope && needs_end_slope && layer_value > 0 && total_way_length <= 35;
@@ -292,17 +374,28 @@ fn generate_highways_internal(
let gap_length: i32 = (5.0 * scale_factor).ceil() as i32;
for (point_index, (x, _, z)) in bresenham_points.iter().enumerate() {
// Calculate Y elevation for this point based on slopes and layer
let current_y = calculate_point_elevation(
segment_index,
point_index,
segment_length,
total_segments,
effective_elevation,
effective_start_slope,
effective_end_slope,
slope_length,
);
// Calculate Y elevation for this point
// For valley bridges: use fixed deck height (max of endpoints) to stay level
// For overpasses and regular roads: use terrain-relative elevation with slopes
let (current_y, use_absolute_y) = if is_valley_bridge {
// Valley bridge deck is level at the maximum endpoint elevation
// Don't add base_elevation - the layer tag indicates it's above water/road,
// not that it should be higher than the terrain endpoints
(bridge_deck_y, true)
} else {
// Regular road or overpass: use terrain-relative calculation with ramps
let y = calculate_point_elevation(
segment_index,
point_index,
segment_length,
total_segments,
effective_elevation,
effective_start_slope,
effective_end_slope,
slope_length,
);
(y, false)
};
// Draw the road surface for the entire width
for dx in -block_range..=block_range {
@@ -318,12 +411,32 @@ fn generate_highways_internal(
let is_horizontal: bool = (x2 - x1).abs() >= (z2 - z1).abs();
if is_horizontal {
if set_x % 2 < 1 {
editor.set_block(
WHITE_CONCRETE,
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
BLACK_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
None,
);
} else {
@@ -337,12 +450,32 @@ fn generate_highways_internal(
);
}
} else if set_z % 2 < 1 {
editor.set_block(
WHITE_CONCRETE,
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
BLACK_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
None,
);
} else {
@@ -355,6 +488,15 @@ fn generate_highways_internal(
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
block_type,
set_x,
current_y,
set_z,
None,
Some(&[BLACK_CONCRETE, WHITE_CONCRETE]),
);
} else {
editor.set_block(
block_type,
@@ -366,30 +508,53 @@ fn generate_highways_internal(
);
}
// Add stone brick foundation underneath elevated highways for thickness
if effective_elevation > 0 && current_y > 0 {
// Add stone brick foundation underneath elevated highways/bridges for thickness
if (effective_elevation > 0 || use_absolute_y) && current_y > 0 {
// Add 1 layer of stone bricks underneath the highway surface
editor.set_block(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
} else {
editor.set_block(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
}
}
// Add support pillars for elevated highways
if effective_elevation != 0 && current_y > 0 {
add_highway_support_pillar(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
// Add support pillars for elevated highways/bridges
if (effective_elevation != 0 || use_absolute_y) && current_y > 0 {
if use_absolute_y {
add_highway_support_pillar_absolute(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
} else {
add_highway_support_pillar(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
}
}
}
}
@@ -400,27 +565,49 @@ fn generate_highways_internal(
for dz in -block_range..=block_range {
let outline_x = x - block_range - 1;
let outline_z = z + dz;
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
} else {
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
}
}
// Right outline
for dz in -block_range..=block_range {
let outline_x = x + block_range + 1;
let outline_z = z + dz;
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
} else {
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
}
}
}
@@ -429,14 +616,25 @@ fn generate_highways_internal(
if stripe_length < dash_length {
let stripe_x: i32 = *x;
let stripe_z: i32 = *z;
editor.set_block(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
}
// Increment stripe_length and reset after completing a dash and gap
@@ -580,6 +778,46 @@ fn add_highway_support_pillar(
}
}
/// Add support pillars for bridges using absolute Y coordinates
/// Pillars extend from ground level up to the bridge deck
fn add_highway_support_pillar_absolute(
editor: &mut WorldEditor,
x: i32,
bridge_deck_y: i32,
z: i32,
dx: i32,
dz: i32,
_block_range: i32, // Keep for future use
) {
// Only add pillars at specific intervals and positions
if dx == 0 && dz == 0 && (x + z) % 8 == 0 {
// Get the actual ground level at this position
let ground_y = editor.get_ground_level(x, z);
// Add pillar from ground up to bridge deck
// Only if the bridge is actually above the ground
if bridge_deck_y > ground_y {
for y in (ground_y + 1)..bridge_deck_y {
editor.set_block_absolute(STONE_BRICKS, x, y, z, None, None);
}
// Add pillar base at ground level
for base_dx in -1..=1 {
for base_dz in -1..=1 {
editor.set_block_absolute(
STONE_BRICKS,
x + base_dx,
ground_y,
z + base_dz,
None,
None,
);
}
}
}
}
}
/// Generates a siding using stone brick slabs
pub fn generate_siding(editor: &mut WorldEditor, element: &ProcessedWay) {
let mut previous_node: Option<XZPoint> = None;

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,16 +1,28 @@
use crate::args::Args;
use crate::block_definitions::*;
use crate::element_processing::tree::Tree;
use crate::floodfill::flood_fill_area;
use crate::bresenham::bresenham_line;
use crate::deterministic_rng::element_rng;
use crate::element_processing::tree::{Tree, TreeType};
use crate::floodfill_cache::{BuildingFootprintBitmap, FloodFillCache};
use crate::osm_parser::{ProcessedMemberRole, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::prelude::IndexedRandom;
use rand::Rng;
pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args: &Args) {
pub fn generate_landuse(
editor: &mut WorldEditor,
element: &ProcessedWay,
args: &Args,
flood_fill_cache: &FloodFillCache,
building_footprints: &BuildingFootprintBitmap,
) {
// Determine block type based on landuse tag
let binding: String = "".to_string();
let landuse_tag: &String = element.tags.get("landuse").unwrap_or(&binding);
// Use deterministic RNG seeded by element ID for consistent results across region boundaries
let mut rng = element_rng(element.id);
let block_type = match landuse_tag.as_str() {
"greenfield" | "meadow" | "grass" | "orchard" | "forest" => GRASS_BLOCK,
"farmland" => FARMLAND,
@@ -22,13 +34,13 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
if residential_tag == "rural" {
GRASS_BLOCK
} else {
STONE_BRICKS
STONE_BRICKS // Placeholder, will be randomized per-block
}
}
"commercial" => SMOOTH_STONE,
"commercial" => SMOOTH_STONE, // Placeholder, will be randomized per-block
"education" => POLISHED_ANDESITE,
"religious" => POLISHED_ANDESITE,
"industrial" => COBBLESTONE,
"industrial" => STONE, // Placeholder, will be randomized per-block
"military" => GRAY_CONCRETE,
"railway" => GRAVEL,
"landfill" => {
@@ -44,30 +56,90 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
_ => GRASS_BLOCK,
};
// Get the area of the landuse element
let polygon_coords: Vec<(i32, i32)> = element.nodes.iter().map(|n| (n.x, n.z)).collect();
let floor_area: Vec<(i32, i32)> = flood_fill_area(&polygon_coords, args.timeout.as_ref());
// Get the area of the landuse element using cache
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
let mut rng: rand::prelude::ThreadRng = rand::thread_rng();
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 {
if landuse_tag == "traffic_island" {
editor.set_block(block_type, x, 1, z, None, None);
} else if landuse_tag == "construction" || landuse_tag == "railway" {
editor.set_block(block_type, x, 0, z, None, Some(&[SPONGE]));
// 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.random_range(0..100);
if random_value < 72 {
STONE_BRICKS
} else if random_value < 87 {
CRACKED_STONE_BRICKS
} else if random_value < 92 {
STONE
} else {
COBBLESTONE
}
} else if landuse_tag == "commercial" {
// Commercial: mix of smooth stone, stone, cobblestone, stone bricks
let random_value = rng.random_range(0..100);
if random_value < 40 {
SMOOTH_STONE
} else if random_value < 70 {
STONE_BRICKS
} else if random_value < 90 {
STONE
} else {
COBBLESTONE
}
} else if landuse_tag == "industrial" {
// Industrial: primarily stone, with some stone bricks and smooth stone
let random_value = rng.random_range(0..100);
if random_value < 70 {
STONE
} else if random_value < 90 {
STONE_BRICKS
} else {
SMOOTH_STONE
}
} else {
editor.set_block(block_type, x, 0, z, None, None);
block_type
};
if landuse_tag == "traffic_island" {
editor.set_block(actual_block, x, 1, z, None, None);
} else if landuse_tag == "construction" || landuse_tag == "railway" {
editor.set_block(actual_block, x, 0, z, None, Some(&[SPONGE]));
} else {
editor.set_block(actual_block, x, 0, z, None, None);
}
// Add specific features for different landuse types
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);
@@ -84,28 +156,46 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
editor.set_block(RED_FLOWER, x, 1, z, None, None);
}
} else if random_choice < 33 {
Tree::create(editor, (x, 1, z));
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));
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 {
editor.set_block(GRASS, x, 1, z, None, None);
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);
}
}
}
}
@@ -115,8 +205,8 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
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 {
@@ -125,14 +215,14 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
} 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 {
@@ -168,7 +258,7 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
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,
@@ -205,43 +295,51 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
}
"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..=170 => editor.set_block(GRASS, 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),
_ => {}
}
}
}
"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..=17 => editor.set_block(GRASS, x, 1, z, None, None),
1..=2 => editor.set_block(FERN, x, 1, z, None, None),
3..=16 => editor.set_block(GRASS, x, 1, z, None, None),
_ => {}
}
}
}
"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));
Tree::create(editor, (x, 1, z), Some(building_footprints));
} else if random_choice < 6 {
editor.set_block(RED_FLOWER, x, 1, z, None, None);
} else if random_choice < 9 {
editor.set_block(OAK_LEAVES, x, 1, z, None, None);
} else if random_choice < 800 {
} else if random_choice < 40 {
editor.set_block(FERN, x, 1, z, None, None);
} 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);
}
}
}
"orchard" => {
if x % 18 == 0 && z % 10 == 0 {
Tree::create(editor, (x, 1, z));
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..=20 => editor.set_block(GRASS, 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),
_ => {}
}
}
@@ -260,7 +358,8 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
"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);
}
@@ -269,40 +368,83 @@ pub fn generate_landuse(editor: &mut WorldEditor, element: &ProcessedWay, args:
_ => {}
}
}
// 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(
editor: &mut WorldEditor,
rel: &ProcessedRelation,
args: &Args,
flood_fill_cache: &FloodFillCache,
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 {
generate_landuse(editor, &member.way.clone(), args);
// 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,
&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);
}
}
}
/// 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

@@ -1,13 +1,20 @@
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::floodfill::flood_fill_area;
use crate::floodfill_cache::{BuildingFootprintBitmap, FloodFillCache};
use crate::osm_parser::{ProcessedMemberRole, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
use rand::Rng;
pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args: &Args) {
pub fn generate_leisure(
editor: &mut WorldEditor,
element: &ProcessedWay,
args: &Args,
flood_fill_cache: &FloodFillCache,
building_footprints: &BuildingFootprintBitmap,
) {
if let Some(leisure_type) = element.tags.get("leisure") {
let mut previous_node: Option<(i32, i32)> = None;
let mut corner_addup: (i32, i32, i32) = (0, 0, 0);
@@ -74,15 +81,13 @@ pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args:
previous_node = Some((node.x, node.z));
}
// Flood-fill the interior of the leisure area
// Flood-fill the interior of the leisure area using cache
if corner_addup != (0, 0, 0) {
let polygon_coords: Vec<(i32, i32)> = element
.nodes
.iter()
.map(|n: &crate::osm_parser::ProcessedNode| (n.x, n.z))
.collect();
let filled_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
// Use deterministic RNG seeded by element ID for consistent results across region boundaries
let mut rng = element_rng(element.id);
for (x, z) in filled_area {
editor.set_block(block_type, x, 0, z, Some(&[GRASS_BLOCK]), None);
@@ -91,19 +96,20 @@ pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args:
if matches!(leisure_type.as_str(), "park" | "garden" | "nature_reserve")
&& editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK]))
{
let mut rng: rand::prelude::ThreadRng = rand::thread_rng();
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
@@ -115,7 +121,7 @@ pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args:
}
105..120 => {
// Tree
Tree::create(editor, (x, 1, z));
Tree::create(editor, (x, 1, z), Some(building_footprints));
}
_ => {}
}
@@ -123,8 +129,7 @@ pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args:
// Add playground or recreation ground features
if matches!(leisure_type.as_str(), "playground" | "recreation_ground") {
let mut rng: rand::prelude::ThreadRng = rand::thread_rng();
let random_choice: i32 = rng.gen_range(0..5000);
let random_choice: i32 = rng.random_range(0..5000);
match random_choice {
0..10 => {
@@ -176,31 +181,30 @@ pub fn generate_leisure_from_relation(
editor: &mut WorldEditor,
rel: &ProcessedRelation,
args: &Args,
flood_fill_cache: &FloodFillCache,
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 {
generate_leisure(editor, &member.way, args);
// 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,
&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);
}
}

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

@@ -1,20 +1,86 @@
use crate::args::Args;
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::element_processing::tree::Tree;
use crate::floodfill::flood_fill_area;
use crate::deterministic_rng::element_rng;
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, element: &ProcessedElement, args: &Args) {
pub fn generate_natural(
editor: &mut WorldEditor,
element: &ProcessedElement,
args: &Args,
flood_fill_cache: &FloodFillCache,
building_footprints: &BuildingFootprintBitmap,
) {
if let Some(natural_type) = element.tags().get("natural") {
if natural_type == "tree" {
if let ProcessedElement::Node(node) = element {
let x: i32 = node.x;
let z: i32 = node.z;
Tree::create(editor, (x, 1, z));
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;
@@ -69,17 +135,36 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
previous_node = Some((x, z));
}
// If there are natural nodes, flood-fill the area
// If there are natural nodes, flood-fill the area using cache
if corner_addup != (0, 0, 0) {
let polygon_coords: Vec<(i32, i32)> = way
.nodes
.iter()
.map(|n: &crate::osm_parser::ProcessedNode| (n.x, n.z))
.collect();
let filled_area: Vec<(i32, i32)> =
flood_fill_area(&polygon_coords, args.timeout.as_ref());
flood_fill_cache.get_or_compute(way, args.timeout.as_ref());
let mut rng: rand::prelude::ThreadRng = rand::thread_rng();
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);
for (x, z) in filled_area {
editor.set_block(block_type, x, 0, z, None, None);
@@ -107,7 +192,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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);
}
}
@@ -115,7 +200,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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);
@@ -130,11 +215,11 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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));
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,
@@ -159,11 +244,19 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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));
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,
@@ -176,13 +269,13 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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);
}
}
@@ -190,14 +283,14 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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,
@@ -218,15 +311,19 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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, (x, 1, z));
Tree::create(
editor,
(x, 1, z),
Some(building_footprints),
);
} else if random_choice < 35 {
editor.set_block(GRASS, x, 1, z, None, None);
}
}
"bog" => {
if rng.gen_bool(0.2) {
if rng.random_bool(0.2) {
editor.set_block(
MOSS_BLOCK,
x,
@@ -236,7 +333,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
None,
);
}
if rng.gen_bool(0.15) {
if rng.random_bool(0.15) {
editor.set_block(GRASS, x, 1, z, None, None);
}
}
@@ -249,7 +346,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
} 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;
}
@@ -258,11 +355,11 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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,
@@ -273,7 +370,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
};
// 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) {
@@ -286,7 +383,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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,
@@ -298,12 +395,14 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
// 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(
editor,
(cluster_x, 1, cluster_z),
Some(building_footprints),
);
} else if vegetation_chance < 15 {
// 15% chance for grass
@@ -327,7 +426,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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);
@@ -337,7 +436,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
} 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);
}
@@ -345,10 +444,10 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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,
@@ -358,7 +457,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
} 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);
@@ -378,7 +477,7 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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);
@@ -393,10 +492,10 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
}
"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,
@@ -413,13 +512,13 @@ pub fn generate_natural(editor: &mut WorldEditor, element: &ProcessedElement, ar
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));
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,
@@ -448,34 +547,30 @@ pub fn generate_natural_from_relation(
editor: &mut WorldEditor,
rel: &ProcessedRelation,
args: &Args,
flood_fill_cache: &FloodFillCache,
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 {
generate_natural(editor, &ProcessedElement::Way(member.way.clone()), args);
// 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(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);
}
}
}

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

@@ -1,4 +1,6 @@
use crate::block_definitions::*;
use crate::deterministic_rng::coord_rng;
use crate::floodfill_cache::BuildingFootprintBitmap;
use crate::world_editor::WorldEditor;
use rand::Rng;
@@ -81,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.
@@ -90,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?
@@ -107,7 +140,49 @@ pub struct Tree<'a> {
}
impl Tree<'_> {
pub fn create(editor: &mut WorldEditor, (x, y, z): Coord) {
/// Creates a tree at the specified coordinates.
///
/// # Arguments
/// * `editor` - The world editor to place blocks
/// * `(x, y, z)` - The base coordinates for the tree
/// * `building_footprints` - Optional bitmap of (x, z) coordinates that are inside buildings.
/// If provided, trees will not be placed at coordinates within this bitmap.
pub fn create(
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 {
if footprints.contains(x, z) {
return;
}
}
let mut blacklist: Vec<Block> = Vec::new();
blacklist.extend(Self::get_building_wall_blocks());
blacklist.extend(Self::get_building_floor_blocks());
@@ -115,14 +190,7 @@ impl Tree<'_> {
blacklist.extend(Self::get_functional_blocks());
blacklist.push(WATER);
let mut rng = rand::thread_rng();
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(
@@ -179,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![]],
},
@@ -193,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
@@ -328,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,28 +1,30 @@
use geo::{Contains, Intersects, LineString, Point, Polygon, Rect};
use std::time::Instant;
use crate::clipping::clip_water_ring_to_bbox;
use crate::{
block_definitions::WATER,
coordinate_system::cartesian::XZPoint,
coordinate_system::cartesian::{XZBBox, XZPoint},
osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation, ProcessedWay},
world_editor::WorldEditor,
};
pub fn generate_water_area_from_way(editor: &mut WorldEditor, element: &ProcessedWay) {
let start_time = Instant::now();
pub fn generate_water_area_from_way(
editor: &mut WorldEditor,
element: &ProcessedWay,
_xzbbox: &XZBBox,
) {
let outers = [element.nodes.clone()];
if !verify_loopy_loops(&outers) {
if !verify_closed_rings(&outers) {
println!("Skipping way {} due to invalid polygon", element.id);
return;
}
generate_water_areas(editor, &outers, &[], start_time);
generate_water_areas(editor, &outers, &[]);
}
pub fn generate_water_areas_from_relation(editor: &mut WorldEditor, element: &ProcessedRelation) {
let start_time = Instant::now();
pub fn generate_water_areas_from_relation(
editor: &mut WorldEditor,
element: &ProcessedRelation,
xzbbox: &XZBBox,
) {
// Check if this is a water relation (either with water tag or natural=water)
let is_water = element.tags.contains_key("water")
|| element
@@ -49,32 +51,107 @@ pub fn generate_water_areas_from_relation(editor: &mut WorldEditor, element: &Pr
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
}
}
merge_loopy_loops(&mut outers);
if !verify_loopy_loops(&outers) {
// Preserve OSM-defined outer/inner roles without modification
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();
super::merge_way_segments(&mut inners);
inners = inners
.into_iter()
.filter_map(|ring| clip_water_ring_to_bbox(&ring, xzbbox))
.collect();
if !verify_closed_rings(&outers) {
// For clipped multipolygons, some loops may not close perfectly
// Instead of force-closing with straight lines (which creates wedges),
// filter out unclosed loops and only render the properly closed ones
// Filter: Keep only loops that are already closed OR can be closed within 1 block
outers.retain(|loop_nodes| {
if loop_nodes.len() < 3 {
return false;
}
let first = &loop_nodes[0];
let last = loop_nodes.last().unwrap();
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
// Keep if already closed by ID or endpoints are within 1 block
first.id == last.id || (dx <= 1 && dz <= 1)
});
// Now close the remaining loops that are within 1 block tolerance
for loop_nodes in outers.iter_mut() {
let first = loop_nodes[0].clone();
let last_idx = loop_nodes.len() - 1;
if loop_nodes[0].id != loop_nodes[last_idx].id {
// Endpoints are close (within tolerance), close the loop
loop_nodes.push(first);
}
}
// If no valid outer loops remain, skip the relation
if outers.is_empty() {
return;
}
// Verify again after filtering and closing
if !verify_closed_rings(&outers) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
}
}
super::merge_way_segments(&mut inners);
if !verify_closed_rings(&inners) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
}
merge_loopy_loops(&mut inners);
if !verify_loopy_loops(&inners) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
}
generate_water_areas(editor, &outers, &inners, start_time);
generate_water_areas(editor, &outers, &inners);
}
fn generate_water_areas(
editor: &mut WorldEditor,
outers: &[Vec<ProcessedNode>],
inners: &[Vec<ProcessedNode>],
start_time: Instant,
) {
let (min_x, min_z) = editor.get_min_coords();
let (max_x, max_z) = editor.get_max_coords();
// Calculate polygon bounding box to limit fill area
let mut poly_min_x = i32::MAX;
let mut poly_min_z = i32::MAX;
let mut poly_max_x = i32::MIN;
let mut poly_max_z = i32::MIN;
for outer in outers {
for node in outer {
poly_min_x = poly_min_x.min(node.x);
poly_min_z = poly_min_z.min(node.z);
poly_max_x = poly_max_x.max(node.x);
poly_max_z = poly_max_z.max(node.z);
}
}
// If no valid bounds, nothing to fill
if poly_min_x == i32::MAX || poly_max_x == i32::MIN {
return;
}
// Clamp to world bounds just in case
let (world_min_x, world_min_z) = editor.get_min_coords();
let (world_max_x, world_max_z) = editor.get_max_coords();
let min_x = poly_min_x.max(world_min_x);
let min_z = poly_min_z.max(world_min_z);
let max_x = poly_max_x.min(world_max_x);
let max_z = poly_max_z.min(world_max_z);
let outers_xz: Vec<Vec<XZPoint>> = outers
.iter()
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
@@ -84,96 +161,25 @@ 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, start_time,
);
scanline_fill_water(min_x, min_z, max_x, max_z, &outers_xz, &inners_xz, editor);
}
// Merges ways that share nodes into full loops
fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
let mut removed: Vec<usize> = vec![];
let mut merged: Vec<Vec<ProcessedNode>> = vec![];
/// Verifies all rings are properly closed (first node matches last).
fn verify_closed_rings(rings: &[Vec<ProcessedNode>]) -> bool {
let mut valid = true;
for ring in rings {
let first = &ring[0];
let last = ring.last().unwrap();
for i in 0..loops.len() {
for j in 0..loops.len() {
if i == j {
continue;
}
// Check if ring is closed (by ID or proximity)
let is_closed = first.id == last.id || {
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
dx <= 1 && dz <= 1
};
if removed.contains(&i) || removed.contains(&j) {
continue;
}
let x: &Vec<ProcessedNode> = &loops[i];
let y: &Vec<ProcessedNode> = &loops[j];
// it's looped already
if x[0].id == x.last().unwrap().id {
continue;
}
// it's looped already
if y[0].id == y.last().unwrap().id {
continue;
}
if x[0].id == y[0].id {
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 x.last().unwrap().id == y.last().unwrap().id {
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 x[0].id == y.last().unwrap().id {
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 x.last().unwrap().id == y[0].id {
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() {
loops.remove(*r);
}
let merged_len: usize = merged.len();
for m in merged {
loops.push(m);
}
if merged_len > 0 {
merge_loopy_loops(loops);
}
}
fn verify_loopy_loops(loops: &[Vec<ProcessedNode>]) -> bool {
let mut valid: bool = true;
for l in loops {
if l[0].id != l.last().unwrap().id {
eprintln!("WARN: Disconnected loop");
if !is_closed {
eprintln!("WARN: Disconnected ring");
valid = false;
}
}
@@ -181,163 +187,248 @@ fn verify_loopy_loops(loops: &[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,
start_time: Instant,
) {
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![],
)
})
.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![],
)
})
.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,
start_time,
);
}
fn inverse_floodfill_recursive(
min: (i32, i32),
max: (i32, i32),
outers: &[Polygon],
inners: &[Polygon],
editor: &mut WorldEditor,
start_time: Instant,
) {
// Check if we've exceeded 25 seconds
if start_time.elapsed().as_secs() > 25 {
println!("Water area generation exceeded 25 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,
start_time,
);
}
}
}
// 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,11 +1,15 @@
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 std::path::Path;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
/// Maximum Y coordinate in Minecraft (build height limit)
const MAX_Y: i32 = 319;
/// Scale factor for converting real elevation to Minecraft heights
const BASE_HEIGHT_SCALE: f64 = 0.7;
/// AWS S3 Terrarium tiles endpoint (no API key required)
const AWS_TERRARIUM_URL: &str =
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png";
@@ -15,6 +19,10 @@ const TERRARIUM_OFFSET: f64 = 32768.0;
const MIN_ZOOM: u8 = 10;
/// Maximum zoom level for terrain tiles
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)]
@@ -27,6 +35,93 @@ pub struct ElevationData {
pub(crate) height: usize,
}
/// RGB image buffer type for elevation tiles
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();
@@ -44,28 +139,131 @@ 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,
tile_y: u32,
zoom: u8,
tile_path: &Path,
) -> Result<image::ImageBuffer<Rgb<u8>, Vec<u8>>, Box<dyn std::error::Error>> {
) -> Result<image::ImageBuffer<Rgb<u8>, Vec<u8>>, String> {
println!("Fetching tile x={tile_x},y={tile_y},z={zoom} from AWS Terrain Tiles");
let url: String = AWS_TERRARIUM_URL
.replace("{z}", &zoom.to_string())
.replace("{x}", &tile_x.to_string())
.replace("{y}", &tile_y.to_string());
let response: reqwest::blocking::Response = client.get(&url).send()?;
response.error_for_status_ref()?;
let bytes = response.bytes()?;
std::fs::write(tile_path, &bytes)?;
let img: image::DynamicImage = image::load_from_memory(&bytes)?;
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())?;
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
Ok(img.to_rgb8())
}
/// Fetches a tile from cache or downloads it if not available
/// Note: In parallel execution, multiple threads may attempt to download the same tile
/// if it's missing or corrupted. This is harmless (just wastes some bandwidth) as
/// file writes are atomic at the OS level.
fn fetch_or_load_tile(
client: &reqwest::blocking::Client,
tile_x: u32,
tile_y: u32,
zoom: u8,
tile_path: &Path,
) -> Result<image::ImageBuffer<Rgb<u8>, Vec<u8>>, String> {
if tile_path.exists() {
// Try to load cached tile, but handle corruption gracefully
match image::open(tile_path) {
Ok(img) => {
println!(
"Loading cached tile x={tile_x},y={tile_y},z={zoom} from {}",
tile_path.display()
);
Ok(img.to_rgb8())
}
Err(e) => {
eprintln!(
"Cached tile at {} is corrupted or invalid: {}. Re-downloading...",
tile_path.display(),
e
);
#[cfg(feature = "gui")]
send_log(
LogLevel::Warning,
"Cached tile is corrupted or invalid. Re-downloading...",
);
// Remove the 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 re-download.",
);
}
// Re-download the tile
download_tile(client, tile_x, tile_y, zoom, tile_path)
}
}
} else {
// Download the tile for the first time
download_tile(client, tile_x, tile_y, zoom, tile_path)
}
}
pub fn fetch_elevation_data(
bbox: &LLBBox,
scale: f64,
@@ -89,79 +287,64 @@ pub fn fetch_elevation_data(
let mut height_grid: Vec<Vec<f64>> = vec![vec![f64::NAN; grid_width]; grid_height];
let mut extreme_values_found = Vec::new(); // Track extreme values for debugging
let client: reqwest::blocking::Client = reqwest::blocking::Client::new();
let tile_cache_dir = Path::new("./arnis-tile-cache");
let tile_cache_dir = PathBuf::from("./arnis-tile-cache");
if !tile_cache_dir.exists() {
std::fs::create_dir_all(tile_cache_dir)?;
std::fs::create_dir_all(&tile_cache_dir)?;
}
// Fetch and process each tile
for (tile_x, tile_y) in &tiles {
// Check if tile is already cached
let tile_path = tile_cache_dir.join(format!("z{zoom}_x{tile_x}_y{tile_y}.png"));
// Create a shared HTTP client for connection pooling
let client = reqwest::blocking::Client::new();
let rgb_img: image::ImageBuffer<Rgb<u8>, Vec<u8>> = 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 = match std::fs::metadata(&tile_path) {
Ok(metadata) => metadata.len(),
Err(_) => 0,
};
// Download tiles in parallel with limited concurrency to be respectful to AWS
let num_tiles = tiles.len();
println!(
"Downloading {num_tiles} elevation tiles (up to {MAX_CONCURRENT_DOWNLOADS} concurrent)..."
);
if file_size < 1000 {
eprintln!("Warning: Cached tile at {} appears to be too small ({} bytes). Refetching tile.",
tile_path.display(), file_size);
// Use a custom thread pool to limit concurrent downloads
let thread_pool = rayon::ThreadPoolBuilder::new()
.num_threads(MAX_CONCURRENT_DOWNLOADS)
.build()
.map_err(|e| format!("Failed to create thread pool: {e}"))?;
// Remove the potentially corrupted file
if let Err(remove_err) = std::fs::remove_file(&tile_path) {
eprintln!(
"Warning: Failed to remove corrupted tile file: {}",
remove_err
);
}
let downloaded_tiles: Vec<TileDownloadResult> = thread_pool.install(|| {
tiles
.par_iter()
.map(|(tile_x, tile_y)| {
let tile_path = tile_cache_dir.join(format!("z{zoom}_x{tile_x}_y{tile_y}.png"));
// Re-download the tile
download_tile(&client, *tile_x, *tile_y, zoom, &tile_path)?
} else {
println!(
"Loading cached tile x={tile_x},y={tile_y},z={zoom} from {}",
tile_path.display()
);
let rgb_img = fetch_or_load_tile(&client, *tile_x, *tile_y, zoom, &tile_path)?;
Ok(((*tile_x, *tile_y), rgb_img))
})
.collect()
});
// Try to load cached tile, but handle corruption gracefully
match image::open(&tile_path) {
Ok(img) => img.to_rgb8(),
Err(e) => {
eprintln!("Warning: Cached tile at {} is corrupted or invalid: {}. Re-downloading...", tile_path.display(), e);
// Remove the corrupted file
if let Err(remove_err) = std::fs::remove_file(&tile_path) {
eprintln!(
"Warning: Failed to remove corrupted tile file: {}",
remove_err
);
}
// Re-download the tile
download_tile(&client, *tile_x, *tile_y, zoom, &tile_path)?
}
}
// Check for any download errors
let mut successful_tiles = Vec::new();
for result in downloaded_tiles {
match result {
Ok(tile_data) => successful_tiles.push(tile_data),
Err(e) => {
eprintln!("Warning: Failed to download tile: {e}");
}
} else {
// Download the tile for the first time
download_tile(&client, *tile_x, *tile_y, zoom, &tile_path)?
};
}
}
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 {
// Only process pixels that fall within the requested bbox
for (y, row) in rgb_img.rows().enumerate() {
for (x, pixel) in row.enumerate() {
// Convert tile pixel coordinates back to geographic coordinates
let pixel_lng = ((*tile_x as f64 + x as f64 / 256.0) / (2.0_f64.powi(zoom as i32)))
let pixel_lng = ((tile_x as f64 + x as f64 / 256.0) / (2.0_f64.powi(zoom as i32)))
* 360.0
- 180.0;
let pixel_lat_rad = std::f64::consts::PI
* (1.0
- 2.0 * (*tile_y as f64 + y as f64 / 256.0) / (2.0_f64.powi(zoom as i32)));
- 2.0 * (tile_y as f64 + y as f64 / 256.0) / (2.0_f64.powi(zoom as i32)));
let pixel_lat = pixel_lat_rad.sinh().atan().to_degrees();
// Skip pixels outside the requested bounding box
@@ -222,58 +405,61 @@ pub fn fetch_elevation_data(
filter_elevation_outliers(&mut height_grid);
// Calculate blur sigma based on grid resolution
// Reference points for tuning:
const SMALL_GRID_REF: f64 = 100.0; // Reference grid size
const SMALL_SIGMA_REF: f64 = 15.0; // Sigma for 100x100 grid
const LARGE_GRID_REF: f64 = 1000.0; // Reference grid size
const LARGE_SIGMA_REF: f64 = 7.0; // Sigma for 1000x1000 grid
// Use sqrt scaling to maintain consistent relative smoothing across different area sizes.
// This prevents larger generation areas from appearing noisier than smaller ones.
// Reference: 100x100 grid uses sigma=5 (5% relative blur)
const BASE_GRID_REF: f64 = 100.0;
const BASE_SIGMA_REF: f64 = 5.0;
let grid_size: f64 = (grid_width.min(grid_height) as f64).max(1.0);
let sigma: f64 = if grid_size <= SMALL_GRID_REF {
// Linear scaling for small grids
SMALL_SIGMA_REF * (grid_size / SMALL_GRID_REF)
} else {
// Logarithmic scaling for larger grids
let ln_small: f64 = SMALL_GRID_REF.ln();
let ln_large: f64 = LARGE_GRID_REF.ln();
let log_grid_size: f64 = grid_size.ln();
let t: f64 = (log_grid_size - ln_small) / (ln_large - ln_small);
SMALL_SIGMA_REF + t * (LARGE_SIGMA_REF - SMALL_SIGMA_REF)
};
// Sqrt scaling provides a good balance:
// - 100x100: sigma = 5 (5% relative)
// - 500x500: sigma ≈ 11.2 (2.2% relative)
// - 1000x1000: sigma ≈ 15.8 (1.6% relative)
// This smooths terrain proportionally while preserving more detail.
let sigma: f64 = BASE_SIGMA_REF * (grid_size / BASE_GRID_REF).sqrt();
/* eprintln!(
"Grid: {}x{}, Blur sigma: {:.2}",
grid_width, grid_height, sigma
); */
//let blur_percentage: f64 = (sigma / grid_size) * 100.0;
/*eprintln!(
"Elevation blur: grid={}x{}, sigma={:.2}, blur_percentage={:.2}%",
grid_width, grid_height, sigma, blur_percentage
);*/
// Continue with the existing blur and conversion to Minecraft heights...
let blurred_heights: Vec<Vec<f64>> = apply_gaussian_blur(&height_grid, sigma);
let mut mc_heights: Vec<Vec<i32>> = Vec::with_capacity(blurred_heights.len());
// Release raw height grid
drop(height_grid);
// 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");
//eprintln!("Height data range: {min_height} to {max_height} m");
if extreme_low_count > 0 {
eprintln!(
"WARNING: Found {extreme_low_count} pixels with extremely low elevations (< -1000m)"
@@ -286,39 +472,63 @@ pub fn fetch_elevation_data(
}
let height_range: f64 = max_height - min_height;
// Apply scale factor to height scaling
let mut height_scale: f64 = BASE_HEIGHT_SCALE * scale.sqrt(); // sqrt to make height scaling less extreme
let mut scaled_range: f64 = height_range * height_scale;
// Adaptive scaling: ensure we don't exceed reasonable Y range
let available_y_range = (MAX_Y - ground_level) as f64;
let safety_margin = 0.9; // Use 90% of available range
let max_allowed_range = available_y_range * safety_margin;
// Realistic height scaling: 1 meter of real elevation = scale blocks in Minecraft
// At scale=1.0, 1 meter = 1 block (realistic 1:1 mapping)
// At scale=2.0, 1 meter = 2 blocks (exaggerated for larger worlds)
let ideal_scaled_range: f64 = height_range * scale;
if scaled_range > max_allowed_range {
let adjustment_factor = max_allowed_range / scaled_range;
height_scale *= adjustment_factor;
scaled_range = height_range * height_scale;
// Calculate available Y range in Minecraft (from ground_level to MAX_Y)
// Leave a buffer at the top for buildings, trees, and other structures
const TERRAIN_HEIGHT_BUFFER: i32 = 15;
let available_y_range: f64 = (MAX_Y - TERRAIN_HEIGHT_BUFFER - ground_level) as f64;
// Determine final height scale:
// - Use realistic 1:1 (times scale) if terrain fits within Minecraft limits
// - Only compress if the terrain would exceed the build height
let scaled_range: f64 = if ideal_scaled_range <= available_y_range {
// Terrain fits! Use realistic scaling
eprintln!(
"Height range too large, applying scaling adjustment factor: {adjustment_factor:.3}"
"Realistic elevation: {:.1}m range fits in {} available blocks",
height_range, available_y_range as i32
);
eprintln!("Adjusted scaled range: {scaled_range:.1} blocks");
}
ideal_scaled_range
} else {
// Terrain too tall, compress to fit within Minecraft limits
let compression_factor: f64 = available_y_range / height_range;
let compressed_range: f64 = height_range * compression_factor;
eprintln!(
"Elevation compressed: {:.1}m range -> {:.0} blocks ({:.2}:1 ratio, 1 block = {:.2}m)",
height_range,
compressed_range,
height_range / compressed_range,
compressed_range / height_range
);
compressed_range
};
// Convert to scaled Minecraft Y coordinates
for row in blurred_heights {
let mc_row: Vec<i32> = row
.iter()
.map(|&h| {
// Scale the height differences
let relative_height: f64 = (h - min_height) / height_range;
let scaled_height: f64 = relative_height * scaled_range;
// With terrain enabled, ground_level is used as the MIN_Y for terrain
((ground_level as f64 + scaled_height).round() as i32).clamp(ground_level, MAX_Y)
})
.collect();
mc_heights.push(mc_row);
}
// Convert to scaled Minecraft Y coordinates (parallelized across rows)
// Lowest real elevation maps to ground_level, highest maps to ground_level + scaled_range
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;
@@ -328,7 +538,7 @@ pub fn fetch_elevation_data(
max_block_height = max_block_height.max(height);
}
}
eprintln!("Minecraft height data range: {min_block_height} to {max_block_height} blocks");
//eprintln!("Minecraft height data range: {min_block_height} to {max_block_height} blocks");
Ok(ElevationData {
heights: mc_heights,
@@ -355,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;
}
}
@@ -478,17 +701,24 @@ 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);
eprintln!("Filtering outliers outside range: {min_reasonable:.1}m to {max_reasonable:.1}m");
// 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");
let mut outliers_filtered = 0;
@@ -503,7 +733,7 @@ fn filter_elevation_outliers(height_grid: &mut [Vec<f64>]) {
}
if outliers_filtered > 0 {
eprintln!("Filtered {outliers_filtered} elevation outliers, interpolating replacements...");
//eprintln!("Filtered {outliers_filtered} elevation outliers, interpolating replacements...");
// Re-run the NaN filling to interpolate the filtered values
fill_nan_values(height_grid);
}

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;
@@ -68,14 +132,11 @@ fn optimized_flood_fill_area(
// Pre-allocate queue with reasonable capacity to avoid reallocations
let mut queue = VecDeque::with_capacity(1024);
let mut iterations = 0u64;
const MAX_ITERATIONS: u64 = 1_000_000; // Safety limit to prevent infinite loops
for z in (min_z..=max_z).step_by(step_z as usize) {
for x in (min_x..=max_x).step_by(step_x as usize) {
// Check timeout more frequently for small areas
#[allow(clippy::manual_is_multiple_of)]
if iterations % 50 == 0 {
// Fast timeout check, only every few iterations
if filled_area.len() % 100 == 0 {
if let Some(timeout) = timeout {
if start_time.elapsed() > *timeout {
return filled_area;
@@ -83,49 +144,17 @@ fn optimized_flood_fill_area(
}
}
// Safety check: prevent infinite loops
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_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() {
// Additional iteration check inside inner loop
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Timeout check in inner loop for problematic polygons
#[allow(clippy::manual_is_multiple_of)]
if iterations % 1000 == 0 {
if let Some(timeout) = timeout {
if start_time.elapsed() > *timeout {
return filled_area;
}
}
}
// Add current point to filled area
filled_area.push((curr_x, curr_z));
@@ -137,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));
}
}
}
@@ -169,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;
@@ -188,65 +217,29 @@ fn original_flood_fill_area(
// Pre-allocate queue and reserve space for filled_area
let mut queue: VecDeque<(i32, i32)> = VecDeque::with_capacity(2048);
filled_area.reserve(1000); // Reserve space to reduce reallocations
let mut iterations = 0u64;
const MAX_ITERATIONS: u64 = 1_000_000; // Safety limit to prevent infinite loops
// Scan for multiple seed points to handle U-shapes and concave polygons
for z in (min_z..=max_z).step_by(step_z as usize) {
for x in (min_x..=max_x).step_by(step_x as usize) {
// Check timeout more frequently for problematic polygons
#[allow(clippy::manual_is_multiple_of)]
if iterations % 50 == 0 {
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
// Reduced timeout checking frequency for better performance
// Use manual % check since is_multiple_of() is unstable on stable Rust
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
}
// Safety check: prevent infinite loops
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_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() {
// Additional iteration check inside inner loop
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Timeout check in inner loop
#[allow(clippy::manual_is_multiple_of)]
if iterations % 1000 == 0 {
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
}
}
// Only check polygon containment once per point when adding to filled_area
if polygon.contains(&Point::new(curr_x as f64, curr_z as f64)) {
filled_area.push((curr_x, curr_z));
@@ -259,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));
}
}
}

509
src/floodfill_cache.rs Normal file
View File

@@ -0,0 +1,509 @@
//! Pre-computed flood fill cache for parallel polygon filling.
//!
//! This module provides a way to pre-compute all flood fill operations in parallel
//! before the main element processing loop, then retrieve cached results during
//! sequential processing.
use crate::coordinate_system::cartesian::XZBBox;
use crate::floodfill::flood_fill_area;
use crate::osm_parser::{ProcessedElement, ProcessedMemberRole, ProcessedWay};
use fnv::FnvHashMap;
use rayon::prelude::*;
use std::time::Duration;
/// 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 CoordinateBitmap {
/// The bitmap data, where each bit represents one (x, z) coordinate
bits: Vec<u8>,
/// Minimum x coordinate (offset for indexing)
min_x: i32,
/// Minimum z coordinate (offset for indexing)
min_z: i32,
/// 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
count: usize,
}
impl CoordinateBitmap {
/// Creates a new empty bitmap covering the given world bounds.
pub fn new(xzbbox: &XZBBox) -> Self {
let min_x = xzbbox.min_x();
let min_z = xzbbox.min_z();
// Use i64 to avoid overflow when world spans more than i32::MAX in either dimension
let width = (i64::from(xzbbox.max_x()) - i64::from(min_x) + 1) as usize;
let height = (i64::from(xzbbox.max_z()) - i64::from(min_z) + 1) as usize;
// Calculate number of bytes needed (round up to nearest byte)
let total_bits = width
.checked_mul(height)
.expect("CoordinateBitmap: world size too large (width * height overflowed)");
let num_bytes = total_bits.div_ceil(8);
Self {
bits: vec![0u8; num_bytes],
min_x,
min_z,
width,
height,
count: 0,
}
}
/// Converts (x, z) coordinate to bit index, returning None if out of bounds.
#[inline]
fn coord_to_index(&self, x: i32, z: i32) -> Option<usize> {
// Use i64 arithmetic to avoid overflow when coordinates span large ranges
let local_x = i64::from(x) - i64::from(self.min_x);
let local_z = i64::from(z) - i64::from(self.min_z);
if local_x < 0 || local_z < 0 {
return None;
}
let local_x = local_x as usize;
let local_z = local_z as usize;
if local_x >= self.width || local_z >= self.height {
return None;
}
// Safe: bounds checks above ensure this won't overflow (max = total_bits - 1)
Some(local_z * self.width + local_x)
}
/// Sets a coordinate.
#[inline]
pub fn set(&mut self, x: i32, z: i32) {
if let Some(bit_index) = self.coord_to_index(x, z) {
let byte_index = bit_index / 8;
let bit_offset = bit_index % 8;
// Safety: coord_to_index already validates bounds, so byte_index is always valid
let mask = 1u8 << bit_offset;
// Only increment count if bit wasn't already set
if self.bits[byte_index] & mask == 0 {
self.bits[byte_index] |= mask;
self.count += 1;
}
}
}
/// 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) {
let byte_index = bit_index / 8;
let bit_offset = bit_index % 8;
// Safety: coord_to_index already validates bounds, so byte_index is always valid
return (self.bits[byte_index] >> bit_offset) & 1 == 1;
}
false
}
/// Returns true if no coordinates are marked.
#[must_use]
#[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
way_cache: FnvHashMap<u64, Vec<(i32, i32)>>,
}
impl FloodFillCache {
/// Creates an empty cache.
pub fn new() -> Self {
Self {
way_cache: FnvHashMap::default(),
}
}
/// Pre-computes flood fills for all elements that need them.
///
/// This runs in parallel using Rayon, taking advantage of multiple CPU cores.
pub fn precompute(elements: &[ProcessedElement], timeout: Option<&Duration>) -> Self {
// Collect all ways that need flood fill
let ways_needing_fill: Vec<&ProcessedWay> = elements
.iter()
.filter_map(|el| match el {
ProcessedElement::Way(way) => {
if Self::way_needs_flood_fill(way) {
Some(way)
} else {
None
}
}
_ => None,
})
.collect();
// Compute all way flood fills in parallel
let way_results: Vec<(u64, Vec<(i32, i32)>)> = ways_needing_fill
.par_iter()
.map(|way| {
let polygon_coords: Vec<(i32, i32)> =
way.nodes.iter().map(|n| (n.x, n.z)).collect();
let filled = flood_fill_area(&polygon_coords, timeout);
(way.id, filled)
})
.collect();
// Build the cache
let mut cache = Self::new();
for (id, filled) in way_results {
cache.way_cache.insert(id, filled);
}
cache
}
/// Gets cached flood fill result for a way, or computes it if not cached.
///
/// Note: Combined ways created from relations (e.g., in `generate_natural_from_relation`)
/// will miss the cache and fall back to on-demand computation. This is by design,
/// these synthetic ways don't exist in the original element list and have relation IDs
/// rather than way IDs. The individual member ways are still cached.
pub fn get_or_compute(
&self,
way: &ProcessedWay,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
if let Some(cached) = self.way_cache.get(&way.id) {
// Clone is intentional: each result is typically accessed once during
// sequential processing, so the cost is acceptable vs Arc complexity
cached.clone()
} else {
// Fallback: compute on demand for synthetic/combined ways from relations
let polygon_coords: Vec<(i32, i32)> = way.nodes.iter().map(|n| (n.x, n.z)).collect();
flood_fill_area(&polygon_coords, timeout)
}
}
/// Gets cached flood fill result for a ProcessedElement (Way only).
/// For Nodes/Relations, returns empty vec.
pub fn get_or_compute_element(
&self,
element: &ProcessedElement,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
match element {
ProcessedElement::Way(way) => self.get_or_compute(way, timeout),
_ => Vec::new(),
}
}
/// Determines if a way element needs flood fill based on its tags.
///
/// This checks for tag presence (not specific values) because:
/// - Only some values within each tag type actually use flood fill
/// - But caching extra results is harmless (small memory overhead)
/// - And avoids duplicating value-checking logic from processors
///
/// Covered cases:
/// - building/building:part -> buildings::generate_buildings (includes bridge)
/// - landuse -> landuse::generate_landuse
/// - leisure -> leisure::generate_leisure
/// - amenity -> amenities::generate_amenities
/// - natural (except tree) -> natural::generate_natural
/// - highway with area=yes -> highways::generate_highways (area fill)
fn way_needs_flood_fill(way: &ProcessedWay) -> bool {
way.tags.contains_key("building")
|| way.tags.contains_key("building:part")
|| way.tags.contains_key("landuse")
|| way.tags.contains_key("leisure")
|| way.tags.contains_key("amenity")
|| way
.tags
.get("natural")
.map(|v| v != "tree")
.unwrap_or(false)
// 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.
///
/// This should be called after precompute() and before elements are processed.
/// Returns a memory-efficient bitmap of all (x, z) coordinates that are part of buildings.
///
/// The bitmap uses only 1 bit per coordinate in the world bounds, compared to ~24 bytes
/// per entry in a HashSet, reducing memory usage by ~200x for large worlds.
pub fn collect_building_footprints(
&self,
elements: &[ProcessedElement],
xzbbox: &XZBBox,
) -> BuildingFootprintBitmap {
let mut footprints = BuildingFootprintBitmap::new(xzbbox);
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) {
for &(x, z) in cached {
footprints.set(x, z);
}
}
}
}
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 member in &rel.members {
// Only treat outer members as building footprints.
// Inner members represent courtyards/holes where trees can spawn.
if member.role == ProcessedMemberRole::Outer {
if let Some(cached) = self.way_cache.get(&member.way.id) {
for &(x, z) in cached {
footprints.set(x, z);
}
}
}
}
}
}
_ => {}
}
}
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.
pub fn remove_way(&mut self, way_id: u64) {
self.way_cache.remove(&way_id);
}
/// Removes all cached flood fill results for ways in a relation.
///
/// Relations contain multiple ways, so we need to remove all of them.
pub fn remove_relation_ways(&mut self, way_ids: &[u64]) {
for &id in way_ids {
self.way_cache.remove(&id);
}
}
}
impl Default for FloodFillCache {
fn default() -> Self {
Self::new()
}
}
/// Configures the global Rayon thread pool with a CPU usage cap.
///
/// Call this once at startup before any parallel operations.
///
/// # Arguments
/// * `cpu_fraction` - Fraction of available cores to use (e.g., 0.9 for 90%).
/// Values are clamped to the range [0.1, 1.0].
pub fn configure_rayon_thread_pool(cpu_fraction: f64) {
// Clamp cpu_fraction to valid range
let cpu_fraction = cpu_fraction.clamp(0.1, 1.0);
let available_cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
let target_threads = ((available_cores as f64) * cpu_fraction).floor() as usize;
let target_threads = target_threads.max(1); // At least 1 thread
// Only configure if we haven't already (this can only be called once)
match rayon::ThreadPoolBuilder::new()
.num_threads(target_threads)
.build_global()
{
Ok(()) => {
// Successfully configured (silent to avoid cluttering output)
}
Err(_) => {
// Thread pool already configured
}
}
}

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

@@ -1,22 +1,25 @@
use crate::args::Args;
use crate::coordinate_system::cartesian::XZPoint;
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::coordinate_system::geographic::{LLBBox, LLPoint};
use crate::coordinate_system::transformation::CoordTransformer;
use crate::data_processing;
use crate::data_processing::{self, GenerationOptions};
use crate::ground::{self, Ground};
use crate::map_transformation;
use crate::osm_parser;
use crate::progress;
use crate::progress::{self, emit_gui_progress_update};
use crate::retrieve_data;
use crate::telemetry::{self, send_log, LogLevel};
use crate::version_check;
use crate::world_editor::WorldFormat;
use colored::Colorize;
use fastnbt::Value;
use flate2::read::GzDecoder;
use fs2::FileExt;
use log::{error, LevelFilter};
use log::LevelFilter;
use rfd::FileDialog;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::{env, fs, io::Write, panic};
use std::{env, fs, io::Write};
use tauri_plugin_log::{Builder as LogBuilder, Target, TargetKind};
/// Manages the session.lock file for a Minecraft world directory
@@ -60,15 +63,17 @@ impl Drop for SessionLock {
}
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...");
// Set a custom panic hook to log panic information
panic::set_hook(Box::new(|panic_info| {
let message = format!("Application panicked: {panic_info:?}");
error!("{message}");
std::process::exit(1);
}));
// Install panic hook for crash reporting
telemetry::install_panic_hook();
// Workaround WebKit2GTK issue with NVIDIA drivers and graphics issues
// Source: https://github.com/tauri-apps/tauri/issues/10702
@@ -89,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()),
@@ -100,10 +105,15 @@ 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
gui_check_for_updates,
gui_get_world_map_data,
gui_show_in_folder
])
.setup(|app| {
let app_handle = app.handle();
@@ -116,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")
})
@@ -141,170 +153,75 @@ fn gui_select_world(generate_new: bool) -> Result<String, i32> {
None
};
if generate_new {
// Handle new world generation
if let Some(default_path) = &default_dir {
if default_path.exists() {
// Call create_new_world and return the result
create_new_world(default_path).map_err(|_| 1) // Error code 1: Minecraft directory not found
} else {
Err(1) // Error code 1: Minecraft directory not found
}
} else {
Err(1) // Error code 1: Minecraft directory not found
if let Some(saves_dir) = mc_saves {
if saves_dir.exists() {
return saves_dir;
}
} 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
}
// Fallback to Desktop
if let Some(desktop) = dirs::desktop_dir() {
if desktop.exists() {
return desktop;
}
}
// If no folder was selected, return an error message
Err(4) // Error code 4: No world selected
// 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
@@ -400,6 +317,11 @@ 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",
);
}
}
}
@@ -414,36 +336,20 @@ fn add_localized_world_name(world_path: PathBuf, bbox: &LLBBox) -> PathBuf {
world_path
}
// Function to update player position in level.dat based on spawn point coordinates
fn update_player_position(
/// Calculates the default spawn point at X=1, Z=1 relative to the world origin.
/// This is used when no spawn point is explicitly selected by the user.
fn calculate_default_spawn(xzbbox: &XZBBox) -> (i32, i32) {
(xzbbox.min_x() + 1, xzbbox.min_z() + 1)
}
/// Sets the player spawn point in level.dat using Minecraft XZ coordinates.
/// The Y coordinate is set to a temporary value (150) and will be updated
/// after terrain generation by `update_player_spawn_y_after_generation`.
fn set_player_spawn_in_level_dat(
world_path: &str,
spawn_point: Option<(f64, f64)>,
bbox_text: String,
scale: f64,
spawn_x: i32,
spawn_z: i32,
) -> Result<(), String> {
use crate::coordinate_system::transformation::CoordTransformer;
let Some((lat, lng)) = spawn_point else {
return Ok(()); // No spawn point selected, exit early
};
// Parse geometrical point and bounding box
let llpoint =
LLPoint::new(lat, lng).map_err(|e| format!("Failed to parse spawn point:\n{e}"))?;
let llbbox = LLBBox::from_str(&bbox_text)
.map_err(|e| format!("Failed to parse bounding box for spawn point:\n{e}"))?;
// Check if spawn point is within the bbox
if !llbbox.contains(&llpoint) {
return Err("Spawn point is outside the selected area".to_string());
}
// Convert lat/lng to Minecraft coordinates
let (transformer, _) = CoordTransformer::llbbox_to_xzbbox(&llbbox, scale)
.map_err(|e| format!("Failed to build transformation on coordinate systems:\n{e}"))?;
let xzpoint = transformer.transform_point(llpoint);
// Default y spawn position since terrain elevation cannot be determined yet
let y = 150.0;
@@ -475,21 +381,24 @@ fn update_player_position(
if let Value::Compound(ref mut root) = nbt_data {
if let Some(Value::Compound(ref mut data)) = root.get_mut("Data") {
// Set world spawn point
data.insert("SpawnX".to_string(), Value::Int(xzpoint.x));
data.insert("SpawnX".to_string(), Value::Int(spawn_x));
data.insert("SpawnY".to_string(), Value::Int(y as i32));
data.insert("SpawnZ".to_string(), Value::Int(xzpoint.z));
data.insert("SpawnZ".to_string(), Value::Int(spawn_z));
// Update player position
// Update player position if Player compound exists
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 pos_x) = pos.get_mut(0).unwrap() {
*pos_x = xzpoint.x as f64;
}
if let Value::Double(ref mut pos_y) = pos.get_mut(1).unwrap() {
*pos_y = y;
}
if let Value::Double(ref mut pos_z) = pos.get_mut(2).unwrap() {
*pos_z = xzpoint.z as f64;
// Safely update position values with bounds checking
if pos.len() >= 3 {
if let Some(Value::Double(ref mut pos_x)) = pos.get_mut(0) {
*pos_x = spawn_x as f64;
}
if let Some(Value::Double(ref mut pos_y)) = pos.get_mut(1) {
*pos_y = y;
}
if let Some(Value::Double(ref mut pos_z)) = pos.get_mut(2) {
*pos_z = spawn_z as f64;
}
}
}
}
@@ -521,19 +430,15 @@ fn update_player_position(
}
// Function to update player spawn Y coordinate based on terrain height after generation
// This updates the spawn Y coordinate to be at terrain height + 3 blocks
pub fn update_player_spawn_y_after_generation(
world_path: &Path,
spawn_point: Option<(f64, f64)>,
bbox_text: String,
scale: f64,
ground: &Ground,
) -> Result<(), String> {
use crate::coordinate_system::transformation::CoordTransformer;
let Some((_lat, _lng)) = spawn_point else {
return Ok(()); // No spawn point selected, exit early
};
// Read the current level.dat file to get existing spawn coordinates
let level_path = PathBuf::from(world_path).join("level.dat");
if !level_path.exists() {
@@ -602,7 +507,7 @@ pub fn update_player_spawn_y_after_generation(
let relative_z = existing_spawn_z - xzbbox.min_z();
let terrain_point = XZPoint::new(relative_x, relative_z);
ground.level(terrain_point) + 2
ground.level(terrain_point) + 3 // Add 3 blocks above terrain for safety
} else {
-61 // Default Y if no terrain
};
@@ -616,8 +521,8 @@ pub fn update_player_spawn_y_after_generation(
// Update player position - only Y coordinate
if let Some(Value::Compound(ref mut player)) = data.get_mut("Player") {
if let Some(Value::List(ref mut pos)) = player.get_mut("Pos") {
// Keep existing X and Z, only update Y
if let Value::Double(ref mut pos_y) = pos.get_mut(1).unwrap() {
// Safely update Y position with bounds checking
if let Some(Value::Double(ref mut pos_y)) = pos.get_mut(1) {
*pos_y = spawn_y as f64;
}
}
@@ -662,6 +567,129 @@ fn gui_check_for_updates() -> Result<bool, String> {
}
}
/// Returns the world map image data as base64 and geo bounds for overlay display.
/// Returns None if the map image or metadata doesn't exist.
#[tauri::command]
fn gui_get_world_map_data(world_path: String) -> Result<Option<WorldMapData>, String> {
let world_dir = PathBuf::from(&world_path);
let map_path = world_dir.join("arnis_world_map.png");
let metadata_path = world_dir.join("metadata.json");
// Check if both files exist
if !map_path.exists() || !metadata_path.exists() {
return Ok(None);
}
// Read and encode the map image as base64
let image_data = fs::read(&map_path).map_err(|e| format!("Failed to read map image: {e}"))?;
let base64_image =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &image_data);
// Read metadata
let metadata_content =
fs::read_to_string(&metadata_path).map_err(|e| format!("Failed to read metadata: {e}"))?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_content)
.map_err(|e| format!("Failed to parse metadata: {e}"))?;
// Extract geo bounds (metadata uses camelCase from serde)
let min_lat = metadata["minGeoLat"]
.as_f64()
.ok_or("Missing minGeoLat in metadata")?;
let max_lat = metadata["maxGeoLat"]
.as_f64()
.ok_or("Missing maxGeoLat in metadata")?;
let min_lon = metadata["minGeoLon"]
.as_f64()
.ok_or("Missing minGeoLon in metadata")?;
let max_lon = metadata["maxGeoLon"]
.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,
}))
}
/// Data structure for world map overlay
#[derive(serde::Serialize)]
struct WorldMapData {
image_base64: String,
min_lat: f64,
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)
#[tauri::command]
fn gui_show_in_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
// On Windows, try to open with default application (Minecraft Bedrock)
// If that fails, show in Explorer
if std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.spawn()
.is_err()
{
std::process::Command::new("explorer")
.args(["/select,", &path])
.spawn()
.map_err(|e| format!("Failed to open explorer: {}", e))?;
}
}
#[cfg(target_os = "macos")]
{
// On macOS, just reveal in Finder
std::process::Command::new("open")
.args(["-R", &path])
.spawn()
.map_err(|e| format!("Failed to open Finder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// On Linux, just show in file manager
let path_parent = std::path::Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
// Try nautilus with select first, then fall back to xdg-open on parent
if std::process::Command::new("nautilus")
.args(["--select", &path])
.spawn()
.is_err()
{
let _ = std::process::Command::new("xdg-open")
.arg(&path_parent)
.spawn();
}
}
Ok(())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
#[allow(unused_variables)]
@@ -670,60 +698,119 @@ fn gui_start_generation(
selected_world: String,
world_scale: f64,
ground_level: i32,
floodfill_timeout: u64,
terrain_enabled: bool,
skip_osm_objects: bool,
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,
world_format: String,
) -> Result<(), String> {
use progress::emit_gui_error;
use LLBBox;
// If spawn point was chosen and the world is new, check and set the spawn point
if is_new_world && spawn_point.is_some() {
// Verify the spawn point is within bounds
if let Some(coords) = spawn_point {
let llbbox = match LLBBox::from_str(&bbox_text) {
Ok(bbox) => bbox,
Err(e) => {
let error_msg = format!("Failed to parse bounding box: {e}");
eprintln!("{error_msg}");
emit_gui_error(&error_msg);
return Err(error_msg);
}
};
// Store telemetry consent for crash reporting
telemetry::set_telemetry_consent(telemetry_consent);
// Send generation click telemetry
telemetry::send_generation_click();
// For new Java worlds, set the spawn point in level.dat
// Only update player position for Java worlds - Bedrock worlds don't have a pre-existing
// level.dat to modify (the spawn point will be set when the .mcworld is created)
if is_new_world && world_format != "bedrock" {
let llbbox = match LLBBox::from_str(&bbox_text) {
Ok(bbox) => bbox,
Err(e) => {
let error_msg = format!("Failed to parse bounding box: {e}");
eprintln!("{error_msg}");
emit_gui_error(&error_msg);
return Err(error_msg);
}
};
let (transformer, xzbbox) = match CoordTransformer::llbbox_to_xzbbox(&llbbox, world_scale) {
Ok(result) => result,
Err(e) => {
let error_msg = format!("Failed to create coordinate transformer: {e}");
eprintln!("{error_msg}");
emit_gui_error(&error_msg);
return Err(error_msg);
}
};
let (spawn_x, spawn_z) = if let Some(coords) = spawn_point {
// User selected a spawn point - verify it's within bounds and convert to XZ
let llpoint = LLPoint::new(coords.0, coords.1)
.map_err(|e| format!("Failed to parse spawn point: {e}"))?;
if llbbox.contains(&llpoint) {
// Spawn point is valid, update the player position
update_player_position(
&selected_world,
spawn_point,
bbox_text.clone(),
world_scale,
)
.map_err(|e| format!("Failed to set spawn point: {e}"))?;
let xzpoint = transformer.transform_point(llpoint);
(xzpoint.x, xzpoint.z)
} else {
// Spawn point outside bounds, use default
calculate_default_spawn(&xzbbox)
}
}
} else {
// No user-selected spawn point - use default at X=1, Z=1 relative to world origin
calculate_default_spawn(&xzbbox)
};
set_player_spawn_in_level_dat(&selected_world, spawn_x, spawn_z)
.map_err(|e| format!("Failed to set spawn point: {e}"))?;
}
tauri::async_runtime::spawn(async move {
if let Err(e) = tokio::task::spawn_blocking(move || {
// Acquire session lock for the world directory before starting generation
let world_path = PathBuf::from(&selected_world);
let _session_lock = match SessionLock::acquire(&world_path) {
Ok(lock) => lock,
Err(e) => {
let error_msg = format!("Failed to acquire session lock: {e}");
// Determine world format from UI selection first (needed for session lock decision)
let world_format = if world_format == "bedrock" {
WorldFormat::BedrockMcWorld
} else {
WorldFormat::JavaAnvil
};
// Check available disk space before starting generation (minimum 3GB required)
const MIN_DISK_SPACE_BYTES: u64 = 3 * 1024 * 1024 * 1024; // 3 GB
let check_path = if world_format == WorldFormat::JavaAnvil {
world_path.clone()
} else {
// For Bedrock, check current directory where .mcworld will be created
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
};
match fs2::available_space(&check_path) {
Ok(available) if available < MIN_DISK_SPACE_BYTES => {
let error_msg = "Not enough disk space available.".to_string();
eprintln!("{error_msg}");
emit_gui_error(&error_msg);
return Err(error_msg);
}
Err(e) => {
// Log warning but don't block generation if we can't check space
eprintln!("Warning: Could not check disk space: {e}");
}
_ => {} // Sufficient space available
}
// Acquire session lock for Java worlds only
// Session lock prevents Minecraft from having the world open during generation
// Bedrock worlds are generated as .mcworld files and don't need this lock
let _session_lock: Option<SessionLock> = if world_format == WorldFormat::JavaAnvil {
match SessionLock::acquire(&world_path) {
Ok(lock) => Some(lock),
Err(e) => {
let error_msg = format!("Failed to acquire session lock: {e}");
eprintln!("{error_msg}");
emit_gui_error(&error_msg);
return Err(error_msg);
}
}
} else {
None
};
// Parse the bounding box from the text with proper error handling
@@ -737,19 +824,70 @@ fn gui_start_generation(
}
};
// Add localized name to the world if user generated a new world
let updated_world_path = if is_new_world {
add_localized_world_name(world_path, &bbox)
} else {
world_path
// Determine output path and level name based on format
let (generation_path, level_name) = match world_format {
WorldFormat::JavaAnvil => {
// Java: use the selected world path, add localized name if new
let updated_path = if is_new_world {
add_localized_world_name(world_path.clone(), &bbox)
} else {
world_path.clone()
};
(updated_path, None)
}
WorldFormat::BedrockMcWorld => {
// 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))
}
};
// Create an Args instance with the chosen bounding box and world directory path
// Calculate MC spawn coordinates from lat/lng if spawn point was provided
// Otherwise, default to X=1, Z=1 (relative to xzbbox min coordinates)
let mc_spawn_point: Option<(i32, i32)> = if let Some((lat, lng)) = spawn_point {
if let Ok(llpoint) = LLPoint::new(lat, lng) {
if let Ok((transformer, _)) =
CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale)
{
let xzpoint = transformer.transform_point(llpoint);
Some((xzpoint.x, xzpoint.z))
} else {
None
}
} else {
None
}
} else {
// Default spawn point: X=1, Z=1 relative to world origin
if let Ok((_, xzbbox)) = CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale) {
Some(calculate_default_spawn(&xzbbox))
} else {
None
}
};
// Create generation options
let generation_options = GenerationOptions {
path: generation_path.clone(),
format: world_format,
level_name,
spawn_point: mc_spawn_point,
};
// Create an Args instance with the chosen bounding box
// Note: path is used for Java-specific features like spawn point update
let args: Args = Args {
bbox,
file: None,
save_json_file: None,
path: updated_world_path,
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,
@@ -757,9 +895,9 @@ 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(floodfill_timeout)),
spawn_point,
timeout: Some(std::time::Duration::from_secs(40)),
};
// If skip_osm_objects is true (terrain-only mode), skip fetching and processing OSM data
@@ -773,14 +911,29 @@ fn gui_start_generation(
CoordTransformer::llbbox_to_xzbbox(&args.bbox, args.scale)
.map_err(|e| format!("Failed to create coordinate transformer: {}", e))?;
let _ = data_processing::generate_world(
let _ = data_processing::generate_world_with_options(
parsed_elements,
xzbbox,
xzbbox.clone(),
args.bbox,
ground,
&args,
generation_options.clone(),
);
// Session lock will be automatically released when _session_lock goes out of scope
// Explicitly release session lock before showing Done message
// so Minecraft can open the world immediately
drop(_session_lock);
emit_gui_progress_update(100.0, "Done! World generation completed.");
println!("{}", "Done! World generation completed.".green().bold());
// Start map preview generation silently in background (Java only)
if world_format == WorldFormat::JavaAnvil {
let preview_info = data_processing::MapPreviewInfo::new(
generation_options.path.clone(),
&xzbbox,
);
data_processing::start_map_preview_generation(preview_info);
}
return Ok(());
}
@@ -811,21 +964,35 @@ fn gui_start_generation(
&mut ground,
);
let _ = data_processing::generate_world(
let _ = data_processing::generate_world_with_options(
parsed_elements,
xzbbox,
xzbbox.clone(),
args.bbox,
ground,
&args,
generation_options.clone(),
);
// Session lock will be automatically released when _session_lock goes out of scope
// Explicitly release session lock before showing Done message
// so Minecraft can open the world immediately
drop(_session_lock);
emit_gui_progress_update(100.0, "Done! World generation completed.");
println!("{}", "Done! World generation completed.".green().bold());
// Start map preview generation silently in background (Java only)
if world_format == WorldFormat::JavaAnvil {
let preview_info = data_processing::MapPreviewInfo::new(
generation_options.path.clone(),
&xzbbox,
);
data_processing::start_map_preview_generation(preview_info);
}
Ok(())
}
Err(e) => {
let error_msg = format!("Failed to fetch data: {e}");
emit_gui_error(&error_msg);
emit_gui_error(&e.to_string());
// Session lock will be automatically released when _session_lock goes out of scope
Err(error_msg)
Err(e.to_string())
}
}
})

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

@@ -8,13 +8,9 @@ body,
font-family: "Courier New", Courier, monospace;
}
/* Hide the BBOX coordinates display at bottom of map */
#info-box {
position: absolute;
width: 100%;
height: auto;
bottom: 0;
border: 0 0 7px 0;
z-index: 10000;
display: none;
}
#coord-format {
@@ -344,4 +340,77 @@ body,
filter: blur(1px) sepia(1) invert(1);
transition: all 1s ease;
}
}
/* World Preview Button in Edit Toolbar */
.leaflet-draw-toolbar .leaflet-draw-edit-preview {
background-position: -31px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-preview.disabled,
.leaflet-draw-toolbar .leaflet-draw-edit-preview.editing-mode {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.leaflet-draw-toolbar .leaflet-draw-edit-preview.active {
background-color: #a0d0ff;
}
.world-preview-slider-container {
padding: 6px 8px !important;
background: white !important;
background-clip: padding-box;
}
.world-preview-slider-container a {
display: none !important;
}
.world-preview-slider {
width: 80px;
height: 8px;
cursor: pointer;
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -158,12 +158,13 @@
background-position: -182px -2px;
}
/* Disabled states reuse same sprites; opacity indicates disabled */
.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled {
background-position: -212px -2px;
background-position: -152px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled {
background-position: -242px -2px;
background-position: -182px -2px;
}
/* ================================================================== */

330
src/gui/css/styles.css vendored
View File

@@ -32,9 +32,12 @@ p {
.logo {
height: 6em;
padding: 1.5em;
padding-top: 0.4em;
padding-bottom: 0.5em;
will-change: filter;
transition: 0.75s;
max-width: 950px;
max-height: 600px;
}
.logo.arnis:hover {
@@ -59,10 +62,11 @@ a:hover {
.flex-container {
display: flex;
gap: 20px;
gap: 15px;
justify-content: center;
align-items: stretch;
margin-top: 5px;
min-height: 70vh;
}
.section {
@@ -74,26 +78,70 @@ a:hover {
.map-box,
.controls-box {
width: 45%;
display: flex;
flex-direction: column;
}
.map-box {
width: 63%;
min-height: 420px;
padding: 0;
overflow: hidden;
background: #575757;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.controls-box {
width: 32%;
background: transparent;
padding: 0;
border-radius: 0;
box-shadow: none;
}
.controls-content {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.controls-box .progress-section {
margin-top: auto;
margin-top: 0;
}
.controls-top {
display: flex;
flex-direction: column;
}
.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;
}
.map-container {
border: 2px solid #e0e0e0;
border: none;
border-radius: 8px;
flex-grow: 1;
width: 100%;
height: 100%;
}
.section h2 {
@@ -133,18 +181,25 @@ button:hover {
margin-top: auto;
}
.progress-section h2 {
margin-bottom: 8px;
text-align: center;
.progress-row {
display: flex;
align-items: center;
gap: 10px;
}
.progress-bar-container {
width: 100%;
flex: 1;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin-top: 8px;
}
#progress-detail {
min-width: 40px;
text-align: right;
font-size: 0.9em;
color: #fff;
}
.progress-bar {
@@ -154,15 +209,6 @@ button:hover {
transition: width 0.4s;
}
/* Left and right alignment for "Saving world..." text */
.progress-status {
display: flex;
justify-content: space-between;
font-size: 0.9em;
margin-top: 8px;
color: #fff;
}
.footer {
margin-top: 20px;
text-align: center;
@@ -181,7 +227,7 @@ button:hover {
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
background-color: #333333;
}
p {
@@ -213,10 +259,73 @@ button:hover {
width: 100%;
}
/* World Selection Container */
.world-selection-container {
width: 100%;
}
.choose-world-btn {
padding: 10px;
line-height: 1.2;
width: 100%;
border-radius: 8px 8px 0 0 !important;
margin-bottom: 0 !important;
margin-top: 0 !important;
box-shadow: none !important;
}
/* World Format Toggle */
.format-toggle-container {
display: flex;
width: 100%;
gap: 0;
margin-top: 0;
}
.format-toggle-btn {
flex: 1;
padding: 10px;
font-size: 1em;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.25s ease;
margin-top: 0;
border-radius: 0;
}
.format-toggle-btn:first-child {
border-radius: 0 0 0 8px;
}
.format-toggle-btn:last-child {
border-radius: 0 0 8px 0;
}
.format-toggle-btn:not(.format-active) {
background-color: #3a3a3a;
color: #b0b0b0;
}
.format-toggle-btn:not(.format-active):hover {
background-color: #4a4a4a;
color: #ffffff;
}
.format-toggle-btn.format-active {
background-color: var(--primary-accent);
color: #0f0f0f;
font-weight: 600;
}
.format-toggle-btn.format-active:hover {
background-color: var(--primary-accent-dark);
}
/* Customization Settings */
.modal {
position: fixed;
z-index: 1000;
z-index: 20001;
left: 0;
top: 0;
width: 100%;
@@ -229,7 +338,7 @@ button:hover {
}
.modal-content {
background-color: #797979;
background-color: #717171;
padding: 20px;
border: 1px solid #797979;
border-radius: 10px;
@@ -249,6 +358,33 @@ button:hover {
color: #ffffff;
}
/* Modal actions/buttons */
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-primary {
background-color: var(--primary-accent);
color: #1a1a1a;
}
.btn-primary:hover {
background-color: var(--primary-accent-dark);
}
.btn-secondary {
background-color: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
.btn-secondary {
background-color: #3a3a3a;
color: #ffffff;
}
}
#terrain-toggle {
accent-color: #fecc44;
}
@@ -281,6 +417,14 @@ button:hover {
margin: 15px 0;
}
#city-boundaries-toggle {
accent-color: #fecc44;
}
#telemetry-toggle {
accent-color: #fecc44;
}
.scale-slider-container label {
display: block;
margin-bottom: 5px;
@@ -306,7 +450,7 @@ button:hover {
#bbox-coords {
width: 100%;
padding: 8px;
padding: 5px;
border: 1px solid #fecc44;
border-radius: 4px;
font-size: 14px;
@@ -318,6 +462,20 @@ button:hover {
box-shadow: 0 0 5px #fecc44;
}
#save-path {
width: 100%;
padding: 5px;
border: 1px solid #fecc44;
border-radius: 4px;
font-size: 14px;
}
#save-path:focus {
outline: none;
border-color: #fecc44;
box-shadow: 0 0 5px #fecc44;
}
/* Settings Modal Layout */
.settings-row {
display: flex;
@@ -329,6 +487,75 @@ button:hover {
.settings-row label {
text-align: left;
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
/* Tooltip icon (question mark in circle) */
.tooltip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: rgba(254, 204, 68, 0.3);
color: #fecc44;
font-size: 11px;
font-weight: bold;
cursor: help;
position: relative;
flex-shrink: 0;
}
.tooltip-icon:hover {
background-color: rgba(254, 204, 68, 0.5);
}
/* Arnis-styled tooltip box */
.tooltip-icon::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
background-color: #2a2a2a;
color: #fecc44;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-weight: normal;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid #fecc44;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
z-index: 1000;
pointer-events: none;
}
/* Tooltip arrow */
.tooltip-icon::before {
content: '';
position: absolute;
left: 50%;
bottom: calc(100% + 2px);
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #fecc44;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
z-index: 1001;
pointer-events: none;
}
.tooltip-icon:hover::after,
.tooltip-icon:hover::before {
opacity: 1;
visibility: visible;
}
.settings-control {
@@ -351,9 +578,47 @@ 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: 10px;
margin-top: 5px;
}
.license-button {
@@ -393,7 +658,7 @@ button:hover {
.generation-mode-dropdown {
width: 100%;
max-width: 180px;
padding: 5px 8px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid #fecc44;
background-color: #ffffff;
@@ -421,7 +686,7 @@ button:hover {
.language-dropdown {
width: 100%;
max-width: 180px;
padding: 5px 8px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid #fecc44;
background-color: #ffffff;
@@ -449,7 +714,7 @@ button:hover {
.theme-dropdown {
width: 100%;
max-width: 180px;
padding: 5px 8px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid #fecc44;
background-color: #ffffff;
@@ -504,9 +769,12 @@ button:hover {
transition: background-color 0.3s, border-color 0.3s;
}
.settings-button .gear-icon::before {
content: "⚙️";
font-size: 18px;
.settings-button svg {
stroke: white;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
}
/* Logo Animation */

198
src/gui/index.html vendored
View File

@@ -20,49 +20,52 @@
</div>
<div class="flex-container">
<!-- Left Box: Map and BBox Input -->
<section class="section map-box" style="margin-bottom: 0; padding-bottom: 0;">
<h2 data-localize="select_location">Select Location</h2>
<span id="bbox-text" style="font-size: 1.0em; display: block; margin-top: -8px; margin-bottom: 3px;" data-localize="zoom_in_and_choose">
Zoom in and choose your area using the rectangle tool
</span>
<iframe src="maps.html" width="100%" height="300" class="map-container" title="Map Picker"></iframe>
<span id="bbox-info"
style="font-size: 0.75em; color: #7bd864; display: block; margin-bottom: 4px; font-weight: bold; min-height: 2em;"></span>
<!-- Left Box: Map -->
<section class="section map-box">
<iframe src="maps.html" width="100%" height="100%" class="map-container" title="Map Picker"></iframe>
</section>
<!-- Right Box: Directory Selection, Start Button, and Progress Bar -->
<section class="section controls-box">
<div class="controls-content">
<h2 data-localize="select_world">Select World</h2>
<!-- Updated Tooltip Structure -->
<div class="tooltip" style="width: 100%;">
<button type="button" onclick="openWorldPicker()" style="padding: 10px; line-height: 1.2; width: 100%;">
<span id="choose_world">Choose 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
</span>
</button>
<div class="controls-top">
<!-- World Selection Container -->
<div class="world-selection-container">
<div class="tooltip" style="width: 100%;">
<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 created
</span>
</button>
</div>
<!-- World Format Toggle -->
<div class="format-toggle-container">
<button type="button" id="format-java" class="format-toggle-btn format-active" onclick="setWorldFormat('java')">
Java
</button>
<button type="button" id="format-bedrock" class="format-toggle-btn" onclick="setWorldFormat('bedrock')">
Bedrock
</button>
</div>
</div>
<div class="button-container">
<button type="button" id="start-button" class="start-button" onclick="startGeneration()" data-localize="start_generation">Start Generation</button>
<button type="button" class="settings-button" onclick="openSettings()" aria-label="Settings">
<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="button-container">
<button type="button" id="start-button" class="start-button" onclick="startGeneration()" data-localize="start_generation">Start Generation</button>
<button type="button" class="settings-button" onclick="openSettings()">
<i class="gear-icon"></i>
</button>
</div>
<br><br>
<div class="progress-section">
<h2 data-localize="progress">Progress</h2>
<div class="progress-bar-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
<div class="progress-status">
<span id="progress-message"></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>
</div>
<span id="progress-detail">0%</span>
</div>
</div>
@@ -70,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">
@@ -89,7 +81,10 @@
<!-- Generation Mode Dropdown -->
<div class="settings-row">
<label for="generation-mode-select" data-localize="generation_mode">Generation Mode</label>
<label for="generation-mode-select">
<span data-localize="generation_mode">Generation Mode</span>
<span class="tooltip-icon" data-tooltip="Choose what to generate: buildings/roads with terrain, just objects, or terrain only">?</span>
</label>
<div class="settings-control">
<select id="generation-mode-select" name="generation-mode-select" class="generation-mode-dropdown">
<option value="geo-terrain" data-localize="mode_geo_terrain">Objects + Terrain</option>
@@ -99,33 +94,56 @@
</div>
</div>
<!-- Interior Toggle Button -->
<div class="settings-row">
<label for="interior-toggle" data-localize="interior">Interior Generation</label>
<div class="settings-control">
<input type="checkbox" id="interior-toggle" name="interior-toggle" checked>
</div>
</div>
<!-- Roof Toggle Button -->
<div class="settings-row">
<label for="roof-toggle" data-localize="roof">Roof Generation</label>
<label for="roof-toggle">
<span data-localize="roof">Roof Generation</span>
<span class="tooltip-icon" data-tooltip="Generate roofs on buildings">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="roof-toggle" name="roof-toggle" checked>
</div>
</div>
<!-- Interior Toggle Button -->
<div class="settings-row">
<label for="interior-toggle">
<span data-localize="interior">Interior Generation</span>
<span class="tooltip-icon" data-tooltip="Generate interior details inside buildings">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="interior-toggle" name="interior-toggle">
</div>
</div>
<!-- Fill ground Toggle Button -->
<div class="settings-row">
<label for="fillground-toggle" data-localize="fillground">Fill Ground</label>
<label for="fillground-toggle">
<span data-localize="fillground">Fill Ground</span>
<span class="tooltip-icon" data-tooltip="Fill the ground below the surface">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="fillground-toggle" name="fillground-toggle">
</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" data-localize="world_scale">World Scale</label>
<label for="scale-value-slider">
<span data-localize="world_scale">World Scale</span>
<span class="tooltip-icon" data-tooltip="Scale factor for the generated world (1.0 = real-world scale)">?</span>
</label>
<div class="settings-control">
<input type="range" id="scale-value-slider" name="scale-value-slider" min="0.30" max="2.5" step="0.1" value="1">
<span id="slider-value">1.00</span>
@@ -134,23 +152,21 @@
<!-- Bounding Box Input -->
<div class="settings-row">
<label for="bbox-coords" data-localize="custom_bounding_box">Custom Bounding Box</label>
<label for="bbox-coords">
<span data-localize="custom_bounding_box">Custom Bounding Box</span>
<span class="tooltip-icon" data-tooltip="Manually enter coordinates (lat,lng,lat,lng) or use map selection">?</span>
</label>
<div class="settings-control">
<input type="text" id="bbox-coords" name="bbox-coords" maxlength="55" placeholder="Format: lat,lng,lat,lng">
</div>
</div>
<!-- Floodfill Timeout Input -->
<div class="settings-row">
<label for="floodfill-timeout" data-localize="floodfill_timeout">Floodfill Timeout (sec)</label>
<div class="settings-control">
<input type="number" id="floodfill-timeout" name="floodfill-timeout" min="0" step="1" value="20" placeholder="Seconds">
</div>
</div>
<!-- Map Theme Selector -->
<div class="settings-row">
<label for="tile-theme-select" data-localize="map_theme">Map Theme</label>
<label for="tile-theme-select">
<span data-localize="map_theme">Map Theme</span>
<span class="tooltip-icon" data-tooltip="Visual style of the map picker">?</span>
</label>
<div class="settings-control">
<select id="tile-theme-select" name="tile-theme-select" class="theme-dropdown">
<option value="osm">Standard</option>
@@ -162,9 +178,26 @@
</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" data-localize="language">Language</label>
<label for="language-select">
<span data-localize="language">Language</span>
<span class="tooltip-icon" data-tooltip="Interface language">?</span>
</label>
<div class="settings-control">
<select id="language-select" name="language-select" class="language-dropdown">
<option value="en">English</option>
@@ -186,6 +219,17 @@
</div>
</div>
<!-- Telemetry Consent Toggle -->
<div class="settings-row">
<label for="telemetry-toggle" style="white-space: nowrap;">
<span>Anonymous Crash Reports</span>
<span class="tooltip-icon" data-tooltip="Send anonymous crash data to help improve Arnis">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="telemetry-toggle" name="telemetry-toggle">
</div>
</div>
<!-- License and Credits Button -->
<div class="settings-row license-button-row">
<button type="button" id="license-button" class="license-button" onclick="openLicense()" data-localize="license_and_credits">License and Credits</button>
@@ -203,6 +247,22 @@
</div>
</div>
<!-- Telemetry Consent Modal (first run) -->
<div id="telemetry-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-button" onclick="rejectTelemetry()">&times;</span>
<h2>Help improve Arnis</h2>
<p style="text-align:left; margin-top:6px; color:#ececec;">
Wed like to collect anonymous usage data like crashes and performance to make Arnis more stable and faster.
<a href="https://arnismc.com/privacypolicy.html" style="color: inherit;" target="_blank">No personal data or world contents are collected.</a>
</p>
<div class="modal-actions" style="margin-top:14px;">
<button type="button" class="btn-secondary" onclick="rejectTelemetry()">No thanks</button>
<button type="button" class="btn-primary" onclick="acceptTelemetry()">Allow anonymous data</button>
</div>
</div>
</div>
<!-- License Modal -->
<div id="license-modal" class="modal" style="display: none;">
<div class="modal-content">

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

@@ -529,7 +529,7 @@ $(document).ready(function () {
failureCount++;
// After a few failures, try HTTP fallback
if (failureCount >= 3 && !this._httpFallbackAttempted && theme.url.startsWith('https://')) {
if (failureCount >= 6 && !this._httpFallbackAttempted && theme.url.startsWith('https://')) {
console.log('HTTPS tile loading failed, attempting HTTP fallback for', themeKey);
this._httpFallbackAttempted = true;
@@ -558,11 +558,417 @@ $(document).ready(function () {
var savedTheme = localStorage.getItem('selectedTileTheme') || 'osm';
changeTileTheme(savedTheme);
// Listen for theme changes from parent window (settings modal)
// World overlay state
var worldOverlay = null;
var worldOverlayData = null;
var worldOverlayEnabled = false;
var worldPreviewAvailable = false;
var sliderControl = null;
var worldOverlayHiddenForEdit = false; // Track if we hid the overlay for edit/delete mode
// Create the opacity slider as a proper Leaflet control
var SliderControl = L.Control.extend({
options: { position: 'topleft' },
onAdd: function(map) {
var container = L.DomUtil.create('div', 'leaflet-bar world-preview-slider-container');
container.id = 'world-preview-slider-container';
container.style.display = 'none';
var slider = L.DomUtil.create('input', 'world-preview-slider', container);
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = '50';
slider.id = 'world-preview-opacity';
slider.title = 'Overlay Opacity';
L.DomEvent.on(slider, 'input', function(e) {
if (worldOverlay) {
worldOverlay.setOpacity(e.target.value / 100);
}
});
// Prevent all map interactions
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
L.DomEvent.on(container, 'mousedown', L.DomEvent.stopPropagation);
L.DomEvent.on(container, 'touchstart', L.DomEvent.stopPropagation);
L.DomEvent.on(slider, 'mousedown', L.DomEvent.stopPropagation);
L.DomEvent.on(slider, 'touchstart', L.DomEvent.stopPropagation);
return container;
}
});
// Function to add world preview button to the draw control's edit toolbar
function addWorldPreviewToEditToolbar() {
// Find the edit toolbar (contains Edit layers and Delete layers buttons)
var editToolbar = document.querySelector('.leaflet-draw-toolbar:not(.leaflet-draw-toolbar-top)');
if (!editToolbar) {
// Try finding by the edit/delete buttons
var deleteBtn = document.querySelector('.leaflet-draw-edit-remove');
if (deleteBtn) {
editToolbar = deleteBtn.parentElement;
}
}
if (editToolbar) {
// Create the preview button
var toggleBtn = document.createElement('a');
toggleBtn.className = 'leaflet-draw-edit-preview disabled';
toggleBtn.href = '#';
toggleBtn.title = 'Show World Preview (not available yet)';
toggleBtn.id = 'world-preview-btn';
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (worldPreviewAvailable) {
toggleWorldOverlay();
}
});
editToolbar.appendChild(toggleBtn);
// Add the slider control to the map
sliderControl = new SliderControl();
map.addControl(sliderControl);
}
}
// Toggle world overlay function
function toggleWorldOverlay() {
if (!worldPreviewAvailable || !worldOverlayData) return;
worldOverlayEnabled = !worldOverlayEnabled;
var btn = document.getElementById('world-preview-btn');
var sliderContainer = document.getElementById('world-preview-slider-container');
if (worldOverlayEnabled) {
// Show overlay
var data = worldOverlayData;
var bounds = L.latLngBounds(
[data.min_lat, data.min_lon],
[data.max_lat, data.max_lon]
);
if (worldOverlay) {
map.removeLayer(worldOverlay);
}
var opacity = document.getElementById('world-preview-opacity');
var opacityValue = opacity ? opacity.value / 100 : 0.5;
worldOverlay = L.imageOverlay(data.image_base64, bounds, {
opacity: opacityValue,
interactive: false,
zIndex: 500
});
worldOverlay.addTo(map);
if (btn) {
btn.classList.add('active');
btn.title = 'Hide World Preview';
}
if (sliderContainer) {
sliderContainer.style.display = 'block';
}
} else {
// Hide overlay
if (worldOverlay) {
map.removeLayer(worldOverlay);
worldOverlay = null;
}
if (btn) {
btn.classList.remove('active');
btn.title = 'Show World Preview';
}
if (sliderContainer) {
sliderContainer.style.display = 'none';
}
}
}
// Enable the preview button when data is available
function enableWorldPreview(data) {
worldOverlayData = data;
worldPreviewAvailable = true;
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.remove('disabled');
btn.title = 'Show World Preview';
}
}
// Disable and reset preview (when world changes)
function disableWorldPreview() {
worldPreviewAvailable = false;
worldOverlayData = null;
worldOverlayEnabled = false;
if (worldOverlay) {
map.removeLayer(worldOverlay);
worldOverlay = null;
}
var btn = document.getElementById('world-preview-btn');
var sliderContainer = document.getElementById('world-preview-slider-container');
if (btn) {
btn.classList.add('disabled');
btn.classList.remove('active');
btn.title = 'Show World Preview (not available yet)';
}
if (sliderContainer) {
sliderContainer.style.display = 'none';
}
}
// Temporarily hide the overlay (for edit/delete mode)
function hideWorldOverlayTemporarily() {
if (worldOverlay && worldOverlayEnabled) {
worldOverlayHiddenForEdit = true;
map.removeLayer(worldOverlay);
}
// Also visually disable the preview button during edit/delete mode
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.add('editing-mode');
}
}
// Restore the overlay after edit/delete mode ends
function restoreWorldOverlay() {
if (worldOverlayHiddenForEdit && worldOverlay && worldOverlayEnabled) {
worldOverlay.addTo(map);
worldOverlayHiddenForEdit = false;
}
// Re-enable the preview button
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.remove('editing-mode');
}
}
// ========== 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') {
changeTileTheme(event.data.theme);
}
// Handle world preview data ready (after generation completes)
if (event.data && event.data.type === 'worldPreviewReady') {
enableWorldPreview(event.data.data);
// Auto-enable the overlay when generation completes
if (!worldOverlayEnabled) {
toggleWorldOverlay();
}
}
// Handle existing world map load (zoom to location and auto-enable)
if (event.data && event.data.type === 'loadExistingWorldMap') {
var data = event.data.data;
enableWorldPreview(data);
// Calculate bounds and zoom to them
var bounds = L.latLngBounds(
[data.min_lat, data.min_lon],
[data.max_lat, data.max_lon]
);
map.fitBounds(bounds, { padding: [50, 50] });
// Auto-enable the overlay
if (!worldOverlayEnabled) {
toggleWorldOverlay();
}
}
// Handle world changed (disable preview)
if (event.data && event.data.type === 'worldChanged') {
disableWorldPreview();
}
});
// Set the dropdown value in parent window if it exists
@@ -652,6 +1058,9 @@ $(document).ready(function () {
}
});
map.addControl(drawControl);
// Add world preview button to the edit toolbar after drawControl is added
addWorldPreviewToEditToolbar();
/*
**
** create bounds layer
@@ -699,6 +1108,15 @@ $(document).ready(function () {
});
}
// If it's a rectangle, remove any existing rectangles first
if (e.layerType === 'rectangle') {
drawnItems.eachLayer(function(layer) {
if (layer instanceof L.Rectangle) {
drawnItems.removeLayer(layer);
}
});
}
// Check if it's a rectangle and set proper styles before adding it to the layer
if (e.layerType === 'rectangle') {
e.layer.setStyle({
@@ -790,6 +1208,23 @@ $(document).ready(function () {
map.fitBounds(bounds.getBounds());
});
// Hide world preview overlay when entering edit or delete mode
map.on('draw:editstart', function() {
hideWorldOverlayTemporarily();
});
map.on('draw:deletestart', function() {
hideWorldOverlayTemporarily();
});
// Restore world preview overlay when exiting edit or delete mode
map.on('draw:editstop', function() {
restoreWorldOverlay();
});
map.on('draw:deletestop', function() {
restoreWorldOverlay();
});
function display() {
$('#boxbounds').text(formatBounds(bounds.getBounds(), '4326'));
$('#boxboundsmerc').text(formatBounds(bounds.getBounds(), currentproj));

View File

@@ -24,6 +24,14 @@ export const licenseText = `
Elevation data derived from the <a href="https://registry.opendata.aws/terrain-tiles/" style="color: inherit;" target="_blank">AWS Terrain Tiles</a> dataset.
<br><br>
<b>bedrock-rs:</b><br>
Bedrock Edition world format support uses the <a href="https://github.com/bedrock-crustaceans/bedrock-rs" style="color: inherit;" target="_blank">bedrock-rs</a> library, licensed under the Apache License 2.0.
<br><br>
<p><b>Privacy Policy:</b></p>
If you consent to telemetry data collection, please review our Privacy Policy at:
<a href="https://arnismc.com/privacypolicy.html" style="color: inherit;" target="_blank">https://arnismc.com/privacypolicy.html</a>.
<p><b>License:</b></p>
<pre style="white-space: pre-wrap; font-family: inherit;">
Apache License

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

@@ -12,17 +12,38 @@ if (window.__TAURI__) {
const DEFAULT_LOCALE_PATH = `./locales/en.json`;
// Track current bbox selection info localization key for language changes
let currentBboxSelectionKey = "select_area_prompt";
let currentBboxSelectionColor = "#ffffff";
// 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;
if (!localization) {
localization = await getLocalization();
}
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();
await applyLocalization(localization);
updateFormatToggleUI(selectedWorldFormat);
initFooter();
await checkForUpdates();
});
@@ -64,7 +85,7 @@ async function localizeElement(json, elementObject, localizedStringKey) {
const attribute = localizedStringKey.startsWith("placeholder_") ? "placeholder" : "textContent";
if (element) {
if (localizedStringKey in json) {
if (json && localizedStringKey in json) {
element[attribute] = json[localizedStringKey];
} else {
// Fallback to default (English) string
@@ -76,39 +97,32 @@ async function localizeElement(json, elementObject, localizedStringKey) {
async function applyLocalization(localization) {
const localizationElements = {
"h2[data-localize='select_location']": "select_location",
"#bbox-text": "zoom_in_and_choose",
"h2[data-localize='select_world']": "select_world",
"span[id='choose_world']": "choose_world",
"span[id='choose_world']": "create_world",
"#selected-world": "no_world_selected",
"#start-button": "start_generation",
"h2[data-localize='progress']": "progress",
"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",
"label[data-localize='floodfill_timeout']": "floodfill_timeout",
"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",
// Placeholder strings
"input[id='bbox-coords']": "placeholder_bbox",
"input[id='floodfill-timeout']": "placeholder_floodfill",
// DEPRECATED: Ground level placeholder removed
// "input[id='ground-level']": "placeholder_ground"
};
@@ -117,6 +131,13 @@ async function applyLocalization(localization) {
localizeElement(localization, { selector: selector }, localizationElements[selector]);
}
// 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
window.localization = localization;
}
@@ -165,7 +186,7 @@ async function checkForUpdates() {
updateMessage.style.textDecoration = "none";
localizeElement(window.localization, { element: updateMessage }, "new_version_available");
footer.style.marginTop = "15px";
footer.style.marginTop = "10px";
footer.appendChild(updateMessage);
}
} catch (error) {
@@ -188,7 +209,7 @@ function registerMessageEvent() {
// Function to set up the progress bar listener
function setupProgressListener() {
const progressBar = document.getElementById("progress-bar");
const progressMessage = document.getElementById("progress-message");
const progressInfo = document.getElementById("progress-info");
const progressDetail = document.getElementById("progress-detail");
window.__TAURI__.event.listen("progress-update", (event) => {
@@ -200,19 +221,36 @@ function setupProgressListener() {
}
if (message != "") {
progressMessage.textContent = message;
progressInfo.textContent = message;
if (message.startsWith("Error!")) {
progressMessage.style.color = "#fa7878";
progressInfo.style.color = "#fa7878";
generationButtonEnabled = true;
} else if (message.startsWith("Done!")) {
progressMessage.style.color = "#7bd864";
progressInfo.style.color = "#7bd864";
generationButtonEnabled = true;
} else {
progressMessage.style.color = "";
progressInfo.style.color = "#ececec";
}
}
});
// Listen for map preview ready event from backend
window.__TAURI__.event.listen("map-preview-ready", () => {
console.log("Map preview ready event received");
showWorldPreviewButton();
});
// Listen for open-mcworld-file event to show the generated Bedrock world in file explorer
window.__TAURI__.event.listen("open-mcworld-file", async (event) => {
const filePath = event.payload;
try {
// Use our custom command to show the file in the system file explorer
await invoke("gui_show_in_folder", { path: filePath });
} catch (error) {
console.error("Failed to show mcworld file in folder:", error);
}
});
}
function initSettings() {
@@ -233,6 +271,20 @@ function initSettings() {
settingsModal.style.display = "none";
}
// Close settings and license modals on escape key
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (settingsModal.style.display === "flex") {
closeSettings();
}
const licenseModal = document.getElementById("license-modal");
if (licenseModal && licenseModal.style.display === "flex") {
closeLicense();
}
}
});
window.openSettings = openSettings;
window.closeSettings = closeSettings;
@@ -241,6 +293,12 @@ function initSettings() {
sliderValue.textContent = parseFloat(slider.value).toFixed(2);
});
// 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);
@@ -279,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
@@ -305,6 +371,20 @@ function initSettings() {
}
});
// Telemetry consent toggle
const telemetryToggle = document.getElementById("telemetry-toggle");
const telemetryKey = 'telemetry-consent';
// Load saved telemetry consent
const savedConsent = localStorage.getItem(telemetryKey);
telemetryToggle.checked = savedConsent === 'true';
// Handle telemetry consent change
telemetryToggle.addEventListener("change", () => {
const isEnabled = telemetryToggle.checked;
localStorage.setItem(telemetryKey, isEnabled ? 'true' : 'false');
});
/// License and Credits
function openLicense() {
@@ -329,24 +409,194 @@ function initSettings() {
window.closeLicense = closeLicense;
}
function initWorldPicker() {
// World Picker
const worldPickerModal = document.getElementById("world-modal");
// World format selection (Java/Bedrock)
let selectedWorldFormat = 'java'; // Default to Java
// Open world picker modal
function openWorldPicker() {
worldPickerModal.style.display = "flex";
worldPickerModal.style.justifyContent = "center";
worldPickerModal.style.alignItems = "center";
function initWorldFormatToggle() {
// Load saved format preference
const savedFormat = localStorage.getItem('arnis-world-format');
if (savedFormat && (savedFormat === 'java' || savedFormat === 'bedrock')) {
selectedWorldFormat = savedFormat;
}
// Apply the saved selection to UI
updateFormatToggleUI(selectedWorldFormat);
}
function setWorldFormat(format) {
if (format !== 'java' && format !== 'bedrock') return;
selectedWorldFormat = format;
localStorage.setItem('arnis-world-format', format);
updateFormatToggleUI(format);
}
function updateFormatToggleUI(format) {
const javaBtn = document.getElementById('format-java');
const bedrockBtn = document.getElementById('format-bedrock');
const chooseWorldBtn = document.getElementById('choose-world-btn');
const selectedWorldText = document.getElementById('selected-world');
if (format === 'java') {
javaBtn.classList.add('format-active');
bedrockBtn.classList.remove('format-active');
// Enable Create World button for Java
if (chooseWorldBtn) {
chooseWorldBtn.disabled = false;
chooseWorldBtn.style.opacity = '1';
chooseWorldBtn.style.cursor = 'pointer';
}
// 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 Create World button for Bedrock
if (chooseWorldBtn) {
chooseWorldBtn.disabled = true;
chooseWorldBtn.style.opacity = '0.5';
chooseWorldBtn.style.cursor = 'not-allowed';
}
// Clear world selection and show Bedrock info message
worldPath = "";
if (selectedWorldText) {
const bedrockText = window.localization?.bedrock_auto_generated || 'Bedrock world is auto-generated';
selectedWorldText.textContent = bedrockText;
selectedWorldText.style.color = '#fecc44';
}
}
}
// Expose to window for onclick handlers
window.setWorldFormat = setWorldFormat;
// Telemetry consent (first run only)
function initTelemetryConsent() {
const key = 'telemetry-consent'; // values: 'true' | 'false'
const existing = localStorage.getItem(key);
const modal = document.getElementById('telemetry-modal');
if (!modal) return;
if (existing === null) {
// First run: ask for consent
modal.style.display = 'flex';
modal.style.justifyContent = 'center';
modal.style.alignItems = 'center';
}
// Close world picker modal
function closeWorldPicker() {
worldPickerModal.style.display = "none";
// Expose handlers
window.acceptTelemetry = () => {
localStorage.setItem(key, 'true');
modal.style.display = 'none';
// Update settings toggle to reflect the consent
const telemetryToggle = document.getElementById('telemetry-toggle');
if (telemetryToggle) {
telemetryToggle.checked = true;
}
};
window.rejectTelemetry = () => {
localStorage.setItem(key, 'false');
modal.style.display = 'none';
// Update settings toggle to reflect the consent
const telemetryToggle = document.getElementById('telemetry-toggle');
if (telemetryToggle) {
telemetryToggle.checked = false;
}
};
// Utility for other scripts to read consent
window.getTelemetryConsent = () => {
const v = localStorage.getItem(key);
return v === null ? null : v === 'true';
};
}
/// Save path management
let savePath = "";
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);
}
}
window.openWorldPicker = openWorldPicker;
window.closeWorldPicker = closeWorldPicker;
// Populate the save path input in settings
const savePathInput = document.getElementById('save-path-input');
if (savePathInput) {
savePathInput.value = savePath;
}
}
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);
}
});
}
}
/**
@@ -356,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();
@@ -368,11 +618,12 @@ function handleBboxInput() {
// Clear the info text only if no map selection exists
if (!mapSelectedBBox) {
bboxInfo.textContent = "";
bboxInfo.style.color = "";
setBboxSelectionInfo(bboxSelectionInfo, "select_area_prompt", "#ffffff");
} else {
// Restore map selection display
displayBboxInfoText(mapSelectedBBox);
// 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(bboxSelectionInfo, selectedSize);
}
return;
}
@@ -408,8 +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
localizeElement(window.localization, { element: bboxInfo }, "custom_selection_confirmed");
bboxInfo.style.color = "#7bd864";
setBboxSelectionInfo(bboxSelectionInfo, "custom_selection_confirmed", "#7bd864");
} else {
// Valid numbers but invalid order or range
customBBoxValid = false;
@@ -419,8 +669,7 @@ function handleBboxInput() {
} else {
selectedBBox = mapSelectedBBox;
}
localizeElement(window.localization, { element: bboxInfo }, "error_coordinates_out_of_range");
bboxInfo.style.color = "#fecc44";
setBboxSelectionInfo(bboxSelectionInfo, "error_coordinates_out_of_range", "#fecc44");
}
} else {
// Input doesn't match the required format
@@ -431,8 +680,7 @@ function handleBboxInput() {
} else {
selectedBBox = mapSelectedBBox;
}
localizeElement(window.localization, { element: bboxInfo }, "invalid_format");
bboxInfo.style.color = "#fecc44";
setBboxSelectionInfo(bboxSelectionInfo, "invalid_format", "#fecc44");
}
});
}
@@ -475,12 +723,27 @@ function normalizeLongitude(lon) {
return ((lon + 180) % 360 + 360) % 360 - 180;
}
const threshold1 = 30000000.00;
const threshold2 = 45000000.00;
const threshold1 = 44000000.00; // Yellow warning threshold (~6.2km x 7km)
const threshold2 = 85000000.00; // Red error threshold (~8.7km x 9.8km)
let selectedBBox = "";
let mapSelectedBBox = ""; // Tracks bbox from map selection
let customBBoxValid = false; // Tracks if custom input is valid
/**
* Displays the appropriate bbox size status message based on area thresholds
* @param {HTMLElement} bboxSelectionElement - The element to display the message in
* @param {number} selectedSize - The calculated bbox area in square meters
*/
function displayBboxSizeStatus(bboxSelectionElement, selectedSize) {
if (selectedSize > threshold2) {
setBboxSelectionInfo(bboxSelectionElement, "area_too_large", "#fa7878");
} else if (selectedSize > threshold1) {
setBboxSelectionInfo(bboxSelectionElement, "area_extensive", "#fecc44");
} else {
setBboxSelectionInfo(bboxSelectionElement, "selection_confirmed", "#7bd864");
}
}
// Function to handle incoming bbox data
function displayBboxInfoText(bboxText) {
let [lng1, lat1, lng2, lat2] = bboxText.split(" ").map(Number);
@@ -494,11 +757,13 @@ 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) {
bboxInfo.textContent = "";
setBboxSelectionInfo(bboxSelectionInfo, "select_area_prompt", "#ffffff");
bboxCoordsInput.value = "";
mapSelectedBBox = "";
if (!customBBoxValid) {
selectedBBox = "";
@@ -506,39 +771,41 @@ function displayBboxInfoText(bboxText) {
return;
}
// Update the custom bbox input with the map selection (comma-separated format)
bboxCoordsInput.value = `${lng1},${lat1},${lng2},${lat2}`;
// Calculate the size of the selected bbox
const selectedSize = calculateBBoxSize(lng1, lat1, lng2, lat2);
if (selectedSize > threshold2) {
localizeElement(window.localization, { element: bboxInfo }, "area_too_large");
bboxInfo.style.color = "#fa7878";
} else if (selectedSize > threshold1) {
localizeElement(window.localization, { element: bboxInfo }, "area_extensive");
bboxInfo.style.color = "#fecc44";
} else {
localizeElement(window.localization, { element: bboxInfo }, "selection_confirmed");
bboxInfo.style.color = "#7bd864";
}
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();
}
} catch (error) {
handleWorldSelectionError(error);
}
closeWorldPicker();
}
/**
@@ -574,19 +841,22 @@ async function startGeneration() {
}
if (!selectedBBox || selectedBBox == "0.000000 0.000000 0.000000 0.000000") {
const bboxInfo = document.getElementById('bbox-info');
localizeElement(window.localization, { element: bboxInfo }, "select_location_first");
bboxInfo.style.color = "#fa7878";
const bboxSelectionInfo = document.getElementById('bbox-selection-info');
setBboxSelectionInfo(bboxSelectionInfo, "select_location_first", "#fa7878");
return;
}
if (!worldPath || worldPath === "") {
// 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;
}
// Clear any existing world preview since we're generating a new one
notifyWorldChanged();
// Get the map iframe reference
const mapFrame = document.querySelector('.map-container');
// Get spawn point coordinates if marker exists
@@ -607,15 +877,17 @@ 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 floodfill_timeout = parseInt(document.getElementById("floodfill-timeout").value, 10);
// var ground_level = parseInt(document.getElementById("ground-level").value, 10);
// DEPRECATED: Ground level input removed from UI
var ground_level = -62;
// Validate floodfill_timeout and ground_level
floodfill_timeout = isNaN(floodfill_timeout) || floodfill_timeout < 0 ? 20 : floodfill_timeout;
ground_level = isNaN(ground_level) || ground_level < -62 ? 20 : ground_level;
// Validate ground_level
ground_level = isNaN(ground_level) || ground_level < -62 ? -62 : ground_level;
// Get telemetry consent (defaults to false if not set)
const telemetryConsent = window.getTelemetryConsent ? window.getTelemetryConsent() : false;
// Pass the selected options to the Rust backend
await invoke("gui_start_generation", {
@@ -623,14 +895,16 @@ async function startGeneration() {
selectedWorld: worldPath,
worldScale: scale,
groundLevel: ground_level,
floodfillTimeout: floodfill_timeout,
terrainEnabled: terrain,
skipOsmObjects: skipOsmObjects,
interiorEnabled: interior,
roofEnabled: roof,
fillgroundEnabled: fill_ground,
isNewWorld: isNewWorld,
spawnPoint: spawnPoint
cityBoundariesEnabled: city_boundaries,
isNewWorld: true,
spawnPoint: spawnPoint,
telemetryConsent: telemetryConsent || false,
worldFormat: selectedWorldFormat
});
console.log("Generation process started.");
@@ -640,3 +914,60 @@ async function startGeneration() {
generationButtonEnabled = true;
}
}
// World preview overlay state
let worldPreviewEnabled = false;
let currentWorldMapData = null;
/**
* Notifies the map iframe that world preview data is ready
* Called when the backend emits the map-preview-ready event
*/
async function showWorldPreviewButton() {
// Try to load the world map data
await loadWorldMapData();
if (currentWorldMapData) {
// Send data to the map iframe
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'worldPreviewReady',
data: currentWorldMapData
}, '*');
console.log("World preview data sent to map iframe");
}
} else {
console.warn("Map data not available yet");
}
}
/**
* Notifies the map iframe that the world has changed (reset preview)
*/
function notifyWorldChanged() {
currentWorldMapData = null;
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'worldChanged'
}, '*');
}
}
/**
* Loads the world map data from the backend
*/
async function loadWorldMapData() {
if (!worldPath) return;
try {
const mapData = await invoke('gui_get_world_map_data', { worldPath: worldPath });
if (mapData) {
currentWorldMapData = mapData;
console.log("World map data loaded successfully");
}
} catch (error) {
console.error("Failed to load world map data:", error);
}
}

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,11 +1,7 @@
{
"select_location": "اختيار موقع",
"zoom_in_and_choose": "قم بالتكبير واختر منطقتك باستخدام أداة المستطيل",
"select_world": "تحديد عالم",
"choose_world": "اختيار عالم",
"no_world_selected": "لم يتم تحديد عالم",
"create_world": "إنشاء عالم",
"no_world_selected": "لم يتم إنشاء عالم",
"start_generation": "بدء البناء",
"progress": "التقدم",
"custom_selection_confirmed": "تم تأكيد التحديد المخصص!",
"error_coordinates_out_of_range": "خطأ: الإحداثيات خارج النطاق أو مرتبة بشكل غير صحيح (مطلوب خط العرض قبل خط الطول).",
"invalid_format": "تنسيق غير صالح. استخدم 'lat,lng,lat,lng' أو 'lat lng lat lng'.",
@@ -15,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": "هناك نسخة جديدة متاحة! انقر هنا لتنزيلها.",
@@ -25,11 +18,12 @@
"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": "المنطقة واسعة جدًا وقد تتطلب الكثير من الوقت والموارد.",
"selection_confirmed": "تم تأكيد التحديد!",
"select_area_prompt": "حدد منطقة على الخريطة باستخدام الأدوات.",
"unknown_error": "خطأ غير معروف",
"license_and_credits": "الرخصة والمساهمون",
"placeholder_bbox": "الصيغة: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "تضاريس فقط",
"interior": "توليد الداخلية",
"roof": "توليد السقف",
"fillground": "ملء الأرض"
"fillground": "ملء الأرض",
"city_boundaries": "أرضية المدينة",
"bedrock_auto_generated": "يتم إنشاء عالم Bedrock تلقائيًا",
"save_path": "مسار الحفظ"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Standort auswählen",
"zoom_in_and_choose": "Zoome hinein und wähle dein Gebiet aus",
"select_world": "Welt auswählen",
"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",
"progress": "Fortschritt",
"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).",
"invalid_format": "Ungültiges Format. Bitte verwende 'lat,lng,lat,lng' oder 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Auswahl bestätigt!",
"select_area_prompt": "Wähle einen Bereich auf der Karte aus.",
"unknown_error": "Unbekannter Fehler",
"license_and_credits": "Lizenz und Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Nur Terrain",
"interior": "Innenraum Generierung",
"roof": "Dach Generierung",
"fillground": "Boden füllen"
"fillground": "Boden füllen",
"city_boundaries": "Stadtboden",
"bedrock_auto_generated": "Bedrock-Welt wird automatisch generiert",
"save_path": "Speicherpfad"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Select Location",
"zoom_in_and_choose": "Zoom in and choose your area using the rectangle tool",
"select_world": "Select World",
"choose_world": "Choose World",
"no_world_selected": "No world selected",
"create_world": "Create World",
"no_world_selected": "No world created",
"start_generation": "Start Generation",
"progress": "Progress",
"custom_selection_confirmed": "Custom selection confirmed!",
"error_coordinates_out_of_range": "Error: Coordinates are out of range or incorrectly ordered (Lat before Lng required).",
"invalid_format": "Invalid format. Please use 'lat,lng,lat,lng' or 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Selection confirmed!",
"select_area_prompt": "Select an area on the map using the tools.",
"unknown_error": "Unknown error",
"license_and_credits": "License and Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Terrain only",
"interior": "Interior Generation",
"roof": "Roof Generation",
"fillground": "Fill Ground"
"fillground": "Fill Ground",
"city_boundaries": "City Ground",
"bedrock_auto_generated": "Bedrock world is auto-generated",
"save_path": "Save Path"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Seleccionar ubicación",
"zoom_in_and_choose": "Acércate y elige tu área usando la herramienta de rectángulo",
"select_world": "Seleccionar mundo",
"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",
"progress": "Progreso",
"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).",
"invalid_format": "Formato inválido. Por favor, use 'lat,lng,lat,lng' o 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "¡Selección confirmada!",
"select_area_prompt": "Selecciona un área en el mapa usando las herramientas.",
"unknown_error": "Unknown error",
"license_and_credits": "License and Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Solo Terreno",
"interior": "Generación Interior",
"roof": "Generación de Tejado",
"fillground": "Rellenar Suelo"
"fillground": "Rellenar Suelo",
"city_boundaries": "Suelo Urbano",
"bedrock_auto_generated": "El mundo Bedrock se genera automáticamente",
"save_path": "Ruta de guardado"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Valitse paikka",
"zoom_in_and_choose": "Zoomaa ja valitse paikka käyttämällä suorakulmatyökalua.",
"select_world": "Valitse maailma",
"choose_world": "Valitse maailma",
"no_world_selected": "Maailmaa ei valittu",
"create_world": "Luo maailma",
"no_world_selected": "Maailmaa ei luotu",
"start_generation": "Aloita generointi",
"progress": "Edistys",
"custom_selection_confirmed": "Mukautettu valinta vahvistettu!",
"error_coordinates_out_of_range": "Virhe: Koordinaatit ovat kantaman ulkopuolella tai vääriin aseteltu (Lat ennen Lng vaadittu).",
"invalid_format": "Väärä formaatti. Käytä 'lat,lng,lat,lng' tai 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Valinta vahvistettu!",
"select_area_prompt": "Valitse alue kartalta työkaluilla.",
"unknown_error": "Tuntematon virhe",
"license_and_credits": "Lisenssi ja krediitit",
"placeholder_bbox": "Formaatti: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Vain maasto",
"interior": "Sisätilan luonti",
"roof": "Katon luonti",
"fillground": "Täytä maa"
"fillground": "Täytä maa",
"city_boundaries": "Kaupungin maa",
"bedrock_auto_generated": "Bedrock-maailma luodaan automaattisesti",
"save_path": "Tallennuspolku"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Sélectionner une localisation",
"zoom_in_and_choose": "Zoomez et choisissez votre zone avec l'outil rectangle",
"select_world": "Sélectionner un monde",
"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",
"progress": "Progrès",
"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).",
"invalid_format": "Format invalide. Utilisez 'lat,lng,lat,lng' ou 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Sélection confirmée !",
"select_area_prompt": "Sélectionnez une zone sur la carte avec les outils.",
"unknown_error": "Erreur inconnue",
"license_and_credits": "Licence et crédits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Terrain uniquement",
"interior": "Génération d'intérieur",
"roof": "Génération de toit",
"fillground": "Remplir le sol"
"fillground": "Remplir le sol",
"city_boundaries": "Sol urbain",
"bedrock_auto_generated": "Le monde Bedrock est généré automatiquement",
"save_path": "Chemin de sauvegarde"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Hely kiválasztása",
"zoom_in_and_choose": "Nagyíts és jelöld ki a területet a kijelölő eszközzel",
"select_world": "Világ kijelölése",
"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",
"progress": "Haladás",
"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)",
"invalid_format": "Érvénytelen formátum. Kérjük, használja a 'lat,lng,lat,lng' vagy a 'lat lng lat lng' formátumot.'.",
@@ -15,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",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Kiválasztás megerősítve",
"select_area_prompt": "Jelölj ki egy területet a térképen az eszközökkel.",
"unknown_error": "Ismeretlen hiba",
"license_and_credits": "Licenc és elismerés.",
"placeholder_bbox": "Formátum: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Csak terep",
"interior": "Belső generálás",
"roof": "Tető generálás",
"fillground": "Talaj feltöltése"
"fillground": "Talaj feltöltése",
"city_boundaries": "Városi talaj",
"bedrock_auto_generated": "A Bedrock világ automatikusan generálódik",
"save_path": "Mentési útvonal"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "장소 선택",
"zoom_in_and_choose": "줌 인하고 직사각형 도구를 사용하여 영역을 선택하세요.",
"select_world": "세계 선택",
"choose_world": "세계 선택",
"no_world_selected": "선택된 세계 없음",
"create_world": "월드 만들기",
"no_world_selected": "생성된 월드 없음",
"start_generation": "생성 시작",
"progress": "진행",
"custom_selection_confirmed": "사용자 지정 선택이 확인되었습니다!",
"error_coordinates_out_of_range": "오류: 좌표가 범위를 벗어나거나 잘못된 순서입니다 (Lat이 Lng보다 먼저 필요합니다).",
"invalid_format": "잘못된 형식입니다. 'lat,lng,lat,lng' 또는 'lat lng lat lng' 형식을 사용하세요.",
@@ -15,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": "새로운 버전이 있습니다! 여기를 클릭하여 다운로드하세요.",
@@ -25,11 +18,12 @@
"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": "이 지역은 꽤 광범위하여 значитель한 시간과 자원이 필요할 수 있습니다.",
"area_extensive": "이 지역은 꽤 광범위하여 상당한 시간과 자원이 필요할 수 있습니다.",
"selection_confirmed": "선택이 확인되었습니다!",
"select_area_prompt": "도구를 사용하여 지도에서 영역을 선택하세요.",
"unknown_error": "Unknown error",
"license_and_credits": "License and Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "지형만",
"interior": "내부 생성",
"roof": "지붕 생성",
"fillground": "지면 채우기"
"fillground": "지면 채우기",
"city_boundaries": "도시 지면",
"bedrock_auto_generated": "Bedrock 월드는 자동 생성됩니다",
"save_path": "저장 경로"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Vietos pasirinkimas",
"zoom_in_and_choose": "Pasididinkite žemėlapį ir pasirinkite plotą su kvadrato įrankiu",
"select_world": "Pasaulio pasirinkimas",
"choose_world": "Pasirinkti pasaulį",
"no_world_selected": "Pasaulis nepasirinktas",
"create_world": "Sukurti pasaulį",
"no_world_selected": "Pasaulis nesukurtas",
"start_generation": "Pradėti generaciją",
"progress": "Progresas",
"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).",
"invalid_format": "Neteisingas formatas. Prašome naudoti 'plat,ilg,plat,ilg' arba 'plat ilg plat ilg'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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ų.",
"selection_confirmed": "Pasirinkimas patvirtintas!",
"select_area_prompt": "Pasirinkite plotą žemėlapyje naudodami įrankius.",
"unknown_error": "Nežinoma klaida",
"license_and_credits": "Licencija ir padėkos",
"placeholder_bbox": "Formatas: plat,lyg,plat,lyg",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Tik reljefas",
"interior": "Interjero generavimas",
"roof": "Stogo generavimas",
"fillground": "Užpildyti pagrindą"
"fillground": "Užpildyti pagrindą",
"city_boundaries": "Miesto žemė",
"bedrock_auto_generated": "Bedrock pasaulis generuojamas automatiškai",
"save_path": "Išsaugojimo kelias"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Izvēlēties atrašanās vietu",
"zoom_in_and_choose": "Pietuviniet un izvēlieties apgabalu",
"select_world": "Izvēlēties pasauli",
"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",
"progress": "Progress",
"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)",
"invalid_format": "Nederīgs formāts. Izmantojiet 'platums,garums,platums,garums' vai 'platums garums platums garums'",
@@ -15,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",
@@ -25,11 +18,12 @@
"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",
"selection_confirmed": "Izvēle apstiprināta!",
"select_area_prompt": "Izvēlieties apgabalu kartē, izmantojot rīkus.",
"unknown_error": "Nezināma kļūda",
"license_and_credits": "Licence un autori",
"placeholder_bbox": "Formāts: platums,garums,platums,garums",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Tikai reljefs",
"interior": "Interjera ģenerēšana",
"roof": "Jumta ģenerēšana",
"fillground": "Aizpildīt zemi"
"fillground": "Aizpildīt zemi",
"city_boundaries": "Pilsētas zeme",
"bedrock_auto_generated": "Bedrock pasaule tiek ģenerēta automātiski",
"save_path": "Saglabāšanas ceļš"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Wybierz lokalizację",
"zoom_in_and_choose": "Przybliż i zaznacz obszar za pomocą prostokąta",
"select_world": "Wybierz świat",
"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",
"progress": "Postęp",
"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ą).",
"invalid_format": "Nieprawidłowy format. Użyj 'szer.,dł.,szer.,dł.' lub 'szer. dł. szer. dł.'.",
@@ -15,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ć.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Wybór potwierdzony!",
"select_area_prompt": "Zaznacz obszar na mapie za pomocą narzędzi.",
"unknown_error": "Nieznany błąd",
"license_and_credits": "Licencja i autorzy",
"placeholder_bbox": "Format: szer,dł,szer,dł",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Tylko teren",
"interior": "Generowanie wnętrza",
"roof": "Generowanie dachu",
"fillground": "Wypełnij podłoże"
"fillground": "Wypełnij podłoże",
"city_boundaries": "Podłoże miejskie",
"bedrock_auto_generated": "Świat Bedrock jest generowany automatycznie",
"save_path": "Ścieżka zapisu"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Выбрать местоположение",
"zoom_in_and_choose": "Приблизьте и выберите область",
"select_world": "Выбрать мир",
"choose_world": "Выбрать мир",
"no_world_selected": "Мир не выбран",
"create_world": "Создать мир",
"no_world_selected": "Мир не создан",
"start_generation": "Начать генерацию",
"progress": "Прогресс",
"custom_selection_confirmed": "Пользовательский выбор подтвержден!",
"error_coordinates_out_of_range": "Ошибка: Координаты находятся вне зоны действия или указаны в неправильном порядке (сначала широта, затем долгота)",
"invalid_format": "Неверный формат. Используйте 'широта,долгота,широта,долгота' или 'широта долгота широта долгота'",
@@ -15,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": "Доступна новая версия! Нажмите здесь, чтобы скачать",
@@ -25,11 +18,12 @@
"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": "Область довольно обширна и может потребовать значительного времени и ресурсов",
"selection_confirmed": "Выбор подтвержден!",
"select_area_prompt": "Выберите область на карте с помощью инструментов.",
"unknown_error": "Неизвестная ошибка",
"license_and_credits": "Лицензия и авторы",
"placeholder_bbox": "Формат: широта,долгота,широта,долгота",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Только Рельеф",
"interior": "Генерация Интерьера",
"roof": "Генерация Крыши",
"fillground": "Заполнить Землю"
"fillground": "Заполнить Землю",
"city_boundaries": "Городской грунт",
"bedrock_auto_generated": "Мир Bedrock генерируется автоматически",
"save_path": "Путь сохранения"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Välj plats",
"zoom_in_and_choose": "Zooma in och välj ditt område med rektangulärt verktyg",
"select_world": "Välj värld",
"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",
"progress": "Framsteg",
"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).",
"invalid_format": "Ogiltigt format. Använd 'lat,lng,lat,lng' eller 'lat lng lat lng'.",
@@ -15,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.",
@@ -25,11 +18,12 @@
"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.",
"selection_confirmed": "Val bekräftat!",
"select_area_prompt": "Välj ett område på kartan med verktygen.",
"unknown_error": "Unknown error",
"license_and_credits": "License and Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Endast terräng",
"interior": "Interiörgenerering",
"roof": "Takgenerering",
"fillground": "Fyll mark"
"fillground": "Fyll mark",
"city_boundaries": "Stadsmark",
"bedrock_auto_generated": "Bedrock-världen genereras automatiskt",
"save_path": "Sökväg"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "Обрати локацію",
"zoom_in_and_choose": "Збільште і оберіть область за допомогою прямокутника",
"select_world": "Обрати світ",
"choose_world": "Обрати світ",
"no_world_selected": "Світ не обрано",
"create_world": "Створити світ",
"no_world_selected": "Світ не створено",
"start_generation": "Почати генерацію",
"progress": "Прогрес",
"custom_selection_confirmed": "Користувацький вибір підтверджено!",
"error_coordinates_out_of_range": "Помилка: Координати поза діапазоном або неправильно впорядковані (потрібно широта перед довгота)",
"invalid_format": "Неправильний формат. Будь ласка, використовуйте 'широта,довгота,широта,довгота' або 'широта довгота широта довгота'",
@@ -15,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": "Доступна нова версія! Натисніть тут, щоб завантажити її",
@@ -25,11 +18,12 @@
"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": "Область досить велика і може вимагати значного часу та ресурсів",
"selection_confirmed": "Вибір підтверджено!",
"select_area_prompt": "Виберіть область на карті за допомогою інструментів.",
"unknown_error": "Unknown error",
"license_and_credits": "License and Credits",
"placeholder_bbox": "Format: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "Тільки рельєф",
"interior": "Генерація інтер'єру",
"roof": "Генерація даху",
"fillground": "Заповнити землю"
"fillground": "Заповнити землю",
"city_boundaries": "Міська земля",
"bedrock_auto_generated": "Bedrock світ генерується автоматично",
"save_path": "Шлях збереження"
}

View File

@@ -1,11 +1,7 @@
{
"select_location": "选择位置",
"zoom_in_and_choose": "放大并使用矩形工具选择您的区域",
"select_world": "选择世界",
"choose_world": "选择世界",
"no_world_selected": "未选择世界",
"create_world": "创建世界",
"no_world_selected": "未创建世界",
"start_generation": "开始生成",
"progress": "进度",
"custom_selection_confirmed": "自定义选择已确认!",
"error_coordinates_out_of_range": "错误:坐标超出范围或顺序不正确(需要先纬度后经度)。",
"invalid_format": "格式无效。请使用 'lat,lng,lat,lng' 或 'lat lng lat lng'。",
@@ -15,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": "有新版本可用!点击这里下载。",
@@ -25,11 +18,12 @@
"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": "该区域相当广泛,可能需要大量时间和资源。",
"selection_confirmed": "选择已确认!",
"select_area_prompt": "使用工具在地图上选择一个区域。",
"unknown_error": "未知错误",
"license_and_credits": "许可证和致谢",
"placeholder_bbox": "格式: lat,lng,lat,lng",
@@ -44,5 +38,8 @@
"mode_terrain_only": "仅地形",
"interior": "内部生成",
"roof": "屋顶生成",
"fillground": "填充地面"
"fillground": "填充地面",
"city_boundaries": "城市地面",
"bedrock_auto_generated": "Bedrock 世界自动生成",
"save_path": "存档路径"
}

2
src/gui/maps.html vendored
View File

@@ -26,7 +26,7 @@
<div id="search-container">
<div id="search-box">
<input type="text" id="city-search" placeholder="Search for a city..." autocomplete="off" />
<button id="search-btn">🔍</button>
<button id="search-btn" aria-label="Search"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></button>
</div>
<div id="search-results"></div>
</div>

View File

@@ -1,30 +1,41 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod args;
#[cfg(feature = "bedrock")]
mod bedrock_block_map;
mod block_definitions;
mod bresenham;
mod clipping;
mod colors;
mod coordinate_system;
mod data_processing;
mod deterministic_rng;
mod element_processing;
mod elevation_data;
mod floodfill;
mod floodfill_cache;
mod ground;
mod map_renderer;
mod map_transformation;
mod osm_parser;
#[cfg(feature = "gui")]
mod progress;
mod retrieve_data;
#[cfg(feature = "gui")]
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};
mod elevation_data;
#[cfg(feature = "gui")]
mod gui;
@@ -33,6 +44,8 @@ mod gui;
mod progress {
pub fn emit_gui_error(_message: &str) {}
pub fn emit_gui_progress_update(_progress: f64, _message: &str) {}
pub fn emit_map_preview_ready() {}
pub fn emit_open_mcworld_file(_path: &str) {}
pub fn is_running_with_gui() -> bool {
false
}
@@ -41,6 +54,12 @@ mod progress {
use windows::Win32::System::Console::{AttachConsole, FreeConsole, ATTACH_PARENT_PROCESS};
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!(
@@ -74,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),
@@ -114,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() {

944
src/map_renderer.rs Normal file
View File

@@ -0,0 +1,944 @@
// Top-down world map renderer for GUI preview.
//
// Generates a 1:1 pixel-per-block PNG image of the generated world,
// showing the topmost visible block at each position.
use fastanvil::Region;
use fastnbt::{from_bytes, Value};
use fnv::FnvHashMap;
use image::{Rgb, RgbImage};
use once_cell::sync::Lazy;
use rayon::prelude::*;
use std::fs::File;
use std::path::Path;
use std::sync::Mutex;
/// Pre-computed block colors for fast lookup
static BLOCK_COLORS: Lazy<FnvHashMap<&'static str, Rgb<u8>>> = Lazy::new(get_block_colors);
/// Renders a top-down view of the generated Minecraft world.
/// Returns the path to the saved image file.
pub fn render_world_map(
world_dir: &Path,
min_x: i32,
max_x: i32,
min_z: i32,
max_z: i32,
) -> Result<std::path::PathBuf, String> {
let width = (max_x - min_x + 1) as u32;
let height = (max_z - min_z + 1) as u32;
if width == 0 || height == 0 {
return Err("Invalid world bounds".to_string());
}
// Use Mutex for thread-safe image access
let img = Mutex::new(RgbImage::from_pixel(width, height, Rgb([255, 255, 255])));
// Calculate region range
let min_region_x = min_x >> 9; // divide by 512 (32 chunks * 16 blocks)
let max_region_x = max_x >> 9;
let min_region_z = min_z >> 9;
let max_region_z = max_z >> 9;
let region_dir = world_dir.join("region");
// Collect all region coordinates for parallel processing
let region_coords: Vec<(i32, i32)> = (min_region_x..=max_region_x)
.flat_map(|rx| (min_region_z..=max_region_z).map(move |rz| (rx, rz)))
.collect();
// Process regions in parallel
region_coords.par_iter().for_each(|&(region_x, region_z)| {
let region_path = region_dir.join(format!("r.{}.{}.mca", region_x, region_z));
if !region_path.exists() {
return;
}
if let Ok(file) = File::open(&region_path) {
if let Ok(mut region) = Region::from_stream(file) {
// Collect all pixels from this region first
let pixels = render_region_to_pixels(
&mut region,
region_x,
region_z,
min_x,
min_z,
max_x,
max_z,
);
// Then batch-write to image under lock
if !pixels.is_empty() {
let mut img_guard = img.lock().unwrap();
for (x, z, color) in pixels {
if x < img_guard.width() && z < img_guard.height() {
img_guard.put_pixel(x, z, color);
}
}
}
}
}
});
// Save the image
let output_path = world_dir.join("arnis_world_map.png");
img.into_inner()
.unwrap()
.save(&output_path)
.map_err(|e| format!("Failed to save map image: {}", e))?;
Ok(output_path)
}
/// Renders all chunks within a region and returns pixel data
fn render_region_to_pixels(
region: &mut Region<File>,
region_x: i32,
region_z: i32,
min_x: i32,
min_z: i32,
max_x: i32,
max_z: i32,
) -> Vec<(u32, u32, Rgb<u8>)> {
let mut pixels = Vec::new();
let region_base_x = region_x * 512;
let region_base_z = region_z * 512;
for chunk_local_x in 0..32 {
for chunk_local_z in 0..32 {
let chunk_base_x = region_base_x + chunk_local_x * 16;
let chunk_base_z = region_base_z + chunk_local_z * 16;
// Skip chunks outside our bounds
if chunk_base_x + 15 < min_x
|| chunk_base_x > max_x
|| chunk_base_z + 15 < min_z
|| chunk_base_z > max_z
{
continue;
}
if let Ok(Some(chunk_data)) =
region.read_chunk(chunk_local_x as usize, chunk_local_z as usize)
{
render_chunk_to_pixels(
&chunk_data,
&mut pixels,
chunk_base_x,
chunk_base_z,
min_x,
min_z,
max_x,
max_z,
);
}
}
}
pixels
}
/// Renders a single chunk and appends pixel data
#[allow(clippy::too_many_arguments)]
fn render_chunk_to_pixels(
chunk_data: &[u8],
pixels: &mut Vec<(u32, u32, Rgb<u8>)>,
chunk_base_x: i32,
chunk_base_z: i32,
min_x: i32,
min_z: i32,
max_x: i32,
max_z: i32,
) {
// Parse chunk NBT - look for Level.sections or sections depending on format
let chunk: Value = match from_bytes(chunk_data) {
Ok(v) => v,
Err(_) => return,
};
// Try to get sections from the chunk data
let sections = get_sections_from_chunk(&chunk);
if sections.is_empty() {
return;
}
// Pre-sort sections by Y (descending) once per chunk, not per column
let sorted_sections = get_sorted_sections(&sections);
if sorted_sections.is_empty() {
return;
}
// For each column in the chunk
for local_x in 0..16 {
for local_z in 0..16 {
let world_x = chunk_base_x + local_x;
let world_z = chunk_base_z + local_z;
// Skip if outside our bounds
if world_x < min_x || world_x > max_x || world_z < min_z || world_z > max_z {
continue;
}
// Find topmost non-air block using pre-sorted sections
if let Some((block_name, world_y)) =
find_top_block_sorted(&sorted_sections, local_x as usize, local_z as usize)
{
// Strip minecraft: prefix for lookup
let short_name = block_name.strip_prefix("minecraft:").unwrap_or(&block_name);
let base_color = BLOCK_COLORS
.get(short_name)
.copied()
.unwrap_or_else(|| get_fallback_color(&block_name));
// Apply elevation shading
let color = apply_elevation_shading(base_color, world_y);
let img_x = (world_x - min_x) as u32;
let img_z = (world_z - min_z) as u32;
pixels.push((img_x, img_z, color));
}
}
}
}
/// Applies elevation-based shading to a color
/// Higher elevations are brighter, lower are darker
#[inline]
fn apply_elevation_shading(color: Rgb<u8>, y: i32) -> Rgb<u8> {
// Base brightness boost of 10%, plus elevation shading
// Shading range: -20% darker to +20% brighter (asymmetric, more bright than dark)
// Normalize Y to a -1.0 to 1.0 range (roughly)
// y=0 -> -0.5, y=0 -> 0, y=200 -> +1.0
let normalized = (y as f32 / 100.0).clamp(-1.0, 1.0);
// Base 10% brightness boost + asymmetric elevation shading
let elevation_adjust = if normalized >= 0.0 {
// Above sea level: up to +20% brighter
normalized * 0.20
} else {
// Below sea level: up to -20% darker
normalized * 0.20
};
let multiplier = 1.10 + elevation_adjust;
Rgb([
(color.0[0] as f32 * multiplier).clamp(0.0, 255.0) as u8,
(color.0[1] as f32 * multiplier).clamp(0.0, 255.0) as u8,
(color.0[2] as f32 * multiplier).clamp(0.0, 255.0) as u8,
])
}
/// Extracts sections from chunk data (handles both old and new formats)
fn get_sections_from_chunk(chunk: &Value) -> Vec<&Value> {
let mut sections = Vec::new();
// Try new format (1.18+): directly in chunk
if let Value::Compound(map) = chunk {
if let Some(Value::List(secs)) = map.get("sections") {
for sec in secs {
sections.push(sec);
}
return sections;
}
// Try via Level wrapper (older format)
if let Some(Value::Compound(level)) = map.get("Level") {
if let Some(Value::List(secs)) = level.get("sections") {
for sec in secs {
sections.push(sec);
}
}
}
}
sections
}
/// Pre-sorts sections by Y coordinate (descending) - called once per chunk
/// Returns Vec of (section_y, section_value) for Y tracking
fn get_sorted_sections<'a>(sections: &[&'a Value]) -> Vec<(i8, &'a Value)> {
let mut sorted: Vec<(i8, &Value)> = sections
.iter()
.filter_map(|s| {
if let Value::Compound(map) = s {
if let Some(Value::Byte(y)) = map.get("Y") {
return Some((*y, *s));
}
}
None
})
.collect();
sorted.sort_by(|a, b| b.0.cmp(&a.0));
sorted
}
/// Finds the topmost non-air block using pre-sorted sections
/// Returns (block_name, world_y) where world_y is the actual Y coordinate
fn find_top_block_sorted(
sorted_sections: &[(i8, &Value)],
local_x: usize,
local_z: usize,
) -> Option<(String, i32)> {
for (section_y, section) in sorted_sections {
if let Some((block_name, local_y)) = get_block_at_section(section, local_x, local_z) {
if !is_transparent_block(&block_name) {
// Calculate world Y: section_y * 16 + local_y
let world_y = (*section_y as i32) * 16 + local_y as i32;
return Some((block_name, world_y));
}
}
}
None
}
/// Gets the topmost non-air block in a section at the given x,z
/// Returns (block_name, local_y) where local_y is 0-15 within the section
fn get_block_at_section(
section: &Value,
local_x: usize,
local_z: usize,
) -> Option<(String, usize)> {
let section_map = match section {
Value::Compound(m) => m,
_ => return None,
};
let block_states = match section_map.get("block_states") {
Some(Value::Compound(bs)) => bs,
_ => return None,
};
let palette = match block_states.get("palette") {
Some(Value::List(p)) => p,
_ => return None,
};
// If palette has only one block, that's the block for the entire section
if palette.len() == 1 {
// Return with local_y=15 (top of section) for single-block sections
return get_block_name_from_palette(&palette[0]).map(|name| (name, 15));
}
let data = match block_states.get("data") {
Some(Value::LongArray(d)) => d,
_ => return None,
};
// Calculate bits per block
let bits_per_block = std::cmp::max(4, (palette.len() as f64).log2().ceil() as usize);
let blocks_per_long = 64 / bits_per_block;
let mask = (1u64 << bits_per_block) - 1;
// Search from top (y=15) to bottom (y=0) within this section
for local_y in (0..16).rev() {
let block_index = local_y * 256 + local_z * 16 + local_x;
let long_index = block_index / blocks_per_long;
let bit_offset = (block_index % blocks_per_long) * bits_per_block;
if long_index >= data.len() {
continue;
}
let palette_index = ((data[long_index] as u64 >> bit_offset) & mask) as usize;
if palette_index < palette.len() {
if let Some(name) = get_block_name_from_palette(&palette[palette_index]) {
if !is_transparent_block(&name) {
return Some((name, local_y));
}
}
}
}
None
}
/// Extracts block name from a palette entry
fn get_block_name_from_palette(entry: &Value) -> Option<String> {
if let Value::Compound(map) = entry {
if let Some(Value::String(name)) = map.get("Name") {
return Some(name.clone());
}
}
None
}
/// Checks if a block should be considered transparent (look through it)
fn is_transparent_block(name: &str) -> bool {
let short_name = name.strip_prefix("minecraft:").unwrap_or(name);
matches!(
short_name,
"air"
| "cave_air"
| "void_air"
| "glass"
| "glass_pane"
| "white_stained_glass"
| "gray_stained_glass"
| "light_gray_stained_glass"
| "brown_stained_glass"
| "tinted_glass"
| "barrier"
| "light"
| "short_grass"
| "tall_grass"
| "dead_bush"
| "poppy"
| "dandelion"
| "blue_orchid"
| "azure_bluet"
| "iron_bars"
| "ladder"
| "scaffolding"
| "rail"
| "powered_rail"
| "detector_rail"
| "activator_rail"
)
}
/// Returns a fallback color based on block name patterns
fn get_fallback_color(name: &str) -> Rgb<u8> {
// Try to guess color from name
if name.contains("stone") || name.contains("cobble") || name.contains("andesite") {
return Rgb([128, 128, 128]);
}
if name.contains("dirt") || name.contains("mud") {
return Rgb([139, 90, 43]);
}
if name.contains("sand") {
return Rgb([219, 211, 160]);
}
if name.contains("grass") {
return Rgb([86, 125, 70]);
}
if name.contains("water") {
return Rgb([59, 86, 165]);
}
if name.contains("log") || name.contains("wood") {
return Rgb([101, 76, 48]);
}
if name.contains("leaves") {
return Rgb([55, 95, 36]);
}
if name.contains("planks") {
return Rgb([162, 130, 78]);
}
if name.contains("brick") {
return Rgb([150, 97, 83]);
}
if name.contains("concrete") {
return Rgb([128, 128, 128]);
}
if name.contains("wool") || name.contains("carpet") {
return Rgb([220, 220, 220]);
}
if name.contains("terracotta") {
return Rgb([152, 94, 67]);
}
if name.contains("iron") {
return Rgb([200, 200, 200]);
}
if name.contains("gold") {
return Rgb([255, 215, 0]);
}
if name.contains("diamond") {
return Rgb([97, 219, 213]);
}
if name.contains("emerald") {
return Rgb([17, 160, 54]);
}
if name.contains("lapis") {
return Rgb([38, 67, 156]);
}
if name.contains("redstone") {
return Rgb([170, 0, 0]);
}
if name.contains("netherrack") || name.contains("nether") {
return Rgb([111, 54, 53]);
}
if name.contains("end_stone") {
return Rgb([219, 222, 158]);
}
if name.contains("obsidian") {
return Rgb([15, 10, 24]);
}
if name.contains("deepslate") {
return Rgb([72, 72, 73]);
}
if name.contains("blackstone") {
return Rgb([42, 36, 41]);
}
if name.contains("quartz") {
return Rgb([235, 229, 222]);
}
if name.contains("prismarine") {
return Rgb([76, 128, 113]);
}
if name.contains("copper") {
return Rgb([192, 107, 79]);
}
if name.contains("amethyst") {
return Rgb([133, 97, 191]);
}
if name.contains("moss") {
return Rgb([89, 109, 45]);
}
if name.contains("dripstone") {
return Rgb([134, 107, 92]);
}
// Default gray for unknown blocks
Rgb([160, 160, 160])
}
/// Returns a mapping of common block names to RGB colors (without minecraft: prefix)
fn get_block_colors() -> FnvHashMap<&'static str, Rgb<u8>> {
FnvHashMap::from_iter([
("grass_block", Rgb([86, 125, 70])),
("short_grass", Rgb([86, 125, 70])),
("tall_grass", Rgb([86, 125, 70])),
("dirt", Rgb([139, 90, 43])),
("coarse_dirt", Rgb([119, 85, 59])),
("podzol", Rgb([91, 63, 24])),
("rooted_dirt", Rgb([144, 103, 76])),
("mud", Rgb([60, 57, 61])),
("stone", Rgb([128, 128, 128])),
("granite", Rgb([149, 108, 91])),
("polished_granite", Rgb([154, 112, 98])),
("diorite", Rgb([189, 188, 189])),
("polished_diorite", Rgb([195, 195, 195])),
("andesite", Rgb([136, 136, 137])),
("polished_andesite", Rgb([132, 135, 134])),
("deepslate", Rgb([72, 72, 73])),
("cobbled_deepslate", Rgb([77, 77, 80])),
("polished_deepslate", Rgb([72, 72, 73])),
("deepslate_bricks", Rgb([70, 70, 71])),
("deepslate_tiles", Rgb([54, 54, 55])),
("calcite", Rgb([223, 224, 220])),
("tuff", Rgb([108, 109, 102])),
("dripstone_block", Rgb([134, 107, 92])),
("sand", Rgb([219, 211, 160])),
("red_sand", Rgb([190, 102, 33])),
("gravel", Rgb([131, 127, 126])),
("clay", Rgb([160, 166, 179])),
("bedrock", Rgb([85, 85, 85])),
("water", Rgb([59, 86, 165])),
("ice", Rgb([145, 183, 253])),
("packed_ice", Rgb([141, 180, 250])),
("blue_ice", Rgb([116, 167, 253])),
("snow", Rgb([249, 254, 254])),
("snow_block", Rgb([249, 254, 254])),
("powder_snow", Rgb([248, 253, 253])),
("oak_log", Rgb([109, 85, 50])),
("oak_planks", Rgb([162, 130, 78])),
("oak_slab", Rgb([162, 130, 78])),
("oak_stairs", Rgb([162, 130, 78])),
("oak_fence", Rgb([162, 130, 78])),
("oak_door", Rgb([162, 130, 78])),
("spruce_log", Rgb([58, 37, 16])),
("spruce_planks", Rgb([115, 85, 49])),
("spruce_slab", Rgb([115, 85, 49])),
("spruce_stairs", Rgb([115, 85, 49])),
("spruce_fence", Rgb([115, 85, 49])),
("spruce_door", Rgb([115, 85, 49])),
("birch_log", Rgb([216, 215, 210])),
("birch_planks", Rgb([196, 179, 123])),
("birch_slab", Rgb([196, 179, 123])),
("birch_stairs", Rgb([196, 179, 123])),
("birch_fence", Rgb([196, 179, 123])),
("birch_door", Rgb([196, 179, 123])),
("jungle_log", Rgb([85, 68, 25])),
("jungle_planks", Rgb([160, 115, 81])),
("acacia_log", Rgb([103, 96, 86])),
("acacia_planks", Rgb([168, 90, 50])),
("dark_oak_log", Rgb([60, 46, 26])),
("dark_oak_planks", Rgb([67, 43, 20])),
("dark_oak_slab", Rgb([67, 43, 20])),
("dark_oak_stairs", Rgb([67, 43, 20])),
("dark_oak_fence", Rgb([67, 43, 20])),
("dark_oak_door", Rgb([67, 43, 20])),
("mangrove_log", Rgb([84, 66, 36])),
("mangrove_planks", Rgb([117, 54, 48])),
("cherry_log", Rgb([54, 33, 44])),
("cherry_planks", Rgb([226, 178, 172])),
("bamboo_block", Rgb([122, 129, 52])),
("bamboo_planks", Rgb([194, 175, 93])),
("crimson_stem", Rgb([92, 25, 29])),
("crimson_planks", Rgb([101, 48, 70])),
("warped_stem", Rgb([58, 58, 77])),
("warped_planks", Rgb([43, 104, 99])),
("oak_leaves", Rgb([55, 95, 36])),
("spruce_leaves", Rgb([61, 99, 61])),
("birch_leaves", Rgb([80, 106, 47])),
("jungle_leaves", Rgb([48, 113, 20])),
("acacia_leaves", Rgb([75, 104, 40])),
("dark_oak_leaves", Rgb([35, 82, 11])),
("mangrove_leaves", Rgb([69, 123, 38])),
("cherry_leaves", Rgb([228, 177, 197])),
("azalea_leaves", Rgb([71, 96, 37])),
("stone_bricks", Rgb([122, 122, 122])),
("stone_brick_slab", Rgb([122, 122, 122])),
("stone_brick_stairs", Rgb([122, 122, 122])),
("stone_brick_wall", Rgb([122, 122, 122])),
("mossy_stone_bricks", Rgb([115, 121, 105])),
("mossy_stone_brick_slab", Rgb([115, 121, 105])),
("mossy_stone_brick_stairs", Rgb([115, 121, 105])),
("mossy_stone_brick_wall", Rgb([115, 121, 105])),
("cracked_stone_bricks", Rgb([118, 117, 118])),
("chiseled_stone_bricks", Rgb([119, 119, 119])),
("cobblestone", Rgb([128, 127, 127])),
("cobblestone_slab", Rgb([128, 127, 127])),
("cobblestone_stairs", Rgb([128, 127, 127])),
("cobblestone_wall", Rgb([128, 127, 127])),
("mossy_cobblestone", Rgb([110, 118, 94])),
("mossy_cobblestone_slab", Rgb([110, 118, 94])),
("mossy_cobblestone_stairs", Rgb([110, 118, 94])),
("mossy_cobblestone_wall", Rgb([110, 118, 94])),
("stone_slab", Rgb([128, 128, 128])),
("stone_stairs", Rgb([128, 128, 128])),
("smooth_stone", Rgb([158, 158, 158])),
("smooth_stone_slab", Rgb([158, 158, 158])),
("bricks", Rgb([150, 97, 83])),
("brick_slab", Rgb([150, 97, 83])),
("brick_stairs", Rgb([150, 97, 83])),
("brick_wall", Rgb([150, 97, 83])),
("mud_bricks", Rgb([137, 103, 79])),
("mud_brick_slab", Rgb([137, 103, 79])),
("mud_brick_stairs", Rgb([137, 103, 79])),
("mud_brick_wall", Rgb([137, 103, 79])),
("terracotta", Rgb([152, 94, 67])),
("white_terracotta", Rgb([210, 178, 161])),
("orange_terracotta", Rgb([162, 84, 38])),
("magenta_terracotta", Rgb([149, 88, 109])),
("light_blue_terracotta", Rgb([113, 109, 138])),
("yellow_terracotta", Rgb([186, 133, 35])),
("lime_terracotta", Rgb([104, 118, 53])),
("pink_terracotta", Rgb([162, 78, 79])),
("gray_terracotta", Rgb([58, 42, 36])),
("light_gray_terracotta", Rgb([135, 107, 98])),
("cyan_terracotta", Rgb([87, 91, 91])),
("purple_terracotta", Rgb([118, 70, 86])),
("blue_terracotta", Rgb([74, 60, 91])),
("brown_terracotta", Rgb([77, 51, 36])),
("green_terracotta", Rgb([76, 83, 42])),
("red_terracotta", Rgb([143, 61, 47])),
("black_terracotta", Rgb([37, 23, 16])),
("white_concrete", Rgb([207, 213, 214])),
("orange_concrete", Rgb([224, 97, 0])),
("magenta_concrete", Rgb([169, 48, 159])),
("light_blue_concrete", Rgb([35, 137, 198])),
("yellow_concrete", Rgb([241, 175, 21])),
("lime_concrete", Rgb([94, 169, 24])),
("pink_concrete", Rgb([214, 101, 143])),
("gray_concrete", Rgb([55, 58, 62])),
("light_gray_concrete", Rgb([125, 125, 115])),
("cyan_concrete", Rgb([21, 119, 136])),
("purple_concrete", Rgb([100, 32, 156])),
("blue_concrete", Rgb([45, 47, 143])),
("brown_concrete", Rgb([96, 60, 32])),
("green_concrete", Rgb([73, 91, 36])),
("red_concrete", Rgb([142, 33, 33])),
("black_concrete", Rgb([8, 10, 15])),
("white_wool", Rgb([234, 236, 237])),
("orange_wool", Rgb([241, 118, 20])),
("magenta_wool", Rgb([190, 68, 179])),
("light_blue_wool", Rgb([58, 175, 217])),
("yellow_wool", Rgb([249, 198, 40])),
("lime_wool", Rgb([112, 185, 26])),
("pink_wool", Rgb([238, 141, 172])),
("gray_wool", Rgb([63, 68, 72])),
("light_gray_wool", Rgb([142, 142, 135])),
("cyan_wool", Rgb([21, 138, 145])),
("purple_wool", Rgb([122, 42, 173])),
("blue_wool", Rgb([53, 57, 157])),
("brown_wool", Rgb([114, 72, 41])),
("green_wool", Rgb([85, 110, 28])),
("red_wool", Rgb([161, 39, 35])),
("black_wool", Rgb([21, 21, 26])),
("sandstone", Rgb([223, 214, 170])),
("sandstone_slab", Rgb([223, 214, 170])),
("sandstone_stairs", Rgb([223, 214, 170])),
("sandstone_wall", Rgb([223, 214, 170])),
("chiseled_sandstone", Rgb([223, 214, 170])),
("cut_sandstone", Rgb([225, 217, 171])),
("cut_sandstone_slab", Rgb([225, 217, 171])),
("smooth_sandstone", Rgb([223, 214, 170])),
("smooth_sandstone_slab", Rgb([223, 214, 170])),
("smooth_sandstone_stairs", Rgb([223, 214, 170])),
("red_sandstone", Rgb([186, 99, 29])),
("red_sandstone_slab", Rgb([186, 99, 29])),
("red_sandstone_stairs", Rgb([186, 99, 29])),
("red_sandstone_wall", Rgb([186, 99, 29])),
("smooth_red_sandstone", Rgb([186, 99, 29])),
("netherrack", Rgb([111, 54, 53])),
("nether_bricks", Rgb([44, 21, 26])),
("nether_brick_slab", Rgb([44, 21, 26])),
("nether_brick_stairs", Rgb([44, 21, 26])),
("nether_brick_wall", Rgb([44, 21, 26])),
("nether_brick_fence", Rgb([44, 21, 26])),
("red_nether_bricks", Rgb([69, 7, 9])),
("red_nether_brick_slab", Rgb([69, 7, 9])),
("red_nether_brick_stairs", Rgb([69, 7, 9])),
("red_nether_brick_wall", Rgb([69, 7, 9])),
("soul_sand", Rgb([81, 62, 51])),
("soul_soil", Rgb([75, 57, 46])),
("basalt", Rgb([73, 72, 77])),
("polished_basalt", Rgb([88, 87, 91])),
("smooth_basalt", Rgb([72, 72, 78])),
("blackstone", Rgb([42, 36, 41])),
("blackstone_slab", Rgb([42, 36, 41])),
("blackstone_stairs", Rgb([42, 36, 41])),
("blackstone_wall", Rgb([42, 36, 41])),
("polished_blackstone", Rgb([53, 49, 56])),
("polished_blackstone_bricks", Rgb([48, 43, 50])),
("polished_blackstone_brick_slab", Rgb([48, 43, 50])),
("polished_blackstone_brick_stairs", Rgb([48, 43, 50])),
("polished_blackstone_brick_wall", Rgb([48, 43, 50])),
("glowstone", Rgb([171, 131, 84])),
("shroomlight", Rgb([240, 146, 70])),
("crying_obsidian", Rgb([32, 10, 60])),
("obsidian", Rgb([15, 10, 24])),
("end_stone", Rgb([219, 222, 158])),
("end_stone_bricks", Rgb([218, 224, 162])),
("end_stone_brick_slab", Rgb([218, 224, 162])),
("end_stone_brick_stairs", Rgb([218, 224, 162])),
("end_stone_brick_wall", Rgb([218, 224, 162])),
("purpur_block", Rgb([170, 126, 170])),
("purpur_pillar", Rgb([171, 129, 171])),
("purpur_slab", Rgb([170, 126, 170])),
("purpur_stairs", Rgb([170, 126, 170])),
("coal_ore", Rgb([105, 105, 105])),
("iron_ore", Rgb([136, 130, 127])),
("copper_ore", Rgb([124, 125, 120])),
("gold_ore", Rgb([143, 140, 125])),
("redstone_ore", Rgb([133, 107, 107])),
("emerald_ore", Rgb([108, 136, 115])),
("lapis_ore", Rgb([99, 112, 135])),
("diamond_ore", Rgb([121, 141, 140])),
("coal_block", Rgb([16, 15, 15])),
("iron_block", Rgb([220, 220, 220])),
("copper_block", Rgb([192, 107, 79])),
("gold_block", Rgb([246, 208, 62])),
("redstone_block", Rgb([170, 0, 0])),
("emerald_block", Rgb([42, 203, 88])),
("lapis_block", Rgb([38, 67, 156])),
("diamond_block", Rgb([97, 219, 213])),
("netherite_block", Rgb([66, 61, 63])),
("amethyst_block", Rgb([133, 97, 191])),
("raw_iron_block", Rgb([166, 136, 107])),
("raw_copper_block", Rgb([154, 105, 79])),
("raw_gold_block", Rgb([221, 169, 46])),
("quartz_block", Rgb([235, 229, 222])),
("quartz_slab", Rgb([235, 229, 222])),
("quartz_stairs", Rgb([235, 229, 222])),
("smooth_quartz", Rgb([235, 229, 222])),
("smooth_quartz_slab", Rgb([235, 229, 222])),
("smooth_quartz_stairs", Rgb([235, 229, 222])),
("quartz_bricks", Rgb([234, 229, 221])),
("quartz_pillar", Rgb([235, 230, 224])),
("chiseled_quartz_block", Rgb([231, 226, 218])),
("prismarine", Rgb([76, 128, 113])),
("prismarine_slab", Rgb([76, 128, 113])),
("prismarine_stairs", Rgb([76, 128, 113])),
("prismarine_wall", Rgb([76, 128, 113])),
("prismarine_bricks", Rgb([99, 172, 158])),
("prismarine_brick_slab", Rgb([99, 172, 158])),
("prismarine_brick_stairs", Rgb([99, 172, 158])),
("dark_prismarine", Rgb([51, 91, 75])),
("dark_prismarine_slab", Rgb([51, 91, 75])),
("dark_prismarine_stairs", Rgb([51, 91, 75])),
("sea_lantern", Rgb([172, 199, 190])),
("exposed_copper", Rgb([161, 125, 103])),
("weathered_copper", Rgb([109, 145, 107])),
("oxidized_copper", Rgb([82, 162, 132])),
("cut_copper", Rgb([191, 106, 80])),
("cut_copper_slab", Rgb([191, 106, 80])),
("cut_copper_stairs", Rgb([191, 106, 80])),
("exposed_cut_copper", Rgb([154, 121, 101])),
("exposed_cut_copper_slab", Rgb([154, 121, 101])),
("exposed_cut_copper_stairs", Rgb([154, 121, 101])),
("weathered_cut_copper", Rgb([109, 145, 107])),
("weathered_cut_copper_slab", Rgb([109, 145, 107])),
("weathered_cut_copper_stairs", Rgb([109, 145, 107])),
("oxidized_cut_copper", Rgb([79, 153, 126])),
("oxidized_cut_copper_slab", Rgb([79, 153, 126])),
("oxidized_cut_copper_stairs", Rgb([79, 153, 126])),
("glass", Rgb([200, 220, 230])),
("glass_pane", Rgb([200, 220, 230])),
("white_stained_glass", Rgb([255, 255, 255])),
("white_stained_glass_pane", Rgb([255, 255, 255])),
("orange_stained_glass", Rgb([216, 127, 51])),
("orange_stained_glass_pane", Rgb([216, 127, 51])),
("magenta_stained_glass", Rgb([178, 76, 216])),
("magenta_stained_glass_pane", Rgb([178, 76, 216])),
("light_blue_stained_glass", Rgb([102, 153, 216])),
("light_blue_stained_glass_pane", Rgb([102, 153, 216])),
("yellow_stained_glass", Rgb([229, 229, 51])),
("yellow_stained_glass_pane", Rgb([229, 229, 51])),
("lime_stained_glass", Rgb([127, 204, 25])),
("lime_stained_glass_pane", Rgb([127, 204, 25])),
("pink_stained_glass", Rgb([242, 127, 165])),
("pink_stained_glass_pane", Rgb([242, 127, 165])),
("gray_stained_glass", Rgb([76, 76, 76])),
("gray_stained_glass_pane", Rgb([76, 76, 76])),
("light_gray_stained_glass", Rgb([153, 153, 153])),
("light_gray_stained_glass_pane", Rgb([153, 153, 153])),
("cyan_stained_glass", Rgb([76, 127, 153])),
("cyan_stained_glass_pane", Rgb([76, 127, 153])),
("purple_stained_glass", Rgb([127, 63, 178])),
("purple_stained_glass_pane", Rgb([127, 63, 178])),
("blue_stained_glass", Rgb([51, 76, 178])),
("blue_stained_glass_pane", Rgb([51, 76, 178])),
("brown_stained_glass", Rgb([102, 76, 51])),
("brown_stained_glass_pane", Rgb([102, 76, 51])),
("green_stained_glass", Rgb([102, 127, 51])),
("green_stained_glass_pane", Rgb([102, 127, 51])),
("red_stained_glass", Rgb([153, 51, 51])),
("red_stained_glass_pane", Rgb([153, 51, 51])),
("black_stained_glass", Rgb([25, 25, 25])),
("black_stained_glass_pane", Rgb([25, 25, 25])),
("bookshelf", Rgb([116, 89, 53])),
("hay_block", Rgb([166, 139, 12])),
("melon", Rgb([111, 145, 31])),
("pumpkin", Rgb([198, 118, 24])),
("jack_o_lantern", Rgb([213, 139, 42])),
("carved_pumpkin", Rgb([198, 118, 24])),
("tnt", Rgb([219, 68, 52])),
("sponge", Rgb([195, 192, 74])),
("wet_sponge", Rgb([171, 181, 70])),
("moss_block", Rgb([89, 109, 45])),
("moss_carpet", Rgb([89, 109, 45])),
("sculk", Rgb([12, 28, 36])),
("honeycomb_block", Rgb([229, 148, 29])),
("slime_block", Rgb([111, 192, 91])),
("honey_block", Rgb([251, 185, 52])),
("barrel", Rgb([140, 106, 60])),
("chest", Rgb([155, 113, 48])),
("trapped_chest", Rgb([155, 113, 48])),
("crafting_table", Rgb([144, 109, 67])),
("furnace", Rgb([110, 110, 110])),
("blast_furnace", Rgb([80, 80, 85])),
("smoker", Rgb([90, 80, 70])),
("anvil", Rgb([68, 68, 68])),
("lectern", Rgb([180, 140, 90])),
("composter", Rgb([100, 80, 45])),
("cauldron", Rgb([60, 60, 60])),
("hopper", Rgb([70, 70, 70])),
("jukebox", Rgb([130, 90, 70])),
("note_block", Rgb([120, 80, 65])),
("bell", Rgb([200, 170, 50])),
("dirt_path", Rgb([148, 121, 65])),
("farmland", Rgb([143, 88, 46])),
("mycelium", Rgb([111, 99, 107])),
("rail", Rgb([125, 108, 77])),
("powered_rail", Rgb([153, 126, 55])),
("detector_rail", Rgb([120, 97, 80])),
("activator_rail", Rgb([117, 85, 76])),
("redstone_wire", Rgb([170, 0, 0])),
("redstone_torch", Rgb([170, 0, 0])),
("redstone_lamp", Rgb([180, 130, 70])),
("lever", Rgb([100, 80, 60])),
("tripwire_hook", Rgb([120, 100, 80])),
("torch", Rgb([255, 200, 100])),
("wall_torch", Rgb([255, 200, 100])),
("lantern", Rgb([200, 150, 80])),
("soul_lantern", Rgb([80, 200, 200])),
("soul_torch", Rgb([80, 200, 200])),
("soul_wall_torch", Rgb([80, 200, 200])),
("campfire", Rgb([200, 100, 50])),
("soul_campfire", Rgb([80, 200, 200])),
("candle", Rgb([200, 180, 130])),
("dandelion", Rgb([255, 236, 85])),
("poppy", Rgb([200, 30, 30])),
("blue_orchid", Rgb([47, 186, 199])),
("allium", Rgb([190, 130, 200])),
("azure_bluet", Rgb([220, 230, 220])),
("red_tulip", Rgb([200, 50, 50])),
("orange_tulip", Rgb([230, 130, 50])),
("white_tulip", Rgb([230, 230, 220])),
("pink_tulip", Rgb([220, 150, 170])),
("oxeye_daisy", Rgb([230, 230, 200])),
("cornflower", Rgb([70, 90, 180])),
("lily_of_the_valley", Rgb([230, 230, 230])),
("wither_rose", Rgb([30, 30, 30])),
("sunflower", Rgb([255, 200, 50])),
("lilac", Rgb([200, 150, 200])),
("rose_bush", Rgb([180, 40, 40])),
("peony", Rgb([230, 180, 200])),
("fern", Rgb([80, 120, 60])),
("large_fern", Rgb([80, 120, 60])),
("dead_bush", Rgb([150, 120, 80])),
("seagrass", Rgb([40, 100, 60])),
("tall_seagrass", Rgb([40, 100, 60])),
("kelp", Rgb([50, 110, 60])),
("kelp_plant", Rgb([50, 110, 60])),
("sugar_cane", Rgb([140, 180, 100])),
("bamboo", Rgb([90, 140, 50])),
("vine", Rgb([50, 100, 40])),
("lily_pad", Rgb([40, 110, 40])),
("sweet_berry_bush", Rgb([60, 90, 50])),
("cactus", Rgb([85, 127, 52])),
("white_carpet", Rgb([234, 236, 237])),
("orange_carpet", Rgb([241, 118, 20])),
("magenta_carpet", Rgb([190, 68, 179])),
("light_blue_carpet", Rgb([58, 175, 217])),
("yellow_carpet", Rgb([249, 198, 40])),
("lime_carpet", Rgb([112, 185, 26])),
("pink_carpet", Rgb([238, 141, 172])),
("gray_carpet", Rgb([63, 68, 72])),
("light_gray_carpet", Rgb([142, 142, 135])),
("cyan_carpet", Rgb([21, 138, 145])),
("purple_carpet", Rgb([122, 42, 173])),
("blue_carpet", Rgb([53, 57, 157])),
("brown_carpet", Rgb([114, 72, 41])),
("green_carpet", Rgb([85, 110, 28])),
("red_carpet", Rgb([161, 39, 35])),
("black_carpet", Rgb([21, 21, 26])),
("oak_sign", Rgb([162, 130, 78])),
("oak_wall_sign", Rgb([162, 130, 78])),
("spruce_sign", Rgb([115, 85, 49])),
("spruce_wall_sign", Rgb([115, 85, 49])),
("birch_sign", Rgb([196, 179, 123])),
("birch_wall_sign", Rgb([196, 179, 123])),
("dark_oak_sign", Rgb([67, 43, 20])),
("dark_oak_wall_sign", Rgb([67, 43, 20])),
("white_bed", Rgb([234, 236, 237])),
("orange_bed", Rgb([241, 118, 20])),
("magenta_bed", Rgb([190, 68, 179])),
("light_blue_bed", Rgb([58, 175, 217])),
("yellow_bed", Rgb([249, 198, 40])),
("lime_bed", Rgb([112, 185, 26])),
("pink_bed", Rgb([238, 141, 172])),
("gray_bed", Rgb([63, 68, 72])),
("light_gray_bed", Rgb([142, 142, 135])),
("cyan_bed", Rgb([21, 138, 145])),
("purple_bed", Rgb([122, 42, 173])),
("blue_bed", Rgb([53, 57, 157])),
("brown_bed", Rgb([114, 72, 41])),
("green_bed", Rgb([85, 110, 28])),
("red_bed", Rgb([161, 39, 35])),
("black_bed", Rgb([21, 21, 26])),
("oak_trapdoor", Rgb([162, 130, 78])),
("spruce_trapdoor", Rgb([115, 85, 49])),
("birch_trapdoor", Rgb([196, 179, 123])),
("dark_oak_trapdoor", Rgb([67, 43, 20])),
("iron_trapdoor", Rgb([200, 200, 200])),
("iron_bars", Rgb([150, 150, 150])),
("ladder", Rgb([160, 130, 70])),
("wheat", Rgb([200, 180, 80])),
("carrots", Rgb([230, 140, 30])),
("potatoes", Rgb([180, 160, 80])),
("beetroots", Rgb([150, 50, 50])),
("pumpkin_stem", Rgb([120, 140, 70])),
("melon_stem", Rgb([120, 140, 70])),
])
}

View File

@@ -1,11 +1,12 @@
use crate::clipping::clip_way_to_bbox;
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::coordinate_system::geographic::{LLBBox, LLPoint};
use crate::coordinate_system::transformation::CoordTransformer;
use crate::progress::emit_gui_progress_update;
use colored::Colorize;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
// Raw data from OSM
@@ -28,9 +29,18 @@ struct OsmElement {
pub members: Vec<OsmMember>,
}
#[derive(Deserialize)]
struct OsmData {
pub elements: Vec<OsmElement>,
#[derive(Debug, Deserialize)]
pub struct OsmData {
elements: Vec<OsmElement>,
#[serde(default)]
pub remark: Option<String>,
}
impl OsmData {
/// Returns true if there are no elements in the OSM data
pub fn is_empty(&self) -> bool {
self.elements.is_empty()
}
}
struct SplitOsmData {
@@ -67,11 +77,6 @@ impl SplitOsmData {
}
}
fn parse_raw_osm_data(json_data: Value) -> Result<SplitOsmData, serde_json::Error> {
let osm_data: OsmData = serde_json::from_value(json_data)?;
Ok(SplitOsmData::from_raw_osm_data(osm_data))
}
// End raw data
// Normalized data that we can use
@@ -106,12 +111,13 @@ pub struct ProcessedWay {
pub enum ProcessedMemberRole {
Outer,
Inner,
Part,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProcessedMember {
pub role: ProcessedMemberRole,
pub way: ProcessedWay,
pub way: Arc<ProcessedWay>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -163,7 +169,7 @@ impl ProcessedElement {
}
pub fn parse_osm_data(
json_data: Value,
osm_data: OsmData,
bbox: LLBBox,
scale: f64,
debug: bool,
@@ -173,7 +179,7 @@ pub fn parse_osm_data(
emit_gui_progress_update(5.0, "Parsing data...");
// Deserialize the JSON data into the OSMData structure
let data = parse_raw_osm_data(json_data).expect("Failed to parse OSM data");
let data = SplitOsmData::from_raw_osm_data(osm_data);
let (coord_transformer, xzbbox) = CoordTransformer::llbbox_to_xzbbox(&bbox, scale)
.unwrap_or_else(|e| {
@@ -188,7 +194,7 @@ pub fn parse_osm_data(
}
let mut nodes_map: HashMap<u64, ProcessedNode> = HashMap::new();
let mut ways_map: HashMap<u64, ProcessedWay> = HashMap::new();
let mut ways_map: HashMap<u64, Arc<ProcessedWay>> = HashMap::new();
let mut processed_elements: Vec<ProcessedElement> = Vec::new();
@@ -211,7 +217,14 @@ pub fn parse_osm_data(
nodes_map.insert(element.id, processed.clone());
processed_elements.push(ProcessedElement::Node(processed));
// Only add tagged nodes to processed_elements if they're within or near the bbox
// This significantly improves performance by filtering out distant nodes
if !element.tags.as_ref().map(|t| t.is_empty()).unwrap_or(true) {
// Node has tags, check if it's in the bbox (with some margin)
if xzbbox.contains(&xzpoint) {
processed_elements.push(ProcessedElement::Node(processed));
}
}
}
}
@@ -226,13 +239,31 @@ pub fn parse_osm_data(
}
}
// Clip the way to bbox to reduce node count dramatically
let tags = element.tags.clone().unwrap_or_default();
// Store unclipped way for relation assembly (clipping happens after ring merging)
let way = Arc::new(ProcessedWay {
id: element.id,
tags,
nodes,
});
ways_map.insert(element.id, Arc::clone(&way));
// Clip way nodes for standalone way processing (not relations)
let clipped_nodes = clip_way_to_bbox(&way.nodes, &xzbbox);
// Skip ways that are completely outside the bbox (empty after clipping)
if clipped_nodes.is_empty() {
continue;
}
let processed: ProcessedWay = ProcessedWay {
id: element.id,
tags: element.tags.clone().unwrap_or_default(),
nodes,
tags: way.tags.clone(),
nodes: clipped_nodes,
};
ways_map.insert(element.id, processed.clone());
processed_elements.push(ProcessedElement::Way(processed));
}
@@ -242,11 +273,25 @@ 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
.iter()
@@ -256,22 +301,56 @@ 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
let way: ProcessedWay = match ways_map.get(&mem.r#ref) {
Some(w) => w.clone(),
let way = match ways_map.get(&mem.r#ref) {
Some(w) => Arc::clone(w),
None => {
// Way was likely filtered out because it was completely outside the bbox
return None;
}
};
Some(ProcessedMember { role, way })
// 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);
if clipped_nodes.is_empty() {
return None;
}
Arc::new(ProcessedWay {
id: way.id,
tags: way.tags.clone(),
nodes: clipped_nodes,
})
};
Some(ProcessedMember {
role,
way: final_way,
})
})
.collect();
@@ -284,11 +363,38 @@ pub fn parse_osm_data(
}
}
emit_gui_progress_update(15.0, "");
emit_gui_progress_update(14.0, "");
drop(nodes_map);
drop(ways_map);
(processed_elements, xzbbox)
}
/// Returns true if tags indicate a water element handled by water_areas.rs.
fn is_water_element(tags: &HashMap<String, String>) -> bool {
// Check for explicit water tag
if tags.contains_key("water") {
return true;
}
// Check for natural=water or natural=bay
if let Some(natural_val) = tags.get("natural") {
if natural_val == "water" || natural_val == "bay" {
return true;
}
}
// Check for waterway=dock (also handled as water area)
if let Some(waterway_val) = tags.get("waterway") {
if waterway_val == "dock" {
return true;
}
}
false
}
const PRIORITY_ORDER: [&str; 6] = [
"entrance", "building", "highway", "waterway", "water", "barrier",
];

View File

@@ -1,3 +1,5 @@
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use once_cell::sync::OnceCell;
use serde_json::json;
use tauri::{Emitter, WebviewWindow};
@@ -38,7 +40,10 @@ pub fn emit_gui_progress_update(progress: f64, message: &str) {
});
if let Err(e) = window.emit("progress-update", payload) {
eprintln!("Failed to emit progress event: {e}");
let error_msg = format!("Failed to emit progress event: {}", e);
eprintln!("{}", error_msg);
#[cfg(feature = "gui")]
send_log(LogLevel::Warning, &error_msg);
}
}
}
@@ -51,3 +56,21 @@ pub fn emit_gui_error(message: &str) {
};
emit_gui_progress_update(0.0, &format!("Error! {truncated_message}"));
}
/// Emits an event when the world map preview is ready
pub fn emit_map_preview_ready() {
if let Some(window) = get_main_window() {
if let Err(e) = window.emit("map-preview-ready", ()) {
eprintln!("Failed to emit map-preview-ready event: {}", e);
}
}
}
/// Emits an event to open the generated mcworld file
pub fn emit_open_mcworld_file(path: &str) {
if let Some(window) = get_main_window() {
if let Err(e) = window.emit("open-mcworld-file", path) {
eprintln!("Failed to emit open-mcworld-file event: {}", e);
}
}
}

View File

@@ -1,12 +1,16 @@
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;
use serde_json::Value;
use std::fs::File;
use std::io::{self, BufReader, Write};
use std::io::{self, BufReader, Cursor, Write};
use std::process::Command;
use std::time::Duration;
@@ -34,19 +38,22 @@ fn download_with_reqwest(url: &str, query: &str) -> Result<String, Box<dyn std::
}
Err(e) => {
if e.is_timeout() {
eprintln!(
"{}",
"Error! Request timed out. Try selecting a smaller area."
.red()
.bold()
);
emit_gui_error("Request timed out. Try selecting a smaller area.");
let msg = "Request timed out. Try selecting a smaller area.";
eprintln!("{}", format!("Error! {msg}").red().bold());
Err(msg.into())
} else if e.is_connect() {
let msg = "No internet connection.";
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());
emit_gui_error(&format!("{:.52}", e.to_string()));
Err(format!("{e:.52}").into())
}
// Always propagate errors
Err(e.into())
}
}
}
@@ -79,13 +86,14 @@ fn download_with_wget(url: &str, query: &str) -> io::Result<String> {
}
}
pub fn fetch_data_from_file(file: &str) -> Result<Value, Box<dyn std::error::Error>> {
pub fn fetch_data_from_file(file: &str) -> Result<OsmData, Box<dyn std::error::Error>> {
println!("{} Loading data from file...", "[1/7]".bold());
emit_gui_progress_update(1.0, "Loading data from file...");
let file: File = File::open(file)?;
let reader: BufReader<File> = BufReader::new(file);
let data: Value = serde_json::from_reader(reader)?;
let mut deserializer = serde_json::Deserializer::from_reader(reader);
let data: OsmData = OsmData::deserialize(&mut deserializer)?;
Ok(data)
}
@@ -95,7 +103,7 @@ pub fn fetch_data_from_overpass(
debug: bool,
download_method: &str,
save_file: Option<&str>,
) -> Result<Value, Box<dyn std::error::Error>> {
) -> Result<OsmData, Box<dyn std::error::Error>> {
println!("{} Fetching data...", "[1/7]".bold());
emit_gui_progress_update(1.0, "Fetching data...");
@@ -109,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"];
@@ -126,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;
(
@@ -168,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;
}
}
@@ -182,14 +197,12 @@ pub fn fetch_data_from_overpass(
println!("API response saved to: {save_file}");
}
let data: Value = serde_json::from_str(&response)?;
let mut deserializer =
serde_json::Deserializer::from_reader(Cursor::new(response.as_bytes()));
let data: OsmData = OsmData::deserialize(&mut deserializer)?;
if data["elements"]
.as_array()
.map_or(0, |elements: &Vec<Value>| elements.len())
== 0
{
if let Some(remark) = data["remark"].as_str() {
if data.is_empty() {
if let Some(remark) = data.remark.as_deref() {
// Check if the remark mentions memory or other runtime errors
if remark.contains("runtime error") && remark.contains("out of memory") {
eprintln!("{}", "Error! The query ran out of memory on the Overpass API server. Try using a smaller area.".red().bold());
@@ -211,7 +224,7 @@ pub fn fetch_data_from_overpass(
}
if debug {
println!("Additional debug information: {data}");
println!("Additional debug information: {data:?}");
}
if !is_running_with_gui() {

249
src/telemetry.rs Normal file
View File

@@ -0,0 +1,249 @@
use log::error;
use reqwest::blocking::Client;
use serde::Serialize;
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
/// Telemetry endpoint URL
const TELEMETRY_URL: &str = "https://arnismc.com/telemetry/report_telemetry.php";
/// Global flag to store user's telemetry consent
static TELEMETRY_CONSENT: AtomicBool = AtomicBool::new(false);
/// Sets the user's telemetry consent preference
pub fn set_telemetry_consent(consent: bool) {
TELEMETRY_CONSENT.store(consent, Ordering::Relaxed);
}
/// Gets the user's telemetry consent preference
fn get_telemetry_consent() -> bool {
TELEMETRY_CONSENT.load(Ordering::Relaxed)
}
/// Determines the current platform as a string
fn get_platform() -> &'static str {
match std::env::consts::OS {
"windows" => "windows",
"linux" => "linux",
"macos" => "macos",
_ => "unknown",
}
}
/// Gets the application version from Cargo.toml
fn get_app_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Crash report payload structure
#[derive(Serialize)]
struct CrashReport<'a> {
r#type: &'a str,
error_message: &'a str,
platform: &'a str,
app_version: &'a str,
}
/// Generation click payload structure
#[derive(Serialize)]
struct GenerationClick<'a> {
r#type: &'a str,
}
/// Log entry payload structure
#[derive(Serialize)]
struct LogEntry<'a> {
r#type: &'a str,
log_level: &'a str,
log_message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
platform: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
app_version: Option<&'a str>,
}
/// Sends a crash report to the telemetry server
fn send_crash_report(error_message: String, platform: &str, app_version: &str) {
// Wrap in catch_unwind to prevent any panics during crash reporting
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let payload = CrashReport {
r#type: "crash",
error_message: &error_message,
platform,
app_version,
};
let _res = client
.post(TELEMETRY_URL)
.header("Content-Type", "application/json")
.json(&payload)
.send()?;
Ok(())
})();
}));
}
/// Sends a generation click event to the telemetry server
pub fn send_generation_click() {
// Check user consent
if !get_telemetry_consent() {
return;
}
// Only send in release builds
if cfg!(debug_assertions) {
return;
}
// Send in background thread to avoid blocking UI
// Wrap in catch_unwind to prevent any panics from escaping
let _ = std::thread::spawn(|| {
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let payload = GenerationClick {
r#type: "generation_click",
};
let _res = client
.post(TELEMETRY_URL)
.header("Content-Type", "application/json")
.json(&payload)
.send()?;
Ok(())
})();
}));
});
}
/// Log levels for telemetry
#[allow(dead_code)]
pub enum LogLevel {
Debug,
Info,
Warning,
Error,
}
impl LogLevel {
fn as_str(&self) -> &'static str {
match self {
LogLevel::Debug => "debug",
LogLevel::Info => "info",
LogLevel::Warning => "warning",
LogLevel::Error => "error",
}
}
}
/// Sends a log entry to the telemetry server
pub fn send_log(level: LogLevel, message: &str) {
// Check user consent
if !get_telemetry_consent() {
return;
}
// Only send in release builds
if cfg!(debug_assertions) {
return;
}
// Truncate message to 1000 characters
let truncated_message = if message.chars().count() > 1000 {
message.chars().take(1000).collect::<String>()
} else {
message.to_string()
};
let platform = get_platform();
let app_version = get_app_version();
// Send in background thread to avoid blocking
// Wrap in catch_unwind to prevent any panics from escaping
let _ = std::thread::spawn(move || {
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let payload = LogEntry {
r#type: "log",
log_level: level.as_str(),
log_message: &truncated_message,
platform: Some(platform),
app_version: Some(app_version),
};
let _res = client
.post(TELEMETRY_URL)
.header("Content-Type", "application/json")
.json(&payload)
.send()?;
Ok(())
})();
}));
});
}
/// Installs a panic hook that logs panics and sends crash reports
pub fn install_panic_hook() {
panic::set_hook(Box::new(|panic_info| {
// Log the panic to both stderr and log file
error!("Application panicked: {:?}", panic_info);
// Filter out secondary "panic in a function that cannot unwind" panics
if let Some(location) = panic_info.location() {
if location.file().contains("panicking.rs") {
return;
}
}
// Check user consent
if !get_telemetry_consent() {
return;
}
// Only send crash reports in release builds
if cfg!(debug_assertions) {
return;
}
// Everything else wrapped in catch_unwind to prevent secondary panics
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
// Extract panic payload
let payload = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
// Extract location
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown location".to_string());
// Combine payload and location
let mut error_message = format!("{} @ {}", payload, location);
// Truncate to 500 Unicode characters
if error_message.chars().count() > 500 {
error_message = error_message.chars().take(500).collect();
}
let platform = get_platform();
let app_version = get_app_version();
// Send crash report (best-effort, ignore all errors)
send_crash_report(error_message, platform, app_version);
}));
}));
}

View File

@@ -7,9 +7,8 @@ use crate::retrieve_data;
// this is copied from main.rs
pub fn generate_example(llbbox: LLBBox) -> (XZBBox, Vec<ProcessedElement>) {
// Fetch data
let raw_data: serde_json::Value =
retrieve_data::fetch_data_from_overpass(llbbox, false, "requests", None)
.expect("Failed to fetch data");
let raw_data = retrieve_data::fetch_data_from_overpass(llbbox, false, "requests", None)
.expect("Failed to fetch data");
// Parse raw data
let (mut parsed_elements, xzbbox) = osm_parser::parse_osm_data(raw_data, llbbox, 1.0, false);

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

File diff suppressed because it is too large Load Diff

1229
src/world_editor/bedrock.rs Normal file
View File

File diff suppressed because it is too large Load Diff

563
src/world_editor/common.rs Normal file
View File

@@ -0,0 +1,563 @@
//! Common data structures for world modification.
//!
//! This module contains the internal data structures used to track block changes
//! before they are written to either Java or Bedrock format.
use crate::block_definitions::*;
/// Minimum Y coordinate in Minecraft (1.18+)
const MIN_Y: i32 = -64;
/// Maximum Y coordinate in Minecraft (1.18+)
const MAX_Y: i32 = 319;
use fastnbt::{LongArray, Value};
use fnv::FnvHashMap;
use serde::{Deserialize, Serialize};
/// Chunk structure for Java Edition NBT format
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Chunk {
pub sections: Vec<Section>,
pub x_pos: i32,
pub z_pos: i32,
#[serde(default)]
pub is_light_on: u8,
#[serde(flatten)]
pub other: FnvHashMap<String, Value>,
}
/// Section within a chunk (16x16x16 blocks)
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct Section {
pub block_states: Blockstates,
#[serde(rename = "Y")]
pub y: i8,
#[serde(flatten)]
pub other: FnvHashMap<String, Value>,
}
/// Block states within a section
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct Blockstates {
pub palette: Vec<PaletteItem>,
pub data: Option<LongArray>,
#[serde(flatten)]
pub other: FnvHashMap<String, Value>,
}
/// Palette item for block state encoding
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct PaletteItem {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Properties")]
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 storage: BlockStorage,
/// Store properties for blocks that have them, indexed by the same index as blocks array
pub properties: FnvHashMap<usize, Value>,
}
impl SectionToModify {
#[inline]
pub fn get_block(&self, x: u8, y: u8, z: u8) -> Option<Block> {
let b = self.storage.get(Self::index(x, y, z));
if b == AIR {
return None;
}
Some(b)
}
#[inline]
pub fn set_block(&mut self, x: u8, y: u8, z: u8, block: Block) {
let index = Self::index(x, y, z);
self.storage.set(index, block);
self.properties.remove(&index);
}
#[inline]
pub fn set_block_with_properties(
&mut self,
x: u8,
y: u8,
z: u8,
block_with_props: BlockWithProperties,
) {
let index = Self::index(x, y, z);
self.storage.set(index, block_with_props.block);
// Store properties if they exist
if let Some(props) = block_with_props.properties {
self.properties.insert(index, props);
} else {
// Remove any existing properties for this position
self.properties.remove(&index);
}
}
/// 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.storage.iter().enumerate() {
let properties = self.properties.get(&i).cloned();
// Create a key for the lookup (block + properties hash)
let props_key = properties.as_ref().map(|p| format!("{p:?}"));
let lookup_key = (block, props_key);
if let std::collections::hash_map::Entry::Vacant(e) = palette_lookup.entry(lookup_key) {
let palette_index = unique_blocks.len();
e.insert(palette_index);
unique_blocks.push((block, properties));
}
}
let mut bits_per_block = 4; // minimum allowed
while (1 << bits_per_block) < unique_blocks.len() {
bits_per_block += 1;
}
let mut data = vec![];
let mut cur = 0;
let mut cur_idx = 0;
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);
let p = palette_lookup[&lookup_key] as i64;
if cur_idx + bits_per_block > 64 {
data.push(cur);
cur = 0;
cur_idx = 0;
}
cur |= p << cur_idx;
cur_idx += bits_per_block;
}
if cur_idx > 0 {
data.push(cur);
}
let palette = unique_blocks
.iter()
.map(|(block, stored_props)| PaletteItem {
name: format!("{}:{}", block.namespace(), block.name()),
properties: stored_props.clone().or_else(|| block.properties()),
})
.collect();
Section {
block_states: Blockstates {
palette,
data: Some(LongArray::new(data)),
other: FnvHashMap::default(),
},
y,
other: FnvHashMap::default(),
}
}
}
impl Default for SectionToModify {
fn default() -> Self {
Self {
storage: BlockStorage::Uniform(AIR),
properties: FnvHashMap::default(),
}
}
}
/// A chunk being modified (16x384x16 blocks, divided into sections)
#[derive(Default)]
pub(crate) struct ChunkToModify {
pub sections: FnvHashMap<i8, SectionToModify>,
pub other: FnvHashMap<String, Value>,
}
impl ChunkToModify {
#[inline]
pub fn get_block(&self, x: u8, y: i32, z: u8) -> Option<Block> {
// Clamp Y to valid Minecraft range to prevent TryFromIntError
let y = y.clamp(MIN_Y, MAX_Y);
let section_idx: i8 = (y >> 4) as i8;
let section = self.sections.get(&section_idx)?;
section.get_block(x, (y & 15) as u8, z)
}
#[inline]
pub fn set_block(&mut self, x: u8, y: i32, z: u8, block: Block) {
// Clamp Y to valid Minecraft range to prevent TryFromIntError
let y = y.clamp(MIN_Y, MAX_Y);
let section_idx: i8 = (y >> 4) as i8;
let section = self.sections.entry(section_idx).or_default();
section.set_block(x, (y & 15) as u8, z, block);
}
#[inline]
pub fn set_block_with_properties(
&mut self,
x: u8,
y: i32,
z: u8,
block_with_props: BlockWithProperties,
) {
// Clamp Y to valid Minecraft range to prevent TryFromIntError
let y = y.clamp(MIN_Y, MAX_Y);
let section_idx: i8 = (y >> 4) as i8;
let section = self.sections.entry(section_idx).or_default();
section.set_block_with_properties(x, (y & 15) as u8, z, block_with_props);
}
pub fn sections(&self) -> impl Iterator<Item = Section> + '_ {
self.sections.iter().map(|(y, s)| s.to_section(*y))
}
}
/// A region being modified (32x32 chunks)
#[derive(Default)]
pub(crate) struct RegionToModify {
pub chunks: FnvHashMap<(i32, i32), ChunkToModify>,
}
impl RegionToModify {
#[inline]
pub fn get_or_create_chunk(&mut self, x: i32, z: i32) -> &mut ChunkToModify {
self.chunks.entry((x, z)).or_default()
}
#[inline]
pub fn get_chunk(&self, x: i32, z: i32) -> Option<&ChunkToModify> {
self.chunks.get(&(x, z))
}
}
/// The entire world being modified.
#[derive(Default)]
pub(crate) struct WorldToModify {
pub regions: FnvHashMap<(i32, i32), RegionToModify>,
}
impl WorldToModify {
#[inline]
pub fn get_or_create_region(&mut self, x: i32, z: i32) -> &mut RegionToModify {
self.regions.entry((x, z)).or_default()
}
#[inline]
pub fn get_region(&self, x: i32, z: i32) -> Option<&RegionToModify> {
self.regions.get(&(x, z))
}
#[inline]
pub fn get_block(&self, x: i32, y: i32, z: i32) -> Option<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: &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,
(z & 15).try_into().unwrap(),
)
}
#[inline]
pub fn set_block(&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: &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,
(z & 15).try_into().unwrap(),
block,
);
}
#[inline]
pub fn set_block_with_properties(
&mut self,
x: i32,
y: i32,
z: i32,
block_with_props: BlockWithProperties,
) {
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: &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,
(z & 15).try_into().unwrap(),
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();
}
}
}
}
}
}

334
src/world_editor/java.rs Normal file
View File

@@ -0,0 +1,334 @@
//! Java Edition Anvil format world saving.
//!
//! This module handles saving worlds in the Java Edition Anvil (.mca) format.
use super::common::{Chunk, ChunkToModify, Section};
use super::WorldEditor;
use crate::block_definitions::GRASS_BLOCK;
use crate::progress::emit_gui_progress_update;
use colored::Colorize;
use fastanvil::Region;
use fastnbt::Value;
use fnv::FnvHashMap;
use indicatif::{ProgressBar, ProgressStyle};
use rayon::prelude::*;
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};
impl<'a> WorldEditor<'a> {
/// Creates a region file for the given region coordinates.
pub(super) fn create_region(&self, region_x: i32, region_z: i32) -> Region<File> {
let region_dir = self.world_dir.join("region");
let out_path = region_dir.join(format!("r.{}.{}.mca", region_x, region_z));
// Ensure region directory exists before creating region files
std::fs::create_dir_all(&region_dir).expect("Failed to create region directory");
const REGION_TEMPLATE: &[u8] = include_bytes!("../../assets/minecraft/region.template");
let mut region_file: File = File::options()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&out_path)
.expect("Failed to open region file");
region_file
.write_all(REGION_TEMPLATE)
.expect("Could not write region template");
Region::from_stream(region_file).expect("Failed to load region")
}
/// 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) {
// Use cached sections (computed once on first call)
let sections = get_base_chunk_sections();
// Prepare chunk data with cloned sections
let chunk_data = Chunk {
sections: sections.to_vec(),
x_pos: abs_chunk_x,
z_pos: abs_chunk_z,
is_light_on: 0,
other: FnvHashMap::default(),
};
// Create the Level wrapper
let level_data = create_level_wrapper(&chunk_data);
// Serialize the chunk with Level wrapper
let mut ser_buffer = Vec::with_capacity(8192);
fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap();
(ser_buffer, true)
}
/// Saves the world in Java Edition Anvil format.
///
/// Uses parallel processing with rayon for fast region saving.
pub(super) fn save_java(&mut self) {
println!("{} Saving world...", "[7/7]".bold());
emit_gui_progress_update(90.0, "Saving world...");
// Save metadata with error handling
if let Err(e) = self.save_metadata() {
eprintln!("Failed to save world metadata: {}", e);
#[cfg(feature = "gui")]
send_log(LogLevel::Warning, "Failed to save world metadata.");
// Continue with world saving even if metadata fails
}
let total_regions = self.world.regions.len() as u64;
let save_pb = ProgressBar::new(total_regions);
save_pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:45}] {pos}/{len} regions ({eta})",
)
.unwrap()
.progress_chars("█▓░"),
);
let regions_processed = AtomicU64::new(0);
self.world
.regions
.par_iter()
.for_each(|((region_x, region_z), region_to_modify)| {
self.save_single_region(*region_x, *region_z, region_to_modify);
// Update progress
let regions_done = regions_processed.fetch_add(1, Ordering::SeqCst) + 1;
// Update progress at regular intervals (every ~10% or at least every 10 regions)
let update_interval = (total_regions / 10).max(1);
if regions_done.is_multiple_of(update_interval) || regions_done == total_regions {
let progress = 90.0 + (regions_done as f64 / total_regions as f64) * 9.0;
emit_gui_progress_update(progress, "Saving world...");
}
save_pb.inc(1);
});
save_pb.finish();
}
/// Saves a single region to disk.
///
/// 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,
region_z: i32,
region_to_modify: &super::common::RegionToModify,
) {
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() {
// 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(),
};
// Create Level wrapper and save
let level_data = create_level_wrapper(&chunk);
ser_buffer.clear();
fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap();
region
.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)
.unwrap();
}
}
// 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);
let abs_chunk_z = chunk_z + (region_z * 32);
// Check if chunk exists in our modifications
let chunk_exists = region_to_modify.chunks.contains_key(&(chunk_x, chunk_z));
// If chunk doesn't exist, create it with base layer
if !chunk_exists {
let (ser_buffer, _) = Self::create_base_chunk(abs_chunk_x, abs_chunk_z);
region
.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)
.unwrap();
}
}
}
}
}
/// Helper function to get entity coordinates
/// Note: Currently unused since we write directly without merging, but kept for potential future use
#[inline]
#[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;
};
Some((x, y, z))
}
/// Creates a Level wrapper for chunk data (Java Edition format)
#[inline]
fn create_level_wrapper(chunk: &Chunk) -> HashMap<String, Value> {
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()));
}
}
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,
}
}

843
src/world_editor/mod.rs Normal file
View File

@@ -0,0 +1,843 @@
//! World editor module for generating Minecraft worlds.
//!
//! This module provides the `WorldEditor` struct which handles block placement
//! and world saving in both Java Edition (Anvil) and Bedrock Edition (.mcworld) formats.
//!
//! # Module Structure
//!
//! - `common` - Shared data structures for world modification
//! - `java` - Java Edition Anvil format saving
//! - `bedrock` - Bedrock Edition .mcworld format saving (behind `bedrock` feature)
mod common;
mod java;
#[cfg(feature = "bedrock")]
pub mod bedrock;
// Re-export common types used internally
pub(crate) use common::WorldToModify;
#[cfg(feature = "bedrock")]
pub(crate) use bedrock::{BedrockSaveError, BedrockWriter};
use crate::block_definitions::*;
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::coordinate_system::geographic::LLBBox;
use crate::ground::Ground;
use crate::progress::emit_gui_progress_update;
use colored::Colorize;
use fastnbt::{IntArray, Value};
use serde::Serialize;
use std::collections::{hash_map::Entry, HashMap};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
/// World format to generate
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[allow(dead_code)]
pub enum WorldFormat {
/// Java Edition Anvil format (.mca region files)
JavaAnvil,
/// Bedrock Edition .mcworld format
BedrockMcWorld,
}
/// Metadata saved with the world
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorldMetadata {
pub min_mc_x: i32,
pub max_mc_x: i32,
pub min_mc_z: i32,
pub max_mc_z: i32,
pub min_geo_lat: f64,
pub max_geo_lat: f64,
pub min_geo_lon: f64,
pub max_geo_lon: f64,
}
/// The main world editor struct for placing blocks and saving worlds.
///
/// The lifetime `'a` is tied to the `XZBBox` reference, which defines
/// the world boundaries and must outlive the WorldEditor instance.
pub struct WorldEditor<'a> {
world_dir: PathBuf,
world: WorldToModify,
xzbbox: &'a XZBBox,
llbbox: LLBBox,
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)>,
}
impl<'a> WorldEditor<'a> {
/// Creates a new WorldEditor with Java Anvil format (default).
///
/// This is the default constructor used by CLI mode.
#[allow(dead_code)]
pub fn new(world_dir: PathBuf, xzbbox: &'a XZBBox, llbbox: LLBBox) -> Self {
Self {
world_dir,
world: WorldToModify::default(),
xzbbox,
llbbox,
ground: None,
format: WorldFormat::JavaAnvil,
#[cfg(feature = "bedrock")]
bedrock_level_name: None,
#[cfg(feature = "bedrock")]
bedrock_spawn_point: None,
}
}
/// Creates a new WorldEditor with a specific format and optional level name.
///
/// Used by GUI mode to support both Java and Bedrock formats.
#[allow(dead_code)]
pub fn new_with_format_and_name(
world_dir: PathBuf,
xzbbox: &'a XZBBox,
llbbox: LLBBox,
format: WorldFormat,
#[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,
world: WorldToModify::default(),
xzbbox,
llbbox,
ground: None,
format,
#[cfg(feature = "bedrock")]
bedrock_level_name,
#[cfg(feature = "bedrock")]
bedrock_spawn_point,
}
}
/// Sets the ground reference for elevation-based block placement
pub fn set_ground(&mut self, ground: Arc<Ground>) {
self.ground = Some(ground);
}
/// Gets a reference to the ground data if available
pub fn get_ground(&self) -> Option<&Ground> {
self.ground.as_deref()
}
/// Returns the current world format
#[allow(dead_code)]
pub fn format(&self) -> WorldFormat {
self.format
}
/// Calculate the absolute Y position from a ground-relative offset
#[inline(always)]
pub fn get_absolute_y(&self, x: i32, y_offset: i32, z: i32) -> i32 {
if let Some(ground) = &self.ground {
ground.level(XZPoint::new(
x - self.xzbbox.min_x(),
z - self.xzbbox.min_z(),
)) + y_offset
} else {
y_offset // If no ground reference, use y_offset as absolute Y
}
}
/// Get the ground level at a specific world coordinate (without any offset)
#[inline(always)]
pub fn get_ground_level(&self, x: i32, z: i32) -> i32 {
if let Some(ground) = &self.ground {
ground.level(XZPoint::new(
x - self.xzbbox.min_x(),
z - self.xzbbox.min_z(),
))
} else {
0 // Default ground level if no terrain data
}
}
/// Returns the minimum world coordinates
pub fn get_min_coords(&self) -> (i32, i32) {
(self.xzbbox.min_x(), self.xzbbox.min_z())
}
/// Returns the maximum world coordinates
pub fn get_max_coords(&self) -> (i32, i32) {
(self.xzbbox.max_x(), self.xzbbox.max_z())
}
/// Checks if there's a block at the given coordinates
#[allow(unused)]
#[inline]
pub fn block_at(&self, x: i32, y: i32, z: i32) -> bool {
let absolute_y = self.get_absolute_y(x, y, z);
self.world.get_block(x, absolute_y, z).is_some()
}
/// Sets a sign at the given coordinates
#[allow(clippy::too_many_arguments, dead_code)]
pub fn set_sign(
&mut self,
line1: String,
line2: String,
line3: String,
line4: String,
x: i32,
y: i32,
z: i32,
_rotation: i8,
) {
let absolute_y = self.get_absolute_y(x, y, z);
let chunk_x = x >> 4;
let chunk_z = z >> 4;
let region_x = chunk_x >> 5;
let region_z = chunk_z >> 5;
let mut block_entities = HashMap::new();
let messages = vec![
Value::String(format!("\"{line1}\"")),
Value::String(format!("\"{line2}\"")),
Value::String(format!("\"{line3}\"")),
Value::String(format!("\"{line4}\"")),
];
let mut text_data = HashMap::new();
text_data.insert("messages".to_string(), Value::List(messages));
text_data.insert("color".to_string(), Value::String("black".to_string()));
text_data.insert("has_glowing_text".to_string(), Value::Byte(0));
block_entities.insert("front_text".to_string(), Value::Compound(text_data));
block_entities.insert(
"id".to_string(),
Value::String("minecraft:sign".to_string()),
);
block_entities.insert("is_waxed".to_string(), Value::Byte(0));
block_entities.insert("keepPacked".to_string(), Value::Byte(0));
block_entities.insert("x".to_string(), Value::Int(x));
block_entities.insert("y".to_string(), Value::Int(absolute_y));
block_entities.insert("z".to_string(), Value::Int(z));
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);
if let Some(chunk_data) = chunk.other.get_mut("block_entities") {
if let Value::List(entities) = chunk_data {
entities.push(Value::Compound(block_entities));
}
} else {
chunk.other.insert(
"block_entities".to_string(),
Value::List(vec![Value::Compound(block_entities)]),
);
}
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.
#[inline]
pub fn set_block(
&mut self,
block: Block,
x: i32,
y: i32,
z: i32,
override_whitelist: Option<&[Block]>,
override_blacklist: Option<&[Block]>,
) {
// Check if coordinates are within bounds
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
// Calculate the absolute Y coordinate based on ground level
let absolute_y = self.get_absolute_y(x, y, z);
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
// Check against whitelist and blacklist
if let Some(whitelist) = override_whitelist {
whitelist
.iter()
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
} else if let Some(blacklist) = override_blacklist {
!blacklist
.iter()
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
} else {
false
}
} else {
true
};
if should_insert {
self.world.set_block(x, absolute_y, z, block);
}
}
/// Sets a block of the specified type at the given coordinates with absolute Y value.
#[inline]
pub fn set_block_absolute(
&mut self,
block: Block,
x: i32,
absolute_y: i32,
z: i32,
override_whitelist: Option<&[Block]>,
override_blacklist: Option<&[Block]>,
) {
// Check if coordinates are within bounds
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
// Check against whitelist and blacklist
if let Some(whitelist) = override_whitelist {
whitelist
.iter()
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
} else if let Some(blacklist) = override_blacklist {
!blacklist
.iter()
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
} else {
false
}
} else {
true
};
if should_insert {
self.world.set_block(x, absolute_y, z, block);
}
}
/// Sets a block with properties at the given coordinates with absolute Y value.
#[inline]
pub fn set_block_with_properties_absolute(
&mut self,
block_with_props: BlockWithProperties,
x: i32,
absolute_y: i32,
z: i32,
override_whitelist: Option<&[Block]>,
override_blacklist: Option<&[Block]>,
) {
// Check if coordinates are within bounds
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
return;
}
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
// Check against whitelist and blacklist
if let Some(whitelist) = override_whitelist {
whitelist
.iter()
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
} else if let Some(blacklist) = override_blacklist {
!blacklist
.iter()
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
} else {
false
}
} else {
true
};
if should_insert {
self.world
.set_block_with_properties(x, absolute_y, z, block_with_props);
}
}
/// Fills a cuboid area with the specified block between two coordinates.
#[allow(clippy::too_many_arguments)]
#[inline]
pub fn fill_blocks(
&mut self,
block: Block,
x1: i32,
y1: i32,
z1: i32,
x2: i32,
y2: 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 < y2 { (y1, y2) } else { (y2, y1) };
let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) };
for x in min_x..=max_x {
for y_offset in min_y..=max_y {
for z in min_z..=max_z {
self.set_block(
block,
x,
y_offset,
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 {
let absolute_y = self.get_absolute_y(x, y, z);
// Retrieve the chunk modification map
if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
if let Some(whitelist) = whitelist {
if whitelist
.iter()
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
{
return true; // Block is in the list
}
}
}
false
}
/// Checks for a block at the given coordinates with absolute Y value.
#[allow(unused)]
pub fn check_for_block_absolute(
&self,
x: i32,
absolute_y: i32,
z: i32,
whitelist: Option<&[Block]>,
blacklist: Option<&[Block]>,
) -> bool {
// Retrieve the chunk modification map
if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
// Check against whitelist and blacklist
if let Some(whitelist) = whitelist {
if whitelist
.iter()
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
{
return true; // Block is in whitelist
}
return false;
}
if let Some(blacklist) = blacklist {
if blacklist
.iter()
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
{
return true; // Block is in blacklist
}
}
return whitelist.is_none() && blacklist.is_none();
}
false
}
/// Checks if a block exists at the given coordinates with absolute Y value.
///
/// Unlike `check_for_block_absolute`, this doesn't filter by block type.
#[allow(unused)]
pub fn block_at_absolute(&self, x: i32, absolute_y: i32, z: i32) -> bool {
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!(
"Generating world for: {}",
match self.format {
WorldFormat::JavaAnvil => "Java Edition (Anvil)",
WorldFormat::BedrockMcWorld => "Bedrock Edition (.mcworld)",
}
);
// 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(),
}
}
#[allow(unreachable_code)]
fn save_bedrock(&mut self) {
println!("{} Saving Bedrock world...", "[7/7]".bold());
emit_gui_progress_update(90.0, "Saving Bedrock world...");
#[cfg(feature = "bedrock")]
{
if let Err(error) = self.save_bedrock_internal() {
eprintln!("Failed to save Bedrock world: {error}");
#[cfg(feature = "gui")]
send_log(
LogLevel::Error,
&format!("Failed to save Bedrock world: {error}"),
);
}
}
#[cfg(not(feature = "bedrock"))]
{
eprintln!(
"Bedrock output requested but the 'bedrock' feature is not enabled at build time."
);
#[cfg(feature = "gui")]
send_log(
LogLevel::Error,
"Bedrock output requested but the 'bedrock' feature is not enabled at build time.",
);
}
}
#[cfg(feature = "bedrock")]
fn save_bedrock_internal(&mut self) -> Result<(), BedrockSaveError> {
// Use the stored level name if available, otherwise extract from path
let level_name = self.bedrock_level_name.clone().unwrap_or_else(|| {
self.world_dir
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Arnis World")
.to_string()
});
BedrockWriter::new(
self.world_dir.clone(),
level_name,
self.bedrock_spawn_point,
self.ground.clone(),
)
.write_world(&self.world, self.xzbbox, &self.llbbox)
}
/// Saves world metadata to a JSON file
pub(crate) fn save_metadata(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let metadata_path = self.world_dir.join("metadata.json");
let mut file = File::create(&metadata_path).map_err(|e| {
format!(
"Failed to create metadata file at {}: {}",
metadata_path.display(),
e
)
})?;
let metadata = WorldMetadata {
min_mc_x: self.xzbbox.min_x(),
max_mc_x: self.xzbbox.max_x(),
min_mc_z: self.xzbbox.min_z(),
max_mc_z: self.xzbbox.max_z(),
min_geo_lat: self.llbbox.min().lat(),
max_geo_lat: self.llbbox.max().lat(),
min_geo_lon: self.llbbox.min().lng(),
max_geo_lon: self.llbbox.max().lng(),
};
let contents = serde_json::to_string(&metadata)
.map_err(|e| format!("Failed to serialize metadata to JSON: {}", e))?;
write!(&mut file, "{}", contents)
.map_err(|e| format!("Failed to write metadata to file: {}", e))?;
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.3.1",
"version": "2.5.0",
"identifier": "com.louisdev.arnis",
"build": {
"frontendDist": "src/gui"
@@ -16,7 +16,7 @@
"minWidth": 1000,
"minHeight": 650,
"resizable": true,
"transparent": true,
"transparent": false,
"center": true,
"theme": "Dark",
"additionalBrowserArgs": "--disable-features=VizDisplayCompositor"