From a7af2840bda184df51249a40d2fe390f570e5cc6 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 20:13:56 +0200 Subject: [PATCH 01/10] feat(trees): variants, jitter, branches, and new species for forest variety Address feedback that dense oak forests looked like cookie-cutter clones. - 27 shape variants across 9 existing species (Oak: 5, Spruce/Birch/DarkOak: 3 each, others: 2). Per-coord deterministic variant pick via coord_rng salt 1. - Per-instance jitter: trunk height +/-1..+2, ~4% organic leaf gaps via coord-derived hash. - Side branches with tapered octahedral leaf clusters (Manhattan dist <= 2). - Drooping leaf tendrils for Willow / weeping Cherry (8 cardinal+diagonal columns, variable length per direction). - LeafPlacer split into place_core (inner cross + droops, never accent) and place_surface (outermost ring + branch tip, may be accent). Trunk height clamped to canopy_top - 1 so caps always cover the trunk top regardless of jitter. - New species using shape-only variation: Bush (leaf clump, no trunk), AzaleaBush, Willow, FloweringOak (oak silhouette + cherry blossom accents). - New species using repurposed unused block IDs: Mangrove (MANGROVE_LOG=0, MANGROVE_LEAVES=233, AZALEA_LEAVES=234). Wired into wetland=mangrove and 60/40 with Willow for wetland=swamp; resolves the long-standing "TODO implement mangrove" in natural.rs. - Broadleaved/needleleaved/mixed tree pools in landuse.rs and natural.rs expanded with the new variants and species. - Forest density modulated by value_noise_01(x, z, 32) for organic clearings vs thickets; average rate unchanged (~1/30). --- src/block_definitions.rs | 10 +- src/element_processing/landuse.rs | 63 +- src/element_processing/natural.rs | 53 +- src/element_processing/tree.rs | 1057 +++++++++++++++++++++++------ 4 files changed, 949 insertions(+), 234 deletions(-) diff --git a/src/block_definitions.rs b/src/block_definitions.rs index 9b77ce5a9..814fc8f74 100644 --- a/src/block_definitions.rs +++ b/src/block_definitions.rs @@ -106,7 +106,7 @@ impl Block { pub fn name(&self) -> &str { match self.id { - 0 => "acacia_planks", + 0 => "mangrove_log", 1 => "air", 2 => "andesite", 3 => "birch_leaves", @@ -329,8 +329,8 @@ impl Block { 230 => "cherry_log", 231 => "cherry_leaves", 232 => "brown_concrete_powder", - 233 => "orange_stained_glass", - 234 => "magenta_stained_glass", + 233 => "mangrove_leaves", + 234 => "azalea_leaves", 235 => "potted_poppy", 236 => "oak_trapdoor", 237 => "oak_trapdoor", @@ -1035,6 +1035,10 @@ pub const CYAN_TERRACOTTA: Block = Block::new(253); pub const BLACK_WOOL: Block = Block::new(254); pub const LIGHT_GRAY_WALL_BANNER: Block = Block::new(255); +pub const MANGROVE_LOG: Block = Block::new(0); +pub const MANGROVE_LEAVES: Block = Block::new(233); +pub const AZALEA_LEAVES: Block = Block::new(234); + /// Maps a block to a stair variant in the same colour family. #[inline] pub fn get_stair_block_for_material(material: Block) -> Block { diff --git a/src/element_processing/landuse.rs b/src/element_processing/landuse.rs index b67c63be8..390541886 100644 --- a/src/element_processing/landuse.rs +++ b/src/element_processing/landuse.rs @@ -63,18 +63,39 @@ pub fn generate_landuse( "broadleaved" => { trees.push(TreeType::Oak); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); + } + "needleleaved" => { + trees.push(TreeType::Spruce); + trees.push(TreeType::Pine); } - "needleleaved" => trees.push(TreeType::Spruce), _ => { trees.push(TreeType::Oak); trees.push(TreeType::Spruce); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Pine); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); + trees.push(TreeType::Willow); } } } else { trees.push(TreeType::Oak); trees.push(TreeType::Spruce); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Pine); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); } trees }; @@ -178,27 +199,33 @@ pub fn generate_landuse( } } "forest" if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) => { - let random_choice: i32 = rng.random_range(0..30); - if random_choice == 20 { + // Density-modulated tree spawn: dense thickets in some patches, + // clearings in others. Average rate stays ~1/30 like before. + let density = crate::ground_generation::value_noise_01(x, z, 32); + let tree_threshold = ((60.0 - density * 45.0) as i32).max(5); + if rng.random_range(0..tree_threshold) == 0 { let tree_type = *trees_ok_to_generate .choose(&mut rng) .unwrap_or(&TreeType::Oak); Tree::create_of_type(editor, (x, 1, z), tree_type, Some(building_footprints)); - } else if random_choice == 2 { - let flower_block: Block = match rng.random_range(1..=6) { - 1 => OAK_LEAVES, - 2 => RED_FLOWER, - 3 => BLUE_FLOWER, - 4 => YELLOW_FLOWER, - 5 => FERN, - _ => WHITE_FLOWER, - }; - editor.set_block(flower_block, x, 1, z, None, None); - } else if random_choice <= 12 { - if rng.random_range(0..100) < 12 { - editor.set_block(FERN, x, 1, z, None, None); - } else { - editor.set_block(GRASS, x, 1, z, None, None); + } else { + let random_choice: i32 = rng.random_range(0..30); + if random_choice == 2 { + let flower_block: Block = match rng.random_range(1..=6) { + 1 => OAK_LEAVES, + 2 => RED_FLOWER, + 3 => BLUE_FLOWER, + 4 => YELLOW_FLOWER, + 5 => FERN, + _ => WHITE_FLOWER, + }; + editor.set_block(flower_block, x, 1, z, None, None); + } else if random_choice <= 12 { + if rng.random_range(0..100) < 12 { + editor.set_block(FERN, x, 1, z, None, None); + } else { + editor.set_block(GRASS, x, 1, z, None, None); + } } } } diff --git a/src/element_processing/natural.rs b/src/element_processing/natural.rs index 361cdfbbb..2606e71f7 100644 --- a/src/element_processing/natural.rs +++ b/src/element_processing/natural.rs @@ -55,18 +55,31 @@ pub fn generate_natural( "broadleaved" => { trees_ok_to_generate.push(TreeType::Oak); trees_ok_to_generate.push(TreeType::Birch); + trees_ok_to_generate.push(TreeType::TallOak); + trees_ok_to_generate.push(TreeType::Cherry); + trees_ok_to_generate.push(TreeType::FloweringOak); + } + "needleleaved" => { + trees_ok_to_generate.push(TreeType::Spruce); + trees_ok_to_generate.push(TreeType::Pine); } - "needleleaved" => trees_ok_to_generate.push(TreeType::Spruce), _ => { trees_ok_to_generate.push(TreeType::Oak); trees_ok_to_generate.push(TreeType::Spruce); trees_ok_to_generate.push(TreeType::Birch); + trees_ok_to_generate.push(TreeType::TallOak); + trees_ok_to_generate.push(TreeType::Pine); + trees_ok_to_generate.push(TreeType::Cherry); + trees_ok_to_generate.push(TreeType::FloweringOak); } } } else { trees_ok_to_generate.push(TreeType::Oak); trees_ok_to_generate.push(TreeType::Spruce); trees_ok_to_generate.push(TreeType::Birch); + trees_ok_to_generate.push(TreeType::TallOak); + trees_ok_to_generate.push(TreeType::Cherry); + trees_ok_to_generate.push(TreeType::FloweringOak); } if trees_ok_to_generate.is_empty() { @@ -181,18 +194,37 @@ pub fn generate_natural( "broadleaved" => { trees.push(TreeType::Oak); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); + } + "needleleaved" => { + trees.push(TreeType::Spruce); + trees.push(TreeType::Pine); } - "needleleaved" => trees.push(TreeType::Spruce), _ => { trees.push(TreeType::Oak); trees.push(TreeType::Spruce); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Pine); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); } } } else { trees.push(TreeType::Oak); trees.push(TreeType::Spruce); trees.push(TreeType::Birch); + trees.push(TreeType::TallOak); + trees.push(TreeType::Cherry); + trees.push(TreeType::FloweringOak); + trees.push(TreeType::Bush); + trees.push(TreeType::AzaleaBush); } trees }; @@ -309,8 +341,12 @@ pub fn generate_natural( if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) { continue; } + // Density-modulated tree spawn for organic clearings/thickets. + let density = crate::ground_generation::value_noise_01(x, z, 32); + let tree_threshold = ((60.0 - density * 45.0) as i32).max(5); + let spawn_tree = rng.random_range(0..tree_threshold) == 0; let random_choice: i32 = rng.random_range(0..30); - if random_choice == 0 { + if spawn_tree { let tree_type = *trees_ok_to_generate .choose(&mut rng) .unwrap_or(&TreeType::Oak); @@ -372,12 +408,19 @@ pub fn generate_natural( editor.set_block(TALL_GRASS_TOP, x, 2, z, None, None); } "swamp" | "mangrove" => { - // TODO implement mangrove let random_choice: i32 = rng.random_range(0..40); if random_choice == 0 { - Tree::create( + let tree_type = if wetland_type == "mangrove" { + TreeType::Mangrove + } else if rng.random_bool(0.6) { + TreeType::Willow + } else { + TreeType::Mangrove + }; + Tree::create_of_type( editor, (x, 1, z), + tree_type, Some(building_footprints), ); } else if random_choice < 35 { diff --git a/src/element_processing/tree.rs b/src/element_processing/tree.rs index b366a2f6a..e98718114 100644 --- a/src/element_processing/tree.rs +++ b/src/element_processing/tree.rs @@ -6,9 +6,9 @@ use rand::Rng; type Coord = (i32, i32, i32); -// TODO all this data would probably be better suited in a TOML file or something. +// ─── Round patterns ──────────────────────────────────────────────────────── +// Concentric rings added on top of the central trunk column to bulk up the canopy. -/// A circular pattern around a central point. #[rustfmt::skip] const ROUND1_PATTERN: [Coord; 8] = [ (-2, 0, 0), @@ -21,7 +21,6 @@ const ROUND1_PATTERN: [Coord; 8] = [ (-1, 0, 1), ]; -/// A wider circular pattern. const ROUND2_PATTERN: [Coord; 12] = [ (3, 0, 0), (2, 0, -1), @@ -37,7 +36,6 @@ const ROUND2_PATTERN: [Coord; 12] = [ (0, 0, 3), ]; -/// A more scattered circular pattern. const ROUND3_PATTERN: [Coord; 12] = [ (3, 0, -1), (3, 0, 1), @@ -53,12 +51,13 @@ const ROUND3_PATTERN: [Coord; 12] = [ (-1, 0, -3), ]; -/// Used for iterating over each of the round patterns const ROUND_PATTERNS: [&[Coord]; 3] = [&ROUND1_PATTERN, &ROUND2_PATTERN, &ROUND3_PATTERN]; -////////////////////////////////////////////////// +// ─── Leaves-fill data per (species, variant) ────────────────────────────── +// Each entry is a y-axis range describing a "column" of leaves around the trunk. -const OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ +// Oak — five variants for shape variety in dense forests. +const OAK_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 3, 0), (-1, 9, 0)), ((1, 3, 0), (1, 9, 0)), ((0, 3, -1), (0, 9, -1)), @@ -66,7 +65,32 @@ const OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 9, 0), (0, 10, 0)), ]; -const SPRUCE_LEAVES_FILL: [(Coord, Coord); 6] = [ +const OAK_LEAVES_FILL_TALL_SLIM: [(Coord, Coord); 5] = [ + ((-1, 6, 0), (-1, 11, 0)), + ((1, 6, 0), (1, 11, 0)), + ((0, 6, -1), (0, 11, -1)), + ((0, 6, 1), (0, 11, 1)), + ((0, 11, 0), (0, 12, 0)), +]; + +const OAK_LEAVES_FILL_BUSHY: [(Coord, Coord); 5] = [ + ((-1, 3, 0), (-1, 7, 0)), + ((1, 3, 0), (1, 7, 0)), + ((0, 3, -1), (0, 7, -1)), + ((0, 3, 1), (0, 7, 1)), + ((0, 7, 0), (0, 8, 0)), +]; + +const OAK_LEAVES_FILL_COMPACT: [(Coord, Coord); 5] = [ + ((-1, 2, 0), (-1, 5, 0)), + ((1, 2, 0), (1, 5, 0)), + ((0, 2, -1), (0, 5, -1)), + ((0, 2, 1), (0, 5, 1)), + ((0, 5, 0), (0, 6, 0)), +]; + +// Spruce — three variants. Conifer cone shape with cross pattern. +const SPRUCE_LEAVES_FILL_STANDARD: [(Coord, Coord); 6] = [ ((-1, 3, 0), (-1, 10, 0)), ((0, 3, -1), (0, 10, -1)), ((1, 3, 0), (1, 10, 0)), @@ -75,7 +99,26 @@ const SPRUCE_LEAVES_FILL: [(Coord, Coord); 6] = [ ((0, 11, 0), (0, 11, 0)), ]; -const BIRCH_LEAVES_FILL: [(Coord, Coord); 5] = [ +const SPRUCE_LEAVES_FILL_TOWERING: [(Coord, Coord); 6] = [ + ((-1, 4, 0), (-1, 13, 0)), + ((0, 4, -1), (0, 13, -1)), + ((1, 4, 0), (1, 13, 0)), + ((0, 4, -1), (0, 13, -1)), + ((0, 4, 1), (0, 13, 1)), + ((0, 14, 0), (0, 14, 0)), +]; + +const SPRUCE_LEAVES_FILL_SQUAT: [(Coord, Coord); 6] = [ + ((-1, 2, 0), (-1, 7, 0)), + ((0, 2, -1), (0, 7, -1)), + ((1, 2, 0), (1, 7, 0)), + ((0, 2, -1), (0, 7, -1)), + ((0, 2, 1), (0, 7, 1)), + ((0, 8, 0), (0, 8, 0)), +]; + +// Birch — three variants. Tall and slender by default. +const BIRCH_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 2, 0), (-1, 7, 0)), ((1, 2, 0), (1, 7, 0)), ((0, 2, -1), (0, 7, -1)), @@ -83,8 +126,24 @@ const BIRCH_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 7, 0), (0, 8, 0)), ]; -/// Dark oak: short but wide canopy, leaves start at y=3 up to y=6 with a cap -const DARK_OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ +const BIRCH_LEAVES_FILL_TALL: [(Coord, Coord); 5] = [ + ((-1, 5, 0), (-1, 10, 0)), + ((1, 5, 0), (1, 10, 0)), + ((0, 5, -1), (0, 10, -1)), + ((0, 5, 1), (0, 10, 1)), + ((0, 10, 0), (0, 11, 0)), +]; + +const BIRCH_LEAVES_FILL_CLUSTER: [(Coord, Coord); 5] = [ + ((-1, 3, 0), (-1, 5, 0)), + ((1, 3, 0), (1, 5, 0)), + ((0, 3, -1), (0, 5, -1)), + ((0, 3, 1), (0, 5, 1)), + ((0, 5, 0), (0, 6, 0)), +]; + +// Dark oak — three variants. Short trunk, wide canopy. +const DARK_OAK_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 3, 0), (-1, 6, 0)), ((1, 3, 0), (1, 6, 0)), ((0, 3, -1), (0, 6, -1)), @@ -92,8 +151,24 @@ const DARK_OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 6, 0), (0, 7, 0)), ]; -/// Jungle: tall tree with canopy only near the top, leaves from y=7 to y=11 -const JUNGLE_LEAVES_FILL: [(Coord, Coord); 5] = [ +const DARK_OAK_LEAVES_FILL_TALL_BUSHY: [(Coord, Coord); 5] = [ + ((-1, 4, 0), (-1, 9, 0)), + ((1, 4, 0), (1, 9, 0)), + ((0, 4, -1), (0, 9, -1)), + ((0, 4, 1), (0, 9, 1)), + ((0, 9, 0), (0, 10, 0)), +]; + +const DARK_OAK_LEAVES_FILL_STUNTED: [(Coord, Coord); 5] = [ + ((-1, 2, 0), (-1, 4, 0)), + ((1, 2, 0), (1, 4, 0)), + ((0, 2, -1), (0, 4, -1)), + ((0, 2, 1), (0, 4, 1)), + ((0, 4, 0), (0, 5, 0)), +]; + +// Jungle — two variants. Tall trunk, canopy near top. +const JUNGLE_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 7, 0), (-1, 11, 0)), ((1, 7, 0), (1, 11, 0)), ((0, 7, -1), (0, 11, -1)), @@ -101,8 +176,16 @@ const JUNGLE_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 11, 0), (0, 12, 0)), ]; -/// Acacia: umbrella-shaped canopy with a gentle dome, leaves from y=5 to y=8 -const ACACIA_LEAVES_FILL: [(Coord, Coord); 5] = [ +const JUNGLE_LEAVES_FILL_BROAD: [(Coord, Coord); 5] = [ + ((-1, 8, 0), (-1, 12, 0)), + ((1, 8, 0), (1, 12, 0)), + ((0, 8, -1), (0, 12, -1)), + ((0, 8, 1), (0, 12, 1)), + ((0, 12, 0), (0, 13, 0)), +]; + +// Acacia — two variants. Umbrella canopy. +const ACACIA_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 5, 0), (-1, 8, 0)), ((1, 5, 0), (1, 8, 0)), ((0, 5, -1), (0, 8, -1)), @@ -110,8 +193,16 @@ const ACACIA_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 8, 0), (0, 9, 0)), ]; -/// Cherry: medium trunk with a wide, rounded canopy of pink blossoms. -const CHERRY_LEAVES_FILL: [(Coord, Coord); 5] = [ +const ACACIA_LEAVES_FILL_TALL: [(Coord, Coord); 5] = [ + ((-1, 7, 0), (-1, 10, 0)), + ((1, 7, 0), (1, 10, 0)), + ((0, 7, -1), (0, 10, -1)), + ((0, 7, 1), (0, 10, 1)), + ((0, 10, 0), (0, 10, 0)), +]; + +// Cherry — two variants. +const CHERRY_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 4, 0), (-1, 9, 0)), ((1, 4, 0), (1, 9, 0)), ((0, 4, -1), (0, 9, -1)), @@ -119,8 +210,16 @@ const CHERRY_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 9, 0), (0, 10, 0)), ]; -/// Tall oak: extra-tall trunk with a smaller crown sitting near the top. -const TALL_OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ +const CHERRY_LEAVES_FILL_WEEPING: [(Coord, Coord); 5] = [ + ((-1, 3, 0), (-1, 8, 0)), + ((1, 3, 0), (1, 8, 0)), + ((0, 3, -1), (0, 8, -1)), + ((0, 3, 1), (0, 8, 1)), + ((0, 8, 0), (0, 9, 0)), +]; + +// Tall oak — two variants. Extra-tall oak (kept for compatibility). +const TALL_OAK_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 8, 0), (-1, 12, 0)), ((1, 8, 0), (1, 12, 0)), ((0, 8, -1), (0, 12, -1)), @@ -128,8 +227,16 @@ const TALL_OAK_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 12, 0), (0, 13, 0)), ]; -/// Pine: very tall narrow conifer using spruce blocks. -const PINE_LEAVES_FILL: [(Coord, Coord); 6] = [ +const TALL_OAK_LEAVES_FILL_GIANT: [(Coord, Coord); 5] = [ + ((-1, 9, 0), (-1, 14, 0)), + ((1, 9, 0), (1, 14, 0)), + ((0, 9, -1), (0, 14, -1)), + ((0, 9, 1), (0, 14, 1)), + ((0, 14, 0), (0, 15, 0)), +]; + +// Pine — two variants. Narrow conifer using spruce blocks. +const PINE_LEAVES_FILL_STANDARD: [(Coord, Coord); 6] = [ ((-1, 5, 0), (-1, 12, 0)), ((0, 5, -1), (0, 12, -1)), ((1, 5, 0), (1, 12, 0)), @@ -138,21 +245,43 @@ const PINE_LEAVES_FILL: [(Coord, Coord); 6] = [ ((0, 13, 0), (0, 13, 0)), ]; -////////////////////////////////////////////////// +const PINE_LEAVES_FILL_TALL: [(Coord, Coord); 6] = [ + ((-1, 6, 0), (-1, 15, 0)), + ((0, 6, -1), (0, 15, -1)), + ((1, 6, 0), (1, 15, 0)), + ((0, 6, -1), (0, 15, -1)), + ((0, 6, 1), (0, 15, 1)), + ((0, 16, 0), (0, 16, 0)), +]; -/// Maximum horizontal canopy radius used by the predefined tree patterns. -const MAX_CANOPY_RADIUS: i32 = 3; +// Mangrove — tall, narrow tropical/swamp tree. +const MANGROVE_LEAVES_FILL: [(Coord, Coord); 5] = [ + ((-1, 5, 0), (-1, 10, 0)), + ((1, 5, 0), (1, 10, 0)), + ((0, 5, -1), (0, 10, -1)), + ((0, 5, 1), (0, 10, 1)), + ((0, 10, 0), (0, 11, 0)), +]; -fn round_absolute( - editor: &mut WorldEditor, - material: Block, - (x, y, z): Coord, - block_pattern: &[Coord], -) { - for (i, j, k) in block_pattern { - editor.set_block_absolute(material, x + i, y + j, z + k, None, None); - } -} +// Bush — no real trunk; small leaf clump at ground level. +const BUSH_LEAVES_FILL: [(Coord, Coord); 3] = [ + ((-1, 0, -1), (1, 1, 1)), + ((-1, 2, 0), (1, 2, 0)), + ((0, 2, -1), (0, 2, 1)), +]; + +// Willow — short trunk, wide canopy. Drooping tendrils added separately. +const WILLOW_LEAVES_FILL: [(Coord, Coord); 5] = [ + ((-1, 4, 0), (-1, 7, 0)), + ((1, 4, 0), (1, 7, 0)), + ((0, 4, -1), (0, 7, -1)), + ((0, 4, 1), (0, 7, 1)), + ((0, 7, 0), (0, 8, 0)), +]; + +// ─── Geometry ────────────────────────────────────────────────────────────── + +const MAX_CANOPY_RADIUS: i32 = 3; #[derive(Clone, Copy)] pub enum TreeType { @@ -165,19 +294,92 @@ pub enum TreeType { Cherry, TallOak, Pine, + Bush, + AzaleaBush, + Willow, + FloweringOak, + Mangrove, } -// TODO what should be moved in, and what should be referenced? -pub struct Tree<'a> { - // kind: TreeType, // NOTE: Not actually necessary to store! +// Per-instance, per-shape tree configuration. +pub struct Tree { log_block: Block, log_height: i32, leaves_block: Block, - leaves_fill: &'a [(Coord, Coord)], + leaves_fill: &'static [(Coord, Coord)], round_ranges: [Vec; 3], + // 0.0..1.0 chance to grow a single horizontal side branch + branch_chance: f32, + // Sprinkled accent leaves block (e.g. cherry blossoms inside an oak canopy) + accent_block: Option, + // 0..100 percent chance per leaf to be the accent + accent_chance: u8, + // Long willow-style drooping leaf tendrils at canopy edge + drooping: bool, +} + +// Helper used inside create_of_type to keep leaf-placement logic in one place. +struct LeafPlacer<'a> { + leaves_block: Block, + accent_block: Option, + accent_chance: u8, + check_collision: bool, + footprints: Option<&'a BuildingFootprintBitmap>, } -impl Tree<'_> { +impl LeafPlacer<'_> { + // Inner-canopy leaf: ~4% organic gap, never an accent block. + fn place_core(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32) { + self.place_with(editor, x, y, z, false); + } + + // Surface (visible) leaf: ~4% gap, may be the accent block. + fn place_surface(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32) { + self.place_with(editor, x, y, z, true); + } + + fn place_with( + &self, + editor: &mut WorldEditor, + x: i32, + y: i32, + z: i32, + allow_accent: bool, + ) { + if self.check_collision { + if let Some(fp) = self.footprints { + if fp.contains(x, z) { + return; + } + } + } + let h = (x as i64 as u64) + .wrapping_mul(73856093) + ^ (y as i64 as u64).wrapping_mul(19349663) + ^ (z as i64 as u64).wrapping_mul(83492791); + // ~4% of leaves are skipped — enough texture without carving holes. + if h % 100 < 4 { + return; + } + let block = if allow_accent { + if let Some(accent) = self.accent_block { + let r2 = h.wrapping_mul(2654435761) % 100; + if r2 < self.accent_chance as u64 { + accent + } else { + self.leaves_block + } + } else { + self.leaves_block + } + } else { + self.leaves_block + }; + editor.set_block_absolute(block, x, y, z, None, None); + } +} + +impl Tree { fn canopy_might_intersect_building( x: i32, z: i32, @@ -210,20 +412,24 @@ impl Tree<'_> { (x, y, z): Coord, building_footprints: Option<&BuildingFootprintBitmap>, ) { - // Use deterministic RNG based on coordinates for consistent tree types across region boundaries - // The element_id of 0 is used as a salt for tree-specific randomness + // Deterministic per-coord type pick (salt 0). let mut rng = coord_rng(x, z, 0); - let tree_type = match rng.random_range(1..=50) { - 1..=13 => TreeType::Oak, - 14..=21 => TreeType::Spruce, - 22..=29 => TreeType::Birch, - 30..=33 => TreeType::DarkOak, - 34..=37 => TreeType::Jungle, - 38..=41 => TreeType::Acacia, - 42 => TreeType::Cherry, - 43..=46 => TreeType::TallOak, - 47..=50 => TreeType::Pine, + let tree_type = match rng.random_range(1..=100) { + 1..=20 => TreeType::Oak, + 21..=32 => TreeType::Spruce, + 33..=44 => TreeType::Birch, + 45..=50 => TreeType::DarkOak, + 51..=56 => TreeType::Jungle, + 57..=62 => TreeType::Acacia, + 63..=64 => TreeType::Cherry, + 65..=70 => TreeType::TallOak, + 71..=77 => TreeType::Pine, + 78..=84 => TreeType::Bush, + 85..=88 => TreeType::AzaleaBush, + 89..=92 => TreeType::Willow, + 93..=98 => TreeType::FloweringOak, + 99..=100 => TreeType::Mangrove, _ => unreachable!(), }; @@ -270,193 +476,627 @@ impl Tree<'_> { blacklist.extend(Self::get_functional_blocks()); blacklist.push(WATER); - let tree = Self::get_tree(tree_type); + // Separate per-coord RNG for shape variation (salt 1). Same coord always + // gets the same variant_idx regardless of which caller stamped the tree. + let mut shape_rng = coord_rng(x, z, 1); + let variant_idx: u32 = shape_rng.random(); + + let tree = Self::get_tree(tree_type, variant_idx); let check_canopy_collision = Self::canopy_might_intersect_building(x, z, building_footprints); - // Resolve trunk base Y once from the trunk's (x,z) position. - // All tree blocks use this as reference so the canopy doesn't - // warp to follow the hillside terrain. + // Resolve trunk base Y once from the trunk's (x,z) position so the + // canopy doesn't warp to follow the hillside terrain. let base_y = editor.get_absolute_y(x, y, z); - // Build the logs - editor.fill_blocks_absolute( - tree.log_block, - x, - base_y, - z, - x, - base_y + tree.log_height, - z, - None, - Some(&blacklist), - ); + // Trunk-height jitter: drawn from bits 8-9 of variant_idx → -1..=+2, + // then clamped so the canopy cap always sits above the trunk top. + let height_jitter = ((variant_idx >> 8) & 0x3) as i32 - 1; + let min_trunk = if tree.log_height == 0 { 0 } else { 2 }; + let canopy_top = tree + .leaves_fill + .iter() + .map(|((_, _, _), (_, j2, _))| *j2) + .max() + .unwrap_or(0); + let trunk_cap = (canopy_top - 1).max(min_trunk); + let trunk_height = (tree.log_height + height_jitter) + .max(min_trunk) + .min(trunk_cap); + + // Place the trunk (skipped for log_height == 0, e.g. Bush). + if tree.log_height > 0 { + editor.fill_blocks_absolute( + tree.log_block, + x, + base_y, + z, + x, + base_y + trunk_height, + z, + None, + Some(&blacklist), + ); + } + + let placer = LeafPlacer { + leaves_block: tree.leaves_block, + accent_block: tree.accent_block, + accent_chance: tree.accent_chance, + check_collision: check_canopy_collision, + footprints: building_footprints, + }; - // Fill in the leaves + // Inner canopy columns — no accent here, so blossoms stay on the surface. for ((i1, j1, k1), (i2, j2, k2)) in tree.leaves_fill { - if check_canopy_collision { - for leaf_x in (x + i1)..=(x + i2) { - for leaf_y in (base_y + j1)..=(base_y + j2) { - for leaf_z in (z + k1)..=(z + k2) { - if building_footprints.is_none_or(|fp| !fp.contains(leaf_x, leaf_z)) { - editor.set_block_absolute( - tree.leaves_block, - leaf_x, - leaf_y, - leaf_z, - None, - None, - ); - } - } + for leaf_x in (x + i1)..=(x + i2) { + for leaf_y in (base_y + j1)..=(base_y + j2) { + for leaf_z in (z + k1)..=(z + k2) { + placer.place_core(editor, leaf_x, leaf_y, leaf_z); } } - } else { - editor.fill_blocks_absolute( - tree.leaves_block, - x + i1, - base_y + j1, - z + k1, - x + i2, - base_y + j2, - z + k2, - None, - None, - ); } } - // Do the three rounds - for (round_range, round_pattern) in tree.round_ranges.iter().zip(ROUND_PATTERNS) { + // Outer rings: only the OUTERMOST non-empty ring is treated as surface + // (so accent blocks like cherry blossoms don't land on inner rings that + // sit visually inside the visible canopy edge). + let outermost_ring_idx: Option = tree + .round_ranges + .iter() + .enumerate() + .rev() + .find(|(_, r)| !r.is_empty()) + .map(|(i, _)| i); + for (idx, (round_range, round_pattern)) in + tree.round_ranges.iter().zip(ROUND_PATTERNS).enumerate() + { + let is_surface = Some(idx) == outermost_ring_idx; for offset in round_range { - if check_canopy_collision { - for &(i, j, k) in round_pattern { - let leaf_x = x + i; - let leaf_z = z + k; - if building_footprints.is_none_or(|fp| !fp.contains(leaf_x, leaf_z)) { - editor.set_block_absolute( - tree.leaves_block, - leaf_x, - base_y + offset + j, - leaf_z, - None, - None, + for &(i, j, k) in round_pattern { + let lx = x + i; + let ly = base_y + offset + j; + let lz = z + k; + if is_surface { + placer.place_surface(editor, lx, ly, lz); + } else { + placer.place_core(editor, lx, ly, lz); + } + } + } + } + + // Side branch — gives the tree asymmetric character. + let branch_roll = ((variant_idx >> 16) & 0xFF) as f32 / 255.0; + if branch_roll < tree.branch_chance && trunk_height >= 5 { + let (dx, dz) = match (variant_idx >> 24) & 0x3 { + 0 => (1, 0), + 1 => (-1, 0), + 2 => (0, 1), + _ => (0, -1), + }; + let branch_y_off = trunk_height - 2 - ((variant_idx >> 12) & 0x1) as i32; + let branch_y = base_y + branch_y_off; + for step in 1..=2 { + editor.set_block_absolute( + tree.log_block, + x + dx * step, + branch_y, + z + dz * step, + None, + Some(&blacklist), + ); + } + // Tapered leaf cluster (octahedron, Manhattan ≤ 2) — rounder than a 3×3×3 cube. + let tip_x = x + dx * 2; + let tip_z = z + dz * 2; + for ddx in -1i32..=1 { + for ddy in -1i32..=1 { + for ddz in -1i32..=1 { + if ddx.abs() + ddy.abs() + ddz.abs() <= 2 { + placer.place_surface( + editor, + tip_x + ddx, + branch_y + ddy, + tip_z + ddz, ); } } - } else { - round_absolute( - editor, - tree.leaves_block, - (x, base_y + offset, z), - round_pattern, - ); + } + } + } + + // Willow droops — eight cardinal+diagonal columns of hanging leaves. + if tree.drooping { + let droop_dirs: [(i32, i32); 8] = [ + (2, 0), + (-2, 0), + (0, 2), + (0, -2), + (1, 1), + (-1, -1), + (1, -1), + (-1, 1), + ]; + for (idx, &(dx, dz)) in droop_dirs.iter().enumerate() { + let len_bits = ((variant_idx >> (idx as u32 * 2)) & 0x3) as i32; + let droop_len = 2 + len_bits; + let top = base_y + 5; + for n in 0..droop_len { + placer.place_core(editor, x + dx, top - n, z + dz); } } } } - fn get_tree(kind: TreeType) -> Self { + fn get_tree(kind: TreeType, variant_idx: u32) -> Self { match kind { - TreeType::Oak => Self { - // kind, - log_block: OAK_LOG, - log_height: 8, - leaves_block: OAK_LEAVES, - leaves_fill: &OAK_LEAVES_FILL, - round_ranges: [ - (3..=8).rev().collect(), - (4..=7).rev().collect(), - (5..=6).rev().collect(), - ], + TreeType::Oak => match variant_idx % 5 { + 0 => Self::oak_standard(), + 1 => Self::oak_tall_slim(), + 2 => Self::oak_bushy(), + 3 => Self::oak_compact(), + _ => Self::oak_lopsided(), }, - - TreeType::Spruce => Self { - // kind, - log_block: SPRUCE_LOG, - log_height: 9, - leaves_block: SPRUCE_LEAVES, - leaves_fill: &SPRUCE_LEAVES_FILL, - // Conical shape: wide at bottom, narrow at top - round_ranges: [vec![9, 7, 6, 4, 3], vec![6, 3], vec![]], + TreeType::Spruce => match variant_idx % 3 { + 0 => Self::spruce_standard(), + 1 => Self::spruce_towering(), + _ => Self::spruce_squat(), }, - - TreeType::Birch => Self { - // kind, - log_block: BIRCH_LOG, - log_height: 6, - leaves_block: BIRCH_LEAVES, - leaves_fill: &BIRCH_LEAVES_FILL, - round_ranges: [(2..=6).rev().collect(), (2..=4).collect(), vec![]], + TreeType::Birch => match variant_idx % 3 { + 0 => Self::birch_standard(), + 1 => Self::birch_tall(), + _ => Self::birch_cluster(), }, - - TreeType::DarkOak => Self { - // Short trunk with a very wide, bushy canopy - log_block: DARK_OAK_LOG, - log_height: 5, - leaves_block: DARK_OAK_LEAVES, - leaves_fill: &DARK_OAK_LEAVES_FILL, - // All 3 round patterns used for maximum width - round_ranges: [ - (3..=6).rev().collect(), - (3..=5).rev().collect(), - (4..=5).rev().collect(), - ], + TreeType::DarkOak => match variant_idx % 3 { + 0 => Self::dark_oak_standard(), + 1 => Self::dark_oak_tall_bushy(), + _ => Self::dark_oak_stunted(), }, - - TreeType::Jungle => Self { - // Tall trunk, canopy clustered at the top - log_block: JUNGLE_LOG, - log_height: 10, - leaves_block: JUNGLE_LEAVES, - leaves_fill: &JUNGLE_LEAVES_FILL, - // Canopy only near the top of the tree - round_ranges: [(7..=11).rev().collect(), (8..=10).rev().collect(), vec![]], + TreeType::Jungle => match variant_idx % 2 { + 0 => Self::jungle_standard(), + _ => Self::jungle_broad(), }, - - TreeType::Acacia => Self { - // Medium trunk with umbrella-shaped canopy, domed center - log_block: ACACIA_LOG, - log_height: 6, - leaves_block: ACACIA_LEAVES, - leaves_fill: &ACACIA_LEAVES_FILL, - // Inner rounds reach higher → gentle dome, outer stays low → wide brim - round_ranges: [ - (5..=8).rev().collect(), - (5..=7).rev().collect(), - (6..=7).rev().collect(), - ], + TreeType::Acacia => match variant_idx % 2 { + 0 => Self::acacia_standard(), + _ => Self::acacia_tall(), }, - - TreeType::Cherry => Self { - log_block: CHERRY_LOG, - log_height: 7, - leaves_block: CHERRY_LEAVES, - leaves_fill: &CHERRY_LEAVES_FILL, - round_ranges: [ - (4..=9).rev().collect(), - (5..=8).rev().collect(), - (6..=7).rev().collect(), - ], + TreeType::Cherry => match variant_idx % 2 { + 0 => Self::cherry_standard(), + _ => Self::cherry_weeping(), }, - - TreeType::TallOak => Self { - log_block: OAK_LOG, - log_height: 11, - leaves_block: OAK_LEAVES, - leaves_fill: &TALL_OAK_LEAVES_FILL, - round_ranges: [(8..=12).rev().collect(), (9..=11).rev().collect(), vec![10]], + TreeType::TallOak => match variant_idx % 2 { + 0 => Self::tall_oak_standard(), + _ => Self::tall_oak_giant(), }, - - TreeType::Pine => Self { - log_block: SPRUCE_LOG, - log_height: 12, - leaves_block: SPRUCE_LEAVES, - leaves_fill: &PINE_LEAVES_FILL, - round_ranges: [vec![11, 9, 7, 5], vec![8, 5], vec![]], + TreeType::Pine => match variant_idx % 2 { + 0 => Self::pine_standard(), + _ => Self::pine_tall(), }, - } // match - } // fn get_tree + TreeType::Bush => Self::bush(), + TreeType::AzaleaBush => Self::azalea_bush(), + TreeType::Willow => Self::willow(), + TreeType::FloweringOak => Self::flowering_oak(), + TreeType::Mangrove => Self::mangrove(), + } + } + + fn make( + log_block: Block, + log_height: i32, + leaves_block: Block, + leaves_fill: &'static [(Coord, Coord)], + round_ranges: [Vec; 3], + ) -> Self { + Self { + log_block, + log_height, + leaves_block, + leaves_fill, + round_ranges, + branch_chance: 0.0, + accent_block: None, + accent_chance: 0, + drooping: false, + } + } + + fn with_branch(mut self, chance: f32) -> Self { + self.branch_chance = chance; + self + } + + fn with_accent(mut self, block: Block, chance: u8) -> Self { + self.accent_block = Some(block); + self.accent_chance = chance; + self + } + + fn drooping(mut self) -> Self { + self.drooping = true; + self + } + + // ─── Oak variants ────────────────────────────────────────────────────── + fn oak_standard() -> Self { + Self::make( + OAK_LOG, + 8, + OAK_LEAVES, + &OAK_LEAVES_FILL_STANDARD, + [ + (3..=8).rev().collect(), + (4..=7).rev().collect(), + (5..=6).rev().collect(), + ], + ) + .with_branch(0.30) + } + + fn oak_tall_slim() -> Self { + Self::make( + OAK_LOG, + 10, + OAK_LEAVES, + &OAK_LEAVES_FILL_TALL_SLIM, + [ + (7..=11).rev().collect(), + (8..=10).rev().collect(), + vec![], + ], + ) + .with_branch(0.40) + } + + fn oak_bushy() -> Self { + Self::make( + OAK_LOG, + 6, + OAK_LEAVES, + &OAK_LEAVES_FILL_BUSHY, + [ + (3..=7).rev().collect(), + (3..=6).rev().collect(), + (4..=5).rev().collect(), + ], + ) + .with_branch(0.20) + } + + fn oak_compact() -> Self { + Self::make( + OAK_LOG, + 5, + OAK_LEAVES, + &OAK_LEAVES_FILL_COMPACT, + [(2..=5).rev().collect(), (3..=4).rev().collect(), vec![]], + ) + } + + fn oak_lopsided() -> Self { + // Standard oak silhouette with a guaranteed side branch — the branch + // is the asymmetry, no need to deform the canopy into a rectangle. + Self::make( + OAK_LOG, + 8, + OAK_LEAVES, + &OAK_LEAVES_FILL_STANDARD, + [ + (3..=8).rev().collect(), + (4..=7).rev().collect(), + (5..=6).rev().collect(), + ], + ) + .with_branch(1.0) + } + + // ─── Spruce variants ─────────────────────────────────────────────────── + fn spruce_standard() -> Self { + Self::make( + SPRUCE_LOG, + 9, + SPRUCE_LEAVES, + &SPRUCE_LEAVES_FILL_STANDARD, + [vec![9, 7, 6, 4, 3], vec![6, 3], vec![]], + ) + } + + fn spruce_towering() -> Self { + Self::make( + SPRUCE_LOG, + 12, + SPRUCE_LEAVES, + &SPRUCE_LEAVES_FILL_TOWERING, + [vec![12, 10, 8, 6, 4], vec![9, 6, 4], vec![]], + ) + } + + fn spruce_squat() -> Self { + Self::make( + SPRUCE_LOG, + 6, + SPRUCE_LEAVES, + &SPRUCE_LEAVES_FILL_SQUAT, + [vec![6, 4, 3], vec![4, 2], vec![3]], + ) + } + + // ─── Birch variants ──────────────────────────────────────────────────── + fn birch_standard() -> Self { + Self::make( + BIRCH_LOG, + 6, + BIRCH_LEAVES, + &BIRCH_LEAVES_FILL_STANDARD, + [(2..=6).rev().collect(), (2..=4).collect(), vec![]], + ) + .with_branch(0.20) + } + + fn birch_tall() -> Self { + Self::make( + BIRCH_LOG, + 9, + BIRCH_LEAVES, + &BIRCH_LEAVES_FILL_TALL, + [(5..=9).rev().collect(), (6..=8).rev().collect(), vec![]], + ) + .with_branch(0.25) + } + + fn birch_cluster() -> Self { + Self::make( + BIRCH_LOG, + 4, + BIRCH_LEAVES, + &BIRCH_LEAVES_FILL_CLUSTER, + [(2..=4).rev().collect(), vec![3], vec![]], + ) + } + + // ─── Dark oak variants ───────────────────────────────────────────────── + fn dark_oak_standard() -> Self { + Self::make( + DARK_OAK_LOG, + 5, + DARK_OAK_LEAVES, + &DARK_OAK_LEAVES_FILL_STANDARD, + [ + (3..=6).rev().collect(), + (3..=5).rev().collect(), + (4..=5).rev().collect(), + ], + ) + .with_branch(0.40) + } + + fn dark_oak_tall_bushy() -> Self { + Self::make( + DARK_OAK_LOG, + 8, + DARK_OAK_LEAVES, + &DARK_OAK_LEAVES_FILL_TALL_BUSHY, + [ + (4..=9).rev().collect(), + (5..=8).rev().collect(), + (6..=7).rev().collect(), + ], + ) + .with_branch(0.50) + } + + fn dark_oak_stunted() -> Self { + Self::make( + DARK_OAK_LOG, + 3, + DARK_OAK_LEAVES, + &DARK_OAK_LEAVES_FILL_STUNTED, + [vec![3, 2], vec![2], vec![]], + ) + } + + // ─── Jungle variants ─────────────────────────────────────────────────── + fn jungle_standard() -> Self { + Self::make( + JUNGLE_LOG, + 10, + JUNGLE_LEAVES, + &JUNGLE_LEAVES_FILL_STANDARD, + [(7..=11).rev().collect(), (8..=10).rev().collect(), vec![]], + ) + .with_branch(0.50) + } + + fn jungle_broad() -> Self { + Self::make( + JUNGLE_LOG, + 11, + JUNGLE_LEAVES, + &JUNGLE_LEAVES_FILL_BROAD, + [ + (8..=12).rev().collect(), + (9..=11).rev().collect(), + (10..=10).rev().collect(), + ], + ) + .with_branch(0.60) + } + + // ─── Acacia variants ─────────────────────────────────────────────────── + fn acacia_standard() -> Self { + Self::make( + ACACIA_LOG, + 6, + ACACIA_LEAVES, + &ACACIA_LEAVES_FILL_STANDARD, + [ + (5..=8).rev().collect(), + (5..=7).rev().collect(), + (6..=7).rev().collect(), + ], + ) + .with_branch(0.35) + } + + fn acacia_tall() -> Self { + Self::make( + ACACIA_LOG, + 8, + ACACIA_LEAVES, + &ACACIA_LEAVES_FILL_TALL, + [(7..=10).rev().collect(), (8..=9).rev().collect(), vec![9]], + ) + .with_branch(0.45) + } + + // ─── Cherry variants ─────────────────────────────────────────────────── + fn cherry_standard() -> Self { + Self::make( + CHERRY_LOG, + 7, + CHERRY_LEAVES, + &CHERRY_LEAVES_FILL_STANDARD, + [ + (4..=9).rev().collect(), + (5..=8).rev().collect(), + (6..=7).rev().collect(), + ], + ) + .with_branch(0.30) + } + + fn cherry_weeping() -> Self { + Self::make( + CHERRY_LOG, + 6, + CHERRY_LEAVES, + &CHERRY_LEAVES_FILL_WEEPING, + [ + (3..=8).rev().collect(), + (4..=7).rev().collect(), + (5..=6).rev().collect(), + ], + ) + .drooping() + } + + // ─── Tall oak variants ───────────────────────────────────────────────── + fn tall_oak_standard() -> Self { + Self::make( + OAK_LOG, + 11, + OAK_LEAVES, + &TALL_OAK_LEAVES_FILL_STANDARD, + [(8..=12).rev().collect(), (9..=11).rev().collect(), vec![10]], + ) + .with_branch(0.40) + } + + fn tall_oak_giant() -> Self { + Self::make( + OAK_LOG, + 13, + OAK_LEAVES, + &TALL_OAK_LEAVES_FILL_GIANT, + [ + (9..=14).rev().collect(), + (10..=13).rev().collect(), + (11..=12).rev().collect(), + ], + ) + .with_branch(0.60) + } + + // ─── Pine variants ───────────────────────────────────────────────────── + fn pine_standard() -> Self { + Self::make( + SPRUCE_LOG, + 12, + SPRUCE_LEAVES, + &PINE_LEAVES_FILL_STANDARD, + [vec![11, 9, 7, 5], vec![8, 5], vec![]], + ) + } + + fn pine_tall() -> Self { + Self::make( + SPRUCE_LOG, + 15, + SPRUCE_LEAVES, + &PINE_LEAVES_FILL_TALL, + [vec![14, 12, 10, 8, 6], vec![11, 7], vec![]], + ) + } + + // ─── New species ─────────────────────────────────────────────────────── + fn bush() -> Self { + // log_height == 0 → no trunk placed + Self::make( + OAK_LOG, + 0, + OAK_LEAVES, + &BUSH_LEAVES_FILL, + [vec![], vec![], vec![]], + ) + } + + fn azalea_bush() -> Self { + // Small flowering shrub using azalea leaves + Self::make( + OAK_LOG, + 0, + AZALEA_LEAVES, + &BUSH_LEAVES_FILL, + [vec![], vec![], vec![]], + ) + } + + fn willow() -> Self { + Self::make( + OAK_LOG, + 5, + OAK_LEAVES, + &WILLOW_LEAVES_FILL, + [ + (4..=6).rev().collect(), + (4..=5).rev().collect(), + vec![5], + ], + ) + .drooping() + } + + fn flowering_oak() -> Self { + // Oak silhouette with cherry-pink blossom accents (~18% of leaves). + Self::make( + OAK_LOG, + 8, + OAK_LEAVES, + &OAK_LEAVES_FILL_STANDARD, + [ + (3..=8).rev().collect(), + (4..=7).rev().collect(), + (5..=6).rev().collect(), + ], + ) + .with_branch(0.40) + .with_accent(CHERRY_LEAVES, 18) + } + + fn mangrove() -> Self { + Self::make( + MANGROVE_LOG, + 8, + MANGROVE_LEAVES, + &MANGROVE_LEAVES_FILL, + [ + (5..=10).rev().collect(), + (6..=9).rev().collect(), + (7..=8).rev().collect(), + ], + ) + .with_branch(0.55) + } /// Get all possible building wall blocks fn get_building_wall_blocks() -> Vec { @@ -606,4 +1246,5 @@ impl Tree<'_> { BEDROCK, ] } -} // impl Tree +} + From 5626eba5cde880424a86c8378b1f3866e43174a7 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 20:42:13 +0200 Subject: [PATCH 02/10] feat(biome): assign Minecraft biomes from land cover per chunk (Java) Replaces the hardcoded minecraft:plains biome on every chunk with a per-chunk 4x4 biome palette derived from ESA WorldCover land cover, packed into the Anvil 1.18+ per-section biomes NBT. Mapping (latitude- and water-distance-aware): LC_TREE_COVER -> taiga (>55 deg) / jungle (<23.5 deg) / forest (otherwise) LC_SHRUBLAND -> savanna LC_GRASSLAND -> plains LC_CROPLAND -> plains LC_BUILT_UP -> plains (neutral; avoids weird ambience in cities) LC_BARE -> desert LC_SNOW_ICE -> snowy_plains LC_WATER -> ocean (water_distance >= 8) / river (otherwise) LC_WETLAND -> swamp LC_MANGROVES -> mangrove_swamp LC_MOSS -> taiga fallback -> plains Format-aware encoding: - 4x4 LC samples per chunk (one per biome cell, centred in each 4-block footprint). Same compound is cloned across all sections in the chunk because LC is 2D, so biomes are y-invariant. - Palette deduped in first-occurrence order. data array is OMITTED for uniform-biome chunks (palette size 1) -- the common case for arnis since cities sit inside a single land-cover class. - Mixed-biome chunks pack the 64 cell indices into i64 longs using the post-1.16 no-straddle layout (1 bit -> 1 long, 2 bits -> 2 longs, 3 bits -> 4 longs with padding). Cost analysis on a 250 km^2 generation: - ~1-2 seconds added wall-clock (parallelised across regions via rayon) - ~4 MB transient peak per region during save, released after region write - No persistent fields added to Chunk / ChunkToModify / Section Bedrock and Luanti save paths are unchanged; they still default to plains. A separate change can wire LC-driven biomes through bedrock.rs once the Java mapping has been validated in-game. --- src/biome.rs | 235 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/world_editor/java.rs | 59 +++++++--- 3 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 src/biome.rs diff --git a/src/biome.rs b/src/biome.rs new file mode 100644 index 000000000..be906abed --- /dev/null +++ b/src/biome.rs @@ -0,0 +1,235 @@ +//! Land-cover-driven biome assignment for Java Anvil chunks (1.18+). +//! +//! Each section in a chunk stores biomes as a 4×4×4 grid (64 cells) referencing +//! a per-section palette of biome IDs. The data array is omitted entirely when +//! the palette has only one entry, which is the common case for arnis chunks +//! that sit inside a uniform land-cover region. See +//! . + +use crate::coordinate_system::cartesian::XZPoint; +use crate::ground::Ground; +use crate::land_cover::{ + LC_BARE, LC_BUILT_UP, LC_CROPLAND, LC_GRASSLAND, LC_MANGROVES, LC_MOSS, LC_SHRUBLAND, + LC_SNOW_ICE, LC_TREE_COVER, LC_WATER, LC_WETLAND, +}; +use fastnbt::{LongArray, Value}; +use std::collections::HashMap; + +/// Map an ESA WorldCover class to a Minecraft biome ID. +/// +/// `lat_deg` is the absolute world-center latitude; tree cover narrows to +/// `taiga` above ~55° and to `jungle` below ~23.5°. `water_dist` is the LC +/// distance-to-shore — large open water becomes `ocean`, anything narrower +/// stays `river`. +pub fn biome_for_class(lc: u8, lat_deg: f64, water_dist: u8) -> &'static str { + let abs_lat = lat_deg.abs(); + match lc { + LC_TREE_COVER => { + if abs_lat > 55.0 { + "minecraft:taiga" + } else if abs_lat < 23.5 { + "minecraft:jungle" + } else { + "minecraft:forest" + } + } + LC_SHRUBLAND => "minecraft:savanna", + LC_GRASSLAND | LC_CROPLAND | LC_BUILT_UP => "minecraft:plains", + LC_BARE => "minecraft:desert", + LC_SNOW_ICE => "minecraft:snowy_plains", + LC_WATER => { + if water_dist >= 8 { + "minecraft:ocean" + } else { + "minecraft:river" + } + } + LC_WETLAND => "minecraft:swamp", + LC_MANGROVES => "minecraft:mangrove_swamp", + LC_MOSS => "minecraft:taiga", + // LC == 0 (no data) or any unknown value + _ => "minecraft:plains", + } +} + +/// A precomputed biome compound the same chunk can share across all of its +/// sections. The fastnbt `Value` is cloned per-section at NBT-build time. +pub type ChunkBiomeNbt = Value; + +/// Build the `biomes` compound for one chunk. +/// +/// Samples land cover at a 4×4 grid (one sample per biome cell, centred in +/// each 4-block footprint) and packs it into the Anvil 1.18+ palette+data +/// layout. The resulting compound is identical for every section in the +/// chunk because biomes are y-invariant under our 2D land-cover model. +/// +/// When `ground` is `None` (no land cover loaded) or all 16 samples reduce to +/// a single biome, the compound contains just the palette and no data array, +/// keeping memory and NBT size minimal. +pub fn build_chunk_biome_nbt( + chunk_x: i32, + chunk_z: i32, + ground: Option<&Ground>, + center_lat_deg: f64, +) -> ChunkBiomeNbt { + let mut names: [&'static str; 16] = ["minecraft:plains"; 16]; + + if let Some(g) = ground { + for zi in 0..4i32 { + for xi in 0..4i32 { + // Sample at the geometric centre of each 4×4-block footprint. + let world_x = chunk_x * 16 + xi * 4 + 2; + let world_z = chunk_z * 16 + zi * 4 + 2; + let coord = XZPoint::new(world_x, world_z); + let lc = g.cover_class(coord); + let wd = g.water_distance(coord); + names[(zi * 4 + xi) as usize] = biome_for_class(lc, center_lat_deg, wd); + } + } + } + + // Dedup palette in first-occurrence order; record per-cell index in parallel. + let mut palette: Vec<&'static str> = Vec::with_capacity(4); + let mut indices: [u8; 16] = [0; 16]; + for (i, &name) in names.iter().enumerate() { + let idx = match palette.iter().position(|p| *p == name) { + Some(idx) => idx, + None => { + palette.push(name); + palette.len() - 1 + } + }; + indices[i] = idx as u8; + } + + let palette_value = Value::List( + palette + .iter() + .map(|&s| Value::String(s.to_string())) + .collect(), + ); + + if palette.len() <= 1 { + // Uniform-biome chunk: data array omitted, all 64 cells default to palette[0]. + let mut map = HashMap::with_capacity(1); + map.insert("palette".to_string(), palette_value); + return Value::Compound(map); + } + + let bits = bits_per_index(palette.len()); + let data = pack_biome_indices(&indices, bits); + + let mut map = HashMap::with_capacity(2); + map.insert("palette".to_string(), palette_value); + map.insert("data".to_string(), Value::LongArray(LongArray::new(data))); + Value::Compound(map) +} + +/// Bits required to index `palette_size` distinct biomes. Biome data has no +/// 4-bit minimum (unlike block_states); a 2-biome chunk uses 1 bit per cell. +fn bits_per_index(palette_size: usize) -> u32 { + if palette_size <= 1 { + 0 + } else { + (palette_size - 1).ilog2() + 1 + } +} + +/// Pack 64 biome cell indices into i64 longs using the post-1.16 layout +/// (values do NOT straddle long boundaries; remaining bits are zero-padded). +/// +/// `indices_16` holds the 4×4 xz biome map; it is replicated across the 4 +/// y-layers of the section's 4×4×4 grid because LC is two-dimensional. +fn pack_biome_indices(indices_16: &[u8; 16], bits: u32) -> Vec { + debug_assert!((1..=6).contains(&bits)); + let bits = bits as usize; + let vals_per_long = 64 / bits; + let num_longs = 64usize.div_ceil(vals_per_long); + let mask: u64 = (1u64 << bits) - 1; + + let mut longs = vec![0u64; num_longs]; + for cell in 0..64usize { + // cell index = y*16 + z*4 + x; xz biomes repeat across y, so xz_idx = cell % 16. + let xz_idx = cell % 16; + let value = (indices_16[xz_idx] as u64) & mask; + let long_idx = cell / vals_per_long; + let bit_offset = (cell % vals_per_long) * bits; + longs[long_idx] |= value << bit_offset; + } + longs.into_iter().map(|u| u as i64).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bits_per_index_table() { + assert_eq!(bits_per_index(1), 0); + assert_eq!(bits_per_index(2), 1); + assert_eq!(bits_per_index(3), 2); + assert_eq!(bits_per_index(4), 2); + assert_eq!(bits_per_index(5), 3); + assert_eq!(bits_per_index(8), 3); + assert_eq!(bits_per_index(9), 4); + assert_eq!(bits_per_index(16), 4); + } + + #[test] + fn pack_alternating_1bit_fits_one_long() { + let mut indices = [0u8; 16]; + for (i, v) in indices.iter_mut().enumerate() { + *v = (i % 2) as u8; + } + let longs = pack_biome_indices(&indices, 1); + assert_eq!(longs.len(), 1); + let expected: u64 = (0..64u64).fold(0, |acc, c| acc | (((c % 2) as u64) << c)); + assert_eq!(longs[0] as u64, expected); + } + + #[test] + fn pack_three_biomes_uses_two_longs() { + let mut indices = [0u8; 16]; + for (i, v) in indices.iter_mut().enumerate() { + *v = (i % 3) as u8; + } + let longs = pack_biome_indices(&indices, 2); + assert_eq!(longs.len(), 2); + } + + #[test] + fn pack_three_bit_pads_to_four_longs() { + // bits=3, vals_per_long = 64/3 = 21, num_longs = ceil(64/21) = 4 + let indices = [4u8; 16]; + let longs = pack_biome_indices(&indices, 3); + assert_eq!(longs.len(), 4); + } + + #[test] + fn no_ground_yields_plains_palette() { + let nbt = build_chunk_biome_nbt(0, 0, None, 0.0); + match nbt { + Value::Compound(map) => { + assert!(map.contains_key("palette")); + assert!(!map.contains_key("data")); // uniform → no data array + } + _ => panic!("expected compound"), + } + } + + #[test] + fn latitude_drives_tree_biome() { + assert_eq!(biome_for_class(LC_TREE_COVER, 0.0, 0), "minecraft:jungle"); + assert_eq!(biome_for_class(LC_TREE_COVER, 40.0, 0), "minecraft:forest"); + assert_eq!(biome_for_class(LC_TREE_COVER, 60.0, 0), "minecraft:taiga"); + // Hemisphere symmetry + assert_eq!(biome_for_class(LC_TREE_COVER, -60.0, 0), "minecraft:taiga"); + } + + #[test] + fn water_distance_drives_river_vs_ocean() { + assert_eq!(biome_for_class(LC_WATER, 0.0, 1), "minecraft:river"); + assert_eq!(biome_for_class(LC_WATER, 0.0, 7), "minecraft:river"); + assert_eq!(biome_for_class(LC_WATER, 0.0, 8), "minecraft:ocean"); + } +} diff --git a/src/main.rs b/src/main.rs index 492025def..ad0633706 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod args; mod bedrock_block_map; +mod biome; mod block_definitions; mod bresenham; mod clipping; diff --git a/src/world_editor/java.rs b/src/world_editor/java.rs index 707460d52..43128886b 100644 --- a/src/world_editor/java.rs +++ b/src/world_editor/java.rs @@ -75,6 +75,7 @@ impl<'a> WorldEditor<'a> { abs_chunk_x: i32, abs_chunk_z: i32, bake_lighting: bool, + biome_value: &Value, ) -> Result, Box> { // Use cached sections (computed once on first call) let sections = get_base_chunk_sections(); @@ -88,7 +89,7 @@ impl<'a> WorldEditor<'a> { other: FnvHashMap::default(), }; - let chunk_nbt = create_chunk_nbt(&chunk_data, bake_lighting); + let chunk_nbt = create_chunk_nbt(&chunk_data, bake_lighting, biome_value); let mut ser_buffer = Vec::with_capacity(8192); fastnbt::to_writer(&mut ser_buffer, &chunk_nbt)?; @@ -186,20 +187,34 @@ impl<'a> WorldEditor<'a> { let mut region = self.create_region(region_x, region_z)?; let mut ser_buffer = Vec::with_capacity(8192); + // World-center latitude drives temperature-based biome variants (taiga + // vs forest vs jungle) at chunk-build time. Cheap to recompute. + let center_lat = + (self.llbbox.min().lat() + self.llbbox.max().lat()) * 0.5; + let ground_ref = self.ground.as_deref(); + // First pass: write all chunks that have content for (&(chunk_x, chunk_z), chunk_to_modify) in ®ion_to_modify.chunks { if !chunk_to_modify.sections.is_empty() || !chunk_to_modify.other.is_empty() { + let abs_chunk_x = chunk_x + (region_x * 32); + let abs_chunk_z = chunk_z + (region_z * 32); // Create chunk directly, we're writing to a fresh region file // so there's no existing data to preserve let chunk = Chunk { sections: chunk_to_modify.sections().collect(), - x_pos: chunk_x + (region_x * 32), - z_pos: chunk_z + (region_z * 32), + x_pos: abs_chunk_x, + z_pos: abs_chunk_z, is_light_on: 0, other: chunk_to_modify.other.clone(), }; - let chunk_nbt = create_chunk_nbt(&chunk, self.bake_lighting); + let biome_value = crate::biome::build_chunk_biome_nbt( + abs_chunk_x, + abs_chunk_z, + ground_ref, + center_lat, + ); + let chunk_nbt = create_chunk_nbt(&chunk, self.bake_lighting, &biome_value); ser_buffer.clear(); fastnbt::to_writer(&mut ser_buffer, &chunk_nbt)?; region.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)?; @@ -217,8 +232,18 @@ impl<'a> WorldEditor<'a> { // If chunk doesn't exist, create it with base layer if !chunk_exists { - let ser_buffer = - Self::create_base_chunk(abs_chunk_x, abs_chunk_z, self.bake_lighting)?; + let biome_value = crate::biome::build_chunk_biome_nbt( + abs_chunk_x, + abs_chunk_z, + ground_ref, + center_lat, + ); + let ser_buffer = Self::create_base_chunk( + abs_chunk_x, + abs_chunk_z, + self.bake_lighting, + &biome_value, + )?; region.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)?; } } @@ -612,7 +637,11 @@ fn compute_lighting( /// DataVersion, Status, yPos, Heightmaps, biomes, structures, etc. /// Section range is determined dynamically: at minimum the vanilla range /// (Y=-4 to Y=19), extended upward/downward to cover any sections with content. -fn create_chunk_nbt(chunk: &Chunk, bake_lighting: bool) -> HashMap { +fn create_chunk_nbt( + chunk: &Chunk, + bake_lighting: bool, + biome_value: &Value, +) -> HashMap { // Index existing sections by Y for quick lookup let section_map: HashMap = chunk .sections @@ -633,12 +662,6 @@ fn create_chunk_nbt(chunk: &Chunk, bake_lighting: bool) -> HashMap Value { + crate::biome::build_chunk_biome_nbt(0, 0, None, 0.0) + } + #[test] fn bake_lighting_writes_valid_light_arrays() { - let nbt = create_chunk_nbt(&grass_chunk(), true); + let nbt = create_chunk_nbt(&grass_chunk(), true, &plains_biome()); assert_eq!(nbt["isLightOn"], Value::Byte(1)); assert!(nbt.contains_key("Heightmaps")); let secs = sections(&nbt); @@ -1011,7 +1038,7 @@ mod tests { #[test] fn no_bake_lighting_omits_light_arrays() { - let nbt = create_chunk_nbt(&grass_chunk(), false); + let nbt = create_chunk_nbt(&grass_chunk(), false, &plains_biome()); assert_eq!(nbt["isLightOn"], Value::Byte(0)); for s in sections(&nbt) { let Value::Compound(m) = s else { panic!() }; @@ -1029,7 +1056,7 @@ mod tests { is_light_on: 0, other: FnvHashMap::default(), }; - let nbt = create_chunk_nbt(&chunk, true); + let nbt = create_chunk_nbt(&chunk, true, &plains_biome()); for s in sections(&nbt) { let Value::Compound(m) = s else { panic!() }; let Value::ByteArray(b) = &m["SkyLight"] else { From fc293bbe0f6d6f91a2baf63f1dfab1ba9dd18d92 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 22:43:01 +0200 Subject: [PATCH 03/10] fix(trees): drop Cherry/FloweringOak from OSM forest pools The "expand broadleaved/mixed tree pools" change in 8eff06c put Cherry and FloweringOak into the explicit OSM-tagged forest pools, giving them ~14% (1 in 7) instead of the intended ~2%. A tagged broadleaved forest ended up looking like a cherry orchard. Both species are now only reachable via the random Tree::create pool at the original low rates (Cherry 2%, FloweringOak 6%), so an OSM-tagged broadleaved/mixed forest contains zero overtly pink trees while untagged contexts (cemeteries, scrub, swamp tree spawns) still see them as the rare specimens they're supposed to be. --- src/element_processing/landuse.rs | 10 ++-------- src/element_processing/natural.rs | 13 ------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/element_processing/landuse.rs b/src/element_processing/landuse.rs index 390541886..b9d916198 100644 --- a/src/element_processing/landuse.rs +++ b/src/element_processing/landuse.rs @@ -56,6 +56,7 @@ pub fn generate_landuse( // Get the area of the landuse element using cache let floor_area = flood_fill_cache.get_or_compute(element, args.timeout.as_ref()); + // Cherry/FloweringOak only via the random Tree::create pool (rare). let trees_ok_to_generate: Vec = { let mut trees: Vec = vec![]; if let Some(leaf_type) = element.tags.get("leaf_type") { @@ -64,8 +65,6 @@ pub fn generate_landuse( trees.push(TreeType::Oak); trees.push(TreeType::Birch); trees.push(TreeType::TallOak); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); } @@ -79,8 +78,6 @@ pub fn generate_landuse( trees.push(TreeType::Birch); trees.push(TreeType::TallOak); trees.push(TreeType::Pine); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); trees.push(TreeType::Willow); @@ -92,8 +89,6 @@ pub fn generate_landuse( trees.push(TreeType::Birch); trees.push(TreeType::TallOak); trees.push(TreeType::Pine); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); } @@ -199,8 +194,7 @@ pub fn generate_landuse( } } "forest" if editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) => { - // Density-modulated tree spawn: dense thickets in some patches, - // clearings in others. Average rate stays ~1/30 like before. + // Density-modulated spawn: thickets in some patches, clearings in others. let density = crate::ground_generation::value_noise_01(x, z, 32); let tree_threshold = ((60.0 - density * 45.0) as i32).max(5); if rng.random_range(0..tree_threshold) == 0 { diff --git a/src/element_processing/natural.rs b/src/element_processing/natural.rs index 2606e71f7..fb7a44447 100644 --- a/src/element_processing/natural.rs +++ b/src/element_processing/natural.rs @@ -56,8 +56,6 @@ pub fn generate_natural( trees_ok_to_generate.push(TreeType::Oak); trees_ok_to_generate.push(TreeType::Birch); trees_ok_to_generate.push(TreeType::TallOak); - trees_ok_to_generate.push(TreeType::Cherry); - trees_ok_to_generate.push(TreeType::FloweringOak); } "needleleaved" => { trees_ok_to_generate.push(TreeType::Spruce); @@ -69,8 +67,6 @@ pub fn generate_natural( trees_ok_to_generate.push(TreeType::Birch); trees_ok_to_generate.push(TreeType::TallOak); trees_ok_to_generate.push(TreeType::Pine); - trees_ok_to_generate.push(TreeType::Cherry); - trees_ok_to_generate.push(TreeType::FloweringOak); } } } else { @@ -78,8 +74,6 @@ pub fn generate_natural( trees_ok_to_generate.push(TreeType::Spruce); trees_ok_to_generate.push(TreeType::Birch); trees_ok_to_generate.push(TreeType::TallOak); - trees_ok_to_generate.push(TreeType::Cherry); - trees_ok_to_generate.push(TreeType::FloweringOak); } if trees_ok_to_generate.is_empty() { @@ -195,8 +189,6 @@ pub fn generate_natural( trees.push(TreeType::Oak); trees.push(TreeType::Birch); trees.push(TreeType::TallOak); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); } @@ -210,8 +202,6 @@ pub fn generate_natural( trees.push(TreeType::Birch); trees.push(TreeType::TallOak); trees.push(TreeType::Pine); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); } @@ -221,8 +211,6 @@ pub fn generate_natural( trees.push(TreeType::Spruce); trees.push(TreeType::Birch); trees.push(TreeType::TallOak); - trees.push(TreeType::Cherry); - trees.push(TreeType::FloweringOak); trees.push(TreeType::Bush); trees.push(TreeType::AzaleaBush); } @@ -341,7 +329,6 @@ pub fn generate_natural( if !editor.check_for_block(x, 0, z, Some(&[GRASS_BLOCK])) { continue; } - // Density-modulated tree spawn for organic clearings/thickets. let density = crate::ground_generation::value_noise_01(x, z, 32); let tree_threshold = ((60.0 - density * 45.0) as i32).max(5); let spawn_tree = rng.random_range(0..tree_threshold) == 0; From 368785338bd45fee70a0a2e76b8dd388f10fd8f0 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 22:43:37 +0200 Subject: [PATCH 04/10] perf(fillground): bulk-fill empty underground sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --fillground writes STONE to every block from MIN_Y+1 to ground_y-3 per column. For each fresh section the very first STONE write promotes BlockStorage::Uniform(AIR) -> Full(Vec), allocating 4 KiB. On a 250 km^2 world that is ~3 fully-buried sections per chunk times ~977k chunks times 4 KiB = ~11 GB of transient heap that compact_sections() later collapses back to Uniform at save time. Add a per-chunk pre-pass that, when the chunk is fully inside both the xzbbox and the rotated bbox, computes the highest section whose entire 16-block y span sits strictly below ground_y - 3 for every column, and sets each empty section in [MIN_SECTION_Y, top_buried] directly to Uniform(STONE). The per-column path then raises its y_min to (top_buried + 1) * 16 so it only walks the boundary section. Sections with pre-existing content (e.g. a deep bridge pier) are detected as non-empty, skipped, and the per-column path takes over via the existing skip_existing branch — final block content is bit-for-bit identical to the previous behavior. 4 new unit tests cover empty chunk, occupied chunk, below-min request, and second-call behavior. All 141 tests pass; clippy clean. --- src/ground_generation.rs | 42 +++++++++++- src/world_editor/common.rs | 137 +++++++++++++++++++++++++++++++++++++ src/world_editor/mod.rs | 14 +++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/src/ground_generation.rs b/src/ground_generation.rs index 5d8cee71e..ba1f5c071 100644 --- a/src/ground_generation.rs +++ b/src/ground_generation.rs @@ -176,6 +176,44 @@ pub fn generate_ground_layer( ) }); + // --fillground fast path: bulk-fill fully-buried sections to + // Uniform(STONE) so the per-column loop only walks the boundary + // section. Gated on full bbox coverage so out-of-bbox columns + // don't get stone underneath. + let mut column_fill_y_min = crate::world_editor::MIN_Y + 1; + if args.fillground { + let chunk_fully_in_bbox = chunk_min_x == chunk_x << 4 + && chunk_max_x == (chunk_x << 4) + 15 + && chunk_min_z == chunk_z << 4 + && chunk_max_z == (chunk_z << 4) + 15; + let rotated_in = chunk_fully_in_bbox + && ground.is_in_rotated_bounds(chunk_min_x, chunk_min_z) + && ground.is_in_rotated_bounds(chunk_max_x, chunk_min_z) + && ground.is_in_rotated_bounds(chunk_min_x, chunk_max_z) + && ground.is_in_rotated_bounds(chunk_max_x, chunk_max_z); + if rotated_in { + let min_ground_y: i32 = if let Some(ref cache) = chunk_ground_cache { + cache + .grid + .iter() + .copied() + .min() + .unwrap_or(args.ground_level) + } else { + args.ground_level + }; + // section_top = section_y*16 + 15 <= min_ground_y - 3 + let top_buried = (min_ground_y - 18).div_euclid(16) as i8; + if top_buried >= crate::world_editor::MIN_SECTION_Y { + let all_clean = editor + .bulk_fill_chunk_sections_below(chunk_x, chunk_z, top_buried, STONE); + if all_clean { + column_fill_y_min = (top_buried as i32 + 1) * 16; + } + } + } + } + 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 @@ -1072,13 +1110,13 @@ pub fn generate_ground_layer( } } - // Fill underground with stone + // Fill underground; column_fill_y_min skips already-Uniform sections. if args.fillground { editor.fill_column_absolute( STONE, x, z, - MIN_Y + 1, + column_fill_y_min, ground_y - 3, true, // skip_existing: don't overwrite blocks placed by element processing ); diff --git a/src/world_editor/common.rs b/src/world_editor/common.rs index d88010a3b..6a87ea432 100644 --- a/src/world_editor/common.rs +++ b/src/world_editor/common.rs @@ -7,6 +7,8 @@ use crate::block_definitions::*; /// Minimum Y coordinate in Minecraft (1.18+) pub const MIN_Y: i32 = -64; +/// Lowest section index covering MIN_Y (-64 / 16). +pub const MIN_SECTION_Y: i8 = (MIN_Y / 16) as i8; /// Maximum Y coordinate in Minecraft (data pack maximum: 2031) /// Vanilla limit is 319, but data packs can extend this up to 2031. /// The world editor supports the full range; the elevation system controls @@ -534,6 +536,42 @@ impl WorldToModify { } } + /// Fill empty sections of a chunk up to `section_y_max` with `Uniform(block)`, + /// bypassing the per-cell Uniform(AIR) -> Full(Vec) 4 KiB promotion. + /// Returns true when every section in the range is now Uniform(block); + /// false if any section was occupied and left untouched. + pub fn bulk_fill_chunk_sections_below( + &mut self, + chunk_x: i32, + chunk_z: i32, + section_y_max: i8, + block: Block, + ) -> bool { + if section_y_max < MIN_SECTION_Y { + return true; + } + let region_x = chunk_x >> 5; + let region_z = chunk_z >> 5; + let region = self.regions.entry((region_x, region_z)).or_default(); + let chunk = region + .chunks + .entry((chunk_x & 31, chunk_z & 31)) + .or_default(); + + let mut all_clean = true; + for section_y in MIN_SECTION_Y..=section_y_max { + let section = chunk.sections.entry(section_y).or_default(); + let is_empty = section.properties.is_empty() + && matches!(§ion.storage, BlockStorage::Uniform(b) if *b == AIR); + if is_empty { + section.storage = BlockStorage::Uniform(block); + } else { + all_clean = false; + } + } + all_clean + } + /// Scan every section and collapse any that are entirely one block type /// from `Full(Vec)` back to `Uniform(Block)`, freeing the 4 KiB allocation. pub fn compact_sections(&mut self) { @@ -548,3 +586,102 @@ impl WorldToModify { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bulk_fill_empty_chunk_all_clean() { + let mut world = WorldToModify::default(); + let all_clean = world.bulk_fill_chunk_sections_below(0, 0, -2, STONE); + assert!(all_clean, "fresh chunk should report all sections clean"); + + let region = world.get_region(0, 0).unwrap(); + let chunk = region.get_chunk(0, 0).unwrap(); + // Sections -4, -3, -2 must now exist as Uniform(STONE) + for y in MIN_SECTION_Y..=-2 { + let section = chunk + .sections + .get(&y) + .unwrap_or_else(|| panic!("section {y} should have been created")); + assert!( + matches!(§ion.storage, BlockStorage::Uniform(b) if *b == STONE), + "section {y} should be Uniform(STONE), got {:?}", + std::mem::discriminant(§ion.storage) + ); + assert!( + section.properties.is_empty(), + "section {y} should have no per-cell properties" + ); + } + } + + #[test] + fn bulk_fill_skips_occupied_section() { + let mut world = WorldToModify::default(); + // Pre-place a non-AIR block deep underground (section -2: y=-32..=-17) + // to simulate e.g. a bridge pier. + world.set_block_if_absent(0, -20, 0, COBBLESTONE); + + let all_clean = world.bulk_fill_chunk_sections_below(0, 0, -2, STONE); + assert!( + !all_clean, + "should return false because section -2 was occupied" + ); + + let region = world.get_region(0, 0).unwrap(); + let chunk = region.get_chunk(0, 0).unwrap(); + // Section -4 and -3 should be Uniform(STONE) + for y in [-4i8, -3] { + let section = chunk.sections.get(&y).unwrap(); + assert!( + matches!(§ion.storage, BlockStorage::Uniform(b) if *b == STONE), + "section {y} should be Uniform(STONE)" + ); + } + // Section -2 should be left alone (Full(Vec) with COBBLESTONE at y=-20) + let section = chunk.sections.get(&-2).unwrap(); + assert!( + matches!(§ion.storage, BlockStorage::Full(_)), + "section -2 should still be Full(Vec) (had COBBLESTONE)" + ); + // The pre-existing block must still be there + let local_y = (-20i32 & 15) as u8; + let idx = SectionToModify::index(0, local_y, 0); + assert_eq!( + section.storage.get(idx), + COBBLESTONE, + "pre-existing COBBLESTONE must not be overwritten" + ); + } + + #[test] + fn bulk_fill_below_min_section_is_noop() { + let mut world = WorldToModify::default(); + let all_clean = world.bulk_fill_chunk_sections_below(0, 0, MIN_SECTION_Y - 1, STONE); + assert!(all_clean, "below-min request should be vacuously clean"); + // No region should have been created + assert!(world.get_region(0, 0).is_none()); + } + + #[test] + fn bulk_fill_second_call_treats_existing_stone_as_occupied() { + // The "empty" check is strict Uniform(AIR). A second bulk-fill call + // on already-Uniform(STONE) sections sees them as occupied (returns + // false) but leaves them in their correct final state — calling + // bulk_fill twice is harmless. + let mut world = WorldToModify::default(); + assert!(world.bulk_fill_chunk_sections_below(0, 0, -2, STONE)); + let second = world.bulk_fill_chunk_sections_below(0, 0, -2, STONE); + assert!(!second, "second call sees Uniform(STONE) as occupied"); + let chunk = world.get_region(0, 0).unwrap().get_chunk(0, 0).unwrap(); + for y in MIN_SECTION_Y..=-2 { + let section = chunk.sections.get(&y).unwrap(); + assert!( + matches!(§ion.storage, BlockStorage::Uniform(b) if *b == STONE), + "section {y} should still be Uniform(STONE)" + ); + } + } +} diff --git a/src/world_editor/mod.rs b/src/world_editor/mod.rs index 49e60b5f9..2e420abad 100644 --- a/src/world_editor/mod.rs +++ b/src/world_editor/mod.rs @@ -16,7 +16,7 @@ mod luanti; pub mod bedrock; pub(crate) use common::WorldToModify; -pub use common::MIN_Y; +pub use common::{MIN_SECTION_Y, MIN_Y}; pub(crate) use bedrock::{BedrockSaveError, BedrockWriter}; @@ -1077,6 +1077,18 @@ impl<'a> WorldEditor<'a> { .fill_column(x, z, y_min, y_max, block, skip_existing); } + /// See [`WorldToModify::bulk_fill_chunk_sections_below`]. + pub fn bulk_fill_chunk_sections_below( + &mut self, + chunk_x: i32, + chunk_z: i32, + section_y_max: i8, + block: Block, + ) -> bool { + self.world + .bulk_fill_chunk_sections_below(chunk_x, chunk_z, section_y_max, block) + } + /// Saves all changes made to the world by writing to the appropriate format. /// /// Returns `Err` on I/O failure so callers can abort the generation pipeline From 1631b0dc25d0c8a3bb8eb6207c9663c53eed0b56 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 22:43:59 +0200 Subject: [PATCH 05/10] feat(ores): random vein generation when --fillground is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprinkles vanilla-flavoured ore veins through the underground stone produced by --fillground. Off when --fillground is off (no stone to convert). 7 ore types: COAL, IRON, COPPER (existing IDs 127-130) plus DIAMOND, REDSTONE, LAPIS using IDs 249-251 which were unused dead slots ("red_sand", "red_sandstone", "cactus" — never referenced by any arnis code). Y ranges, vein sizes, and per-chunk densities are simplified vanilla 1.21 distributions. Each chunk is deterministically seeded from (chunk_x, chunk_z, 0xC0DE) so re-generating the same world places ore in the same spots and the seed can't collide with the tree-variant or biome RNGs. Each vein is a short 3D random walk that replaces STONE only — bedrock, air above surface, buildings, water carved by water_depth, and subway tunnels are all left intact, so ore placement is safe in any order relative to those passes. --- src/block_definitions.rs | 10 ++- src/data_processing.rs | 4 ++ src/main.rs | 1 + src/ore_generation.rs | 138 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/ore_generation.rs diff --git a/src/block_definitions.rs b/src/block_definitions.rs index 814fc8f74..aa3e709b5 100644 --- a/src/block_definitions.rs +++ b/src/block_definitions.rs @@ -345,9 +345,9 @@ impl Block { 246 => "potted_red_tulip", 247 => "potted_dandelion", 248 => "potted_blue_orchid", - 249 => "red_sand", - 250 => "red_sandstone", - 251 => "cactus", + 249 => "diamond_ore", + 250 => "redstone_ore", + 251 => "lapis_ore", 252 => "gray_concrete_powder", 253 => "cyan_terracotta", 254 => "black_wool", @@ -1039,6 +1039,10 @@ pub const MANGROVE_LOG: Block = Block::new(0); pub const MANGROVE_LEAVES: Block = Block::new(233); pub const AZALEA_LEAVES: Block = Block::new(234); +pub const DIAMOND_ORE: Block = Block::new(249); +pub const REDSTONE_ORE: Block = Block::new(250); +pub const LAPIS_ORE: Block = Block::new(251); + /// Maps a block to a stair variant in the same colour family. #[inline] pub fn get_stair_block_for_material(material: Block) -> Block { diff --git a/src/data_processing.rs b/src/data_processing.rs index 8c1f5f4f9..8f85f1b17 100644 --- a/src/data_processing.rs +++ b/src/data_processing.rs @@ -417,6 +417,10 @@ pub fn generate_world_with_options( &building_footprints, )?; + if args.fillground { + crate::ore_generation::generate_ores(&mut editor, &xzbbox); + } + // Carve depth into ESA water cells (water_areas.rs only covers OSM polygons). crate::water_depth::carve_lc_water_pass( &mut editor, diff --git a/src/main.rs b/src/main.rs index ad0633706..49e99d0b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ mod luanti_block_map; mod map_renderer; mod map_transformation; mod models_3d; +mod ore_generation; mod osm_parser; mod overture; #[cfg(feature = "gui")] diff --git a/src/ore_generation.rs b/src/ore_generation.rs new file mode 100644 index 000000000..86220f857 --- /dev/null +++ b/src/ore_generation.rs @@ -0,0 +1,138 @@ +//! Random ore veins for the stone produced by `--fillground`. + +use crate::block_definitions::{ + Block, COAL_ORE, COPPER_ORE, DIAMOND_ORE, GOLD_ORE, IRON_ORE, LAPIS_ORE, REDSTONE_ORE, STONE, +}; +use crate::coordinate_system::cartesian::XZBBox; +use crate::deterministic_rng::coord_rng; +use crate::progress::emit_gui_progress_update; +use crate::world_editor::WorldEditor; +use colored::Colorize; +use rand::Rng; + +struct OreDef { + block: Block, + y_min: i32, + y_max: i32, + vein_min: u32, + vein_max: u32, + /// Sampled as uniform 0..=2*avg, giving the requested mean. + avg_veins_per_chunk: u32, +} + +const ORES: &[OreDef] = &[ + OreDef { + block: COAL_ORE, + y_min: -20, + y_max: 50, + vein_min: 8, + vein_max: 17, + avg_veins_per_chunk: 8, + }, + OreDef { + block: IRON_ORE, + y_min: -50, + y_max: 30, + vein_min: 5, + vein_max: 9, + avg_veins_per_chunk: 6, + }, + OreDef { + block: COPPER_ORE, + y_min: -16, + y_max: 30, + vein_min: 8, + vein_max: 12, + avg_veins_per_chunk: 4, + }, + OreDef { + block: LAPIS_ORE, + y_min: -50, + y_max: 10, + vein_min: 4, + vein_max: 7, + avg_veins_per_chunk: 2, + }, + OreDef { + block: GOLD_ORE, + y_min: -50, + y_max: -10, + vein_min: 5, + vein_max: 9, + avg_veins_per_chunk: 3, + }, + OreDef { + block: REDSTONE_ORE, + y_min: -60, + y_max: -10, + vein_min: 5, + vein_max: 10, + avg_veins_per_chunk: 4, + }, + OreDef { + block: DIAMOND_ORE, + y_min: -60, + y_max: -30, + vein_min: 4, + vein_max: 7, + avg_veins_per_chunk: 1, + }, +]; + +/// Place ore veins across every chunk in `xzbbox`. Only meaningful when +/// `--fillground` populated the underground; caller gates the call. +pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { + println!("{} Sprinkling ore veins...", "[6b/7]".bold()); + emit_gui_progress_update(89.0, "Sprinkling ore veins..."); + + let min_chunk_x = xzbbox.min_x() >> 4; + let max_chunk_x = xzbbox.max_x() >> 4; + let min_chunk_z = xzbbox.min_z() >> 4; + let max_chunk_z = xzbbox.max_z() >> 4; + + for chunk_x in min_chunk_x..=max_chunk_x { + for chunk_z in min_chunk_z..=max_chunk_z { + // Salt 0xC0DE so this RNG can't collide with tree-variant or biome RNGs. + let mut rng = coord_rng(chunk_x, chunk_z, 0xC0DE); + + for ore in ORES { + let max_veins = ore.avg_veins_per_chunk * 2; + let n = rng.random_range(0..=max_veins); + for _ in 0..n { + let cx = (chunk_x << 4) + rng.random_range(0..16); + let cz = (chunk_z << 4) + rng.random_range(0..16); + let cy = rng.random_range(ore.y_min..=ore.y_max); + let size = rng.random_range(ore.vein_min..=ore.vein_max); + place_vein(editor, ore.block, cx, cy, cz, size, &mut rng); + } + } + } + } +} + +// 3D random walk that replaces STONE only — any other block ends the placement +// at that step (next step continues from the new position). +fn place_vein( + editor: &mut WorldEditor, + block: Block, + x: i32, + y: i32, + z: i32, + size: u32, + rng: &mut impl Rng, +) { + let (mut cx, mut cy, mut cz) = (x, y, z); + for _ in 0..size { + if editor.check_for_block_absolute(cx, cy, cz, Some(&[STONE]), None) { + editor.set_block_absolute(block, cx, cy, cz, None, None); + } + match rng.random_range(0..6) { + 0 => cx += 1, + 1 => cx -= 1, + 2 => cy += 1, + 3 => cy -= 1, + 4 => cz += 1, + _ => cz -= 1, + } + } +} From f613122b2e2641915b6204be5c31a04634e61a14 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 22:44:24 +0200 Subject: [PATCH 06/10] style: trim verbose comments across recent additions Single-line // only, drop multi-line // blocks, remove section dividers and over-detailed doc comments per project style. --- src/biome.rs | 43 ++------------ src/element_processing/tree.rs | 102 ++++++--------------------------- src/world_editor/java.rs | 3 +- 3 files changed, 25 insertions(+), 123 deletions(-) diff --git a/src/biome.rs b/src/biome.rs index be906abed..9b5761ef1 100644 --- a/src/biome.rs +++ b/src/biome.rs @@ -1,10 +1,4 @@ //! Land-cover-driven biome assignment for Java Anvil chunks (1.18+). -//! -//! Each section in a chunk stores biomes as a 4×4×4 grid (64 cells) referencing -//! a per-section palette of biome IDs. The data array is omitted entirely when -//! the palette has only one entry, which is the common case for arnis chunks -//! that sit inside a uniform land-cover region. See -//! . use crate::coordinate_system::cartesian::XZPoint; use crate::ground::Ground; @@ -16,11 +10,6 @@ use fastnbt::{LongArray, Value}; use std::collections::HashMap; /// Map an ESA WorldCover class to a Minecraft biome ID. -/// -/// `lat_deg` is the absolute world-center latitude; tree cover narrows to -/// `taiga` above ~55° and to `jungle` below ~23.5°. `water_dist` is the LC -/// distance-to-shore — large open water becomes `ocean`, anything narrower -/// stays `river`. pub fn biome_for_class(lc: u8, lat_deg: f64, water_dist: u8) -> &'static str { let abs_lat = lat_deg.abs(); match lc { @@ -47,25 +36,14 @@ pub fn biome_for_class(lc: u8, lat_deg: f64, water_dist: u8) -> &'static str { LC_WETLAND => "minecraft:swamp", LC_MANGROVES => "minecraft:mangrove_swamp", LC_MOSS => "minecraft:taiga", - // LC == 0 (no data) or any unknown value _ => "minecraft:plains", } } -/// A precomputed biome compound the same chunk can share across all of its -/// sections. The fastnbt `Value` is cloned per-section at NBT-build time. pub type ChunkBiomeNbt = Value; -/// Build the `biomes` compound for one chunk. -/// -/// Samples land cover at a 4×4 grid (one sample per biome cell, centred in -/// each 4-block footprint) and packs it into the Anvil 1.18+ palette+data -/// layout. The resulting compound is identical for every section in the -/// chunk because biomes are y-invariant under our 2D land-cover model. -/// -/// When `ground` is `None` (no land cover loaded) or all 16 samples reduce to -/// a single biome, the compound contains just the palette and no data array, -/// keeping memory and NBT size minimal. +/// Build the `biomes` compound for one chunk, sampling LC at a 4x4 grid +/// (4-block resolution) and packing into the Anvil 1.18+ palette+data layout. pub fn build_chunk_biome_nbt( chunk_x: i32, chunk_z: i32, @@ -77,7 +55,6 @@ pub fn build_chunk_biome_nbt( if let Some(g) = ground { for zi in 0..4i32 { for xi in 0..4i32 { - // Sample at the geometric centre of each 4×4-block footprint. let world_x = chunk_x * 16 + xi * 4 + 2; let world_z = chunk_z * 16 + zi * 4 + 2; let coord = XZPoint::new(world_x, world_z); @@ -88,7 +65,6 @@ pub fn build_chunk_biome_nbt( } } - // Dedup palette in first-occurrence order; record per-cell index in parallel. let mut palette: Vec<&'static str> = Vec::with_capacity(4); let mut indices: [u8; 16] = [0; 16]; for (i, &name) in names.iter().enumerate() { @@ -110,7 +86,6 @@ pub fn build_chunk_biome_nbt( ); if palette.len() <= 1 { - // Uniform-biome chunk: data array omitted, all 64 cells default to palette[0]. let mut map = HashMap::with_capacity(1); map.insert("palette".to_string(), palette_value); return Value::Compound(map); @@ -125,8 +100,6 @@ pub fn build_chunk_biome_nbt( Value::Compound(map) } -/// Bits required to index `palette_size` distinct biomes. Biome data has no -/// 4-bit minimum (unlike block_states); a 2-biome chunk uses 1 bit per cell. fn bits_per_index(palette_size: usize) -> u32 { if palette_size <= 1 { 0 @@ -135,11 +108,7 @@ fn bits_per_index(palette_size: usize) -> u32 { } } -/// Pack 64 biome cell indices into i64 longs using the post-1.16 layout -/// (values do NOT straddle long boundaries; remaining bits are zero-padded). -/// -/// `indices_16` holds the 4×4 xz biome map; it is replicated across the 4 -/// y-layers of the section's 4×4×4 grid because LC is two-dimensional. +// Post-1.16 packing: values do not straddle long boundaries. fn pack_biome_indices(indices_16: &[u8; 16], bits: u32) -> Vec { debug_assert!((1..=6).contains(&bits)); let bits = bits as usize; @@ -149,7 +118,7 @@ fn pack_biome_indices(indices_16: &[u8; 16], bits: u32) -> Vec { let mut longs = vec![0u64; num_longs]; for cell in 0..64usize { - // cell index = y*16 + z*4 + x; xz biomes repeat across y, so xz_idx = cell % 16. + // xz biomes repeat across y, so xz_idx = cell % 16. let xz_idx = cell % 16; let value = (indices_16[xz_idx] as u64) & mask; let long_idx = cell / vals_per_long; @@ -199,7 +168,6 @@ mod tests { #[test] fn pack_three_bit_pads_to_four_longs() { - // bits=3, vals_per_long = 64/3 = 21, num_longs = ceil(64/21) = 4 let indices = [4u8; 16]; let longs = pack_biome_indices(&indices, 3); assert_eq!(longs.len(), 4); @@ -211,7 +179,7 @@ mod tests { match nbt { Value::Compound(map) => { assert!(map.contains_key("palette")); - assert!(!map.contains_key("data")); // uniform → no data array + assert!(!map.contains_key("data")); } _ => panic!("expected compound"), } @@ -222,7 +190,6 @@ mod tests { assert_eq!(biome_for_class(LC_TREE_COVER, 0.0, 0), "minecraft:jungle"); assert_eq!(biome_for_class(LC_TREE_COVER, 40.0, 0), "minecraft:forest"); assert_eq!(biome_for_class(LC_TREE_COVER, 60.0, 0), "minecraft:taiga"); - // Hemisphere symmetry assert_eq!(biome_for_class(LC_TREE_COVER, -60.0, 0), "minecraft:taiga"); } diff --git a/src/element_processing/tree.rs b/src/element_processing/tree.rs index e98718114..85eda216e 100644 --- a/src/element_processing/tree.rs +++ b/src/element_processing/tree.rs @@ -6,9 +6,7 @@ use rand::Rng; type Coord = (i32, i32, i32); -// ─── Round patterns ──────────────────────────────────────────────────────── -// Concentric rings added on top of the central trunk column to bulk up the canopy. - +// Concentric rings added on top of the trunk column to bulk up the canopy. #[rustfmt::skip] const ROUND1_PATTERN: [Coord; 8] = [ (-2, 0, 0), @@ -53,10 +51,7 @@ const ROUND3_PATTERN: [Coord; 12] = [ const ROUND_PATTERNS: [&[Coord]; 3] = [&ROUND1_PATTERN, &ROUND2_PATTERN, &ROUND3_PATTERN]; -// ─── Leaves-fill data per (species, variant) ────────────────────────────── -// Each entry is a y-axis range describing a "column" of leaves around the trunk. - -// Oak — five variants for shape variety in dense forests. +// Leaves-fill data per (species, variant): y-axis columns around the trunk. const OAK_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 3, 0), (-1, 9, 0)), ((1, 3, 0), (1, 9, 0)), @@ -279,8 +274,6 @@ const WILLOW_LEAVES_FILL: [(Coord, Coord); 5] = [ ((0, 7, 0), (0, 8, 0)), ]; -// ─── Geometry ────────────────────────────────────────────────────────────── - const MAX_CANOPY_RADIUS: i32 = 3; #[derive(Clone, Copy)] @@ -301,24 +294,19 @@ pub enum TreeType { Mangrove, } -// Per-instance, per-shape tree configuration. pub struct Tree { log_block: Block, log_height: i32, leaves_block: Block, leaves_fill: &'static [(Coord, Coord)], round_ranges: [Vec; 3], - // 0.0..1.0 chance to grow a single horizontal side branch branch_chance: f32, - // Sprinkled accent leaves block (e.g. cherry blossoms inside an oak canopy) accent_block: Option, - // 0..100 percent chance per leaf to be the accent + /// 0..100 percent chance per surface leaf to be the accent block. accent_chance: u8, - // Long willow-style drooping leaf tendrils at canopy edge drooping: bool, } -// Helper used inside create_of_type to keep leaf-placement logic in one place. struct LeafPlacer<'a> { leaves_block: Block, accent_block: Option, @@ -328,24 +316,15 @@ struct LeafPlacer<'a> { } impl LeafPlacer<'_> { - // Inner-canopy leaf: ~4% organic gap, never an accent block. fn place_core(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32) { self.place_with(editor, x, y, z, false); } - // Surface (visible) leaf: ~4% gap, may be the accent block. fn place_surface(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32) { self.place_with(editor, x, y, z, true); } - fn place_with( - &self, - editor: &mut WorldEditor, - x: i32, - y: i32, - z: i32, - allow_accent: bool, - ) { + fn place_with(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32, allow_accent: bool) { if self.check_collision { if let Some(fp) = self.footprints { if fp.contains(x, z) { @@ -353,11 +332,10 @@ impl LeafPlacer<'_> { } } } - let h = (x as i64 as u64) - .wrapping_mul(73856093) + let h = (x as i64 as u64).wrapping_mul(73856093) ^ (y as i64 as u64).wrapping_mul(19349663) ^ (z as i64 as u64).wrapping_mul(83492791); - // ~4% of leaves are skipped — enough texture without carving holes. + // ~4% organic leaf gap. if h % 100 < 4 { return; } @@ -401,18 +379,11 @@ impl Tree { } /// Creates a tree at the specified coordinates. - /// - /// # Arguments - /// * `editor` - The world editor to place blocks - /// * `(x, y, z)` - The base coordinates for the tree - /// * `building_footprints` - Optional bitmap of (x, z) coordinates that are inside buildings. - /// If provided, trees will not be placed at coordinates within this bitmap. pub fn create( editor: &mut WorldEditor, (x, y, z): Coord, building_footprints: Option<&BuildingFootprintBitmap>, ) { - // Deterministic per-coord type pick (salt 0). let mut rng = coord_rng(x, z, 0); let tree_type = match rng.random_range(1..=100) { @@ -443,14 +414,13 @@ impl Tree { tree_type: TreeType, building_footprints: Option<&BuildingFootprintBitmap>, ) { - // Skip if this coordinate is inside a building if let Some(footprints) = building_footprints { if footprints.contains(x, z) { return; } } - // Skip if this coordinate is on a road, path, or other paved surface + // Skip road/path/water surfaces. if editor.check_for_block( x, 0, @@ -476,8 +446,7 @@ impl Tree { blacklist.extend(Self::get_functional_blocks()); blacklist.push(WATER); - // Separate per-coord RNG for shape variation (salt 1). Same coord always - // gets the same variant_idx regardless of which caller stamped the tree. + // Salt 1 keeps the shape RNG independent of the type-pick RNG. let mut shape_rng = coord_rng(x, z, 1); let variant_idx: u32 = shape_rng.random(); @@ -485,12 +454,10 @@ impl Tree { let check_canopy_collision = Self::canopy_might_intersect_building(x, z, building_footprints); - // Resolve trunk base Y once from the trunk's (x,z) position so the - // canopy doesn't warp to follow the hillside terrain. + // One base_y for the whole tree so the canopy doesn't warp to follow terrain. let base_y = editor.get_absolute_y(x, y, z); - // Trunk-height jitter: drawn from bits 8-9 of variant_idx → -1..=+2, - // then clamped so the canopy cap always sits above the trunk top. + // Trunk jitter clamped so the canopy cap always sits above the trunk top. let height_jitter = ((variant_idx >> 8) & 0x3) as i32 - 1; let min_trunk = if tree.log_height == 0 { 0 } else { 2 }; let canopy_top = tree @@ -504,7 +471,6 @@ impl Tree { .max(min_trunk) .min(trunk_cap); - // Place the trunk (skipped for log_height == 0, e.g. Bush). if tree.log_height > 0 { editor.fill_blocks_absolute( tree.log_block, @@ -527,7 +493,7 @@ impl Tree { footprints: building_footprints, }; - // Inner canopy columns — no accent here, so blossoms stay on the surface. + // Inner canopy columns — accent only on the outermost ring (below). for ((i1, j1, k1), (i2, j2, k2)) in tree.leaves_fill { for leaf_x in (x + i1)..=(x + i2) { for leaf_y in (base_y + j1)..=(base_y + j2) { @@ -538,9 +504,7 @@ impl Tree { } } - // Outer rings: only the OUTERMOST non-empty ring is treated as surface - // (so accent blocks like cherry blossoms don't land on inner rings that - // sit visually inside the visible canopy edge). + // Only the outermost non-empty ring gets surface (accent-eligible) leaves. let outermost_ring_idx: Option = tree .round_ranges .iter() @@ -566,7 +530,6 @@ impl Tree { } } - // Side branch — gives the tree asymmetric character. let branch_roll = ((variant_idx >> 16) & 0xFF) as f32 / 255.0; if branch_roll < tree.branch_chance && trunk_height >= 5 { let (dx, dz) = match (variant_idx >> 24) & 0x3 { @@ -587,26 +550,20 @@ impl Tree { Some(&blacklist), ); } - // Tapered leaf cluster (octahedron, Manhattan ≤ 2) — rounder than a 3×3×3 cube. + // Tapered cluster (Manhattan <= 2): rounder than a 3x3x3 cube. let tip_x = x + dx * 2; let tip_z = z + dz * 2; for ddx in -1i32..=1 { for ddy in -1i32..=1 { for ddz in -1i32..=1 { if ddx.abs() + ddy.abs() + ddz.abs() <= 2 { - placer.place_surface( - editor, - tip_x + ddx, - branch_y + ddy, - tip_z + ddz, - ); + placer.place_surface(editor, tip_x + ddx, branch_y + ddy, tip_z + ddz); } } } } } - // Willow droops — eight cardinal+diagonal columns of hanging leaves. if tree.drooping { let droop_dirs: [(i32, i32); 8] = [ (2, 0), @@ -717,7 +674,6 @@ impl Tree { self } - // ─── Oak variants ────────────────────────────────────────────────────── fn oak_standard() -> Self { Self::make( OAK_LOG, @@ -739,11 +695,7 @@ impl Tree { 10, OAK_LEAVES, &OAK_LEAVES_FILL_TALL_SLIM, - [ - (7..=11).rev().collect(), - (8..=10).rev().collect(), - vec![], - ], + [(7..=11).rev().collect(), (8..=10).rev().collect(), vec![]], ) .with_branch(0.40) } @@ -773,9 +725,8 @@ impl Tree { ) } + // Standard oak silhouette with a guaranteed side branch (the branch is the asymmetry). fn oak_lopsided() -> Self { - // Standard oak silhouette with a guaranteed side branch — the branch - // is the asymmetry, no need to deform the canopy into a rectangle. Self::make( OAK_LOG, 8, @@ -790,7 +741,6 @@ impl Tree { .with_branch(1.0) } - // ─── Spruce variants ─────────────────────────────────────────────────── fn spruce_standard() -> Self { Self::make( SPRUCE_LOG, @@ -821,7 +771,6 @@ impl Tree { ) } - // ─── Birch variants ──────────────────────────────────────────────────── fn birch_standard() -> Self { Self::make( BIRCH_LOG, @@ -854,7 +803,6 @@ impl Tree { ) } - // ─── Dark oak variants ───────────────────────────────────────────────── fn dark_oak_standard() -> Self { Self::make( DARK_OAK_LOG, @@ -895,7 +843,6 @@ impl Tree { ) } - // ─── Jungle variants ─────────────────────────────────────────────────── fn jungle_standard() -> Self { Self::make( JUNGLE_LOG, @@ -922,7 +869,6 @@ impl Tree { .with_branch(0.60) } - // ─── Acacia variants ─────────────────────────────────────────────────── fn acacia_standard() -> Self { Self::make( ACACIA_LOG, @@ -949,7 +895,6 @@ impl Tree { .with_branch(0.45) } - // ─── Cherry variants ─────────────────────────────────────────────────── fn cherry_standard() -> Self { Self::make( CHERRY_LOG, @@ -980,7 +925,6 @@ impl Tree { .drooping() } - // ─── Tall oak variants ───────────────────────────────────────────────── fn tall_oak_standard() -> Self { Self::make( OAK_LOG, @@ -1007,7 +951,6 @@ impl Tree { .with_branch(0.60) } - // ─── Pine variants ───────────────────────────────────────────────────── fn pine_standard() -> Self { Self::make( SPRUCE_LOG, @@ -1028,9 +971,8 @@ impl Tree { ) } - // ─── New species ─────────────────────────────────────────────────────── + // log_height == 0 → no trunk placed. fn bush() -> Self { - // log_height == 0 → no trunk placed Self::make( OAK_LOG, 0, @@ -1041,7 +983,6 @@ impl Tree { } fn azalea_bush() -> Self { - // Small flowering shrub using azalea leaves Self::make( OAK_LOG, 0, @@ -1057,17 +998,13 @@ impl Tree { 5, OAK_LEAVES, &WILLOW_LEAVES_FILL, - [ - (4..=6).rev().collect(), - (4..=5).rev().collect(), - vec![5], - ], + [(4..=6).rev().collect(), (4..=5).rev().collect(), vec![5]], ) .drooping() } + // Oak silhouette with cherry-pink blossom accents on the outermost ring. fn flowering_oak() -> Self { - // Oak silhouette with cherry-pink blossom accents (~18% of leaves). Self::make( OAK_LOG, 8, @@ -1247,4 +1184,3 @@ impl Tree { ] } } - diff --git a/src/world_editor/java.rs b/src/world_editor/java.rs index 43128886b..f33ec560f 100644 --- a/src/world_editor/java.rs +++ b/src/world_editor/java.rs @@ -189,8 +189,7 @@ impl<'a> WorldEditor<'a> { // World-center latitude drives temperature-based biome variants (taiga // vs forest vs jungle) at chunk-build time. Cheap to recompute. - let center_lat = - (self.llbbox.min().lat() + self.llbbox.max().lat()) * 0.5; + let center_lat = (self.llbbox.min().lat() + self.llbbox.max().lat()) * 0.5; let ground_ref = self.ground.as_deref(); // First pass: write all chunks that have content From 20fc824d606980252acb469dc64444a616b50b88 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 23:02:28 +0200 Subject: [PATCH 07/10] fix(ores): pass whitelist to set_block_absolute so STONE actually gets overwritten The previous ore placement called set_block_absolute(ore, x, y, z, None, None) which silently no-ops over any non-AIR block. set_block_with_properties_absolute at mod.rs:892 routes to an else { false } branch when there is an existing block and no filters are given. So every vein step that landed on stone was a no-op. Passing Some(&[STONE]) as the whitelist makes the existing-block path match and write. The earlier check_for_block_absolute pre-gate is kept because, when the target cell is AIR, the same function's else { true } branch would otherwise write ore into the air above the surface. --- src/ore_generation.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ore_generation.rs b/src/ore_generation.rs index 86220f857..5050deeff 100644 --- a/src/ore_generation.rs +++ b/src/ore_generation.rs @@ -110,8 +110,7 @@ pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { } } -// 3D random walk that replaces STONE only — any other block ends the placement -// at that step (next step continues from the new position). +// Whitelist on set_block_absolute is required to overwrite STONE; pre-check filters AIR. fn place_vein( editor: &mut WorldEditor, block: Block, @@ -124,7 +123,7 @@ fn place_vein( let (mut cx, mut cy, mut cz) = (x, y, z); for _ in 0..size { if editor.check_for_block_absolute(cx, cy, cz, Some(&[STONE]), None) { - editor.set_block_absolute(block, cx, cy, cz, None, None); + editor.set_block_absolute(block, cx, cy, cz, Some(&[STONE]), None); } match rng.random_range(0..6) { 0 => cx += 1, From 3a29f869b130d7e4260f37a42c01034c2c0c0537 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 23:42:06 +0200 Subject: [PATCH 08/10] feat(ores): ground-relative depths, remove copper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor each ore's Y range to the chunk's local ground level (sampled once at chunk centre) instead of fixed absolute Y. Mountains now carry the full ore column inside their bulk; flat terrain keeps a vanilla-like underground distribution. One get_ground_level call per chunk — under 10 ms across a 250 km^2 world. Drop COPPER_ORE Also: tighten the bulk_fill_chunk_sections_below doc comment to match the implementation (a section already Uniform(block) is treated as occupied and counts against the "all_clean" return) per code review. --- src/ore_generation.rs | 57 ++++++++++++++++++++------------------ src/world_editor/common.rs | 8 +++--- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/ore_generation.rs b/src/ore_generation.rs index 5050deeff..adc85b1d9 100644 --- a/src/ore_generation.rs +++ b/src/ore_generation.rs @@ -1,19 +1,21 @@ //! Random ore veins for the stone produced by `--fillground`. use crate::block_definitions::{ - Block, COAL_ORE, COPPER_ORE, DIAMOND_ORE, GOLD_ORE, IRON_ORE, LAPIS_ORE, REDSTONE_ORE, STONE, + Block, COAL_ORE, DIAMOND_ORE, GOLD_ORE, IRON_ORE, LAPIS_ORE, REDSTONE_ORE, STONE, }; use crate::coordinate_system::cartesian::XZBBox; use crate::deterministic_rng::coord_rng; use crate::progress::emit_gui_progress_update; -use crate::world_editor::WorldEditor; +use crate::world_editor::{WorldEditor, MIN_Y}; use colored::Colorize; use rand::Rng; struct OreDef { block: Block, - y_min: i32, - y_max: i32, + /// Shallowest depth below local ground level (e.g. 3 = 3 blocks under surface). + depth_min: i32, + /// Deepest depth below local ground level. + depth_max: i32, vein_min: u32, vein_max: u32, /// Sampled as uniform 0..=2*avg, giving the requested mean. @@ -23,64 +25,57 @@ struct OreDef { const ORES: &[OreDef] = &[ OreDef { block: COAL_ORE, - y_min: -20, - y_max: 50, + depth_min: 3, + depth_max: 45, vein_min: 8, vein_max: 17, avg_veins_per_chunk: 8, }, OreDef { block: IRON_ORE, - y_min: -50, - y_max: 30, + depth_min: 3, + depth_max: 60, vein_min: 5, vein_max: 9, avg_veins_per_chunk: 6, }, - OreDef { - block: COPPER_ORE, - y_min: -16, - y_max: 30, - vein_min: 8, - vein_max: 12, - avg_veins_per_chunk: 4, - }, OreDef { block: LAPIS_ORE, - y_min: -50, - y_max: 10, + depth_min: 25, + depth_max: 55, vein_min: 4, vein_max: 7, avg_veins_per_chunk: 2, }, OreDef { block: GOLD_ORE, - y_min: -50, - y_max: -10, + depth_min: 40, + depth_max: 60, vein_min: 5, vein_max: 9, avg_veins_per_chunk: 3, }, OreDef { block: REDSTONE_ORE, - y_min: -60, - y_max: -10, + depth_min: 45, + depth_max: 65, vein_min: 5, vein_max: 10, avg_veins_per_chunk: 4, }, OreDef { block: DIAMOND_ORE, - y_min: -60, - y_max: -30, + depth_min: 50, + depth_max: 65, vein_min: 4, vein_max: 7, avg_veins_per_chunk: 1, }, ]; -/// Place ore veins across every chunk in `xzbbox`. Only meaningful when -/// `--fillground` populated the underground; caller gates the call. +/// Place ore veins across every chunk in `xzbbox`. Y ranges are anchored to +/// the chunk's local ground level so mountains carry ore inside their bulk +/// instead of all veins collapsing to the lowest world layer. pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { println!("{} Sprinkling ore veins...", "[6b/7]".bold()); emit_gui_progress_update(89.0, "Sprinkling ore veins..."); @@ -92,16 +87,24 @@ pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { for chunk_x in min_chunk_x..=max_chunk_x { for chunk_z in min_chunk_z..=max_chunk_z { + // One ground sample at chunk centre — cheap, and ore depth bands + // smear over many blocks anyway so per-cell ground accuracy doesn't matter. + let ground_y = editor.get_ground_level((chunk_x << 4) + 8, (chunk_z << 4) + 8); // Salt 0xC0DE so this RNG can't collide with tree-variant or biome RNGs. let mut rng = coord_rng(chunk_x, chunk_z, 0xC0DE); for ore in ORES { + let y_min = (ground_y - ore.depth_max).max(MIN_Y + 1); + let y_max = (ground_y - ore.depth_min).max(MIN_Y + 1); + if y_min > y_max { + continue; + } let max_veins = ore.avg_veins_per_chunk * 2; let n = rng.random_range(0..=max_veins); for _ in 0..n { let cx = (chunk_x << 4) + rng.random_range(0..16); let cz = (chunk_z << 4) + rng.random_range(0..16); - let cy = rng.random_range(ore.y_min..=ore.y_max); + let cy = rng.random_range(y_min..=y_max); let size = rng.random_range(ore.vein_min..=ore.vein_max); place_vein(editor, ore.block, cx, cy, cz, size, &mut rng); } diff --git a/src/world_editor/common.rs b/src/world_editor/common.rs index 6a87ea432..05d7d14e4 100644 --- a/src/world_editor/common.rs +++ b/src/world_editor/common.rs @@ -536,10 +536,10 @@ impl WorldToModify { } } - /// Fill empty sections of a chunk up to `section_y_max` with `Uniform(block)`, - /// bypassing the per-cell Uniform(AIR) -> Full(Vec) 4 KiB promotion. - /// Returns true when every section in the range is now Uniform(block); - /// false if any section was occupied and left untouched. + /// Fill empty (Uniform(AIR), no properties) sections of a chunk up to + /// `section_y_max` with `Uniform(block)`. Returns true only if every section + /// in the range was empty and got filled; false if any was already occupied + /// (including sections that are already `Uniform(block)`). pub fn bulk_fill_chunk_sections_below( &mut self, chunk_x: i32, From dc8f4bd70816556321038c7ea8b82b044d81d09d Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Tue, 26 May 2026 23:48:27 +0200 Subject: [PATCH 09/10] style: trim comments to single lines --- src/ore_generation.rs | 7 +------ src/world_editor/common.rs | 6 ++---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/ore_generation.rs b/src/ore_generation.rs index adc85b1d9..7225c4db1 100644 --- a/src/ore_generation.rs +++ b/src/ore_generation.rs @@ -73,9 +73,7 @@ const ORES: &[OreDef] = &[ }, ]; -/// Place ore veins across every chunk in `xzbbox`. Y ranges are anchored to -/// the chunk's local ground level so mountains carry ore inside their bulk -/// instead of all veins collapsing to the lowest world layer. +/// Place ore veins across every chunk; Y is relative to local ground. pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { println!("{} Sprinkling ore veins...", "[6b/7]".bold()); emit_gui_progress_update(89.0, "Sprinkling ore veins..."); @@ -87,10 +85,7 @@ pub fn generate_ores(editor: &mut WorldEditor, xzbbox: &XZBBox) { for chunk_x in min_chunk_x..=max_chunk_x { for chunk_z in min_chunk_z..=max_chunk_z { - // One ground sample at chunk centre — cheap, and ore depth bands - // smear over many blocks anyway so per-cell ground accuracy doesn't matter. let ground_y = editor.get_ground_level((chunk_x << 4) + 8, (chunk_z << 4) + 8); - // Salt 0xC0DE so this RNG can't collide with tree-variant or biome RNGs. let mut rng = coord_rng(chunk_x, chunk_z, 0xC0DE); for ore in ORES { diff --git a/src/world_editor/common.rs b/src/world_editor/common.rs index 05d7d14e4..3dd8ff9f1 100644 --- a/src/world_editor/common.rs +++ b/src/world_editor/common.rs @@ -536,10 +536,8 @@ impl WorldToModify { } } - /// Fill empty (Uniform(AIR), no properties) sections of a chunk up to - /// `section_y_max` with `Uniform(block)`. Returns true only if every section - /// in the range was empty and got filled; false if any was already occupied - /// (including sections that are already `Uniform(block)`). + /// Fill empty (Uniform(AIR)) sections of a chunk up to `section_y_max` with + /// `Uniform(block)`. Returns true only if every section in the range was empty. pub fn bulk_fill_chunk_sections_below( &mut self, chunk_x: i32, From d486b5a481b9cbec4f6fc4e46d002425dca9fd40 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Wed, 27 May 2026 00:11:42 +0200 Subject: [PATCH 10/10] fix: address CI clippy + leaf duplicates + Bedrock leaf persistence - biome.rs: drop redundant `as u64` cast (c is already u64; -D warnings in CI flagged clippy::unnecessary_cast). - tree.rs: remove duplicate `(0, _, -1)` column from SPRUCE_LEAVES_FILL_* and PINE_LEAVES_FILL_* (carried over from the original SPRUCE_LEAVES_FILL pattern). The duplicate produced the same leaves twice; trimming to 5 entries matches the oak/birch shape and removes wasted writes. - bedrock_block_map.rs: add explicit persistent_bit + update_bit states for mangrove_leaves and azalea_leaves (matching the cherry_leaves pattern) so Bedrock exports don't decay the new leaves. cargo clippy --all-targets --all-features -- -D warnings clean. --- src/bedrock_block_map.rs | 18 ++++++++++++++++++ src/biome.rs | 2 +- src/element_processing/tree.rs | 15 +++++---------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/bedrock_block_map.rs b/src/bedrock_block_map.rs index 395357c7f..289f7f081 100644 --- a/src/bedrock_block_map.rs +++ b/src/bedrock_block_map.rs @@ -229,6 +229,24 @@ pub fn to_bedrock_block(block: Block) -> BedrockBlock { ], ), + // Mangrove leaves with persistence (1.19+) + "mangrove_leaves" => BedrockBlock::with_states( + "mangrove_leaves", + vec![ + ("persistent_bit", BedrockBlockStateValue::Bool(true)), + ("update_bit", BedrockBlockStateValue::Bool(false)), + ], + ), + + // Azalea leaves with persistence (1.17+) + "azalea_leaves" => BedrockBlock::with_states( + "azalea_leaves", + vec![ + ("persistent_bit", BedrockBlockStateValue::Bool(true)), + ("update_bit", BedrockBlockStateValue::Bool(false)), + ], + ), + // Stone slab (bottom half by default) "stone_slab" => BedrockBlock::with_states( "stone_block_slab", diff --git a/src/biome.rs b/src/biome.rs index 9b5761ef1..46eee0a4f 100644 --- a/src/biome.rs +++ b/src/biome.rs @@ -152,7 +152,7 @@ mod tests { } let longs = pack_biome_indices(&indices, 1); assert_eq!(longs.len(), 1); - let expected: u64 = (0..64u64).fold(0, |acc, c| acc | (((c % 2) as u64) << c)); + let expected: u64 = (0..64u64).fold(0, |acc, c| acc | ((c % 2) << c)); assert_eq!(longs[0] as u64, expected); } diff --git a/src/element_processing/tree.rs b/src/element_processing/tree.rs index 85eda216e..bca57f69f 100644 --- a/src/element_processing/tree.rs +++ b/src/element_processing/tree.rs @@ -85,29 +85,26 @@ const OAK_LEAVES_FILL_COMPACT: [(Coord, Coord); 5] = [ ]; // Spruce — three variants. Conifer cone shape with cross pattern. -const SPRUCE_LEAVES_FILL_STANDARD: [(Coord, Coord); 6] = [ +const SPRUCE_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 3, 0), (-1, 10, 0)), ((0, 3, -1), (0, 10, -1)), ((1, 3, 0), (1, 10, 0)), - ((0, 3, -1), (0, 10, -1)), ((0, 3, 1), (0, 10, 1)), ((0, 11, 0), (0, 11, 0)), ]; -const SPRUCE_LEAVES_FILL_TOWERING: [(Coord, Coord); 6] = [ +const SPRUCE_LEAVES_FILL_TOWERING: [(Coord, Coord); 5] = [ ((-1, 4, 0), (-1, 13, 0)), ((0, 4, -1), (0, 13, -1)), ((1, 4, 0), (1, 13, 0)), - ((0, 4, -1), (0, 13, -1)), ((0, 4, 1), (0, 13, 1)), ((0, 14, 0), (0, 14, 0)), ]; -const SPRUCE_LEAVES_FILL_SQUAT: [(Coord, Coord); 6] = [ +const SPRUCE_LEAVES_FILL_SQUAT: [(Coord, Coord); 5] = [ ((-1, 2, 0), (-1, 7, 0)), ((0, 2, -1), (0, 7, -1)), ((1, 2, 0), (1, 7, 0)), - ((0, 2, -1), (0, 7, -1)), ((0, 2, 1), (0, 7, 1)), ((0, 8, 0), (0, 8, 0)), ]; @@ -231,20 +228,18 @@ const TALL_OAK_LEAVES_FILL_GIANT: [(Coord, Coord); 5] = [ ]; // Pine — two variants. Narrow conifer using spruce blocks. -const PINE_LEAVES_FILL_STANDARD: [(Coord, Coord); 6] = [ +const PINE_LEAVES_FILL_STANDARD: [(Coord, Coord); 5] = [ ((-1, 5, 0), (-1, 12, 0)), ((0, 5, -1), (0, 12, -1)), ((1, 5, 0), (1, 12, 0)), - ((0, 5, -1), (0, 12, -1)), ((0, 5, 1), (0, 12, 1)), ((0, 13, 0), (0, 13, 0)), ]; -const PINE_LEAVES_FILL_TALL: [(Coord, Coord); 6] = [ +const PINE_LEAVES_FILL_TALL: [(Coord, Coord); 5] = [ ((-1, 6, 0), (-1, 15, 0)), ((0, 6, -1), (0, 15, -1)), ((1, 6, 0), (1, 15, 0)), - ((0, 6, -1), (0, 15, -1)), ((0, 6, 1), (0, 15, 1)), ((0, 16, 0), (0, 16, 0)), ];