Merge origin/main into feature/overture-maps-integration

Resolve conflicts: keep both rotation (from main) and
no_overture (from this branch) in args.rs and gui.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louis-e
2026-04-05 23:15:46 +02:00
33 changed files with 902 additions and 41 deletions

View File

@@ -85,6 +85,10 @@ pub struct Args {
/// in OpenStreetMap coverage.
#[arg(long = "no-overture", default_value_t = false)]
pub no_overture: bool,
/// Clockwise rotation angle in degrees (optional, range: -90 to 90)
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
pub rotation: f64,
}
/// Validates CLI arguments after parsing.
@@ -144,6 +148,11 @@ pub fn validate_args(args: &Args) -> Result<(), String> {
_ => {}
}
// Validate rotation angle range (also rejects NaN and infinity)
if !args.rotation.is_finite() || args.rotation < -90.0 || args.rotation > 90.0 {
return Err("Rotation angle must be between -90 and 90 degrees.".to_string());
}
Ok(())
}

View File

@@ -3,5 +3,6 @@ mod xzpoint;
mod xzvector;
pub use xzbbox::XZBBox;
pub use xzbbox::XZBBoxRect;
pub use xzpoint::XZPoint;
pub use xzvector::XZVector;

View File

@@ -1,4 +1,5 @@
mod rectangle;
mod xzbbox_enum;
pub use rectangle::XZBBoxRect;
pub use xzbbox_enum::XZBBox;

View File

