Compare commits
433 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbdd22fe9 | ||
|
|
1e6ab65b7a | ||
|
|
58cc17a174 | ||
|
|
d84fa1243d | ||
|
|
c2f6632c93 | ||
|
|
2ef79875cc | ||
|
|
21bd94c142 | ||
|
|
1d170d88bc | ||
|
|
b4af81c49a | ||
|
|
3ee0f27a53 | ||
|
|
8dcc8a3cb9 | ||
|
|
1d5d9116ae | ||
|
|
6f5b1229e0 | ||
|
|
f563d4dd26 | ||
|
|
e888163bd4 | ||
|
|
8b3eb516b8 | ||
|
|
5257a045b2 | ||
|
|
9582da2081 | ||
|
|
4787e07e05 | ||
|
|
09a6d2135c | ||
|
|
c369e9bae5 | ||
|
|
38adb1f589 | ||
|
|
1e87ff53ca | ||
|
|
6c67610bd0 | ||
|
|
cd7e3363e7 | ||
|
|
614da8da7c | ||
|
|
7d907208d1 | ||
|
|
f9c009c173 | ||
|
|
dfe799bac0 | ||
|
|
57a44500f4 | ||
|
|
8514a07fca | ||
|
|
ea9f11d427 | ||
|
|
882b18410e | ||
|
|
0c083b3b82 | ||
|
|
674591945f | ||
|
|
c5e5239062 | ||
|
|
e22a1b4f73 | ||
|
|
67a14a7f4b | ||
|
|
230d233737 | ||
|
|
b975ea19d7 | ||
|
|
e4939dc4bb | ||
|
|
11d624e734 | ||
|
|
d1d3bf22c5 | ||
|
|
489e571a42 | ||
|
|
67e22a574a | ||
|
|
c094db8464 | ||
|
|
9be9104c8d | ||
|
|
8c0f0cc366 | ||
|
|
403469dcb5 | ||
|
|
e0674823fd | ||
|
|
438328ec28 | ||
|
|
d2a8f09487 | ||
|
|
318ab1e26c | ||
|
|
6adb8d050e | ||
|
|
0cc32e70b9 | ||
|
|
ed07de68a6 | ||
|
|
0fda04f2be | ||
|
|
8598a1847b | ||
|
|
2422786607 | ||
|
|
4e2d886077 | ||
|
|
4299863410 | ||
|
|
b316f95030 | ||
|
|
7cc53434a7 | ||
|
|
6bd17c937d | ||
|
|
cf198f9e93 | ||
|
|
bedf2b763a | ||
|
|
02823134df | ||
|
|
36e1c04e6f | ||
|
|
333ed52e28 | ||
|
|
e9c8f203a7 | ||
|
|
87069665fe | ||
|
|
5bfb8606e2 | ||
|
|
44023f99e2 | ||
|
|
3aad272c20 | ||
|
|
9f47b0269b | ||
|
|
c7e1fec02c | ||
|
|
fb05e2f2b8 | ||
|
|
7015cfff5f | ||
|
|
78ca5a49ce | ||
|
|
e265f8fa7e | ||
|
|
552f4ab013 | ||
|
|
319eb656ee | ||
|
|
11a756ab06 | ||
|
|
0f93853dcb | ||
|
|
b4c47f559c | ||
|
|
a86e23129b | ||
|
|
69b30ef59f | ||
|
|
1733f5d664 | ||
|
|
e6b6de27ff | ||
|
|
ac0fc275dc | ||
|
|
de1f52bfaf | ||
|
|
851aec71d0 | ||
|
|
03cc86f3e2 | ||
|
|
d4af5ce7ef | ||
|
|
382ab19a0d | ||
|
|
38678deefc | ||
|
|
674a2d9656 | ||
|
|
c4ad3dd61a | ||
|
|
e42bc121fa | ||
|
|
19da1fe55d | ||
|
|
663394f3b5 | ||
|
|
bef7bd3965 | ||
|
|
d8e1b29146 | ||
|
|
a9f53b2cd6 | ||
|
|
516b9ecf33 | ||
|
|
babe610cca | ||
|
|
20922a3be6 | ||
|
|
f1dc3b8ffb | ||
|
|
602767c1d1 | ||
|
|
96e6d9e129 | ||
|
|
c722ea689f | ||
|
|
f473e980a2 | ||
|
|
1901c21049 | ||
|
|
bc41838671 | ||
|
|
11de6cfd85 | ||
|
|
92f629fc96 | ||
|
|
880d86971d | ||
|
|
1421247ea4 | ||
|
|
1b21dec366 | ||
|
|
c9a9d55f76 | ||
|
|
53846a7b5a | ||
|
|
0593615909 | ||
|
|
f79b610c0d | ||
|
|
c62600e972 | ||
|
|
225cb79381 | ||
|
|
9fd1868d41 | ||
|
|
ceb0c80fba | ||
|
|
6444a4498a | ||
|
|
6ef8169d45 | ||
|
|
568a6063f7 | ||
|
|
32695555aa | ||
|
|
6cdebbed78 | ||
|
|
5291f72215 | ||
|
|
c24e22b790 | ||
|
|
d4f324fd96 | ||
|
|
e7e65d0e6f | ||
|
|
927aaec22d | ||
|
|
5ec942dbd1 | ||
|
|
19bba3cc26 | ||
|
|
17d6d323fc | ||
|
|
236072dc42 | ||
|
|
7a8226923a | ||
|
|
107ab70602 | ||
|
|
1364d96291 | ||
|
|
b74b5c5ccb | ||
|
|
dd8004b159 | ||
|
|
b0845ce1df | ||
|
|
fc540db4cd | ||
|
|
1ecdffc039 | ||
|
|
9ea34b9911 | ||
|
|
48248aad05 | ||
|
|
169545d937 | ||
|
|
fba331232b | ||
|
|
b02a2783c1 | ||
|
|
dbc4741b78 | ||
|
|
b52485badc | ||
|
|
447416f6ce | ||
|
|
d26b23937e | ||
|
|
5e01abc5b6 | ||
|
|
7c808ec352 | ||
|
|
b757c5acf4 | ||
|
|
ced5fc274e | ||
|
|
295ca415d7 | ||
|
|
e2b4ca8bdb | ||
|
|
07105f0208 | ||
|
|
ad57fdbc3a | ||
|
|
550870d9e0 | ||
|
|
bd693ea007 | ||
|
|
ce8f343414 | ||
|
|
f882145780 | ||
|
|
b52d750935 | ||
|
|
4d30899909 | ||
|
|
311610a717 | ||
|
|
b4902ebc9e | ||
|
|
e5bbb3e4a0 | ||
|
|
0238cfe2d0 | ||
|
|
2d9892fe7f | ||
|
|
b858ce4691 | ||
|
|
e031e53492 | ||
|
|
6fb9b8943d | ||
|
|
18266dd459 | ||
|
|
b1940fa412 | ||
|
|
d57a732055 | ||
|
|
4e52b38f5a | ||
|
|
feb4317086 | ||
|
|
d02cbed997 | ||
|
|
99d1f8e117 | ||
|
|
6fa76bc381 | ||
|
|
0fef27e6af | ||
|
|
fa3384cf86 | ||
|
|
ffbc5e5788 | ||
|
|
4215e7644c | ||
|
|
118335bad4 | ||
|
|
7bbee28279 | ||
|
|
9cb35a3b13 | ||
|
|
4fecf98c54 | ||
|
|
47a7b81f99 | ||
|
|
7ec90b4fef | ||
|
|
f1f3fb287a | ||
|
|
b23658d5ef | ||
|
|
cc89576828 | ||
|
|
809fa23941 | ||
|
|
51ad1fef3f | ||
|
|
8e8d8e0567 | ||
|
|
ee2356d734 | ||
|
|
da6f23c0a2 | ||
|
|
d4a872989c | ||
|
|
2a5a5230c5 | ||
|
|
9018584b1d | ||
|
|
9eda39846c | ||
|
|
5e9d6795df | ||
|
|
54a7a4f2a9 | ||
|
|
d0d65643f5 | ||
|
|
946fd43a5e | ||
|
|
05e5ffdd2a | ||
|
|
0b7e27df7f | ||
|
|
613a410c93 | ||
|
|
faefd29e30 | ||
|
|
9ad6c75440 | ||
|
|
e51f28f067 | ||
|
|
47ddb9b211 | ||
|
|
46415bb002 | ||
|
|
0683dd3343 | ||
|
|
4d304dc978 | ||
|
|
ceec7cc190 | ||
|
|
d876f5ce60 | ||
|
|
d3a416754d | ||
|
|
fc6c2a255f | ||
|
|
3b70694167 | ||
|
|
e5f0b1050a | ||
|
|
a0fd0c12e2 | ||
|
|
9b87e3538a | ||
|
|
46959365df | ||
|
|
5d97391820 | ||
|
|
bef3cfb090 | ||
|
|
5a898944f7 | ||
|
|
9fdd960009 | ||
|
|
f57d14b200 | ||
|
|
0a51b302ee | ||
|
|
93dc9f446c | ||
|
|
e6430f2a04 | ||
|
|
58e4a337d9 | ||
|
|
236a7e5af9 | ||
|
|
5962decf44 | ||
|
|
9173e5b4de | ||
|
|
1fd02d8005 | ||
|
|
438b2beceb | ||
|
|
a62e181c16 | ||
|
|
12abba3bc8 | ||
|
|
a8e31700d8 | ||
|
|
7a109cce0b | ||
|
|
86543714af | ||
|
|
b84a565210 | ||
|
|
93becaae7f | ||
|
|
06e377ce29 | ||
|
|
e22380bdd3 | ||
|
|
35cac44209 | ||
|
|
61af45d2f4 | ||
|
|
393f1f9bd8 | ||
|
|
e6f8466177 | ||
|
|
02d3a32a03 | ||
|
|
f00304ff3a | ||
|
|
a93b908104 | ||
|
|
7cbc4fa263 | ||
|
|
7e7f7ed476 | ||
|
|
3c0ba60657 | ||
|
|
fb438c4a0f | ||
|
|
5015c8b9b4 | ||
|
|
af0ace422f | ||
|
|
0bb39b7d9e | ||
|
|
5b5e93b89a | ||
|
|
958dc2107e | ||
|
|
562a3bca66 | ||
|
|
f1b37fbbb6 | ||
|
|
b34cbf4307 | ||
|
|
a03318bb98 | ||
|
|
8bb779d6cc | ||
|
|
6d164102ad | ||
|
|
127a0e5e68 | ||
|
|
4a326c3dad | ||
|
|
d4fd9b9cd3 | ||
|
|
ee0521f232 | ||
|
|
8b3a41b131 | ||
|
|
02594b1cae | ||
|
|
06ba4db97e | ||
|
|
59d31cfbb8 | ||
|
|
94388e4164 | ||
|
|
f8c9fd8f4c | ||
|
|
2ee2d48f6a | ||
|
|
56c2f2e5cd | ||
|
|
9d34bc8e92 | ||
|
|
c95b78fdcd | ||
|
|
6e52e08b8a | ||
|
|
57a4a801cf | ||
|
|
0c47e365bc | ||
|
|
dad3ab3b34 | ||
|
|
b8b63a2bc5 | ||
|
|
cab20b5e50 | ||
|
|
0e879837fa | ||
|
|
92be2ccf00 | ||
|
|
3b76d707d9 | ||
|
|
be8559dee7 | ||
|
|
94eda2fad3 | ||
|
|
7d86854e3c | ||
|
|
cddaa89d35 | ||
|
|
453845977d | ||
|
|
4e196e51bd | ||
|
|
ea4dc5dc08 | ||
|
|
c56ff83094 | ||
|
|
2b40a520ff | ||
|
|
a192be981a | ||
|
|
eb77bca10d | ||
|
|
4a891c3603 | ||
|
|
84adfdd931 | ||
|
|
823b6ba052 | ||
|
|
2ba8157ec9 | ||
|
|
7235ba0be9 | ||
|
|
dee580c564 | ||
|
|
41fc5662e0 | ||
|
|
ac884b8c2a | ||
|
|
7a9b792bee | ||
|
|
83e9a634e5 | ||
|
|
56ddea57d0 | ||
|
|
430a4970f5 | ||
|
|
74fbdabaee | ||
|
|
2643155e9a | ||
|
|
d45c360074 | ||
|
|
6277a14d22 | ||
|
|
c355f243e3 | ||
|
|
2c31d2659c | ||
|
|
996e06ab2c | ||
|
|
e11231ad0f | ||
|
|
9adf31121e | ||
|
|
69da18fbfb | ||
|
|
5976cc2868 | ||
|
|
a85eaed835 | ||
|
|
37c3d85672 | ||
|
|
15b698a1eb | ||
|
|
834ce7b51d | ||
|
|
93b6f5ac99 | ||
|
|
77df683deb | ||
|
|
9d791f4299 | ||
|
|
a2dff0b84b | ||
|
|
24630351b9 | ||
|
|
819b03e3b1 | ||
|
|
f315245637 | ||
|
|
16c9f3c3bf | ||
|
|
3043ca6d24 | ||
|
|
3657878f01 | ||
|
|
44cc57c3dd | ||
|
|
408caa9176 | ||
|
|
3191a3676d | ||
|
|
8fff2d2fb5 | ||
|
|
8c702a36ff | ||
|
|
9bedca071a | ||
|
|
d611837746 | ||
|
|
e06fcaf7a2 | ||
|
|
1f2772f052 | ||
|
|
82f3460043 | ||
|
|
65d6bb6c99 | ||
|
|
7ebee82982 | ||
|
|
5056e83dff | ||
|
|
3cb0b994e1 | ||
|
|
5ded3c961e | ||
|
|
893c14bff8 | ||
|
|
4f8e0020e3 | ||
|
|
456018abf7 | ||
|
|
175bdc4582 | ||
|
|
ed294a3973 | ||
|
|
6c966fc9cc | ||
|
|
ecfc6634fc | ||
|
|
a336ccd1aa | ||
|
|
f702a41af1 | ||
|
|
29c4dc6d7c | ||
|
|
f60b341eca | ||
|
|
92c5e52f46 | ||
|
|
e4d7dd15c2 | ||
|
|
cc6748115b | ||
|
|
ce9496aea5 | ||
|
|
4c87eb8141 | ||
|
|
ada475f73e | ||
|
|
e5a82ba526 | ||
|
|
309ac19b09 | ||
|
|
90df7688df | ||
|
|
5b3889a7bb | ||
|
|
67deb739e6 | ||
|
|
7c08d21f36 | ||
|
|
280acc7a8a | ||
|
|
ee59da5d9b | ||
|
|
b772cb6ab9 | ||
|
|
2646947ed0 | ||
|
|
b9976fd562 | ||
|
|
a621703da4 | ||
|
|
87efd02c74 | ||
|
|
6a6b58fd8f | ||
|
|
855c6fe846 | ||
|
|
1592951fe3 | ||
|
|
e267e04350 | ||
|
|
c2e8d5959f | ||
|
|
ffe8f865d2 | ||
|
|
4f9f4f127a | ||
|
|
014208426b | ||
|
|
6d848ef7cd | ||
|
|
1e25dfea37 | ||
|
|
903efec459 | ||
|
|
22b3969c72 | ||
|
|
0f62c4283b | ||
|
|
4299e64cba | ||
|
|
0cd386b399 | ||
|
|
2fdecbe206 | ||
|
|
75d1ab14e7 | ||
|
|
f9c18ae5f6 | ||
|
|
963fe2327b | ||
|
|
2a8ff6f641 | ||
|
|
ff3ea1093a | ||
|
|
fd401ab23f | ||
|
|
3f67e060eb | ||
|
|
858c2f4c93 | ||
|
|
e8fad0e197 | ||
|
|
7ebc66db7e | ||
|
|
28b75a22cf | ||
|
|
9ee9f0de64 | ||
|
|
5b874a5c29 | ||
|
|
eeecdc8f99 | ||
|
|
8b33e152ef | ||
|
|
ca8e50fbf1 | ||
|
|
7f4ef9130f | ||
|
|
8d183543be | ||
|
|
dd07fba15c | ||
|
|
083fbed040 | ||
|
|
4191fa4902 | ||
|
|
6c82265ee3 | ||
|
|
7d978047b4 |
2
.gitattributes
vendored
@@ -1 +1 @@
|
||||
gui-src/** linguist-vendored
|
||||
src/gui/** linguist-vendored
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,13 +11,13 @@ assignees: ''
|
||||
A clear and concise description of what the bug is and what you expected to happen.
|
||||
|
||||
**Used bbox area**
|
||||
Please provide your input parameters so we can reproduce the issue. *(For example: 48.133444 11.569462 48.142609 11.584740)*
|
||||
Please provide your input parameters (BBOX) so we can reproduce the issue. *(For example: 48.133444 11.569462 48.142609 11.584740)*
|
||||
|
||||
**Arnis and Minecraft version**
|
||||
Please tell us what version of Arnis and Minecraft you used.
|
||||
Please tell us what version of Arnis and Minecraft you used, as well as if you are on Windows, Linux or MacOS.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. If you used any more custom settings, please provide them here too. If you experienced any issue with the application itself like a crash, please provide the log file which can be found at C:\Users\USERNAME\AppData\Local\com.louisdev.arnis\logs
|
||||
Add any other context about the problem here. If you used any more custom settings, please provide them here too. Please provide the log file if possible as well, which can be found at C:\Users\USERNAME\AppData\Local\com.louisdev.arnis\logs
|
||||
|
||||
6
.github/workflows/ci-build.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
|
||||
14
.github/workflows/pr-benchmark.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
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 }}
|
||||
24
.github/workflows/release.yml
vendored
@@ -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@v4
|
||||
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@v4
|
||||
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@v4
|
||||
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@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Windows build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
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@v4
|
||||
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@v4
|
||||
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 }}
|
||||
|
||||
2
.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
/wiki
|
||||
*.mcworld
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.envrc
|
||||
/.direnv
|
||||
|
||||
# Build artifacts
|
||||
|
||||
3535
Cargo.lock
generated
39
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "arnis"
|
||||
version = "2.3.0"
|
||||
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 }
|
||||
fastanvil = "0.31.0"
|
||||
fastnbt = "2.5.0"
|
||||
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.23"
|
||||
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.20.0"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
22
README.md
@@ -1,17 +1,17 @@
|
||||
<img src="https://github.com/louis-e/arnis/blob/main/gitassets/banner.png?raw=true" width="100%" alt="Banner">
|
||||
<img src="assets/git/banner.png" width="100%" alt="Banner">
|
||||
|
||||
# Arnis [](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) [](https://github.com/louis-e/arnis/releases) [](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!
|
||||
|
||||

|
||||
<i>This Github page is the official project website. Do not download Arnis from any other website.</i>
|
||||

|
||||
<i>This Github page and [arnismc.com](https://arnismc.com) are the only official project websites. Do not download Arnis from any other website.</i>
|
||||
|
||||
## :keyboard: Usage
|
||||
<img width="60%" src="https://github.com/louis-e/arnis/blob/main/gitassets/gui.png?raw=true"><br>
|
||||
<img width="60%" src="assets/git/gui.png"><br>
|
||||
Download the [latest release](https://github.com/louis-e/arnis/releases/) or [compile](#trophy-open-source) the project on your own.
|
||||
|
||||
Choose your area on the map using the rectangle tool and select your Minecraft world - then simply click on <i>Start Generation</i>!
|
||||
@@ -19,7 +19,7 @@ Additionally, you can customize various generation settings, such as world scale
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
<img src="https://github.com/louis-e/arnis/blob/main/gitassets/documentation.png?raw=true" width="100%" alt="Banner">
|
||||
<img src="assets/git/documentation.png" width="100%" alt="Banner">
|
||||
|
||||
Full documentation is available in the [GitHub Wiki](https://github.com/louis-e/arnis/wiki/), covering topics such as technical explanations, FAQs, contribution guidelines and roadmaps.
|
||||
|
||||
@@ -34,11 +34,13 @@ Full documentation is available in the [GitHub Wiki](https://github.com/louis-e/
|
||||
#### How to contribute
|
||||
This project is open source and welcomes contributions from everyone! Whether you're interested in fixing bugs, improving performance, adding new features, or enhancing documentation, your input is valuable. Simply fork the repository, make your changes, and submit a pull request. Please respect the above mentioned key objectives. Contributions of all levels are appreciated, and your efforts help improve this tool for everyone.
|
||||
|
||||
Command line Build: ```cargo run --no-default-features -- --terrain --path="C:/YOUR_PATH/.minecraft/saves/worldname" --bbox="min_lng,min_lat,max_lng,max_lat"```<br>
|
||||
Command line Build: ```cargo run --no-default-features -- --terrain --path="C:/YOUR_PATH/.minecraft/saves/worldname" --bbox="min_lat,min_lng,max_lat,max_lng"```<br>
|
||||
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">
|
||||
@@ -51,7 +53,7 @@ After your pull request was merged, I will take care of regularly creating updat
|
||||
|
||||
## :newspaper: Academic & Press Recognition
|
||||
|
||||
<img src="https://github.com/louis-e/arnis/blob/main/gitassets/recognition.png?raw=true" width="100%" alt="Banner">
|
||||
<img src="assets/git/recognition.png" width="100%" alt="Banner">
|
||||
|
||||
Arnis has been recognized in various academic and press publications after gaining a lot of attention in December 2024.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -78,7 +82,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.[^3]
|
||||
|
||||
Download Arnis only from the official source (https://github.com/louis-e/arnis/). Every other website providing a download and claiming to be affiliated with the project is unofficial and may be malicious.
|
||||
Download Arnis only from the official source https://arnismc.com or https://github.com/louis-e/arnis/. Every other website providing a download and claiming to be affiliated with the project is unofficial and may be malicious.
|
||||
|
||||
The logo was made by @nxfx21.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 790 KiB After Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
assets/minecraft/level.dat
Normal file
BIN
assets/minecraft/world_icon.jpeg
Normal file
|
After Width: | Height: | Size: 54 KiB |
26
flake.lock
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1755615617,
|
||||
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "20075955deac2583bb12f07151c2df830ef346b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
64
flake.nix
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }: {
|
||||
|
||||
packages = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
lib = pkgs.lib;
|
||||
toml = lib.importTOML ./Cargo.toml;
|
||||
in
|
||||
{
|
||||
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
|
||||
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";
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
99
flake/flake.lock
generated
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724653830,
|
||||
"narHash": "sha256-88f0KK8h6tGIP4Na5RJDKs0S+7WsGGaCGNkLj/bPV3g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "9ecf5e7d800ace001320da8acadd4a3deb872a83",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1724479785,
|
||||
"narHash": "sha256-pP3Azj5d6M5nmG68Fu4JqZmdGt4S4vqI5f8te+E/FTw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d0e1602ddde669d5beb01aec49d71a51937ed7be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1724586512,
|
||||
"narHash": "sha256-mrfwk6nO8N2WtCq3sB2zhd2QN1HMKzeSESzOA6lSsQg=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "7106cd3be50b2a43c1d9f2787bf22d4369c2b25b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rust-lang",
|
||||
"ref": "nightly",
|
||||
"repo": "rust-analyzer",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
inputs = {
|
||||
fenix = {
|
||||
url = "github:nix-community/fenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, fenix, flake-utils, nixpkgs }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
fenixPkgs = (fenix.packages.${system}.stable);
|
||||
in
|
||||
{
|
||||
devShell = pkgs.mkShell
|
||||
{
|
||||
buildInputs = with pkgs; [
|
||||
openssl.dev
|
||||
pkg-config
|
||||
fenixPkgs.toolchain
|
||||
wayland
|
||||
glib
|
||||
gdk-pixbuf
|
||||
pango
|
||||
gtk3
|
||||
libsoup_3.dev
|
||||
webkitgtk_4_1.dev
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,221 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="./css/styles.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arnis</title>
|
||||
<script type="module" src="./js/main.js" defer></script>
|
||||
<script type="module" src="./js/license.js" defer></script>
|
||||
<script type="module" src="./js/language-selector.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<div class="row">
|
||||
<a href="https://github.com/louis-e/arnis" target="_blank">
|
||||
<img src="./images/logo.png" id="arnis-logo" class="logo arnis" alt="Arnis Logo" style="width: 35%; height: auto;">
|
||||
</a>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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-detail">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- World Picker Modal -->
|
||||
<div id="world-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close-button" onclick="closeWorldPicker()">×</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">
|
||||
<span class="close-button" onclick="closeSettings()">×</span>
|
||||
<h2 data-localize="customization_settings">Customization Settings</h2>
|
||||
|
||||
<!-- Terrain Toggle Button -->
|
||||
<div class="settings-row">
|
||||
<label for="terrain-toggle" data-localize="terrain">Terrain</label>
|
||||
<div class="settings-control">
|
||||
<input type="checkbox" id="terrain-toggle" name="terrain-toggle" checked>
|
||||
</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>
|
||||
<div class="settings-control">
|
||||
<input type="checkbox" id="roof-toggle" name="roof-toggle" checked>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fill ground Toggle Button -->
|
||||
<div class="settings-row">
|
||||
<label for="fillground-toggle" data-localize="fillground">Fill Ground</label>
|
||||
<div class="settings-control">
|
||||
<input type="checkbox" id="fillground-toggle" name="fillground-toggle">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- World Scale Slider -->
|
||||
<div class="settings-row">
|
||||
<label for="scale-value-slider" data-localize="world_scale">World Scale</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bounding Box Input -->
|
||||
<div class="settings-row">
|
||||
<label for="bbox-coords" data-localize="custom_bounding_box">Custom Bounding Box</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>
|
||||
<div class="settings-control">
|
||||
<select id="tile-theme-select" name="tile-theme-select" class="theme-dropdown">
|
||||
<option value="osm">Standard</option>
|
||||
<option value="esri-imagery">Satellite</option>
|
||||
<option value="opentopomap">Topographic</option>
|
||||
<option value="stadia-bright">Smooth Bright</option>
|
||||
<option value="stadia-dark">Smooth Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<div class="settings-row">
|
||||
<label for="language-select" data-localize="language">Language</label>
|
||||
<div class="settings-control">
|
||||
<select id="language-select" name="language-select" class="language-dropdown">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr-FR">Français</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh-CN">中文 (简体)</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="sv">Svenska</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="fi">Suomi</option>
|
||||
<option value="hu">Magyar</option>
|
||||
<option value="lt">Lietuvių</option>
|
||||
<option value="ua">Українська</option>
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Buy Me a Coffee and Discord -->
|
||||
<div class="settings-row buymeacoffee-row">
|
||||
<a href="https://buymeacoffee.com/louisdev" target="_blank" class="buymeacoffee-link">
|
||||
<img src="./images/buymeacoffee.png" alt="Buy Me a Coffee" class="buymeacoffee-logo">
|
||||
</a>
|
||||
<a href="https://discord.gg/mA2g69Fhxq" target="_blank" class="discord-link">
|
||||
<img src="./images/discord.png" alt="Discord" class="discord-logo">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Modal -->
|
||||
<div id="license-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close-button" onclick="closeLicense()">×</span>
|
||||
<h2 data-localize="license_and_credits">License and Credits</h2>
|
||||
<div id="license-content" style="overflow-y: auto; max-height: 300px; font-size: 0.85em; line-height: 1.3; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<a href="https://github.com/louis-e/arnis" target="_blank" class="footer-link" data-localize="footer_text">
|
||||
© {year} Arnis v{version} by louis-e
|
||||
</a>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
153
src/args.rs
@@ -1,6 +1,6 @@
|
||||
use crate::coordinate_system::geographic::LLBBox;
|
||||
use clap::Parser;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Command-line arguments parser
|
||||
@@ -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: String,
|
||||
/// 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<String, String> {
|
||||
let mc_world_path = Path::new(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(path.to_string())
|
||||
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(®ion_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
@@ -236,7 +236,7 @@ impl Block {
|
||||
155 => "chest",
|
||||
156 => "red_carpet",
|
||||
157 => "anvil",
|
||||
158 => "jukebox",
|
||||
158 => "note_block",
|
||||
159 => "oak_door",
|
||||
160 => "brewing_stand",
|
||||
161 => "red_bed", // North head
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -667,7 +869,7 @@ pub const OAK_STAIRS: Block = Block::new(144);
|
||||
pub const CHEST: Block = Block::new(155);
|
||||
pub const RED_CARPET: Block = Block::new(156);
|
||||
pub const ANVIL: Block = Block::new(157);
|
||||
pub const JUKEBOX: Block = Block::new(158);
|
||||
pub const NOTE_BLOCK: Block = Block::new(158);
|
||||
pub const OAK_DOOR: Block = Block::new(159);
|
||||
pub const BREWING_STAND: Block = Block::new(160);
|
||||
pub const RED_BED_NORTH_HEAD: Block = Block::new(161);
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -1,34 +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 region_dir: String = format!("{}/region", args.path);
|
||||
let mut editor: WorldEditor = WorldEditor::new(®ion_dir, &xzbbox);
|
||||
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()
|
||||
@@ -40,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 {
|
||||
@@ -58,36 +136,86 @@ 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);
|
||||
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);
|
||||
} else if way.tags.contains_key("waterway") {
|
||||
waterways::generate_waterways(&mut editor, way);
|
||||
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, &xzbbox);
|
||||
} else {
|
||||
waterways::generate_waterways(&mut editor, way);
|
||||
}
|
||||
} else if way.tags.contains_key("bridge") {
|
||||
//bridges::generate_bridges(&mut editor, way, ground_level); // TODO FIX
|
||||
} else if way.tags.contains_key("railway") {
|
||||
railways::generate_railways(&mut editor, way);
|
||||
} else if way.tags.contains_key("roller_coaster") {
|
||||
railways::generate_roller_coaster(&mut editor, way);
|
||||
} else if way.tags.contains_key("aeroway") || way.tags.contains_key("area:aeroway")
|
||||
{
|
||||
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") {
|
||||
@@ -95,45 +223,110 @@ 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);
|
||||
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);
|
||||
} else if rel.tags.contains_key("water")
|
||||
|| rel.tags.get("natural") == Some(&"water".to_string())
|
||||
{
|
||||
water_areas::generate_water_areas(&mut editor, rel);
|
||||
} 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(
|
||||
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,
|
||||
&ProcessedElement::Relation(rel.clone()),
|
||||
rel,
|
||||
args,
|
||||
&flood_fill_cache,
|
||||
&xzbbox,
|
||||
);
|
||||
} else if rel.tags.contains_key("water")
|
||||
|| rel
|
||||
.tags
|
||||
.get("natural")
|
||||
.map(|val| val == "water" || val == "bay")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
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,
|
||||
&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;
|
||||
@@ -157,44 +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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,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
@@ -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>());
|
||||
}
|
||||
}
|
||||
120
src/element_processing/advertising.rs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -92,22 +146,12 @@ pub fn generate_amenities(editor: &mut WorldEditor, element: &ProcessedElement,
|
||||
}
|
||||
}
|
||||
}
|
||||
"vending" => {
|
||||
// Place vending machine blocks
|
||||
if let Some(pt) = first_node {
|
||||
editor.set_block(IRON_BLOCK, pt.x, 1, pt.z, None, None);
|
||||
editor.set_block(IRON_BLOCK, pt.x, 2, pt.z, None, None);
|
||||
}
|
||||
}
|
||||
"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() {
|
||||
@@ -267,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")
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
src/element_processing/emergency.rs
Normal 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);
|
||||
}
|
||||
@@ -2,11 +2,82 @@ 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;
|
||||
|
||||
pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, args: &Args) {
|
||||
/// 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,
|
||||
highway_connectivity: &HighwayConnectivityMap,
|
||||
flood_fill_cache: &FloodFillCache,
|
||||
) {
|
||||
generate_highways_internal(
|
||||
editor,
|
||||
element,
|
||||
args,
|
||||
highway_connectivity,
|
||||
flood_fill_cache,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if let ProcessedElement::Way(way) = element {
|
||||
if way.tags.contains_key("highway") {
|
||||
let layer_value = way
|
||||
.tags
|
||||
.get("layer")
|
||||
.and_then(|layer| layer.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Treat negative layers as ground level (0) for connectivity
|
||||
let layer_value = if layer_value < 0 { 0 } else { layer_value };
|
||||
|
||||
// Add connectivity for start and end nodes
|
||||
if !way.nodes.is_empty() {
|
||||
let start_node = &way.nodes[0];
|
||||
let end_node = &way.nodes[way.nodes.len() - 1];
|
||||
|
||||
let start_coord = (start_node.x, start_node.z);
|
||||
let end_coord = (end_node.x, end_node.z);
|
||||
|
||||
connectivity_map
|
||||
.entry(start_coord)
|
||||
.or_default()
|
||||
.push(layer_value);
|
||||
connectivity_map
|
||||
.entry(end_coord)
|
||||
.or_default()
|
||||
.push(layer_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectivity_map
|
||||
}
|
||||
|
||||
/// Internal function that generates highways with connectivity context for elevation handling
|
||||
fn generate_highways_internal(
|
||||
editor: &mut WorldEditor,
|
||||
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" {
|
||||
// Handle street lamps
|
||||
@@ -77,14 +148,9 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -97,13 +163,33 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
let mut add_outline = false;
|
||||
let scale_factor = args.scale;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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 mut layer_value = element
|
||||
.tags()
|
||||
.get("layer")
|
||||
.and_then(|layer| layer.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Treat negative layers as ground level (0)
|
||||
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") {
|
||||
if level.parse::<i32>().unwrap_or(0) < 0 {
|
||||
return;
|
||||
@@ -120,10 +206,14 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
block_type = DIRT_PATH;
|
||||
block_range = 1;
|
||||
}
|
||||
"motorway" | "primary" => {
|
||||
"motorway" | "primary" | "trunk" => {
|
||||
block_range = 5;
|
||||
add_stripe = true;
|
||||
}
|
||||
"secondary" => {
|
||||
block_range = 4;
|
||||
add_stripe = true;
|
||||
}
|
||||
"tertiary" => {
|
||||
add_stripe = true;
|
||||
}
|
||||
@@ -173,7 +263,98 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
block_range = ((block_range as f64) * scale_factor).floor() as i32;
|
||||
}
|
||||
|
||||
// Calculate elevation based on layer
|
||||
const LAYER_HEIGHT_STEP: i32 = 6; // Each layer is 6 blocks higher/lower
|
||||
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(
|
||||
&way.nodes[way.nodes.len() - 1],
|
||||
layer_value,
|
||||
highway_connectivity,
|
||||
);
|
||||
|
||||
// Calculate total way length for slope distribution (needed before valley bridge check)
|
||||
let total_way_length = calculate_way_length(way);
|
||||
|
||||
// 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;
|
||||
|
||||
// Override elevation and slopes for short isolated segments
|
||||
let (effective_elevation, effective_start_slope, effective_end_slope) =
|
||||
if is_short_isolated_elevated {
|
||||
(0, false, false) // Treat as ground level
|
||||
} else {
|
||||
(base_elevation, needs_start_slope, needs_end_slope)
|
||||
};
|
||||
|
||||
let slope_length = (total_way_length as f32 * 0.35).clamp(15.0, 50.0) as usize; // 35% of way length, max 50 blocks, min 15 blocks
|
||||
|
||||
// Iterate over nodes to create the highway
|
||||
let mut segment_index = 0;
|
||||
let total_segments = way.nodes.len() - 1;
|
||||
|
||||
for node in &way.nodes {
|
||||
if let Some(prev) = previous_node {
|
||||
let (x1, z1) = prev;
|
||||
@@ -181,17 +362,41 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
let z2: i32 = node.z;
|
||||
|
||||
// Generate the line of coordinates between the two nodes
|
||||
// we don't care about the y because it's going to get overwritten
|
||||
// I'm not sure if we'll keep it this way
|
||||
let bresenham_points: Vec<(i32, i32, i32)> =
|
||||
bresenham_line(x1, 0, z1, x2, 0, z2);
|
||||
|
||||
// Calculate elevation for this segment
|
||||
let segment_length = bresenham_points.len();
|
||||
|
||||
// Variables to manage dashed line pattern
|
||||
let mut stripe_length: i32 = 0;
|
||||
let dash_length: i32 = (5.0 * scale_factor).ceil() as i32; // Length of the solid part of the stripe
|
||||
let gap_length: i32 = (5.0 * scale_factor).ceil() as i32; // Length of the gap part of the stripe
|
||||
let dash_length: i32 = (5.0 * scale_factor).ceil() as i32;
|
||||
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
|
||||
// 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)
|
||||
};
|
||||
|
||||
for (x, _, z) in bresenham_points {
|
||||
// Draw the road surface for the entire width
|
||||
for dx in -block_range..=block_range {
|
||||
for dz in -block_range..=block_range {
|
||||
@@ -206,52 +411,150 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
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,
|
||||
0,
|
||||
current_y,
|
||||
set_z,
|
||||
Some(&[BLACK_CONCRETE]),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
editor.set_block(
|
||||
BLACK_CONCRETE,
|
||||
set_x,
|
||||
0,
|
||||
current_y,
|
||||
set_z,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
} 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,
|
||||
0,
|
||||
current_y,
|
||||
set_z,
|
||||
Some(&[BLACK_CONCRETE]),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
editor.set_block(
|
||||
BLACK_CONCRETE,
|
||||
set_x,
|
||||
0,
|
||||
current_y,
|
||||
set_z,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
editor.set_block(
|
||||
} else if use_absolute_y {
|
||||
editor.set_block_absolute(
|
||||
block_type,
|
||||
set_x,
|
||||
0,
|
||||
current_y,
|
||||
set_z,
|
||||
None,
|
||||
Some(&[BLACK_CONCRETE, WHITE_CONCRETE]),
|
||||
);
|
||||
} else {
|
||||
editor.set_block(
|
||||
block_type,
|
||||
set_x,
|
||||
current_y,
|
||||
set_z,
|
||||
None,
|
||||
Some(&[BLACK_CONCRETE, WHITE_CONCRETE]),
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
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/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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,43 +565,76 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
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,
|
||||
0,
|
||||
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,
|
||||
0,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a dashed white line in the middle for larger roads
|
||||
if add_stripe {
|
||||
if stripe_length < dash_length {
|
||||
let stripe_x: i32 = x;
|
||||
let stripe_z: i32 = z;
|
||||
editor.set_block(
|
||||
WHITE_CONCRETE,
|
||||
stripe_x,
|
||||
0,
|
||||
stripe_z,
|
||||
Some(&[BLACK_CONCRETE]),
|
||||
None,
|
||||
);
|
||||
let stripe_x: i32 = *x;
|
||||
let stripe_z: i32 = *z;
|
||||
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
|
||||
@@ -308,6 +644,8 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
segment_index += 1;
|
||||
}
|
||||
previous_node = Some((node.x, node.z));
|
||||
}
|
||||
@@ -315,6 +653,171 @@ pub fn generate_highways(editor: &mut WorldEditor, element: &ProcessedElement, a
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to determine if a slope should be added at a specific node
|
||||
fn should_add_slope_at_node(
|
||||
node: &crate::osm_parser::ProcessedNode,
|
||||
current_layer: i32,
|
||||
highway_connectivity: &HashMap<(i32, i32), Vec<i32>>,
|
||||
) -> bool {
|
||||
let node_coord = (node.x, node.z);
|
||||
|
||||
// If we don't have connectivity information, always add slopes for non-zero layers
|
||||
if highway_connectivity.is_empty() {
|
||||
return current_layer != 0;
|
||||
}
|
||||
|
||||
// Check if there are other highways at different layers connected to this node
|
||||
if let Some(connected_layers) = highway_connectivity.get(&node_coord) {
|
||||
// Count how many ways are at the same layer as current way
|
||||
let same_layer_count = connected_layers
|
||||
.iter()
|
||||
.filter(|&&layer| layer == current_layer)
|
||||
.count();
|
||||
|
||||
// If this is the only way at this layer connecting to this node, we need a slope
|
||||
// (unless we're at ground level and connecting to ground level ways)
|
||||
if same_layer_count <= 1 {
|
||||
return current_layer != 0;
|
||||
}
|
||||
|
||||
// If there are multiple ways at the same layer, don't add slope
|
||||
false
|
||||
} else {
|
||||
// No other highways connected, add slope if not at ground level
|
||||
current_layer != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to calculate the total length of a way in blocks
|
||||
fn calculate_way_length(way: &ProcessedWay) -> usize {
|
||||
let mut total_length = 0;
|
||||
let mut previous_node: Option<&crate::osm_parser::ProcessedNode> = None;
|
||||
|
||||
for node in &way.nodes {
|
||||
if let Some(prev) = previous_node {
|
||||
let dx = (node.x - prev.x).abs();
|
||||
let dz = (node.z - prev.z).abs();
|
||||
total_length += ((dx * dx + dz * dz) as f32).sqrt() as usize;
|
||||
}
|
||||
previous_node = Some(node);
|
||||
}
|
||||
|
||||
total_length
|
||||
}
|
||||
|
||||
/// Calculate the Y elevation for a specific point along the highway
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn calculate_point_elevation(
|
||||
segment_index: usize,
|
||||
point_index: usize,
|
||||
segment_length: usize,
|
||||
total_segments: usize,
|
||||
base_elevation: i32,
|
||||
needs_start_slope: bool,
|
||||
needs_end_slope: bool,
|
||||
slope_length: usize,
|
||||
) -> i32 {
|
||||
// If no slopes needed, return base elevation
|
||||
if !needs_start_slope && !needs_end_slope {
|
||||
return base_elevation;
|
||||
}
|
||||
|
||||
// Calculate total distance from start
|
||||
let total_distance_from_start = segment_index * segment_length + point_index;
|
||||
let total_way_length = total_segments * segment_length;
|
||||
|
||||
// Ensure we have reasonable values
|
||||
if total_way_length == 0 || slope_length == 0 {
|
||||
return base_elevation;
|
||||
}
|
||||
|
||||
// Start slope calculation - gradual rise from ground level
|
||||
if needs_start_slope && total_distance_from_start <= slope_length {
|
||||
let slope_progress = total_distance_from_start as f32 / slope_length as f32;
|
||||
let elevation_offset = (base_elevation as f32 * slope_progress) as i32;
|
||||
return elevation_offset;
|
||||
}
|
||||
|
||||
// End slope calculation - gradual descent to ground level
|
||||
if needs_end_slope
|
||||
&& total_distance_from_start >= (total_way_length.saturating_sub(slope_length))
|
||||
{
|
||||
let distance_from_end = total_way_length - total_distance_from_start;
|
||||
let slope_progress = distance_from_end as f32 / slope_length as f32;
|
||||
let elevation_offset = (base_elevation as f32 * slope_progress) as i32;
|
||||
return elevation_offset;
|
||||
}
|
||||
|
||||
// Middle section at full elevation
|
||||
base_elevation
|
||||
}
|
||||
|
||||
/// Add support pillars for elevated highways
|
||||
fn add_highway_support_pillar(
|
||||
editor: &mut WorldEditor,
|
||||
x: i32,
|
||||
highway_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 {
|
||||
// Add pillar from ground to highway level
|
||||
for y in 1..highway_y {
|
||||
editor.set_block(STONE_BRICKS, x, y, z, None, None);
|
||||
}
|
||||
|
||||
// Add pillar base
|
||||
for base_dx in -1..=1 {
|
||||
for base_dz in -1..=1 {
|
||||
editor.set_block(STONE_BRICKS, x + base_dx, 0, z + base_dz, None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
340
src/element_processing/historic.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -18,6 +25,7 @@ pub fn generate_leisure(editor: &mut WorldEditor, element: &ProcessedWay, args:
|
||||
"park" | "nature_reserve" | "garden" | "disc_golf_course" | "golf_course" => {
|
||||
GRASS_BLOCK
|
||||
}
|
||||
"schoolyard" => BLACK_CONCRETE,
|
||||
"playground" | "recreation_ground" | "pitch" | "beach_resort" | "dog_park" => {
|
||||
if let Some(surface) = element.tags.get("surface") {
|
||||
match surface.as_str() {
|
||||
@@ -73,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);
|
||||
@@ -90,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
|
||||
@@ -114,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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -122,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 => {
|
||||
@@ -175,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn generate_man_made(editor: &mut WorldEditor, element: &ProcessedElement, _
|
||||
"chimney" => generate_chimney(editor, element),
|
||||
"water_well" => generate_water_well(editor, element),
|
||||
"water_tower" => generate_water_tower(editor, element),
|
||||
"mast" => generate_antenna(editor, element),
|
||||
_ => {} // Unknown man_made type, ignore
|
||||
}
|
||||
}
|
||||
@@ -96,7 +97,6 @@ fn generate_antenna(editor: &mut WorldEditor, element: &ProcessedElement) {
|
||||
Some(h) => h.parse::<i32>().unwrap_or(20).min(40), // Max 40 blocks
|
||||
None => match element.tags().get("tower:type").map(|s| s.as_str()) {
|
||||
Some("communication") => 20,
|
||||
Some("transmission") => 25,
|
||||
Some("cellular") => 15,
|
||||
_ => 20,
|
||||
},
|
||||
@@ -249,6 +249,7 @@ pub fn generate_man_made_nodes(editor: &mut WorldEditor, node: &ProcessedNode) {
|
||||
"chimney" => generate_chimney(editor, &element),
|
||||
"water_well" => generate_water_well(editor, &element),
|
||||
"water_tower" => generate_water_tower(editor, &element),
|
||||
"mast" => generate_antenna(editor, &element),
|
||||
_ => {} // Unknown man_made type, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
385
src/element_processing/power.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,71 @@ fn determine_rail_direction(
|
||||
(None, None) => RAIL_NORTH_SOUTH,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_roller_coaster(editor: &mut WorldEditor, element: &ProcessedWay) {
|
||||
if let Some(roller_coaster) = element.tags.get("roller_coaster") {
|
||||
if roller_coaster == "track" {
|
||||
// Check if it's indoor (skip if yes)
|
||||
if let Some(indoor) = element.tags.get("indoor") {
|
||||
if indoor == "yes" {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if layer is negative (skip if yes)
|
||||
if let Some(layer) = element.tags.get("layer") {
|
||||
if let Ok(layer_value) = layer.parse::<i32>() {
|
||||
if layer_value < 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elevation_height = 4; // 4 blocks in the air
|
||||
let pillar_interval = 6; // Support pillars every 6 blocks
|
||||
|
||||
for i in 1..element.nodes.len() {
|
||||
let prev_node = element.nodes[i - 1].xz();
|
||||
let cur_node = element.nodes[i].xz();
|
||||
|
||||
let points = bresenham_line(prev_node.x, 0, prev_node.z, cur_node.x, 0, cur_node.z);
|
||||
let smoothed_points = smooth_diagonal_rails(&points);
|
||||
|
||||
for j in 0..smoothed_points.len() {
|
||||
let (bx, _, bz) = smoothed_points[j];
|
||||
|
||||
// Place track foundation at elevation height
|
||||
editor.set_block(IRON_BLOCK, bx, elevation_height, bz, None, None);
|
||||
|
||||
let prev = if j > 0 {
|
||||
Some(smoothed_points[j - 1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if j < smoothed_points.len() - 1 {
|
||||
Some(smoothed_points[j + 1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rail_block = determine_rail_direction(
|
||||
(bx, bz),
|
||||
prev.map(|(x, _, z)| (x, z)),
|
||||
next.map(|(x, _, z)| (x, z)),
|
||||
);
|
||||
|
||||
// Place rail on top of the foundation
|
||||
editor.set_block(rail_block, bx, elevation_height + 1, bz, None, None);
|
||||
|
||||
// Place support pillars every pillar_interval blocks
|
||||
if bx % pillar_interval == 0 && bz % pillar_interval == 0 {
|
||||
// Create a pillar from ground level up to the track
|
||||
for y in 1..elevation_height {
|
||||
editor.set_block(IRON_BLOCK, bx, y, bz, None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -133,7 +246,7 @@ pub fn get_interior_block(c: char, is_layer2: bool, wall_block: Block) -> Option
|
||||
'6' => Some(RED_BED_SOUTH_FOOT), // Bed South Foot
|
||||
'7' => Some(RED_BED_WEST_HEAD), // Bed West Head
|
||||
'8' => Some(RED_BED_WEST_FOOT), // Bed West Foot
|
||||
'H' => Some(CHEST), // Chest
|
||||
// 'H' => Some(CHEST), // Chest
|
||||
'L' => Some(CAULDRON), // Cauldron
|
||||
'A' => Some(ANVIL), // Anvil
|
||||
'P' => Some(OAK_PRESSURE_PLATE), // Pressure Plate
|
||||
@@ -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(JUKEBOX), // Jukebox
|
||||
'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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -307,7 +413,7 @@ impl Tree<'_> {
|
||||
FURNACE,
|
||||
ANVIL,
|
||||
BREWING_STAND,
|
||||
JUKEBOX,
|
||||
NOTE_BLOCK,
|
||||
BOOKSHELF,
|
||||
CAULDRON,
|
||||
// Beds
|
||||
@@ -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,
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
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,
|
||||
osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation},
|
||||
coordinate_system::cartesian::{XZBBox, XZPoint},
|
||||
osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation, ProcessedWay},
|
||||
world_editor::WorldEditor,
|
||||
};
|
||||
|
||||
pub fn generate_water_areas(editor: &mut WorldEditor, element: &ProcessedRelation) {
|
||||
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_closed_rings(&outers) {
|
||||
println!("Skipping way {} due to invalid polygon", element.id);
|
||||
return;
|
||||
}
|
||||
|
||||
generate_water_areas(editor, &outers, &[]);
|
||||
}
|
||||
|
||||
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.tags.get("natural") == Some(&"water".to_string());
|
||||
|| element
|
||||
.tags
|
||||
.get("natural")
|
||||
.map(|val| val == "water" || val == "bay")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_water {
|
||||
return;
|
||||
@@ -33,160 +51,135 @@ pub fn generate_water_areas(editor: &mut WorldEditor, element: &ProcessedRelatio
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Process each outer polygon individually
|
||||
for (i, outer_nodes) in outers.iter().enumerate() {
|
||||
let mut individual_outers = vec![outer_nodes.clone()];
|
||||
// Preserve OSM-defined outer/inner roles without modification
|
||||
super::merge_way_segments(&mut outers);
|
||||
|
||||
merge_loopy_loops(&mut individual_outers);
|
||||
if !verify_loopy_loops(&individual_outers) {
|
||||
println!(
|
||||
"Skipping invalid outer polygon {} for relation {}",
|
||||
i + 1,
|
||||
element.id
|
||||
);
|
||||
continue; // Skip this outer if it's not valid
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
merge_loopy_loops(&mut inners);
|
||||
if !verify_loopy_loops(&inners) {
|
||||
// If inners are invalid, process outer without inners
|
||||
let empty_inners: Vec<Vec<ProcessedNode>> = vec![];
|
||||
let mut temp_inners = empty_inners;
|
||||
merge_loopy_loops(&mut temp_inners);
|
||||
|
||||
let (min_x, min_z) = editor.get_min_coords();
|
||||
let (max_x, max_z) = editor.get_max_coords();
|
||||
let individual_outers_xz: Vec<Vec<XZPoint>> = individual_outers
|
||||
.iter()
|
||||
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let empty_inners_xz: Vec<Vec<XZPoint>> = vec![];
|
||||
|
||||
inverse_floodfill(
|
||||
min_x,
|
||||
min_z,
|
||||
max_x,
|
||||
max_z,
|
||||
individual_outers_xz,
|
||||
empty_inners_xz,
|
||||
editor,
|
||||
start_time,
|
||||
);
|
||||
continue;
|
||||
// If no valid outer loops remain, skip the relation
|
||||
if outers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (min_x, min_z) = editor.get_min_coords();
|
||||
let (max_x, max_z) = editor.get_max_coords();
|
||||
let individual_outers_xz: Vec<Vec<XZPoint>> = individual_outers
|
||||
.iter()
|
||||
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let inners_xz: Vec<Vec<XZPoint>> = inners
|
||||
.iter()
|
||||
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
|
||||
inverse_floodfill(
|
||||
min_x,
|
||||
min_z,
|
||||
max_x,
|
||||
max_z,
|
||||
individual_outers_xz,
|
||||
inners_xz,
|
||||
editor,
|
||||
start_time,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
generate_water_areas(editor, &outers, &inners);
|
||||
}
|
||||
|
||||
// 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![];
|
||||
fn generate_water_areas(
|
||||
editor: &mut WorldEditor,
|
||||
outers: &[Vec<ProcessedNode>],
|
||||
inners: &[Vec<ProcessedNode>],
|
||||
) {
|
||||
// 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 i in 0..loops.len() {
|
||||
for j in 0..loops.len() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
removed.sort();
|
||||
|
||||
for r in removed.iter().rev() {
|
||||
loops.remove(*r);
|
||||
// If no valid bounds, nothing to fill
|
||||
if poly_min_x == i32::MAX || poly_max_x == i32::MIN {
|
||||
return;
|
||||
}
|
||||
|
||||
let merged_len: usize = merged.len();
|
||||
for m in merged {
|
||||
loops.push(m);
|
||||
}
|
||||
// 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);
|
||||
|
||||
if merged_len > 0 {
|
||||
merge_loopy_loops(loops);
|
||||
}
|
||||
let outers_xz: Vec<Vec<XZPoint>> = outers
|
||||
.iter()
|
||||
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let inners_xz: Vec<Vec<XZPoint>> = inners
|
||||
.iter()
|
||||
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
|
||||
scanline_fill_water(min_x, min_z, max_x, max_z, &outers_xz, &inners_xz, editor);
|
||||
}
|
||||
|
||||
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");
|
||||
/// 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();
|
||||
|
||||
// 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 !is_closed {
|
||||
eprintln!("WARN: Disconnected ring");
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
@@ -194,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +139,131 @@ fn lat_lng_to_tile(lat: f64, lng: f64, zoom: u8) -> (u32, u32) {
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// 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>>, 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 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,
|
||||
@@ -67,51 +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() {
|
||||
println!(
|
||||
"Loading cached tile x={tile_x},y={tile_y},z={zoom} from {}",
|
||||
tile_path.display()
|
||||
);
|
||||
let img: image::DynamicImage = image::open(&tile_path)?;
|
||||
img.to_rgb8()
|
||||
} else {
|
||||
// AWS Terrain Tiles don't require an API key
|
||||
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());
|
||||
// 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)..."
|
||||
);
|
||||
|
||||
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)?;
|
||||
img.to_rgb8()
|
||||
};
|
||||
// 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}"))?;
|
||||
|
||||
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"));
|
||||
|
||||
let rgb_img = fetch_or_load_tile(&client, *tile_x, *tile_y, zoom, &tile_path)?;
|
||||
Ok(((*tile_x, *tile_y), rgb_img))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -172,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)"
|
||||
@@ -236,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;
|
||||
@@ -278,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,
|
||||
@@ -305,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,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;
|
||||
|
||||
@@ -453,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);
|
||||
}
|
||||
|
||||
134
src/floodfill.rs
@@ -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;
|
||||
@@ -71,7 +135,7 @@ fn optimized_flood_fill_area(
|
||||
|
||||
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) {
|
||||
// Fast timeout check - only every few iterations
|
||||
// Fast timeout check, only every few iterations
|
||||
if filled_area.len() % 100 == 0 {
|
||||
if let Some(timeout) = timeout {
|
||||
if start_time.elapsed() > *timeout {
|
||||
@@ -81,16 +145,14 @@ fn optimized_flood_fill_area(
|
||||
}
|
||||
|
||||
// Skip if already visited or not inside polygon
|
||||
if global_visited.contains(&(x, z))
|
||||
|| !polygon.contains(&Point::new(x as f64, z as f64))
|
||||
{
|
||||
if visited.contains(x, z) || !polygon.contains(&Point::new(x as f64, z as f64)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start flood fill from this seed point
|
||||
queue.clear(); // Reuse queue instead of creating new one
|
||||
queue.push_back((x, z));
|
||||
global_visited.insert((x, z));
|
||||
visited.insert(x, z);
|
||||
|
||||
while let Some((curr_x, curr_z)) = queue.pop_front() {
|
||||
// Add current point to filled area
|
||||
@@ -104,17 +166,16 @@ fn optimized_flood_fill_area(
|
||||
(curr_x, curr_z + 1),
|
||||
];
|
||||
|
||||
for (nx, nz) in neighbors.iter() {
|
||||
if *nx >= min_x
|
||||
&& *nx <= max_x
|
||||
&& *nz >= min_z
|
||||
&& *nz <= max_z
|
||||
&& !global_visited.contains(&(*nx, *nz))
|
||||
for &(nx, nz) in &neighbors {
|
||||
if nx >= min_x
|
||||
&& nx <= max_x
|
||||
&& nz >= min_z
|
||||
&& nz <= max_z
|
||||
&& visited.insert(nx, nz)
|
||||
{
|
||||
// Only check polygon containment for unvisited points
|
||||
if polygon.contains(&Point::new(*nx as f64, *nz as f64)) {
|
||||
global_visited.insert((*nx, *nz));
|
||||
queue.push_back((*nx, *nz));
|
||||
if polygon.contains(&Point::new(nx as f64, nz as f64)) {
|
||||
queue.push_back((nx, nz));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,15 +197,16 @@ fn original_flood_fill_area(
|
||||
) -> Vec<(i32, i32)> {
|
||||
let start_time = Instant::now();
|
||||
let mut filled_area: Vec<(i32, i32)> = Vec::new();
|
||||
let mut global_visited: HashSet<(i32, i32)> = HashSet::new();
|
||||
let mut visited = FloodBitmap::new(min_x, max_x, min_z, max_z);
|
||||
|
||||
// Convert input to a geo::Polygon for efficient point-in-polygon testing
|
||||
// Convert input to a geo::Polygon for efficient point-in-polygon testing,
|
||||
// with normalized winding order to avoid undefined Contains results
|
||||
let exterior_coords: Vec<(f64, f64)> = polygon_coords
|
||||
.iter()
|
||||
.map(|&(x, z)| (x as f64, z as f64))
|
||||
.collect::<Vec<_>>();
|
||||
let exterior: LineString = LineString::from(exterior_coords);
|
||||
let polygon: Polygon<f64> = Polygon::new(exterior, vec![]);
|
||||
let polygon: Polygon<f64> = Polygon::new(exterior, vec![]).orient(Direction::Default);
|
||||
|
||||
// Optimized step sizes for large polygons - coarser sampling for speed
|
||||
let width = max_x - min_x + 1;
|
||||
@@ -160,25 +222,22 @@ fn original_flood_fill_area(
|
||||
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) {
|
||||
// Reduced timeout checking frequency for better performance
|
||||
if global_visited.len() % 200 == 0 {
|
||||
if let Some(timeout) = timeout {
|
||||
if &start_time.elapsed() > timeout {
|
||||
return filled_area;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if already processed or not inside polygon
|
||||
if global_visited.contains(&(x, z))
|
||||
|| !polygon.contains(&Point::new(x as f64, z as f64))
|
||||
{
|
||||
if visited.contains(x, z) || !polygon.contains(&Point::new(x as f64, z as f64)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start flood-fill from this seed point
|
||||
queue.clear(); // Reuse queue
|
||||
queue.push_back((x, z));
|
||||
global_visited.insert((x, z));
|
||||
visited.insert(x, z);
|
||||
|
||||
while let Some((curr_x, curr_z)) = queue.pop_front() {
|
||||
// Only check polygon containment once per point when adding to filled_area
|
||||
@@ -193,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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -23,12 +25,26 @@ impl Ground {
|
||||
}
|
||||
|
||||
pub fn new_enabled(bbox: &LLBBox, scale: f64, ground_level: i32) -> Self {
|
||||
let elevation_data = fetch_elevation_data(bbox, scale, ground_level)
|
||||
.expect("Failed to fetch elevation data");
|
||||
Self {
|
||||
elevation_enabled: true,
|
||||
ground_level,
|
||||
elevation_data: Some(elevation_data),
|
||||
match fetch_elevation_data(bbox, scale, ground_level) {
|
||||
Ok(elevation_data) => Self {
|
||||
elevation_enabled: true,
|
||||
ground_level,
|
||||
elevation_data: Some(elevation_data),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch elevation data: {}", e);
|
||||
#[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,
|
||||
ground_level,
|
||||
elevation_data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,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");
|
||||
|
||||
798
src/gui.rs
83
gui-src/css/bbox.css → src/gui/css/bbox.css
vendored
@@ -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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 418 B After Width: | Height: | Size: 418 B |
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 205 B After Width: | Height: | Size: 205 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 207 B |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 278 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 328 B After Width: | Height: | Size: 328 B |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 849 B After Width: | Height: | Size: 849 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 847 B After Width: | Height: | Size: 847 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
BIN
src/gui/css/maps/images/spritesheet-2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/gui/css/maps/images/spritesheet.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
356
gui-src/css/styles.css → src/gui/css/styles.css
vendored
@@ -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 {
|
||||
@@ -389,11 +654,39 @@ button:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Generation mode dropdown styling */
|
||||
.generation-mode-dropdown {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fecc44;
|
||||
background-color: #ffffff;
|
||||
color: #0f0f0f;
|
||||
appearance: menulist;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.generation-mode-dropdown option {
|
||||
padding: 5px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.generation-mode-dropdown {
|
||||
background-color: #0f0f0f98;
|
||||
color: #ffffff;
|
||||
border: 1px solid #fecc44;
|
||||
}
|
||||
}
|
||||
|
||||
/* Language dropdown styling */
|
||||
.language-dropdown {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
padding: 5px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fecc44;
|
||||
background-color: #ffffff;
|
||||
@@ -421,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;
|
||||
@@ -476,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 */
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 487 B After Width: | Height: | Size: 487 B |