From 1011f6a2c81a824008b52a42726d4bff7250faeb Mon Sep 17 00:00:00 2001 From: strebdom Date: Sat, 24 Oct 2026 13:07:32 +0200 Subject: [PATCH 1/5] feat(labels): label lines along them, straight or curved, and italic labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two label placements for lines: Parallel sets the text straight, turned to the line's direction across it; Curved turns each character with the line where it bends. Both sit at the middle of the line's longest part on the screen, centred on the line and kept upright, and may run past its ends by about a letter. A line too short for its text, or one bending more than 25 degrees from one character to the next (QGIS's default), is left unlabelled. Points and polygons take them as Automatic. Label classes can also be italic. - map: `LabelPlacement::Parallel`/`Curved`, `LabelClass::italic`. - render: `line_labels` places the text along a screen line; `PlacedLabel::glyphs` carries a curved label's characters (each placed and turned about the label's centre), drawn one by one, colliding as a square each, and turned with the view (`turn_with`, read from the other end when upside down). - ui: each glyph turned on screen; Italic and Position › Along lines in the Labeling pane. - engine: `labels.placement` "parallel"/"curved", `labels.style` italic. - docs: cartography.md › Labeling. --- crates/engine/src/cmd/labels.rs | 2 +- crates/engine/src/cmd/layer.rs | 7 +- crates/engine/src/tests.rs | 28 +++++ crates/map/src/symbology.rs | 22 +++- crates/render/src/lib.rs | 163 +++++++++++++++++++++++++++++- crates/render/src/line_labels.rs | 154 ++++++++++++++++++++++++++++ crates/render/src/stream_tests.rs | 1 + crates/render/src/text.rs | 17 ++++ crates/render/src/tiles.rs | 1 + crates/ui-egui/src/labelpane.rs | 13 +++ crates/ui-egui/src/mapview.rs | 8 ++ docs/user-guide/cartography.md | 10 +- 12 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/line_labels.rs diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index f0a1cfd0b..6e3ace0bb 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -49,7 +49,7 @@ pub fn commands() -> Vec { }) .when(need_feature_layer) .params( - r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position": "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"}"#, + r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position": "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"|"parallel" (along a line, straight)|"curved" (along a line, following its bends)}"#, ), // Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes // listed first, Default last, are placed first (`Labeling`, #732). diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 5100e9784..f81c8a702 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -287,7 +287,7 @@ pub fn commands() -> Vec { C::new("labels.style", "Text Symbol", "Style › Text Symbol", |s, p| { let size = f(p, "size").map(|v| v.clamp(*LABEL_SIZE.start(), *LABEL_SIZE.end()) as f32); let color = s.get_color(p, "color")?; - let (halo, bold) = (b(p, "halo"), b(p, "bold")); + let (halo, bold, italic) = (b(p, "halo"), b(p, "bold"), b(p, "italic")); super::labels::with_class(s, p, |c| { if let Some(v) = size { c.size = v; @@ -301,10 +301,13 @@ pub fn commands() -> Vec { if let Some(bb) = bold { c.bold = bb; } + if let Some(it) = italic { + c.italic = it; + } }) }) .when(need_feature_layer) - .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool}"##), + .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool}"##), C::new("layer.definitionQuery", "Definition Query", "Data › Definition Query", |s, p| { let q = ps(p, "where").map(str::trim).unwrap_or("").to_string(); let id = s.layer_id(p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4b6956df5..374e10b87 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -577,6 +577,34 @@ fn a_reference_scale_is_set_kept_and_undone() { check(&s); } +#[test] +fn rivers_are_labelled_along_their_lines_in_italics() { + let mut s = world(); + s.run("labels.field", &json!({"layer": "Rivers", "field": "name"})).unwrap(); + s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved"})).unwrap(); + s.run("labels.style", &json!({"layer": "Rivers", "italic": true})).unwrap(); + let check = |s: &Session| { + let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; + assert_eq!((c.placement, c.italic), (rustgis_map::LabelPlacement::Curved, true)); + }; + check(&s); + let dir = std::env::temp_dir().join(format!("rustgis-line-labels-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("labels.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + check(&t); + t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + s.run("labels.placement", &json!({"layer": "Rivers", "position": "parallel"})).unwrap(); + assert_eq!(s.map().layer_by_name("Rivers").unwrap().labels.default.placement, rustgis_map::LabelPlacement::Parallel); + // Not slanted unless asked: a project from before reads as upright. + s.run("labels.style", &json!({"layer": "Rivers", "italic": false})).unwrap(); + let text = serde_json::to_string(&s.map().layer_by_name("Rivers").unwrap().labels).unwrap(); + assert!(!text.contains("italic"), "{text}"); +} + #[test] fn project_save_and_open_roundtrip() { let mut s = world(); diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 2b79dba9d..c55dd28a8 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1150,6 +1150,9 @@ pub struct LabelClass { /// Where a label sits relative to its feature. #[serde(default)] pub placement: LabelPlacement, + /// Slanted (an italic face where the font has one). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub italic: bool, } fn default_class_name() -> String { @@ -1183,10 +1186,16 @@ pub enum LabelPlacement { AboveLeft, BelowRight, BelowLeft, + /// Along a line, straight, turned to its direction across the text, at its middle; points + /// and polygons are labelled as `Auto`. + Parallel, + /// Along a line, each character turned with it where it bends, at its middle; points and + /// polygons are labelled as `Auto`. + Curved, } impl LabelPlacement { - pub const ALL: [LabelPlacement; 10] = [ + pub const ALL: [LabelPlacement; 12] = [ LabelPlacement::Auto, LabelPlacement::Center, LabelPlacement::Above, @@ -1197,6 +1206,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft, LabelPlacement::BelowRight, LabelPlacement::BelowLeft, + LabelPlacement::Parallel, + LabelPlacement::Curved, ]; pub fn code(self) -> &'static str { match self { @@ -1210,6 +1221,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft => "aboveLeft", LabelPlacement::BelowRight => "belowRight", LabelPlacement::BelowLeft => "belowLeft", + LabelPlacement::Parallel => "parallel", + LabelPlacement::Curved => "curved", } } pub fn label(self) -> &'static str { @@ -1224,6 +1237,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft => "Above Left", LabelPlacement::BelowRight => "Below Right", LabelPlacement::BelowLeft => "Below Left", + LabelPlacement::Parallel => "Parallel", + LabelPlacement::Curved => "Curved", } } pub fn from_code(s: &str) -> Option { @@ -1235,8 +1250,8 @@ impl LabelPlacement { let gap = marker / 2.0 + 2.0; let (dx, dy) = (w / 2.0 + gap, h / 2.0 + gap); match self { - LabelPlacement::Auto if is_point => (0.0, -(marker / 2.0 + h * 0.6)), - LabelPlacement::Auto | LabelPlacement::Center => (0.0, 0.0), + LabelPlacement::Auto | LabelPlacement::Parallel | LabelPlacement::Curved if is_point => (0.0, -(marker / 2.0 + h * 0.6)), + LabelPlacement::Auto | LabelPlacement::Center | LabelPlacement::Parallel | LabelPlacement::Curved => (0.0, 0.0), LabelPlacement::Above => (0.0, -dy), LabelPlacement::Below => (0.0, dy), LabelPlacement::Left => (-dx, 0.0), @@ -1274,6 +1289,7 @@ impl LabelClass { closest: None, farthest: None, placement: LabelPlacement::Auto, + italic: false, } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f448e075b..c9d7bd341 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod effects; pub mod fonts; pub mod gpu_scene; pub mod layout; +mod line_labels; pub mod logo; mod mesh; pub mod pdf; @@ -109,7 +110,7 @@ use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::{Coord, Extent, extent_to_merc, lonlat_to_merc}; use rustgis_map::globe::{V3, densify}; use rustgis_map::layout::TextAlign; -use rustgis_map::{Camera, Globe, LineStroke, Map, Marker, Rgba, Symbol}; +use rustgis_map::{Camera, Globe, LabelPlacement, LineStroke, Map, Marker, Rgba, Symbol}; /// The image type pages and maps are drawn into. pub use tiny_skia::Pixmap; use tiny_skia::{ @@ -194,6 +195,19 @@ pub struct PlacedLabel { pub angle: Option, /// How it collides with other labels (padding, optional text or icon, overlaps). pub collide: rustgis_tiles::Collide, + /// Curved along a line: its characters one by one, each placed and turned where the line + /// takes it (`text` is all of them). Empty for a label drawn as one block. + pub glyphs: Vec, +} + +/// One character of a label curved along a line: its text, and its centre and angle (radians, +/// clockwise, kept upright) relative to the label's `x`, `y`. +#[derive(Clone, Debug, PartialEq)] +pub struct LabelGlyph { + pub text: String, + pub dx: f32, + pub dy: f32, + pub angle: f32, } impl PlacedLabel { @@ -2932,6 +2946,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac line_align: rustgis_map::graphics::align(ds_ptr, row), angle: None, collide: Default::default(), + glyphs: Vec::new(), }); } } @@ -2958,6 +2973,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } let Some((x, y)) = ctx.screen(c) else { continue }; let is_point = g.kind() == GeometryType::Point; + // A line's longest part on the screen, for the classes placed along it: worked + // out once, for the first that asks. + let mut screen_line: Option>> = None; let marker = if is_point { layer.symbol(row).size } else { 0.0 }; // Bigger polygons get priority. let area = match g { @@ -2991,6 +3009,25 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac is_point, ); let (dx, dy) = (dx * label_factor, dy * label_factor); + let (mut lx, mut ly, mut angle, mut glyphs) = (x as f32 + dx, y as f32 + dy, None, Vec::new()); + 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)); + let Some(line) = line.as_deref() else { continue }; + // It may run past the line's ends by about a letter, as QGIS's overrun. + let overrun = size; + if class.placement == LabelPlacement::Parallel { + let width = text::block_width(&text, size, class.bold); + let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; + (lx, ly, angle) = (cx, cy, Some(a)); + } else { + let chars: Vec = text.chars().map(String::from).collect(); + let advances: Vec = chars.iter().map(|c| text::measure(c, size, class.bold)).collect(); + let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; + (lx, ly) = (cx, cy); + glyphs = + chars.into_iter().zip(at).map(|(text, (dx, dy, angle))| LabelGlyph { text, dx, dy, angle }).collect(); + } + } // Layers above first, then the classes listed first (#732), then bigger // polygons: a class (ci ≤ MAX_LABEL_CLASSES, 1e9 each) never outweighs a // layer (1e12), nor an area (≤ 1e8) a class. @@ -2998,8 +3035,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac label_candidates.push(( PlacedLabel { text, - x: x as f32 + dx, - y: y as f32 + dy, + x: lx, + y: ly, size, color: class.color, halo: class.halo.then_some(halo_for(class.color)), @@ -3007,11 +3044,12 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac icon: None, align: 0.0, framed: false, - italic: false, + italic: class.italic, spacing: 0.0, line_align: TextAlign::Left, - angle: None, + angle, collide: Default::default(), + glyphs, }, pri, (lid, label_row(row), ci), @@ -3654,6 +3692,20 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) /// 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. +/// A line's longest part as it is on the screen, in points (`None` when no part has two points +/// in sight): the one a label along the line follows. +fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option> { + let screen = |part: &Vec| -> Vec<(f32, f32)> { + part.iter() + .filter_map(|c| ctx.screen(*c)) + .map(|(x, y)| (x as f32, y as f32)) + .filter(|p| p.0.is_finite() && p.1.is_finite()) + .collect() + }; + let length = |pts: &[(f32, f32)]| pts.windows(2).map(|w| (w[1].0 - w[0].0).hypot(w[1].1 - w[0].1)).sum::(); + parts.iter().map(screen).filter(|pts| pts.len() >= 2).max_by(|a, b| length(a).total_cmp(&length(b))) +} + 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())?); @@ -3664,6 +3716,27 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) let x = l.center_x(); l.angle = Some(turned(x, l.y, a).map_or(a, rustgis_tiles::symbols::upright)); } + // A curved label's characters: each moved and turned, about where its centre goes. + if !l.glyphs.is_empty() + && let Some((cx, cy)) = at(l.x, l.y) + { + for g in &mut l.glyphs { + let (gx, gy) = (l.x + g.dx, l.y + g.dy); + if let Some((x, y)) = at(gx, gy) { + (g.dx, g.dy) = (x - cx, y - cy); + } + g.angle = turned(gx, gy, g.angle).unwrap_or(g.angle); + } + // Turned upside down (a map turned past a quarter): read from the other end. + let back = l.glyphs.iter().map(|g| g.angle.cos()).sum::() < 0.0; + if back { + let texts: Vec = l.glyphs.iter().rev().map(|g| g.text.clone()).collect(); + for (g, t) in l.glyphs.iter_mut().zip(texts) { + g.text = t; + g.angle += std::f32::consts::PI; + } + } + } if let Some(i) = &mut l.icon && let Some(a) = i.angle { @@ -3759,6 +3832,15 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { let th = text::block_height(&l.text, l.size); let p = l.collide.text_padding.clamp(0.0, 100.0); let cx = l.center_x(); + if !l.glyphs.is_empty() { + // A curved label: a square round each character, as tall as the text. + let r = th / 2.0 + p; + for g in l.glyphs.iter().take(500) { + let (x, y) = (l.x + g.dx, l.y + g.dy); + text.push([x - r, y - r, x + r, y + r]); + } + return (text, Vec::new()); + } match l.angle.filter(|a| a.is_finite() && a.abs() > 1e-3) { Some(a) => { // Squares as tall as the text, end to end along it. @@ -3904,6 +3986,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }; text::draw(pm, &label, ppp); } @@ -3928,6 +4011,7 @@ mod tests { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), } } @@ -5519,6 +5603,75 @@ mod tests { } } + #[test] + fn labels_run_along_lines_straight_or_curved() { + // A road rising to the right, labelled along it: straight and turned to it, or a + // character at a time; drawn either way, and a too sharp bend leaves a curved label out. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("roads", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, -1.0), Coord::new(1.0, 1.0)]])), vec![Value::Text("Seestrasse".into())]); + // A right angle at its middle. + d.push( + Some(Geometry::Line(vec![vec![Coord::new(-1.0, -1.6), Coord::new(0.6, -1.6), Coord::new(0.6, 0.0)]])), + vec![Value::Text("Bahnhofstrasse".into())], + ); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.3)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 400.0, pixels_per_point: 1.0, camera, sky: false }; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.size = 12.0; + l.labels.default.placement = rustgis_map::LabelPlacement::Parallel; + let r = render(&mut m, &view).unwrap(); + let along = r.labels.iter().find(|l| l.text == "Seestrasse").expect("placed"); + // Up and to the right on the screen: −45°, about the middle of the line. + assert!(along.angle.is_some_and(|a| (a + std::f32::consts::FRAC_PI_4).abs() < 0.02), "{:?}", along.angle); + assert!(along.glyphs.is_empty()); + m.layer_mut(id).unwrap().labels.default.placement = rustgis_map::LabelPlacement::Curved; + let r = render(&mut m, &view).unwrap(); + let curved = r.labels.iter().find(|l| l.text == "Seestrasse").expect("placed"); + assert_eq!(curved.glyphs.iter().map(|g| g.text.as_str()).collect::(), "Seestrasse"); + assert!(curved.glyphs.windows(2).all(|w| w[1].dx > w[0].dx && w[1].dy < w[0].dy), "left to right, rising"); + assert!(r.labels.iter().all(|l| l.text != "Bahnhofstrasse"), "the bend is too sharp to curve round"); + // Drawn: dark text along the line, and a square to collide with per character. + let mut pm = Pixmap::new(400, 400).unwrap(); + text::draw(&mut pm, curved, 1.0); + let dark = pm.pixels().iter().filter(|p| p.alpha() > 200 && p.red() < 120).count(); + assert!(dark > 30, "{dark} text pixels"); + assert_eq!(label_boxes(curved).0.len(), 10); + } + + #[test] + fn a_curved_label_turned_upside_down_reads_from_its_other_end() { + // "AB" along a level line, then the map turned half round: the characters swap ends and + // stay upright. + let glyph = |text: &str, dx: f32| LabelGlyph { text: text.into(), dx, dy: 0.0, angle: 0.0 }; + let mut l = PlacedLabel { + text: "AB".into(), + x: 100.0, + y: 100.0, + size: 10.0, + color: Rgba::rgb(0, 0, 0), + halo: None, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: vec![glyph("A", -5.0), glyph("B", 5.0)], + }; + turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); + let order: Vec<(&str, f32)> = l.glyphs.iter().map(|g| (g.text.as_str(), g.dx)).collect(); + assert_eq!(order, [("B", 5.0), ("A", -5.0)]); + assert!(l.glyphs.iter().all(|g| g.angle.cos() > 0.99), "upright"); + } + #[test] fn a_reference_scale_scales_labels_and_their_gap() { // A point labelled to its right: at twice the size, the label is twice as large and twice diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs new file mode 100644 index 000000000..6c834e596 --- /dev/null +++ b/crates/render/src/line_labels.rs @@ -0,0 +1,154 @@ +//! Labels along lines, in screen points: straight and turned to the line ([`parallel`]), or each +//! character turned with it where it bends ([`curved`]), at the line's middle and kept upright, +//! as QGIS's Parallel and Curved line placements put text on the line. + +/// The most a curved label's text turns from one character to the next (radians): QGIS's +/// default limit, 25°. Past it, where a line bends sharply, the label is left out. +pub(super) const MAX_TURN: f32 = 0.436; + +/// A line in screen points, with how far along it each vertex is. +struct Path { + pts: Vec<(f32, f32)>, + along: Vec, +} + +impl Path { + /// The finite vertices of `line` (repeats dropped), reversed when `reversed`; `None` for + /// one with fewer than two. + fn new(line: &[(f32, f32)], reversed: bool) -> Option { + let mut pts: Vec<(f32, f32)> = Vec::with_capacity(line.len()); + for p in line.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()) { + if pts.last() != Some(&p) { + pts.push(p); + } + } + if reversed { + pts.reverse(); + } + let mut along = Vec::with_capacity(pts.len()); + let mut d = 0.0f32; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + d += (p.0 - q.0).hypot(p.1 - q.1); + } + along.push(d); + } + (pts.len() >= 2 && d.is_finite() && d > 0.0).then_some(Path { pts, along }) + } + + fn length(&self) -> f32 { + self.along.last().copied().unwrap_or(0.0) + } + + /// The point `d` along the line; before its start or past its end, on the first or last + /// segment carried on. + fn at(&self, d: f32) -> Option<(f32, f32)> { + let n = self.pts.len(); + // The segment holding `d`: the first or last one for a place off the line's ends. + let i = self.along.partition_point(|a| *a < d).clamp(1, n - 1); + let (a, b) = (self.pts.get(i - 1)?, self.pts.get(i)?); + let (da, db) = (self.along.get(i - 1)?, self.along.get(i)?); + let t = if db > da { (d - da) / (db - da) } else { 0.0 }; + Some((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)) + } +} + +/// The angle (radians, clockwise) from `a` to `b`. +fn angle(a: (f32, f32), b: (f32, f32)) -> f32 { + (b.1 - a.1).atan2(b.0 - a.0) +} + +/// The line as text `len` points long reads along it, left to right: reversed when its middle +/// stretch runs leftward, so the text is never upside down. `None` when the line, with +/// `overrun` past each end, is shorter than the text. +fn upright(line: &[(f32, f32)], len: f32, overrun: f32) -> Option { + let path = Path::new(line, false)?; + let total = path.length(); + if !(len.is_finite() && len > 0.0) || len > total + 2.0 * overrun.max(0.0) { + return None; + } + let (a, b) = (path.at((total - len) / 2.0)?, path.at((total + len) / 2.0)?); + if b.0 < a.0 { Path::new(line, true) } else { Some(path) } +} + +/// Text `len` points long straight along `line` at its middle: its centre and angle (radians, +/// clockwise, upright). `None` when the line (with `overrun` past each end) is shorter. +pub(super) fn parallel(line: &[(f32, f32)], len: f32, overrun: f32) -> Option<(f32, f32, f32)> { + let path = upright(line, len, overrun)?; + let total = path.length(); + let (a, b) = (path.at((total - len) / 2.0)?, path.at((total + len) / 2.0)?); + let a_to_b = angle(a, b); + a_to_b.is_finite().then_some(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, a_to_b)) +} + +/// Characters `advances` wide (points), in turn along `line` at its middle: the label's centre, +/// and each character's centre relative to it and angle (radians, clockwise, upright). `None` +/// when the line (with `overrun` past each end) is shorter than the text, or turns more than +/// [`MAX_TURN`] from one character to the next. +pub(super) fn curved(line: &[(f32, f32)], advances: &[f32], overrun: f32) -> Option<((f32, f32), Vec<(f32, f32, f32)>)> { + let len: f32 = advances.iter().sum(); + let path = upright(line, len, overrun)?; + let total = path.length(); + let centre = path.at(total / 2.0)?; + let mut d = (total - len) / 2.0; + let mut out = Vec::with_capacity(advances.len()); + let mut last: Option = None; + for adv in advances { + let mid = d + adv / 2.0; + // A character's angle: across its own width, so it sits on the bend rather than a vertex. + let (a, b) = (path.at(mid - adv.max(0.5) / 2.0)?, path.at(mid + adv.max(0.5) / 2.0)?); + let (p, turn) = (path.at(mid)?, angle(a, b)); + if !turn.is_finite() { + return None; + } + if let Some(prev) = last { + let mut step = turn - prev; + // Wrapped to within half a turn either way. + step = (step + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + if step.abs() > MAX_TURN { + return None; + } + } + last = Some(turn); + out.push((p.0 - centre.0, p.1 - centre.1, turn)); + d += adv; + } + Some((centre, out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parallel_text_sits_at_the_middle_turned_to_the_line_and_upright() { + // Along a line rising to the right at 45° (screen y down: up-right is −45°). + let (x, y, a) = parallel(&[(0.0, 100.0), (100.0, 0.0)], 40.0, 0.0).unwrap(); + assert!((x - 50.0).abs() < 1e-3 && (y - 50.0).abs() < 1e-3); + assert!((a + std::f32::consts::FRAC_PI_4).abs() < 1e-4, "{a}"); + // The same line drawn right to left reads the same way. + let (_, _, b) = parallel(&[(100.0, 0.0), (0.0, 100.0)], 40.0, 0.0).unwrap(); + assert!((a - b).abs() < 1e-4, "{a} {b}"); + // Too short for the text, unless it may run past the ends. + assert!(parallel(&[(0.0, 0.0), (30.0, 0.0)], 40.0, 0.0).is_none()); + assert!(parallel(&[(0.0, 0.0), (30.0, 0.0)], 40.0, 5.0).is_some()); + assert!(parallel(&[(0.0, 0.0), (0.0, 0.0)], 1.0, 5.0).is_none(), "no length"); + } + + #[test] + fn curved_text_follows_a_gentle_bend_and_leaves_a_sharp_one() { + // A shallow arc: each character a little more turned than the one before. + let arc: Vec<(f32, f32)> = (0..=40) + .map(|i| { + let t = (i as f32 / 40.0 - 0.5) * 1.2; + (200.0 * t.sin() + 200.0, 200.0 - 200.0 * t.cos()) + }) + .collect(); + let (_, glyphs) = curved(&arc, &[10.0; 8], 0.0).unwrap(); + assert_eq!(glyphs.len(), 8); + assert!(glyphs.windows(2).all(|w| w[1].2 > w[0].2), "turning steadily clockwise"); + assert!(glyphs.windows(2).all(|w| w[1].0 > w[0].0), "left to right"); + // A right angle at the middle: too sharp for the text. + assert!(curved(&[(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)], &[10.0; 6], 0.0).is_none()); + } +} diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index bebda5a69..2b5d26176 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -549,6 +549,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }, if i % 17 == 0 { f64::NAN } else { (i % 7) as f64 }, (1, i, 0), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 1433dce62..6d6dc613d 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -431,6 +431,22 @@ pub fn styled_at(pm: &mut Pixmap, text: &str, left: f32, cy: f32, px: f32, color /// Draws a placed label (logical coordinates) with its halo. A label turned with the map (one /// along a street) is drawn upright into a picture of its own, which is turned into place. pub fn draw(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { + // Curved along a line: each character turned into its place. + if !l.glyphs.is_empty() { + for g in &l.glyphs { + let one = PlacedLabel { + text: g.text.clone(), + x: l.x + g.dx, + y: l.y + g.dy, + align: 0.0, + angle: None, + glyphs: Vec::new(), + ..l.clone() + }; + draw_turned(pm, &one, g.angle, ppp); + } + return; + } match l.angle { Some(a) if a.is_finite() && a.abs() > 1e-3 => draw_turned(pm, l, a, ppp), _ => draw_upright(pm, l, ppp), @@ -560,6 +576,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }; draw(pm, &label, ppp); } diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index bc698ae91..66e2d7755 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -81,6 +81,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> line_align, angle: l.angle, collide: l.collide, + glyphs: Vec::new(), }, l.priority, )) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 53dbd33a5..398378574 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -189,6 +189,10 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { if ui.checkbox(&mut bold, tl!("Bold")).changed() { run(app, "labels.style", json!({"bold": bold})); } + let mut italic = l.labels.default.italic; + if ui.checkbox(&mut italic, tl!("Italic")).changed() { + run(app, "labels.style", json!({"italic": italic})); + } let mut halo = l.labels.default.halo; if ui.checkbox(&mut halo, tl!("Halo")).changed() { run(app, "labels.style", json!({"halo": halo})); @@ -217,6 +221,15 @@ fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { ui.end_row(); } }); + // Lines: the text along them, straight or bending with them. + if l.geometry_type() == rustgis_engine::data::GeometryType::Polyline { + ui.add_space(4.0); + ui.label(RichText::new(tl!("Along lines")).size(11.5)); + ui.horizontal(|ui| { + pick(app, ui, LabelPlacement::Parallel); + pick(app, ui, LabelPlacement::Curved); + }); + } }); } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index e0be21403..64cc59456 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1197,6 +1197,14 @@ fn place_galley(anchor: Align2, at: Pos2, g: &egui::Galley) -> (Pos2, Rect) { } fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { + // Curved along a line: each character turned into its place. + if !l.glyphs.is_empty() { + for g in &l.glyphs { + let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), align: 0.0, glyphs: Vec::new(), ..l.clone() }; + draw_label(p, &one, at + vec2(g.dx, g.dy)); + } + return; + } let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align)); // Text set beside an icon is lined up by its edge, with egui's own measure of its width. let anchor = if l.align < -0.25 { diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index a50178c82..b69412f65 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -229,7 +229,7 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: - **Label field and text**: the field the labels show (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), - colour, halo and bold (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). + colour, halo, bold and italic (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). - **SQL**: label only the features that match a SQL clause (the language of definition queries), such as `pop_max > 5000000`. In the Labeling pane, Class › **SQL** › **New expression**: write the clause (the field and operator buttons add to it), **Check** says @@ -241,7 +241,13 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: two viewing distances (Closest / Farthest). Clear Limits removes all four. - **Placement**: automatic (above points, centred on lines and polygons), centred, or one of eight positions around the feature (`labels.placement={"layer": "Cities", "position": - "aboveRight"}`). + "aboveRight"}`). Lines can be labelled **along** them (Position › Along lines): **Parallel** + sets the text straight, turned to the line's direction across it; **Curved** turns each + character with the line where it bends. Both sit at the middle of the line's longest part on + screen, centred on the line and kept upright, and may run past its ends by about a letter; a + line too short for its text, or (curved) one bending more than 25° from one character to the + next, is left unlabelled. Points and polygons with these placements are labelled as automatic + (`labels.placement={"layer": "Rivers", "position": "curved"}`). - **Lock** keeps the labelled features fixed while you pan and zoom; **Pause** stops drawing labels for faster navigation; **View Unplaced** draws the labels that didn't fit in red. - **Convert** turns a layer's labels into an annotation layer: one point per label, carrying its From ad7d8d4ade6949051abe4956416881c095b9815a Mon Sep 17 00:00:00 2001 From: strebdom Date: Sat, 24 Oct 2026 14:06:01 +0200 Subject: [PATCH 2/5] fix(labels): label a line's stretch in view, keep accents on their letters Review follow-ups for labels along lines: - A line labelled along it is no longer skipped when its label point is out of view: the label sits on the longest stretch in view, each part cut at the view's edges and where it goes out of sight. - Curved labels are cut into grapheme clusters (unicode-segmentation), so a combining accent stays on its letter. - A text over 200 characters is set straight rather than curved, so drawing and collision boxes stay bounded. - A curved label turned upside down places each character by its own width. - turn_with gets its doc comment back. --- Cargo.lock | 1 + Cargo.toml | 3 + apps/rustgis-python/THIRD-PARTY-LICENSES.txt | 8 +- crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 124 +++++++++++++++---- crates/render/src/line_labels.rs | 81 ++++++++++++ docs/user-guide/cartography.md | 7 +- 7 files changed, 196 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cf6769f1..1c77ef40e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4822,6 +4822,7 @@ dependencies = [ "serde_json", "tempfile", "tiny-skia 0.12.0", + "unicode-segmentation", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 6e41ce5a3..403d8a60a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ bytemuck = { version = "1", features = ["derive"] } lyon = "1" image = { version = "0.25", default-features = false, features = ["png"] } ab_glyph = "0.2" +# A label curved along a line: its text cut into the characters drawn as one (a letter with its +# accents). +unicode-segmentation = "1.13" # Rasters: GeoTIFF decoding/encoding, and coordinate systems by EPSG code (pure Rust, except the # Zstandard decoder `rustgis-data` turns on, C that clang compiles for the browser build too). tiff = "0.11" diff --git a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt index ef3070bc8..b1b9a6411 100644 --- a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt +++ b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt @@ -29,10 +29,10 @@ semver, seq-macro, serde, serde_core, serde_derive, serde_json, sha1, sha2, shle simdutf8, siphasher, smallvec, socket2, spade, spki, stable_deref_trait, syn, sys-locale, tar, tempfile, thiserror, thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, -ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, version_check, wasm-bindgen, -wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasmi, wasmi_collections, -wasmi_core, wasmi_ir, wasmparser, web-sys, weezl, whoami, winauth, wkt, x509-cert, xattr, zeroize, -zune-core, zune-jpeg +unicode-segmentation, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, version_check, +wasm-bindgen, wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasmi, +wasmi_collections, wasmi_core, wasmi_ir, wasmparser, web-sys, weezl, whoami, winauth, wkt, +x509-cert, xattr, zeroize, zune-core, zune-jpeg ==================================================================================================== Apache License diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index db22b9358..7ad7a3ee3 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -16,6 +16,7 @@ tiny-skia = { workspace = true } bytemuck = { workspace = true } lyon = { workspace = true } ab_glyph = { workspace = true } +unicode-segmentation = { workspace = true } epaint_default_fonts = { workspace = true } log = { workspace = true } image = { workspace = true, features = ["jpeg"] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c9d7bd341..0788d2ea2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -116,6 +116,7 @@ pub use tiny_skia::Pixmap; use tiny_skia::{ FillRule, GradientStop, LineCap, LineJoin, Paint, PathBuilder, PixmapPaint, Point, RadialGradient, SpreadMode, Stroke, Transform, }; +use unicode_segmentation::UnicodeSegmentation; /// Selection highlight (cyan, as desktop GIS users expect). pub const SELECTION: Rgba = Rgba::rgb(0x00, 0xE5, 0xFF); @@ -2962,13 +2963,23 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let label_factor = layer.size_factor(label_scale); let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); let lid = layer.id; + // Whether a class labels lines along them: those take the stretch of a line in + // view, wherever its label point is. + let along_line = |placement: LabelPlacement, g: &Geometry| { + matches!(placement, LabelPlacement::Parallel | LabelPlacement::Curved) && matches!(g, Geometry::Line(_)) + }; + let any_along = labels + .in_order() + .zip(&label_fields) + .any(|(c, f)| f.is_some() && matches!(c.placement, LabelPlacement::Parallel | LabelPlacement::Curved)); for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) { continue; } let Some(c) = g.label_point() else { continue }; - if globe.is_none() && !ctx.cull.contains(c) { + let point_in_view = globe.is_some() || ctx.cull.contains(c); + if !point_in_view && !(any_along && matches!(g, Geometry::Line(_)) && ctx.cull.intersects(&g.extent())) { continue; } let Some((x, y)) = ctx.screen(c) else { continue }; @@ -2993,6 +3004,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row), ci))) { continue; } + if !point_in_view && !along_line(class.placement, g) { + continue; + } let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; let v = ds_ptr.value(row, fi); if v.is_null() { @@ -3015,12 +3029,16 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let Some(line) = line.as_deref() else { continue }; // It may run past the line's ends by about a letter, as QGIS's overrun. let overrun = size; - if class.placement == LabelPlacement::Parallel { + // A text too long to curve character by character goes straight. + let curved = class.placement == LabelPlacement::Curved + && text.graphemes(true).nth(line_labels::MAX_CURVED_CHARS).is_none(); + if !curved { let width = text::block_width(&text, size, class.bold); let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; (lx, ly, angle) = (cx, cy, Some(a)); } else { - let chars: Vec = text.chars().map(String::from).collect(); + // 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(c, size, class.bold)).collect(); let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; (lx, ly) = (cx, cy); @@ -3689,23 +3707,21 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) out } -/// 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. -/// A line's longest part as it is on the screen, in points (`None` when no part has two points -/// in sight): the one a label along the line follows. +/// A line's longest stretch in view, in points (`None` when none has two points in sight): the +/// one a label along the line follows. Each part is cut at the view's edges and where it goes +/// out of sight (behind the globe), so a long part mostly off screen counts by what is on it. fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option> { - let screen = |part: &Vec| -> Vec<(f32, f32)> { - part.iter() - .filter_map(|c| ctx.screen(*c)) - .map(|(x, y)| (x as f32, y as f32)) - .filter(|p| p.0.is_finite() && p.1.is_finite()) - .collect() - }; + let (w, h) = (ctx.view.width as f32, ctx.view.height as f32); let length = |pts: &[(f32, f32)]| pts.windows(2).map(|w| (w[1].0 - w[0].0).hypot(w[1].1 - w[0].1)).sum::(); - parts.iter().map(screen).filter(|pts| pts.len() >= 2).max_by(|a, b| length(a).total_cmp(&length(b))) + parts + .iter() + .flat_map(|part| line_labels::in_view(part.iter().map(|c| ctx.screen(*c).map(|(x, y)| (x as f32, y as f32))), w, h)) + .max_by(|a, b| length(a).total_cmp(&length(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())?); @@ -3730,11 +3746,7 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) // Turned upside down (a map turned past a quarter): read from the other end. let back = l.glyphs.iter().map(|g| g.angle.cos()).sum::() < 0.0; if back { - let texts: Vec = l.glyphs.iter().rev().map(|g| g.text.clone()).collect(); - for (g, t) in l.glyphs.iter_mut().zip(texts) { - g.text = t; - g.angle += std::f32::consts::PI; - } + read_back(l); } } if let Some(i) = &mut l.icon @@ -3744,6 +3756,31 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) } } +/// A curved label's characters read from the line's other end, turned half round: each placed +/// as far from that end as it was from the start, by its own width rather than that of the +/// character whose place it takes, a step along the nearest one's angle from it. +fn read_back(l: &mut PlacedLabel) { + let widths: Vec = l.glyphs.iter().map(|g| text::measure(&g.text, l.size, l.bold)).collect(); + let total: f32 = widths.iter().sum(); + // How far along the text each character's centre was. + let mut centres = Vec::with_capacity(widths.len()); + let mut d = 0.0; + for w in &widths { + centres.push(d + w / 2.0); + d += w; + } + let before = l.glyphs.clone(); + for (g, c) in l.glyphs.iter_mut().zip(¢res) { + let to = total - c; + let nearest = centres.iter().zip(&before).min_by(|a, b| (a.0 - to).abs().total_cmp(&(b.0 - to).abs())); + let Some((at, slot)) = nearest else { continue }; + let step = to - at; + g.dx = slot.dx + step * slot.angle.cos(); + g.dy = slot.dy + step * slot.angle.sin(); + g.angle = slot.angle + std::f32::consts::PI; + } +} + /// A halo that contrasts with the text colour. fn halo_for(text: Rgba) -> Rgba { let lum = 0.299 * text.r() as f32 + 0.587 * text.g() as f32 + 0.114 * text.b() as f32; @@ -5643,6 +5680,40 @@ mod tests { assert_eq!(label_boxes(curved).0.len(), 10); } + #[test] + fn a_line_mostly_off_screen_is_labelled_along_its_stretch_in_view() { + // A river reaching far off to the east, its middle well out of view, and a road whose + // longer part is out of view too: both labelled on what is on the screen, and the + // river's accent (a combining one) kept on its letter. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("rivers", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.5, 0.5), Coord::new(30.0, 0.5)]])), vec![Value::Text("Rho\u{302}ne".into())]); + d.push( + Some(Geometry::Line(vec![ + vec![Coord::new(-1.5, -1.0), Coord::new(1.5, -1.0)], + vec![Coord::new(10.0, -1.0), Coord::new(20.0, -1.0)], + ])), + vec![Value::Text("Seestrasse".into())], + ); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.3)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 400.0, pixels_per_point: 1.0, camera, sky: false }; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.size = 12.0; + l.labels.default.placement = rustgis_map::LabelPlacement::Curved; + let r = render(&mut m, &view).unwrap(); + for name in ["Rho\u{302}ne", "Seestrasse"] { + let l = r.labels.iter().find(|l| l.text == name).unwrap_or_else(|| panic!("{name} placed")); + assert!((0.0..400.0).contains(&l.x) && (0.0..400.0).contains(&l.y), "{name} in view: {} {}", l.x, l.y); + } + let river = r.labels.iter().find(|l| l.text.starts_with("Rho")).unwrap(); + let glyphs: Vec<&str> = river.glyphs.iter().map(|g| g.text.as_str()).collect(); + assert_eq!(glyphs, ["R", "h", "o\u{302}", "n", "e"]); + } + #[test] fn a_curved_label_turned_upside_down_reads_from_its_other_end() { // "AB" along a level line, then the map turned half round: the characters swap ends and @@ -5668,8 +5739,17 @@ mod tests { }; turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); let order: Vec<(&str, f32)> = l.glyphs.iter().map(|g| (g.text.as_str(), g.dx)).collect(); - assert_eq!(order, [("B", 5.0), ("A", -5.0)]); + assert_eq!(order.iter().map(|g| g.0).collect::>(), ["A", "B"]); + assert!(order[0].1 < order[1].1, "A on the left, read first: {order:?}"); assert!(l.glyphs.iter().all(|g| g.angle.cos() > 0.99), "upright"); + // Each placed by its own width: "Wi" turned round keeps "i" beside "W", not a "W" apart. + let (w, i) = (text::measure("W", 10.0, false), text::measure("i", 10.0, false)); + l.glyphs = vec![glyph("W", -i / 2.0), glyph("i", w / 2.0)]; + turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); + let (gw, gi) = (&l.glyphs[0], &l.glyphs[1]); + assert_eq!((gw.text.as_str(), gi.text.as_str()), ("W", "i")); + assert!(((gi.dx - gw.dx) - (w + i) / 2.0).abs() < 1e-3, "{gw:?} {gi:?}"); + assert!((gw.dx + i / 2.0).abs() < 1e-3, "W first, at the left end: {gw:?}"); } #[test] diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index 6c834e596..9854af960 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -6,6 +6,10 @@ /// default limit, 25°. Past it, where a line bends sharply, the label is left out. pub(super) const MAX_TURN: f32 = 0.436; +/// The most characters a label curves along a line: a longer one is placed straight, so a long +/// attribute value on many lines doesn't lay out, and draw, character by character. +pub(super) const MAX_CURVED_CHARS: usize = 200; + /// A line in screen points, with how far along it each vertex is. struct Path { pts: Vec<(f32, f32)>, @@ -53,6 +57,70 @@ impl Path { } } +/// The stretches of a line in screen points that are in a view `w` × `h` points: cut where it +/// crosses the view's edges and at points out of sight (`None`, or of no number), each with two +/// points or more. +pub(super) fn in_view(line: impl Iterator>, w: f32, h: f32) -> Vec> { + let mut runs: Vec> = Vec::new(); + let mut run: Vec<(f32, f32)> = Vec::new(); + let mut prev: Option<(f32, f32)> = None; + let end = |run: &mut Vec<(f32, f32)>, runs: &mut Vec>| { + let run = std::mem::take(run); + if run.len() >= 2 { + runs.push(run); + } + }; + for p in line { + let p = p.filter(|p| p.0.is_finite() && p.1.is_finite()); + match (prev, p) { + (Some(a), Some(b)) => match clip(a, b, w, h) { + Some((a, b, cut_end)) => { + if run.last() != Some(&a) { + end(&mut run, &mut runs); + run.push(a); + } + run.push(b); + // Left the view: what comes back in is another stretch. + if cut_end { + end(&mut run, &mut runs); + } + } + None => end(&mut run, &mut runs), + }, + _ => end(&mut run, &mut runs), + } + prev = p; + } + end(&mut run, &mut runs); + runs +} + +/// The part of the segment `a`–`b` inside a view `w` × `h` points (Liang–Barsky), and whether it +/// stops short of `b`; `None` when none of it is. +fn clip(a: (f32, f32), b: (f32, f32), w: f32, h: f32) -> Option<((f32, f32), (f32, f32), bool)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-dx, a.0), (dx, w - a.0), (-dy, a.1), (dy, h - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + if t0.is_nan() || t1.is_nan() || t0 > t1 { + return None; + } + let at = |t: f32| (a.0 + dx * t, a.1 + dy * t); + Some((if t0 > 0.0 { at(t0) } else { a }, if t1 < 1.0 { at(t1) } else { b }, t1 < 1.0)) +} + /// The angle (radians, clockwise) from `a` to `b`. fn angle(a: (f32, f32), b: (f32, f32)) -> f32 { (b.1 - a.1).atan2(b.0 - a.0) @@ -135,6 +203,19 @@ mod tests { assert!(parallel(&[(0.0, 0.0), (0.0, 0.0)], 1.0, 5.0).is_none(), "no length"); } + #[test] + fn a_line_in_view_is_cut_at_its_edges_and_where_it_is_out_of_sight() { + // Across the view and out, back in and out again: two stretches, cut at the edges. + let line = [(-50.0, 50.0), (50.0, 50.0), (50.0, 150.0), (80.0, 150.0), (80.0, 50.0), (150.0, 50.0)]; + let runs = in_view(line.iter().copied().map(Some), 100.0, 100.0); + assert_eq!(runs, [vec![(0.0, 50.0), (50.0, 50.0), (50.0, 100.0)], vec![(80.0, 100.0), (80.0, 50.0), (100.0, 50.0)]]); + // A point out of sight splits the line; a stretch of one point is none. + let runs = + in_view([Some((10.0, 10.0)), Some((20.0, 10.0)), None, Some((30.0, 10.0)), Some((f32::NAN, 0.0))].into_iter(), 100.0, 100.0); + assert_eq!(runs, [vec![(10.0, 10.0), (20.0, 10.0)]]); + assert!(in_view([Some((-10.0, -10.0)), Some((-20.0, 200.0))].into_iter(), 100.0, 100.0).is_empty()); + } + #[test] fn curved_text_follows_a_gentle_bend_and_leaves_a_sharp_one() { // A shallow arc: each character a little more turned than the one before. diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index b69412f65..865cf9685 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -243,10 +243,11 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: eight positions around the feature (`labels.placement={"layer": "Cities", "position": "aboveRight"}`). Lines can be labelled **along** them (Position › Along lines): **Parallel** sets the text straight, turned to the line's direction across it; **Curved** turns each - character with the line where it bends. Both sit at the middle of the line's longest part on - screen, centred on the line and kept upright, and may run past its ends by about a letter; a + character with the line where it bends. Both sit at the middle of the line's longest stretch + in view, centred on the line and kept upright, and may run past its ends by about a letter; a line too short for its text, or (curved) one bending more than 25° from one character to the - next, is left unlabelled. Points and polygons with these placements are labelled as automatic + next, is left unlabelled. A text over 200 characters long is set straight rather than + curved. Points and polygons with these placements are labelled as automatic (`labels.placement={"layer": "Rivers", "position": "curved"}`). - **Lock** keeps the labelled features fixed while you pan and zoom; **Pause** stops drawing labels for faster navigation; **View Unplaced** draws the labels that didn't fit in red. From b4a2f4bb0ee0a4f49a01b1700da7810c88ed1420 Mon Sep 17 00:00:00 2001 From: strebdom Date: Sat, 24 Oct 2026 14:23:30 +0200 Subject: [PATCH 3/5] fix(labels): label a line in view whose label point has no screen position A line labelled along it is no longer skipped when its label point is behind the globe or past the projection's limits: only the classes that need the point wait for it, and those along the line take its stretch in view. The check for a line in view uses the layer's cached extents. --- crates/render/src/lib.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0788d2ea2..a6c11921a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2978,11 +2978,14 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac continue; } let Some(c) = g.label_point() else { continue }; - let point_in_view = globe.is_some() || ctx.cull.contains(c); - if !point_in_view && !(any_along && matches!(g, Geometry::Line(_)) && ctx.cull.intersects(&g.extent())) { + // Where the label point is on the screen, `None` when it is out of view (or + // behind the globe): then only a line labelled along it can be, on its + // stretch in view. + let point = if globe.is_some() || ctx.cull.contains(c) { ctx.screen(c) } else { None }; + let line_in_view = || globe.is_some() || ctx.cull.intersects(&extents.get(row).copied().unwrap_or_else(|| g.extent())); + if point.is_none() && !(any_along && matches!(g, Geometry::Line(_)) && line_in_view()) { continue; } - let Some((x, y)) = ctx.screen(c) else { continue }; let is_point = g.kind() == GeometryType::Point; // A line's longest part on the screen, for the classes placed along it: worked // out once, for the first that asks. @@ -3004,9 +3007,11 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row), ci))) { continue; } - if !point_in_view && !along_line(class.placement, g) { + if point.is_none() && !along_line(class.placement, g) { continue; } + // (Along a line, the label is put on its stretch in view below.) + let (x, y) = point.unwrap_or_default(); let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; let v = ds_ptr.value(row, fi); if v.is_null() { From b4225f629793563bfdffb3543c3890c293938ac5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 14:38:09 +0200 Subject: [PATCH 4/5] fix(labels): a curved label's halos go under all its letters; lines' parts out of view aren't projected for labels --- crates/engine/src/tests.rs | 2 ++ crates/map/src/symbology.rs | 6 ++++ crates/render/src/lib.rs | 58 ++++++++++++++++++++++++++++++----- crates/render/src/text.rs | 51 +++++++++++++++++++----------- crates/ui-egui/src/mapview.rs | 29 +++++++++++++----- 5 files changed, 113 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 374e10b87..2e2aec37a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1340,6 +1340,8 @@ fn hostile_params_never_panic() { json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), + // A label class placed along lines, with junk the placement and style read after it. + json!({"layer": "Countries", "position": "curved", "italic": "maybe", "size": -1e308}), // A publication sheet's style, accent and orientation, and a shape's rounded corners. // (The profile left out or valid, so the style and accent are reached.) json!({"style": 7, "accent": "#XYZ", "kind": "ellipse", "element": 1, "radius": "x"}), diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index c55dd28a8..328852da6 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1195,6 +1195,12 @@ pub enum LabelPlacement { } impl LabelPlacement { + /// Whether a line is labelled along it ([`Parallel`](Self::Parallel) or + /// [`Curved`](Self::Curved)). + pub fn along_line(self) -> bool { + matches!(self, LabelPlacement::Parallel | LabelPlacement::Curved) + } + pub const ALL: [LabelPlacement; 12] = [ LabelPlacement::Auto, LabelPlacement::Center, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a6c11921a..fca2a618b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2965,13 +2965,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let lid = layer.id; // Whether a class labels lines along them: those take the stretch of a line in // view, wherever its label point is. - let along_line = |placement: LabelPlacement, g: &Geometry| { - matches!(placement, LabelPlacement::Parallel | LabelPlacement::Curved) && matches!(g, Geometry::Line(_)) - }; - let any_along = labels - .in_order() - .zip(&label_fields) - .any(|(c, f)| f.is_some() && matches!(c.placement, LabelPlacement::Parallel | LabelPlacement::Curved)); + let along_line = |placement: LabelPlacement, g: &Geometry| placement.along_line() && matches!(g, Geometry::Line(_)); + let any_along = labels.in_order().zip(&label_fields).any(|(c, f)| f.is_some() && c.placement.along_line()); for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) { @@ -3718,8 +3713,17 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option> { let (w, h) = (ctx.view.width as f32, ctx.view.height as f32); let length = |pts: &[(f32, f32)]| pts.windows(2).map(|w| (w[1].0 - w[0].0).hypot(w[1].1 - w[0].1)).sum::(); + // A part whose extent is out of view has no stretch in it: not projected point by point. + let near = |part: &[Coord]| { + ctx.globe.is_some() || { + let mut e = Extent::EMPTY; + part.iter().for_each(|c| e.expand(*c)); + ctx.cull.intersects(&e) + } + }; parts .iter() + .filter(|part| near(part)) .flat_map(|part| line_labels::in_view(part.iter().map(|c| ctx.screen(*c).map(|(x, y)| (x as f32, y as f32))), w, h)) .max_by(|a, b| length(a).total_cmp(&length(b))) } @@ -5757,6 +5761,46 @@ mod tests { assert!((gw.dx + i / 2.0).abs() < 1e-3, "W first, at the left end: {gw:?}"); } + #[test] + fn a_curved_labels_halos_go_under_all_its_letters() { + // Two letters close together along a line, with a halo: the second one's halo does not + // cover the first one's strokes, so the letters are as dark as drawn without halos. + let glyph = |dx: f32| LabelGlyph { text: "l".into(), dx, dy: 0.0, angle: 0.0 }; + let label = |halo: Option| PlacedLabel { + text: "ll".into(), + x: 20.0, + y: 20.0, + size: 12.0, + color: Rgba::rgb(0, 0, 0), + halo, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: vec![glyph(-0.75), glyph(0.75)], + }; + let drawn = |halo| { + let mut pm = Pixmap::new(40, 40).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(128, 128, 128, 255)); + text::draw(&mut pm, &label(halo), 1.0); + pm + }; + let (plain, haloed) = (drawn(None), drawn(Some(Rgba::rgb(255, 255, 255)))); + let mut letters = 0; + for (a, b) in plain.pixels().iter().zip(haloed.pixels()) { + if a.red() < 40 { + letters += 1; + assert!(b.red() < 80, "a letter's pixel covered by a halo: {} → {}", a.red(), b.red()); + } + } + assert!(letters > 5, "the letters are drawn: {letters}"); + } + #[test] fn a_reference_scale_scales_labels_and_their_gap() { // A point labelled to its right: at twice the size, the label is twice as large and twice diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 6d6dc613d..b5b288b7d 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -431,30 +431,42 @@ pub fn styled_at(pm: &mut Pixmap, text: &str, left: f32, cy: f32, px: f32, color /// Draws a placed label (logical coordinates) with its halo. A label turned with the map (one /// along a street) is drawn upright into a picture of its own, which is turned into place. pub fn draw(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { - // Curved along a line: each character turned into its place. + // Curved along a line: each character turned into its place, every character's halo + // first, so none covers the letter before it. if !l.glyphs.is_empty() { - for g in &l.glyphs { - let one = PlacedLabel { - text: g.text.clone(), - x: l.x + g.dx, - y: l.y + g.dy, - align: 0.0, - angle: None, - glyphs: Vec::new(), - ..l.clone() - }; - draw_turned(pm, &one, g.angle, ppp); + let inks: &[Ink] = if l.halo.is_some() { &[Ink::Halo, Ink::Text] } else { &[Ink::Text] }; + for &ink in inks { + for g in &l.glyphs { + let one = PlacedLabel { + text: g.text.clone(), + x: l.x + g.dx, + y: l.y + g.dy, + align: 0.0, + angle: None, + glyphs: Vec::new(), + ..l.clone() + }; + draw_turned(pm, &one, g.angle, ppp, ink); + } } return; } match l.angle { - Some(a) if a.is_finite() && a.abs() > 1e-3 => draw_turned(pm, l, a, ppp), - _ => draw_upright(pm, l, ppp), + Some(a) if a.is_finite() && a.abs() > 1e-3 => draw_turned(pm, l, a, ppp, Ink::Both), + _ => draw_upright(pm, l, ppp, Ink::Both), } } +/// What of a label to draw: its halo, its text, or both (the halo under the text). +#[derive(Clone, Copy, PartialEq)] +enum Ink { + Halo, + Text, + Both, +} + /// Draws `l` turned by `angle` (radians, clockwise) about its centre. -fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32) { +fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32, ink: Ink) { let (w, h) = (label_width(l), block_height(&l.text, l.size).max(line_pitch(l.size))); // Room for the halo, and for the glyphs reaching past the line. let pad = 4.0 + l.size * 0.5; @@ -465,14 +477,14 @@ fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32) { let Some(mut pic) = Pixmap::new(pw.max(1.0) as u32, ph.max(1.0) as u32) else { return }; let (cx, cy) = (pw / ppp / 2.0, ph / ppp / 2.0); let upright = PlacedLabel { x: cx + l.align * w, y: cy, angle: None, framed: false, icon: None, ..l.clone() }; - draw_upright(&mut pic, &upright, ppp); + draw_upright(&mut pic, &upright, ppp, ink); let (tx, ty) = (l.center_x() * ppp, l.y * ppp); let t = Transform::from_translate(-pw / 2.0, -ph / 2.0).post_rotate(angle.to_degrees()).post_translate(tx, ty); let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..tiny_skia::PixmapPaint::default() }; pm.draw_pixmap(0, 0, pic.as_ref(), &paint, t, None); } -fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { +fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { let px = l.size * 1.33 * ppp; let (cx, cy) = (l.center_x() * ppp, l.y * ppp); // Several lines (#577) as the map view draws them: each aligned in a block as wide as the @@ -510,7 +522,7 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { } }; // Every line's halo first, so none covers the line above it. - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| ink != Ink::Text) { let r = 1.2 * ppp; let ring = [(-r, 0.0), (r, 0.0), (0.0, -r), (0.0, r), (-r * 0.7, -r * 0.7), (r * 0.7, r * 0.7), (-r * 0.7, r * 0.7), (r * 0.7, -r * 0.7)]; @@ -520,6 +532,9 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { } } } + if ink == Ink::Halo { + return; + } for line in &placed { put(pm, line, l.color, (0.0, 0.0)); } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 64cc59456..8b9a2681f 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1197,14 +1197,23 @@ fn place_galley(anchor: Align2, at: Pos2, g: &egui::Galley) -> (Pos2, Rect) { } fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { - // Curved along a line: each character turned into its place. + // Curved along a line: each character turned into its place, every character's halo first, + // so none covers the letter before it. if !l.glyphs.is_empty() { - for g in &l.glyphs { - let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), align: 0.0, glyphs: Vec::new(), ..l.clone() }; - draw_label(p, &one, at + vec2(g.dx, g.dy)); + let inks: &[(bool, bool)] = if l.halo.is_some() { &[(true, false), (false, true)] } else { &[(false, true)] }; + for &(halo, text) in inks { + for g in &l.glyphs { + let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), align: 0.0, glyphs: Vec::new(), ..l.clone() }; + draw_label_ink(p, &one, at + vec2(g.dx, g.dy), halo, text); + } } return; } + draw_label_ink(p, l, at, true, true); +} + +/// Draws a label (not a curved one) at `at`: its halo when `halo`, its text when `text`. +fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text: bool) { let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align)); // Text set beside an icon is lined up by its edge, with egui's own measure of its width. let anchor = if l.align < -0.25 { @@ -1222,13 +1231,15 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { .with_override_text_color(color) .with_angle_and_anchor(angle, Align2::CENTER_CENTER) }; - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| halo) { let hc = c32(h); for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { p.add(shape(vec2(dx, dy), hc)); } } - p.add(shape(egui::Vec2::ZERO, c32(l.color))); + if text { + p.add(shape(egui::Vec2::ZERO, c32(l.color))); + } return; } let (pos, r) = place_galley(anchor, at, &galley); @@ -1237,13 +1248,15 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { let cyan = c32(rustgis_engine::render::SELECTION); p.rect(r.expand(3.0), 0.0, cyan.gamma_multiply(40.0 / 255.0), Stroke::new(2.0, cyan), egui::StrokeKind::Outside); } - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| halo) { let hc = c32(h); for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { p.galley_with_override_text_color(pos + vec2(dx, dy), galley.clone(), hc); } } - p.galley_with_override_text_color(pos, galley, c32(l.color)); + if text { + p.galley_with_override_text_color(pos, galley, c32(l.color)); + } } /// The globe as seen in this view, when the map is a globe. From 7c2815b554084adb2177aefd1bfa4ee57e89afc3 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 15:09:22 +0200 Subject: [PATCH 5/5] fix(labels): a vertical line's label reads downward whichever way it was drawn; curved labels don't copy their characters per character --- crates/render/src/line_labels.rs | 26 +++++++++++++++++++++++--- crates/render/src/text.rs | 12 +++--------- crates/ui-egui/src/mapview.rs | 4 +++- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index 9854af960..a4ae18640 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -127,8 +127,10 @@ fn angle(a: (f32, f32), b: (f32, f32)) -> f32 { } /// The line as text `len` points long reads along it, left to right: reversed when its middle -/// stretch runs leftward, so the text is never upside down. `None` when the line, with -/// `overrun` past each end, is shorter than the text. +/// stretch runs leftward, so the text is never upside down. A vertical stretch reads downward, +/// as street labels turned with the map do ([`rustgis_tiles::symbols::upright`]), whichever way +/// the line was drawn. `None` when the line, with `overrun` past each end, is shorter than the +/// text. fn upright(line: &[(f32, f32)], len: f32, overrun: f32) -> Option { let path = Path::new(line, false)?; let total = path.length(); @@ -136,7 +138,10 @@ fn upright(line: &[(f32, f32)], len: f32, overrun: f32) -> Option { return None; } let (a, b) = (path.at((total - len) / 2.0)?, path.at((total + len) / 2.0)?); - if b.0 < a.0 { Path::new(line, true) } else { Some(path) } + let a_to_b = angle(a, b); + // Turned half round to stand upright: read from the other end. + let flipped = (rustgis_tiles::symbols::upright(a_to_b) - a_to_b).abs() > 1.0; + if flipped { Path::new(line, true) } else { Some(path) } } /// Text `len` points long straight along `line` at its middle: its centre and angle (radians, @@ -203,6 +208,21 @@ mod tests { assert!(parallel(&[(0.0, 0.0), (0.0, 0.0)], 1.0, 5.0).is_none(), "no length"); } + #[test] + fn a_vertical_line_reads_downward_whichever_way_it_was_drawn() { + // Screen y down: downward is +90°. Drawn up or down, exactly vertical or a hair off it. + let down = std::f32::consts::FRAC_PI_2; + for line in + [[(50.0, 0.0), (50.0, 100.0)], [(50.0, 100.0), (50.0, 0.0)], [(50.0, 100.0), (50.001, 0.0)], [(50.001, 100.0), (50.0, 0.0)]] + { + let (_, _, a) = parallel(&line, 40.0, 0.0).unwrap(); + assert!((a - down).abs() < 1e-3, "{line:?}: {a}"); + let (_, glyphs) = curved(&line, &[10.0; 4], 0.0).unwrap(); + assert!(glyphs.iter().all(|g| (g.2 - down).abs() < 1e-3), "{line:?}: {glyphs:?}"); + assert!(glyphs.windows(2).all(|w| w[1].1 > w[0].1), "first character at the top: {glyphs:?}"); + } + } + #[test] fn a_line_in_view_is_cut_at_its_edges_and_where_it_is_out_of_sight() { // Across the view and out, back in and out again: two stretches, cut at the edges. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b5b288b7d..ddd500856 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -435,17 +435,11 @@ pub fn draw(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { // first, so none covers the letter before it. if !l.glyphs.is_empty() { let inks: &[Ink] = if l.halo.is_some() { &[Ink::Halo, Ink::Text] } else { &[Ink::Text] }; + // (The label without its characters, copied for each: not the characters each time.) + let base = PlacedLabel { text: String::new(), align: 0.0, angle: None, glyphs: Vec::new(), ..l.clone() }; for &ink in inks { for g in &l.glyphs { - let one = PlacedLabel { - text: g.text.clone(), - x: l.x + g.dx, - y: l.y + g.dy, - align: 0.0, - angle: None, - glyphs: Vec::new(), - ..l.clone() - }; + let one = PlacedLabel { text: g.text.clone(), x: l.x + g.dx, y: l.y + g.dy, ..base.clone() }; draw_turned(pm, &one, g.angle, ppp, ink); } } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 8b9a2681f..a07f9718c 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1201,9 +1201,11 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { // so none covers the letter before it. if !l.glyphs.is_empty() { let inks: &[(bool, bool)] = if l.halo.is_some() { &[(true, false), (false, true)] } else { &[(false, true)] }; + // (The label without its characters, copied for each: not the characters each time.) + let base = PlacedLabel { text: String::new(), align: 0.0, glyphs: Vec::new(), ..l.clone() }; for &(halo, text) in inks { for g in &l.glyphs { - let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), align: 0.0, glyphs: Vec::new(), ..l.clone() }; + let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), ..base.clone() }; draw_label_ink(p, &one, at + vec2(g.dx, g.dy), halo, text); } }