@@ -8,6 +8,23 @@ use crate::telemetry::{send_log, LogLevel};
use colored::Colorize;
use image::{Rgb, RgbImage};
/// Parameters describing the inverse-rotation needed to check whether a world
/// coordinate falls inside the original (pre-rotation) bounding box.
#[derive(Clone)]
pub struct RotationMask {
/// Center of rotation (world coordinates)
pub cx: f64,
pub cz: f64,
/// sin/cos of the *negative* angle (inverse rotation)
pub neg_sin: f64,
pub cos: f64,
/// Original axis-aligned bounding box before rotation
pub orig_min_x: i32,
pub orig_max_x: i32,
pub orig_min_z: i32,
pub orig_max_z: i32,
}
/// Represents terrain data, land cover classification, and elevation settings
#[derive(Clone)]
pub struct Ground {
@@ -15,6 +32,8 @@ pub struct Ground {
ground_level: i32,
elevation_data: Option<ElevationData>,
land_cover: Option<LandCoverData>,
/// When set, coordinates outside the rotated original bbox are skipped.
rotation_mask: Option<RotationMask>,
}
impl Ground {
@@ -24,6 +43,7 @@ impl Ground {
ground_level,
elevation_data: None,
land_cover: None,
rotation_mask: None,
}
}
@@ -59,6 +79,7 @@ impl Ground {
ground_level,
elevation_data: Some(elevation_data),
land_cover,
rotation_mask: None,
}
}
Err(e) => {
@@ -74,6 +95,7 @@ impl Ground {
ground_level,
elevation_data: None,
land_cover: None,
rotation_mask: None,
}
}
}
@@ -185,6 +207,60 @@ impl Ground {
data.heights[z][x]
}
/// Replace the elevation grid with new rotated/transformed data.
/// Used by the rotation operator to update elevation after rotating.
pub fn set_elevation_data(&mut self, heights: Vec<Vec<i32>>, width: usize, height: usize) {
if let Some(ref mut data) = self.elevation_data {
data.heights = heights;
data.width = width;
data.height = height;
}
}
/// Replace the land-cover grids with new rotated/transformed data.
/// Used by the rotation operator to keep land cover aligned with elevation.
pub fn set_land_cover_data(
&mut self,
grid: Vec<Vec<u8>>,
water_distance: Vec<Vec<u8>>,
width: usize,
height: usize,
) {
if let Some(ref mut lc) = self.land_cover {
lc.grid = grid;
lc.water_distance = water_distance;
lc.width = width;
lc.height = height;
}
}
/// Store rotation parameters so we can mask out-of-bounds blocks later.
pub fn set_rotation_mask(&mut self, mask: RotationMask) {
self.rotation_mask = Some(mask);
}
/// Returns `true` if the coordinate is inside the rotated original bbox.
/// When no rotation was applied, always returns `true`.
#[inline(always)]
pub fn is_in_rotated_bounds(&self, x: i32, z: i32) -> bool {
let mask = match self.rotation_mask {
Some(ref m) => m,
None => return true,
};
// Inverse-rotate (x, z) back to original space
let dx = x as f64 - mask.cx;
let dz = z as f64 - mask.cz;
let orig_x = dx * mask.cos + dz * mask.neg_sin + mask.cx;
let orig_z = -dx * mask.neg_sin + dz * mask.cos + mask.cz;
// Allow a tiny tolerance so points that land infinitesimally outside the
// integer bbox due to floating-point rounding are still considered inside.
const EPSILON: f64 = 1.0e-9;
orig_x >= mask.orig_min_x as f64 - EPSILON
&& orig_x <= mask.orig_max_x as f64 + EPSILON
&& orig_z >= mask.orig_min_z as f64 - EPSILON
&& orig_z <= mask.orig_max_z as f64 + EPSILON
}
fn save_debug_image(&self, filename: &str) {
let heights = &self
.elevation_data

View File

@@ -88,6 +88,15 @@ pub fn generate_ground_layer(
for x in chunk_min_x..=chunk_max_x {
for z in chunk_min_z..=chunk_max_z {
// Skip blocks outside the rotated original bounding box
if !ground.is_in_rotated_bounds(x, z) {
block_counter += 1;
if block_counter.is_multiple_of(batch_size) {
ground_pb.set_position(block_counter);
}
continue;
}
// 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 {
@@ -96,7 +105,7 @@ pub fn generate_ground_layer(
args.ground_level
};
let coord = XZPoint::new(x, z);
let coord = XZPoint::new(x - xzbbox.min_x(), z - xzbbox.min_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) {
@@ -179,8 +188,8 @@ pub fn generate_ground_layer(
.iter()
.filter(|(dx, dz)| {
ground.cover_class(XZPoint::new(
x + dx,
z + dz,
x + dx - xzbbox.min_x(),
z + dz - xzbbox.min_z(),
)) == land_cover::LC_BARE
})
.count();
@@ -237,8 +246,10 @@ pub fn generate_ground_layer(
&& ground.water_distance(coord) == 0
&& [(-1i32, 0i32), (1, 0), (0, -1), (0, 1)].iter().any(
|(dx, dz)| {
ground.cover_class(XZPoint::new(x + dx, z + dz))
== land_cover::LC_WATER
ground.cover_class(XZPoint::new(
x + dx - xzbbox.min_x(),
z + dz - xzbbox.min_z(),
)) == land_cover::LC_WATER
},
);
let near_placed_water = [(-1i32, 0i32), (1, 0), (0, -1), (0, 1)]

View File

@@ -711,6 +711,7 @@ fn gui_start_generation(
spawn_point: Option<(f64, f64)>,
telemetry_consent: bool,
world_format: String,
rotation_angle: f64,
) -> Result<(), String> {
use progress::emit_gui_error;
use LLBBox;
@@ -762,6 +763,14 @@ fn gui_start_generation(
calculate_default_spawn(&xzbbox)
};
// Rotate spawn point to match the rotated world
let (spawn_x, spawn_z) = map_transformation::rotate::rotate_xz_point(
spawn_x,
spawn_z,
rotation_angle.clamp(-90.0, 90.0),
&xzbbox,
);
set_player_spawn_in_level_dat(&selected_world, spawn_x, spawn_z)
.map_err(|e| format!("Failed to set spawn point: {e}"))?;
}
@@ -849,26 +858,27 @@ fn gui_start_generation(
// Calculate MC spawn coordinates from lat/lng if spawn point was provided
// Otherwise, default to X=1, Z=1 (relative to xzbbox min coordinates)
let mc_spawn_point: Option<(i32, i32)> = if let Some((lat, lng)) = spawn_point {
if let Ok(llpoint) = LLPoint::new(lat, lng) {
if let Ok((transformer, _)) =
CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale)
{
let mc_spawn_point: Option<(i32, i32)> = if let Ok((transformer, pre_rot_bbox)) =
CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale)
{
let (sx, sz) = if let Some((lat, lng)) = spawn_point {
if let Ok(llpoint) = LLPoint::new(lat, lng) {
let xzpoint = transformer.transform_point(llpoint);
Some((xzpoint.x, xzpoint.z))
(xzpoint.x, xzpoint.z)
} else {
None
calculate_default_spawn(&pre_rot_bbox)
}
} else {
None
}
calculate_default_spawn(&pre_rot_bbox)
};
Some(map_transformation::rotate::rotate_xz_point(
sx,
sz,
rotation_angle.clamp(-90.0, 90.0),
&pre_rot_bbox,
))
} else {
// Default spawn point: X=1, Z=1 relative to world origin
if let Ok((_, xzbbox)) = CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale) {
Some(calculate_default_spawn(&xzbbox))
} else {
None
}
None
};
// Create generation options
@@ -904,6 +914,7 @@ fn gui_start_generation(
spawn_lat: None,
spawn_lng: None,
no_overture: false,
rotation: rotation_angle.clamp(-90.0, 90.0),
};
// If skip_osm_objects is true (terrain-only mode), skip fetching and processing OSM data
@@ -984,6 +995,17 @@ fn gui_start_generation(
&mut ground,
);
// Apply rotation if specified
if rotation_angle.abs() > f64::EPSILON {
map_transformation::rotate::rotate_world(
rotation_angle.clamp(-90.0, 90.0),
&mut parsed_elements,
&mut xzbbox,
&mut ground,
)
.map_err(|e| format!("Rotation failed: {e}"))?;
}
let _ = data_processing::generate_world_with_options(
parsed_elements,
xzbbox.clone(),

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

@@ -414,3 +414,49 @@ body,
background: #e0e0e0;
margin: 4px 0;
}
/* Hint overlay shown when no bbox is selected */
.bbox-hint-overlay {
background: rgba(255, 255, 255, 0.92);
color: #333;
padding: 8px 16px;
border-radius: 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(0, 0, 0, 0.1);
pointer-events: none;
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
z-index: 1000;
}
/* Toast notification for rotation angle feedback */
.rotation-toast {
background: rgba(254, 204, 68, 0.95);
color: #333;
padding: 8px 16px;
border-radius: 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(0, 0, 0, 0.1);
pointer-events: none;
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
z-index: 2000;
opacity: 1;
transition: opacity 0.6s ease;
}
.rotation-toast.fade-out {
opacity: 0;
}

View File

@@ -583,6 +583,24 @@ button:hover {
gap: 0;
}
/* Rotation Angle Control */
.rotation-control {
gap: 4px;
}
.rotation-control input[type="number"] {
width: 60px !important;
max-width: 60px !important;
text-align: center;
font-weight: bold;
}
.rotation-unit {
font-weight: bold;
color: #ececec;
font-size: 0.95em;
}
.save-path-input {
max-width: 200px !important;
font-size: 0.85em;

12
src/gui/index.html vendored
View File

@@ -150,6 +150,18 @@
</div>
</div>
<!-- Rotation Angle -->
<div class="settings-row">
<label for="rotation-angle-input">
<span data-localize="rotation_angle">Rotation Angle</span>
<span class="tooltip-icon" data-tooltip="Rotate the map to align roads/buildings with Minecraft's coordinate system. Use the line tool to draw a reference line.">?</span>
</label>
<div class="settings-control rotation-control">
<input type="number" id="rotation-angle-input" min="-90" max="90" step="0.01" value="0">
<span class="rotation-unit">&deg;</span>
</div>
</div>
<!-- Bounding Box Input -->
<div class="settings-row">
<label for="bbox-coords">

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

@@ -691,6 +691,12 @@ $(document).ready(function () {
// Enable the preview button when data is available
function enableWorldPreview(data) {
// Skip world preview when rotation is active — the preview image covers
// the expanded post-rotation MC bbox but the geo bounds are pre-rotation,
// so the image would be squeezed incorrectly onto the map.
if (Math.abs(window._rotationAngle || 0) >= 0.001) {
return;
}
worldOverlayData = data;
worldPreviewAvailable = true;
var btn = document.getElementById('world-preview-btn');
@@ -749,6 +755,7 @@ $(document).ready(function () {
}
}
// ========== Context Menu for Coordinate Copying ==========
var contextMenuElement = null;
@@ -969,6 +976,17 @@ $(document).ready(function () {
if (event.data && event.data.type === 'worldChanged') {
disableWorldPreview();
}
// Handle rotation preview angle update (store it for preview-skip logic)
if (event.data && event.data.type === 'rotatePreview') {
var angle = event.data.angle || 0;
window._rotationAngle = angle;
// Clear the world preview since it won't align at a different angle
if (worldOverlayEnabled && Math.abs(angle) >= 0.001) {
disableWorldPreview();
}
}
});
// Set the dropdown value in parent window if it exists
@@ -1031,6 +1049,30 @@ $(document).ready(function () {
popupAnchor: [0, -10]
});
// Calculate geographic angle between two lat/lng points (in degrees)
function calculateAngleGeo(latlng1, latlng2) {
var lat1 = latlng1.lat * Math.PI / 180;
var lat2 = latlng2.lat * Math.PI / 180;
var dx = (latlng2.lng - latlng1.lng) * Math.cos((lat1 + lat2) / 2);
var dy = latlng2.lat - latlng1.lat;
var radians = Math.atan2(dy, dx);
var degrees = radians * (180 / Math.PI);
if (degrees < 0) degrees += 360;
return degrees;
}
// Calculate the signed rotation needed to align to the nearest cardinal axis (0, 90, 180, 270)
// Positive = clockwise on map, negative = counterclockwise
function getRotationToNearestAxis(angle) {
var axes = [0, 90, 180, 270, 360];
var bestDiff = 360;
for (var i = 0; i < axes.length; i++) {
var diff = angle - axes[i];
if (Math.abs(diff) < Math.abs(bestDiff)) bestDiff = diff;
}
return bestDiff;
}
drawControl = new L.Control.Draw({
edit: {
featureGroup: drawnItems
@@ -1059,6 +1101,136 @@ $(document).ready(function () {
});
map.addControl(drawControl);
// ========== Custom Angle Line Tool ==========
// A simple 2-click tool: click start point, click end point, done.
// Uses a transparent overlay to capture clicks even on top of drawn layers.
var _angleLine = null;
var _angleStartLatLng = null;
var _angleToolActive = false;
var _angleToolBtn = null;
var _angleOverlay = null; // transparent click-capture div
function startAngleTool() {
stopAngleTool();
_angleToolActive = true;
// Create a transparent overlay over the map to capture all clicks
// (otherwise clicks on the bbox rectangle get swallowed by the layer)
_angleOverlay = document.createElement('div');
_angleOverlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1000;cursor:crosshair;';
map.getContainer().appendChild(_angleOverlay);
_angleOverlay.addEventListener('click', _onAngleOverlayClick);
_angleOverlay.addEventListener('mousemove', _onAngleOverlayMouseMove);
}
function stopAngleTool() {
_angleToolActive = false;
_angleStartLatLng = null;
if (_angleOverlay) {
_angleOverlay.removeEventListener('click', _onAngleOverlayClick);
_angleOverlay.removeEventListener('mousemove', _onAngleOverlayMouseMove);
_angleOverlay.parentNode && _angleOverlay.parentNode.removeChild(_angleOverlay);
_angleOverlay = null;
}
if (_angleLine) {
map.removeLayer(_angleLine);
_angleLine = null;
}
if (_angleToolBtn) {
L.DomUtil.removeClass(_angleToolBtn, 'leaflet-draw-toolbar-button-enabled');
}
}
function _overlayEventToLatLng(e) {
var rect = map.getContainer().getBoundingClientRect();
var point = L.point(e.clientX - rect.left, e.clientY - rect.top);
return map.containerPointToLatLng(point);
}
function _onAngleOverlayClick(e) {
var latlng = _overlayEventToLatLng(e);
if (!_angleStartLatLng) {
// First click — place start point
_angleStartLatLng = latlng;
_angleLine = L.polyline([_angleStartLatLng, _angleStartLatLng], {
color: '#00aaff',
weight: 3,
dashArray: '5, 5'
}).addTo(map);
} else {
// Second click — finish
_angleLine.setLatLngs([_angleStartLatLng, latlng]);
var angle = calculateAngleGeo(_angleStartLatLng, latlng);
var rotation = getRotationToNearestAxis(angle);
var rotationValue = parseFloat(rotation.toFixed(2));
window.parent.postMessage({
type: 'angleMeasured',
angle: rotationValue
}, '*');
showRotationToast('Rotation angle set to ' + rotationValue + '\u00B0 (see settings)');
// Keep the line visible briefly, then remove
var lineRef = _angleLine;
_angleLine = null;
setTimeout(function() {
if (lineRef) map.removeLayer(lineRef);
}, 1500);
stopAngleTool();
}
}
function _onAngleOverlayMouseMove(e) {
if (_angleLine && _angleStartLatLng) {
_angleLine.setLatLngs([_angleStartLatLng, _overlayEventToLatLng(e)]);
}
}
// Inject the angle tool button into the top draw toolbar (alongside rectangle & marker)
(function addAngleToolButton() {
var drawToolbar = document.querySelector('.leaflet-draw-toolbar.leaflet-draw-toolbar-top');
if (!drawToolbar) return;
var btn = L.DomUtil.create('a', 'leaflet-draw-draw-polyline');
btn.href = '#';
btn.title = 'Set rotation angle';
L.DomEvent
.on(btn, 'click', L.DomEvent.stopPropagation)
.on(btn, 'mousedown', L.DomEvent.stopPropagation)
.on(btn, 'dblclick', L.DomEvent.stopPropagation)
.on(btn, 'click', L.DomEvent.preventDefault)
.on(btn, 'click', function() {
if (_angleToolActive) {
stopAngleTool();
} else {
startAngleTool();
L.DomUtil.addClass(btn, 'leaflet-draw-toolbar-button-enabled');
}
});
_angleToolBtn = btn;
// Insert before the marker (spawn) button so it's: rectangle | angle | marker
var markerBtn = drawToolbar.querySelector('.leaflet-draw-draw-marker');
if (markerBtn) {
drawToolbar.insertBefore(btn, markerBtn);
} else {
drawToolbar.appendChild(btn);
}
})();
// Add hint overlay at bottom-center of map when no bbox is selected
var hintDiv = document.createElement('div');
hintDiv.className = 'bbox-hint-overlay';
hintDiv.innerHTML = 'Use the <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px; opacity: 0.85;"><rect x="3" y="3" width="18" height="18"></rect></svg> tool to draw a custom area';
map.getContainer().appendChild(hintDiv);
// Add world preview button to the edit toolbar after drawControl is added
addWorldPreviewToEditToolbar();
/*
@@ -1097,7 +1269,30 @@ $(document).ready(function () {
});
map.addLayer(bounds);
// Show a brief toast notification on the map
function showRotationToast(message) {
// Remove any existing toast
var existing = map.getContainer().querySelector('.rotation-toast');
if (existing) existing.remove();
var toast = document.createElement('div');
toast.className = 'rotation-toast';
toast.textContent = message;
map.getContainer().appendChild(toast);
setTimeout(function() {
toast.classList.add('fade-out');
setTimeout(function() { toast.remove(); }, 600);
}, 6000);
}
map.on('draw:created', function (e) {
// Hide the hint overlay when a bbox area is drawn
if (e.layerType === 'rectangle') {
var hint = document.querySelector('.bbox-hint-overlay');
if (hint) hint.style.display = 'none';
}
// If it's a marker, make sure we only have one
if (e.layerType === 'marker') {
// Remove any existing markers
@@ -1167,6 +1362,17 @@ $(document).ready(function () {
e.layers.eachLayer(function (l) {
drawnItems.removeLayer(l);
});
// Show hint overlay again if no rectangles remain
var hasRectangle = false;
drawnItems.eachLayer(function(layer) {
if (layer instanceof L.Rectangle) hasRectangle = true;
});
if (!hasRectangle) {
var hint = document.querySelector('.bbox-hint-overlay');
if (hint) hint.style.display = '';
}
if (drawnItems.getLayers().length > 0 &&
!((drawnItems.getLayers().length == 1) && (drawnItems.getLayers()[0] instanceof L.Marker))) {
bounds.setBounds(drawnItems.getBounds())
@@ -1284,6 +1490,9 @@ $(document).ready(function () {
display();
});
// Store rotation angle for preview-skip logic (no mask drawn)
window._rotationAngle = 0;
});
function notifyBboxUpdate() {

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

@@ -117,6 +117,7 @@ async function applyLocalization(localization) {
"span[data-localize='land_cover']": "land_cover",
"span[data-localize='map_theme']": "map_theme",
"span[data-localize='save_path']": "save_path",
"span[data-localize='rotation_angle']": "rotation_angle",
".footer-link": "footer_text",
"button[data-localize='license_and_credits']": "license_and_credits",
"h2[data-localize='license_and_credits']": "license_and_credits",
@@ -203,6 +204,24 @@ function registerMessageEvent() {
console.log("Updated BBOX Coordinates:", bboxText);
displayBboxInfoText(bboxText);
}
// Handle angle measurement from the map polyline tool
if (event.data && event.data.type === 'angleMeasured') {
var angle = event.data.angle;
var rotationInput = document.getElementById("rotation-angle-input");
if (rotationInput) {
var clamped = Math.min(Math.max(angle, -90), 90);
rotationInput.value = clamped.toFixed(2);
// Also trigger the rotation preview update on the map
var mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'rotatePreview',
angle: clamped
}, '*');
}
}
}
});
}
@@ -291,6 +310,34 @@ function initSettings() {
slider.addEventListener("input", () => {
sliderValue.textContent = parseFloat(slider.value).toFixed(2);
});
// Double-click to reset world scale to default (1.00)
slider.addEventListener("dblclick", () => {
slider.value = 1;
sliderValue.textContent = "1.00";
});
// Rotation angle input
const rotationInput = document.getElementById("rotation-angle-input");
function updateRotation(val) {
if (isNaN(val)) val = 0;
val = Math.min(Math.max(val, -90), 90);
rotationInput.value = val.toFixed(2);
// Tell the map iframe to update the rotation mask overlay
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'rotatePreview',
angle: val
}, '*');
}
}
rotationInput.addEventListener("input", () => {
updateRotation(parseFloat(rotationInput.value));
});
rotationInput.addEventListener("change", () => {
updateRotation(parseFloat(rotationInput.value));
});
// World format toggle (Java/Bedrock)
initWorldFormatToggle();
@@ -891,6 +938,9 @@ async function startGeneration() {
// Get telemetry consent (defaults to false if not set)
const telemetryConsent = window.getTelemetryConsent ? window.getTelemetryConsent() : false;
// Get rotation angle
var rotationAngle = parseFloat(document.getElementById("rotation-angle-input").value) || 0;
// Pass the selected options to the Rust backend
await invoke("gui_start_generation", {
bboxText: selectedBBox,
@@ -906,7 +956,8 @@ async function startGeneration() {
isNewWorld: true,
spawnPoint: spawnPoint,
telemetryConsent: telemetryConsent || false,
worldFormat: selectedWorldFormat
worldFormat: selectedWorldFormat,
rotationAngle: rotationAngle
});
console.log("Generation process started.");

View File

@@ -42,5 +42,6 @@
"fillground": "ملء الأرض",
"land_cover": "تصنيف الأرض",
"bedrock_auto_generated": "يتم إنشاء عالم Bedrock تلقائيًا",
"save_path": "مسار الحفظ"
"save_path": "مسار الحفظ",
"rotation_angle": "زاوية الدوران"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Boden füllen",
"land_cover": "Landbedeckung",
"bedrock_auto_generated": "Bedrock-Welt wird automatisch generiert",
"save_path": "Speicherpfad"
"save_path": "Speicherpfad",
"rotation_angle": "Rotationswinkel"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Fill Ground",
"land_cover": "Land Cover",
"bedrock_auto_generated": "Bedrock world is auto-generated",
"save_path": "Save Path"
"save_path": "Save Path",
"rotation_angle": "Rotation Angle"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Rellenar Suelo",
"land_cover": "Cobertura del suelo",
"bedrock_auto_generated": "El mundo Bedrock se genera automáticamente",
"save_path": "Ruta de guardado"
"save_path": "Ruta de guardado",
"rotation_angle": "Ángulo de Rotación"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Täytä maa",
"land_cover": "Maanpeite",
"bedrock_auto_generated": "Bedrock-maailma luodaan automaattisesti",
"save_path": "Tallennuspolku"
"save_path": "Tallennuspolku",
"rotation_angle": "Kiertokulma"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Remplir le sol",
"land_cover": "Couverture du sol",
"bedrock_auto_generated": "Le monde Bedrock est généré automatiquement",
"save_path": "Chemin de sauvegarde"
"save_path": "Chemin de sauvegarde",
"rotation_angle": "Angle de Rotation"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Talaj feltöltése",
"land_cover": "Felszínborítás",
"bedrock_auto_generated": "A Bedrock világ automatikusan generálódik",
"save_path": "Mentési útvonal"
"save_path": "Mentési útvonal",
"rotation_angle": "Forgatási Szög"
}

View File

@@ -42,5 +42,6 @@
"fillground": "地面を埋める",
"land_cover": "土地被覆",
"bedrock_auto_generated": "Bedrock ワールドは自動生成されます",
"save_path": "保存先"
"save_path": "保存先",
"rotation_angle": "回転角度"
}

View File

@@ -42,5 +42,6 @@
"fillground": "지면 채우기",
"land_cover": "토지 피복",
"bedrock_auto_generated": "Bedrock 월드는 자동 생성됩니다",
"save_path": "저장 경로"
"save_path": "저장 경로",
"rotation_angle": "회전 각도"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Užpildyti pagrindą",
"land_cover": "Žemės danga",
"bedrock_auto_generated": "Bedrock pasaulis generuojamas automatiškai",
"save_path": "Išsaugojimo kelias"
"save_path": "Išsaugojimo kelias",
"rotation_angle": "Pasukimo Kampas"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Aizpildīt zemi",
"land_cover": "Zemes segums",
"bedrock_auto_generated": "Bedrock pasaule tiek ģenerēta automātiski",
"save_path": "Saglabāšanas ceļš"
"save_path": "Saglabāšanas ceļš",
"rotation_angle": "Rotācijas Leņķis"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Wypełnij podłoże",
"land_cover": "Pokrycie terenu",
"bedrock_auto_generated": "Świat Bedrock jest generowany automatycznie",
"save_path": "Ścieżka zapisu"
"save_path": "Ścieżka zapisu",
"rotation_angle": "Kąt Obrotu"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Preencher solo",
"land_cover": "Cobertura do solo",
"bedrock_auto_generated": "O mundo de bedrock é gerado automaticamente",
"save_path": "Caminho de salvamento"
"save_path": "Caminho de salvamento",
"rotation_angle": "Ângulo de Rotação"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Заполнить Землю",
"land_cover": "Земельный покров",
"bedrock_auto_generated": "Мир Bedrock генерируется автоматически",
"save_path": "Путь сохранения"
"save_path": "Путь сохранения",
"rotation_angle": "Угол Поворота"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Fyll mark",
"land_cover": "Marktäcke",
"bedrock_auto_generated": "Bedrock-världen genereras automatiskt",
"save_path": "Sökväg"
"save_path": "Sökväg",
"rotation_angle": "Rotationsvinkel"
}

View File

@@ -42,5 +42,6 @@
"fillground": "Заповнити землю",
"land_cover": "Земельне покриття",
"bedrock_auto_generated": "Bedrock світ генерується автоматично",
"save_path": "Шлях збереження"
"save_path": "Шлях збереження",
"rotation_angle": "Кут Обертання"
}

View File

@@ -42,5 +42,6 @@
"fillground": "填充地面",
"land_cover": "土地覆盖",
"bedrock_auto_generated": "Bedrock 世界自动生成",
"save_path": "存档路径"
"save_path": "存档路径",
"rotation_angle": "旋转角度"
}

View File

@@ -204,6 +204,19 @@ fn run_cli() {
// Transform map (parsed_elements). Operations are defined in a json file
map_transformation::transform_map(&mut parsed_elements, &mut xzbbox, &mut ground);
// Apply rotation if specified
if args.rotation.abs() > f64::EPSILON {
if let Err(e) = map_transformation::rotate::rotate_world(
args.rotation,
&mut parsed_elements,
&mut xzbbox,
&mut ground,
) {
eprintln!("{} Rotation failed: {}", "Error:".red().bold(), e);
std::process::exit(1);
}
}
// Convert spawn lat/lng to Minecraft XZ coordinates if provided
let spawn_point: Option<(i32, i32)> = match (args.spawn_lat, args.spawn_lng) {
(Some(lat), Some(lng)) => {
@@ -215,8 +228,8 @@ fn run_cli() {
std::process::exit(1);
});
let (transformer, _) = CoordTransformer::llbbox_to_xzbbox(&args.bbox, args.scale)
.unwrap_or_else(|e| {
let (transformer, pre_rot_bbox) =
CoordTransformer::llbbox_to_xzbbox(&args.bbox, args.scale).unwrap_or_else(|e| {
eprintln!(
"{} Failed to convert spawn point: {}",
"Error:".red().bold(),
@@ -226,7 +239,14 @@ fn run_cli() {
});
let xzpoint = transformer.transform_point(llpoint);
Some((xzpoint.x, xzpoint.z))
let (sx, sz) = map_transformation::rotate::rotate_xz_point(
xzpoint.x,
xzpoint.z,
args.rotation,
&pre_rot_bbox,
);
Some((sx, sz))
}
_ => None,
};

View File

@@ -5,4 +5,5 @@ mod transform_map;
pub use transform_map::transform_map;
// interface for custom specific operator generation
pub mod rotate;
pub mod translate;

View File

@@ -1,3 +1,4 @@
use super::rotate::rotator_from_json;
use super::translate::translator_from_json;
use crate::coordinate_system::cartesian::XZBBox;
use crate::ground::Ground;
@@ -30,6 +31,7 @@ pub fn operator_from_json(config: &serde_json::Value) -> Result<Box<dyn Operator
let operator_result: Result<Box<dyn Operator>, String> = match operation_str {
"translate" => translator_from_json(operator_config),
"rotate" => rotator_from_json(operator_config),
_ => Err(format!("Unrecognized operation type '{operation_str}'")),
};

View File

@@ -0,0 +1,12 @@
mod rotator;
// quick access to parent trait for this mod
use super::operator::Operator;
// interface for generation from json
pub use rotator::rotator_from_json;
// interface for direct function call (used by CLI/GUI)
pub use rotator::rotate_world;
// interface for rotating a single point (used for spawn rotation)
pub use rotator::rotate_xz_point;

View File

@@ -0,0 +1,353 @@
use super::Operator;
use crate::coordinate_system::cartesian::{XZBBox, XZBBoxRect, XZPoint};
use crate::ground::{Ground, RotationMask};
use crate::osm_parser::ProcessedElement;
use serde::Deserialize;
use std::sync::Arc;
/// Rotates the entire map (elements, bounding box, elevation) by a given angle
/// around the center of the current bounding box.
#[derive(Debug, Deserialize, PartialEq)]
pub struct Rotator {
/// Clockwise rotation angle in degrees (as seen on a map)
pub angle_degrees: f64,
}
impl Operator for Rotator {
fn operate(
&self,
elements: &mut Vec<ProcessedElement>,
xzbbox: &mut XZBBox,
ground: &mut Ground,
) {
if let Err(e) = rotate_world(self.angle_degrees, elements, xzbbox, ground) {
eprintln!("Rotation failed: {e}");
}
}
fn repr(&self) -> String {
format!("rotate {}°", self.angle_degrees)
}
}
/// Create a Rotator from JSON config
pub fn rotator_from_json(config: &serde_json::Value) -> Result<Box<dyn Operator>, String> {
let result: Result<Box<Rotator>, _> = serde_json::from_value(config.clone())
.map(Box::new)
.map_err(|e| e.to_string());
result
.map(|o| o as Box<dyn Operator>)
.map_err(|e| format!("Rotator config format error:\n{e}"))
}
/// Apply rotation to all world data: elements, bounding box, and ground/elevation.
pub fn rotate_world(
angle_degrees: f64,
elements: &mut [ProcessedElement],
xzbbox: &mut XZBBox,
ground: &mut Ground,
) -> Result<(), String> {
if angle_degrees.abs() < f64::EPSILON {
return Ok(()); // No rotation needed
}
// Negate: the user-facing convention is positive = clockwise on the map,
// but the internal XZ rotation formula is counterclockwise.
let rad = (-angle_degrees).to_radians();
let sin_r = rad.sin();
let cos_r = rad.cos();
// Center of rotation = center of current bounding box
let cx = (xzbbox.min_x() + xzbbox.max_x()) as f64 / 2.0;
let cz = (xzbbox.min_z() + xzbbox.max_z()) as f64 / 2.0;
// Store the original bbox extents for the rotation mask and elevation sampling
let orig_min_x = xzbbox.min_x();
let orig_max_x = xzbbox.max_x();
let orig_min_z = xzbbox.min_z();
let orig_max_z = xzbbox.max_z();
let orig_width = (orig_max_x - orig_min_x + 1) as usize;
let orig_height = (orig_max_z - orig_min_z + 1) as usize;
// --- 1. Compute new axis-aligned bounding box after rotation ---
let corners = [
(xzbbox.min_x() as f64, xzbbox.min_z() as f64),
(xzbbox.min_x() as f64, xzbbox.max_z() as f64),
(xzbbox.max_x() as f64, xzbbox.min_z() as f64),
(xzbbox.max_x() as f64, xzbbox.max_z() as f64),
];
let mut rotated_xs = Vec::with_capacity(4);
let mut rotated_zs = Vec::with_capacity(4);
for &(x, z) in &corners {
let (rx, rz) = rotate_point(x, z, cx, cz, sin_r, cos_r);
rotated_xs.push(rx);
rotated_zs.push(rz);
}
let new_min_x = rotated_xs
.iter()
.cloned()
.fold(f64::INFINITY, f64::min)
.floor() as i32;
let new_max_x = rotated_xs
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.ceil() as i32;
let new_min_z = rotated_zs
.iter()
.cloned()
.fold(f64::INFINITY, f64::min)
.floor() as i32;
let new_max_z = rotated_zs
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
.ceil() as i32;
*xzbbox = XZBBox::Rect(XZBBoxRect::new(
XZPoint {
x: new_min_x,
z: new_min_z,
},
XZPoint {
x: new_max_x,
z: new_max_z,
},
)?);
// --- 2. Rotate all elements ---
for elem in elements.iter_mut() {
match elem {
ProcessedElement::Node(node) => {
let (rx, rz) = rotate_point(node.x as f64, node.z as f64, cx, cz, sin_r, cos_r);
node.x = rx.round() as i32;
node.z = rz.round() as i32;
}
ProcessedElement::Way(way) => {
for node in way.nodes.iter_mut() {
let (rx, rz) = rotate_point(node.x as f64, node.z as f64, cx, cz, sin_r, cos_r);
node.x = rx.round() as i32;
node.z = rz.round() as i32;
}
}
ProcessedElement::Relation(rel) => {
for member in rel.members.iter_mut() {
let way = Arc::make_mut(&mut member.way);
for node in way.nodes.iter_mut() {
let (rx, rz) =
rotate_point(node.x as f64, node.z as f64, cx, cz, sin_r, cos_r);
node.x = rx.round() as i32;
node.z = rz.round() as i32;
}
}
}
}
}
// --- 3. Rotate elevation and land-cover data ---
rotate_ground_data(
ground,
xzbbox,
orig_min_x,
orig_min_z,
orig_width,
orig_height,
cx,
cz,
sin_r,
cos_r,
);
// --- 4. Set rotation mask so ground generation skips out-of-bounds blocks ---
ground.set_rotation_mask(RotationMask {
cx,
cz,
neg_sin: -sin_r,
cos: cos_r,
orig_min_x,
orig_max_x,
orig_min_z,
orig_max_z,
});
Ok(())
}
/// Rotate a single (x, z) point around center (cx, cz).
/// Counterclockwise rotation in the XZ plane.
#[inline]
fn rotate_point(x: f64, z: f64, cx: f64, cz: f64, sin_r: f64, cos_r: f64) -> (f64, f64) {
let dx = x - cx;
let dz = z - cz;
let rx = dx * cos_r + dz * sin_r + cx;
let rz = -dx * sin_r + dz * cos_r + cz;
(rx, rz)
}
/// Rotate a single integer (x, z) point by `angle_degrees` around the center of `xzbbox`.
/// Used by CLI and GUI to rotate spawn points to match the rotated world.
pub fn rotate_xz_point(x: i32, z: i32, angle_degrees: f64, xzbbox: &XZBBox) -> (i32, i32) {
if angle_degrees.abs() < f64::EPSILON {
return (x, z);
}
let rad = (-angle_degrees).to_radians();
let cx = (xzbbox.min_x() + xzbbox.max_x()) as f64 / 2.0;
let cz = (xzbbox.min_z() + xzbbox.max_z()) as f64 / 2.0;
let (rx, rz) = rotate_point(x as f64, z as f64, cx, cz, rad.sin(), rad.cos());
(rx.round() as i32, rz.round() as i32)
}
/// Rotate elevation grid and land-cover data, applying Laplacian smoothing to
/// reduce jagged edges from coordinate discretization during rotation.
#[allow(clippy::too_many_arguments)]
fn rotate_ground_data(
ground: &mut Ground,
xzbbox: &XZBBox,
orig_min_x: i32,
orig_min_z: i32,
orig_width: usize,
orig_height: usize,
cx: f64,
cz: f64,
sin_r: f64,
cos_r: f64,
) {
// Check elevation_enabled BEFORE cloning to avoid unnecessary allocation
if !ground.elevation_enabled {
return;
}
let original_ground = ground.clone();
let new_w = (xzbbox.max_x() - xzbbox.min_x() + 1) as usize;
let new_h = (xzbbox.max_z() - xzbbox.min_z() + 1) as usize;
// For each cell in the new grid, inverse-rotate to find the source cell
let neg_sin_r = -sin_r; // Inverse rotation
let mut new_heights: Vec<Vec<i32>> = Vec::with_capacity(new_h);
let mut has_data: Vec<Vec<bool>> = Vec::with_capacity(new_h);
// Also rotate land-cover grids if present
let has_land_cover = original_ground.has_land_cover();
let mut new_cover: Option<Vec<Vec<u8>>> = has_land_cover.then(|| Vec::with_capacity(new_h));
let mut new_water: Option<Vec<Vec<u8>>> = has_land_cover.then(|| Vec::with_capacity(new_h));
for z_idx in 0..new_h {
let mut height_row = Vec::with_capacity(new_w);
let mut data_row = Vec::with_capacity(new_w);
let mut cover_row: Option<Vec<u8>> = has_land_cover.then(|| Vec::with_capacity(new_w));
let mut water_row: Option<Vec<u8>> = has_land_cover.then(|| Vec::with_capacity(new_w));
for x_idx in 0..new_w {
let world_x = xzbbox.min_x() + x_idx as i32;
let world_z = xzbbox.min_z() + z_idx as i32;
// Inverse-rotate this world coordinate back to original space
let (orig_x, orig_z) =
rotate_point(world_x as f64, world_z as f64, cx, cz, neg_sin_r, cos_r);
// Convert to coordinates relative to the original bbox origin,
// which is what Ground::level / cover_class / water_distance expect
let rel_x = orig_x.round() as i32 - orig_min_x;
let rel_z = orig_z.round() as i32 - orig_min_z;
// Full bounds check: both lower AND upper against original grid dimensions
let in_original = rel_x >= 0
&& rel_z >= 0
&& (rel_x as usize) < orig_width
&& (rel_z as usize) < orig_height;
let coord = XZPoint::new(rel_x, rel_z);
height_row.push(original_ground.level(coord));
data_row.push(in_original);
if let Some(ref mut cr) = cover_row {
cr.push(original_ground.cover_class(coord));
}
if let Some(ref mut wr) = water_row {
wr.push(original_ground.water_distance(coord));
}
}
new_heights.push(height_row);
has_data.push(data_row);
if let Some(ref mut cg) = new_cover {
cg.push(cover_row.unwrap());
}
if let Some(ref mut wd) = new_water {
wd.push(water_row.unwrap());
}
}
// Apply Laplacian smoothing (3 iterations) to reduce jagged edges
// from coordinate discretization during rotation
const SMOOTH_ITERATIONS: usize = 3;
for _ in 0..SMOOTH_ITERATIONS {
let prev = new_heights.clone();
for z_idx in 1..new_h.saturating_sub(1) {
for x_idx in 1..new_w.saturating_sub(1) {
if !has_data[z_idx][x_idx] {
continue; // Don't smooth padding areas
}
let neighbors_sum = prev[z_idx - 1][x_idx] as f64
+ prev[z_idx + 1][x_idx] as f64
+ prev[z_idx][x_idx - 1] as f64
+ prev[z_idx][x_idx + 1] as f64;
let avg = neighbors_sum / 4.0;
// Blend: 70% original + 30% neighbor average
new_heights[z_idx][x_idx] =
(0.7 * prev[z_idx][x_idx] as f64 + 0.3 * avg).round() as i32;
}
}
}
// Update ground with rotated elevation
ground.set_elevation_data(new_heights, new_w, new_h);
// Update land cover with rotated data
if let (Some(cover_grid), Some(water_dist)) = (new_cover, new_water) {
ground.set_land_cover_data(cover_grid, water_dist, new_w, new_h);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zero_rotation_is_noop() {
let mut elements = Vec::new();
let mut xzbbox = XZBBox::rect_from_xz_lengths(100.0, 100.0).unwrap();
let mut ground = Ground::new_flat(-62);
let original_bbox = xzbbox.clone();
rotate_world(0.0, &mut elements, &mut xzbbox, &mut ground).unwrap();
assert_eq!(xzbbox.min_x(), original_bbox.min_x());
assert_eq!(xzbbox.max_x(), original_bbox.max_x());
}
#[test]
fn test_rotate_point_90_degrees() {
let rad = 90.0_f64.to_radians();
let (rx, rz) = rotate_point(10.0, 0.0, 0.0, 0.0, rad.sin(), rad.cos());
// 90° CCW: (10, 0) -> (0, -10)
assert!((rx - 0.0).abs() < 1e-10);
assert!((rz - (-10.0)).abs() < 1e-10);
}
#[test]
fn test_bbox_expands_on_45deg_rotation() {
let mut elements = Vec::new();
let mut xzbbox = XZBBox::rect_from_xz_lengths(100.0, 100.0).unwrap();
let mut ground = Ground::new_flat(-62);
let orig_area = xzbbox.bounding_rect().total_blocks();
rotate_world(45.0, &mut elements, &mut xzbbox, &mut ground).unwrap();
let new_area = xzbbox.bounding_rect().total_blocks();
// 45° rotation of a square produces a larger bounding rect
assert!(new_area > orig_area);
}
}