From aaa526ff80633d28964feacf1b086841d00136cb Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:55:55 +0800 Subject: [PATCH 01/12] refactor(render): retain geometry for label alternatives --- crates/render/src/lib.rs | 175 ++++++++++++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 32 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b3be9dfc6..fb370a934 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1837,14 +1837,58 @@ 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. +/// 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)>, + alternatives: 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, +} + +impl From<(f32, f32)> for LabelPlace { + fn from((x, y): (f32, f32)) -> Self { + Self { x, y, angle: None, glyphs: Vec::new() } + } +} + +impl LabelPlace { + fn of(l: &PlacedLabel) -> Self { + Self { x: l.x, y: l.y, angle: l.angle, glyphs: l.glyphs.clone() } + } + + 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 }; + 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. @@ -2103,7 +2147,7 @@ fn lift_labels(view: &View, ground: &terrain::Ground, candidates: &mut [Candidat if let Some(i) = &mut l.icon { (i.x, i.y) = (i.x + dx, i.y + dy); } - alternatives.iter_mut().for_each(|(x, y)| (*x, *y) = (*x + dx, *y + dy)); + alternatives.iter_mut().for_each(|p| (p.x, p.y) = (p.x + dx, p.y + dy)); } } @@ -2311,7 +2355,7 @@ fn raise_labels(map: &Map, view: &View, candidates: &mut [Candidate]) { if let Some(i) = &mut l.icon { (i.x, i.y) = (i.x + dx, i.y + dy); } - alternatives.iter_mut().for_each(|(x, y)| (*x, *y) = (*x + dx, *y + dy)); + alternatives.iter_mut().for_each(|p| (p.x, p.y) = (p.x + dx, p.y + dy)); } } @@ -3726,14 +3770,14 @@ 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 alternatives: Vec = 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; } Vec::new() } - None => others().collect(), + None => others().map(LabelPlace::from).collect(), }; // (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() }; @@ -3878,8 +3922,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac (i.x, i.y) = at(i.x, i.y); } } - for (x, y) in label_candidates.iter_mut().flat_map(|c| &mut c.alternatives) { - (*x, *y) = at(*x, *y); + for c in &mut label_candidates { + for p in &mut c.alternatives { + p.transform(&mut c.label, &|x, y| Some(at(x, y))); + } } (to.width, to.height) } @@ -4441,15 +4487,19 @@ fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option 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 { + let mut places = std::iter::once(LabelPlace::of(&c.label)) + .chain(std::mem::take(&mut c.alternatives)) + .filter_map(|mut p| p.transform(&mut c.label, at).then_some(p)); + let mut first = places.next()?; + let alternatives = places.collect(); + first.swap(&mut c.label); + 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(); + c.alternatives = alternatives; Some(c) } @@ -4470,15 +4520,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)); @@ -4575,8 +4628,8 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve // 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); + for (i, mut place) in std::iter::once(LabelPlace::take(&mut l)).chain(alternatives).enumerate() { + place.swap(&mut l); let (text, icon) = label_boxes(&l); if text.iter().chain(&icon).all(outside) { continue; @@ -4584,14 +4637,14 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve 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)); + at = Some((i, LabelPlace::take(&mut l), text, icon, show_text, show_icon)); } if whole { break; } } - let Some((i, x, y, text, icon, show_text, show_icon)) = at else { continue }; - (l.x, l.y) = (x, y); + let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; + place.swap(&mut l); 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 }); @@ -5018,7 +5071,12 @@ 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 }; + 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(), + }; 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); @@ -5038,25 +5096,78 @@ 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), 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), 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(), }; 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()); } @@ -5095,7 +5206,7 @@ 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() }; + 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 usize, 0, 0), alternatives } }) .collect() From 04301fb0aeb9b10f3ada9c6cfca01d4e3c7d7b07 Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:23:13 +0800 Subject: [PATCH 02/12] fix(labels): preserve curved candidates across tilted bands --- crates/render/src/label_places.rs | 6 +- crates/render/src/lib.rs | 132 ++++++++++++++-------- crates/render/src/line_labels.rs | 8 +- crates/render/src/line_positions_tests.rs | 36 +++++- 4 files changed, 127 insertions(+), 55 deletions(-) diff --git a/crates/render/src/label_places.rs b/crates/render/src/label_places.rs index 53708b3ff..333f41e8b 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -24,6 +24,8 @@ pub(super) struct Curves { pub spec: Arc, pub positions: Vec, pub locked: Option, + pub deduplicate: bool, + pub fallbacks: Vec, changes: Vec, } @@ -35,7 +37,7 @@ impl std::fmt::Debug for Curves { impl Curves { pub fn new(spec: Arc, positions: Vec, locked: Option) -> Self { - Self { spec, positions, locked, changes: Vec::new() } + Self { spec, positions, locked, deduplicate: false, fallbacks: Vec::new(), changes: Vec::new() } } pub fn project(&mut self, at: impl Fn(f32, f32) -> Option<(f32, f32)> + Send + Sync + 'static) { @@ -46,7 +48,7 @@ impl Curves { self.changes.push(Change::Shift(dx, dy)); } - /// A cheap anchor for band deduplication and height lifting; no character layout. + /// The first in-sight anchor (middle first) for a tilted view's label; no character layout. pub fn anchor_in(&self, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> Option<(f32, f32)> { for (i, d) in self.positions.iter().enumerate() { if self.locked.is_some_and(|won| won != i) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d8599b36a..bf5c1d9b2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1889,6 +1889,15 @@ impl LabelPlace { /// 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 }; + // 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); @@ -2296,13 +2305,32 @@ 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))), + // Deferred curves claim the key only once a band's candidate is placed. + _ if c.curves.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 unlayoutable curve must not suppress a later band's layoutable one. + let mut groups: Vec = Vec::new(); + let mut curve_groups = std::collections::HashMap::new(); + for mut candidate in candidates { + if let Some(curves) = &mut candidate.curves { + curves.deduplicate = true; + if let Some(&index) = curve_groups.get(&candidate.key) + && let Some(first) = groups.get_mut(index).and_then(|c: &mut Candidate| c.curves.as_mut()) + { + first.fallbacks.push(candidate); + continue; + } + curve_groups.insert(candidate.key, groups.len()); + } + groups.push(candidate); + } + place_labels(groups, Vec::new(), view.width as f32, view.height as f32, false) } /// Whether a turned or tilted `view` of `map` may be drawn on the GPU ([`gpu_turned`]): only @@ -3360,6 +3388,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let layer: &rustgis_map::Layer = layer; // Its symbol by expression, compiled once for the drawing (none: the layer's own). let styler = layer.styler(Some(scale)); + // Name labels by feature id so locks survive edits that move rows (#1046). let label_id = |row: usize| layer.dataset.features.get(row).map_or(row, |f| f.id as usize); let (labels, selection, ds_ptr) = (&layer.labels, &layer.selection, &layer.dataset); // The globe draws the lon/lat geometry itself; the flat map its Web Mercator copy. @@ -3763,22 +3792,13 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // It may run past the line's ends by about a letter, as QGIS's overrun. let overrun = size; let repeat = class.repeat.map(|r| r * 1.33 * label_factor); - if class.other_positions && repeat.is_none() { - let (chars, advances): (Vec, Vec) = if curving { - let chars: Vec = text.graphemes(true).map(String::from).collect(); - let advances = chars.iter().map(|c| text::measure_in(c, size, face)).collect(); - (chars, advances) - } else { - (Vec::new(), Vec::new()) - }; - let width = if curving { - advances.iter().map(|a| (a + spacing * 1.33).max(0.0)).sum::() - - advances.last().map_or(0.0, |a| (a + spacing * 1.33).max(0.0) - a) - } else { - text::spaced_block_width(&text, size, face, spacing) - }; - let positions = line_labels::positions(line.length(), width, overrun); - if curving { + let try_others = class.other_positions && repeat.is_none(); + 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(); + if try_others { + let width = line_labels::curved_len(&advances, spacing * 1.33); + let positions = line_labels::positions(line.length(), width, overrun); let Some((cx, cy)) = line.anchor(line.length() / 2.0) else { continue }; let angle = line.parallel(width, overrun, None).map(|(_, _, a)| a); // The preview is also View Unplaced's middle when every curve fails. @@ -3797,31 +3817,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac locked, )); } else { - for (i, at) in positions.into_iter().enumerate() { - if let Some((x, y, angle)) = line.parallel(width, overrun, Some(at)) { - if i == 0 { - places.push((0, x, y, Some(angle), Vec::new())); - } else { - line_alternatives.push(LabelPlace { x, y, angle: Some(angle), glyphs: Vec::new() }); - } - } - } - } - } else { - 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())); - } - } - } else { - // Each character with its accents, drawn as one. - 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 { + 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; }; @@ -3833,6 +3829,26 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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 { + line_labels::positions(line.length(), width, overrun).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 { let (lx, ly) = place(class.placement); @@ -4757,13 +4773,24 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve } let mut out = Placement { labels: fixed, unplaced: Vec::new(), keys: 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, curves, .. } in cands.into_iter().take(20_000) { - let c = l.collide; + let mut placed_curves = std::collections::HashSet::new(); + let mut failed_curves = std::collections::HashSet::new(); + let mut unplaced = Vec::new(); + let candidates = cands.into_iter().take(20_000).flat_map(|mut candidate| { + let fallbacks = candidate.curves.as_mut().map(|curves| std::mem::take(&mut curves.fallbacks)).unwrap_or_default(); + std::iter::once(candidate).chain(fallbacks) + }); + for Candidate { label: mut l, key, alternatives, curves, .. } in candidates { + let grouped = curves.as_ref().is_some_and(|curves| curves.deduplicate); + if grouped && placed_curves.contains(&key) { + continue; + } + 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(), |c| c.positions.len()); + 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() { @@ -4775,7 +4802,7 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let mut ready = std::iter::once(original).chain(alternatives); for i in 0..count { let place = match &curves { - Some(c) => c.place(i, &mut l), + Some(curves) => curves.place(i, &mut l), None => ready.next(), }; let Some(mut place) = place else { continue }; @@ -4796,15 +4823,15 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; place.swap(&mut l); 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 unplaced.len() < 2000 && has_text && (!grouped || failed_curves.insert(key)) { + unplaced.push((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); } continue; } - if show_text && !c.text_ignore_placement { + if show_text && !collide.text_ignore_placement { text.iter().for_each(|b| grid.insert(*b)); } - if show_icon && !c.icon_ignore_placement { + if show_icon && !collide.icon_ignore_placement { icon.iter().for_each(|b| grid.insert(*b)); } if !show_text { @@ -4813,9 +4840,14 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve if !show_icon { l.icon = None; } + if grouped { + placed_curves.insert(key); + } out.labels.push(l); out.keys.push((key, u8::try_from(i).unwrap_or(u8::MAX))); } + out.unplaced = + unplaced.into_iter().filter(|(key, _)| key.is_none_or(|key| !placed_curves.contains(&key))).map(|(_, label)| label).collect(); out } diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index 7dff17245..b366a565b 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -10,6 +10,12 @@ 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 { @@ -194,7 +200,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; diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 3ba1232bd..984cd1de6 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -172,7 +172,6 @@ fn rendering_retries_only_opted_in_nonrepeated_line_labels() { #[test] fn dense_curved_labels_have_a_bounded_lazy_layout_budget() { - let start = web_time::Instant::now(); label_places::LAYOUTS.with(|n| n.set(0)); let c = curved(&[(20.0, 100.0), (380.0, 100.0)]); let candidates = (0..4000) @@ -186,7 +185,6 @@ fn dense_curved_labels_have_a_bounded_lazy_layout_budget() { 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)); - assert!(start.elapsed() < web_time::Duration::from_secs(5)); } #[test] @@ -251,3 +249,37 @@ fn a_rendered_curved_label_can_leave_a_sharp_middle_bend() { 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 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 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"); + // Band siblings share one slot even across the placement cap: a viable sibling is kept. + 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_eq!(p.labels.len(), 1); + assert!(p.unplaced.is_empty()); +} From 3ce57318a04ec47022c512184efb0724dd0c315c Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:45:32 +0800 Subject: [PATCH 03/12] fix(labels): stabilize locked lines and bound band fallbacks --- crates/map/src/camera.rs | 9 +- crates/map/src/lib.rs | 4 + crates/render/src/label_places.rs | 4 +- crates/render/src/lib.rs | 180 +++++++++++++++++----- crates/render/src/line_labels.rs | 29 +++- crates/render/src/line_positions_tests.rs | 112 +++++++++++++- 6 files changed, 290 insertions(+), 48 deletions(-) diff --git a/crates/map/src/camera.rs b/crates/map/src/camera.rs index 0d7507eda..c0510819e 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. @@ -486,7 +486,8 @@ impl GroundLookup { } /// The most bands a tilted view is cut into. -const MAX_BANDS: usize = 8; +/// Maximum number of flat bands used to draw one tilted view. +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 +713,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 975e4a574..4101563dc 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -1164,6 +1164,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, @@ -1286,6 +1289,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/render/src/label_places.rs b/crates/render/src/label_places.rs index 333f41e8b..3af03affe 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -24,8 +24,6 @@ pub(super) struct Curves { pub spec: Arc, pub positions: Vec, pub locked: Option, - pub deduplicate: bool, - pub fallbacks: Vec, changes: Vec, } @@ -37,7 +35,7 @@ impl std::fmt::Debug for Curves { impl Curves { pub fn new(spec: Arc, positions: Vec, locked: Option) -> Self { - Self { spec, positions, locked, deduplicate: false, fallbacks: Vec::new(), changes: Vec::new() } + Self { spec, positions, locked, changes: Vec::new() } } pub fn project(&mut self, at: impl Fn(f32, f32) -> Option<(f32, f32)> + Send + Sync + 'static) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bf5c1d9b2..20d3d3483 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1847,11 +1847,20 @@ struct Candidate { key: rustgis_map::LabelKey, 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, } +/// Alternatives from one line stretch; band siblings retain one top-level candidate slot. +#[derive(Clone, Debug, Default)] +struct LinePlaces { + anchors: Vec>, + deduplicate: bool, + fallbacks: Vec, +} + /// Geometry of a possible text placement; style and the stationary icon belong to the candidate. #[derive(Clone, Debug, PartialEq)] struct LabelPlace { @@ -1889,6 +1898,9 @@ impl LabelPlace { /// 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 @@ -1929,7 +1941,7 @@ impl Candidate { /// 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(), curves: None, row: usize::MAX } + Candidate { label, priority, key, alternatives: Vec::new(), curves: None, line: None, row: usize::MAX } } } @@ -2291,9 +2303,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(); @@ -2305,8 +2318,8 @@ 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))), - // Deferred curves claim the key only once a band's candidate is placed. - _ if c.curves.is_some() => true, + // 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); @@ -2314,19 +2327,21 @@ fn place_tilted_labels(map: &Map, view: &View, mut candidates: Vec, g lift_labels(view, g, &mut candidates); } // Group bands under one candidate slot, retaining their near-first order. A nearer - // band's unlayoutable curve must not suppress a later band's layoutable one. + // band's failed places must not suppress a later band's usable ones. let mut groups: Vec = Vec::new(); - let mut curve_groups = std::collections::HashMap::new(); + let mut line_groups = std::collections::HashMap::new(); for mut candidate in candidates { - if let Some(curves) = &mut candidate.curves { - curves.deduplicate = true; - if let Some(&index) = curve_groups.get(&candidate.key) - && let Some(first) = groups.get_mut(index).and_then(|c: &mut Candidate| c.curves.as_mut()) + 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()) { - first.fallbacks.push(candidate); + if first.fallbacks.len() < rustgis_map::camera::MAX_TILT_BANDS.saturating_sub(1) { + first.fallbacks.push(candidate); + } continue; } - curve_groups.insert(candidate.key, groups.len()); + line_groups.insert(candidate.key, groups.len()); } groups.push(candidate); } @@ -3187,6 +3202,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. @@ -3784,23 +3800,57 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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)).map(std::sync::Arc::new) - }); - 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 try_others = class.other_positions && repeat.is_none(); + let saved_anchor = + lock.as_ref().and_then(|_| map.label_lock_line_anchors.get(&key)).copied().filter(|_| try_others); + 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 { + 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]| { + 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(); if try_others { let width = line_labels::curved_len(&advances, spacing * 1.33); - let positions = line_labels::positions(line.length(), width, overrun); - let Some((cx, cy)) = line.anchor(line.length() / 2.0) else { continue }; - let angle = line.parallel(width, overrun, None).map(|(_, _, a)| a); + let positions = positions(width); + 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 = @@ -3833,7 +3883,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // 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 { - line_labels::positions(line.length(), width, overrun).into_iter().enumerate().collect() + 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) }; @@ -3886,6 +3938,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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. Point alternatives have a fixed shape. continue; } Vec::new() @@ -3925,6 +3979,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac key, alternatives, curves: curves.take(), + line: line_places.take(), row, }); } @@ -4054,6 +4109,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // (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() }; @@ -4602,6 +4658,26 @@ 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. @@ -4647,7 +4723,10 @@ fn turn_candidate(c: &mut Candidate, at: impl Fn(f32, f32) -> Option<(f32, f32)> (i.x, i.y) = (x, y); } for p in &mut c.alternatives { - p.transform(&mut c.label, &at); + 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); @@ -4747,6 +4826,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 @@ -4771,18 +4851,18 @@ 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; - let mut placed_curves = std::collections::HashSet::new(); - let mut failed_curves = std::collections::HashSet::new(); + let mut placed_groups = std::collections::HashSet::new(); + let mut failed_groups = std::collections::HashSet::new(); let mut unplaced = Vec::new(); let candidates = cands.into_iter().take(20_000).flat_map(|mut candidate| { - let fallbacks = candidate.curves.as_mut().map(|curves| std::mem::take(&mut curves.fallbacks)).unwrap_or_default(); + let fallbacks = candidate.line.as_mut().map(|line| std::mem::take(&mut line.fallbacks)).unwrap_or_default(); std::iter::once(candidate).chain(fallbacks) }); - for Candidate { label: mut l, key, alternatives, curves, .. } in candidates { - let grouped = curves.as_ref().is_some_and(|curves| curves.deduplicate); - if grouped && placed_curves.contains(&key) { + for Candidate { label: mut l, key, alternatives, curves, line, .. } in candidates { + let grouped = line.as_ref().is_some_and(|line| line.deduplicate); + if grouped && placed_groups.contains(&key) { continue; } let collide = l.collide; @@ -4806,6 +4886,9 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve 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) { @@ -4823,7 +4906,7 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; place.swap(&mut l); if !show_text && !show_icon { - if unplaced.len() < 2000 && has_text && (!grouped || failed_curves.insert(key)) { + if unplaced.len() < 2000 && has_text && (!grouped || failed_groups.insert(key)) { unplaced.push((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); } continue; @@ -4841,13 +4924,16 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve l.icon = None; } if grouped { - placed_curves.insert(key); + 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))); } out.unplaced = - unplaced.into_iter().filter(|(key, _)| key.is_none_or(|key| !placed_curves.contains(&key))).map(|(_, label)| label).collect(); + unplaced.into_iter().filter(|(key, _)| key.is_none_or(|key| !placed_groups.contains(&key))).map(|(_, label)| label).collect(); out } @@ -5259,6 +5345,7 @@ mod tests { 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::>(); @@ -5293,8 +5380,15 @@ mod tests { 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, row: 0, alternatives: vec![other.clone()] }; + 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); @@ -5323,7 +5417,15 @@ mod tests { 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, row: 0, alternatives: vec![alternative.clone()] }; + 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)); @@ -5346,6 +5448,7 @@ mod tests { key: (1, 0, 0, 0), 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(); @@ -5400,6 +5503,7 @@ mod tests { key: (u32::MAX, i as usize, 0, 0), alternatives, curves: None, + line: None, row: usize::MAX, } }) diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index b366a565b..b1071b468 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -176,6 +176,32 @@ 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) @@ -234,7 +260,8 @@ impl Line { /// 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; - let room = mid - width / 2.0 + overrun.max(0.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; diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 984cd1de6..ef6b98a2f 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -14,6 +14,7 @@ fn curved(line: &[(f32, f32)]) -> Candidate { 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, @@ -277,9 +278,116 @@ fn a_nearer_bands_unlayoutable_curve_does_not_claim_the_feature_key() { 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"); - // Band siblings share one slot even across the placement cap: a viable sibling is kept. - let candidates = std::iter::repeat_n(bad, 20_000).chain(std::iter::once(good)).collect(); + // 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); +} From 20757275f7ccc813efd35256b42752592128d237 Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:47:03 +0800 Subject: [PATCH 04/12] fix(labels): preserve lock caches and cap frame layout work --- crates/map/src/camera.rs | 3 +- crates/render/src/label_places.rs | 2 +- crates/render/src/layout.rs | 3 + crates/render/src/lib.rs | 31 ++++++-- crates/render/src/line_positions_tests.rs | 86 +++++++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/crates/map/src/camera.rs b/crates/map/src/camera.rs index c0510819e..f9a4b9d81 100644 --- a/crates/map/src/camera.rs +++ b/crates/map/src/camera.rs @@ -485,8 +485,7 @@ impl GroundLookup { } } -/// The most bands a tilted view is cut into. -/// Maximum number of flat bands used to draw one tilted view. +/// 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 diff --git a/crates/render/src/label_places.rs b/crates/render/src/label_places.rs index 3af03affe..c3aea705e 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -52,7 +52,7 @@ impl Curves { if self.locked.is_some_and(|won| won != i) { continue; } - let (x, y) = self.spec.line.anchor(*d)?; + let Some((x, y)) = self.spec.line.anchor(*d) else { continue }; if let Some(p) = at(x, y) { return Some(p); } diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 405823398..49af0a4d0 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1443,6 +1443,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<'_> { @@ -1451,6 +1452,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 { @@ -1458,6 +1460,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 20d3d3483..6741f47ab 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4557,6 +4557,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 { @@ -4584,6 +4585,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 } @@ -4615,6 +4617,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 { @@ -4634,6 +4637,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 } @@ -4682,6 +4686,8 @@ fn line_near_anchor(ctx: &Ctx<'_>, parts: &[Vec], anchor: (f32, f32), ove /// 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)> + Clone + Send + Sync + 'static)) -> Option { + // turn_with handles the curved label icon angle; the ready path leaves it here. + let curved = c.curves.is_some(); if let Some(curves) = &mut c.curves { let (x, y) = curves.anchor_in(at)?; turn_with(&mut c.label, at); @@ -4703,7 +4709,7 @@ fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32) c.alternatives = alternatives; } if let Some(i) = &mut c.label.icon { - if let Some(a) = i.angle { + if !curved && 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)?; @@ -4853,6 +4859,8 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve } 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; + // Same work bound as 20,000 heads with eight positions, shared across all bands. + let mut layouts_left = 20_000 * 8; let mut placed_groups = std::collections::HashSet::new(); let mut failed_groups = std::collections::HashSet::new(); let mut unplaced = Vec::new(); @@ -4882,7 +4890,16 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let mut ready = std::iter::once(original).chain(alternatives); for i in 0..count { let place = match &curves { - Some(curves) => curves.place(i, &mut l), + Some(curves) => { + if curves.locked.is_some_and(|won| won != i) { + continue; + } + if layouts_left == 0 { + break; + } + layouts_left -= 1; + curves.place(i, &mut l) + } None => ready.next(), }; let Some(mut place) = place else { continue }; @@ -4906,7 +4923,7 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; place.swap(&mut l); if !show_text && !show_icon { - if unplaced.len() < 2000 && has_text && (!grouped || failed_groups.insert(key)) { + if has_text && (!grouped || failed_groups.insert(key)) { unplaced.push((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); } continue; @@ -4932,8 +4949,12 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve out.labels.push(l); out.keys.push((key, u8::try_from(i).unwrap_or(u8::MAX))); } - out.unplaced = - unplaced.into_iter().filter(|(key, _)| key.is_none_or(|key| !placed_groups.contains(&key))).map(|(_, label)| label).collect(); + out.unplaced = unplaced + .into_iter() + .filter(|(key, _)| key.is_none_or(|key| !placed_groups.contains(&key))) + .take(2000) + .map(|(_, label)| label) + .collect(); out } diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index ef6b98a2f..1239e2862 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -391,3 +391,89 @@ fn locked_multipart_lines_do_not_follow_a_distant_parts_extension() { 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); + label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 20_000 * 8)); + 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 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)); + } +} From aeb25bfdd5053ef01cc621927a0ee08a4c1b77eb Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:31:22 +0800 Subject: [PATCH 05/12] fix(labels): project previews from their visible line anchors --- crates/engine/src/cmd/items.rs | 1 + crates/engine/src/cmd/labels.rs | 1 + crates/engine/src/cmd/navigate.rs | 1 + crates/render/src/label_places.rs | 13 ++++--- crates/render/src/lib.rs | 28 ++++++++++------ crates/render/src/line_labels.rs | 6 ++++ crates/render/src/line_positions_tests.rs | 41 +++++++++++++++++++---- 7 files changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index da58f3c06..0259c2214 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 a3215af5a..8c91524ed 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -271,6 +271,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) } diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index 79de67e9e..f8756ffb0 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(); for l in &mut m.layers { l.renew_instance_id(); l.selection.clear(); diff --git a/crates/render/src/label_places.rs b/crates/render/src/label_places.rs index c3aea705e..cd9c580f7 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -46,15 +46,20 @@ impl Curves { self.changes.push(Change::Shift(dx, dy)); } - /// The first in-sight anchor (middle first) for a tilted view's label; no character layout. - pub fn anchor_in(&self, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) -> Option<(f32, f32)> { + /// 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 }; - if let Some(p) = at(x, y) { - return Some(p); + 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 diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6741f47ab..0aea3e68c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1845,6 +1845,8 @@ struct Candidate { label: PlacedLabel, priority: f64, key: rustgis_map::LabelKey, + // 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, @@ -1858,6 +1860,7 @@ struct Candidate { struct LinePlaces { anchors: Vec>, deduplicate: bool, + // Other camera bands for the same key, each retaining its own placement slots. fallbacks: Vec, } @@ -3805,8 +3808,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let key = (lid, label_id(row), ci, 0); let repeat = class.repeat.map(|r| r * 1.33 * label_factor); let try_others = class.other_positions && repeat.is_none(); - let saved_anchor = - lock.as_ref().and_then(|_| map.label_lock_line_anchors.get(&key)).copied().filter(|_| try_others); + let saved_anchor = lock.as_ref().and(map.label_lock_line_anchors.get(&key)).copied().filter(|_| try_others); let (line, locked_at) = if let Some(anchor) = saved_anchor { let target = match &globe { Some(globe) => globe.forward(anchor), @@ -3831,6 +3833,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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() { + return Vec::new(); + } positions .iter() .map(|d| { @@ -3853,8 +3859,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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(&(lid, label_id(row), ci, 0))).map(|won| usize::from(*won)); + 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(), @@ -3939,7 +3944,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac (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. Point alternatives have a fixed shape. + // 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() @@ -4686,12 +4693,9 @@ fn line_near_anchor(ctx: &Ctx<'_>, parts: &[Vec], anchor: (f32, f32), ove /// 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)> + Clone + Send + Sync + 'static)) -> Option { - // turn_with handles the curved label icon angle; the ready path leaves it here. - let curved = c.curves.is_some(); if let Some(curves) = &mut c.curves { - let (x, y) = curves.anchor_in(at)?; - turn_with(&mut c.label, at); - (c.label.x, c.label.y) = (x, y); + let mut preview = curves.preview_in(&mut c.label, at)?; + preview.swap(&mut c.label); curves.project(at.clone()); } else { let mut first = None; @@ -4709,7 +4713,7 @@ fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32) c.alternatives = alternatives; } if let Some(i) = &mut c.label.icon { - if !curved && let Some(a) = i.angle { + 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)?; @@ -4718,6 +4722,8 @@ fn tilt_candidate(mut c: Candidate, at: &(impl Fn(f32, f32) -> Option<(f32, f32) } /// 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) { diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index b1071b468..8eb457258 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -147,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() diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 1239e2862..03c0d6f4a 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -133,13 +133,7 @@ fn rendering_retries_only_opted_in_nonrepeated_line_labels() { 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); - if let Ok(dir) = std::env::var("RUSTGIS_LINE_LABEL_EVIDENCE_DIR") { - std::fs::write(format!("{dir}/{placement:?}-before.png"), render_png(&mut map, &view).unwrap()).unwrap(); - } map.layer_mut(id).unwrap().labels.default.other_positions = true; - if let Ok(dir) = std::env::var("RUSTGIS_LINE_LABEL_EVIDENCE_DIR") { - std::fs::write(format!("{dir}/{placement:?}-after.png"), render_png(&mut map, &view).unwrap()).unwrap(); - } 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(); @@ -477,3 +471,38 @@ fn tilted_icons_turn_once_for_both_ready_and_deferred_labels() { 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()); + // 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()); +} From 02c78bcd87e949aa27c54da38216a5512a8aba32 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Mon, 2 Nov 2026 00:25:12 +0800 Subject: [PATCH 06/12] fix(labels): honor terrain and repeat when placing lines --- crates/engine/src/cmd/labels.rs | 4 ++- crates/engine/src/tests.rs | 10 ++++++ crates/render/src/label_places.rs | 38 +++++++++++++++++++++++ crates/render/src/lib.rs | 33 ++++++++++++++------ crates/render/src/line_positions_tests.rs | 33 ++++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 8c91524ed..efdaee4d4 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -294,7 +294,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/tests.rs b/crates/engine/src/tests.rs index 59afe0c90..3c3a7669e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2672,6 +2672,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/render/src/label_places.rs b/crates/render/src/label_places.rs index cd9c580f7..7473c6192 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -8,6 +8,7 @@ type Projection = Arc Option<(f32, f32)> + Send + Sync>; enum Change { Project(Projection), Shift(f32, f32), + PlaceShifts(Vec<(f32, f32)>), } #[derive(Debug)] @@ -46,6 +47,38 @@ impl Curves { self.changes.push(Change::Shift(dx, dy)); } + /// 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::Shift(dx, dy) => { + x += dx; + y += dy; + } + 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 { @@ -84,6 +117,11 @@ impl Curves { } } Change::Shift(dx, dy) => (p.x, p.y) = (p.x + dx, p.y + dy), + Change::PlaceShifts(shifts) => { + if let Some((dx, dy)) = shifts.get(i) { + (p.x, p.y) = (p.x + dx, p.y + dy); + } + } } } Some(p) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0aea3e68c..8ece49a34 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2185,16 +2185,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 c in candidates.iter_mut() { - let l = &mut c.label; - 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; + } + } + 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); } - c.shift_places(dx, dy); } } @@ -3834,7 +3849,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac }; let anchors = |positions: &[f32]| { // Back-project only while locking is requested, never on ordinary frames or replay. - if !display.locked || lock.is_some() { + if !display.locked || lock.is_some() || matches!(labels_to, Labels::Tilted(..)) { return Vec::new(); } positions diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 03c0d6f4a..83d6c8de0 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -230,6 +230,36 @@ fn terrain_and_roofs_translate_ready_and_deferred_places_without_changing_glyph_ } } +#[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); @@ -499,10 +529,13 @@ fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { 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); } From 98aa0e68e4df2847ad36800cd3880d5ad89747af Mon Sep 17 00:00:00 2001 From: choupeanut Date: Mon, 2 Nov 2026 08:17:47 +0800 Subject: [PATCH 07/12] fix(labels): skip hidden previews and preserve anchor slots --- crates/render/src/lib.rs | 22 +++++++++++++++--- crates/render/src/line_positions_tests.rs | 27 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8ece49a34..c4fd5df07 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2363,7 +2363,7 @@ fn place_tilted_labels(map: &Map, view: &View, mut candidates: Vec, g } groups.push(candidate); } - place_labels(groups, Vec::new(), view.width as f32, view.height as f32, false) + 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 @@ -3919,6 +3919,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } 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() }); } } } @@ -4127,7 +4130,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } 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(..)) { @@ -4868,7 +4871,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 }; @@ -4944,7 +4960,7 @@ fn place_labels(cands: impl IntoIterator>, fixed: Ve let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; place.swap(&mut l); if !show_text && !show_icon { - if has_text && (!grouped || failed_groups.insert(key)) { + if show_unplaced && has_text && (!grouped || failed_groups.insert(key)) { unplaced.push((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); } continue; diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 83d6c8de0..58403e582 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -290,7 +290,8 @@ fn a_nearer_bands_unlayoutable_curve_does_not_claim_the_feature_key() { // 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 map = Map::new("bands"); + let mut map = Map::new("bands"); + map.label_display.show_unplaced = true; let view = View { width: 400.0, height: 200.0, @@ -485,6 +486,30 @@ fn successful_band_previews_do_not_consume_the_unplaced_cap() { 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)); From 05e83edc3735ae4aeeb40f2dd5f5e6771f757b96 Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:26:47 +0800 Subject: [PATCH 08/12] fix(labels): bound previews and reuse rejected glyph layouts --- crates/map/src/symbology.rs | 7 +- crates/render/src/label_places.rs | 26 +-- crates/render/src/lib.rs | 215 +++++++++++----------- crates/render/src/line_positions_tests.rs | 23 ++- 4 files changed, 147 insertions(+), 124 deletions(-) diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index c9bdf0a43..49aeb824f 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1839,8 +1839,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, @@ -2261,7 +2262,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 index 7473c6192..826cde7cf 100644 --- a/crates/render/src/label_places.rs +++ b/crates/render/src/label_places.rs @@ -7,7 +7,6 @@ type Projection = Arc Option<(f32, f32)> + Send + Sync>; #[derive(Clone)] enum Change { Project(Projection), - Shift(f32, f32), PlaceShifts(Vec<(f32, f32)>), } @@ -43,10 +42,6 @@ impl Curves { self.changes.push(Change::Project(Arc::new(at))); } - pub fn shift(&mut self, dx: f32, dy: f32) { - self.changes.push(Change::Shift(dx, dy)); - } - /// 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 @@ -61,10 +56,6 @@ impl Curves { let Some(p) = at(x, y) else { return (0.0, 0.0) }; (x, y) = p; } - Change::Shift(dx, dy) => { - x += dx; - y += dy; - } Change::PlaceShifts(shifts) => { if let Some((dx, dy)) = shifts.get(i) { x += dx; @@ -106,8 +97,20 @@ impl Curves { #[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))?; - let glyphs = - self.spec.chars.iter().zip(turns).map(|(text, (dx, dy, angle))| LabelGlyph { text: text.clone(), dx, dy, angle }).collect(); + // 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 { @@ -116,7 +119,6 @@ impl Curves { return None; } } - Change::Shift(dx, dy) => (p.x, p.y) = (p.x + dx, p.y + dy), Change::PlaceShifts(shifts) => { if let Some((dx, dy)) = shifts.get(i) { (p.x, p.y) = (p.x + dx, p.y + dy); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c4fd5df07..c761f5012 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1923,24 +1923,6 @@ impl LabelPlace { } } -impl Candidate { - fn shift_places(&mut self, dx: f32, dy: f32) { - self.label.x += dx; - self.label.y += dy; - if let Some(i) = &mut self.label.icon { - i.x += dx; - i.y += dy; - } - for p in &mut self.alternatives { - p.x += dx; - p.y += dy; - } - if let Some(curves) = &mut self.curves { - curves.shift(dx, dy); - } - } -} - /// 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 { @@ -2441,16 +2423,32 @@ fn raise_labels(map: &Map, view: &View, candidates: &mut [Candidate]) { 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); - c.shift_places(dx, 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); + } } } @@ -4899,99 +4897,100 @@ fn place_labels_with_previews( // Same work bound as 20,000 heads with eight positions, shared across all bands. let mut layouts_left = 20_000 * 8; let mut placed_groups = std::collections::HashSet::new(); - let mut failed_groups = std::collections::HashSet::new(); let mut unplaced = Vec::new(); - let candidates = cands.into_iter().take(20_000).flat_map(|mut candidate| { - let fallbacks = candidate.line.as_mut().map(|line| std::mem::take(&mut line.fallbacks)).unwrap_or_default(); - std::iter::once(candidate).chain(fallbacks) - }); - for Candidate { label: mut l, key, alternatives, curves, line, .. } in candidates { - let grouped = line.as_ref().is_some_and(|line| line.deduplicate); - if grouped && placed_groups.contains(&key) { - continue; - } - 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; - } - if layouts_left == 0 { - break; + 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 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; + } + if layouts_left == 0 { + break; + } + layouts_left -= 1; + curves.place(i, &mut l) } - layouts_left -= 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; } - None => ready.next(), - }; - let Some(mut place) = place else { continue }; - if !place.x.is_finite() || !place.y.is_finite() { - continue; } + let Some((i, mut place, text, icon, show_text, show_icon)) = at else { continue }; place.swap(&mut l); - let (text, icon) = label_boxes(&l); - if text.iter().chain(&icon).all(outside) { + 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 (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 show_text && !collide.text_ignore_placement { + text.iter().for_each(|b| grid.insert(*b)); } - if whole { - break; + if show_icon && !collide.icon_ignore_placement { + icon.iter().for_each(|b| grid.insert(*b)); } - } - 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 && (!grouped || failed_groups.insert(key)) { - unplaced.push((grouped.then_some(key), PlacedLabel { color: UNPLACED, halo: Some(halo_for(UNPLACED)), icon: None, ..l })); + if !show_text { + l.text.clear(); } - continue; - } - if show_text && !collide.text_ignore_placement { - text.iter().for_each(|b| grid.insert(*b)); - } - 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 !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 let Some(anchor) = line.as_ref().and_then(|line| line.anchors.get(i)).copied().flatten() { - out.line_anchors.push((key, anchor)); + 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 - .into_iter() - .filter(|(key, _)| key.is_none_or(|key| !placed_groups.contains(&key))) - .take(2000) - .map(|(_, label)| label) - .collect(); + out.unplaced = unplaced; out } diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 58403e582..b8830fa4f 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -216,7 +216,14 @@ fn terrain_and_roofs_translate_ready_and_deferred_places_without_changing_glyph_ c.key.1 = blocks.layer(id).unwrap().dataset.features[0].id as usize; let before = c.curves.as_ref().unwrap().place(0, &mut c.label.clone()).unwrap(); c.alternatives.push(before.clone()); - let mut candidates = vec![c]; + 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 { @@ -227,6 +234,8 @@ fn terrain_and_roofs_translate_ready_and_deferred_places_without_changing_glyph_ 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); } } @@ -564,3 +573,15 @@ fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { 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"); +} From 39310fda43fe32ebba060264b16936f248db803c Mon Sep 17 00:00:00 2001 From: choupeanut Date: Mon, 2 Nov 2026 22:05:06 +0800 Subject: [PATCH 09/12] fix(labels): drop invalid curved lock previews and keep own place under curve budget An invalid locked slot on a curved line no longer draws a View Unplaced preview, matching straight lines. The shared curve layout budget now only limits alternatives, so a label's own place is always tried. --- crates/render/src/lib.rs | 11 +++++++++-- crates/render/src/line_positions_tests.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c761f5012..39c8bde0c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3867,6 +3867,11 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac 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; + } 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); @@ -4928,10 +4933,12 @@ fn place_labels_with_previews( if curves.locked.is_some_and(|won| won != i) { continue; } - if layouts_left == 0 { + // 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 -= 1; + layouts_left = layouts_left.saturating_sub(1); curves.place(i, &mut l) } None => ready.next(), diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index b8830fa4f..2501b21c4 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -574,6 +574,24 @@ fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { assert_eq!(map.label_lock_line_anchors, anchors); } +#[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)]); From 715fcd496f40c8d074dc8517c768af8678a433b7 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Mon, 2 Nov 2026 23:51:34 +0800 Subject: [PATCH 10/12] fix(labels): type the curve budget and the lock test's feature id after merge --- crates/render/src/lib.rs | 2 +- crates/render/src/line_positions_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 820b1703c..061d8a9bc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4935,7 +4935,7 @@ fn place_labels_with_previews( 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; // Same work bound as 20,000 heads with eight positions, shared across all bands. - let mut layouts_left = 20_000 * 8; + 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) { diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 2501b21c4..95a8c0c73 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -213,7 +213,7 @@ fn terrain_and_roofs_translate_ready_and_deferred_places_without_changing_glyph_ 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 as usize; + 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(); From ee1fc63e7c8683c70b0c5bc4696ee4ae947a5c24 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Tue, 3 Nov 2026 00:20:05 +0800 Subject: [PATCH 11/12] test(labels): the curve budget limits alternatives, not each label's own place --- crates/render/src/line_positions_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 95a8c0c73..2ff0f44dd 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -465,7 +465,10 @@ fn colliding_band_groups_share_a_frame_layout_budget() { .collect::>(); label_places::LAYOUTS.with(|n| n.set(0)); let p = place_labels(candidates, vec![blocker], 400.0, 200.0, false); - label_places::LAYOUTS.with(|n| assert_eq!(n.get(), 20_000 * 8)); + // 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); } From 400936ec0d119ee5ac9cc357a8b09a3ebc89f555 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Tue, 3 Nov 2026 11:38:26 +0800 Subject: [PATCH 12/12] fix(labels): a locked line without its saved anchor is omitted, not slid --- crates/render/src/lib.rs | 5 +++++ crates/render/src/line_positions_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dced7acb4..92198e474 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3862,6 +3862,11 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let repeat = class.repeat.map(|r| r * 1.33 * label_factor); 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), diff --git a/crates/render/src/line_positions_tests.rs b/crates/render/src/line_positions_tests.rs index 2ff0f44dd..1573738fb 100644 --- a/crates/render/src/line_positions_tests.rs +++ b/crates/render/src/line_positions_tests.rs @@ -577,6 +577,20 @@ fn an_invalid_locked_straight_slot_never_invents_a_middle_preview() { 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);