diff --git a/src/data_processing.rs b/src/data_processing.rs index 8a28a959f..bbfd91803 100644 --- a/src/data_processing.rs +++ b/src/data_processing.rs @@ -1132,7 +1132,7 @@ pub fn generate_world_with_options( }; let mut signage_ctx: Option> = (world_format == WorldFormat::JavaAnvil) - .then(|| signage::build_context(&elements, args, llbbox, &xzbbox, first_decal_id)) + .then(|| signage::build_context(&elements, args, llbbox, first_decal_id)) .flatten() .map(Arc::new); if let (Some(t), Some(_)) = (signage_start, signage_ctx.as_ref()) { @@ -1321,14 +1321,22 @@ pub fn generate_world_with_options( // Pre-build a bitmap of every (x, z) block coordinate covered by a rendered // road or path surface. Uses the same Bresenham + block_range geometry as // generate_highways_internal, so the bitmap is a 1:1 match of what gets placed. - // Amenity processors use this for O(1) nearest-road-block lookups. - let road_mask = Arc::new(highways::collect_road_surface_coords( + // Amenity processors use this for O(1) nearest-road-block lookups. The carriageways + // among them come in the same pass: trees keep their trunks out of them, mapped + // street trees included, and leave more headroom over them than over a footway. + let (road_mask, carriageway) = highways::collect_road_and_carriageway_coords( &elements, &editor, &clip_bbox, args.scale, args.water.water_detail, - )); + ); + let road_mask = Arc::new(road_mask); + let carriageway = Arc::new(if carriageway.is_empty() { + CoordinateBitmap::new_empty() + } else { + carriageway + }); let barrier_clearance = barriers::RoadClearance::build(&elements, &editor, args.scale, Arc::clone(&road_mask)); @@ -1343,6 +1351,8 @@ pub fn generate_world_with_options( }; editor.set_sealed_surface(Arc::clone(&sealed_surface)); + editor.set_carriageway(Arc::clone(&carriageway)); + let mut mapped_trunks = crate::trees::mapped::MappedTrunks::collect(&elements, args.scale); let crown_r = crate::element_processing::tree::crown_probe_blocks(args.scale); mapped_trunks.measure_typical_height(|x, z| editor.canopy_top_m(x, z, crown_r).flatten()); @@ -1873,6 +1883,7 @@ pub fn generate_world_with_options( tile_editor.set_tree_pack(Arc::clone(tp)); } tile_editor.set_sealed_surface(Arc::clone(&sealed_surface)); + tile_editor.set_carriageway(Arc::clone(&carriageway)); tile_editor.set_mapped_trunks(Arc::clone(&mapped_trunks)); tile_editor.set_mapped_cranes(Arc::clone(&mapped_cranes)); tile_editor.set_clear_corridors(Arc::clone(&clear_corridors)); @@ -2458,6 +2469,7 @@ pub fn generate_world_with_options( editor.release_sealed_surface(); } drop(sealed_surface); + drop(carriageway); // The barrier clearance shares the road mask, so it goes first. drop(barrier_clearance); drop(road_mask); diff --git a/src/element_processing/highways.rs b/src/element_processing/highways.rs index 43d0237bc..157287084 100644 --- a/src/element_processing/highways.rs +++ b/src/element_processing/highways.rs @@ -3575,21 +3575,8 @@ pub(crate) fn highway_block_range( block_range } -/// Collect all (x, z) coordinates that are covered by any rendered road or path -/// surface. The returned bitmap has 1 for every block that the highway renderer -/// places as a road/path surface and 0 everywhere else. -/// -/// Geometry is computed identically to `generate_highways_internal`: -/// - Bresenham line between each consecutive pair of OSM nodes -/// - Expanded by `block_range` in both axes (same value as the renderer uses) -/// - `area=yes` ways, indoor ways, negative-level ways, and pure node types -/// (street_lamp, crossing, bus_stop) are excluded, matching the renderer's -/// early-return guards. -/// -/// This lets `get_nearest_road_block` in `amenities.rs` or other processors do a single O(1) bitmap lookup -/// instead of live `get_ground_level` + `check_for_block_absolute` world scans. -/// -/// `water_detail` drops the water cells of short crossings, which the renderer then skips. +/// The road half of `collect_road_and_carriageway_coords`. +#[cfg(test)] pub fn collect_road_surface_coords( elements: &[ProcessedElement], editor: &WorldEditor, @@ -3597,26 +3584,7 @@ pub fn collect_road_surface_coords( scale: f64, water_detail: crate::water_detail::WaterDetail, ) -> CoordinateBitmap { - collect_highway_surface_coords(elements, Some(editor), xzbbox, scale, water_detail, |_| { - true - }) -} - -/// Vehicular carriageways only (no footways, cycleways, paths, steps or pedestrian -/// streets). Signage uses this to keep posts on the sidewalk but off the road. -pub fn collect_carriageway_coords( - elements: &[ProcessedElement], - xzbbox: &XZBBox, - scale: f64, -) -> CoordinateBitmap { - collect_highway_surface_coords( - elements, - None, - xzbbox, - scale, - Default::default(), - is_carriageway, - ) + collect_road_and_carriageway_coords(elements, editor, xzbbox, scale, water_detail).0 } /// True for highway types that carry vehicles rather than people on foot, bike or horse. @@ -3681,16 +3649,55 @@ pub(crate) fn tunnel_renders_at_surface( !renders_as_highway_tunnel(way) || editor.is_some_and(|e| !tunnel_bore_fits(e, way, scale)) } -/// Shared stamping loop for the road-surface bitmaps; `include` filters by highway type. -fn collect_highway_surface_coords( +/// Collect all (x, z) coordinates that are covered by any rendered road or path +/// surface, and in a second bitmap the carriageways among them, in one pass over the +/// roads. The first bitmap has 1 for every block that the highway renderer +/// places as a road/path surface and 0 everywhere else. +/// +/// Geometry is computed identically to `generate_highways_internal`: +/// - Bresenham line between each consecutive pair of OSM nodes +/// - Expanded by `block_range` in both axes (same value as the renderer uses) +/// - `area=yes` ways, indoor ways, negative-level ways, and pure node types +/// (street_lamp, crossing, bus_stop) are excluded, matching the renderer's +/// early-return guards. +/// +/// This lets `get_nearest_road_block` in `amenities.rs` or other processors do a single O(1) bitmap lookup +/// instead of live `get_ground_level` + `check_for_block_absolute` world scans. +/// +/// `water_detail` drops the water cells of short crossings, which the renderer then skips. +pub fn collect_road_and_carriageway_coords( + elements: &[ProcessedElement], + editor: &WorldEditor, + xzbbox: &XZBBox, + scale: f64, + water_detail: crate::water_detail::WaterDetail, +) -> (CoordinateBitmap, CoordinateBitmap) { + let mut roads = CoordinateBitmap::new(xzbbox); + let mut carriageways = CoordinateBitmap::new(xzbbox); + let editor = Some(editor); + stamp_highway_surfaces( + elements, + editor, + scale, + water_detail, + |carriageway, x, z| { + roads.set(x, z); + if carriageway { + carriageways.set(x, z); + } + }, + ); + (roads, carriageways) +} + +/// Hands `stamp` every road and path surface cell, with whether its way carries vehicles. +fn stamp_highway_surfaces( elements: &[ProcessedElement], editor: Option<&WorldEditor>, - xzbbox: &XZBBox, scale: f64, water_detail: crate::water_detail::WaterDetail, - include: impl Fn(&str) -> bool, -) -> CoordinateBitmap { - let mut bitmap = CoordinateBitmap::new(xzbbox); + mut stamp: impl FnMut(bool, i32, i32), +) { // A way that meets a bridge is its ramp or its approach, and keeps its surface. let drowning = editor.filter(|_| water_detail.drowns_crossing(scale, &HashMap::new())); let bridge_nodes: HashSet<(i32, i32)> = match drowning { @@ -3718,12 +3725,13 @@ fn collect_highway_surface_coords( let Some(highway_type) = surface_highway_type(way) else { continue; }; - if !include(highway_type) || !tunnel_renders_at_surface(way, editor, scale) { + if !tunnel_renders_at_surface(way, editor, scale) { continue; } // Use the same block_range the renderer uses for this highway type let block_range = highway_block_range(highway_type, &way.tags, scale); + let carriageway = is_carriageway(highway_type); let drowned = drowning.filter(|_| { water_detail.drowns_crossing(scale, &way.tags) && !way.nodes.iter().any(|n| bridge_nodes.contains(&(n.x, n.z))) @@ -3753,13 +3761,11 @@ fn collect_highway_surface_coords( if skip_water && drowned.is_some_and(|e| fills(e, bx + dx, bz + dz)) { continue; } - bitmap.set(bx + dx, bz + dz); + stamp(carriageway, bx + dx, bz + dz); } } } } - - bitmap } /// Collect all (x, z) coordinates covered by highways tagged diff --git a/src/element_processing/signage.rs b/src/element_processing/signage.rs index face63108..29c8cfc21 100644 --- a/src/element_processing/signage.rs +++ b/src/element_processing/signage.rs @@ -4,7 +4,6 @@ use crate::args::{Args, SignageLevel}; use crate::block_definitions::*; use crate::bresenham::bresenham_line; -use crate::coordinate_system::cartesian::XZBBox; use crate::coordinate_system::geographic::LLBBox; use crate::decals::font; use crate::decals::region::SignRegion; @@ -12,7 +11,7 @@ use crate::decals::{pictograms, DecalKey, DecalRegistry, ShieldStyle, TextStyle, use crate::element_processing::building_facade::FacadeAnchor; use crate::element_processing::buildings::is_underground_building; use crate::element_processing::highways::{ - collect_carriageway_coords, highway_block_range, is_carriageway, surface_highway_type, + highway_block_range, is_carriageway, surface_highway_type, }; use crate::element_processing::{get_nearest_non_road_block, get_nearest_road_block}; use crate::floodfill_cache::{BuildingFootprintBitmap, RoadMaskBitmap}; @@ -54,8 +53,6 @@ pub struct SignageContext { /// Node id -> which way its stop, give-way or crossing sign faces. pub node_headings: HashMap, pub scale: f64, - /// Vehicular road surfaces; posts stand beside these (sidewalks are fine). - pub carriageway: RoadMaskBitmap, /// Placement tally for the end-of-run summary. report: Mutex, } @@ -77,15 +74,12 @@ impl SignageContext { intersections: IntersectionIndex::new(), node_headings: HashMap::new(), scale: 1.0, - carriageway: RoadMaskBitmap::new_empty(), report: Mutex::new(PlacementReport::new()), } } - /// Frees what only element placement reads: the carriageway mask, a bit per world - /// column, and the node sign headings. + /// Frees what only element placement reads: the node sign headings. pub fn release_placement_masks(&mut self) { - self.carriageway = RoadMaskBitmap::new_empty(); self.node_headings = HashMap::new(); } @@ -989,14 +983,12 @@ pub fn build_context( elements: &[ProcessedElement], args: &Args, llbbox: LLBBox, - xzbbox: &XZBBox, first_map_id: i32, ) -> Option { let level = args.signage; if !level.enabled() { return None; } - let carriageway = collect_carriageway_coords(elements, xzbbox, args.scale); let lat = (llbbox.min().lat() + llbbox.max().lat()) / 2.0; let lon = (llbbox.min().lng() + llbbox.max().lng()) / 2.0; let region = SignRegion::detect(lat, lon); @@ -1025,7 +1017,6 @@ pub fn build_context( intersections, node_headings, scale: args.scale, - carriageway, report: Mutex::new(PlacementReport::new()), }) } @@ -1460,7 +1451,7 @@ pub fn generate_power_signage( return; }; let (x, z) = (node.x, node.z); - if ctx.carriageway.contains(x, z) || !editor.owns(x, z) { + if editor.on_carriageway(x, z) || !editor.owns(x, z) { return; } let ground = editor.get_absolute_y(x, 0, z); @@ -1525,7 +1516,7 @@ pub fn generate_parking_signage( return; }; let (x, z) = (node.x, node.z); - if ctx.carriageway.contains(x, z) || !editor.owns(x, z) { + if editor.on_carriageway(x, z) || !editor.owns(x, z) { return; } let ground = editor.get_absolute_y(x, 0, z); @@ -1755,7 +1746,7 @@ fn place_roadside_sign( ) }) .find_map(|(sx, sz)| { - post_ground(editor, ctx, footprints, sx, sz, POST_SHAFT + 1).map(|g| (sx, sz, g)) + post_ground(editor, footprints, sx, sz, POST_SHAFT + 1).map(|g| (sx, sz, g)) }) else { return; @@ -1812,7 +1803,7 @@ fn place_street_name_post( }) .map(|(dx, dz)| (node.x + dx, node.z + dz)) .find_map(|(px, pz)| { - post_ground(editor, ctx, footprints, px, pz, POST_SHAFT + 2).map(|g| (px, pz, g)) + post_ground(editor, footprints, px, pz, POST_SHAFT + 2).map(|g| (px, pz, g)) }) else { return; @@ -1870,8 +1861,11 @@ pub fn generate_highway_node_signage( let (px, pz) = match kerb { Some(p) => p, // The node sits on the carriageway; the post goes to the nearest kerb. - None if ctx.carriageway.contains(x, z) => { - match get_nearest_non_road_block(x, z, 10, &ctx.carriageway) { + None if editor.on_carriageway(x, z) => { + match editor + .carriageway() + .and_then(|roads| get_nearest_non_road_block(x, z, 10, roads)) + { Some(p) => p, None => return, } @@ -1928,11 +1922,11 @@ fn kerb_beside( }; let edge = (1..=12).find(|&k| { let (cx, cz) = cell(k); - !ctx.carriageway.contains(cx, cz) + !editor.on_carriageway(cx, cz) })?; (edge..edge + 4) .map(cell) - .find(|&(cx, cz)| post_ground(editor, ctx, footprints, cx, cz, POST_SHAFT + 1).is_some()) + .find(|&(cx, cz)| post_ground(editor, footprints, cx, cz, POST_SHAFT + 1).is_some()) } /// Unit vector to the kerb that traffic travelling along `dir` keeps to. @@ -1948,13 +1942,12 @@ fn kerb_side(region: SignRegion, (dx, dz): (f64, f64)) -> (f64, f64) { /// carriageway, outside buildings and water, in free cells. fn post_ground( editor: &WorldEditor, - ctx: &SignageContext, footprints: &BuildingFootprintBitmap, x: i32, z: i32, height: i32, ) -> Option { - if ctx.carriageway.contains(x, z) || footprints.contains(x, z) || editor.is_lc_water(x, z) { + if editor.on_carriageway(x, z) || footprints.contains(x, z) || editor.is_lc_water(x, z) { return None; } let ground = editor.get_absolute_y(x, 0, z); @@ -2011,7 +2004,7 @@ pub fn generate_railway_node_signage( return; } // Metro entrance / tram stop: a lit logo on all four faces of a post. - if ctx.carriageway.contains(x, z) { + if editor.on_carriageway(x, z) { return; } let Some(head) = traffic_sign_post(editor, x, ground, z) else { @@ -2808,7 +2801,7 @@ mod tests { carriageway.set(x, z); } } - ctx.carriageway = carriageway; + editor.set_carriageway(std::sync::Arc::new(carriageway)); ctx.node_headings.insert( 7, NodeSignHeading { diff --git a/src/element_processing/tree.rs b/src/element_processing/tree.rs index 9b0fbc34a..cb6331c4f 100644 --- a/src/element_processing/tree.rs +++ b/src/element_processing/tree.rs @@ -289,6 +289,76 @@ const YOUNG_TREE_M: (f64, f64) = (4.0, 8.0); const MAPPED_TREE_M: f64 = 9.0; // Sentinel for a canopy column with no building under it. const NO_ROOF: i32 = i32::MIN; +/// Clear height a tree leaves over a road for vehicles, as street trees are pruned. +const CARRIAGEWAY_HEADROOM_M: f64 = 4.0; +/// Clear height over the rest of the sealed surface: footways, paths, squares, pitches, +/// managed turf and other paving. +const FOOTWAY_HEADROOM_M: f64 = 2.5; +/// Furthest a mapped tree moves to get its trunk out of the road. +const MAPPED_SHIFT_M: f64 = 8.0; +/// Fewest blocks it may move, so it can always step off a road drawn at its narrowest. +const MAPPED_SHIFT_MIN: i32 = 3; +/// Road, path and paving surfaces, and water: ground a scattered tree never stands on. +const PAVING_OR_WATER: &[Block] = &[ + BLACK_CONCRETE, + GRAY_CONCRETE_POWDER, + CYAN_TERRACOTTA, + GRAY_CONCRETE, + LIGHT_GRAY_CONCRETE, + DIRT_PATH, + SMOOTH_STONE, + WATER, +]; + +/// Blocks of clear air a tree leaves above this column's surface, or `None` on open ground. +/// Players and vehicles keep their size at any scale, so it never grows past the 1:1 one. +pub(crate) fn headroom(editor: &WorldEditor, x: i32, z: i32) -> Option { + if !editor.surface_is_sealed(x, z) { + return None; + } + let m = if editor.on_carriageway(x, z) { + CARRIAGEWAY_HEADROOM_M + } else { + FOOTWAY_HEADROOM_M + }; + Some(((m * editor.scale().min(1.0)).round() as i32).max(2)) +} + +/// True if a tree block at this height would stand in the clear space over a road or paving. +pub(crate) fn in_headroom(editor: &WorldEditor, x: i32, y: i32, z: i32) -> bool { + headroom(editor, x, z).is_some_and(|h| y <= editor.get_ground_level(x, z) + h) +} + +/// The nearest spot to (x, z), at most `reach` blocks off, where `fits` finds something. +/// Open ground wins over paving at the same distance, so a street tree moved out of the +/// road lands on the verge where there is one and on the sidewalk otherwise. +fn nearest_fit( + editor: &WorldEditor, + x: i32, + z: i32, + reach: i32, + mut fits: impl FnMut(i32, i32) -> Option, +) -> Option<(i32, i32, T)> { + // Most trees fit where they are asked for, and need no list of spots around it. + if let Some(t) = fits(x, z) { + return Some((x, z, t)); + } + let mut offsets: Vec<(i32, i32)> = (-reach..=reach) + .flat_map(|dx| (-reach..=reach).map(move |dz| (dx, dz))) + .filter(|&(dx, dz)| (dx, dz) != (0, 0) && dx * dx + dz * dz <= reach * reach) + .collect(); + offsets.sort_by_key(|&(dx, dz)| { + ( + dx * dx + dz * dz, + editor.surface_is_sealed(x + dx, z + dz), + dx, + dz, + ) + }); + offsets + .into_iter() + .find_map(|(dx, dz)| fits(x + dx, z + dz).map(|t| (x + dx, z + dz, t))) +} #[derive(Clone, Copy)] pub enum TreeType { @@ -397,7 +467,7 @@ impl LeafPlacer<'_> { // Apex cap: the lone center-column cover over the trunk; skips the organic // gap (but not the footprint gate) so the log is never left exposed. fn place_apex_cap(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32) { - if self.blocked(x, y, z) { + if self.blocked(x, y, z) || in_headroom(editor, x, y, z) { return; } editor.set_block_absolute(self.leaves_block, x, y, z, None, None); @@ -410,6 +480,7 @@ impl LeafPlacer<'_> { fn place_with(&self, editor: &mut WorldEditor, x: i32, y: i32, z: i32, allow_accent: bool) { // A leaf resting right on water reads as floating. if self.blocked(x, y, z) + || in_headroom(editor, x, y, z) || editor.check_for_block_absolute(x, y - 1, z, Some(&[WATER]), None) { return; @@ -526,6 +597,63 @@ impl Tree { false } + /// Blocks a mapped tree may move to get its trunk out of the road. + fn mapped_reach(editor: &WorldEditor) -> i32 { + ((MAPPED_SHIFT_M * editor.scale()).round() as i32).max(MAPPED_SHIFT_MIN) + } + + /// Base row of a model anchored at (x, z): at most 2 above the lowest ground under + /// it, so it doesn't hover on a cliff. + fn schem_base( + editor: &WorldEditor, + schem: &crate::trees::schematic::Schematic, + x: i32, + z: i32, + y: i32, + ) -> i32 { + let half = (schem.width.max(schem.length) / 2).clamp(1, 6); + let center = editor.get_absolute_y(x, y, z); + let mut fpmin = center; + for (dx, dz) in [ + (-half, 0), + (half, 0), + (0, -half), + (0, half), + (-half, -half), + (half, half), + (-half, half), + (half, -half), + ] { + fpmin = fpmin.min(editor.get_absolute_y(x + dx, y, z + dz)); + } + center.min(fpmin + 2) + } + + /// Whether a trunk may stand at (x, z): not on water, a building, under a bridge deck + /// or in a cleared lane, and off paving unless it is a mapped tree. A slot or a moved + /// tree can be a few blocks off its request, so this runs again where it lands; without + /// the footprint check a tree asked for beside a building gets snapped onto its roof. + fn spot_ok( + editor: &WorldEditor, + x: i32, + z: i32, + allow_on_paved: bool, + building_footprints: Option<&BuildingFootprintBitmap>, + bridge_surface: Option<&BridgeSurfaceMap>, + ) -> bool { + let ground: &[Block] = if allow_on_paved { + &[WATER] + } else { + PAVING_OR_WATER + }; + !(editor.is_lc_water(x, z) + || editor.in_clear_corridor(x, z) + || building_footprints.is_some_and(|f| f.contains(x, z)) + || editor.check_for_block(x, 0, z, Some(ground)) + || bridge_surface.is_some_and(|b| b.contains(x, z)) + || (!allow_on_paved && editor.surface_is_sealed(x, z))) + } + /// The species mix for a scattered tree, keyed on the column. fn random_type(x: i32, z: i32) -> TreeType { let mut rng = coord_rng(x, z, 0); @@ -683,21 +811,32 @@ impl Tree { let forbidden_ground: &[Block] = if allow_on_paved { &[WATER] } else { - &[ - BLACK_CONCRETE, - GRAY_CONCRETE_POWDER, - CYAN_TERRACOTTA, - GRAY_CONCRETE, - LIGHT_GRAY_CONCRETE, - DIRT_PATH, - SMOOTH_STONE, - WATER, - ] + PAVING_OR_WATER }; if editor.check_for_block(x, 0, z, Some(forbidden_ground)) { return; } + // A mapped street tree may stand on the sidewalk but not in the road, where a road + // drawn wider than the real one often puts it. A one-block trunk moves out here; a + // model moves further down, once its whole trunk and roots are known. + let pack = editor.tree_pack(); + let one_block_trunk = editor.scale() < MICRO_TREE_MAX_SCALE || pack.is_none(); + let (x, z) = if allow_on_paved && one_block_trunk && editor.on_carriageway(x, z) { + let reach = Self::mapped_reach(editor); + match nearest_fit(editor, x, z, reach, |ax, az| { + (editor.owns(ax, az) + && !editor.on_carriageway(ax, az) + && Self::spot_ok(editor, ax, az, true, building_footprints, bridge_surface)) + .then_some(()) + }) { + Some((ax, az, ())) => (ax, az), + None => return, + } + } else { + (x, z) + }; + let mut blacklist: Vec = Vec::new(); blacklist.extend(Self::get_building_wall_blocks()); blacklist.extend(Self::get_building_floor_blocks()); @@ -729,26 +868,48 @@ impl Tree { } // Schematic pack active: stamp a model instead of the procedural tree (checks above still apply). - if let Some(region) = editor.tree_pack() { - // same paving rule as above; is_lc_water still blocks water - let road_water: &[Block] = if allow_on_paved { - &[WATER] - } else { - &[ - BLACK_CONCRETE, - GRAY_CONCRETE_POWDER, - CYAN_TERRACOTTA, - GRAY_CONCRETE, - LIGHT_GRAY_CONCRETE, - DIRT_PATH, - SMOOTH_STONE, - WATER, - ] - }; + if let Some(region) = pack { let hint = habitat_for_tree_type(tree_type); let elev_y = editor.terrain_level(x, z).unwrap_or(base_y); - let picked = match mapped { + let spot_ok = |sx: i32, sz: i32| { + Self::spot_ok( + editor, + sx, + sz, + allow_on_paved, + building_footprints, + bridge_surface, + ) + }; + // The anchor is the model's middle, and its trunk may be wide, off to one + // side or flared at the roots, so all of its wood low enough to stand in a + // road's headroom has to keep off the road: off any paving for a scattered + // tree, off the carriageway for a mapped one. A model that does not fit + // gives way to one that does, as the data says a tree stands there. + let keep_off = |wx: i32, wz: i32| { + if allow_on_paved { + editor.on_carriageway(wx, wz) + } else { + editor.surface_is_sealed(wx, wz) + } + }; + let fits_at = |ax: i32, az: i32, idx: usize, rot: u8| { + let schem = region.schem(idx); + crate::trees::schematic::low_wood_clear( + editor, + schem, + rot, + ax, + az, + keep_off, + || Self::schem_base(editor, schem, ax, az, y), + ) + }; + let planted = match mapped { Some(m) => { + if !spot_ok(x, z) { + return; + } let eco = editor.ecoregion(x, z); let req = crate::trees::region::MappedRequest { genus: m.genus.as_deref(), @@ -757,7 +918,39 @@ impl Tree { eco, beach: eco.is_some() && beside_beach(editor, x, z), }; - region.pick_mapped(x, z, hint, elev_y, req) + let Some(pick) = region.mapped_pick(x, z, hint, elev_y, req) else { + return; + }; + // Its middle stays out of the road too, so a model without a trunk to + // check does not hang its crown over the carriageway. + let fit = |models: &[usize], ax: i32, az: i32| { + if !editor.owns(ax, az) || editor.on_carriageway(ax, az) || !spot_ok(ax, az) + { + return None; + } + let fits = |i: usize, r: u8| fits_at(ax, az, i, r); + models.iter().find_map(|&i| { + crate::trees::region::turn_to_fit(i, pick.rot, &fits).map(|r| (i, r)) + }) + }; + // As picked where it is mapped; else at the nearest spot off the road as + // its own species, genus or leaf type, slimmer models before moving + // further; only where none of those fits, a tree of its community. + let reach = Self::mapped_reach(editor); + fit(&[pick.model], x, z) + .map(|found| (x, z, found)) + .or_else(|| { + let own = pick.own_models(); + nearest_fit(editor, x, z, reach, |ax, az| fit(&own, ax, az)) + }) + .or_else(|| { + let other = pick.community_models(); + if other.is_empty() { + return None; + } + nearest_fit(editor, x, z, reach, |ax, az| fit(&other, ax, az)) + }) + .map(|(ax, az, (idx, rot))| (ax, az, idx, rot)) } None => { // Read at the slot, not the request, so the hint describes the @@ -767,7 +960,19 @@ impl Tree { z, region.slot_spacing(density_decided), ); + // A slot across a tile seam would put this tile's trunk on the + // neighbour's undergrowth, so the tree keeps to the requested cell. + let (sx, sz) = if editor.owns(hx, hz) { + (hx, hz) + } else { + (x, z) + }; + // Requests from several cells snap to one slot: its first tree stands. + if editor.tree_slot_taken(sx, sz) || !spot_ok(sx, sz) { + return; + } let eco = editor.ecoregion(hx, hz); + let fits = |i: usize, r: u8| fits_at(sx, sz, i, r); let req = crate::trees::region::SlotRequest { want_height: wanted_height_m(editor, hx, hz, None).map(blocks), density_decided, @@ -778,51 +983,17 @@ impl Tree { editor.cover_class(hx, hz), crate::land_cover::LC_WETLAND | crate::land_cover::LC_MANGROVES ), + fits: Some(&fits), }; - region.pick_slot(x, z, hint, elev_y, req) + region + .pick_slot(x, z, hint, elev_y, req) + .map(|(_, _, idx, rot)| (sx, sz, idx, rot)) } }; - if let Some((sx, sz, idx, rot)) = picked { - // A slot across a tile seam would put this tile's trunk on the - // neighbour's undergrowth, so the tree keeps to the requested cell. - let (sx, sz) = if editor.owns(sx, sz) { - (sx, sz) - } else { - (x, z) - }; - // The slot can be a few blocks off (x,z), so every check that - // rejected the request has to run again on the moved trunk. - // Without the footprint one a tree asked for beside a building - // gets snapped onto its roof. - if editor.is_lc_water(sx, sz) - || editor.in_clear_corridor(sx, sz) - || building_footprints.is_some_and(|f| f.contains(sx, sz)) - || editor.check_for_block(sx, 0, sz, Some(road_water)) - || bridge_surface.is_some_and(|b| b.contains(sx, sz)) - || (!allow_on_paved && editor.surface_is_sealed(sx, sz)) - { - return; - } - // Clamp the base to <=2 above the lowest footprint ground so it doesn't hover on a cliff. + if let Some((sx, sz, idx, rot)) = planted { let schem = region.schem(idx); - let half = (schem.width.max(schem.length) / 2).clamp(1, 6); - let center = editor.get_absolute_y(sx, y, sz); - let mut fpmin = center; - for (dx, dz) in [ - (-half, 0), - (half, 0), - (0, -half), - (0, half), - (-half, -half), - (half, half), - (-half, half), - (half, -half), - ] { - fpmin = fpmin.min(editor.get_absolute_y(sx + dx, y, sz + dz)); - } - let slot_base_y = center.min(fpmin + 2); - // Requests from several cells snap to one slot: its first tree stands, - // and a later one would only stamp itself into that trunk. + let slot_base_y = Self::schem_base(editor, schem, sx, sz, y); + // A later request would only stamp itself into the trunk standing there. let trunk_taken = (slot_base_y..=slot_base_y + 2).any(|wy| { editor.check_for_block_absolute( sx, @@ -951,24 +1122,34 @@ impl Tree { }; 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 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); + // A branch reaching low over a road is pruned off whole. + let pruned = + (1..=2).any(|step| in_headroom(editor, x + dx * step, branch_y, z + dz * step)); + if !pruned { + for step in 1..=2 { + editor.set_block_absolute( + tree.log_block, + x + dx * step, + branch_y, + z + dz * step, + None, + Some(&blacklist), + ); + } + // 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, + ); + } } } } @@ -2083,3 +2264,267 @@ mod height_tests { assert!((7..=13).contains(&half), "1:2 gave {half}"); } } + +#[cfg(test)] +mod road_clearance_tests { + use super::*; + use crate::canopy::{CanopyData, CANOPY_NODATA}; + use crate::coordinate_system::cartesian::XZBBox; + use crate::coordinate_system::geographic::LLBBox; + use crate::floodfill_cache::{CoordinateBitmap, SealedSurfaceBitmap}; + use crate::ground::Ground; + use crate::trees::mapped::MappedTree; + use crate::trees::tree_library::SizeFilter; + use crate::trees::tree_pack::TreePackSource; + use std::collections::HashMap; + use std::sync::Arc; + + const SIZE: i32 = 96; + /// A road across the world: carriageway on z 44..=51, a footway beside it on 52..=53. + const ROAD: std::ops::RangeInclusive = 44..=51; + const FOOTWAY: std::ops::RangeInclusive = 52..=53; + + fn editor_with_road(xzbbox: &XZBBox, pack: bool) -> WorldEditor<'_> { + editor_with(xzbbox, pack, true) + } + + fn editor_with(xzbbox: &XZBBox, pack: bool, road: bool) -> WorldEditor<'_> { + editor_at(xzbbox, pack, road, 1.0) + } + + fn editor_at(xzbbox: &XZBBox, pack: bool, road: bool, scale: f64) -> WorldEditor<'_> { + let n = SIZE as usize; + let canopy = CanopyData::from_grid(vec![CANOPY_NODATA; n * n], n, n); + let llbbox = LLBBox::new(48.0, 11.0, 48.001, 11.001).unwrap(); + let mut editor = WorldEditor::new(std::env::temp_dir(), xzbbox, llbbox); + editor.set_ground(Arc::new(Ground::new_flat_canopy_test(canopy, n, n))); + editor.set_ground_origin(0, 0); + editor.set_projection_info("local", scale); + let mut sealed = SealedSurfaceBitmap::new(xzbbox); + let mut carriageway = CoordinateBitmap::new(xzbbox); + for x in 0..SIZE { + for z in ROAD { + sealed.set(x, z); + carriageway.set(x, z); + } + for z in FOOTWAY { + sealed.set(x, z); + } + } + if road { + editor.set_sealed_surface(Arc::new(sealed)); + editor.set_carriageway(Arc::new(carriageway)); + } + if pack { + let src = TreePackSource::embedded("eur"); + let lib = crate::trees::region::RegionLibrary::load( + &src, + 1.0, + 0, + 1.0, + SizeFilter::default(), + false, + ) + .unwrap(); + editor.set_tree_pack(Arc::new(lib)); + } + editor + } + + fn is_wood(b: Block) -> bool { + crate::ground_decoration::WOOD.contains(&b) + } + + /// Tree blocks standing in the headroom over the columns `over` picks. + fn in_the_way(editor: &WorldEditor, over: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32, i32)> { + let mut found = Vec::new(); + for x in 0..SIZE { + for z in 0..SIZE { + let Some(h) = headroom(editor, x, z).filter(|_| over(x, z)) else { + continue; + }; + let ground = editor.get_ground_level(x, z); + for y in ground + 1..=ground + h { + if editor.get_block_absolute(x, y, z).is_some() { + found.push((x, y, z)); + } + } + } + } + found + } + + fn wood_count(editor: &WorldEditor) -> usize { + (0..SIZE) + .flat_map(|x| (0..SIZE).map(move |z| (x, z))) + .flat_map(|(x, z)| (-4..40).map(move |y| (x, y, z))) + .filter(|&(x, y, z)| editor.get_block_absolute(x, y, z).is_some_and(is_wood)) + .count() + } + + #[test] + fn scattered_trees_leave_the_road_and_footway_clear() { + for pack in [false, true] { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let mut editor = editor_with_road(&xzbbox, pack); + for x in (2..SIZE - 2).step_by(3) { + for z in (2..SIZE - 2).step_by(3) { + Tree::create_from_canopy(&mut editor, (x, 1, z), None, None); + } + } + assert!( + wood_count(&editor) > 0, + "pack {pack}: the verges still get trees" + ); + let blocked = in_the_way(&editor, |_, _| true); + assert!( + blocked.is_empty(), + "pack {pack}: tree blocks in the headroom at {:?}", + &blocked[..blocked.len().min(8)] + ); + } + } + + /// Players and vehicles keep their size, so a bigger world does not clear more air. + #[test] + fn headroom_stops_growing_past_one_to_one() { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let road = *ROAD.start(); + let path = *FOOTWAY.start(); + let at = |scale: f64| { + let editor = editor_at(&xzbbox, false, true, scale); + (headroom(&editor, 5, road), headroom(&editor, 5, path)) + }; + assert_eq!(at(1.0), (Some(4), Some(3))); + assert_eq!(at(4.0), at(1.0)); + assert_eq!(at(0.5), (Some(2), Some(2))); + assert_eq!(headroom(&editor_at(&xzbbox, false, true, 1.0), 5, 5), None); + } + + /// A tree a few blocks tall in a scaled-down world keeps its leaves on a sidewalk, + /// and one mapped in the road steps off it, though the road is wider than its reach + /// in metres. + #[test] + fn micro_trees_keep_their_leaves_and_step_off_the_road() { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let mut editor = editor_at(&xzbbox, false, true, 0.2); + let mapped = MappedTree::from_tags(&HashMap::new(), 1); + let on_path = (20, *FOOTWAY.start()); + Tree::create_mapped(&mut editor, (on_path.0, 1, on_path.1), &mapped, None, None); + assert!( + (0..40).any(|y| editor + .get_block_absolute(on_path.0, y, on_path.1) + .is_some_and(|b| !is_wood(b))), + "the sidewalk tree keeps its crown" + ); + let in_road = (60, *ROAD.end() - 2); + Tree::create_mapped(&mut editor, (in_road.0, 1, in_road.1), &mapped, None, None); + let planted: Vec<(i32, i32)> = (in_road.1 - 4..=in_road.1 + 4) + .map(|z| (in_road.0, z)) + .chain((in_road.0 - 4..=in_road.0 + 4).map(|x| (x, in_road.1))) + .filter(|&(x, z)| (0..40).any(|y| editor.get_block_absolute(x, y, z).is_some())) + .collect(); + assert!(!planted.is_empty(), "the tree in the road is kept"); + let trunk_in_road = planted.iter().any(|&(x, z)| { + editor.on_carriageway(x, z) + && (0..40).any(|y| editor.get_block_absolute(x, y, z).is_some_and(is_wood)) + }); + assert!( + !trunk_in_road, + "and its trunk stands off the road: {planted:?}" + ); + } + + /// Blocks standing within `r` of (x, z). + fn blocks_near(editor: &WorldEditor, x: i32, z: i32, r: i32) -> usize { + (x - r..=x + r) + .flat_map(|bx| (z - r..=z + r).map(move |bz| (bx, bz))) + .flat_map(|(bx, bz)| (-4..40).map(move |y| (bx, y, bz))) + .filter(|&(bx, y, bz)| editor.get_block_absolute(bx, y, bz).is_some()) + .count() + } + + /// Where the model a slot picks would put wood in the road, the slot still holds a + /// tree: the model turns or gives way to one that keeps off. + #[test] + fn a_tree_whose_model_reaches_into_the_road_still_stands() { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let mut open = editor_with(&xzbbox, true, false); + let mut beside = editor_with_road(&xzbbox, true); + let requests: Vec<(i32, i32)> = (4..SIZE - 4) + .step_by(5) + .flat_map(|x| [(x, *ROAD.start() - 3), (x, *FOOTWAY.end() + 3)]) + .collect(); + let (mut planted_open, mut planted_beside) = (0, 0); + for &(x, z) in &requests { + let before = blocks_near(&open, x, z, 16); + Tree::create_from_canopy(&mut open, (x, 1, z), None, None); + planted_open += usize::from(blocks_near(&open, x, z, 16) > before); + let before = blocks_near(&beside, x, z, 16); + Tree::create_from_canopy(&mut beside, (x, 1, z), None, None); + planted_beside += usize::from(blocks_near(&beside, x, z, 16) > before); + } + let spilled = (0..SIZE) + .flat_map(|x| ROAD.chain(FOOTWAY).map(move |z| (x, z))) + .filter(|&(x, z)| { + (1..=4).any(|y| open.get_block_absolute(x, y, z).is_some_and(is_wood)) + }) + .count(); + assert!(spilled > 0, "some models picked here reach into the road"); + assert!(planted_open > 0); + // Slimmer stand-ins can leave a neighbour's slot free, so there may be more. + assert!( + planted_beside >= planted_open, + "{planted_beside} trees beside the road, {planted_open} without it" + ); + let blocked = in_the_way(&beside, |_, _| true); + assert!( + blocked.is_empty(), + "tree blocks in the headroom at {blocked:?}" + ); + } + + #[test] + fn a_mapped_tree_in_the_road_moves_out_of_it() { + for pack in [false, true] { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let mut editor = editor_with_road(&xzbbox, pack); + let mapped = MappedTree::from_tags(&HashMap::new(), 1); + // Two blocks in from the road's edge, as a street tree on a road drawn too wide. + for x in (10..SIZE - 10).step_by(12) { + Tree::create_mapped(&mut editor, (x, 1, 50), &mapped, None, None); + } + let in_road = in_the_way(&editor, |x, z| editor.on_carriageway(x, z)); + let wood_in_road: Vec<_> = in_road + .iter() + .filter(|&&(x, y, z)| editor.get_block_absolute(x, y, z).is_some_and(is_wood)) + .collect(); + assert!( + wood_in_road.is_empty(), + "pack {pack}: wood in the road at {:?}", + &wood_in_road[..wood_in_road.len().min(8)] + ); + assert!( + in_road.is_empty(), + "pack {pack}: leaves low over the road at {:?}", + &in_road[..in_road.len().min(8)] + ); + assert!(wood_count(&editor) > 0, "pack {pack}: the trees were kept"); + } + } + + #[test] + fn a_one_block_trunk_moves_to_the_nearest_column_off_the_road() { + let xzbbox = XZBBox::rect_from_min_max(0, 0, SIZE - 1, SIZE - 1).unwrap(); + let mut editor = editor_with_road(&xzbbox, false); + let mapped = MappedTree::from_tags(&HashMap::new(), 1); + Tree::create_mapped(&mut editor, (40, 1, 50), &mapped, None, None); + let trunk = (*ROAD.end() + 1, editor.get_absolute_y(40, 1, 52)); + assert!( + editor + .get_block_absolute(40, trunk.1, trunk.0) + .is_some_and(is_wood), + "the trunk stands on the footway beside the road" + ); + } +} diff --git a/src/floodfill_cache.rs b/src/floodfill_cache.rs index 188107916..da4d0ef17 100644 --- a/src/floodfill_cache.rs +++ b/src/floodfill_cache.rs @@ -306,7 +306,7 @@ impl CoordinateBitmap { pub type BuildingFootprintBitmap = CoordinateBitmap; /// Type alias for the road surface bitmap used by amenity processors. -/// Built by `highways::collect_road_surface_coords` using the same Bresenham + +/// Built by `highways::collect_road_and_carriageway_coords` using the same Bresenham + /// block_range geometry as the renderer, so every placed road/path block coordinate /// is marked as 1 and everything else is 0. pub type RoadMaskBitmap = CoordinateBitmap; diff --git a/src/trees/region.rs b/src/trees/region.rs index 97b7d9b8b..b762dfc78 100644 --- a/src/trees/region.rs +++ b/src/trees/region.rs @@ -248,9 +248,12 @@ fn build_pools( }) } +/// Whether a model, turned a number of quarter turns, fits where it is asked for. +pub type Fits<'a> = &'a dyn Fn(usize, u8) -> bool; + /// What a caller already knows about the slot it is asking for. #[derive(Clone, Copy, Default)] -pub struct SlotRequest { +pub struct SlotRequest<'a> { /// Model height in blocks the slot asks for, when anything measured it. pub want_height: Option, /// The caller fixed the density itself, so skip the pack's grove noise. @@ -263,6 +266,8 @@ pub struct SlotRequest { pub wet_ground: bool, /// Beside a sand beach, where the mix's palms line the shore. pub beach: bool, + /// Which models fit the ground, as beside a road; `None` takes any. + pub fits: Option>, } /// What OSM says about a mapped tree. @@ -276,6 +281,70 @@ pub struct MappedRequest<'a> { pub beach: bool, } +/// What a mapped tree grows as: its own pool, which is its genus, its leaf type or else +/// its habitat's community, and the model picked from it. +pub struct MappedPick<'a> { + lib: &'a RegionLibrary, + own: Cow<'a, Community>, + own_no_palms: bool, + choice: Choice, + /// The habitat's community, where it is not the own pool already. + community: Option<&'a Community>, + no_palms: bool, + x: i32, + z: i32, + height: i32, + /// The model picked for the tree. + pub model: usize, + /// Quarter turns the model is tried in first. + pub rot: u8, +} + +impl MappedPick<'_> { + /// The model picked, then the rest of its species, then the rest of its pool, + /// nearest height and slimmest trunk first. + pub fn own_models(&self) -> Vec { + let mut models = vec![self.model]; + models.extend( + self.lib + .stand_ins(&self.own, &self.choice, self.own_no_palms), + ); + models + } + + /// The habitat community's models in the same order, for where nothing of its own fits. + pub fn community_models(&self) -> Vec { + let Some(c) = self.community else { + return Vec::new(); + }; + let Some(choice) = + self.lib + .choose_in_community(c, self.x, self.z, self.height, self.no_palms) + else { + return Vec::new(); + }; + let mut models = vec![choice.picked]; + models.extend(self.lib.stand_ins(c, &choice, self.no_palms)); + models + } +} + +/// The first turn, from `rot` on, in which model `i` fits. +pub fn turn_to_fit(i: usize, rot: u8, fits: Fits) -> Option { + (0..4).map(|k| (rot + k) % 4).find(|&r| fits(i, r)) +} + +/// What `choose_in_community` settled on, for finding its stand-ins. +struct Choice { + picked: usize, + /// Index of the picked model's species in the community. + species: usize, + /// Height the pick was judged by. + want: i32, + /// Whether the community had a model the size cap allows. + any_capped: bool, +} + pub struct RegionLibrary { realm: String, /// Pack directory, as ecoregion mixes name it. @@ -993,21 +1062,30 @@ impl RegionLibrary { }) } + /// Whether model `i` may be picked: no palm where they are kept out, and within the + /// size cap unless nothing of the community is. + fn candidate(&self, i: usize, no_palms: bool, any_capped: bool) -> bool { + !(no_palms && self.palm[i]) && (!any_capped || self.size_allowed(self.entries[i].1)) + } + /// The model of `c` closest to `want` blocks. The species is rolled among /// those with a model that close, then the trunk width roll picks within it. /// Past the size cap only where nothing under it is left: a tree beats a hole. - fn pick_in_community( + fn choose_in_community( &self, c: &Community, x: i32, z: i32, want: i32, no_palms: bool, - ) -> Option { + ) -> Option { let usable = |i: usize| !(no_palms && self.palm[i]); - let capped = |i: usize| usable(i) && self.size_allowed(self.entries[i].1); - let any_capped = c.species.iter().flatten().any(|&i| capped(i)); - let cand = |i: usize| if any_capped { capped(i) } else { usable(i) }; + let any_capped = c + .species + .iter() + .flatten() + .any(|&i| usable(i) && self.size_allowed(self.entries[i].1)); + let cand = |i: usize| self.candidate(i, no_palms, any_capped); // Nothing under the cap: the community's smallest come closest to it. let want = if any_capped { want @@ -1074,7 +1152,67 @@ impl RegionLibrary { group = pool; } let h = coord_hash(x + 313, z + 727) as usize; - Some(group[h % group.len()]) + Some(Choice { + picked: group[h % group.len()], + species: chosen, + want, + any_capped, + }) + } + + /// What stands in for a pick that does not fit, as beside a road: the rest of its + /// species, then the community's other species, nearest height and slimmest trunk first. + fn stand_ins(&self, c: &Community, choice: &Choice, no_palms: bool) -> Vec { + let cand = |i: usize| i != choice.picked && self.candidate(i, no_palms, choice.any_capped); + let order = |i: &usize| { + ( + (self.entries[*i].0.height - choice.want).abs(), + self.entries[*i].2, + *i, + ) + }; + let mut own: Vec = c.species[choice.species] + .iter() + .copied() + .filter(|&i| cand(i)) + .collect(); + own.sort_unstable_by_key(order); + let mut rest: Vec = c + .species + .iter() + .enumerate() + .filter(|&(k, _)| k != choice.species) + .flat_map(|(_, sp)| sp.iter().copied()) + .filter(|&i| cand(i)) + .collect(); + rest.sort_unstable_by_key(order); + own.extend(rest); + own + } + + /// The model `choose_in_community` picks, and how it is turned. Where it fits in no + /// turn, its stand-ins are tried in order. + #[allow(clippy::too_many_arguments)] + fn pick_in_community( + &self, + c: &Community, + x: i32, + z: i32, + want: i32, + no_palms: bool, + rot: u8, + fits: Option, + ) -> Option<(usize, u8)> { + let choice = self.choose_in_community(c, x, z, want, no_palms)?; + let Some(fits) = fits else { + return Some((choice.picked, rot)); + }; + if let Some(r) = turn_to_fit(choice.picked, rot, fits) { + return Some((choice.picked, r)); + } + self.stand_ins(c, &choice, no_palms) + .into_iter() + .find_map(|i| turn_to_fit(i, rot, fits).map(|r| (i, r))) } /// Choose a community for `habitat_hint`; on a montane cell lowland/wet swap to conifer. @@ -1240,8 +1378,8 @@ impl RegionLibrary { .or_else(|| vanilla.then(|| self.pick_vanilla(mix, want, sx, sz))) } }); - let (community, idx): (&Community, Option) = if let Some(c) = from_mix { - (c, self.pick_in_community(c, sx, sz, height, no_palms)) + let community: &Community = if let Some(c) = from_mix { + c } else if blend >= 97 { let n = self.realm_pack.own; if n == 0 { @@ -1249,19 +1387,16 @@ impl RegionLibrary { } let ci = (coord_hash(sx + 5, sz + 9) % n as u64) as usize; let c = &self.realm_pack.communities[ci]; - let c = if self.fits(c, height, no_palms) { + if self.fits(c, height, no_palms) { c } else { self.pick_community(&self.realm_pack, hint, sx, sz, montane) - }; - (c, self.pick_in_community(c, sx, sz, height, no_palms)) + } } else { let sprinkle = (blend >= 67 && !self.vanilla_pack.is_empty()) .then(|| self.pick_community(&self.vanilla_pack, hint, sx, sz, montane)) .filter(|c| self.fits(c, height, no_palms)); - let c = sprinkle - .unwrap_or_else(|| self.pick_community(&self.realm_pack, hint, sx, sz, montane)); - (c, self.pick_in_community(c, sx, sz, height, no_palms)) + sprinkle.unwrap_or_else(|| self.pick_community(&self.realm_pack, hint, sx, sz, montane)) }; // The grove noise invents clearings, which would thin the same trees // twice when the caller already measured the density. @@ -1272,13 +1407,15 @@ impl RegionLibrary { return None; } } - let idx = idx?; let rot = (coord_hash(sx ^ 0x5bd1, sz ^ 0x9e37) % 4) as u8; + let (idx, rot) = + self.pick_in_community(community, sx, sz, height, no_palms, rot, req.fits)?; Some((sx, sz, idx, rot)) } /// A mapped tree keeps its position and is never thinned. Its genus comes first, /// then the community's species of its leaf type. + #[cfg(test)] pub fn pick_mapped( &self, x: i32, @@ -1287,43 +1424,25 @@ impl RegionLibrary { elev_y: i32, req: MappedRequest, ) -> Option<(i32, i32, usize, u8)> { + self.mapped_pick(x, z, hint, elev_y, req) + .map(|p| (x, z, p.model, p.rot)) + } + + /// What a mapped tree grows as. Its genus comes first, then the community's species + /// of its leaf type, then the community. + pub fn mapped_pick<'a>( + &'a self, + x: i32, + z: i32, + hint: Habitat, + elev_y: i32, + req: MappedRequest, + ) -> Option> { let rot = (coord_hash(x ^ 0x5bd1, z ^ 0x9e37) % 4) as u8; let mix = req.eco.and_then(|e| self.mixes.get(&e.id)); let no_palms = !self.palms_at(mix); let height = self.capped_height(req.want_height.unwrap_or_else(|| self.default_height(x, z))); - let pick = |species: Vec>, no_palms: bool| { - if species.is_empty() { - return None; - } - let pool = Community { - name: String::new(), - habitat: hint, - species, - names: Vec::new(), - genera: Vec::new(), - density: 0, - }; - self.pick_in_community(&pool, x, z, height, no_palms) - }; - let mix_communities = || { - mix.into_iter() - .flat_map(|m| &m.communities) - .map(|&c| &self.realm_pack.communities[c]) - }; - // A mapped genus is what stands there, planted palm or not. - if let Some(genus) = req.genus { - let same = |g: &str| g.eq_ignore_ascii_case(genus); - if let Some(idx) = pick(species_where(mix_communities(), same), false) { - return Some((x, z, idx, rot)); - } - for pack in [&self.realm_pack, &self.vanilla_pack] { - if let Some(idx) = pick(species_where(&pack.communities, same), false) { - return Some((x, z, idx, rot)); - } - } - } - let montane = self.is_montane(elev_y) && crate::ground_generation::value_noise_01(x, z, 64) < 0.6; let want = Want { @@ -1343,24 +1462,73 @@ impl RegionLibrary { let community = beach .or_else(|| mix.and_then(|m| self.pick_from_mix(m, want, x, z, height, no_palms))) .unwrap_or_else(|| self.pick_community(&self.realm_pack, hint, x, z, montane)); - let idx = match req.conifer { - Some(conifer) => { - let of_type = |g: &str| crate::trees::mapped::is_conifer_genus(g) == conifer; - pick(species_where([community], of_type), no_palms) - .or_else(|| pick(species_where(mix_communities(), of_type), no_palms)) - .or_else(|| { - pick( - species_where(&self.realm_pack.communities, of_type), - no_palms, - ) - }) - .or_else(|| self.pick_in_community(community, x, z, height, no_palms)) + let pick = |own: Cow<'a, Community>, own_no_palms: bool, fallback: bool| { + let choice = self.choose_in_community(&own, x, z, height, own_no_palms)?; + Some(MappedPick { + lib: self, + model: choice.picked, + own, + own_no_palms, + choice, + community: fallback.then_some(community), + no_palms, + x, + z, + height, + rot, + }) + }; + let pool = |species: Vec>| { + (!species.is_empty()).then(|| { + Cow::Owned(Community { + name: String::new(), + habitat: hint, + species, + names: Vec::new(), + genera: Vec::new(), + density: 0, + }) + }) + }; + let mix_communities = || { + mix.into_iter() + .flat_map(|m| &m.communities) + .map(|&c| &self.realm_pack.communities[c]) + }; + // A mapped genus is what stands there, planted palm or not. + if let Some(genus) = req.genus { + let same = |g: &str| g.eq_ignore_ascii_case(genus); + let found = pool(species_where(mix_communities(), same)) + .and_then(|p| pick(p, false, true)) + .or_else(|| { + [&self.realm_pack, &self.vanilla_pack] + .into_iter() + .find_map(|pack| { + pool(species_where(&pack.communities, same)) + .and_then(|p| pick(p, false, true)) + }) + }); + if found.is_some() { + return found; } - None => self.pick_in_community(community, x, z, height, no_palms), } - // A mapped tree always stands, even if only a palm is left to stand for it. - .or_else(|| self.pick_in_community(community, x, z, height, false))?; - Some((x, z, idx, rot)) + let of_type = req.conifer.and_then(|conifer| { + let of_type = |g: &str| crate::trees::mapped::is_conifer_genus(g) == conifer; + pool(species_where([community], of_type)) + .and_then(|p| pick(p, no_palms, true)) + .or_else(|| { + pool(species_where(mix_communities(), of_type)) + .and_then(|p| pick(p, no_palms, true)) + }) + .or_else(|| { + pool(species_where(&self.realm_pack.communities, of_type)) + .and_then(|p| pick(p, no_palms, true)) + }) + }); + of_type + .or_else(|| pick(Cow::Borrowed(community), no_palms, false)) + // A mapped tree always stands, even if only a palm is left to stand for it. + .or_else(|| pick(Cow::Borrowed(community), false, false)) } pub fn report(&self) { @@ -1471,6 +1639,77 @@ mod tests { } } + /// A fit check that takes every model changes no pick. One that refuses the turn + /// picked turns the model; one that refuses the model gets a stand-in, the same + /// species first, and only one that refuses everything leaves the slot empty. + #[test] + fn a_model_that_does_not_fit_gives_way_to_one_that_does() { + let src = TreePackSource::embedded("eur"); + let lib = RegionLibrary::load(&src, 1.0, -62, 1.0, SizeFilter::default(), false) + .expect("load eur"); + let species_of = |idx: usize| { + [&lib.realm_pack, &lib.vanilla_pack] + .iter() + .flat_map(|p| &p.communities) + .flat_map(|c| &c.species) + .find(|sp| sp.contains(&idx)) + .cloned() + .unwrap_or_default() + }; + let (mut picks, mut same_species) = (0, 0); + for k in 0..300 { + let (x, z) = (k * 11 % 997 * 5, k * 7 % 991 * 5); + let req = SlotRequest { + density_decided: true, + ..Default::default() + }; + let Some((sx, sz, idx, rot)) = lib.pick_slot(x, z, Habitat::Lowland, 0, req) else { + continue; + }; + picks += 1; + let any = |_: usize, _: u8| true; + let all = SlotRequest { + fits: Some(&any), + ..req + }; + assert_eq!( + lib.pick_slot(x, z, Habitat::Lowland, 0, all), + Some((sx, sz, idx, rot)) + ); + let other_turn = |i: usize, r: u8| !(i == idx && r == rot); + let turned = SlotRequest { + fits: Some(&other_turn), + ..req + }; + assert_eq!( + lib.pick_slot(x, z, Habitat::Lowland, 0, turned), + Some((sx, sz, idx, (rot + 1) % 4)) + ); + let other_model = |i: usize, _: u8| i != idx; + let stand_in = SlotRequest { + fits: Some(&other_model), + ..req + }; + if let Some((_, _, i, _)) = lib.pick_slot(x, z, Habitat::Lowland, 0, stand_in) { + assert_ne!(i, idx); + if species_of(idx).contains(&i) { + same_species += 1; + } + } + let none = |_: usize, _: u8| false; + let nothing = SlotRequest { + fits: Some(&none), + ..req + }; + assert_eq!(lib.pick_slot(x, z, Habitat::Lowland, 0, nothing), None); + } + assert!(picks > 100, "{picks} picks"); + assert!( + same_species * 2 > picks, + "a species' own models stand in first: {same_species} of {picks}" + ); + } + /// Entry indices of every pack species whose genus passes `keep`. fn entries_where(lib: &RegionLibrary, keep: impl Fn(&str) -> bool) -> Vec { [&lib.realm_pack, &lib.vanilla_pack] @@ -1546,6 +1785,37 @@ mod tests { } } + /// Where a mapped lime's model does not fit, every model it may move as is a lime, the + /// one picked first; only after those come the community's, and an untagged tree, + /// whose own pool is its community, has nothing further to fall back on. + #[test] + fn a_mapped_tree_keeps_its_genus_before_its_community() { + let src = TreePackSource::embedded("eur"); + let lib = RegionLibrary::load(&src, 1.0, -62, 1.0, SizeFilter::default(), false).unwrap(); + let tilia = entries_where(&lib, |g| g == "Tilia"); + for k in 0..200 { + let (x, z) = (k * 13, k * 29); + let lime = MappedRequest { + genus: Some("Tilia"), + ..Default::default() + }; + let pick = lib.mapped_pick(x, z, Habitat::Lowland, 0, lime).unwrap(); + let own = pick.own_models(); + assert_eq!(own[0], pick.model); + assert!( + own.iter().all(|i| tilia.contains(i)), + "only limes of its own" + ); + assert!(!pick.community_models().is_empty()); + + let untagged = lib + .mapped_pick(x, z, Habitat::Lowland, 0, MappedRequest::default()) + .unwrap(); + assert!(untagged.own_models().len() > 1); + assert!(untagged.community_models().is_empty()); + } + } + /// Heights of the models a run of slots plants for `want` blocks. fn heights_for(lib: &RegionLibrary, want: Option) -> Vec { let req = SlotRequest { diff --git a/src/trees/schematic.rs b/src/trees/schematic.rs index df3865b90..5befd42fc 100644 --- a/src/trees/schematic.rs +++ b/src/trees/schematic.rs @@ -26,6 +26,8 @@ pub struct Schematic { pub min_log_vy: i32, /// Columns with a leaf in them, what the crown covers seen from above. pub leaf_columns: u32, + /// Every column with wood in it, as (x, z, lowest log y), what has to keep off a road. + pub wood_columns: Vec<(i16, i16, i16)>, } /// Map a Minecraft block-state string to one of our blocks, or `None` for anything we @@ -258,12 +260,30 @@ pub fn load_schem_within( width, height, length, + wood_columns: lowest_wood(&voxels), voxels, min_log_vy, leaf_columns: crown.len() as u32, }) } +/// The lowest log in each column with wood in it, as (x, z, y). +fn lowest_wood(voxels: &[(i16, i16, i16, Block)]) -> Vec<(i16, i16, i16)> { + let mut lowest: HashMap<(i16, i16), i16> = HashMap::new(); + for &(x, y, z, block) in voxels { + if is_log(block) { + lowest + .entry((x, z)) + .and_modify(|v| *v = (*v).min(y)) + .or_insert(y); + } + } + let mut columns: Vec<(i16, i16, i16)> = + lowest.into_iter().map(|((x, z), y)| (x, z, y)).collect(); + columns.sort_unstable(); + columns +} + /// Furthest apart, per axis, two voxels of one tree may be: one air block between still /// reads as the crown. const CROWN_REACH: i32 = 2; @@ -364,6 +384,44 @@ fn is_log(b: Block) -> bool { crate::ground_decoration::WOOD.contains(&b) } +/// True if no wood of the model, anchored at (ax, az) with its base row at `base_y()`, +/// stands in the headroom over a column `keep_off` names. Leaves are trimmed there instead, +/// but a trunk, a root flare or a low branch cut off at the kerb would read as broken. +/// The base is only worked out once some wood stands over such a column. +pub fn low_wood_clear( + editor: &WorldEditor, + schem: &Schematic, + rot: u8, + ax: i32, + az: i32, + keep_off: impl Fn(i32, i32) -> bool, + base_y: impl Fn() -> i32, +) -> bool { + let mut base = None; + let (fw, fl) = if rot & 1 == 0 { + (schem.width, schem.length) + } else { + (schem.length, schem.width) + }; + let (cx, cz) = ((fw - 1) / 2, (fl - 1) / 2); + schem.wood_columns.iter().all(|&(vx, vz, vy)| { + let (rx, rz) = rotate_xz(vx.into(), vz.into(), schem.width, schem.length, rot); + let (wx, wz) = (ax + rx - cx, az + rz - cz); + if !keep_off(wx, wz) { + return true; + } + // A trunk or root column is drawn down to its own ground, wherever the base sits; + // a branch keeps its height. Wood higher in the column is higher still. + let vy = i32::from(vy); + let bottom = if vy <= schem.min_log_vy + ROOT_BASE_VY { + i32::MIN + } else { + *base.get_or_insert_with(&base_y) + vy + }; + !crate::element_processing::tree::in_headroom(editor, wx, bottom, wz) + }) +} + /// Max blocks above the schem floor for a log column to count as a ground-rooted trunk base. const ROOT_BASE_VY: i32 = 2; /// Safety bound on how far a real trunk root may reach down (only bites tile-seam deltas). @@ -430,9 +488,11 @@ pub fn place_schematic_tree( } } // Logs over water are skipped (only root-level logs for predicted ESA water); leaves - // overhang, but not one resting on the surface, which reads as floating. + // overhang, but not one resting on the surface, which reads as floating. Over a + // road or paving they keep above the headroom, as on a pruned street tree. if !is_log(block) - && editor.check_for_block_absolute(wx, base_y + vy - 1, wz, Some(&[WATER]), None) + && (editor.check_for_block_absolute(wx, base_y + vy - 1, wz, Some(&[WATER]), None) + || crate::element_processing::tree::in_headroom(editor, wx, base_y + vy, wz)) { continue; } @@ -533,6 +593,56 @@ pub fn place_schematic_tree( mod tests { use super::*; + /// A wide trunk is judged by all its columns, not the model's middle; a leaf is not wood. + #[test] + fn low_wood_is_checked_on_every_trunk_column() { + use crate::block_definitions::{OAK_LEAVES, OAK_LOG}; + use crate::coordinate_system::cartesian::XZBBox; + use crate::coordinate_system::geographic::LLBBox; + use crate::floodfill_cache::SealedSurfaceBitmap; + use std::sync::Arc; + + let xzbbox = XZBBox::rect_from_min_max(0, 0, 31, 31).unwrap(); + let llbbox = LLBBox::new(54.6, 9.9, 54.61, 9.91).unwrap(); + let mut editor = WorldEditor::new(std::env::temp_dir(), &xzbbox, llbbox); + let mut sealed = SealedSurfaceBitmap::new(&xzbbox); + sealed.set(17, 16); + sealed.set(16, 18); + editor.set_sealed_surface(Arc::new(sealed)); + // 3 x 3 model: a two-column trunk in the middle and on its east side, a leaf to the south. + let voxels = vec![ + (1, 0, 1, OAK_LOG), + (2, 0, 1, OAK_LOG), + (1, 1, 1, OAK_LOG), + (1, 0, 2, OAK_LEAVES), + ]; + let schem = Schematic { + width: 3, + height: 4, + length: 3, + wood_columns: lowest_wood(&voxels), + voxels, + min_log_vy: 0, + leaf_columns: 1, + }; + let paved = |x: i32, z: i32| editor.surface_is_sealed(x, z); + // Anchored at (16, 16) the east trunk column lands on the paving at (17, 16). + assert!(!low_wood_clear(&editor, &schem, 0, 16, 16, paved, || 1)); + // Turned twice, the second trunk column stands west of the middle, on open ground. + assert!(low_wood_clear(&editor, &schem, 2, 16, 16, paved, || 1)); + // Anchored one row south, the leaf sits over the paving at (16, 18) but no wood does. + let under_the_leaf = |x: i32, z: i32| (x, z) == (16, 18); + assert!(low_wood_clear( + &editor, + &schem, + 0, + 16, + 17, + under_the_leaf, + || 1 + )); + } + /// A trunk base left hanging above lower ground goes down to it, even when /// roots below the base hid it from the root pass; a low branch stays a /// branch, and undergrowth under it is cleared. @@ -559,6 +669,7 @@ mod tests { ], min_log_vy: -3, leaf_columns: 0, + wood_columns: Vec::new(), }; editor.set_block_absolute(GRASS, 10, 1, 10, None, None); editor.set_block_absolute(RED_FLOWER, 12, 4, 10, None, None); @@ -711,6 +822,7 @@ mod tests { voxels: vec![(0, 0, 0, JUNGLE_LOG), (0, 1, 0, JUNGLE_LOG)], min_log_vy: 0, leaf_columns: 0, + wood_columns: Vec::new(), }; assert!(!cactus.has_leaves()); let tree = Schematic { @@ -720,6 +832,7 @@ mod tests { voxels: vec![(0, 0, 0, OAK_LOG), (0, 1, 0, OAK_LEAVES)], min_log_vy: 0, leaf_columns: 0, + wood_columns: Vec::new(), }; assert!(tree.has_leaves()); } diff --git a/src/world_editor/mod.rs b/src/world_editor/mod.rs index 7a27c7c53..727415c8d 100644 --- a/src/world_editor/mod.rs +++ b/src/world_editor/mod.rs @@ -241,6 +241,9 @@ pub struct WorldEditor<'a> { /// Columns owned by a man-made ground cover (roads, paths, pitches, courts, /// parking); vegetation stays off them. Shared via Arc with the tile editors. sealed_surface: Option>, + /// The vehicular part of the roads, where trees keep their trunks out and leave + /// more headroom than over a footway. Shared via Arc with the tile editors. + carriageway: Option>, /// Mapped tree trunks, shared via Arc with the tile editors. mapped_trunks: Option>, /// Where `man_made=crane` stands, shared via Arc with the tile editors. @@ -364,6 +367,7 @@ impl<'a> WorldEditor<'a> { ground: None, tree_pack: None, sealed_surface: None, + carriageway: None, mapped_trunks: None, mapped_cranes: None, clear_corridors: None, @@ -437,6 +441,7 @@ impl<'a> WorldEditor<'a> { ground: None, tree_pack: None, sealed_surface: None, + carriageway: None, mapped_trunks: None, mapped_cranes: None, clear_corridors: None, @@ -510,6 +515,7 @@ impl<'a> WorldEditor<'a> { ground: None, tree_pack: None, sealed_surface: None, + carriageway: None, mapped_trunks: None, mapped_cranes: None, clear_corridors: None, @@ -658,10 +664,16 @@ impl<'a> WorldEditor<'a> { self.sealed_surface = Some(mask); } - /// Drops the sealed-surface mask and the mapped trunks once every vegetation - /// pass is done, so neither is carried into the save phase. + /// Sets the carriageway mask (shared across the main and tile editors). + pub fn set_carriageway(&mut self, mask: Arc) { + self.carriageway = Some(mask); + } + + /// Drops the sealed-surface and carriageway masks and the mapped trunks once + /// every vegetation pass is done, so none is carried into the save phase. pub fn release_sealed_surface(&mut self) { self.sealed_surface = None; + self.carriageway = None; self.mapped_trunks = None; } @@ -802,6 +814,18 @@ impl<'a> WorldEditor<'a> { .is_some_and(|m| m.contains(x, z)) } + /// True if a road for vehicles runs over this column (footways, paths and tracks + /// are not counted). + #[inline] + pub fn on_carriageway(&self, x: i32, z: i32) -> bool { + self.carriageway.as_ref().is_some_and(|m| m.contains(x, z)) + } + + /// The carriageway mask, while vegetation and signs are placed. + pub fn carriageway(&self) -> Option<&crate::floodfill_cache::CoordinateBitmap> { + self.carriageway.as_deref() + } + /// ESA land-cover class at (x, z), or 0 where there is no land cover. #[inline] pub fn cover_class(&self, x: i32, z: i32) -> u8 { @@ -974,6 +998,11 @@ impl<'a> WorldEditor<'a> { self.signage = Some(ctx); } + /// Whether a tree already stands in the slot at (x, z). + pub fn tree_slot_taken(&self, x: i32, z: i32) -> bool { + self.tree_slots.contains(&(x, z)) + } + /// Claims the trunk slot (x, z); false when a tree already stands in it. pub fn claim_tree_slot(&mut self, x: i32, z: i32) -> bool { self.tree_slots.insert((x, z))