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/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..2e2aec37a 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(); @@ -1312,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 2b79dba9d..328852da6 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,22 @@ 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] = [ + /// 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, LabelPlacement::Above, @@ -1197,6 +1212,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft, LabelPlacement::BelowRight, LabelPlacement::BelowLeft, + LabelPlacement::Parallel, + LabelPlacement::Curved, ]; pub fn code(self) -> &'static str { match self { @@ -1210,6 +1227,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 +1243,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 +1256,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 +1295,7 @@ impl LabelClass { closest: None, farthest: None, placement: LabelPlacement::Auto, + italic: false, } } 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 f448e075b..fca2a618b 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,12 +110,13 @@ 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::{ 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); @@ -194,6 +196,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 +2947,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(), }); } } @@ -2947,17 +2963,28 @@ 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| 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) { continue; } let Some(c) = g.label_point() else { continue }; - if globe.is_none() && !ctx.cull.contains(c) { + // 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. + 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 { @@ -2975,6 +3002,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.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() { @@ -2991,6 +3023,29 @@ 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; + // 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 { + // 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); + 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 +3053,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 +3062,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), @@ -3651,6 +3707,27 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) out } +/// 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 (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))) +} + /// 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. @@ -3664,6 +3741,23 @@ 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 { + read_back(l); + } + } if let Some(i) = &mut l.icon && let Some(a) = i.angle { @@ -3671,6 +3765,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; @@ -3759,6 +3878,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 +4032,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 +4057,7 @@ mod tests { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), } } @@ -5519,6 +5649,158 @@ 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_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 + // 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.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] + 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/line_labels.rs b/crates/render/src/line_labels.rs new file mode 100644 index 000000000..a4ae18640 --- /dev/null +++ b/crates/render/src/line_labels.rs @@ -0,0 +1,255 @@ +//! 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; + +/// 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)>, + 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 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) +} + +/// 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. 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(); + 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)?); + 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, +/// 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 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. + 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. + 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..ddd500856 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -431,14 +431,36 @@ 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, every character's halo + // 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, ..base.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; @@ -449,14 +471,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 @@ -494,7 +516,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)]; @@ -504,6 +526,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)); } @@ -560,6 +585,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..a07f9718c 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1197,6 +1197,25 @@ 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, every character's halo first, + // 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), ..base.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 { @@ -1214,13 +1233,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); @@ -1229,13 +1250,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. diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index a50178c82..865cf9685 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,14 @@ 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 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. 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. - **Convert** turns a layer's labels into an annotation layer: one point per label, carrying its