diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index aeda61248..0007da415 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -109,6 +109,7 @@ pub fn commands() -> Vec { l.transparency = st.transparency; if relock { s.map_mut().label_lock = None; + s.map_mut().label_lock_line_anchors.clear(); } Ok(Value::Null) }) diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 4b38e8c08..a12ac7c6d 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -336,6 +336,7 @@ pub(crate) fn with_labels(s: &mut Session, p: &Value, f: impl FnOnce(&mut Labeli l.labels = labels; if relock { s.map_mut().label_lock = None; + s.map_mut().label_lock_line_anchors.clear(); } Ok(Value::Null) } @@ -358,7 +359,9 @@ pub(crate) fn lock_holds(a: &Labeling, b: &Labeling) -> bool { /// so another position, or trying the others turned on or off, locks the labels again; with no /// class trying them a new position keeps the lock, as before. fn same_places(a: &Labeling, b: &Labeling) -> bool { - let classes = |l: &Labeling| l.classes.iter().chain([&l.default]).map(|c| (c.placement, c.other_positions)).collect::>(); + let classes = |l: &Labeling| { + l.classes.iter().chain([&l.default]).map(|c| (c.placement, c.other_positions, c.repeat.is_some())).collect::>() + }; classes(a).into_iter().zip(classes(b)).all(|(x, y)| x == y || !(x.1 || y.1)) } diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 8d6041c1e..86b2b2456 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -269,6 +269,7 @@ fn duplicate_map(s: &mut Session, p: &Value) -> CmdResult { let mut m = s.project.maps.get(i).ok_or("no map")?.clone(); m.name = super::copy_name(ps(p, "name"), &m.name, |n| s.project.maps.iter().any(|x| x.name.eq_ignore_ascii_case(n))); m.label_lock = None; + m.label_lock_line_anchors.clear(); m.renew_instance_id(); for l in &mut m.layers { l.renew_instance_id(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f577024a2..63393469d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3174,6 +3174,16 @@ fn a_change_to_where_point_labels_go_locks_them_again() { assert!(s.map().label_lock.is_none(), "a style putting the labels elsewhere: locked again"); } +#[test] +fn changing_line_repeat_relocks_alternative_positions() { + let mut s = world(); + let id = s.map().layer_by_name("Rivers").unwrap().id; + s.map_mut().layer_mut(id).unwrap().labels.default.other_positions = true; + s.map_mut().label_lock = Some(vec![((id, 0, 0, 0), 1)]); + s.run("labels.placement", &json!({"layer": "Rivers", "repeat": 144})).unwrap(); + assert!(s.map().label_lock.is_none()); +} + /// A lock names each label by its feature's id, not its row: deleting a feature (the rows after /// it move up) neither shows a label left out when the lock was taken nor hides one placed (#1046). #[test] diff --git a/crates/map/src/camera.rs b/crates/map/src/camera.rs index 0d7507eda..f9a4b9d81 100644 --- a/crates/map/src/camera.rs +++ b/crates/map/src/camera.rs @@ -360,10 +360,10 @@ impl Camera { let z_far = self.unproject(w / 2.0, 0.0, w, h).map_or(t.d * FAR, |(_, v)| z_of(v)).min(t.d * FAR); let mut bands = Vec::new(); let mut z0 = z_near; - while z0 < z_far * 0.999 && bands.len() < MAX_BANDS { + while z0 < z_far * 0.999 && bands.len() < MAX_TILT_BANDS { let mut z1 = (z0 * 2.0).min(z_far); // The last band reaches the view's far end, whatever is left. - if z_far / z1 < 1.25 || bands.len() + 1 == MAX_BANDS { + if z_far / z1 < 1.25 || bands.len() + 1 == MAX_TILT_BANDS { z1 = z_far; } // Ground px per band px: the scale at the band's near edge. @@ -485,8 +485,8 @@ impl GroundLookup { } } -/// The most bands a tilted view is cut into. -const MAX_BANDS: usize = 8; +/// The most flat bands a tilted view is cut into. +pub const MAX_TILT_BANDS: usize = 8; /// The most area a band of a tilted view is drawn with, as a multiple of the screen area it /// covers. @@ -712,7 +712,7 @@ mod tests { c.set_pitch(70.0); for (w, h) in [(1.0, 1.0), (0.0, 0.0), (3.0, 1e-9), (800.0, 2.0)] { let bands = c.tilt_bands(w, h); - assert!(!bands.is_empty() && bands.len() <= MAX_BANDS, "{w}x{h}"); + assert!(!bands.is_empty() && bands.len() <= MAX_TILT_BANDS, "{w}x{h}"); } // A flat view is one band: itself. c.set_pitch(0.0); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index d73590f09..a2d4a1912 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -1230,6 +1230,9 @@ pub struct Map { /// the map moves. Filled by the renderer. #[serde(skip)] pub label_lock: Option>, + /// Geographic anchors of locked non-repeated line labels. Render cache only, never saved. + #[serde(skip)] + pub label_lock_line_anchors: std::collections::HashMap, /// Relationships, snapping, topology rules and edit settings. #[serde(default)] pub editing: design::MapEditing, @@ -1355,6 +1358,7 @@ impl Map { popup_mode: PopupMode::Topmost, label_display: LabelDisplay::default(), label_lock: None, + label_lock_line_anchors: Default::default(), editing: design::MapEditing::default(), swipe: None, view: view::ViewSettings::default(), diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index d12b471c1..da48a9269 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -2044,8 +2044,9 @@ pub struct LabelClass { /// Where a label sits relative to its feature. #[serde(default)] pub placement: LabelPlacement, - /// A point's label taken by another tries the other places round the point - /// ([`LabelPlacement::alternatives`]) before it is left out (#1011). Off by default: placed + /// A label taken by another tries nearby positions before it is left out (#1011): + /// round a point ([`LabelPlacement::alternatives`]), or along a non-repeated line. + /// Off by default: placed /// elsewhere, a label may take the place another has today. #[serde(default, deserialize_with = "de_flag", skip_serializing_if = "std::ops::Not::not")] pub other_positions: bool, @@ -2470,7 +2471,7 @@ impl LabelPlacement { /// The places round a point a label tries after this one, when [`LabelClass::other_positions`] /// is on: in QGIS's cartographic order (its `DEFAULT_PLACEMENT_ORDER`), corners first, this - /// one left out. + /// one left out. Line alternatives are generated along their geometry by the renderer. pub fn alternatives(self) -> impl Iterator { [ LabelPlacement::AboveRight, diff --git a/crates/render/src/label_places.rs b/crates/render/src/label_places.rs new file mode 100644 index 000000000..826cde7cf --- /dev/null +++ b/crates/render/src/label_places.rs @@ -0,0 +1,136 @@ +//! Deferred curved placements: transformations are retained until collision placement needs them. +use super::*; +use std::sync::Arc; + +type Projection = Arc Option<(f32, f32)> + Send + Sync>; + +#[derive(Clone)] +enum Change { + Project(Projection), + PlaceShifts(Vec<(f32, f32)>), +} + +#[derive(Debug)] +pub(super) struct CurveSpec { + pub line: Arc, + pub chars: Vec, + pub advances: Vec, + pub gap: f32, + pub overrun: f32, +} + +#[derive(Clone)] +pub(super) struct Curves { + pub spec: Arc, + pub positions: Vec, + pub locked: Option, + changes: Vec, +} + +impl std::fmt::Debug for Curves { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Curves").field("positions", &self.positions).field("locked", &self.locked).finish_non_exhaustive() + } +} + +impl Curves { + pub fn new(spec: Arc, positions: Vec, locked: Option) -> Self { + Self { spec, positions, locked, changes: Vec::new() } + } + + pub fn project(&mut self, at: impl Fn(f32, f32) -> Option<(f32, f32)> + Send + Sync + 'static) { + self.changes.push(Change::Project(Arc::new(at))); + } + + /// Compute each placement's relief shift at its own transformed anchor, without laying out text. + pub fn shift_each(&mut self, offset: &impl Fn(f32, f32) -> Option<(f32, f32)>) { + let shifts = self + .positions + .iter() + .enumerate() + .map(|(i, d)| { + let Some((mut x, mut y)) = self.spec.line.anchor(*d) else { return (0.0, 0.0) }; + for change in &self.changes { + match change { + Change::Project(at) => { + let Some(p) = at(x, y) else { return (0.0, 0.0) }; + (x, y) = p; + } + Change::PlaceShifts(shifts) => { + if let Some((dx, dy)) = shifts.get(i) { + x += dx; + y += dy; + } + } + } + } + offset(x, y).unwrap_or((0.0, 0.0)) + }) + .collect(); + self.changes.push(Change::PlaceShifts(shifts)); + } + + /// The first in-sight preview, with its own line direction projected at its source anchor; + /// no character layout. Even a too-short stretch has a tangent for View Unplaced. + pub fn preview_in(&self, template: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> Option { + let width = line_labels::curved_len(&self.spec.advances, self.spec.gap); + for (i, d) in self.positions.iter().enumerate() { + if self.locked.is_some_and(|won| won != i) { + continue; + } + let Some((x, y)) = self.spec.line.anchor(*d) else { continue }; + let angle = + self.spec.line.parallel(width, self.spec.overrun, Some(*d)).map(|(_, _, a)| a).or_else(|| self.spec.line.direction(*d)); + let mut preview = LabelPlace { x, y, angle, glyphs: Vec::new() }; + if preview.transform(template, at) { + return Some(preview); + } + } + None + } + + pub fn place(&self, i: usize, template: &mut PlacedLabel) -> Option { + if self.locked.is_some_and(|won| won != i) { + return None; + } + let at = *self.positions.get(i)?; + #[cfg(test)] + LAYOUTS.with(|n| n.set(n.get() + 1)); + let ((x, y), turns) = self.spec.line.curved(&self.spec.advances, self.spec.gap, self.spec.overrun, Some(at))?; + // Reuse the preceding rejected place's vector and String capacities. The accepted + // middle preview retains its own glyphs; later failed attempts can share this pool. + let mut glyphs = std::mem::take(&mut template.glyphs); + let mut count = 0; + for (i, (text, (dx, dy, angle))) in self.spec.chars.iter().zip(turns).enumerate() { + if let Some(glyph) = glyphs.get_mut(i) { + glyph.text.clone_from(text); + (glyph.dx, glyph.dy, glyph.angle) = (dx, dy, angle); + } else { + glyphs.push(LabelGlyph { text: text.clone(), dx, dy, angle }); + } + count += 1; + } + glyphs.truncate(count); + let mut p = LabelPlace { x, y, angle: None, glyphs }; + for change in &self.changes { + match change { + Change::Project(at) => { + if !p.transform(template, &|x, y| at(x, y)) { + return None; + } + } + Change::PlaceShifts(shifts) => { + if let Some((dx, dy)) = shifts.get(i) { + (p.x, p.y) = (p.x + dx, p.y + dy); + } + } + } + } + Some(p) + } +} + +#[cfg(test)] +thread_local! { + pub(super) static LAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 88628d562..3bc3e96d1 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1474,6 +1474,7 @@ pub fn frame_coverage(map: &mut Map, center: Coord, scale: f64, w_mm: f64, h_mm: tiles: Vec, graticule: bool, lock: Option>, + anchors: std::collections::HashMap, map: &'a mut Map, } impl Drop for Aside<'_> { @@ -1482,6 +1483,7 @@ pub fn frame_coverage(map: &mut Map, center: Coord, scale: f64, w_mm: f64, h_mm: self.map.tiles = std::mem::take(&mut self.tiles); self.map.graticule = self.graticule; self.map.label_lock = self.lock.take(); + self.map.label_lock_line_anchors = std::mem::take(&mut self.anchors); } } let aside = Aside { @@ -1489,6 +1491,7 @@ pub fn frame_coverage(map: &mut Map, center: Coord, scale: f64, w_mm: f64, h_mm: tiles: std::mem::take(&mut map.tiles), graticule: std::mem::replace(&mut map.graticule, false), lock: map.label_lock.take(), + anchors: std::mem::take(&mut map.label_lock_line_anchors), map, }; let pm = crate::render(&mut *aside.map, &view)?.pixmap; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 97b82cf47..92198e474 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ pub mod fonts; mod glyphs; pub mod gpu_scene; mod gradient_fill; +mod label_places; pub mod layout; mod line_labels; mod line_markers; @@ -1847,23 +1848,95 @@ fn visit_complete( } /// A label waiting to be placed: the label, its priority and its key, and where else it may go -/// when its own place is taken (#1011): its `x`, `y` there, tried in order. Whatever moves the -/// label on its way to the screen moves them too, as it does its icon. `row` is its feature's -/// row where it was offered: only a hint, checked against the key's feature id before use -/// (a streamed layer's rows are a chunk's), `usize::MAX` for none. +/// when its own place is taken: position, angle and curved glyphs, tried in order. Whatever +/// moves the label on its way to the screen moves each place's geometry too. #[derive(Clone, Debug)] struct Candidate { label: PlacedLabel, priority: f64, key: rustgis_map::LabelKey, - alternatives: Vec<(f32, f32)>, + // Ready slots and deferred curve positions are mutually exclusive. Slot 0 is the + // own place; alternatives keep slots 1.. even when invalid (locks name these slots). + alternatives: Vec, + curves: Option, + line: Option, + /// Offered row hint (a streamed chunk's row), verified against the feature id before use; + /// `usize::MAX` when no row is known. row: usize, } -/// A label with no alternatives and no row. +/// Alternatives from one line stretch; band siblings retain one top-level candidate slot. +#[derive(Clone, Debug, Default)] +struct LinePlaces { + anchors: Vec>, + deduplicate: bool, + // Other camera bands for the same key, each retaining its own placement slots. + fallbacks: Vec, +} + +/// Geometry of a possible text placement; style and the stationary icon belong to the candidate. +#[derive(Clone, Debug, PartialEq)] +struct LabelPlace { + x: f32, + y: f32, + angle: Option, + glyphs: Vec, +} + +/// Point places only: these labels stay upright and never carry an angle or curved glyphs. +impl From<(f32, f32)> for LabelPlace { + fn from((x, y): (f32, f32)) -> Self { + Self { x, y, angle: None, glyphs: Vec::new() } + } +} + +impl LabelPlace { + #[cfg(test)] + fn of(l: &PlacedLabel) -> Self { + Self { x: l.x, y: l.y, angle: l.angle, glyphs: l.glyphs.clone() } + } + + /// Move glyph ownership out; the label has no glyphs until a place is swapped back in. + fn take(l: &mut PlacedLabel) -> Self { + Self { x: l.x, y: l.y, angle: l.angle, glyphs: std::mem::take(&mut l.glyphs) } + } + + fn swap(&mut self, l: &mut PlacedLabel) { + std::mem::swap(&mut self.x, &mut l.x); + std::mem::swap(&mut self.y, &mut l.y); + std::mem::swap(&mut self.angle, &mut l.angle); + std::mem::swap(&mut self.glyphs, &mut l.glyphs); + } + + /// Transform this place using the candidate's font metrics, without moving its icon. + fn transform(&mut self, l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> bool { + let Some((x, y)) = at(self.x, self.y) else { return false }; + if !x.is_finite() || !y.is_finite() { + return false; + } + // A character left in the source view would be drawn at a stale offset. Reject the + // whole place, including a missing baseline direction, before mutating the template. + if self + .glyphs + .iter() + .any(|g| at(self.x + g.dx, self.y + g.dy).is_none() || turned_angle(self.x + g.dx, self.y + g.dy, g.angle, at).is_none()) + { + return false; + } + let icon = l.icon.take(); + self.swap(l); + turn_with(l, at); + (l.x, l.y) = (x, y); + self.swap(l); + l.icon = icon; + true + } +} + +/// A label with no alternatives. impl From<(PlacedLabel, f64, rustgis_map::LabelKey)> for Candidate { fn from((label, priority, key): (PlacedLabel, f64, rustgis_map::LabelKey)) -> Candidate { - Candidate { label, priority, key, alternatives: Vec::new(), row: usize::MAX } + Candidate { label, priority, key, alternatives: Vec::new(), curves: None, line: None, row: usize::MAX } } } @@ -2104,19 +2177,31 @@ fn affine(src: [(f64, f64); 3], dst: [(f64, f64); 3]) -> Option { fn lift_labels(view: &View, ground: &terrain::Ground, candidates: &mut [Candidate]) { let cam = view.camera; let (w, h) = (view.width, view.height); - for Candidate { label: l, alternatives, .. } in candidates.iter_mut() { - let Some(m) = cam.try_to_map(f64::from(l.x), f64::from(l.y), w, h) else { continue }; + let offset = |x: f32, y: f32| { + let m = cam.try_to_map(f64::from(x), f64::from(y), w, h)?; let (u, v) = cam.ground_px(m); - let Some((sx, sy)) = cam.project_raised(u, v, ground.up_px(m, &cam), w, h) else { continue }; - let (dx, dy) = (sx as f32 - l.x, sy as f32 - l.y); - if !(dx.is_finite() && dy.is_finite()) { - continue; + let (sx, sy) = cam.project_raised(u, v, ground.up_px(m, &cam), w, h)?; + let (dx, dy) = (sx as f32 - x, sy as f32 - y); + (dx.is_finite() && dy.is_finite()).then_some((dx, dy)) + }; + for c in candidates.iter_mut() { + if let Some((dx, dy)) = offset(c.label.x, c.label.y) { + c.label.x += dx; + c.label.y += dy; + if let Some(icon) = &mut c.label.icon { + icon.x += dx; + icon.y += dy; + } } - (l.x, l.y) = (sx as f32, sy as f32); - if let Some(i) = &mut l.icon { - (i.x, i.y) = (i.x + dx, i.y + dy); + for place in &mut c.alternatives { + if let Some((dx, dy)) = offset(place.x, place.y) { + place.x += dx; + place.y += dy; + } + } + if let Some(curves) = &mut c.curves { + curves.shift_each(&offset); } - alternatives.iter_mut().for_each(|(x, y)| (*x, *y) = (*x + dx, *y + dy)); } } @@ -2228,9 +2313,10 @@ fn gpu_turned(map: &mut Map, view: &View, full_res_rasters: bool, sources_ready: Some(GpuScene { clear, cmds, labels: placed.labels, unplaced, attribution, tiles_pending, data_pending, tile_errors, tilt, clips }) } -/// Places the labels a tilted view's bands offered. A feature drawn in two bands offers its -/// label twice: the nearer band's (offered first) is kept. Tile labels (keyed by their place in a -/// band's list) are told apart by their text and where they land, to a few points. Extruded +/// Places the labels a tilted view's bands offered, nearest band first. Non-repeated line +/// alternatives keep bounded band fallbacks until one fits; other features keep the nearer +/// band's offer. Tile labels (keyed by their place in a band's list) are told apart by their +/// text and where they land, to a few points. Extruded /// features' labels go up onto their roofs, and on terrain every label onto the ground's relief. fn place_tilted_labels(map: &Map, view: &View, mut candidates: Vec, ground: Option<&terrain::Ground>) -> Placement { let mut seen = std::collections::HashSet::new(); @@ -2242,13 +2328,34 @@ fn place_tilted_labels(map: &Map, view: &View, mut candidates: Vec, g // A line's repeats: each band numbers those on its own stretch, so they are told apart by // where they land (the same repeat in two bands lands in one place). (l, r, k, i) if i > 0 => seen_repeat.insert((l, r, k, near(c))), + // Non-repeated line alternatives claim the key only once a band is placed. + _ if c.line.is_some() => true, key => seen.insert(key), }); raise_labels(map, view, &mut candidates); if let Some(g) = ground { lift_labels(view, g, &mut candidates); } - place_labels(candidates, Vec::new(), view.width as f32, view.height as f32, false) + // Group bands under one candidate slot, retaining their near-first order. A nearer + // band's failed places must not suppress a later band's usable ones. + let mut groups: Vec = Vec::new(); + let mut line_groups = std::collections::HashMap::new(); + for mut candidate in candidates { + if let Some(line) = &mut candidate.line { + line.deduplicate = true; + if let Some(&index) = line_groups.get(&candidate.key) + && let Some(first) = groups.get_mut(index).and_then(|c: &mut Candidate| c.line.as_mut()) + { + if first.fallbacks.len() < rustgis_map::camera::MAX_TILT_BANDS.saturating_sub(1) { + first.fallbacks.push(candidate); + } + continue; + } + line_groups.insert(candidate.key, groups.len()); + } + groups.push(candidate); + } + place_labels_with_previews(groups, Vec::new(), view.width as f32, view.height as f32, false, map.label_display.show_unplaced) } /// Whether a turned or tilted `view` of `map` may be drawn on the GPU ([`gpu_turned`]): only @@ -2311,33 +2418,48 @@ fn raise_labels(map: &Map, view: &View, candidates: &mut [Candidate]) { let (w, h) = (view.width, view.height); // Each extruded layer's rows by feature id, the label keys naming features by id (#1046). let mut rows: std::collections::HashMap> = std::collections::HashMap::new(); - for Candidate { label: l, key: (id, fid, _, _), alternatives, row: offered, .. } in candidates.iter_mut() { - let Some(layer) = map.layer(*id).filter(|l| l.geometry_type() == GeometryType::Polygon) else { continue }; + for c in candidates.iter_mut() { + let (id, fid, _, _) = c.key; + let offered = c.row; + let l = &mut c.label; + let Some(layer) = map.layer(id).filter(|l| l.geometry_type() == GeometryType::Polygon) else { continue }; let Some(x) = &layer.extrusion else { continue }; let features = &layer.dataset.features; // The row it was offered from while its feature is there, else looked up by id; one not // among them, as a streamed layer's, rises by the height no field gives. - let row = Some(*offered) - .filter(|&r| features.get(r).is_some_and(|f| f.id == *fid)) + let row = Some(offered) + .filter(|&r| features.get(r).is_some_and(|f| f.id == fid)) .or_else(|| { - let by_id = rows.entry(*id).or_insert_with(|| features.iter().enumerate().map(|(r, f)| (f.id, r)).collect()); - by_id.get(fid).copied() + let by_id = rows.entry(id).or_insert_with(|| features.iter().enumerate().map(|(r, f)| (f.id, r)).collect()); + by_id.get(&fid).copied() }) .unwrap_or(usize::MAX); - let Some(m) = cam.try_to_map(f64::from(l.x), f64::from(l.y), w, h) else { continue }; - // At the label's place (Web Mercator stretches the ground toward the poles). - let up = (effects::extrusion_metres(&layer.dataset, x, row) - / (flat.ground_per_unit(m) * map.body().ratio()).max(1e-9) - / cam.res.max(1e-9)) - .clamp(0.0, 1e7); - let (u, v) = cam.ground_px(m); - let Some((sx, sy)) = cam.project_raised(u, v, up, w, h) else { continue }; - let (dx, dy) = (sx as f32 - l.x, sy as f32 - l.y); - (l.x, l.y) = (sx as f32, sy as f32); - if let Some(i) = &mut l.icon { - (i.x, i.y) = (i.x + dx, i.y + dy); + let metres = effects::extrusion_metres(&layer.dataset, x, row); + let offset = |x: f32, y: f32| { + let m = cam.try_to_map(f64::from(x), f64::from(y), w, h)?; + let up = (metres / (flat.ground_per_unit(m) * map.body().ratio()).max(1e-9) / cam.res.max(1e-9)).clamp(0.0, 1e7); + let (u, v) = cam.ground_px(m); + let (sx, sy) = cam.project_raised(u, v, up, w, h)?; + let delta = (sx as f32 - x, sy as f32 - y); + (delta.0.is_finite() && delta.1.is_finite()).then_some(delta) + }; + if let Some((dx, dy)) = offset(l.x, l.y) { + l.x += dx; + l.y += dy; + if let Some(icon) = &mut l.icon { + icon.x += dx; + icon.y += dy; + } + } + for p in &mut c.alternatives { + if let Some((dx, dy)) = offset(p.x, p.y) { + p.x += dx; + p.y += dy; + } + } + if let Some(curves) = &mut c.curves { + curves.shift_each(&offset); } - alternatives.iter_mut().for_each(|(x, y)| (*x, *y) = (*x + dx, *y + dy)); } } @@ -3107,6 +3229,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let display = map.label_display; if !display.locked { map.label_lock = None; + map.label_lock_line_anchors.clear(); } let lock: Option> = map.label_lock.as_ref().map(|v| v.iter().copied().collect()); // The classes of features with a label locked (any of its repeats), to pass the rest over early. @@ -3668,7 +3791,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let is_point = g.kind() == GeometryType::Point; // A line's longest part on the screen, for the classes placed along it: worked // out once (its path each way too), for the first that asks. - let mut screen_line: Option> = None; + let mut screen_line: Option>> = None; let marker = if is_point { styler.symbol(row).size } else { 0.0 }; // Bigger polygons get priority. let area = match g { @@ -3731,36 +3854,123 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Where it goes, each with its repeat's index: by its feature (0), or along // a line at its middle and, repeated, every so far either side. let mut places: Vec<(usize, f32, f32, Option, Vec)> = Vec::new(); + let mut curves = None; + let mut line_alternatives = Vec::new(); + let mut line_places = None; if let (LabelPlacement::Parallel | LabelPlacement::Curved, Geometry::Line(parts)) = (class.placement, g) { - let line = - screen_line.get_or_insert_with(|| longest_on_screen(ctx, parts).and_then(|l| line_labels::Line::new(&l))); - let Some(line) = line.as_ref() else { continue }; - // It may run past the line's ends by about a letter, as QGIS's overrun. - let overrun = size; + let key = (lid, label_id(row), ci, 0); let repeat = class.repeat.map(|r| r * 1.33 * label_factor); - let along = line_labels::repeats(line.length(), repeat); - // A text too long to curve character by character goes straight. - if !curving { - let width = text::spaced_block_width(&text, size, face, spacing); - for (i, at) in along { - if let Some((cx, cy, a)) = line.parallel(width, overrun, Some(at)) { - places.push((i, cx, cy, Some(a), Vec::new())); - } - } + let try_others = class.other_positions && repeat.is_none(); + let saved_anchor = lock.as_ref().and(map.label_lock_line_anchors.get(&key)).copied().filter(|_| try_others); + // A lock naming this label without its saved anchor (a failed back-projection) has + // no place to hold: sliding it to whatever stretch shows now would move it. + if try_others && saved_anchor.is_none() && lock.as_ref().is_some_and(|k| k.contains_key(&key)) { + continue; + } + let (line, locked_at) = if let Some(anchor) = saved_anchor { + let target = match &globe { + Some(globe) => globe.forward(anchor), + None => flat.forward(anchor).map(|p| view.to_screen(p)), + }; + let Some((x, y)) = target else { continue }; + let Some((line, at)) = line_near_anchor(ctx, parts, (x as f32, y as f32), size) else { continue }; + (std::sync::Arc::new(line), Some(at)) } else { - // Each character with its accents, drawn as one. + let line = screen_line.get_or_insert_with(|| { + longest_on_screen(ctx, parts).and_then(|l| line_labels::Line::new(&l)).map(std::sync::Arc::new) + }); + let Some(line) = line.as_ref() else { continue }; + (line.clone(), None) + }; + let overrun = size; + let positions = |width| match locked_at { + Some(at) => { + let won = lock.as_ref().and_then(|keys| keys.get(&key)).copied().unwrap_or(0); + vec![at; (usize::from(won) + 1).min(8)] + } + None => line_labels::positions(line.length(), width, overrun), + }; + let anchors = |positions: &[f32]| { + // Back-project only while locking is requested, never on ordinary frames or replay. + if !display.locked || lock.is_some() || matches!(labels_to, Labels::Tilted(..)) { + return Vec::new(); + } + positions + .iter() + .map(|d| { + let (x, y) = line.anchor(*d)?; + match &globe { + Some(globe) => globe.inverse(f64::from(x), f64::from(y)), + None => flat.inverse(view.camera.to_map(f64::from(x), f64::from(y), view.width, view.height)), + } + }) + .collect() + }; + if curving { let chars: Vec = text.graphemes(true).map(String::from).collect(); let advances: Vec = chars.iter().map(|c| text::measure_in(c, size, face)).collect(); - for (i, at) in along { - let Some(((cx, cy), turns)) = line.curved(&advances, spacing * 1.33, overrun, Some(at)) else { + if try_others { + let width = line_labels::curved_len(&advances, spacing * 1.33); + let positions = positions(width); + // A saved slot the curve cannot take has no truthful preview either (as for a + // straight line): never draw one at the anchor or the middle. + if lock.as_ref().and_then(|k| k.get(&key)).is_some_and(|won| usize::from(*won) >= positions.len()) { continue; - }; - let glyphs = chars - .iter() - .zip(turns) - .map(|(text, (dx, dy, angle))| LabelGlyph { text: text.clone(), dx, dy, angle }) - .collect(); - places.push((i, cx, cy, None, glyphs)); + } + line_places = Some(LinePlaces { anchors: anchors(&positions), ..Default::default() }); + let Some((cx, cy)) = line.anchor(locked_at.unwrap_or(line.length() / 2.0)) else { continue }; + let angle = line.parallel(width, overrun, locked_at).map(|(_, _, a)| a); + // The preview is also View Unplaced's middle when every curve fails. + places.push((0, cx, cy, angle, Vec::new())); + let locked = lock.as_ref().and_then(|k| k.get(&key)).map(|won| usize::from(*won)); + curves = Some(label_places::Curves::new( + std::sync::Arc::new(label_places::CurveSpec { + line: line.clone(), + chars, + advances, + gap: spacing * 1.33, + overrun, + }), + positions, + locked, + )); + } else { + for (i, at) in line_labels::repeats(line.length(), repeat) { + let Some(((cx, cy), turns)) = line.curved(&advances, spacing * 1.33, overrun, Some(at)) else { + continue; + }; + let glyphs = chars + .iter() + .zip(turns) + .map(|(text, (dx, dy, angle))| LabelGlyph { text: text.clone(), dx, dy, angle }) + .collect(); + places.push((i, cx, cy, None, glyphs)); + } + } + } else { + // A text too long to curve character by character also goes straight. + let width = text::spaced_block_width(&text, size, face, spacing); + let along = if try_others { + let positions = positions(width); + line_places = Some(LinePlaces { anchors: anchors(&positions), ..Default::default() }); + positions.into_iter().enumerate().collect() + } else { + line_labels::repeats(line.length(), repeat) + }; + // Alternatives share one width/overrun and stay inside upright's + // range: if the middle cannot fit, none can. Keep it as place 0 + // so a lock's alternative indices do not change. + for (i, at) in along { + if let Some((x, y, angle)) = line.parallel(width, overrun, Some(at)) { + if try_others && i > 0 { + line_alternatives.push(LabelPlace { x, y, angle: Some(angle), glyphs: Vec::new() }); + } else { + places.push((i, x, y, Some(angle), Vec::new())); + } + } else if try_others && i > 0 { + // Keep position/anchor indices aligned even when this slot cannot fit. + line_alternatives.push(LabelPlace { x: f32::NAN, y: f32::NAN, angle: None, glyphs: Vec::new() }); + } } } } else { @@ -3780,7 +3990,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // its label at the middle, nearest first (lines have no area). let pri = (n_layers - li) as f64 * 1e12 - ci as f64 * 1e9 + area; let last = places.len().saturating_sub(1); - for (n, (i, mut lx, mut ly, angle, glyphs)) in places.into_iter().enumerate() { + for (n, (i, mut lx, mut ly, mut angle, mut glyphs)) in places.into_iter().enumerate() { let key = (lid, label_id(row), ci, i); // Locked, only the repeats placed then. if lock.as_ref().is_some_and(|k| !k.contains_key(&key)) { @@ -3788,15 +3998,33 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } // A point's label may go round it when its own place is taken (#1011); // locked, it goes where it went then, and nowhere else. - let others = || class.placement.alternatives().filter(|_| class.other_positions && is_point).map(place); - let alternatives: Vec<(f32, f32)> = match lock.as_ref().and_then(|k| k.get(&key)) { + let others = || { + if is_point { + class + .placement + .alternatives() + .filter(|_| class.other_positions) + .map(place) + .map(LabelPlace::from) + .collect::>() + } else { + line_alternatives.clone() + } + }; + let alternatives = match lock.as_ref().and_then(|k| k.get(&key)) { Some(&won) => { - if let Some(at) = usize::from(won).checked_sub(1).and_then(|i| others().nth(i)) { - (lx, ly) = at; + if let Some(at) = usize::from(won).checked_sub(1).and_then(|i| others().into_iter().nth(i)) { + (lx, ly, angle, glyphs) = (at.x, at.y, at.angle, at.glyphs); + } else if won > 0 && !is_point && curves.is_none() { + // Do not slide a locked line to its middle when its saved place + // cannot be represented. Without a valid saved place there is also + // no truthful View Unplaced preview; never invent one at the middle. + // Point alternatives have a fixed shape. + continue; } Vec::new() } - None => others().collect(), + None => others(), }; // (The text copied for each repeat but the last, which takes it.) let text = if n == last { std::mem::take(&mut text) } else { text.clone() }; @@ -3823,13 +4051,15 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac background: class .background .as_ref() - .filter(|b| b.draws() && glyphs.is_empty()) + .filter(|b| b.draws() && glyphs.is_empty() && curves.is_none()) .map(|b| b.scaled(label_factor)), glyphs, }, priority: pri - i as f64, key, alternatives, + curves: curves.take(), + line: line_places.take(), row, }); } @@ -3909,10 +4139,11 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if let Labels::Tilted(to, out) = &mut labels_to { // At their places in the tilted view; those behind the camera, under the sky or too far // to read are left out. + let (from, to) = (*view, **to); let (tw, th) = (to.width, to.height); let sky_y = to.camera.sky_y(th).unwrap_or(0.0) as f32; - let at = |x: f32, y: f32| -> Option<(f32, f32)> { - let m = view.camera.to_map(f64::from(x), f64::from(y), view.width, view.height); + let at = move |x: f32, y: f32| -> Option<(f32, f32)> { + let m = from.camera.to_map(f64::from(x), f64::from(y), from.width, from.height); let near = to.camera.depth_scale(m, th).is_some_and(|k| k >= LABEL_MIN_DEPTH_SCALE); let (sx, sy) = to.camera.try_to_screen(m, tw, th).filter(|_| near)?; (sy as f32 > sky_y).then_some((sx as f32, sy as f32)) @@ -3930,29 +4161,35 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac }; let (lw, lh) = match turned { Some(to) => { - // Into the turned view's screen, where they stay upright. - let at = |x: f32, y: f32| { - let (sx, sy) = to.to_screen(view.camera.to_map(f64::from(x), f64::from(y), view.width, view.height)); - (sx as f32, sy as f32) + // Into the turned view's screen, where point labels stay upright. + let (from, target) = (*view, *to); + let at = move |x: f32, y: f32| { + let (sx, sy) = target.to_screen(from.camera.to_map(f64::from(x), f64::from(y), from.width, from.height)); + Some((sx as f32, sy as f32)) }; - for l in label_candidates.iter_mut().map(|c| &mut c.label).chain(fixed_labels.iter_mut()) { - turn_with(l, &|x, y| Some(at(x, y))); - (l.x, l.y) = at(l.x, l.y); - if let Some(i) = &mut l.icon { - (i.x, i.y) = at(i.x, i.y); - } + for c in &mut label_candidates { + turn_candidate(c, at); } - for (x, y) in label_candidates.iter_mut().flat_map(|c| &mut c.alternatives) { - (*x, *y) = at(*x, *y); + for l in &mut fixed_labels { + turn_with(l, &at); + if let Some((x, y)) = at(l.x, l.y) { + (l.x, l.y) = (x, y); + } + if let Some(i) = &mut l.icon + && let Some((x, y)) = at(i.x, i.y) + { + (i.x, i.y) = (x, y); + } } (to.width, to.height) } None => (view.width, view.height), }; - let placed = place_labels(label_candidates, fixed_labels, lw as f32, lh as f32, lock.is_some()); + let placed = place_labels_with_previews(label_candidates, fixed_labels, lw as f32, lh as f32, lock.is_some(), display.show_unplaced); // (The lock is never taken from a tilted view: its labels' places out of sight are dropped, // so the numbers of the places they take aren't those of their class's.) if display.locked && map.label_lock.is_none() && !matches!(labels_to, Labels::Tilted(..)) { + map.label_lock_line_anchors = placed.line_anchors.into_iter().collect(); map.label_lock = Some(placed.keys); } let unplaced = if display.show_unplaced { placed.unplaced } else { Vec::new() }; @@ -4406,6 +4643,7 @@ pub fn render_globe_drape(map: &mut Map, view: &View, boxes: &[Extent], max_side let max_side = f64::from(max_side.clamp(64, 8192)); let projection = std::mem::replace(&mut map.projection, rustgis_map::Projection::WebMercator); let (display, lock) = (map.label_display, map.label_lock.take()); + let anchors = std::mem::take(&mut map.label_lock_line_anchors); map.label_display.paused = true; let mut out = Vec::new(); for b in boxes { @@ -4433,6 +4671,7 @@ pub fn render_globe_drape(map: &mut Map, view: &View, boxes: &[Extent], max_side map.projection = projection; map.label_display = display; map.label_lock = lock; + map.label_lock_line_anchors = anchors; out } @@ -4464,6 +4703,7 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) }; sync_tilted(map, view, &bands, false); let (display, lock) = (map.label_display, map.label_lock.take()); + let anchors = std::mem::take(&mut map.label_lock_line_anchors); map.label_display.paused = true; let mut out = Vec::new(); for b in bands { @@ -4483,6 +4723,7 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) } map.label_display = display; map.label_lock = lock; + map.label_lock_line_anchors = anchors; out } @@ -4507,22 +4748,82 @@ fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option, parts: &[Vec], anchor: (f32, f32), overrun: f32) -> Option<(line_labels::Line, f32)> { + let mut best = None; + let mut distance = f32::INFINITY; + for part in parts { + let points: Vec<_> = part.iter().map(|p| ctx.screen(*p).map(|(x, y)| (x as f32, y as f32))).collect(); + for run in points.split(|p| p.is_none_or(|(x, y)| !x.is_finite() || !y.is_finite())) { + let run: Vec<_> = run.iter().flatten().copied().collect(); + if let Some(line) = line_labels::Line::new(&run) + && let Some((d, at)) = line.nearest(anchor, overrun) + && d < distance + { + distance = d; + best = Some((line, at)); + } + } + } + best +} + /// A label in a tilted view, at its place there (`at`: a place's in the view, if in sight). Its /// own place, or else the first of its alternatives in sight, is where it goes; the rest in sight /// are left to try. Left out with none in sight, or its icon out of sight. -fn tilt_candidate(mut c: Candidate, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> Option { - let l = &mut c.label; - turn_with(l, at); - let alternatives = std::mem::take(&mut c.alternatives); - let mut spots = std::iter::once((l.x, l.y)).chain(alternatives).filter_map(|(x, y)| at(x, y)); - (l.x, l.y) = spots.next()?; - if let Some(i) = &mut l.icon { +fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32)> + Clone + Send + Sync + 'static)) -> Option { + if let Some(curves) = &mut c.curves { + let mut preview = curves.preview_in(&mut c.label, at)?; + preview.swap(&mut c.label); + curves.project(at.clone()); + } else { + let mut first = None; + let mut alternatives = Vec::new(); + for mut p in std::iter::once(LabelPlace::take(&mut c.label)).chain(std::mem::take(&mut c.alternatives)) { + if p.transform(&mut c.label, at) { + if first.is_none() { + first = Some(p); + } else { + alternatives.push(p); + } + } + } + first?.swap(&mut c.label); + c.alternatives = alternatives; + } + if let Some(i) = &mut c.label.icon { + if let Some(a) = i.angle { + i.angle = Some(turned_angle(i.x, i.y, a, at).unwrap_or(a)); + } (i.x, i.y) = at(i.x, i.y)?; } - c.alternatives = spots.collect(); Some(c) } +/// Project every possible text place, retaining deferred curved layouts until placement. +/// `at` must succeed for the main label and its icon (the turned-view caller is infallible). +/// Alternative glyph positions may fail; their slots stay invalid rather than being renumbered. +fn turn_candidate(c: &mut Candidate, at: impl Fn(f32, f32) -> Option<(f32, f32)> + Clone + Send + Sync + 'static) { + turn_with(&mut c.label, &at); + if let Some((x, y)) = at(c.label.x, c.label.y) { + (c.label.x, c.label.y) = (x, y); + } + if let Some(i) = &mut c.label.icon + && let Some((x, y)) = at(i.x, i.y) + { + (i.x, i.y) = (x, y); + } + for p in &mut c.alternatives { + if !p.transform(&mut c.label, &at) { + // Preserve the slot number for locks, but never place source-view geometry. + (p.x, p.y) = (f32::NAN, f32::NAN); + } + } + if let Some(curves) = &mut c.curves { + curves.project(at); + } +} + /// The one band a flat view turned to a bearing is draped on: facing north, square over the /// circle round the view, so turning it stays on the band (its tiles and pictures the same at /// every bearing). `None` for a view north up or tilted. @@ -4540,15 +4841,18 @@ pub fn turn_label(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f3 turn_with(l, at); } +/// Direction of a short step after projection into another view. +fn turned_angle(x: f32, y: f32, a: f32, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> Option { + let ((x0, y0), (x1, y1)) = (at(x, y)?, at(x + 4.0 * a.cos(), y + 4.0 * a.sin())?); + let b = (y1 - y0).atan2(x1 - x0); + b.is_finite().then_some(b) +} + /// Turns a label turned with the map (and its icon) as `at` moves it into another view (a /// turned or tilted one): its angle there is that of a short step along it, the text kept /// upright. Called before the label itself is moved. fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) { - let turned = |x: f32, y: f32, a: f32| -> Option { - let ((x0, y0), (x1, y1)) = (at(x, y)?, at(x + 4.0 * a.cos(), y + 4.0 * a.sin())?); - let b = (y1 - y0).atan2(x1 - x0); - b.is_finite().then_some(b) - }; + let turned = |x, y, a| turned_angle(x, y, a, at); if let Some(a) = l.angle { let x = l.center_x(); l.angle = Some(turned(x, l.y, a).map_or(a, rustgis_tiles::symbols::upright)); @@ -4613,6 +4917,7 @@ struct Placement { /// The key of each placed label, and the place it took (0 its own, else its alternative's /// number from 1). keys: Vec<(rustgis_map::LabelKey, u8)>, + line_anchors: Vec<(rustgis_map::LabelKey, Coord)>, } /// Greedy collision-free placement, highest priority first. Locked labels are all placed (they @@ -4627,7 +4932,20 @@ struct Placement { /// /// A label with alternatives (#1011) tries its own place, then each of them in turn, and takes /// the first where all of it shows; where none does, its own place shows what fits there. +#[cfg(test)] fn place_labels(cands: impl IntoIterator>, fixed: Vec, w: f32, h: f32, locked: bool) -> Placement { + place_labels_with_previews(cands, fixed, w, h, locked, true) +} + +/// Places labels while retaining failed previews only when View Unplaced needs them. +fn place_labels_with_previews( + cands: impl IntoIterator>, + fixed: Vec, + w: f32, + h: f32, + locked: bool, + show_unplaced: bool, +) -> Placement { let mut cands: Vec = cands.into_iter().map(Into::into).collect(); // A priority of no number places last, not first (`total_cmp` puts NaN above everything). let rank = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p }; @@ -4637,52 +4955,107 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let (text, icon) = label_boxes(l); text.iter().chain(&icon).for_each(|b| grid.insert(*b)); } - let mut out = Placement { labels: fixed, unplaced: Vec::new(), keys: Vec::new() }; + let mut out = Placement { labels: fixed, unplaced: Vec::new(), keys: Vec::new(), line_anchors: Vec::new() }; let outside = |b: &[f32; 4]| b[2] < 0.0 || b[3] < 0.0 || b[0] > w || b[1] > h; - for Candidate { label: mut l, key, alternatives, .. } in cands.into_iter().take(20_000) { - let c = l.collide; - let (has_text, has_icon) = (!l.text.is_empty(), l.icon.is_some()); - // Its own place, else the first alternative where all of it shows (the text moves there; - // an icon stays). - let mut at = None; - for (i, (x, y)) in std::iter::once((l.x, l.y)).chain(alternatives).enumerate() { - (l.x, l.y) = (x, y); - let (text, icon) = label_boxes(&l); - if text.iter().chain(&icon).all(outside) { + // Same work bound as 20,000 heads with eight positions, shared across all bands. + let mut layouts_left: usize = 20_000 * 8; + let mut placed_groups = std::collections::HashSet::new(); + let mut unplaced = Vec::new(); + for mut head in cands.into_iter().take(20_000) { + let fallbacks = head.line.as_mut().map(|line| std::mem::take(&mut line.fallbacks)).unwrap_or_default(); + // Siblings are contiguous: hold just one temporary preview until the group finishes. + let mut pending = None; + for Candidate { label: mut l, key, alternatives, curves, line, .. } in std::iter::once(head).chain(fallbacks) { + let grouped = line.as_ref().is_some_and(|line| line.deduplicate); + if grouped && placed_groups.contains(&key) { continue; } - let (show_text, show_icon) = shows(&l, &text, &icon, &grid, locked); - let whole = show_text == has_text && show_icon == has_icon; - if i == 0 || whole { - at = Some((i, x, y, text, icon, show_text, show_icon)); + let collide = l.collide; + let (has_text, has_icon) = (!l.text.is_empty(), l.icon.is_some()); + // Its own place, else the first alternative where all of it shows (the text moves there; + // an icon stays). + let mut at = None; + let count = curves.as_ref().map_or(1 + alternatives.len(), |curves| curves.positions.len()); + let original = LabelPlace::take(&mut l); + // A failed curve at the middle still appears there once in View Unplaced. + if curves.is_some() { + let (text, icon) = label_boxes(&l); + if !text.iter().chain(&icon).all(outside) { + at = Some((0, original.clone(), text, icon, false, false)); + } + } + let mut ready = std::iter::once(original).chain(alternatives); + for i in 0..count { + let place = match &curves { + Some(curves) => { + if curves.locked.is_some_and(|won| won != i) { + continue; + } + // The budget only limits alternatives: a label's own place is always tried, + // so exhausting it never silently drops later curved labels. + if layouts_left == 0 && i > 0 { + break; + } + layouts_left = layouts_left.saturating_sub(1); + curves.place(i, &mut l) + } + None => ready.next(), + }; + let Some(mut place) = place else { continue }; + if !place.x.is_finite() || !place.y.is_finite() { + continue; + } + place.swap(&mut l); + let (text, icon) = label_boxes(&l); + if text.iter().chain(&icon).all(outside) { + continue; + } + let (show_text, show_icon) = shows(&l, &text, &icon, &grid, locked); + let whole = show_text == has_text && show_icon == has_icon; + if i == 0 || whole { + at = Some((i, LabelPlace::take(&mut l), text, icon, show_text, show_icon)); + } + if whole { + break; + } } - if whole { - break; + let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; + place.swap(&mut l); + if !show_text && !show_icon { + if show_unplaced && has_text && unplaced.len() < 2000 && pending.is_none() { + pending = + Some((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); + } + continue; } - } - let Some((i, x, y, text, icon, show_text, show_icon)) = at else { continue }; - (l.x, l.y) = (x, y); - if !show_text && !show_icon { - if out.unplaced.len() < 2000 && has_text { - out.unplaced.push(PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l }); + if show_text && !collide.text_ignore_placement { + text.iter().for_each(|b| grid.insert(*b)); } - continue; - } - if show_text && !c.text_ignore_placement { - text.iter().for_each(|b| grid.insert(*b)); - } - if show_icon && !c.icon_ignore_placement { - icon.iter().for_each(|b| grid.insert(*b)); - } - if !show_text { - l.text.clear(); + if show_icon && !collide.icon_ignore_placement { + icon.iter().for_each(|b| grid.insert(*b)); + } + if !show_text { + l.text.clear(); + } + if !show_icon { + l.icon = None; + } + if grouped { + placed_groups.insert(key); + } + if let Some(anchor) = line.as_ref().and_then(|line| line.anchors.get(i)).copied().flatten() { + out.line_anchors.push((key, anchor)); + } + out.labels.push(l); + out.keys.push((key, u8::try_from(i).unwrap_or(u8::MAX))); } - if !show_icon { - l.icon = None; + if let Some((key, preview)) = pending + && key.is_none_or(|k| !placed_groups.contains(&k)) + { + unplaced.push(preview); } - out.labels.push(l); - out.keys.push((key, u8::try_from(i).unwrap_or(u8::MAX))); } + out.unplaced = unplaced; out } @@ -4902,7 +5275,7 @@ pub(crate) fn draw_attribution(pm: &mut Canvas, attribution: &[String], ppp: f32 #[cfg(test)] mod tests { - fn label(text: &str, x: f32, icon: Option, align: f32) -> PlacedLabel { + pub(super) fn label(text: &str, x: f32, icon: Option, align: f32) -> PlacedLabel { PlacedLabel { text: text.into(), x, @@ -5089,8 +5462,15 @@ mod tests { #[test] fn labels_try_their_alternatives() { let blocker = || Candidate::from((label("Blocker", 100.0, None, 0.0), 9.0, (u32::MAX, 0, 0, 0))); - let taken = - |alternatives| Candidate { label: label("Taken", 100.0, None, 0.0), priority: 1.0, key: (1, 0, 0, 0), alternatives, row: 0 }; + let taken = |alternatives: Vec<(f32, f32)>| Candidate { + label: label("Taken", 100.0, None, 0.0), + priority: 1.0, + key: (1, 0, 0, 0), + alternatives: alternatives.into_iter().map(LabelPlace::from).collect(), + curves: None, + line: None, + row: 0, + }; let at = |p: &Placement| p.labels.iter().map(|l| (l.text.clone(), l.x, l.y)).collect::>(); // None: left out, as before. let p = place_labels(vec![blocker(), taken(Vec::new())], Vec::new(), 400.0, 200.0, false); @@ -5110,26 +5490,97 @@ mod tests { assert_eq!(at(&p)[1], ("Taken".to_string(), 100.0, 20.0)); } + #[test] + fn alternatives_keep_their_angle_and_curved_glyphs() { + let base = label("ab", 100.0, None, 0.0); + let first = LabelPlace::of(&base); + let other = LabelPlace { + x: 300.0, + y: 80.0, + angle: Some(0.4), + glyphs: vec![ + LabelGlyph { text: "a".into(), dx: -5.0, dy: 1.0, angle: 0.2 }, + LabelGlyph { text: "b".into(), dx: 5.0, dy: -1.0, angle: 0.4 }, + ], + }; + let candidate = Candidate { + label: base.clone(), + priority: 1.0, + key: (1, 0, 0, 0), + curves: None, + line: None, + row: 0, + alternatives: vec![other.clone()], + }; + let blocker = label("Blocker", 100.0, None, 0.0); + let p = place_labels(vec![candidate.clone()], vec![blocker], 400.0, 200.0, false); + assert_eq!(LabelPlace::of(&p.labels[1]), other); + assert_eq!(p.keys, [((1, 0, 0, 0), 1)]); + // If every place collides, View Unplaced retains the original geometry, once. + let p = place_labels( + vec![candidate], + vec![base, PlacedLabel { x: other.x, y: other.y, ..label("Blocker", 0.0, None, 0.0) }], + 400.0, + 200.0, + false, + ); + assert_eq!(p.unplaced.len(), 1); + assert_eq!(LabelPlace::of(&p.unplaced[0]), first); + } + + #[test] + fn tilted_alternatives_transform_their_own_glyph_geometry() { + let base = label("ab", 100.0, None, 0.0); + let alternative = LabelPlace { + x: 100.0, + y: 80.0, + angle: Some(0.4), + glyphs: vec![ + LabelGlyph { text: "a".into(), dx: -5.0, dy: 1.0, angle: 0.2 }, + LabelGlyph { text: "b".into(), dx: 5.0, dy: -1.0, angle: 0.4 }, + ], + }; + let c = Candidate { + label: base, + priority: 1.0, + key: (1, 0, 0, 0), + curves: None, + line: None, + row: 0, + alternatives: vec![alternative.clone()], + }; + let projected = tilt_candidate(c, &|x, y| (y > 50.0).then_some((2.0 * x + 10.0, 2.0 * y))).unwrap(); + assert!(projected.alternatives.is_empty()); + assert_eq!((projected.label.x, projected.label.y), (210.0, 160.0)); + assert!((projected.label.angle.unwrap() - 0.4).abs() < 1e-5); + for (got, original) in projected.label.glyphs.iter().zip(&alternative.glyphs) { + assert_eq!((got.dx, got.dy), (original.dx * 2.0, original.dy * 2.0)); + assert!((got.angle - original.angle).abs() < 1e-5); + } + } + /// In a tilted view a label whose own place is out of sight (in the sky, say) goes to the /// first of its alternatives in sight, and keeps the others in sight to try. #[test] fn tilted_labels_fall_back_on_an_alternative_in_sight() { // In sight below y = 100 only, and moved 1000 right there. let at = |x: f32, y: f32| (y > 100.0).then_some((x + 1000.0, y)); - let offer = |(x, y): (f32, f32), alternatives| Candidate { + let offer = |(x, y): (f32, f32), alternatives: Vec<(f32, f32)>| Candidate { label: PlacedLabel { x, y, ..label("Paris", 0.0, None, 0.0) }, priority: 1.0, key: (1, 0, 0, 0), - alternatives, + alternatives: alternatives.into_iter().map(LabelPlace::from).collect(), + curves: None, + line: None, row: 0, }; let c = tilt_candidate(offer((10.0, 50.0), vec![(10.0, 60.0), (10.0, 150.0), (20.0, 160.0)]), &at).unwrap(); assert_eq!((c.label.x, c.label.y), (1010.0, 150.0)); - assert_eq!(c.alternatives, [(1020.0, 160.0)]); + assert_eq!(c.alternatives, [LabelPlace::from((1020.0, 160.0))]); // Its own place in sight stays its own. let c = tilt_candidate(offer((10.0, 120.0), vec![(10.0, 50.0), (10.0, 150.0)]), &at).unwrap(); assert_eq!((c.label.x, c.label.y), (1010.0, 120.0)); - assert_eq!(c.alternatives, [(1010.0, 150.0)]); + assert_eq!(c.alternatives, [LabelPlace::from((1010.0, 150.0))]); // None in sight: left out. assert!(tilt_candidate(offer((10.0, 50.0), vec![(10.0, 60.0)]), &at).is_none()); } @@ -5168,8 +5619,16 @@ mod tests { .map(|i| { let (x, y) = ((i % 200) as f32 * 10.0, (i / 200) as f32 * 10.0); let l = PlacedLabel { x, y, ..label("ab", x, None, 0.0) }; - let alternatives = if others { round.map(|(dx, dy)| (x + dx, y + dy)).to_vec() } else { Vec::new() }; - Candidate { label: l, priority: -f64::from(i), key: (u32::MAX, i as u64, 0, 0), alternatives, row: usize::MAX } + let alternatives = if others { round.map(|(dx, dy)| LabelPlace::from((x + dx, y + dy))).to_vec() } else { Vec::new() }; + Candidate { + label: l, + priority: -f64::from(i), + key: (u32::MAX, i as u64, 0, 0), + alternatives, + curves: None, + line: None, + row: usize::MAX, + } }) .collect() }; @@ -6274,7 +6733,7 @@ mod tests { } /// A hill of a DEM 3000 m high in the middle of a 2° square around (0, 0). - fn hill_map() -> Map { + pub(super) fn hill_map() -> Map { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; let mut m = Map::new("Map"); m.basemap = rustgis_map::Basemap::Blank; @@ -8738,3 +9197,6 @@ mod offset_normal_tests { #[cfg(test)] mod lod_tests; + +#[cfg(test)] +mod line_positions_tests; diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index ddcdf5e09..8eb457258 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -10,7 +10,14 @@ pub(super) const MAX_TURN: f32 = 0.436; /// attribute value on many lines doesn't lay out, and draw, character by character. pub(super) const MAX_CURVED_CHARS: usize = 200; +/// Length of curved text including spacing, with no trailing gap or backward character step. +pub(super) fn curved_len(advances: &[f32], gap: f32) -> f32 { + let step = |adv: f32| (adv + gap).max(0.0); + advances.iter().map(|a| step(*a)).sum::() - advances.last().map_or(0.0, |a| step(*a) - a) +} + /// A line in screen points, with how far along it each vertex is. +#[derive(Debug)] struct Path { pts: Vec<(f32, f32)>, along: Vec, @@ -128,6 +135,7 @@ fn angle(a: (f32, f32), b: (f32, f32)) -> f32 { /// A line in screen points as labels go along it: its path each way, built once for all the /// labels (and repeats) placed along it. +#[derive(Debug)] pub(super) struct Line { fwd: Path, rev: Path, @@ -139,6 +147,12 @@ impl Line { Some(Line { fwd: Path::new(line, false)?, rev: Path::new(line, true)? }) } + /// Upright local direction at a preview anchor, even when the text cannot fit there. + pub(super) fn direction(&self, at: f32) -> Option { + let (a, b) = (self.fwd.at(at - 0.5)?, self.fwd.at(at + 0.5)?); + Some(rustgis_tiles::symbols::upright(angle(a, b))) + } + /// Its length in screen points. pub(super) fn length(&self) -> f32 { self.fwd.length() @@ -168,6 +182,37 @@ impl Line { if flipped { Some((&self.rev, total - d)) } else { Some((&self.fwd, d)) } } + /// Squared screen distance and distance along the path of its nearest point. + pub(super) fn nearest(&self, point: (f32, f32), overrun: f32) -> Option<(f32, f32)> { + let mut best = None; + let mut distance = f32::INFINITY; + for (i, edge) in self.fwd.pts.windows(2).enumerate() { + let (a, b) = (edge.first()?, edge.get(1)?); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + continue; + } + let t = ((point.0 - a.0) * (dx / len) + (point.1 - a.1) * (dy / len)) / len; + // Locked overrun placements may lie beyond an end; carry the end segment on. + let reach = overrun.max(0.0) / len; + let low = if i == 0 { -reach } else { 0.0 }; + let high = if i + 2 == self.fwd.pts.len() { 1.0 + reach } else { 1.0 }; + let t = t.clamp(low, high); + let d = (point.0 - a.0 - t * dx).powi(2) + (point.1 - a.1 - t * dy).powi(2); + if d < distance { + distance = d; + best = Some((d, *self.fwd.along.get(i)? + t * len)); + } + } + best + } + + /// A placement's centre on the path, without laying out its characters. + pub(super) fn anchor(&self, at: f32) -> Option<(f32, f32)> { + self.fwd.at(at) + } + /// Text `len` points long straight along the line, centred `at` points along it (its middle when /// `None`): its centre and angle (radians, clockwise, upright). `None` when the line (with /// `overrun` past each end) is shorter, or the text there runs past an end by more than that. @@ -187,7 +232,7 @@ impl Line { pub(super) fn curved(&self, advances: &[f32], gap: f32, overrun: f32, at: Option) -> Option<((f32, f32), Vec<(f32, f32, f32)>)> { // Each step from a character to the next: its advance and the letter spacing (never back). let step = |adv: f32| (adv + gap).max(0.0); - let len: f32 = advances.iter().map(|a| step(*a)).sum::() - advances.last().map_or(0.0, |a| step(*a) - a); + let len = curved_len(advances, gap); let (path, mid) = self.upright(len, overrun, at)?; let centre = path.at(mid)?; let mut d = mid - len / 2.0; @@ -217,6 +262,31 @@ impl Line { } } +/// Middle first, then the nearest places either side, at most eight places in all. +/// The ends allow the same overrun as ordinary line labels; no alternative is past that range. +pub(super) fn positions(total: f32, width: f32, overrun: f32) -> Vec { + let mid = total / 2.0; + // Keep endpoints a few ULPs inside upright's strict fit bounds. + let room = (mid - width / 2.0 + overrun.max(0.0)) * (1.0 - 8.0 * f32::EPSILON); + let mut out = vec![mid]; + if !room.is_finite() || room <= 0.0 || !width.is_finite() || width <= 0.0 { + return out; + } + let step = (width * 0.75).max(room / 4.0).max(1.0).min(room); + for k in 1..=4 { + let d = step * k as f32; + if d > room { + break; + } + for side in [-1.0, 1.0] { + if out.len() < 8 { + out.push(mid + side * d); + } + } + } + out +} + /// The most labels a line repeats a label in (the one at its middle among them). pub(super) const MAX_REPEATS: usize = 32; diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs new file mode 100644 index 000000000..1573738fb --- /dev/null +++ b/crates/render/src/line_positions_tests.rs @@ -0,0 +1,622 @@ +use super::*; +use crate::tests::label; +use rustgis_data::{Dataset, Field, FieldType, Value}; +use rustgis_map::LabelPlacement; +use std::sync::Arc; + +fn curved(line: &[(f32, f32)]) -> Candidate { + let line = Arc::new(line_labels::Line::new(line).unwrap()); + let positions = line_labels::positions(line.length(), 40.0, 10.0); + let (x, y) = line.anchor(line.length() / 2.0).unwrap(); + Candidate { + label: PlacedLabel { x, y, ..label("road", x, None, 0.0) }, + priority: 1.0, + row: 0, + key: (1, 0, 0, 0), + alternatives: Vec::new(), + line: Some(LinePlaces::default()), + curves: Some(label_places::Curves::new( + Arc::new(label_places::CurveSpec { + line, + chars: "road".chars().map(|c| c.to_string()).collect(), + advances: vec![10.0; 4], + gap: 0.0, + overrun: 10.0, + }), + positions, + None, + )), + } +} + +#[test] +fn curves_are_laid_out_only_until_a_place_fits() { + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + label_places::LAYOUTS.with(|n| n.set(0)); + let p = place_labels(vec![c.clone()], Vec::new(), 400.0, 200.0, false); + assert_eq!(p.keys[0].1, 0); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 1)); + let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 200.0, None, 0.0) }; + label_places::LAYOUTS.with(|n| n.set(0)); + let p = place_labels(vec![c], vec![blocker], 400.0, 200.0, false); + let won = p.keys[0].1; + assert!(won > 0 && won < 8); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), usize::from(won) + 1)); + assert_eq!(p.labels[1].glyphs.len(), 4); +} + +#[test] +fn a_sharp_middle_tries_a_straight_stretch_and_a_lock_replays_that_index() { + let c = curved(&[(0.0, 100.0), (200.0, 100.0), (200.0, 300.0)]); + assert!(c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).is_none()); + let p = place_labels(vec![c.clone()], Vec::new(), 400.0, 400.0, false); + let won = usize::from(p.keys[0].1); + assert!(won > 0); + let mut locked = c; + locked.curves.as_mut().unwrap().locked = Some(won); + // Shift the map projection: the same place and its characters move together. + turn_candidate(&mut locked, |x, y| Some((x + 12.0, y + 7.0))); + label_places::LAYOUTS.with(|n| n.set(0)); + let q = place_labels(vec![locked], Vec::new(), 450.0, 450.0, true); + assert_eq!(usize::from(q.keys[0].1), won); + assert!((q.labels[0].x - p.labels[0].x - 12.0).abs() < 1e-4); + assert!((q.labels[0].y - p.labels[0].y - 7.0).abs() < 1e-4); + assert_eq!(q.labels[0].glyphs, p.labels[0].glyphs); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 1)); +} + +#[test] +fn all_curves_taken_are_unplaced_once_at_the_middle() { + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + let blocker = PlacedLabel { y: 100.0, size: 200.0, ..label("XXXXXXXXXXXXXXXXXXXX", 200.0, None, 0.0) }; + let p = place_labels(vec![c], vec![blocker], 400.0, 200.0, false); + assert_eq!(p.labels.len(), 1); + assert_eq!(p.unplaced.len(), 1); + assert_eq!((p.unplaced[0].x, p.unplaced[0].y), (200.0, 100.0)); +} + +#[test] +fn turned_and_tilted_curves_defer_their_glyphs_until_placement() { + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + for tilt in [false, true] { + label_places::LAYOUTS.with(|n| n.set(0)); + // A 45-degree turn: each glyph follows its own projected baseline. + let at = |x, y| Some((200.0 + (x - y) * std::f32::consts::FRAC_1_SQRT_2, (x + y) * std::f32::consts::FRAC_1_SQRT_2)); + let mut c = c.clone(); + if tilt { + c = tilt_candidate(c, &at).unwrap(); + } else { + turn_candidate(&mut c, at); + } + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 0)); + let p = place_labels(vec![c], Vec::new(), 400.0, 400.0, false); + let l = &p.labels[0]; + assert!((l.x - 270.7107).abs() < 1e-3 && (l.y - 212.132).abs() < 1e-3); + assert!(l.glyphs.iter().all(|g| (g.angle - std::f32::consts::FRAC_PI_4).abs() < 1e-4)); + assert!(l.glyphs.windows(2).all(|w| w[1].dy > w[0].dy)); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 1)); + } +} + +fn map_with_line(blocked: bool) -> (Map, u32, View) { + let mut map = Map::new("lines"); + map.basemap = rustgis_map::Basemap::Blank; + let mut ds = Dataset::new("roads", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + ds.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.0), Coord::new(1.0, 0.0)]])), vec![Value::Text("road".into())]); + let id = map.add_dataset(ds); + let layer = map.layer_mut(id).unwrap(); + layer.labels.enabled = true; + layer.labels.default.field = "name".into(); + layer.labels.default.size = 10.0; + layer.labels.default.placement = LabelPlacement::Parallel; + if blocked { + let mut ds = Dataset::new("blocker", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![Value::Text("BLOCKER".into())]); + let blocker = map.add_dataset(ds); + let l = map.layer_mut(blocker).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.placement = LabelPlacement::Center; + } + let camera = Camera { center: Coord::new(0.0, 0.0), res: 750.0, bearing: 0.0, pitch: 0.0 }; + map.camera = camera; + (map, id, View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera, sky: false }) +} + +#[test] +fn rendering_retries_only_opted_in_nonrepeated_line_labels() { + for placement in [LabelPlacement::Parallel, LabelPlacement::Curved] { + let (mut map, id, view) = map_with_line(true); + map.layer_mut(id).unwrap().labels.default.placement = placement; + let before = render(&mut map, &view).unwrap(); + assert!(before.labels.iter().all(|l| l.text != "road")); + assert!(before.labels.iter().any(|l| l.text == "BLOCKER"), "{:?}", before.labels); + let exported = render_complete(&mut map, &view).unwrap(); + assert!(exported.labels.iter().any(|l| l.text == "BLOCKER"), "export labels: {:?}", exported.labels); + map.layer_mut(id).unwrap().labels.default.other_positions = true; + for (bearing, pitch) in [(0.0, 0.0), (35.0, 0.0), (20.0, 30.0)] { + let view = View { camera: Camera { bearing, pitch, ..view.camera }, ..view }; + let r = render(&mut map, &view).unwrap(); + assert!(r.labels.iter().any(|l| l.text == "road"), "{placement:?}, bearing {bearing}, pitch {pitch}"); + } + // A saved lock selects the same alternative, including after a small pan. + map.label_display.locked = true; + let first = render(&mut map, &view).unwrap(); + let key = map.label_lock.as_ref().unwrap().iter().find(|(k, _)| k.0 == id).copied().unwrap(); + assert!(key.1 > 0); + let panned = View { camera: Camera { center: Coord::new(7500.0, 0.0), ..view.camera }, ..view }; + let next = render(&mut map, &panned).unwrap(); + let x = |r: &Rendered| r.labels.iter().find(|l| l.text == "road").unwrap().x; + assert!((x(&next) - x(&first) + 10.0).abs() < 0.05); + assert!(map.label_lock.as_ref().unwrap().contains(&key)); + + // Turning the option on changes neither an unblocked middle nor repeats. + for repeat in [None, Some(45.0)] { + let (mut clean, id, view) = map_with_line(false); + let c = &mut clean.layer_mut(id).unwrap().labels.default; + c.placement = placement; + c.repeat = repeat; + let before = render(&mut clean, &view).unwrap(); + clean.layer_mut(id).unwrap().labels.default.other_positions = true; + let after = render(&mut clean, &view).unwrap(); + assert_eq!(before.labels, after.labels); + assert_eq!(before.pixmap.data(), after.pixmap.data()); + } + } +} + +#[test] +fn dense_curved_labels_have_a_bounded_lazy_layout_budget() { + label_places::LAYOUTS.with(|n| n.set(0)); + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + let candidates = (0..4000) + .map(|i| { + let mut c = c.clone(); + c.key.1 = i; + c.priority = -(i as f64); + c + }) + .collect::>(); + let p = place_labels(candidates, Vec::new(), 400.0, 200.0, false); + assert!(!p.labels.is_empty()); + label_places::LAYOUTS.with(|n| assert!(n.get() <= 4000 * 8)); +} + +#[test] +fn terrain_and_roofs_translate_ready_and_deferred_places_without_changing_glyph_offsets() { + let camera = Camera { center: Coord::new(0.0, 0.0), res: 600.0, bearing: 0.0, pitch: 45.0 }; + let view = View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera, sky: false }; + let mut hill = crate::tests::hill_map(); + hill.view.terrain = + Some(rustgis_map::view::Terrain { source: rustgis_map::view::TerrainSource::Layer("dem".into()), exaggeration: 2.0 }); + let ground = terrain::Ground::of(&hill, &camera).unwrap(); + let mut blocks = Map::new("roofs"); + let mut ds = Dataset::new("block", GeometryType::Polygon, vec![]); + ds.push( + Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(-0.1, -0.1), + Coord::new(0.1, -0.1), + Coord::new(0.0, 0.1), + Coord::new(-0.1, -0.1), + ]]])), + vec![], + ); + let id = blocks.add_dataset(ds); + blocks.layer_mut(id).unwrap().extrusion = Some(rustgis_map::effects::Extrusion { + field: None, + height: 6000.0, + factor: 1.0, + face_culling: true, + lighting: true, + shadows: true, + }); + for roof in [false, true] { + let mut c = curved(&[(20.0, 150.0), (380.0, 150.0)]); + c.key.0 = id; + c.key.1 = blocks.layer(id).unwrap().dataset.features[0].id; + let before = c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).unwrap(); + c.alternatives.push(before.clone()); + let mut distant = before.clone(); + distant.y += 40.0; + c.alternatives.push(distant.clone()); + let mut probe = c.clone(); + probe.curves = None; + probe.alternatives.clear(); + (probe.label.x, probe.label.y) = (distant.x, distant.y); + let mut candidates = vec![c, probe]; + if roof { + raise_labels(&blocks, &view, &mut candidates); + } else { + lift_labels(&view, &ground, &mut candidates); + } + let c = &candidates[0]; + let after = c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).unwrap(); + assert!(after.y < before.y - 0.1, "roof {roof}"); + assert_eq!(after.glyphs, before.glyphs); + assert_eq!(c.alternatives[0], after); + assert!((c.alternatives[1].x - candidates[1].label.x).abs() < 1e-4); + assert!((c.alternatives[1].y - candidates[1].label.y).abs() < 1e-4); + } +} + +#[test] +fn terrain_lifts_line_places_at_their_own_height() { + let camera = Camera { center: Coord::new(0.0, 0.0), res: 600.0, bearing: 0.0, pitch: 45.0 }; + let view = View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera, sky: false }; + let mut map = crate::tests::hill_map(); + map.view.terrain = + Some(rustgis_map::view::Terrain { source: rustgis_map::view::TerrainSource::Layer("dem".into()), exaggeration: 2.0 }); + let ground = terrain::Ground::of(&map, &camera).unwrap(); + let mut c = curved(&[(30.0, 150.0), (370.0, 150.0)]); + let before: Vec<_> = (0..c.curves.as_ref().unwrap().positions.len()) + .filter_map(|i| c.curves.as_ref().unwrap().place(i, &mut c.label.clone()).map(|p| (i, p))) + .collect(); + c.alternatives = before.iter().map(|(_, p)| p.clone()).collect(); + let mut candidates = vec![c]; + lift_labels(&view, &ground, &mut candidates); + let c = &candidates[0]; + let mut shifts = Vec::new(); + for ((i, prior), ready) in before.iter().zip(&c.alternatives) { + let after = c.curves.as_ref().unwrap().place(*i, &mut c.label.clone()).unwrap(); + let m = camera.try_to_map(f64::from(prior.x), f64::from(prior.y), view.width, view.height).unwrap(); + let (u, v) = camera.ground_px(m); + let (x, y) = camera.project_raised(u, v, ground.up_px(m, &camera), view.width, view.height).unwrap(); + assert!((after.x - x as f32).abs() < 0.01 && (after.y - y as f32).abs() < 0.01); + assert_eq!(&after, ready); + assert_eq!(after.glyphs, prior.glyphs); + shifts.push(after.y - prior.y); + } + assert!(shifts.iter().any(|v| (v - shifts[0]).abs() > 0.1), "places must sample different heights: {shifts:?}"); +} + +#[test] +fn a_rendered_curved_label_can_leave_a_sharp_middle_bend() { + let (mut map, id, view) = map_with_line(false); + let layer = map.layer_mut(id).unwrap(); + layer.dataset.features[0].geometry = + Some(Geometry::Line(vec![vec![Coord::new(-1.0, -1.0), Coord::new(1.0, -1.0), Coord::new(1.0, 1.0)]])); + layer.touch_geometry(); + layer.labels.default.placement = LabelPlacement::Curved; + assert!(render(&mut map, &view).unwrap().labels.is_empty()); + map.layer_mut(id).unwrap().labels.default.other_positions = true; + let r = render(&mut map, &view).unwrap(); + assert_eq!(r.labels.len(), 1); + assert!(!r.labels[0].glyphs.is_empty()); +} + +#[test] +fn a_projected_anchor_does_not_keep_unprojectable_characters() { + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + let c = tilt_candidate(c, &|x, y| (x > 195.0).then_some((x, y))).unwrap(); + let p = place_labels(vec![c], Vec::new(), 400.0, 200.0, false); + assert_eq!(p.keys[0].1, 2, "middle anchor is visible, but its leftmost glyphs are not"); + assert!(p.labels[0].glyphs.iter().all(|g| p.labels[0].x + g.dx > 195.0)); +} + +#[test] +fn a_nearer_bands_unlayoutable_curve_does_not_claim_the_feature_key() { + let mut bad = curved(&[(100.0, 100.0), (110.0, 100.0)]); + // No overrun: four letters cannot fit on this band's short stretch. + Arc::get_mut(&mut bad.curves.as_mut().unwrap().spec).unwrap().overrun = 0.0; + let good = curved(&[(20.0, 100.0), (380.0, 100.0)]); + let mut map = Map::new("bands"); + map.label_display.show_unplaced = true; + let view = View { + width: 400.0, + height: 200.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1000.0, bearing: 0.0, pitch: 30.0 }, + sky: false, + }; + let p = place_tilted_labels(&map, &view, vec![bad.clone(), good.clone(), good.clone()], None); + assert_eq!(p.labels.len(), 1); + assert_eq!(p.labels[0].glyphs.len(), 4); + assert!(p.unplaced.is_empty(), "a successful later band replaces the failed preview"); + // Every physical camera band remains available, with a hard bound on excess siblings. + let max = rustgis_map::camera::MAX_TILT_BANDS; + let candidates = std::iter::repeat_n(bad.clone(), max - 1).chain(std::iter::once(good.clone())).collect(); + let p = place_tilted_labels(&map, &view, candidates, None); + assert_eq!(p.labels.len(), 1); + label_places::LAYOUTS.with(|n| n.set(0)); + let candidates = std::iter::repeat_n(bad, 20_000).chain(std::iter::once(good)).collect(); + let p = place_tilted_labels(&map, &view, candidates, None); + assert!(p.labels.is_empty()); + assert_eq!(p.unplaced.len(), 1); + label_places::LAYOUTS.with(|n| assert!(n.get() <= max * 8)); +} + +#[test] +fn locked_line_anchors_survive_changes_to_the_visible_stretch() { + for placement in [LabelPlacement::Parallel, LabelPlacement::Curved] { + let (mut map, id, view) = map_with_line(true); + let layer = map.layer_mut(id).unwrap(); + layer.dataset.features[0].geometry = Some(Geometry::Line(vec![vec![Coord::new(-3.0, 0.0), Coord::new(3.0, 0.0)]])); + layer.touch_geometry(); + layer.labels.default.placement = placement; + layer.labels.default.other_positions = true; + map.label_display.locked = true; + let first = render(&mut map, &view).unwrap(); + let first = first.labels.iter().find(|l| l.text == "road").unwrap(); + assert_eq!(map.label_lock_line_anchors.len(), 1); + let moved = View { camera: Camera { center: Coord::new(60_000.0, 0.0), ..view.camera }, ..view }; + let next = render(&mut map, &moved).unwrap(); + let next = next.labels.iter().find(|l| l.text == "road").unwrap(); + assert!((next.x - first.x + 80.0).abs() < 0.1, "{placement:?}: {} -> {}", first.x, next.x); + let zoom = View { camera: Camera { res: view.camera.res * 2.0, ..view.camera }, ..view }; + let zoomed = render(&mut map, &zoom).unwrap(); + let zoomed = zoomed.labels.iter().find(|l| l.text == "road").unwrap(); + assert!((zoomed.x - (200.0 + (first.x - 200.0) / 2.0)).abs() < 0.1); + let outside = View { camera: Camera { center: Coord::new(300_000.0, 0.0), ..view.camera }, ..view }; + assert!(render(&mut map, &outside).unwrap().labels.iter().all(|l| l.text != "road"), "a lock never slides back into view"); + assert!(serde_json::to_value(&map).unwrap().get("label_lock_line_anchors").is_none()); + } +} + +#[test] +fn a_later_parallel_band_can_supply_a_free_place() { + let map = Map::new("bands"); + let view = View { + width: 400.0, + height: 200.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1000.0, bearing: 0.0, pitch: 30.0 }, + sky: false, + }; + let mut first = Candidate::from((PlacedLabel { y: 100.0, ..label("road", 100.0, None, 0.0) }, 1.0, (1, 0, 0, 0))); + first.line = Some(LinePlaces::default()); + first.alternatives.push((110.0, 100.0).into()); + let mut second = first.clone(); + second.label.x = 300.0; + second.alternatives = vec![(320.0, 100.0).into()]; + let blocker = Candidate::from((PlacedLabel { y: 100.0, ..label("BLOCKER", 100.0, None, 0.0) }, 9.0, (u32::MAX, 0, 0, 0))); + let p = place_tilted_labels(&map, &view, vec![blocker, first, second], None); + assert_eq!(p.labels.iter().filter(|l| l.text == "road").count(), 1); + assert_eq!(p.labels.iter().find(|l| l.text == "road").unwrap().x, 300.0); + assert!(p.unplaced.is_empty()); +} + +#[test] +fn turned_candidates_skip_failed_alternatives_without_renumbering_slots() { + let mut c = Candidate::from((label("road", 100.0, None, 0.0), 1.0, (1, 0, 0, 0))); + c.alternatives = vec![(200.0, 20.0).into(), (300.0, 20.0).into()]; + turn_candidate(&mut c, |x, y| (x != 200.0).then_some((x + 50.0, y))); + let p = place_labels(vec![c], vec![label("BLOCKER", 150.0, None, 0.0)], 400.0, 200.0, false); + assert_eq!(p.keys[0].1, 2); + assert_eq!(p.labels[1].x, 350.0); +} + +#[test] +fn end_candidates_fit_and_nearest_anchors_keep_overrun() { + for total in [1.0, 10.0, 100.0, 1_000_000.0] { + let line = line_labels::Line::new(&[(0.0, 0.0), (total, 0.0)]).unwrap(); + for width in [0.1, 1.0, 8.0, 20.0] { + for overrun in [0.0, 0.5, 12.0] { + if width > total + 2.0 * overrun { + continue; + } + let positions = line_labels::positions(total, width, overrun); + assert!(positions.len() <= 8); + assert!(positions.into_iter().all(|at| line.parallel(width, overrun, Some(at)).is_some())); + } + } + assert!((line.nearest((-2.0, 0.0), 2.0).unwrap().1 + 2.0).abs() < 1e-4); + } +} + +#[test] +fn locked_multipart_lines_do_not_follow_a_distant_parts_extension() { + let view = View { + width: 400.0, + height: 200.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let ctx = Ctx { + view: &view, + ppp: 1.0, + wireframe: false, + cull: Extent::EMPTY, + globe: None, + sprites: Default::default(), + bursts: Default::default(), + }; + let parts = vec![vec![Coord::new(-100.0, 0.0), Coord::new(-90.0, 0.0)], vec![Coord::new(-10.0, 0.0), Coord::new(10.0, 0.0)]]; + let (line, at) = line_near_anchor(&ctx, &parts, (200.0, 100.0), 5.0).unwrap(); + assert!((line.length() - 20.0).abs() < 1e-4 && (at - 10.0).abs() < 1e-4); +} + +#[test] +fn auxiliary_drawings_preserve_locked_line_anchors() { + let (mut map, id, view) = map_with_line(true); + map.layer_mut(id).unwrap().labels.default.other_positions = true; + map.label_display.locked = true; + render(&mut map, &view).unwrap(); + let lock = map.label_lock.clone(); + let anchors = map.label_lock_line_anchors.clone(); + assert!(!anchors.is_empty()); + let tilted = View { camera: Camera { pitch: 30.0, ..view.camera }, ..view }; + assert!(!render_tilt_drape(&mut map, &tilted, 0.0, 256).is_empty()); + assert_eq!(map.label_lock, lock); + assert_eq!(map.label_lock_line_anchors, anchors); + assert!(!render_globe_drape(&mut map, &view, &[Extent::new(-1.0, -1.0, 1.0, 1.0)], 256).is_empty()); + assert_eq!(map.label_lock, lock); + assert_eq!(map.label_lock_line_anchors, anchors); + assert!(layout::frame_coverage(&mut map, Coord::new(0.0, 0.0), 1_000_000.0, 100.0, 100.0, 32).is_some()); + assert_eq!(map.label_lock, lock); + assert_eq!(map.label_lock_line_anchors, anchors); +} + +#[test] +fn colliding_band_groups_share_a_frame_layout_budget() { + let mut c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + c.line.as_mut().unwrap().deduplicate = true; + let blocker = PlacedLabel { y: 100.0, size: 200.0, ..label("XXXXXXXXXXXXXXXXXXXX", 200.0, None, 0.0) }; + // Layout itself succeeds at all eight positions; collision is the reason for retrying. + assert!(c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).is_some()); + let candidates = (0..2600) + .map(|i| { + let mut c = c.clone(); + c.key.1 = i; + let siblings = vec![c.clone(); rustgis_map::camera::MAX_TILT_BANDS - 1]; + c.line.as_mut().unwrap().fallbacks = siblings; + c + }) + .collect::>(); + label_places::LAYOUTS.with(|n| n.set(0)); + let p = place_labels(candidates, vec![blocker], 400.0, 200.0, false); + // The budget only limits alternatives: once spent, each remaining label still lays out its + // own place (one attempt per band), so the total stays within budget plus those. + let budget = 20_000 * 8; + label_places::LAYOUTS.with(|n| assert!((budget..=budget + 2600 * rustgis_map::camera::MAX_TILT_BANDS).contains(&n.get()))); + assert_eq!(p.labels.len(), 1); + assert_eq!(p.unplaced.len(), 2000); +} + +#[test] +fn successful_band_previews_do_not_consume_the_unplaced_cap() { + let mut bad = Candidate::from((PlacedLabel { y: 100.0, ..label("road", 100.0, None, 0.0) }, 1.0, (1, 0, 0, 0))); + bad.line = Some(LinePlaces { deduplicate: true, ..Default::default() }); + let mut good = bad.clone(); + good.label.x = 300.0; + good.label.collide.text_allow_overlap = true; + good.label.collide.text_ignore_placement = true; + let mut candidates = Vec::new(); + for i in 0..2000 { + let mut c = bad.clone(); + c.key.1 = i; + let mut sibling = good.clone(); + sibling.key = c.key; + c.line.as_mut().unwrap().fallbacks.push(sibling); + candidates.push(c); + } + bad.key.1 = 2000; + candidates.push(bad); + let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 100.0, None, 0.0) }; + let p = place_labels(candidates, vec![blocker], 400.0, 200.0, false); + assert_eq!(p.labels.len(), 2001); + assert_eq!(p.unplaced.len(), 1); +} + +#[test] +fn hidden_unplaced_previews_do_not_change_placement() { + let candidates: Vec<_> = + (0..3000).map(|i| Candidate::from((PlacedLabel { y: 100.0, ..label("road", 100.0, None, 0.0) }, 1.0, (1, i, 0, 0)))).collect(); + let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 100.0, None, 0.0) }; + let shown = place_labels_with_previews(candidates.clone(), vec![blocker.clone()], 400.0, 200.0, false, true); + let hidden = place_labels_with_previews(candidates, vec![blocker], 400.0, 200.0, false, false); + assert_eq!(shown.unplaced.len(), 2000); + assert!(hidden.unplaced.is_empty()); + assert_eq!(shown.labels, hidden.labels); + assert_eq!(shown.keys, hidden.keys); +} + +#[test] +fn invalid_straight_slots_keep_their_matching_anchor() { + let mut c = Candidate::from((PlacedLabel { y: 100.0, ..label("road", 100.0, None, 0.0) }, 1.0, (1, 0, 0, 0))); + c.alternatives = vec![LabelPlace::from((f32::NAN, f32::NAN)), LabelPlace::from((300.0, 100.0))]; + c.line = Some(LinePlaces { anchors: vec![Some(Coord::new(0.0, 0.0)), None, Some(Coord::new(2.0, 0.0))], ..Default::default() }); + let blocker = PlacedLabel { y: 100.0, ..label("BLOCKER", 100.0, None, 0.0) }; + let placed = place_labels(vec![c], vec![blocker], 400.0, 200.0, false); + assert_eq!(placed.keys, vec![((1, 0, 0, 0), 2)]); + assert_eq!(placed.line_anchors, vec![((1, 0, 0, 0), Coord::new(2.0, 0.0))]); +} + +#[test] +fn tilted_icons_turn_once_for_both_ready_and_deferred_labels() { + let at = |x, y| Some((300.0 - y, x)); + for deferred in [false, true] { + let mut c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + if !deferred { + c.curves = None; + } + c.label.icon = + Some(PlacedIcon { image: Arc::new(Pixmap::new(2, 2).unwrap()), x: 200.0, y: 100.0, w: 2.0, h: 2.0, angle: Some(0.0) }); + let c = tilt_candidate(c, &at).unwrap(); + let icon = c.label.icon.unwrap(); + assert!((icon.angle.unwrap() - std::f32::consts::FRAC_PI_2).abs() < 1e-4); + assert_eq!((icon.x, icon.y), (200.0, 200.0)); + } +} + +#[test] +fn an_unplaced_tilted_curve_uses_the_visible_anchors_own_direction() { + let mut c = curved(&[(20.0, 100.0), (220.0, 100.0), (220.0, 300.0)]); + let curves = c.curves.as_mut().unwrap(); + curves.positions = vec![200.0, 300.0]; + // The text cannot fit: retain a preview without laying out glyphs. The midpoint is + // hidden and the next position is on a vertical segment with a different direction. + Arc::get_mut(&mut curves.spec).unwrap().advances = vec![500.0; 4]; + label_places::LAYOUTS.with(|n| n.set(0)); + let c = tilt_candidate(c, &|x, y| (y > 150.0).then_some((x + (y - 200.0) * 0.25, y))).unwrap(); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 0)); + let p = place_labels(vec![c], Vec::new(), 500.0, 400.0, false); + assert!(p.labels.is_empty()); + assert_eq!(p.unplaced.len(), 1); + let preview = &p.unplaced[0]; + assert_eq!((preview.x, preview.y), (220.0, 200.0)); + assert!((preview.angle.unwrap() - 1.0_f32.atan2(0.25)).abs() < 1e-4); +} + +#[test] +fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { + let (mut map, id, view) = map_with_line(false); + map.layer_mut(id).unwrap().labels.default.other_positions = true; + map.label_display.locked = true; + map.label_display.show_unplaced = true; + render(&mut map, &view).unwrap(); + assert!(!map.label_lock.as_ref().unwrap().is_empty()); + let anchors = map.label_lock_line_anchors.clone(); + assert!(!anchors.is_empty()); + // A stale or malformed lock cannot name a real place. There is no accurate preview + // to draw, even when View Unplaced is on; the valid lock's geographic anchor stays put. + map.label_lock.as_mut().unwrap()[0].1 = u8::MAX; + let r = render(&mut map, &view).unwrap(); + assert!(r.labels.is_empty()); + assert!(r.unplaced.is_empty()); + assert_eq!(map.label_lock_line_anchors, anchors); +} + +#[test] +fn a_locked_line_without_its_saved_anchor_does_not_slide() { + let (mut map, id, view) = map_with_line(false); + map.layer_mut(id).unwrap().labels.default.other_positions = true; + map.label_display.locked = true; + render(&mut map, &view).unwrap(); + assert!(!map.label_lock.as_ref().unwrap().is_empty()); + assert!(!map.label_lock_line_anchors.is_empty()); + // The anchor is gone (a failed back-projection): the label is not re-placed on another stretch. + map.label_lock_line_anchors.clear(); + let r = render(&mut map, &view).unwrap(); + assert!(r.labels.is_empty()); +} + +#[test] +fn an_invalid_locked_curved_slot_never_invents_a_middle_preview() { + let (mut map, id, view) = map_with_line(false); + let labels = &mut map.layer_mut(id).unwrap().labels; + labels.default.other_positions = true; + labels.default.placement = LabelPlacement::Curved; + map.label_display.locked = true; + map.label_display.show_unplaced = true; + render(&mut map, &view).unwrap(); + assert!(!map.label_lock.as_ref().unwrap().is_empty()); + let anchors = map.label_lock_line_anchors.clone(); + map.label_lock.as_mut().unwrap()[0].1 = u8::MAX; + let r = render(&mut map, &view).unwrap(); + assert!(r.labels.is_empty()); + assert!(r.unplaced.is_empty()); + assert_eq!(map.label_lock_line_anchors, anchors); +} + +#[test] +fn rejected_curved_places_reuse_grapheme_allocations() { + let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); + let curves = c.curves.as_ref().unwrap(); + let mut template = c.label.clone(); + template.glyphs = curves.place(0, &mut c.label.clone()).unwrap().glyphs; + let allocations: Vec<_> = template.glyphs.iter().map(|g| g.text.as_ptr()).collect(); + let next = curves.place(1, &mut template).unwrap(); + assert_eq!(next.glyphs.iter().map(|g| g.text.as_ptr()).collect::>(), allocations); + assert_eq!(next.glyphs.iter().map(|g| g.text.as_str()).collect::(), "road"); +}