feat(galaxy): multi-disk galaxy generation with tab-based UI

- Add multi-disk support (up to 4 independent disk layers) with per-disk
  arms, twist, tilt, rotation, inner/outer radius
- Add polar beams (top/bottom) with taper, gravity, thickness controls
- Implement tab-based disk selector UI with add/remove disk
- Add Randomize Settings button for full param randomization
- Add Regenerate (reseed) and Center View buttons
- Add per-system contents generation (planets, belts, stations, anomalies,
  gas clouds, stargates) with orbital mechanics
- Refactor control panel into scrollable sections with value display
This commit is contained in:
2026-06-10 00:11:30 -04:00
parent f83a0c5e59
commit 80060be5ba
3 changed files with 821 additions and 284 deletions

View File

@@ -45,6 +45,7 @@ impl Plugin for GalaxyPlugin {
Update, Update,
( (
escape_to_main_menu, escape_to_main_menu,
ui::rebuild_scroll_content,
ui::param_button_handler, ui::param_button_handler,
ui::refresh_control_panel_values, ui::refresh_control_panel_values,
ui::scroll_control_panel, ui::scroll_control_panel,
@@ -181,21 +182,22 @@ fn generate_galaxy(
(systems, contents, connections) (systems, contents, connections)
} }
/// Position-only galaxy generation. Composes three structural layers — /// Position-only galaxy generation. Composes multiple structural layers —
/// a [`CoreParams`] cluster, a [`DiskParams`] of horizontal spiral arms, /// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
/// and two [`BeamParams`] columns along ±Y — into a single flat list of /// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
/// systems ready for the connection graph and the spawner. /// flat list of systems ready for the connection graph and the spawner.
/// ///
/// Each layer owns its own system count; the total galaxy population is /// Each layer owns its own system count; the total galaxy population is
/// `core.count + disk.count + beam_top.count + beam_bottom.count`. System /// `core.count + Σ disks[].count + beam_top.count + beam_bottom.count`.
/// indices are global across all layers (used for `g-{n}` IDs and name /// System indices are global across all layers (used for `g-{n}` IDs and
/// suffixes) so the connection graph and POI generator see one consistent /// name suffixes) so the connection graph and POI generator see one
/// vector. /// consistent vector.
fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> { fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> {
// Total budget — pre-allocation only; each pass appends as many systems // Total budget — pre-allocation only; each pass appends as many systems
// as it manages to place. // as it manages to place.
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
let total = params.core.count let total = params.core.count
+ params.disk.count + disk_total
+ params.beam_top.count + params.beam_top.count
+ params.beam_bottom.count; + params.beam_bottom.count;
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total); let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
@@ -209,14 +211,19 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
let mut next_index = 0usize; let mut next_index = 0usize;
generate_core(&mut systems, rng, &params.core, params.size, base_spacing, &mut next_index); generate_core(&mut systems, rng, &params.core, params.size, base_spacing, &mut next_index);
generate_disk(
&mut systems, for (disk_index, disk) in params.disks.iter().enumerate() {
rng, generate_disk(
&params.disk, &mut systems,
params.size, rng,
base_spacing, disk,
&mut next_index, params.size,
); base_spacing,
&mut next_index,
disk_index,
);
}
if params.beam_top.enabled { if params.beam_top.enabled {
generate_beam( generate_beam(
&mut systems, &mut systems,
@@ -302,9 +309,10 @@ fn generate_core(
} }
} }
/// Generate the horizontal disk of spiral arms in the XZ plane. Faithful to /// Generate one disk layer of spiral arms. Faithful to the original
/// the original generator's density bias (`pow(0.62)` → packed near origin) /// generator's density bias (`pow(0.62)` → packed near origin) and arm twist.
/// and arm twist. /// Supports tilt (rotation around X, capped at 45°), Y-axis rotation offset,
/// and independent inner/outer radii.
fn generate_disk( fn generate_disk(
systems: &mut Vec<GeneratedSystem>, systems: &mut Vec<GeneratedSystem>,
rng: &mut StdRng, rng: &mut StdRng,
@@ -312,24 +320,53 @@ fn generate_disk(
galaxy_size: f32, galaxy_size: f32,
base_spacing: f32, base_spacing: f32,
next_index: &mut usize, next_index: &mut usize,
disk_index: usize,
) { ) {
let arm_count = disk.arms.max(1); let arm_count = disk.arms.max(1);
// Effective outer radius: explicit value if set, else galaxy size.
let outer = if disk.outer_radius > 0.0 {
disk.outer_radius
} else {
galaxy_size
};
let inner = disk.inner_radius;
let span = (outer - inner).max(1.0);
// Pre-compute the rotation quaternion for this disk's tilt + Y rotation.
// Tilt is applied first (around X), then the Y rotation offset.
let disk_rotation = if disk.tilt.abs() > 0.001 || disk.rotation_offset.abs() > 0.001 {
let tilt = Quat::from_rotation_x(disk.tilt);
let yaw = Quat::from_rotation_y(disk.rotation_offset);
Some(yaw * tilt)
} else {
None
};
for i in 0..disk.count { for i in 0..disk.count {
let arm = (i as u32) % arm_count; let arm = (i as u32) % arm_count;
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari. // Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
let faction_index = 1 + (arm as usize) % (FACTIONS.len() - 1); // Offset by disk_index so different disks don't all share the same
// faction assignment pattern.
let faction_index =
1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
let (faction, color) = FACTIONS[faction_index]; let (faction, color) = FACTIONS[faction_index];
let mut position = Option::<Vec3>::None; let mut position = Option::<Vec3>::None;
let mut final_radius = 0.0f32; let mut final_radius = 0.0f32;
for attempt in 0..SPACING_ATTEMPTS { for attempt in 0..SPACING_ATTEMPTS {
let r = rng.gen::<f32>().powf(0.62) * galaxy_size; let r = inner + rng.gen::<f32>().powf(0.62) * span;
let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32 let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
+ (r / galaxy_size) * disk.twist + ((r - inner) / span) * disk.twist
+ (rng.gen::<f32>() - 0.5) * 0.72; + (rng.gen::<f32>() - 0.5) * 0.72;
let y = (rng.gen::<f32>() - 0.5) * 20.0; let y = (rng.gen::<f32>() - 0.5) * 20.0;
let candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r); let mut candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
final_radius = r;
// Apply tilt + rotation for non-flat disks.
if let Some(rot) = disk_rotation {
candidate = rot * candidate;
}
final_radius = candidate.length();
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING); let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
if systems if systems
.iter() .iter()
@@ -356,7 +393,10 @@ fn generate_disk(
color, color,
security, security,
is_core: false, is_core: false,
origin: SystemOrigin::Disk { arm }, origin: SystemOrigin::Disk {
disk: disk_index,
arm,
},
}); });
} }
} }
@@ -478,7 +518,12 @@ fn security_for_radius(radius: f32, galaxy_size: f32) -> f32 {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
enum SystemOrigin { enum SystemOrigin {
Core, Core,
Disk { arm: u32 }, Disk {
/// Which disk layer this system belongs to (index into [`GalaxyParams::disks`]).
disk: usize,
/// Which arm within the disk.
arm: u32,
},
Beam { side: BeamTag }, Beam { side: BeamTag },
} }

View File

@@ -6,9 +6,10 @@
//! //!
//! ## Structure //! ## Structure
//! //!
//! A galaxy is composed of three layers, each owning its own system count: //! A galaxy is composed of three structural layer types:
//! - [`CoreParams`] — Concord systems clustered near the origin. //! - [`CoreParams`] — Concord systems clustered near the origin.
//! - [`DiskParams`] — horizontal spiral arms in the XZ plane. //! - [`DiskParams`] (up to [`MAX_DISKS`]) — independent spiral disks, each
//! with its own arm count, twist, tilt, rotation, and radial extent.
//! - [`BeamParams`] (×2, top and bottom) — vertical columns of systems along //! - [`BeamParams`] (×2, top and bottom) — vertical columns of systems along
//! ±Y, the "polar beams". Each beam is its own population of selectable //! ±Y, the "polar beams". Each beam is its own population of selectable
//! star systems, fully equal citizens to disk arms. //! star systems, fully equal citizens to disk arms.
@@ -57,6 +58,9 @@ pub const CORE_RADIUS_MIN: f32 = 10.0;
pub const CORE_RADIUS_MAX: f32 = 120.0; pub const CORE_RADIUS_MAX: f32 = 120.0;
pub const CORE_RADIUS_STEP: f32 = 5.0; pub const CORE_RADIUS_STEP: f32 = 5.0;
/// Maximum number of independent disk layers.
pub const MAX_DISKS: usize = 4;
pub const DISK_COUNT_MIN: usize = 20; pub const DISK_COUNT_MIN: usize = 20;
pub const DISK_COUNT_MAX: usize = 220; pub const DISK_COUNT_MAX: usize = 220;
pub const DISK_COUNT_STEP: usize = 4; pub const DISK_COUNT_STEP: usize = 4;
@@ -66,6 +70,24 @@ pub const DISK_TWIST_MIN: f32 = 1.0;
pub const DISK_TWIST_MAX: f32 = 6.0; pub const DISK_TWIST_MAX: f32 = 6.0;
pub const DISK_TWIST_STEP: f32 = 0.2; pub const DISK_TWIST_STEP: f32 = 0.2;
/// Max tilt is 45° — disks should never be parallel to beams.
pub const DISK_TILT_MIN: f32 = 0.0;
pub const DISK_TILT_MAX: f32 = std::f32::consts::FRAC_PI_4;
/// ~5° steps (45° / 9).
pub const DISK_TILT_STEP: f32 = std::f32::consts::FRAC_PI_4 / 9.0;
pub const DISK_ROTATION_MIN: f32 = 0.0;
pub const DISK_ROTATION_MAX: f32 = std::f32::consts::TAU;
/// 30° steps.
pub const DISK_ROTATION_STEP: f32 = std::f32::consts::TAU / 12.0;
pub const DISK_INNER_RADIUS_MIN: f32 = 0.0;
pub const DISK_INNER_RADIUS_MAX: f32 = 400.0;
pub const DISK_INNER_RADIUS_STEP: f32 = 10.0;
pub const DISK_OUTER_RADIUS_MIN: f32 = 40.0;
pub const DISK_OUTER_RADIUS_MAX: f32 = 420.0;
pub const DISK_OUTER_RADIUS_STEP: f32 = 10.0;
pub const BEAM_COUNT_MIN: usize = 0; pub const BEAM_COUNT_MIN: usize = 0;
pub const BEAM_COUNT_MAX: usize = 120; pub const BEAM_COUNT_MAX: usize = 120;
pub const BEAM_COUNT_STEP: usize = 4; pub const BEAM_COUNT_STEP: usize = 4;
@@ -115,12 +137,31 @@ impl Default for CoreParams {
} }
} }
/// Horizontal disk of spiral arms in the XZ plane. /// One independent disk layer of spiral arms.
///
/// Disks are generated in the XZ plane and then tilted by [`Self::tilt`]
/// (rotation around the X axis) and rotated by [`Self::rotation_offset`]
/// (around the Y axis). Systems are placed between [`Self::inner_radius`]
/// and [`Self::outer_radius`]; if `outer_radius` is 0 the galaxy-wide
/// [`GalaxyParams::size`] is used instead.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DiskParams { pub struct DiskParams {
pub count: usize, pub count: usize,
pub arms: u32, pub arms: u32,
pub twist: f32, pub twist: f32,
/// Tilt angle in radians (rotation around X axis).
/// Capped at 45° so disks never become parallel to beams.
/// 0 = flat in XZ plane, π/4 = maximum tilt.
pub tilt: f32,
/// Rotation offset around Y axis in radians.
/// Rotates the disk in the horizontal plane, determining where the
/// tilted disk's "high side" points.
pub rotation_offset: f32,
/// Minimum distance from the origin for systems in this disk.
/// 0 = systems can start at the origin (overlapping the core).
pub inner_radius: f32,
/// Maximum distance from the origin. 0 = use [`GalaxyParams::size`].
pub outer_radius: f32,
} }
impl Default for DiskParams { impl Default for DiskParams {
@@ -129,6 +170,10 @@ impl Default for DiskParams {
count: DEFAULT_DISK_COUNT, count: DEFAULT_DISK_COUNT,
arms: DEFAULT_DISK_ARMS, arms: DEFAULT_DISK_ARMS,
twist: DEFAULT_DISK_TWIST, twist: DEFAULT_DISK_TWIST,
tilt: 0.0,
rotation_offset: 0.0,
inner_radius: 0.0,
outer_radius: 0.0, // means "use galaxy size"
} }
} }
} }
@@ -189,7 +234,8 @@ pub struct GalaxyParams {
/// Overall galaxy extent — drives the disk radius. /// Overall galaxy extent — drives the disk radius.
pub size: f32, pub size: f32,
pub core: CoreParams, pub core: CoreParams,
pub disk: DiskParams, /// Independent disk layers. Always contains at least one entry.
pub disks: Vec<DiskParams>,
pub beam_top: BeamParams, pub beam_top: BeamParams,
pub beam_bottom: BeamParams, pub beam_bottom: BeamParams,
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`]. /// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
@@ -204,7 +250,7 @@ impl Default for GalaxyParams {
seed: DEFAULT_SEED, seed: DEFAULT_SEED,
size: DEFAULT_SIZE, size: DEFAULT_SIZE,
core: CoreParams::default(), core: CoreParams::default(),
disk: DiskParams::default(), disks: vec![DiskParams::default()],
beam_top: BeamParams::default(), beam_top: BeamParams::default(),
beam_bottom: BeamParams::default(), beam_bottom: BeamParams::default(),
generation: 0, generation: 0,

File diff suppressed because it is too large Load Diff