fix(galaxy): ensure spiral arms are always visible

Three fixes for circular/non-spiral galaxies produced by Randomize:

1. Raise twist range from [1.0, 6.0] to [4.0, 18.0] radians
   - Old max (~0.95 turns) could barely curve; new range covers
     0.6–2.9 full turns, matching real spiral galaxy morphology
   - Default twist raised from 3.0 to 12.0 (~1.9 turns)

2. Scale angular scatter inversely with arm count
   - Replaced hardcoded ±0.36 rad scatter with arm_scatter() that
     uses 30% of the inter-arm gap (TAU / arms × 0.30)
   - Single-arm disks use a generous 1.5 rad scatter
   - Prevents high-arm-count disks from blurring into circles

3. Bias randomize_disk toward spiral-friendly values
   - Arms weighted toward 2–4 (10%, 30%, 30%, 20%, 10% distribution)
   - Twist floored at arms × 4.0 rad so multi-arm disks always wind
     enough for distinct spiral structure
   - Outer radius defaults to "auto" 60% of the time
This commit is contained in:
2026-06-10 00:13:39 -04:00
parent 80060be5ba
commit ce631b00f1
3 changed files with 45 additions and 10 deletions

View File

@@ -357,7 +357,7 @@ fn generate_disk(
let r = inner + rng.gen::<f32>().powf(0.62) * span;
let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
+ ((r - inner) / span) * disk.twist
+ (rng.gen::<f32>() - 0.5) * 0.72;
+ (rng.gen::<f32>() - 0.5) * arm_scatter(arm_count);
let y = (rng.gen::<f32>() - 0.5) * 20.0;
let mut candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
@@ -491,6 +491,20 @@ fn generate_beam(
}
}
/// Angular scatter width per system, scaled to the inter-arm gap.
/// Each arm occupies `TAU / arm_count` radians; systems scatter within
/// ~30% of that gap so arms remain visually distinct regardless of
/// how many there are. Single-arm disks use a fixed wide scatter
/// since there are no adjacent arms to bleed into.
fn arm_scatter(arm_count: u32) -> f32 {
if arm_count <= 1 {
// No adjacent arms — generous scatter for a natural-looking blob.
1.5
} else {
std::f32::consts::TAU / arm_count as f32 * 0.30
}
}
/// Spacing relaxation curve — same shape as the previous single-pass
/// generator: full spacing for the first 60 attempts, 82% until attempt
/// 120, then 68% as a fallback to guarantee placement.

View File

@@ -33,7 +33,7 @@ const DEFAULT_CORE_RADIUS: f32 = 38.0;
// keeping the same arms/twist as the docs prototype.
const DEFAULT_DISK_COUNT: usize = 100;
const DEFAULT_DISK_ARMS: u32 = 4;
const DEFAULT_DISK_TWIST: f32 = 3.0;
const DEFAULT_DISK_TWIST: f32 = 12.0; // ~1.9 full turns
// Beam defaults: small population so the two polar columns read clearly
// without dominating the scene. Equal top/bottom for symmetry.
@@ -66,9 +66,9 @@ pub const DISK_COUNT_MAX: usize = 220;
pub const DISK_COUNT_STEP: usize = 4;
pub const DISK_ARMS_MIN: u32 = 1;
pub const DISK_ARMS_MAX: u32 = 6;
pub const DISK_TWIST_MIN: f32 = 1.0;
pub const DISK_TWIST_MAX: f32 = 6.0;
pub const DISK_TWIST_STEP: f32 = 0.2;
pub const DISK_TWIST_MIN: f32 = 4.0; // ~0.6 turns — bare minimum curvature
pub const DISK_TWIST_MAX: f32 = 18.0; // ~2.9 turns — tight logarithmic spiral
pub const DISK_TWIST_STEP: f32 = 0.4;
/// Max tilt is 45° — disks should never be parallel to beams.
pub const DISK_TILT_MIN: f32 = 0.0;

View File

@@ -1300,14 +1300,35 @@ fn spawn_scroll_contents_with_tabs(
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Randomize all fields of a [`DiskParams`] to random valid values within
/// their respective bounds, rounded to the UI step.
/// their respective bounds, rounded to the UI step. Biased toward
/// spiral-friendly combinations: arms are weighted toward 24, twist is
/// floored at `arms × 4.0` radians (ensuring at least ~0.6 turns per arm),
/// and outer radius defaults to "auto" most of the time so disks fill the
/// galaxy.
fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams {
let mut disk = DiskParams::default();
disk.count = rng.gen_range(DISK_COUNT_MIN..=DISK_COUNT_MAX);
disk.count = (disk.count / DISK_COUNT_STEP) * DISK_COUNT_STEP;
disk.arms = rng.gen_range(DISK_ARMS_MIN..=DISK_ARMS_MAX);
disk.twist = rng.gen_range(DISK_TWIST_MIN..=DISK_TWIST_MAX);
// Weight arms toward 24 for clear spiral structure. 1 and 56 are
// allowed but less likely.
disk.arms = match rng.gen_range(0..10) {
0 => 1,
1..=3 => 2,
4..=6 => 3,
7..=8 => 4,
9 => rng.gen_range(5..=DISK_ARMS_MAX),
_ => DISK_ARMS_MIN,
};
// Floor twist at arms × 4.0 so multi-arm disks always wind enough to
// show distinct spiral structure. The floor ensures at least ~0.6 turns
// between adjacent arms.
let twist_floor = disk.arms as f32 * 4.0;
let effective_min = DISK_TWIST_MIN.max(twist_floor);
disk.twist = rng.gen_range(effective_min..=DISK_TWIST_MAX);
disk.twist = (disk.twist / DISK_TWIST_STEP).round() * DISK_TWIST_STEP;
disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX);
disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP;
disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX);
@@ -1319,8 +1340,8 @@ fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams {
disk.outer_radius = rng.gen_range(DISK_OUTER_RADIUS_MIN..=DISK_OUTER_RADIUS_MAX);
disk.outer_radius =
(disk.outer_radius / DISK_OUTER_RADIUS_STEP).round() * DISK_OUTER_RADIUS_STEP;
// Allow 0 (auto) ~50% of the time.
if rng.gen_bool(0.5) {
// Allow 0 (auto) ~60% of the time so disks fill the galaxy by default.
if rng.gen_bool(0.6) {
disk.outer_radius = 0.0;
}
disk