diff --git a/src/args.rs b/src/args.rs index 96c2dc5..118f75f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -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(()) } diff --git a/src/coordinate_system/cartesian/mod.rs b/src/coordinate_system/cartesian/mod.rs index 7f412f8..c55186f 100644 --- a/src/coordinate_system/cartesian/mod.rs +++ b/src/coordinate_system/cartesian/mod.rs @@ -3,5 +3,6 @@ mod xzpoint; mod xzvector; pub use xzbbox::XZBBox; +pub use xzbbox::XZBBoxRect; pub use xzpoint::XZPoint; pub use xzvector::XZVector; diff --git a/src/coordinate_system/cartesian/xzbbox/mod.rs b/src/coordinate_system/cartesian/xzbbox/mod.rs index a7047f4..b1f2ae0 100644 --- a/src/coordinate_system/cartesian/xzbbox/mod.rs +++ b/src/coordinate_system/cartesian/xzbbox/mod.rs @@ -1,4 +1,5 @@ mod rectangle; mod xzbbox_enum; +pub use rectangle::XZBBoxRect; pub use xzbbox_enum::XZBBox; diff --git a/src/ground.rs b/src/ground.rs index aa6c9af..bb4aac0 100644 --- a/src/ground.rs +++ b/src/ground.rs @@ -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, land_cover: Option, + /// When set, coordinates outside the rotated original bbox are skipped. + rotation_mask: Option, } 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>, 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>, + water_distance: Vec>, + 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 diff --git a/src/ground_generation.rs b/src/ground_generation.rs index 0bcb6ce..9774fae 100644 --- a/src/ground_generation.rs +++ b/src/ground_generation.rs @@ -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)] diff --git a/src/gui.rs b/src/gui.rs index d081bd8..6e44902 100644 --- a/src/gui.rs +++ b/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(), diff --git a/src/gui/css/bbox.css b/src/gui/css/bbox.css index cc8d3b1..123505f 100644 --- a/src/gui/css/bbox.css +++ b/src/gui/css/bbox.css @@ -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; +} diff --git a/src/gui/css/styles.css b/src/gui/css/styles.css index 07958de..b7c8d72 100644 --- a/src/gui/css/styles.css +++ b/src/gui/css/styles.css @@ -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; diff --git a/src/gui/index.html b/src/gui/index.html index 8e66919..e765827 100644 --- a/src/gui/index.html +++ b/src/gui/index.html @@ -150,6 +150,18 @@ + +
+ +
+ + ° +
+
+