mirror of
https://github.com/louis-e/arnis.git
synced 2026-04-20 22:20:08 -04:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ mod xzpoint;
|
||||
mod xzvector;
|
||||
|
||||
pub use xzbbox::XZBBox;
|
||||
pub use xzbbox::XZBBoxRect;
|
||||
pub use xzpoint::XZPoint;
|
||||
pub use xzvector::XZVector;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod rectangle;
|
||||
mod xzbbox_enum;
|
||||
|
||||
pub use rectangle::XZBBoxRect;
|
||||
pub use xzbbox_enum::XZBBox;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
52
src/gui.rs
52
src/gui.rs
@@ -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
46
src/gui/css/bbox.css
vendored
@@ -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;
|
||||
}
|
||||
|
||||
18
src/gui/css/styles.css
vendored
18
src/gui/css/styles.css
vendored
@@ -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
12
src/gui/index.html
vendored
@@ -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">°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bounding Box Input -->
|
||||
<div class="settings-row">
|
||||
<label for="bbox-coords">
|
||||
|
||||
209
src/gui/js/bbox.js
vendored
209
src/gui/js/bbox.js
vendored
@@ -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
53
src/gui/js/main.js
vendored
@@ -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.");
|
||||
|
||||
3
src/gui/locales/ar.json
vendored
3
src/gui/locales/ar.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "ملء الأرض",
|
||||
"land_cover": "تصنيف الأرض",
|
||||
"bedrock_auto_generated": "يتم إنشاء عالم Bedrock تلقائيًا",
|
||||
"save_path": "مسار الحفظ"
|
||||
"save_path": "مسار الحفظ",
|
||||
"rotation_angle": "زاوية الدوران"
|
||||
}
|
||||
|
||||
3
src/gui/locales/de.json
vendored
3
src/gui/locales/de.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/en-US.json
vendored
3
src/gui/locales/en-US.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/es.json
vendored
3
src/gui/locales/es.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/fi.json
vendored
3
src/gui/locales/fi.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
3
src/gui/locales/fr-FR.json
vendored
3
src/gui/locales/fr-FR.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
3
src/gui/locales/hu.json
vendored
3
src/gui/locales/hu.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/ja.json
vendored
3
src/gui/locales/ja.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "地面を埋める",
|
||||
"land_cover": "土地被覆",
|
||||
"bedrock_auto_generated": "Bedrock ワールドは自動生成されます",
|
||||
"save_path": "保存先"
|
||||
"save_path": "保存先",
|
||||
"rotation_angle": "回転角度"
|
||||
}
|
||||
|
||||
3
src/gui/locales/ko.json
vendored
3
src/gui/locales/ko.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "지면 채우기",
|
||||
"land_cover": "토지 피복",
|
||||
"bedrock_auto_generated": "Bedrock 월드는 자동 생성됩니다",
|
||||
"save_path": "저장 경로"
|
||||
"save_path": "저장 경로",
|
||||
"rotation_angle": "회전 각도"
|
||||
}
|
||||
3
src/gui/locales/lt.json
vendored
3
src/gui/locales/lt.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/lv.json
vendored
3
src/gui/locales/lv.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/pl.json
vendored
3
src/gui/locales/pl.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/pt-BR.json
vendored
3
src/gui/locales/pt-BR.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
3
src/gui/locales/ru.json
vendored
3
src/gui/locales/ru.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "Заполнить Землю",
|
||||
"land_cover": "Земельный покров",
|
||||
"bedrock_auto_generated": "Мир Bedrock генерируется автоматически",
|
||||
"save_path": "Путь сохранения"
|
||||
"save_path": "Путь сохранения",
|
||||
"rotation_angle": "Угол Поворота"
|
||||
}
|
||||
|
||||
3
src/gui/locales/sv.json
vendored
3
src/gui/locales/sv.json
vendored
@@ -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"
|
||||
}
|
||||
3
src/gui/locales/ua.json
vendored
3
src/gui/locales/ua.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "Заповнити землю",
|
||||
"land_cover": "Земельне покриття",
|
||||
"bedrock_auto_generated": "Bedrock світ генерується автоматично",
|
||||
"save_path": "Шлях збереження"
|
||||
"save_path": "Шлях збереження",
|
||||
"rotation_angle": "Кут Обертання"
|
||||
}
|
||||
3
src/gui/locales/zh-CN.json
vendored
3
src/gui/locales/zh-CN.json
vendored
@@ -42,5 +42,6 @@
|
||||
"fillground": "填充地面",
|
||||
"land_cover": "土地覆盖",
|
||||
"bedrock_auto_generated": "Bedrock 世界自动生成",
|
||||
"save_path": "存档路径"
|
||||
"save_path": "存档路径",
|
||||
"rotation_angle": "旋转角度"
|
||||
}
|
||||
26
src/main.rs
26
src/main.rs
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'")),
|
||||
};
|
||||
|
||||
|
||||
12
src/map_transformation/rotate/mod.rs
Normal file
12
src/map_transformation/rotate/mod.rs
Normal 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;
|
||||
353
src/map_transformation/rotate/rotator.rs
Normal file
353
src/map_transformation/rotate/rotator.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user