From 5721c3ad976a0923f6b98a88964f4fb56296dac7 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Wed, 27 May 2026 21:20:31 +0200 Subject: [PATCH 1/3] feat(models_3d): stadium archetype + custom-model pipeline Detects leisure=stadium / building=stadium with size + structural heuristics (>=20k m^2 alone, or >=10k m^2 with inner building=stadium), fetches a generic bowl GLB from arnismc.com (cached), voxelizes it non-uniformly to fit the polygon footprint, and suppresses overlapping inner buildings, pitches, and tracks. Extends voxelize_glb with vertex-color (COLOR_0) support per glTF spec and reserves pure magenta (#FF00FF) as a GLASS sentinel. WorldTransform now supports non-uniform world scaling via with_world_scale_xyz. Consolidates 3DMR / Wikidata / custom orchestration into a single Models3dPipeline; groups Arnis-hosted archetypes under models_3d/custom/ with a shared HTTP+cache client so future archetypes (water towers, wind turbines, etc.) are ~50 lines each. --- src/data_processing.rs | 46 +- src/models_3d/custom/client.rs | 57 ++ src/models_3d/custom/mod.rs | 4 + src/models_3d/custom/stadium.rs | 951 ++++++++++++++++++++++++++++++++ src/models_3d/mod.rs | 9 +- src/models_3d/pipeline.rs | 56 ++ src/models_3d/three_dmr/mod.rs | 2 +- src/models_3d/voxelize.rs | 115 +++- src/models_3d/wikidata/mod.rs | 2 +- 9 files changed, 1185 insertions(+), 57 deletions(-) create mode 100644 src/models_3d/custom/client.rs create mode 100644 src/models_3d/custom/mod.rs create mode 100644 src/models_3d/custom/stadium.rs create mode 100644 src/models_3d/pipeline.rs diff --git a/src/data_processing.rs b/src/data_processing.rs index 8f85f1b17..b7c54221a 100644 --- a/src/data_processing.rs +++ b/src/data_processing.rs @@ -129,43 +129,20 @@ pub fn generate_world_with_options( let pb_batch_size: u64 = (elements_count as u64 / desired_updates).max(1); let mut element_counter: u64 = 0; - // Pre-scan 3DMR-tagged elements first; 3DMR wins over Wikidata on conflict. - let three_dmr_prescan = if args.use_3d { - Some(crate::models_3d::three_dmr::prescan( - &elements, - args.rotation, - )) - } else { - None - }; + let models_3d_pipeline = args + .use_3d + .then(|| crate::models_3d::Models3dPipeline::prescan(&elements, args)); let empty_suppressed: HashSet<(&'static str, u64)> = HashSet::new(); - let three_dmr_suppressed: &HashSet<(&'static str, u64)> = three_dmr_prescan - .as_ref() - .map(|p| &p.suppressed_ids) - .unwrap_or(&empty_suppressed); - - // Wikidata pre-scan runs after 3DMR's, skipping any element 3DMR already claimed. - let wikidata_prescan = if args.use_3d { - Some(crate::models_3d::wikidata::prescan( - &elements, - three_dmr_suppressed, - args.rotation, - args.scale, - )) - } else { - None - }; - let wikidata_suppressed: &HashSet<(&'static str, u64)> = wikidata_prescan + let models_3d_suppressed: &HashSet<(&'static str, u64)> = models_3d_pipeline .as_ref() - .map(|p| &p.suppressed_ids) + .map(|p| p.suppressed()) .unwrap_or(&empty_suppressed); // Process all elements for element in elements.into_iter() { element_counter += 1; let suppression_key = (element.kind(), element.id()); - if three_dmr_suppressed.contains(&suppression_key) - || wikidata_suppressed.contains(&suppression_key) + if models_3d_suppressed.contains(&suppression_key) || outline_suppression.contains(&suppression_key) { continue; @@ -439,15 +416,8 @@ pub fn generate_world_with_options( } // Run after ground generation so anchor Y reflects the final terrain. - if let Some(prescan) = three_dmr_prescan.as_ref() { - if prescan.placement_count() > 0 { - crate::models_3d::three_dmr::place_three_dmr_models(&mut editor, args, prescan); - } - } - if let Some(prescan) = wikidata_prescan.as_ref() { - if prescan.placement_count() > 0 { - crate::models_3d::wikidata::place_wikidata_models(&mut editor, args, prescan); - } + if let Some(p) = models_3d_pipeline.as_ref() { + p.place(&mut editor, args); } // Save world diff --git a/src/models_3d/custom/client.rs b/src/models_3d/custom/client.rs new file mode 100644 index 000000000..532ef76f2 --- /dev/null +++ b/src/models_3d/custom/client.rs @@ -0,0 +1,57 @@ +//! Shared HTTP fetch + on-disk cache for Arnis-hosted archetype models. + +use reqwest::blocking::ClientBuilder; +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; + +const CACHE_SUBDIR: &str = "arnis/custom_models"; +const REQUEST_TIMEOUT_SECS: u64 = 20; +const MAX_GLB_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) fn cache_root() -> PathBuf { + dirs::cache_dir() + .map(|d| d.join(CACHE_SUBDIR)) + .unwrap_or_else(|| PathBuf::from("./.arnis_custom_cache")) +} + +pub(super) fn fetch_glb(url: &str, filename: &str) -> Result, String> { + let dir = cache_root(); + let path = dir.join(filename); + if let Ok(bytes) = fs::read(&path) { + if !bytes.is_empty() { + return Ok(bytes); + } + } + + let client = ClientBuilder::new() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .user_agent(concat!( + "Arnis/", + env!("CARGO_PKG_VERSION"), + " (+https://github.com/louis-e/arnis)" + )) + .build() + .map_err(|e| e.to_string())?; + let mut resp = client.get(url).send().map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + if let Some(len) = resp.content_length() { + if len > MAX_GLB_BYTES { + return Err(format!( + "exceeds {MAX_GLB_BYTES}-byte cap (advertised {len})" + )); + } + } + let mut buf: Vec = Vec::new(); + let mut taken = (&mut resp).take(MAX_GLB_BYTES + 1); + taken.read_to_end(&mut buf).map_err(|e| e.to_string())?; + if buf.len() as u64 > MAX_GLB_BYTES { + return Err(format!("exceeds {MAX_GLB_BYTES}-byte cap")); + } + let _ = fs::create_dir_all(&dir); + let _ = fs::write(&path, &buf); + Ok(buf) +} diff --git a/src/models_3d/custom/mod.rs b/src/models_3d/custom/mod.rs new file mode 100644 index 000000000..b2702533f --- /dev/null +++ b/src/models_3d/custom/mod.rs @@ -0,0 +1,4 @@ +//! Arnis-hosted archetype models triggered by OSM tags (stadiums, etc.). + +pub(crate) mod client; +pub(crate) mod stadium; diff --git a/src/models_3d/custom/stadium.rs b/src/models_3d/custom/stadium.rs new file mode 100644 index 000000000..892ad1a13 --- /dev/null +++ b/src/models_3d/custom/stadium.rs @@ -0,0 +1,951 @@ +//! Stadium archetype with footprint-fit GLB voxelization. + +use crate::args::Args; +use crate::models_3d::custom::client; +use crate::models_3d::voxelize::{glb_model_bbox, voxelize_glb, WorldTransform}; +use crate::osm_parser::ProcessedElement; +use crate::world_editor::WorldEditor; +use colored::Colorize; +use std::collections::HashSet; + +const MODEL_URL: &str = "https://arnismc.com/assets/3dmodels/stadium.glb"; +const CACHE_FILE: &str = "stadium.glb"; + +const MIN_SHORT_EXTENT_M: f32 = 10.0; +/// `leisure=stadium` qualifies on its own above this footprint area (m²). +const LARGE_STADIUM_AREA_M2: f64 = 20_000.0; +/// Smaller `leisure=stadium` qualifies only with an inner `building=stadium` corroborating. +const MEDIUM_STADIUM_AREA_M2: f64 = 10_000.0; +const DEFAULT_HEIGHT_M: f32 = 28.0; +const HEIGHT_MULTIPLIER: f32 = 1.5; + +#[derive(Clone, Debug)] +struct Placement { + osm_id: u64, + anchor_x: i32, + anchor_z: i32, + footprint: Bbox, + long_m: f32, + short_m: f32, + /// Long-axis bearing in degrees, CCW from world +X. + yaw_degrees: f64, + osm_height_m: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Bbox { + min_x: i32, + min_z: i32, + max_x: i32, + max_z: i32, +} + +impl Bbox { + fn contains(&self, x: i32, z: i32) -> bool { + x >= self.min_x && x <= self.max_x && z >= self.min_z && z <= self.max_z + } +} + +pub struct PrescanResult { + pub suppressed_ids: HashSet<(&'static str, u64)>, + placements: Vec, + model_bytes: Option>, +} + +impl PrescanResult { + pub fn placement_count(&self) -> usize { + self.placements.len() + } +} + +pub fn prescan( + elements: &[ProcessedElement], + already_suppressed: &HashSet<(&'static str, u64)>, + args_scale: f64, +) -> PrescanResult { + let (placements, mut suppressed, footprints) = + collect_stadium_placements(elements, already_suppressed, args_scale); + + if placements.is_empty() { + return PrescanResult { + suppressed_ids: suppressed, + placements, + model_bytes: None, + }; + } + + // On fetch failure, drop suppression so inner features still render procedurally. + let model_bytes = match client::fetch_glb(MODEL_URL, CACHE_FILE) { + Ok(b) => b, + Err(e) => { + eprintln!( + "{} stadium model fetch failed ({MODEL_URL}): {e}", + "Warning:".yellow().bold() + ); + return PrescanResult { + suppressed_ids: HashSet::new(), + placements: Vec::new(), + model_bytes: None, + }; + } + }; + + let interior = + collect_interior_suppression(elements, already_suppressed, &suppressed, &footprints); + suppressed.extend(interior); + + PrescanResult { + suppressed_ids: suppressed, + placements, + model_bytes: Some(model_bytes), + } +} + +fn collect_stadium_placements( + elements: &[ProcessedElement], + already_suppressed: &HashSet<(&'static str, u64)>, + args_scale: f64, +) -> (Vec, HashSet<(&'static str, u64)>, Vec) { + let building_stadium_anchors: Vec<(i32, i32)> = elements + .iter() + .filter(|e| e.tags().get("building").map(|s| s.as_str()) == Some("stadium")) + .filter_map(anchor_xz) + .collect(); + + let mut placements: Vec = Vec::new(); + let mut suppressed: HashSet<(&'static str, u64)> = HashSet::new(); + let mut footprints: Vec = Vec::new(); + + for element in elements { + let key = (element.kind(), element.id()); + if already_suppressed.contains(&key) { + continue; + } + if element.tags().get("leisure").map(|s| s.as_str()) != Some("stadium") { + continue; + } + let Some(p) = build_placement(element, args_scale) else { + continue; + }; + if !leisure_stadium_qualifies(&p, &building_stadium_anchors) { + continue; + } + suppressed.insert(key); + footprints.push(p.footprint); + placements.push(p); + } + + for element in elements { + let key = (element.kind(), element.id()); + if already_suppressed.contains(&key) || suppressed.contains(&key) { + continue; + } + if element.tags().get("building").map(|s| s.as_str()) != Some("stadium") { + continue; + } + let Some((cx, cz)) = anchor_xz(element) else { + continue; + }; + if footprints.iter().any(|b| b.contains(cx, cz)) { + suppressed.insert(key); + continue; + } + let Some(p) = build_placement(element, args_scale) else { + continue; + }; + if footprint_area_m2(&p) < LARGE_STADIUM_AREA_M2 { + continue; + } + suppressed.insert(key); + footprints.push(p.footprint); + placements.push(p); + } + + (placements, suppressed, footprints) +} + +fn footprint_area_m2(p: &Placement) -> f64 { + p.long_m as f64 * p.short_m as f64 +} + +fn leisure_stadium_qualifies(p: &Placement, building_anchors: &[(i32, i32)]) -> bool { + let area = footprint_area_m2(p); + if area >= LARGE_STADIUM_AREA_M2 { + return true; + } + if area < MEDIUM_STADIUM_AREA_M2 { + return false; + } + building_anchors + .iter() + .any(|&(x, z)| p.footprint.contains(x, z)) +} + +fn collect_interior_suppression( + elements: &[ProcessedElement], + already_suppressed: &HashSet<(&'static str, u64)>, + claimed: &HashSet<(&'static str, u64)>, + footprints: &[Bbox], +) -> HashSet<(&'static str, u64)> { + let mut interior: HashSet<(&'static str, u64)> = HashSet::new(); + if footprints.is_empty() { + return interior; + } + for element in elements { + let key = (element.kind(), element.id()); + if already_suppressed.contains(&key) || claimed.contains(&key) { + continue; + } + if !is_suppressible(element) { + continue; + } + let Some((cx, cz)) = anchor_xz(element) else { + continue; + }; + if footprints.iter().any(|b| b.contains(cx, cz)) { + interior.insert(key); + } + } + interior +} + +fn is_suppressible(element: &ProcessedElement) -> bool { + let tags = element.tags(); + if tags.contains_key("building") || tags.contains_key("building:part") { + return true; + } + matches!( + tags.get("leisure").map(|s| s.as_str()), + Some("pitch") | Some("track") + ) +} + +fn build_placement(element: &ProcessedElement, args_scale: f64) -> Option { + let points = polygon_points(element)?; + if points.len() < 3 { + return None; + } + let footprint = bbox_of(&points)?; + + let (long_blocks, short_blocks, theta) = principal_axis(&points)?; + let long_m = (long_blocks / args_scale) as f32; + let short_m = (short_blocks / args_scale) as f32; + if short_m < MIN_SHORT_EXTENT_M { + return None; + } + + let (cx, cz) = centroid(&points)?; + let osm_height_m = element + .tags() + .get("height") + .and_then(|s| parse_meters(s)) + .map(|m| m as f32); + + Some(Placement { + osm_id: element.id(), + anchor_x: cx, + anchor_z: cz, + footprint, + long_m, + short_m, + yaw_degrees: theta.to_degrees(), + osm_height_m, + }) +} + +pub fn place_stadium_models(editor: &mut WorldEditor, args: &Args, prescan: &PrescanResult) { + if prescan.placements.is_empty() { + return; + } + let Some(model_bytes) = prescan.model_bytes.as_deref() else { + return; + }; + + let (model_min, model_max) = match glb_model_bbox(model_bytes) { + Ok(b) => b, + Err(e) => { + eprintln!( + "{} stadium GLB bbox failed: {e}", + "Warning:".yellow().bold() + ); + return; + } + }; + + let mx = model_max[0] - model_min[0]; + let my = model_max[1] - model_min[1]; + let mz = model_max[2] - model_min[2]; + if mx < 1e-3 || my < 1e-3 || mz < 1e-3 { + eprintln!( + "{} stadium GLB has degenerate extents", + "Warning:".yellow().bold() + ); + return; + } + + let model_x_is_long = mx >= mz; + let model_long_extent = mx.max(mz); + let model_short_extent = mx.min(mz); + + // Center the model on the origin in XZ; Y is handled by post-voxelize ground snap. + let center_x = -(model_min[0] + model_max[0]) * 0.5; + let center_z = -(model_min[2] + model_max[2]) * 0.5; + + println!( + "{} Placing {} stadium model{}...", + " [+]".bold(), + prescan.placements.len(), + if prescan.placements.len() == 1 { + "" + } else { + "s" + } + ); + + let block_per_meter = args.scale as f32; + let mut placed = 0usize; + let mut total_voxels = 0usize; + + for placement in &prescan.placements { + let target_long_blocks = placement.long_m * block_per_meter; + let target_short_blocks = placement.short_m * block_per_meter; + let target_height_blocks = placement.osm_height_m.unwrap_or(DEFAULT_HEIGHT_M) + * block_per_meter + * HEIGHT_MULTIPLIER; + + // 90° pre-rotation when model is Z-long so its long axis lines up with X for per-axis scale. + let intrinsic_yaw_deg = if model_x_is_long { 0.0 } else { 90.0 }; + let scale_x = target_long_blocks / model_long_extent; + let scale_z = target_short_blocks / model_short_extent; + let scale_y = target_height_blocks / my; + + // Post-rotation offsets: rotating (-cx, *, -cz) by +90° yields (cz, *, -cx). + let (intrinsic_tx, intrinsic_tz) = if model_x_is_long { + (center_x, center_z) + } else { + (-center_z, center_x) + }; + + let ground_y = lowest_ground_in_bbox(editor, &placement.footprint); + + let transform = WorldTransform::with_world_scale_xyz( + intrinsic_yaw_deg, + 1.0, + [intrinsic_tx as f64, 0.0, intrinsic_tz as f64], + [scale_x, scale_y, scale_z], + placement.yaw_degrees, + placement.anchor_x as f32, + ground_y as f32, + placement.anchor_z as f32, + ); + + let mut voxels = match voxelize_glb(model_bytes, transform) { + Ok(v) => v, + Err(e) => { + eprintln!( + "{} stadium (OSM {}) voxelization failed: {e}", + "Warning:".yellow().bold(), + placement.osm_id + ); + continue; + } + }; + + if let Some(min_voxel_y) = voxels.iter().map(|(p, _)| p[1]).min() { + let dy = ground_y - min_voxel_y; + if dy != 0 { + for (pos, _) in voxels.iter_mut() { + pos[1] += dy; + } + } + } + + for ([x, y, z], block) in &voxels { + editor.set_block_absolute(*block, *x, *y, *z, None, None); + } + total_voxels += voxels.len(); + placed += 1; + } + + println!( + " Placed {} stadium model{} ({} blocks)", + placed.to_string().bright_white().bold(), + if placed == 1 { "" } else { "s" }, + total_voxels + ); +} + +fn polygon_points(element: &ProcessedElement) -> Option> { + let v: Vec<(i32, i32)> = match element { + ProcessedElement::Way(w) => w.nodes.iter().map(|n| (n.x, n.z)).collect(), + ProcessedElement::Relation(r) => r + .members + .iter() + .flat_map(|m| m.way.nodes.iter().map(|n| (n.x, n.z))) + .collect(), + ProcessedElement::Node(_) => return None, + }; + if v.is_empty() { + None + } else { + Some(v) + } +} + +fn anchor_xz(element: &ProcessedElement) -> Option<(i32, i32)> { + match element { + ProcessedElement::Node(n) => Some((n.x, n.z)), + ProcessedElement::Way(w) => centroid_iter(w.nodes.iter().map(|n| (n.x, n.z))), + ProcessedElement::Relation(r) => centroid_iter( + r.members + .iter() + .flat_map(|m| m.way.nodes.iter().map(|n| (n.x, n.z))), + ), + } +} + +fn centroid(points: &[(i32, i32)]) -> Option<(i32, i32)> { + centroid_iter(points.iter().copied()) +} + +fn centroid_iter>(coords: I) -> Option<(i32, i32)> { + let mut sx: i64 = 0; + let mut sz: i64 = 0; + let mut count: i64 = 0; + for (x, z) in coords { + sx += x as i64; + sz += z as i64; + count += 1; + } + if count == 0 { + None + } else { + Some(((sx / count) as i32, (sz / count) as i32)) + } +} + +fn bbox_of(points: &[(i32, i32)]) -> Option { + let (x0, z0) = *points.first()?; + let mut min_x = x0; + let mut max_x = x0; + let mut min_z = z0; + let mut max_z = z0; + for &(x, z) in &points[1..] { + min_x = min_x.min(x); + max_x = max_x.max(x); + min_z = min_z.min(z); + max_z = max_z.max(z); + } + Some(Bbox { + min_x, + min_z, + max_x, + max_z, + }) +} + +/// PCA on (x, z): returns (long_extent, short_extent, theta_rad CCW from +X). +fn principal_axis(points: &[(i32, i32)]) -> Option<(f64, f64, f64)> { + let n = points.len() as f64; + if n < 3.0 { + return None; + } + let cx = points.iter().map(|p| p.0 as f64).sum::() / n; + let cz = points.iter().map(|p| p.1 as f64).sum::() / n; + let mut cxx = 0.0_f64; + let mut cxz = 0.0_f64; + let mut czz = 0.0_f64; + for &(x, z) in points { + let dx = x as f64 - cx; + let dz = z as f64 - cz; + cxx += dx * dx; + cxz += dx * dz; + czz += dz * dz; + } + + let theta = 0.5_f64 * (2.0 * cxz).atan2(cxx - czz); + let (sin_t, cos_t) = theta.sin_cos(); + + let mut min_a = f64::INFINITY; + let mut max_a = f64::NEG_INFINITY; + let mut min_p = f64::INFINITY; + let mut max_p = f64::NEG_INFINITY; + for &(x, z) in points { + let dx = x as f64 - cx; + let dz = z as f64 - cz; + let a = dx * cos_t + dz * sin_t; + let p = -dx * sin_t + dz * cos_t; + if a < min_a { + min_a = a; + } + if a > max_a { + max_a = a; + } + if p < min_p { + min_p = p; + } + if p > max_p { + max_p = p; + } + } + let ext_a = max_a - min_a; + let ext_p = max_p - min_p; + if ext_a >= ext_p { + Some((ext_a, ext_p, theta)) + } else { + Some((ext_p, ext_a, theta + std::f64::consts::FRAC_PI_2)) + } +} + +fn lowest_ground_in_bbox(editor: &WorldEditor, bbox: &Bbox) -> i32 { + let dx = bbox.max_x - bbox.min_x; + let dz = bbox.max_z - bbox.min_z; + let stride = (dx.max(dz) / 16).clamp(1, 8); + let mut lowest = i32::MAX; + let mut x = bbox.min_x; + while x <= bbox.max_x { + let mut z = bbox.min_z; + while z <= bbox.max_z { + lowest = lowest.min(editor.get_ground_level(x, z)); + z += stride; + } + x += stride; + } + for (x, z) in [ + (bbox.min_x, bbox.min_z), + (bbox.max_x, bbox.min_z), + (bbox.min_x, bbox.max_z), + (bbox.max_x, bbox.max_z), + ] { + lowest = lowest.min(editor.get_ground_level(x, z)); + } + if lowest == i32::MAX { + editor.get_ground_level((bbox.min_x + bbox.max_x) / 2, (bbox.min_z + bbox.max_z) / 2) + } else { + lowest + } +} + +fn parse_meters(raw: &str) -> Option { + let s = raw.trim().trim_end_matches('m').trim(); + s.parse::().ok().filter(|v| v.is_finite() && *v > 0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::osm_parser::{ProcessedNode, ProcessedWay}; + use std::collections::HashMap as StdMap; + + fn mk_node(id: u64, x: i32, z: i32) -> ProcessedNode { + ProcessedNode { + id, + tags: StdMap::new(), + x, + z, + } + } + + fn mk_way(id: u64, nodes: Vec, tags: StdMap) -> ProcessedWay { + ProcessedWay { id, nodes, tags } + } + + #[test] + fn principal_axis_of_axis_aligned_rect() { + let pts = vec![(-100, -50), (100, -50), (100, 50), (-100, 50)]; + let (long, short, theta) = principal_axis(&pts).unwrap(); + assert!((long - 200.0).abs() < 1e-6); + assert!((short - 100.0).abs() < 1e-6); + assert!(theta.abs() < 1e-6, "theta = {theta}"); + } + + #[test] + fn principal_axis_of_z_aligned_rect_returns_long_first() { + let pts = vec![(-50, -100), (50, -100), (50, 100), (-50, 100)]; + let (long, short, theta) = principal_axis(&pts).unwrap(); + assert!((long - 200.0).abs() < 1e-6); + assert!((short - 100.0).abs() < 1e-6); + let t = theta.rem_euclid(std::f64::consts::PI); + assert!( + (t - std::f64::consts::FRAC_PI_2).abs() < 1e-6, + "theta = {theta}" + ); + } + + #[test] + fn principal_axis_of_45deg_rect() { + let s2 = std::f64::consts::FRAC_1_SQRT_2; + let pts: Vec<(i32, i32)> = [ + (-100.0, -50.0), + (100.0, -50.0), + (100.0, 50.0), + (-100.0, 50.0), + ] + .iter() + .map(|&(x, z)| { + let rx = x * s2 - z * s2; + let rz = x * s2 + z * s2; + (rx.round() as i32, rz.round() as i32) + }) + .collect(); + let (long, short, theta) = principal_axis(&pts).unwrap(); + assert!((long - 200.0).abs() < 2.0, "long = {long}"); + assert!((short - 100.0).abs() < 2.0, "short = {short}"); + let t = theta.rem_euclid(std::f64::consts::PI); + assert!( + (t - std::f64::consts::FRAC_PI_4).abs() < 0.05, + "theta = {theta}" + ); + } + + fn prescan_offline( + elements: &[ProcessedElement], + already: &HashSet<(&'static str, u64)>, + ) -> (Vec, HashSet<(&'static str, u64)>) { + let (placements, mut suppressed, footprints) = + collect_stadium_placements(elements, already, 1.0); + let interior = collect_interior_suppression(elements, already, &suppressed, &footprints); + suppressed.extend(interior); + (placements, suppressed) + } + + #[test] + fn prescan_claims_leisure_stadium_and_suppresses_inner_pitch() { + let mut stadium_tags = StdMap::new(); + stadium_tags.insert("leisure".to_string(), "stadium".to_string()); + let stadium = ProcessedElement::Way(mk_way( + 1, + vec![ + mk_node(10, -150, -100), + mk_node(11, 150, -100), + mk_node(12, 150, 100), + mk_node(13, -150, 100), + ], + stadium_tags, + )); + + let mut pitch_tags = StdMap::new(); + pitch_tags.insert("leisure".to_string(), "pitch".to_string()); + let pitch = ProcessedElement::Way(mk_way( + 2, + vec![ + mk_node(20, -40, -20), + mk_node(21, 40, -20), + mk_node(22, 40, 20), + mk_node(23, -40, 20), + ], + pitch_tags, + )); + + let mut gs_tags = StdMap::new(); + gs_tags.insert("building".to_string(), "grandstand".to_string()); + let grandstand = ProcessedElement::Way(mk_way( + 3, + vec![ + mk_node(30, -120, -90), + mk_node(31, -100, -90), + mk_node(32, -100, 90), + mk_node(33, -120, 90), + ], + gs_tags, + )); + + let mut road_tags = StdMap::new(); + road_tags.insert("highway".to_string(), "service".to_string()); + let road = ProcessedElement::Way(mk_way( + 4, + vec![mk_node(40, -10, -10), mk_node(41, 10, 10)], + road_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[stadium, pitch, grandstand, road], &empty); + + assert_eq!(placements.len(), 1); + assert!(suppressed.contains(&("way", 1))); + assert!(suppressed.contains(&("way", 2))); + assert!(suppressed.contains(&("way", 3))); + assert!(!suppressed.contains(&("way", 4))); + } + + #[test] + fn prescan_falls_back_to_building_stadium_when_no_leisure() { + let mut bs_tags = StdMap::new(); + bs_tags.insert("building".to_string(), "stadium".to_string()); + let bs = ProcessedElement::Way(mk_way( + 5, + vec![ + mk_node(50, 0, 0), + mk_node(51, 200, 0), + mk_node(52, 200, 150), + mk_node(53, 0, 150), + ], + bs_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[bs], &empty); + assert_eq!(placements.len(), 1); + assert!(suppressed.contains(&("way", 5))); + } + + #[test] + fn prescan_subsumes_building_stadium_inside_leisure_stadium() { + let mut ls_tags = StdMap::new(); + ls_tags.insert("leisure".to_string(), "stadium".to_string()); + let ls = ProcessedElement::Way(mk_way( + 6, + vec![ + mk_node(60, -200, -150), + mk_node(61, 200, -150), + mk_node(62, 200, 150), + mk_node(63, -200, 150), + ], + ls_tags, + )); + + let mut bs_tags = StdMap::new(); + bs_tags.insert("building".to_string(), "stadium".to_string()); + let bs = ProcessedElement::Way(mk_way( + 7, + vec![ + mk_node(70, -100, -100), + mk_node(71, 100, -100), + mk_node(72, 100, 100), + mk_node(73, -100, 100), + ], + bs_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[ls, bs], &empty); + assert_eq!(placements.len(), 1); + assert!(suppressed.contains(&("way", 6))); + assert!(suppressed.contains(&("way", 7))); + } + + #[test] + fn prescan_rejects_tiny_stadium() { + let mut tags = StdMap::new(); + tags.insert("leisure".to_string(), "stadium".to_string()); + let tiny = ProcessedElement::Way(mk_way( + 8, + vec![ + mk_node(80, 0, 0), + mk_node(81, 8, 0), + mk_node(82, 8, 6), + mk_node(83, 0, 6), + ], + tags, + )); + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[tiny], &empty); + assert_eq!(placements.len(), 0); + assert!(!suppressed.contains(&("way", 8))); + } + + #[test] + fn prescan_rejects_medium_stadium_without_inner_building() { + let mut tags = StdMap::new(); + tags.insert("leisure".to_string(), "stadium".to_string()); + tags.insert("sport".to_string(), "swimming".to_string()); + let swim = ProcessedElement::Way(mk_way( + 9, + vec![ + mk_node(90, -55, -50), + mk_node(91, 55, -50), + mk_node(92, 55, 50), + mk_node(93, -55, 50), + ], + tags, + )); + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[swim], &empty); + assert_eq!(placements.len(), 0); + assert!(!suppressed.contains(&("way", 9))); + } + + #[test] + fn prescan_rejects_small_stadium_even_with_inner_building() { + let mut ls_tags = StdMap::new(); + ls_tags.insert("leisure".to_string(), "stadium".to_string()); + let ls = ProcessedElement::Way(mk_way( + 10, + vec![ + mk_node(100, -60, -30), + mk_node(101, 60, -30), + mk_node(102, 60, 30), + mk_node(103, -60, 30), + ], + ls_tags, + )); + + let mut bs_tags = StdMap::new(); + bs_tags.insert("building".to_string(), "stadium".to_string()); + let bs = ProcessedElement::Way(mk_way( + 11, + vec![ + mk_node(110, -50, -25), + mk_node(111, 50, -25), + mk_node(112, 50, 25), + mk_node(113, -50, 25), + ], + bs_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[ls, bs], &empty); + assert_eq!(placements.len(), 0); + assert!(!suppressed.contains(&("way", 10))); + assert!(!suppressed.contains(&("way", 11))); + } + + #[test] + fn prescan_accepts_medium_stadium_with_inner_building() { + let mut ls_tags = StdMap::new(); + ls_tags.insert("leisure".to_string(), "stadium".to_string()); + let ls = ProcessedElement::Way(mk_way( + 12, + vec![ + mk_node(120, -65, -50), + mk_node(121, 65, -50), + mk_node(122, 65, 50), + mk_node(123, -65, 50), + ], + ls_tags, + )); + + let mut bs_tags = StdMap::new(); + bs_tags.insert("building".to_string(), "stadium".to_string()); + let bs = ProcessedElement::Way(mk_way( + 13, + vec![ + mk_node(130, -55, -40), + mk_node(131, 55, -40), + mk_node(132, 55, 40), + mk_node(133, -55, 40), + ], + bs_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[ls, bs], &empty); + assert_eq!(placements.len(), 1); + assert!(suppressed.contains(&("way", 12))); + assert!(suppressed.contains(&("way", 13))); + } + + #[test] + fn prescan_rejects_standalone_small_building_stadium() { + let mut tags = StdMap::new(); + tags.insert("building".to_string(), "stadium".to_string()); + let small = ProcessedElement::Way(mk_way( + 14, + vec![ + mk_node(140, 0, 0), + mk_node(141, 100, 0), + mk_node(142, 100, 80), + mk_node(143, 0, 80), + ], + tags, + )); + let empty = HashSet::new(); + let (placements, suppressed) = prescan_offline(&[small], &empty); + assert_eq!(placements.len(), 0); + assert!(!suppressed.contains(&("way", 14))); + } + + /// Berlin Olympiapark regression: only the real Olympiastadion claims a model. + #[test] + fn prescan_berlin_olympiapark_only_olympiastadion_claims_model() { + let mut olympia_tags = StdMap::new(); + olympia_tags.insert("leisure".to_string(), "stadium".to_string()); + olympia_tags.insert("name".to_string(), "Olympiastadion Berlin".to_string()); + let olympia = ProcessedElement::Way(mk_way( + 38862723, + vec![ + mk_node(1000, -125, -110), + mk_node(1001, 125, -110), + mk_node(1002, 125, 110), + mk_node(1003, -125, 110), + ], + olympia_tags, + )); + let mut olympia_bs_tags = StdMap::new(); + olympia_bs_tags.insert("building".to_string(), "stadium".to_string()); + let olympia_bs = ProcessedElement::Way(mk_way( + 24296022, + vec![ + mk_node(1010, -115, -100), + mk_node(1011, 115, -100), + mk_node(1012, 115, 100), + mk_node(1013, -115, 100), + ], + olympia_bs_tags, + )); + + let mut swim_tags = StdMap::new(); + swim_tags.insert("leisure".to_string(), "stadium".to_string()); + swim_tags.insert("name".to_string(), "Olympia-Schwimmstadion".to_string()); + let swim = ProcessedElement::Way(mk_way( + 38863016, + vec![ + mk_node(2000, 800, -40), + mk_node(2001, 900, -40), + mk_node(2002, 900, 40), + mk_node(2003, 800, 40), + ], + swim_tags, + )); + + let mut amateur_tags = StdMap::new(); + amateur_tags.insert("leisure".to_string(), "stadium".to_string()); + amateur_tags.insert("name".to_string(), "Stadion auf dem Wurfplatz".to_string()); + let amateur = ProcessedElement::Way(mk_way( + 24296069, + vec![ + mk_node(3000, -1500, -30), + mk_node(3001, -1380, -30), + mk_node(3002, -1380, 30), + mk_node(3003, -1500, 30), + ], + amateur_tags, + )); + let mut amateur_bs_tags = StdMap::new(); + amateur_bs_tags.insert("building".to_string(), "stadium".to_string()); + let amateur_bs = ProcessedElement::Way(mk_way( + 764233954, + vec![ + mk_node(3010, -1490, -25), + mk_node(3011, -1390, -25), + mk_node(3012, -1390, 25), + mk_node(3013, -1490, 25), + ], + amateur_bs_tags, + )); + + let empty = HashSet::new(); + let (placements, suppressed) = + prescan_offline(&[olympia, olympia_bs, swim, amateur, amateur_bs], &empty); + + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].osm_id, 38862723); + + assert!(suppressed.contains(&("way", 38862723))); + assert!(suppressed.contains(&("way", 24296022))); + + assert!(!suppressed.contains(&("way", 38863016))); + assert!(!suppressed.contains(&("way", 24296069))); + assert!(!suppressed.contains(&("way", 764233954))); + } + + #[test] + fn parse_meters_basic() { + assert_eq!(parse_meters("12"), Some(12.0)); + assert_eq!(parse_meters("12.5"), Some(12.5)); + assert_eq!(parse_meters("28 m"), Some(28.0)); + assert_eq!(parse_meters("28m"), Some(28.0)); + assert_eq!(parse_meters("bogus"), None); + assert_eq!(parse_meters("-3"), None); + } +} diff --git a/src/models_3d/mod.rs b/src/models_3d/mod.rs index 081a42ad8..447263a12 100644 --- a/src/models_3d/mod.rs +++ b/src/models_3d/mod.rs @@ -1,14 +1,19 @@ -//! 3D model substitution pipelines (3DMR glTF + Wikidata P4896 STL) sharing voxelizer + palette. +//! 3D model substitution pipelines (3DMR glTF, Wikidata P4896 STL, Arnis-hosted archetypes). +pub(crate) mod custom; pub(crate) mod palette; +pub(crate) mod pipeline; pub(crate) mod three_dmr; pub(crate) mod voxelize; pub(crate) mod wikidata; +pub use pipeline::Models3dPipeline; + use crate::elevation::cache::{clear_cache_dir, CacheClearStats}; -/// Clears on-disk caches for the 3D-model fetchers (3DMR + Wikidata). +/// Clears on-disk caches for every 3D-model fetcher. pub fn clear_model_caches() -> CacheClearStats { clear_cache_dir(&three_dmr::client::cache_root()) .combined(clear_cache_dir(&wikidata::client::cache_root())) + .combined(clear_cache_dir(&custom::client::cache_root())) } diff --git a/src/models_3d/pipeline.rs b/src/models_3d/pipeline.rs new file mode 100644 index 000000000..29cf95d3a --- /dev/null +++ b/src/models_3d/pipeline.rs @@ -0,0 +1,56 @@ +//! Orchestrates 3D-model substitution: 3DMR (external) → Wikidata (external) → custom archetypes. + +use crate::args::Args; +use crate::models_3d::{custom, three_dmr, wikidata}; +use crate::osm_parser::ProcessedElement; +use crate::world_editor::WorldEditor; +use std::collections::HashSet; + +pub struct Models3dPipeline { + three_dmr: three_dmr::PrescanResult, + wikidata: wikidata::PrescanResult, + stadium: custom::stadium::PrescanResult, + union_suppressed: HashSet<(&'static str, u64)>, +} + +impl Models3dPipeline { + pub fn prescan(elements: &[ProcessedElement], args: &Args) -> Self { + let three_dmr = three_dmr::prescan(elements, args.rotation); + let wikidata = wikidata::prescan( + elements, + &three_dmr.suppressed_ids, + args.rotation, + args.scale, + ); + + let mut combined: HashSet<(&'static str, u64)> = HashSet::new(); + combined.extend(three_dmr.suppressed_ids.iter().copied()); + combined.extend(wikidata.suppressed_ids.iter().copied()); + + let stadium = custom::stadium::prescan(elements, &combined, args.scale); + combined.extend(stadium.suppressed_ids.iter().copied()); + + Self { + three_dmr, + wikidata, + stadium, + union_suppressed: combined, + } + } + + pub fn suppressed(&self) -> &HashSet<(&'static str, u64)> { + &self.union_suppressed + } + + pub fn place(&self, editor: &mut WorldEditor, args: &Args) { + if self.three_dmr.placement_count() > 0 { + three_dmr::place_three_dmr_models(editor, args, &self.three_dmr); + } + if self.wikidata.placement_count() > 0 { + wikidata::place_wikidata_models(editor, args, &self.wikidata); + } + if self.stadium.placement_count() > 0 { + custom::stadium::place_stadium_models(editor, args, &self.stadium); + } + } +} diff --git a/src/models_3d/three_dmr/mod.rs b/src/models_3d/three_dmr/mod.rs index 716559356..d803a4d23 100644 --- a/src/models_3d/three_dmr/mod.rs +++ b/src/models_3d/three_dmr/mod.rs @@ -3,4 +3,4 @@ pub(crate) mod client; mod placement; -pub use placement::{place_three_dmr_models, prescan}; +pub use placement::{place_three_dmr_models, prescan, PrescanResult}; diff --git a/src/models_3d/voxelize.rs b/src/models_3d/voxelize.rs index c5db98fa2..32e18bef5 100644 --- a/src/models_3d/voxelize.rs +++ b/src/models_3d/voxelize.rs @@ -1,9 +1,20 @@ //! glTF (.glb) → triangles → voxels via dda-voxelize. -use crate::block_definitions::{Block, STONE_BRICKS}; +use crate::block_definitions::{Block, GLASS, STONE_BRICKS}; use crate::models_3d::palette::closest_block; use dda_voxelize::DdaVoxelizer; +/// Pure magenta (#FF00FF) in model colors becomes GLASS instead of palette-mapping. +const GLASS_SENTINEL: [f32; 3] = [1.0, 0.0, 1.0]; +const GLASS_SENTINEL_TOL: f32 = 1e-3; + +#[inline] +fn is_glass_sentinel(c: [f32; 3]) -> bool { + (c[0] - GLASS_SENTINEL[0]).abs() < GLASS_SENTINEL_TOL + && (c[1] - GLASS_SENTINEL[1]).abs() < GLASS_SENTINEL_TOL + && (c[2] - GLASS_SENTINEL[2]).abs() < GLASS_SENTINEL_TOL +} + /// Composed vertex transform: intrinsic glTF/3DMR step, then world placement step. #[derive(Clone, Copy, Debug)] pub struct WorldTransform { @@ -13,7 +24,7 @@ pub struct WorldTransform { intrinsic_tx: f32, intrinsic_ty: f32, intrinsic_tz: f32, - world_scale: f32, + world_scale: [f32; 3], world_rot_cos: f32, world_rot_sin: f32, world_tx: f32, @@ -32,6 +43,30 @@ impl WorldTransform { anchor_x: f32, anchor_y: f32, anchor_z: f32, + ) -> Self { + let s = world_scale as f32; + Self::with_world_scale_xyz( + intrinsic_yaw_degrees, + intrinsic_scale, + intrinsic_translation, + [s, s, s], + world_yaw_degrees, + anchor_x, + anchor_y, + anchor_z, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn with_world_scale_xyz( + intrinsic_yaw_degrees: f64, + intrinsic_scale: f64, + intrinsic_translation: [f64; 3], + world_scale: [f32; 3], + world_yaw_degrees: f64, + anchor_x: f32, + anchor_y: f32, + anchor_z: f32, ) -> Self { let it = (intrinsic_yaw_degrees as f32).to_radians(); let wt = (world_yaw_degrees as f32).to_radians(); @@ -42,7 +77,7 @@ impl WorldTransform { intrinsic_tx: intrinsic_translation[0] as f32, intrinsic_ty: intrinsic_translation[1] as f32, intrinsic_tz: intrinsic_translation[2] as f32, - world_scale: world_scale as f32, + world_scale, world_rot_cos: wt.cos(), world_rot_sin: wt.sin(), world_tx: anchor_x, @@ -60,9 +95,9 @@ impl WorldTransform { let r1y = sy + self.intrinsic_ty; let r1z = sx * self.intrinsic_rot_sin + sz * self.intrinsic_rot_cos + self.intrinsic_tz; - let mx = r1x * self.world_scale; - let my = r1y * self.world_scale; - let mz = r1z * self.world_scale; + let mx = r1x * self.world_scale[0]; + let my = r1y * self.world_scale[1]; + let mz = r1z * self.world_scale[2]; let r2x = mx * self.world_rot_cos - mz * self.world_rot_sin + self.world_tx; let r2y = my + self.world_ty; let r2z = mx * self.world_rot_sin + mz * self.world_rot_cos + self.world_tz; @@ -198,16 +233,13 @@ pub fn voxelize_glb( }) .and_then(image_average_color); - let final_color = if let Some(tex) = texture_avg { + let material_color = if let Some(tex) = texture_avg { [factor[0] * tex[0], factor[1] * tex[1], factor[2] * tex[2]] } else { [factor[0], factor[1], factor[2]] }; - let uncolored = texture_avg.is_none() && is_default_white(factor); - let value: VoxelValue = (final_color, uncolored); - - let shader = - |prev: Option<&VoxelValue>, _: [i32; 3], _: [f32; 3]| *prev.unwrap_or(&value); + let material_uncolored = texture_avg.is_none() && is_default_white(factor); + let material_value: VoxelValue = (material_color, material_uncolored); let reader = primitive.reader(|b| Some(&buffers[b.index()])); let Some(positions) = reader.read_positions() else { @@ -219,17 +251,41 @@ pub fn voxelize_glb( } else { (0..positions.len() as u32).collect() }; + // Vertex colors (COLOR_0) override material color when present. + let vertex_colors: Option> = + reader.read_colors(0).map(|c| c.into_rgba_f32().collect()); for tri in indices.chunks_exact(3) { - let a = positions[tri[0] as usize]; - let b = positions[tri[1] as usize]; - let c = positions[tri[2] as usize]; + let ia = tri[0] as usize; + let ib = tri[1] as usize; + let ic = tri[2] as usize; + let a = positions[ia]; + let b = positions[ib]; + let c = positions[ic]; let wa = transform.apply(transform_point(&world, a)); let wb = transform.apply(transform_point(&world, b)); let wc = transform.apply(transform_point(&world, c)); if !triangle_finite(&wa, &wb, &wc) { continue; } + + let value: VoxelValue = match vertex_colors.as_ref() { + Some(vc) if ia < vc.len() && ib < vc.len() && ic < vc.len() => { + let ca = vc[ia]; + let cb = vc[ib]; + let cc = vc[ic]; + let avg = [ + (ca[0] + cb[0] + cc[0]) / 3.0 * material_color[0], + (ca[1] + cb[1] + cc[1]) / 3.0 * material_color[1], + (ca[2] + cb[2] + cc[2]) / 3.0 * material_color[2], + ]; + (avg, false) + } + _ => material_value, + }; + let shader = |prev: Option<&VoxelValue>, _: [i32; 3], _: [f32; 3]| { + *prev.unwrap_or(&value) + }; voxelizer.add_triangle(&[wa, wb, wc], &shader); } } @@ -245,6 +301,8 @@ pub fn voxelize_glb( for (pos, (color, uncolored)) in occupied { let block = if uncolored { STONE_BRICKS + } else if is_glass_sentinel(color) { + GLASS } else { let r = (color[0].clamp(0.0, 1.0) * 255.0) as u8; let g = (color[1].clamp(0.0, 1.0) * 255.0) as u8; @@ -366,6 +424,33 @@ mod tests { assert_eq!(out, [7.0, 0.0, 0.0]); } + #[test] + fn world_transform_nonuniform_xyz_scale() { + let t = WorldTransform::with_world_scale_xyz( + 0.0, + 1.0, + [0.0, 0.0, 0.0], + [3.0, 5.0, 7.0], + 0.0, + 0.0, + 0.0, + 0.0, + ); + let out = t.apply([1.0, 1.0, 1.0]); + assert_eq!(out, [3.0, 5.0, 7.0]); + } + + #[test] + fn glass_sentinel_matches_pure_magenta() { + assert!(is_glass_sentinel([1.0, 0.0, 1.0])); + assert!(is_glass_sentinel([0.9995, 0.0005, 0.9998])); + assert!(!is_glass_sentinel([1.0, 0.0, 0.9])); + assert!(!is_glass_sentinel([1.0, 0.1, 1.0])); + assert!(!is_glass_sentinel([0.9, 0.0, 1.0])); + // Reddish-pink shouldn't trigger. + assert!(!is_glass_sentinel([1.0, 0.5, 0.8])); + } + #[test] fn default_white_detection() { assert!(is_default_white([1.0, 1.0, 1.0, 1.0])); diff --git a/src/models_3d/wikidata/mod.rs b/src/models_3d/wikidata/mod.rs index 853c17652..a58cc8b60 100644 --- a/src/models_3d/wikidata/mod.rs +++ b/src/models_3d/wikidata/mod.rs @@ -6,4 +6,4 @@ mod placement; mod stl; pub use index::PERMISSIVE_ATTRIBUTIONS; -pub use placement::{place_wikidata_models, prescan}; +pub use placement::{place_wikidata_models, prescan, PrescanResult}; From 9a0a536db638c4ccec0664a503d74a78f14705ce Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Wed, 27 May 2026 21:52:10 +0200 Subject: [PATCH 2/3] style(models_3d): trim verbose stadium comments --- src/models_3d/custom/stadium.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models_3d/custom/stadium.rs b/src/models_3d/custom/stadium.rs index 892ad1a13..01bcafd85 100644 --- a/src/models_3d/custom/stadium.rs +++ b/src/models_3d/custom/stadium.rs @@ -14,7 +14,7 @@ const CACHE_FILE: &str = "stadium.glb"; const MIN_SHORT_EXTENT_M: f32 = 10.0; /// `leisure=stadium` qualifies on its own above this footprint area (m²). const LARGE_STADIUM_AREA_M2: f64 = 20_000.0; -/// Smaller `leisure=stadium` qualifies only with an inner `building=stadium` corroborating. +/// Smaller `leisure=stadium` needs an inner `building=stadium` to qualify. const MEDIUM_STADIUM_AREA_M2: f64 = 10_000.0; const DEFAULT_HEIGHT_M: f32 = 28.0; const HEIGHT_MULTIPLIER: f32 = 1.5; @@ -287,7 +287,7 @@ pub fn place_stadium_models(editor: &mut WorldEditor, args: &Args, prescan: &Pre let model_long_extent = mx.max(mz); let model_short_extent = mx.min(mz); - // Center the model on the origin in XZ; Y is handled by post-voxelize ground snap. + // Center XZ on origin; Y is set by the post-voxelize ground snap. let center_x = -(model_min[0] + model_max[0]) * 0.5; let center_z = -(model_min[2] + model_max[2]) * 0.5; @@ -313,7 +313,7 @@ pub fn place_stadium_models(editor: &mut WorldEditor, args: &Args, prescan: &Pre * block_per_meter * HEIGHT_MULTIPLIER; - // 90° pre-rotation when model is Z-long so its long axis lines up with X for per-axis scale. + // Pre-rotate Z-long models 90° so X is the effective long axis. let intrinsic_yaw_deg = if model_x_is_long { 0.0 } else { 90.0 }; let scale_x = target_long_blocks / model_long_extent; let scale_z = target_short_blocks / model_short_extent; From 7900dcce90f0d5e183af36d6c6d45dedbc7a42f8 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Wed, 27 May 2026 22:20:09 +0200 Subject: [PATCH 3/3] fix(models_3d): address Copilot PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify voxelize.rs comment: vertex colors modulate (not override) material. - Add MAX_LONG_EXTENT_M / MAX_SHORT_EXTENT_M / MAX_HEIGHT_M caps so mis-tagged whole-sports-complex polygons don't blow up voxelization. Mirrors Wikidata's extent caps. Test covers an oversize 1500×800 m leisure=stadium being rejected. --- src/models_3d/custom/stadium.rs | 29 +++++++++++++++++++++++++++-- src/models_3d/voxelize.rs | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/models_3d/custom/stadium.rs b/src/models_3d/custom/stadium.rs index 01bcafd85..307b95303 100644 --- a/src/models_3d/custom/stadium.rs +++ b/src/models_3d/custom/stadium.rs @@ -12,6 +12,10 @@ const MODEL_URL: &str = "https://arnismc.com/assets/3dmodels/stadium.glb"; const CACHE_FILE: &str = "stadium.glb"; const MIN_SHORT_EXTENT_M: f32 = 10.0; +/// Caps to avoid voxelizing absurd polygons (entire sports complexes mis-tagged as one stadium). +const MAX_LONG_EXTENT_M: f32 = 500.0; +const MAX_SHORT_EXTENT_M: f32 = 400.0; +const MAX_HEIGHT_M: f32 = 200.0; /// `leisure=stadium` qualifies on its own above this footprint area (m²). const LARGE_STADIUM_AREA_M2: f64 = 20_000.0; /// Smaller `leisure=stadium` needs an inner `building=stadium` to qualify. @@ -230,7 +234,7 @@ fn build_placement(element: &ProcessedElement, args_scale: f64) -> Option MAX_LONG_EXTENT_M || short_m > MAX_SHORT_EXTENT_M { return None; } @@ -239,7 +243,7 @@ fn build_placement(element: &ProcessedElement, args_scale: f64) -> Option> = reader.read_colors(0).map(|c| c.into_rgba_f32().collect());