From bb519dd8b18caeae56d7a7fe3c6911763a067630 Mon Sep 17 00:00:00 2001 From: strebdom Date: Mon, 26 Oct 2026 08:08:46 +0100 Subject: [PATCH 1/2] feat(map): a character of a font as a marker, as QGIS's font markers draw one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A point's marker, and a marker along a line, can be a character of an installed font (Marker::Char): the size is the font's em, the character's advance is centred on the point and its baseline half the font's ascent below it, as QGIS 4.2 places a font marker (measured in its renders). A family the machine lacks draws in the label font; a character no font has draws a circle, so the point still shows. - map: Glyph (character and family) and FontName, a family name kept once so Symbol stays Copy; shapes save and load as before, a crafted file's names are bounded and junk reads as the label font. - render: glyphs.rs reads the outline (ab_glyph) once per character; the GPU has no template for it, so such a layer draws on the CPU. - QGIS import: FontMarker becomes a character marker on points and along marker lines (was a circle, and left out along lines); several characters, styles and anchors off the centre are noted. - layer.marker takes "char" and "font"; a stroke's markers take "mark": "character" with "char" and "font". - Symbology pane: Shape › Character, with the character and a font picker (the label pane's, shared); legend patches draw the character. Part of #882 (piece 3). --- crates/engine/src/cmd/layer.rs | 42 +++++++++- crates/engine/src/tests.rs | 42 ++++++++++ crates/map/src/lib.rs | 4 +- crates/map/src/qgis/symbol.rs | 51 ++++++++---- crates/map/src/qgis/tests.rs | 23 ++++-- crates/map/src/symbology.rs | 98 ++++++++++++++++++++++ crates/render/src/glyphs.rs | 132 ++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 97 +++++++++++++++++++++- crates/render/src/line_markers.rs | 9 +- crates/render/src/mesh.rs | 14 +++- crates/render/src/text.rs | 17 ++++ crates/ui-egui/src/labelpane.rs | 38 ++++++--- crates/ui-egui/src/panes.rs | 67 ++++++++++++++- docs/user-guide/cartography.md | 14 +++- docs/user-guide/workspace.md | 9 +- 15 files changed, 604 insertions(+), 53 deletions(-) create mode 100644 crates/render/src/glyphs.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 20684ee6b..20e31b39c 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -4,7 +4,7 @@ use std::path::Path; use rustgis_data::{GeometryType, photo}; use rustgis_map::symbology::{ClassRef, ClassStyle, LABEL_SIZE, MAX_STROKES}; -use rustgis_map::{ClassMethod, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, Marker, Placements, Ramp, Renderer, Rgba}; +use rustgis_map::{ClassMethod, Glyph, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, Marker, Placements, Ramp, Renderer, Rgba}; use serde_json::{Value, json}; use super::{b, f, need_feature_layer, need_layer, ps, uint}; @@ -177,7 +177,7 @@ pub fn commands() -> Vec { C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes) .when(need_lines) .params( - r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"hash" (a mark across the line), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, + r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"character"|"hash" (a mark across the line), "char"?: text (a character of a font as the marker instead of a shape), "font"?: text (the installed family it is drawn from; none: the label font), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, ), C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| { let on = b(p, "on").ok_or("on is true or false")?; @@ -194,11 +194,25 @@ pub fn commands() -> Vec { C::new("layer.marker", "Marker", "Style › Drawing", |s, p| { let shape = ps(p, "shape").map(|n| Marker::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(n)).ok_or("unknown marker shape")).transpose()?; let size = f(p, "size").map(|v| v.clamp(1.0, 80.0) as f32); + let (ch, font) = (character(p)?, ps(p, "font")); + if shape.is_some() && ch.is_some() { + return Err("give a shape or a char, not both".into()); + } with(s, p, |l| { l.renderer.map_symbols(|sym| { if let Some(m) = shape { sym.marker = m; } + let was = match sym.marker { + Marker::Char(g) => Some(g), + _ => None, + }; + // A new character keeps the family of the one before; a family alone changes a character's. + match (ch, was, font) { + (Some(c), _, _) => sym.marker = Marker::Char(Glyph::new(c, font.unwrap_or(was.map_or("", |g| g.font.as_str())))), + (None, Some(g), Some(fam)) => sym.marker = Marker::Char(Glyph::new(g.ch, fam)), + _ => {} + } if let Some(sz) = size { sym.size = sz; } @@ -206,7 +220,7 @@ pub fn commands() -> Vec { }) }) .when(need_feature_layer) - .params(r#"{"layer"?, "shape"?: "circle"|"square"|"triangle"|"diamond"|"star", "size"?: number}"#), + .params(r#"{"layer"?, "shape"?: "circle"|"square"|"triangle"|"diamond"|"star", "char"?: text (a character of a font as the marker, its first; sized by the font's em), "font"?: text (the installed family the character is drawn from; "" or one not installed: the label font), "size"?: number}"#), C::new("layer.transparency", "Transparency", "Style › Effects", |s, p| { let t = f(p, "value").ok_or("value (0–100) is required")?.clamp(0.0, 100.0) as f32; with(s, p, |l| l.transparency = t) @@ -472,6 +486,15 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"strokes": n})) } +/// A character marker's `char` (its first character), if given. +fn character(p: &Value) -> Result, String> { + match p.get("char") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(c)) => c.chars().next().map(Some).ok_or_else(|| "char is a character".into()), + Some(_) => Err("char is a character".into()), + } +} + /// A stroke's `markers`: what it draws along the line instead of a line, and where. fn line_markers(v: &Value) -> Result { let o = v.as_object().ok_or("an object, such as {\"mark\": \"circle\", \"interval\": 12}")?; @@ -487,11 +510,24 @@ fn line_markers(v: &Value) -> Result { let mark = match o.get("mark") { None | Some(Value::Null) => d.mark, Some(Value::String(m)) if m.trim().eq_ignore_ascii_case("hash") => LineMark::Hash, + // Its `char` says which. + Some(Value::String(m)) if m.trim().eq_ignore_ascii_case("character") => { + if character(v)?.is_none() { + return Err("a character mark needs its char".into()); + } + d.mark + } Some(Value::String(m)) => LineMark::Marker( Marker::ALL.into_iter().find(|s| s.label().eq_ignore_ascii_case(m.trim())).ok_or_else(|| format!("unknown mark '{m}'"))?, ), Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\" or \"hash\"".into()), }; + // A character of a font instead of a shape. + let mark = match (character(v)?, o.get("font")) { + (Some(_), _) if mark == LineMark::Hash => return Err("hash marks take no char".into()), + (Some(c), font) => LineMark::Marker(Marker::Char(Glyph::new(c, font.and_then(Value::as_str).unwrap_or_default()))), + (None, _) => mark, + }; let bad_at = || format!("at is a placement or a list of them: {}", Placements::all_codes()); let at = match o.get("at") { None | Some(Value::Null) => d.at, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index def24f2b7..2cfc46dd7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -470,6 +470,48 @@ fn line_strokes_take_markers_and_hash_marks_along_the_line() { assert_eq!(s.undo_depth(), depth); } +#[test] +fn a_character_of_a_font_is_a_marker_on_points_and_along_lines() { + use rustgis_map::{Glyph, LineMark, Marker}; + let mut s = world(); + let marker = |s: &Session| s.map().layer_by_name("Cities").unwrap().symbol(0).marker; + s.run("layer.marker", &json!({"layer": "Cities", "char": "★x", "font": "DejaVu Sans", "size": 14})).unwrap(); + assert_eq!(marker(&s), Marker::Char(Glyph::new('★', "DejaVu Sans"))); + // Another character keeps the family; a family alone changes the character's. + s.run("layer.marker", &json!({"layer": "Cities", "char": "A"})).unwrap(); + assert_eq!(marker(&s), Marker::Char(Glyph::new('A', "DejaVu Sans"))); + s.run("layer.marker", &json!({"layer": "Cities", "font": ""})).unwrap(); + assert_eq!(marker(&s), Marker::Char(Glyph::new('A', ""))); + // Drawn in an export, a family this machine lacks in the label font. + let dir = std::env::temp_dir().join(format!("rustgis-chars-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + s.run("layer.marker", &json!({"layer": "Cities", "font": "No Such Family"})).unwrap(); + let strokes = + json!([{"color": "#1E5AA8", "markers": {"mark": "character", "char": "»", "font": "DejaVu Sans", "size": 8, "interval": 20}}]); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); + let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap(); + assert_eq!(m.mark, LineMark::Marker(Marker::Char(Glyph::new('»', "DejaVu Sans")))); + s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + // A shape goes back to the shape. + s.run("layer.marker", &json!({"layer": "Cities", "shape": "square"})).unwrap(); + assert_eq!(marker(&s), Marker::Square); + let depth = s.undo_depth(); + for (bad, says) in [ + (json!({"layer": "Cities", "char": ""}), "char is a character"), + (json!({"layer": "Cities", "char": 7}), "char is a character"), + (json!({"layer": "Cities", "char": "a", "shape": "star"}), "not both"), + ] { + let err = s.run("layer.marker", &bad).unwrap_err(); + assert!(err.contains(says), "{bad}: {err}"); + } + let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "hash", "char": "x"}}]})).unwrap_err(); + assert!(err.contains("hash marks take no char"), "{err}"); + let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "character"}}]})).unwrap_err(); + assert!(err.contains("needs its char"), "{err}"); + assert_eq!(s.undo_depth(), depth); +} + #[test] fn a_stroke_is_dashed_by_command() { let mut s = world(); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index df07f9aff..955d8797a 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -31,8 +31,8 @@ pub use color::{Ramp, RampGroup, Rgba}; pub use globe::{Globe, Projection}; pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch}; pub use symbology::{ - ClassMethod, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, - Marker, Placements, Renderer, Symbol, + ClassMethod, FontName, Glyph, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, + LineStroke, Marker, Placements, Renderer, Symbol, }; pub use tiles::{TileKind, TileLayer}; diff --git a/crates/map/src/qgis/symbol.rs b/crates/map/src/qgis/symbol.rs index b8b7eb6d0..c5a2096f4 100644 --- a/crates/map/src/qgis/symbol.rs +++ b/crates/map/src/qgis/symbol.rs @@ -1,14 +1,14 @@ //! QGIS symbols (``) as a RustGIS [`Symbol`], and a line's //! symbol layers as stacked [`LineStroke`]s. A symbol layer RustGIS has no match for is drawn -//! as near as it can (a pattern fill as a tint of its colour, a font or SVG marker as a circle in -//! its colour) or left out, and noted. +//! as near as it can (a pattern fill as a tint of its colour, an SVG marker as a circle in its +//! colour) or left out, and noted. use roxmltree::Node; use super::{Report, color, data_defined_on, has_effect, points, prop, props, signed_points}; use crate::Rgba; use crate::symbology::{ - Dash, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Placements, Symbol, + Dash, Glyph, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Placements, Symbol, }; /// A symbol layer's settings by name. @@ -379,12 +379,7 @@ fn marker_mark( Marker::Circle }) } - // A character is mostly thin strokes: a dot of its size would draw a heavy blot along the - // line where QGIS draws a tick or a dash, so it is left out. - "FontMarker" => { - report.skipped(layer, "font markers (characters of a symbol font) along lines"); - return None; - } + "FontMarker" => font_char(p, layer, report)?, "SvgMarker" | "RasterMarker" | "EllipseMarker" | "FilledMarker" => { report.approximated(layer, format!("{} markers, drawn as circles in their colour", marker_name(class))); Marker::Circle @@ -394,6 +389,9 @@ fn marker_mark( return None; } }; + if class == "FontMarker" && anchored(p) { + report.approximated(layer, "font markers anchored off their centre, drawn centred"); + } let fill = prop(p, "color").and_then(color).unwrap_or(Rgba::new(0, 0, 0, 0)); let outline = if prop(p, "outline_style") == Some("no") { None } else { prop(p, "outline_color").and_then(color) }; let width = if outline.is_some() { size(p, "outline_width", reference, layer, report).unwrap_or(0.0) } else { 0.0 }; @@ -474,10 +472,10 @@ fn marker(layers: &[(Node, Props)], reference: Option, layer: &str, report: Marker::Circle }) } - "FontMarker" => { - report.approximated(layer, "font markers (characters of a symbol font), drawn as circles in their colour"); - Marker::Circle - } + "FontMarker" => match font_char(p, layer, report) { + Some(m) => m, + None => continue, + }, "SvgMarker" | "RasterMarker" | "EllipseMarker" | "FilledMarker" => { report.approximated(layer, format!("{} markers, drawn as circles in their colour", marker_name(class))); Marker::Circle @@ -487,12 +485,14 @@ fn marker(layers: &[(Node, Props)], reference: Option, layer: &str, report: continue; } }; + if class == "FontMarker" && anchored(p) { + report.approximated(layer, "font markers anchored off their centre, drawn centred"); + } if prop(p, "angle").and_then(|a| a.trim().parse::().ok()).is_some_and(|a| a != 0.0) { report.approximated(layer, "turned markers, drawn upright"); } let fill = fill.unwrap_or(Rgba::new(0, 0, 0, 0)); - // A font marker's character is all fill; an outline of no width draws none. - let width = if class == "FontMarker" { 0.0 } else { size(p, "outline_width", reference, layer, report).unwrap_or(0.0) }; + let width = size(p, "outline_width", reference, layer, report).unwrap_or(0.0); // Each layer drawn over the one before: the top one's look, the biggest size. symbol = Symbol { fill, @@ -505,6 +505,27 @@ fn marker(layers: &[(Node, Props)], reference: Option, layer: &str, report: Converted { symbol, strokes: Vec::new() } } +/// A font marker's character (`chr`, of the family `font`) as a character marker; `None` for one +/// with none (it draws nothing). A string of several draws its first (noted). +fn font_char(p: &[(String, String)], layer: &str, report: &mut Report) -> Option { + let text = prop(p, "chr").unwrap_or_default(); + let mut chars = text.chars(); + let ch = chars.next()?; + if chars.next().is_some() { + report.approximated(layer, "font markers of several characters, drawn as their first"); + } + if prop(p, "font_style").is_some_and(|s| !s.trim().is_empty() && !s.trim().eq_ignore_ascii_case("regular")) { + report.approximated(layer, "font marker styles (bold, italic and the like), drawn regular"); + } + Some(Marker::Char(Glyph::new(ch, prop(p, "font").unwrap_or_default()))) +} + +/// Whether a marker is anchored off its centre (`horizontal_anchor_point`, +/// `vertical_anchor_point`: 1 is the centre). +fn anchored(p: &[(String, String)]) -> bool { + ["horizontal_anchor_point", "vertical_anchor_point"].iter().any(|k| prop(p, k).is_some_and(|v| v.trim() != "1")) +} + /// QGIS's simple marker shapes RustGIS has. fn shape_of(name: &str) -> Option { Some(match name { diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index cd2fbbf9b..7b65bb41c 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -1,7 +1,7 @@ //! A small QGIS project and style, written by hand in the shapes QGIS writes them. use super::*; -use crate::symbology::{LabelPlacement, LineCap, LineMark, Marker, Placements}; +use crate::symbology::{Glyph, LabelPlacement, LineCap, LineMark, Marker, Placements}; const PROJECT: &str = r#" @@ -180,7 +180,8 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() { let Some(Renderer::Unique { field, classes, other, .. }) = &towns.style.renderer else { panic!() }; assert_eq!((field.as_str(), classes.len()), ("rank", 2)); assert_eq!((classes[0].1.marker, classes[0].1.fill), (Marker::Star, Rgba::rgb(200, 0, 0))); - assert_eq!((other.marker, other.fill), (Marker::Circle, Rgba::rgb(0, 0, 0))); + // A font marker: its character (of no named family: the label font's). + assert_eq!((other.marker, other.fill), (Marker::Char(Glyph::new('A', "")), Rgba::rgb(0, 0, 0))); // What didn't carry over is said, once for each thing. let said = @@ -193,7 +194,7 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() { // A marker line without the symbol it places draws nothing, in QGIS too: nothing to say. assert!(!said("Roads", NoteKind::Skipped, "markers")); assert!(said("Roads", NoteKind::Skipped, "length(")); - assert!(said("Towns", NoteKind::Approximated, "font markers")); + assert!(!report.notes.iter().any(|n| n.layer == "Towns" && n.text.contains("font markers")), "drawn as characters: {:?}", report.notes); // A switched-off label rule is never drawn, so its filter isn't noted. assert!(!report.notes.iter().any(|n| n.text.contains("Switched off")), "{:?}", report.notes); } @@ -604,7 +605,7 @@ fn marker_and_hashed_lines_carry_over_as_strokes_of_marks() { let strokes = s.line_strokes.unwrap(); let mm = 96.0 / 25.4; let near = |a: f32, b: f32| (a - b).abs() < 1e-3; - assert_eq!(strokes.len(), 4, "{strokes:?}"); + assert_eq!(strokes.len(), 5, "{strokes:?}"); assert!(strokes[0].markers.is_none()); // An offset to the left stays to the left (it used to be taken as none). assert!(near(strokes[0].offset, -0.5 * mm), "{:?}", strokes[0]); @@ -622,17 +623,21 @@ fn marker_and_hashed_lines_carry_over_as_strokes_of_marks() { assert_eq!((m.mark, m.angle, m.outline), (LineMark::Marker(Marker::Triangle), 90.0, Some(Rgba::new(0, 0, 0, 128)))); assert!(near(tri.width.unwrap(), 0.2 * mm) && near(tri.offset, 0.5 * mm) && near(m.size, 2.0 * mm)); + // The font character: its family's, in its colour, of its size. + let (chr, m) = (strokes[3], strokes[3].markers.unwrap()); + assert_eq!(m.mark, LineMark::Marker(Marker::Char(Glyph::new('{', "DKM_Symbole")))); + assert_eq!(chr.color, Some(Rgba::new(0, 0, 0, 128))); + assert!(near(m.size, mm) && near(chr.offset, 0.5 * mm), "{chr:?}"); + // QGIS 2's hash marks: upright, at their angle, the line's colour and width. - let (hash, m) = (strokes[3], strokes[3].markers.unwrap()); + let (hash, m) = (strokes[4], strokes[4].markers.unwrap()); assert_eq!((m.mark, m.at, m.rotate, m.angle), (LineMark::Hash, Placements::INTERVAL, false, 30.0)); assert_eq!(hash.color, Some(Rgba::rgb(0, 0, 255))); assert!(near(m.interval, 2.0 * mm) && near(m.size, 3.0 * mm) && near(hash.width.unwrap(), 0.3 * mm)); - // A font character would be a blot of its size where QGIS draws a tick: left out until RustGIS - // draws characters. - assert!(report.notes.iter().any(|n| n.kind == NoteKind::Skipped && n.text.contains("font markers")), "{:?}", report.notes); + // Only the picture isn't drawn as QGIS draws it. + assert_eq!(report.notes.len(), 1, "{:?}", report.notes); assert!(report.notes.iter().any(|n| n.kind == NoteKind::Approximated && n.text.contains("SVG markers")), "{:?}", report.notes); - assert_eq!(report.notes.iter().filter(|n| n.text.contains("along lines")).count(), 1, "{:?}", report.notes); // A placement on curves is drawn at the vertices; one QGIS adds later is left out, and said. let other = qml.replace("FirstVertex|LastVertex", "CurvePoint|SomethingNew"); diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 866260a86..a290146f2 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -16,9 +16,12 @@ pub enum Marker { Triangle, Diamond, Star, + /// A character of a font, as QGIS's font markers draw one: the size is the font's em. + Char(Glyph), } impl Marker { + /// The shapes (every marker but [`Marker::Char`]). pub const ALL: [Marker; 5] = [Marker::Circle, Marker::Square, Marker::Triangle, Marker::Diamond, Marker::Star]; pub fn label(self) -> &'static str { match self { @@ -27,10 +30,76 @@ impl Marker { Marker::Triangle => "Triangle", Marker::Diamond => "Diamond", Marker::Star => "Star", + Marker::Char(_) => "Character", } } } +/// A character marker's character, and the installed family it is drawn from (empty: the label +/// font). A family the machine lacks draws in the label font, as a label's does. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Glyph { + pub ch: char, + #[serde(default, skip_serializing_if = "FontName::is_default")] + pub font: FontName, +} + +impl Glyph { + pub fn new(ch: char, font: &str) -> Glyph { + Glyph { ch, font: FontName::new(font) } + } +} + +/// The most font names kept (each is kept for the rest of the run); one past them is the label font. +const MAX_FONT_NAMES: usize = 4096; + +/// A font family's name, kept once for the rest of the run so a symbol naming one stays `Copy`. +/// Empty for the label font. Names are kept as a label's are ([`font_name`]), and past +/// [`MAX_FONT_NAMES`] different ones (from a crafted file) the rest are the label font. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FontName(&'static str); + +impl FontName { + pub fn new(name: &str) -> FontName { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static NAMES: OnceLock>> = OnceLock::new(); + let Some(name) = font_name(name) else { return FontName::default() }; + let name = name.as_str(); + let mut names = NAMES.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner()); + if let Some(kept) = names.get(name) { + return FontName(kept); + } + if names.len() >= MAX_FONT_NAMES { + return FontName::default(); + } + let kept: &'static str = Box::leak(name.to_owned().into_boxed_str()); + names.insert(kept); + FontName(kept) + } + + pub fn as_str(self) -> &'static str { + self.0 + } + + pub fn is_default(&self) -> bool { + self.0.is_empty() + } +} + +impl Serialize for FontName { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.0) + } +} + +impl<'de> Deserialize<'de> for FontName { + /// Not a name (a crafted file): the label font, and the rest still reads. + fn deserialize>(d: D) -> Result { + Ok(de_font(d)?.map_or_else(FontName::default, |n| FontName::new(&n))) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Symbol { pub fill: Rgba, @@ -1842,6 +1911,35 @@ mod tests { assert_eq!(read("x".repeat(10_000).into()).default.font.map(|f| f.chars().count()), Some(MAX_FONT_NAME)); } + /// The shapes are written as they always were; a character with its family, which a crafted + /// file can't make long or many, or stop the rest from reading. + #[test] + fn character_markers_keep_their_font_and_shapes_their_saved_form() { + for m in Marker::ALL { + assert_eq!(serde_json::to_value(m).unwrap(), serde_json::json!(m.label())); + assert_eq!(serde_json::from_value::(serde_json::json!(m.label())).unwrap(), m); + } + let old = serde_json::json!({"fill": [1, 2, 3, 255], "stroke": [0, 0, 0, 255], "stroke_width": 1.0, "size": 7.0, "marker": "Star"}); + let s: Symbol = serde_json::from_value(old.clone()).unwrap(); + assert_eq!(serde_json::to_value(s).unwrap(), old); + + let g = Marker::Char(Glyph::new('@', " DKM_Symbole ")); + let v = serde_json::to_value(g).unwrap(); + assert_eq!(v, serde_json::json!({"Char": {"ch": "@", "font": "DKM_Symbole"}})); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + // The label font's character isn't written a family. + assert_eq!(serde_json::to_value(Glyph::new('A', "")).unwrap(), serde_json::json!({"ch": "A"})); + for junk in [serde_json::json!(7), serde_json::json!(["Arial"]), serde_json::json!({"a": 1}), serde_json::Value::Null] { + let read: Marker = serde_json::from_value(serde_json::json!({"Char": {"ch": "x", "font": junk}})).unwrap(); + assert_eq!(read, Marker::Char(Glyph::new('x', ""))); + } + let long: Marker = serde_json::from_value(serde_json::json!({"Char": {"ch": "x", "font": "y".repeat(10_000)}})).unwrap(); + let Marker::Char(g) = long else { panic!() }; + assert_eq!(g.font.as_str().chars().count(), MAX_FONT_NAME); + // Names are kept once each: the same name is the same one. + assert!(std::ptr::eq(FontName::new("Some Family").as_str(), FontName::new("Some Family ").as_str())); + } + /// A project file is read with at most `MAX_LABEL_CLASSES` classes, and only that many count /// in a `Labeling` built with more. A class on /// a field the data doesn't have is pointed at Default's, and kept. diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs new file mode 100644 index 000000000..3cb96190d --- /dev/null +++ b/crates/render/src/glyphs.rs @@ -0,0 +1,132 @@ +//! Character markers ([`Marker::Char`](rustgis_map::Marker::Char)): a character of an installed +//! family as a marker's outline, placed as QGIS places a font marker: the em is the marker's size, +//! the character's advance is centred on the point and its baseline is half the font's ascent +//! below it. A family the machine lacks, or one without the character, draws it in the label font. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use ab_glyph::{Font, OutlineCurve}; +use rustgis_map::Glyph; +use tiny_skia::{Path, PathBuilder}; + +/// The most characters kept, and the most curves read from one (a crafted font's glyph). +const MAX_KEPT: usize = 4096; +const MAX_CURVES: usize = 20_000; + +/// A character's outline one em across, the point at the origin (y down), and the furthest it +/// reaches from the point, in ems. +#[derive(Debug)] +pub(crate) struct CharShape { + pub path: Path, + pub reach: f32, +} + +/// `g`'s outline (see the module), read once and kept; `None` for a character with no outline +/// (a space) in its family and in the label font. +pub(crate) fn shape(g: Glyph) -> Option> { + static KEPT: OnceLock>>>> = OnceLock::new(); + let kept = KEPT.get_or_init(Default::default); + if let Some(s) = kept.lock().ok()?.get(&g) { + return s.clone(); + } + let (s, settled) = read(g); + // Not kept while the installed families are still being indexed: drawn in the label font for + // now, in its own family once they are. + if settled + && let Ok(mut kept) = kept.lock() + && kept.len() < MAX_KEPT + { + kept.insert(g, s.clone()); + } + s +} + +/// `g`'s outline, and whether it is final (its family's face, or the label font's because the +/// family isn't installed rather than not indexed yet). +fn read(g: Glyph) -> (Option>, bool) { + let (named, settled) = crate::text::marker_font(g.font.as_str()); + let from = |f: &ab_glyph::FontRef<'static>| { + let id = f.glyph_id(g.ch); + (id.0 != 0).then(|| outline(f, id)).flatten() + }; + let s = named.and_then(from).or_else(|| crate::text::label_font().and_then(from)); + (s.map(Arc::new), settled) +} + +fn outline(f: &ab_glyph::FontRef<'static>, id: ab_glyph::GlyphId) -> Option { + let upm = f.units_per_em().filter(|u| u.is_finite() && *u > 0.0)?; + let advance = f.h_advance_unscaled(id); + let ascent = f.ascent_unscaled(); + let (dx, dy) = (if advance.is_finite() { advance / 2.0 } else { 0.0 }, if ascent.is_finite() { ascent / 2.0 } else { 0.0 }); + // Font units (y up, from the origin of the advance and the baseline) to ems about the point. + let at = |p: ab_glyph::Point| ((p.x - dx) / upm, (dy - p.y) / upm); + let o = f.outline(id)?; + let mut pb = PathBuilder::new(); + let mut last: Option = None; + for c in o.curves.iter().take(MAX_CURVES) { + let (start, end) = match c { + OutlineCurve::Line(a, b) | OutlineCurve::Quad(a, _, b) | OutlineCurve::Cubic(a, _, _, b) => (*a, *b), + }; + if last != Some(start) { + if last.is_some() { + pb.close(); + } + let (x, y) = at(start); + pb.move_to(x, y); + } + match c { + OutlineCurve::Line(_, b) => { + let (x, y) = at(*b); + pb.line_to(x, y); + } + OutlineCurve::Quad(_, k, b) => { + let ((kx, ky), (x, y)) = (at(*k), at(*b)); + pb.quad_to(kx, ky, x, y); + } + OutlineCurve::Cubic(_, k1, k2, b) => { + let ((ax, ay), (bx, by), (x, y)) = (at(*k1), at(*k2), at(*b)); + pb.cubic_to(ax, ay, bx, by, x, y); + } + } + last = Some(end); + } + if last.is_some() { + pb.close(); + } + let path = pb.finish()?; + let b = path.bounds(); + let reach = b.left().abs().max(b.right().abs()).max(b.top().abs()).max(b.bottom().abs()); + reach.is_finite().then_some(CharShape { path, reach }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_character_is_centred_on_its_advance_with_its_baseline_below_the_point() { + // The label font's "H": as wide either side of the point, its foot on the baseline half + // the ascent below it, standing a cap height tall. + let f = crate::text::label_font().unwrap(); + let s = shape(Glyph::new('H', "")).unwrap(); + let b = s.path.bounds(); + let upm = f.units_per_em().unwrap(); + let id = f.glyph_id('H'); + let ink = f.outline(id).unwrap().bounds; + let advance = f.h_advance_unscaled(id); + assert!((b.left() - (ink.min.x - advance / 2.0) / upm).abs() < 1e-3, "{b:?}"); + assert!((b.bottom() - f.ascent_unscaled() / 2.0 / upm).abs() < 0.02, "{b:?}"); + assert!(b.height() > 0.5 && b.height() < 0.8, "a cap height: {b:?}"); + assert!(s.reach >= b.bottom() && s.reach < 1.0); + } + + #[test] + fn a_missing_family_or_character_draws_in_the_label_font_and_a_space_draws_nothing() { + let label = shape(Glyph::new('H', "")).unwrap(); + let missing = shape(Glyph::new('H', "No Such Family Anywhere")).unwrap(); + assert_eq!(label.path.bounds(), missing.path.bounds()); + assert!(shape(Glyph::new(' ', "")).is_none()); + assert!(shape(Glyph::new('\u{10FFFD}', "")).is_none(), "a character no font has"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e74be4a50..7f69a3cd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ mod effects; pub mod families; pub mod fonts; +mod glyphs; pub mod gpu_scene; pub mod layout; mod line_labels; @@ -783,7 +784,7 @@ impl Ctx<'_> { let (qx, qy) = (((x - fx) * 4.0).round(), ((y - fy) * 4.0).round()); let (ix, iy) = (fx as i64 + (qx >= 4.0) as i64, fy as i64 + (qy >= 4.0) as i64); let (qx, qy) = ((qx as u8) % 4, (qy as u8) % 4); - let reach = ((s.size.max(1.0) * 0.6 + s.stroke_width.max(0.0)) * self.ppp).ceil() as i64 + 3; + let reach = ((s.size.max(1.0) * marker_reach(s.marker) + s.stroke_width.max(0.0)) * self.ppp).ceil() as i64 + 3; if ix < -reach || iy < -reach || ix > pm.width() as i64 + reach || iy > pm.height() as i64 + reach { return; } @@ -1252,10 +1253,29 @@ pub(crate) fn marker_path(x: f32, y: f32, r: f32, marker: Marker) -> Option match glyphs::shape(g) { + Some(s) => { + let em = 2.0 * r; + return s.path.clone().transform(Transform::from_row(em, 0.0, 0.0, em, x, y)); + } + // A space draws nothing; a character no font here has, a circle of its size, so the + // point still shows. + None if g.ch.is_whitespace() => return None, + None => pb.push_circle(x, y, r), + }, } pb.finish() } +/// How far a `marker` reaches from its centre, in its sizes: a shape (a star's points) 0.6, a +/// character as far as its outline does. +pub(crate) fn marker_reach(marker: Marker) -> f32 { + match marker { + Marker::Char(g) => glyphs::shape(g).map_or(0.6, |s| s.reach), + _ => 0.6, + } +} + /// A marker centred at (`x`, `y`) device pixels. fn draw_marker(pm: &mut Pixmap, x: f32, y: f32, s: &Symbol, opacity: f32, halo: Option, ppp: f32) { let r = (s.size.max(1.0) * ppp) / 2.0; @@ -2296,10 +2316,11 @@ impl UprightMarker { /// The outline of a `marker` of radius `r` centred at (`x`, `y`), as [`draw_marker`] draws it /// (a circle as a polygon): for drawing upright markers as meshes. Every outline is seen whole -/// from its centre, so a fan from the centre fills it. +/// from its centre, so a fan from the centre fills it. A character, which no fan fills, as a circle +/// of its size (while a tilted view moves; the settled view draws the character). pub fn marker_outline(marker: Marker, x: f32, y: f32, r: f32) -> Vec<(f32, f32)> { match marker { - Marker::Circle => (0..24) + Marker::Circle | Marker::Char(_) => (0..24) .map(|i| { let a = std::f32::consts::TAU * i as f32 / 24.0; (x + r * a.cos(), y + r * a.sin()) @@ -5759,6 +5780,76 @@ mod tests { assert_eq!(r.pixmap.pixel(100, 100).unwrap(), blank, "nothing on the centreline"); } + #[test] + fn a_character_marker_draws_its_character_where_qgis_does_and_on_the_cpu() { + use ab_glyph::Font; + use rustgis_map::Glyph; + // A 60 pt "L" at the centre of a 200 px view: its advance centred, its baseline half the + // font's ascent below the point. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("signs", GeometryType::Point, vec![]); + d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let id = m.add_dataset(d); + let black = Rgba::rgb(0, 0, 0); + let none = Rgba::new(0, 0, 0, 0); + m.layer_mut(id).unwrap().renderer = rustgis_map::Renderer::Single(Symbol { + fill: black, + stroke: none, + stroke_width: 0.0, + size: 60.0, + marker: Marker::Char(Glyph::new('L', "")), + }); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 200.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let r = render(&mut m, &view).unwrap(); + let ink = |x: u32, y: u32| r.pixmap.pixel(x, y).is_some_and(|p| p.red() < 100 && p.alpha() > 200); + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..200 { + for x in 0..200 { + if ink(x, y) { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + let f = crate::text::label_font().unwrap(); + let upm = f.units_per_em().unwrap(); + let baseline = 100.0 + 60.0 * f.ascent_unscaled() / 2.0 / upm; + assert!((y1 as f32 + 1.0 - baseline).abs() < 2.0, "foot on the baseline: {y1} vs {baseline}"); + assert!(y1 - y0 > x1 - x0 && x0 < 100 && x1 > 100, "an L, about the point: {x0},{y0} {x1},{y1}"); + assert!(ink(x0 + 1, y0 + 1) && !ink(x1 - 1, y0 + 1) && ink(x1 - 1, y1 - 1), "its stem and foot, not a blob"); + // The GPU has no character template: the layer is a CPU picture there. + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut m, &view, Backend::Gpu) else { panic!("gpu scene") }; + let kinds = cmd_kinds(&scene); + assert!(kinds.contains(&"image") && !kinds.contains(&"points"), "{kinds:?}"); + // A character no font here has still shows the point: a circle of its size. + if let rustgis_map::Renderer::Single(sym) = &mut m.layer_mut(id).unwrap().renderer { + sym.marker = Marker::Char(Glyph::new('\u{10FFFD}', "")); + } + let r = render(&mut m, &view).unwrap(); + let ink = |x: u32, y: u32| r.pixmap.pixel(x, y).is_some_and(|p| p.red() < 100 && p.alpha() > 200); + assert!(ink(100, 100) && ink(100, 75) && !ink(100, 65), "a 60 pt circle"); + // Along a line too, turned with it. + let mut d = Dataset::new("rail", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.5), Coord::new(1.0, 0.5)]])), vec![]); + let line = m.add_dataset(d); + let marks = rustgis_map::LineMarkers { + mark: rustgis_map::LineMark::Marker(Marker::Char(Glyph::new('L', ""))), + size: 20.0, + interval: 40.0, + ..Default::default() + }; + m.layer_mut(line).unwrap().line_strokes = + vec![rustgis_map::LineStroke { color: Some(black), markers: Some(marks), ..Default::default() }]; + m.layer_mut(id).unwrap().visible = false; + let r = render(&mut m, &view).unwrap(); + let drawn = (0..200) + .flat_map(|y| (0..200).map(move |x| (x, y))) + .filter(|(x, y)| r.pixmap.pixel(*x, *y).is_some_and(|p| p.alpha() > 200 && p.red() < 100)) + .count(); + assert!(drawn > 40, "{drawn} px of characters along the line"); + } + #[test] fn markers_and_hash_marks_draw_along_a_line() { use rustgis_map::{LineMark, LineMarkers, LineStroke, Placements}; diff --git a/crates/render/src/line_markers.rs b/crates/render/src/line_markers.rs index 1e33877b4..e2015f5f1 100644 --- a/crates/render/src/line_markers.rs +++ b/crates/render/src/line_markers.rs @@ -6,7 +6,7 @@ use rustgis_map::{LineMark, LineMarkers, Placements, Rgba}; use tiny_skia::{FillRule, LineCap, Path, PathBuilder, Pixmap, Stroke, Transform}; -use crate::{marker_path, paint}; +use crate::{marker_path, marker_reach, paint}; /// The most marks one stroke draws on one feature, so an interval far shorter than the line /// seen whole can't take the time. @@ -228,7 +228,12 @@ pub(crate) fn draw(pm: &mut Pixmap, runs: &[Run], m: &LineMarkers, color: Rgba, let Some(shape) = shape else { return }; let stroke = Stroke { width: (width * ppp).max(0.5), line_cap: LineCap::Butt, ..Stroke::default() }; let offset = f64::from(offset * ppp); - let reach = f64::from(size + width * ppp) + offset.abs() + 2.0; + // A character can reach past its size (a shape stays within it). + let extent = match m.mark { + LineMark::Marker(shape) => 2.0 * marker_reach(shape).max(0.5), + LineMark::Hash => 1.0, + }; + let reach = f64::from(size * extent + width * ppp) + offset.abs() + 2.0; let (w, h) = (f64::from(pm.width()), f64::from(pm.height())); let near = Window { x0: -reach, y0: -reach, x1: w + reach, y1: h + reach }; let mut room = MAX_MARKS; diff --git a/crates/render/src/mesh.rs b/crates/render/src/mesh.rs index ebbc86819..dcd7acf79 100644 --- a/crates/render/src/mesh.rs +++ b/crates/render/src/mesh.rs @@ -11,7 +11,7 @@ use lyon::tessellation::{ }; use rustgis_data::Geometry; use rustgis_geom::{Coord, Extent}; -use rustgis_map::{Layer, Rgba, Symbol}; +use rustgis_map::{Layer, Marker, Rgba, Symbol}; use crate::SELECTION; use crate::gpu_scene::{DrawCmd, MeshVertex, PointInstance}; @@ -270,7 +270,13 @@ pub(crate) fn stroke_lines( /// stroke at r + w/2 under fill at r − w/2 reproduces the exact band (`Ctx::marker`). pub(crate) fn marker_instances(c: Coord, origin: [f64; 2], s: &Symbol, ppp: f32, halo: Option) -> Vec { let r = (s.size.max(1.0) * ppp) / 2.0; - let shape = s.marker as u32; + let shape = match s.marker { + Marker::Circle | Marker::Char(_) => 0, + Marker::Square => 1, + Marker::Triangle => 2, + Marker::Diamond => 3, + Marker::Star => 4, + }; let mut out = Vec::with_capacity(3); if let Some(h) = halo { out.push(PointInstance { position: rel(c, origin), color: premul(h), radius_px: r + 1.75 * ppp, shape }); @@ -322,6 +328,10 @@ fn append_geometry( ) -> Result<(), TessError> { // What the layer's mesh has left: tessellation stops once a part passes it. let room = |v: &Vec| limit.saturating_sub(v.len()); + // A character has no shape template: its layer draws on the CPU. + if matches!(s.marker, Marker::Char(_)) && matches!(g, Geometry::Point(_) | Geometry::MultiPoint(_)) { + return Err(TessError::Refused); + } match g { Geometry::Point(c) => pts.extend(marker_instances(*c, origin, s, ppp, None)), Geometry::MultiPoint(cs) => cs.iter().for_each(|c| pts.extend(marker_instances(*c, origin, s, ppp, None))), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e61291b74..317faad4f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -118,6 +118,23 @@ fn family_font(fam: &crate::families::Family, face: Face) -> Option<(&'static Fo f.map(|f| (f, slanted)) } +/// The label font's regular face. +pub(crate) fn label_font() -> Option<&'static FontRef<'static>> { + font(false) +} + +/// The regular face of the installed family `name` a character marker draws in, and whether that +/// is final: `None` and `false` while the families are still being indexed (see +/// [`crate::families::find_ready`]), `None` and `true` for a family this machine lacks (or none). +pub(crate) fn marker_font(name: &str) -> (Option<&'static FontRef<'static>>, bool) { + if name.trim().is_empty() { + return (None, true); + } + let Some(families) = crate::families::available() else { return (None, false) }; + let named = crate::families::find_ready(name).and_then(|i| families.get(usize::from(i))); + (named.and_then(|fam| family_font(fam, Face::default())).map(|(f, _)| f), true) +} + /// The label font of `face`'s weight, for the characters `f` lacks: `None` when `f` is it. fn sans_beside(f: &'static FontRef<'static>, face: Face) -> Option<&'static FontRef<'static>> { font(face.bold).filter(|s| !std::ptr::eq(*s, f)) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 5c10402f2..995a66975 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -567,28 +567,46 @@ const MAX_FONTS_SHOWN: usize = 200; /// project made on another, is shown as missing and drawn in the label font. fn font(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { let input_label = ui.label(tl!("Font")); - // The installed fonts are indexed on a thread of their own; until then there's none to pick. + let search_id = layer_key(app, l).with("label_pane_font_search"); + let picked = family_picker(ui, "label_pane_font", search_id, c.it.font.as_deref()); + picked.response.labelled_by(input_label.id); + if let Some(name) = picked.chosen { + run(app, "labels.style", c.params(json!({"font": name.unwrap_or_default()}))); + } +} + +/// What [`family_picker`] showed, and what was picked: `Some(None)` the label font, `Some(Some(name))` +/// an installed family. +pub(crate) struct Picked { + pub response: egui::Response, + pub chosen: Option>, +} + +/// A list of the installed families to pick from (found by typing part of the name, kept under +/// `search_id`), the label font first; `cur` the one in use (none: the label font). A family this +/// machine lacks, from a project made on another, is shown as missing. Until the installed fonts +/// are indexed (on a thread of their own), a disabled button says so. +pub(crate) fn family_picker(ui: &mut Ui, salt: impl std::hash::Hash + std::fmt::Debug, search_id: egui::Id, cur: Option<&str>) -> Picked { let Some(installed) = families::installed_now() else { families::warm_up(); - ui.add_enabled(false, egui::Button::new(tl!("Reading the installed fonts…"))).labelled_by(input_label.id); + let response = ui.add_enabled(false, egui::Button::new(tl!("Reading the installed fonts…"))); ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); - return; + return Picked { response, chosen: None }; }; - let cur = c.it.font.as_deref(); let shown = match cur { None => tl!("Label font").to_string(), Some(name) if families::find(name).is_some() => name.to_string(), Some(name) => crate::i18n::fmt("{name} (not installed)", &[("name", name)]), }; - let search_id = layer_key(app, l).with("label_pane_font_search"); - egui::ComboBox::from_id_salt("label_pane_font") + let mut chosen = None; + let response = egui::ComboBox::from_id_salt(salt) .selected_text(shown) .width(180.0) .height(320.0) .show_ui(ui, |ui| { let search = list_search(ui, search_id, tl!("Search fonts")); if ui.selectable_label(cur.is_none(), tl!("Label font")).clicked() { - run(app, "labels.style", c.params(json!({"font": ""}))); + chosen = Some(None); end_search(ui, search_id); } let mut matching = installed.iter().filter(|f| f.listed && (search.is_empty() || f.name.to_lowercase().contains(&search))); @@ -596,7 +614,7 @@ fn font(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { let on = cur.is_some_and(|c| c.eq_ignore_ascii_case(&f.name)); if ui.selectable_label(on, &f.name).clicked() { if !on { - run(app, "labels.style", c.params(json!({"font": f.name}))); + chosen = Some(Some(f.name.clone())); } end_search(ui, search_id); } @@ -606,8 +624,8 @@ fn font(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { ui.weak(crate::i18n::fmt("{count} more: type part of a name", &[("count", &more.to_string())])); } }) - .response - .labelled_by(input_label.id); + .response; + Picked { response, chosen } } fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 6d54e74ba..b5d3de326 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -14,7 +14,7 @@ use rustgis_engine::map::symbology::{ }; use rustgis_engine::map::tiles::{TileKind, TileLayer}; use rustgis_engine::map::{ - ClassMethod, Layer, Marker, Ramp, RampGroup, RasterDefaults, RasterRenderer, Renderer, Resampling, Stretch, Symbol, + ClassMethod, Glyph, Layer, Marker, Ramp, RampGroup, RasterDefaults, RasterRenderer, Renderer, Resampling, Stretch, Symbol, }; use serde_json::{Value, json}; @@ -453,6 +453,15 @@ fn left_header(app: &mut RustGisApp, ui: &mut Ui, left: LeftPane, t: &Tokens) { // --------------------------------------------------------------------------------------------- // Contents +/// Paints a character marker's character centred on `at`, `px` tall, in its installed family (the +/// interface font until egui has it, or where the machine lacks it). +fn glyph(p: &egui::Painter, at: egui::Pos2, g: Glyph, px: f32, c: egui::Color32) { + use rustgis_engine::render::families; + let font = families::find_ready(g.font.as_str()); + let family = crate::labelfonts::family(p.ctx(), font, false, false).map_or(egui::FontFamily::Proportional, |(f, _)| f); + p.text(at, Align2::CENTER_CENTER, g.ch, egui::FontId::new(px, family), c); +} + /// Paints a symbol patch for a layer of geometry type `g`. pub fn patch(p: &egui::Painter, r: Rect, s: &Symbol, g: GeometryType) { match g { @@ -483,6 +492,7 @@ pub fn patch(p: &egui::Painter, r: Rect, s: &Symbol, g: GeometryType) { Stroke::new(s.stroke_width.min(2.0), c32(s.stroke)), )); } + Marker::Char(g) => glyph(p, c, g, (s.size * 1.4).clamp(8.0, 18.0), c32(s.fill)), _ => { p.circle_filled(c, rad, c32(s.fill)); p.circle_stroke(c, rad, Stroke::new(s.stroke_width.min(2.0), c32(s.stroke))); @@ -4159,10 +4169,19 @@ fn symbol_editor(app: &mut RustGisApp, ui: &mut Ui, s: Symbol, g: GeometryType, let _ = app.run("layer.marker", json!({"shape": m.label()})); } } + let on = matches!(s.marker, Marker::Char(_)); + if ui.selectable_label(on, tl!("Character")).clicked() && !on { + let _ = app.run("layer.marker", json!({"char": DEFAULT_CHAR.to_string()})); + } }) .response .labelled_by(input_label.id); ui.end_row(); + if let Marker::Char(g) = s.marker + && let Some((ch, font)) = char_rows(ui, "marker_char", g) + { + let _ = app.run("layer.marker", json!({"char": ch.to_string(), "font": font.unwrap_or_default()})); + } } }); }); @@ -4227,11 +4246,17 @@ fn stroke_json(st: &LineStroke) -> Value { /// A stroke's markers as `layer.lineStrokes` takes them. fn markers_json(m: &LineMarkers) -> Value { + let glyph = match m.mark { + LineMark::Marker(Marker::Char(g)) => Some(g), + _ => None, + }; json!({ "mark": match m.mark { LineMark::Hash => "hash".to_string(), LineMark::Marker(shape) => shape.label().to_ascii_lowercase(), }, + "char": glyph.map(|g| g.ch.to_string()), + "font": glyph.map(|g| g.font.as_str()), "size": m.size, "at": m.at.codes(), "interval": m.interval, @@ -4243,6 +4268,35 @@ fn markers_json(m: &LineMarkers) -> Value { }) } +/// The character a marker switched to a character starts as (one the label font has). +const DEFAULT_CHAR: char = 'A'; + +/// A character marker's rows (`g`): its character, typed, and the installed family it is drawn +/// from. The character and family (none: the label font) when either changes. +fn char_rows(ui: &mut Ui, salt: impl std::hash::Hash + std::fmt::Debug + Copy, g: Glyph) -> Option<(char, Option)> { + let font = (!g.font.is_default()).then(|| g.font.as_str().to_string()); + let mut out = None; + let input_label = ui.label(tl!("Character")); + let mut text = g.ch.to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(48.0).char_limit(2)).labelled_by(input_label.id); + // The character typed: the last one, so typing over the old one replaces it. + if r.changed() + && let Some(ch) = text.chars().rfind(|c| *c != g.ch).or_else(|| text.chars().next()) + && ch != g.ch + { + out = Some((ch, font.clone())); + } + ui.end_row(); + let input_label = ui.label(tl!("Font")); + let picked = crate::labelpane::family_picker(ui, salt, egui::Id::unique((salt, "search")), font.as_deref()); + picked.response.labelled_by(input_label.id); + if let Some(f) = picked.chosen { + out = Some((g.ch, f)); + } + ui.end_row(); + out +} + /// The places along a line the stroke editor offers for markers. fn marker_places() -> [(&'static str, Placements); 7] { [ @@ -4326,6 +4380,7 @@ fn marker_preview(ui: &Ui, m: &LineMarkers, x: (f32, f32), y: f32, k: f32, c: eg LineMark::Marker(Marker::Square) => { ui.painter().rect_filled(Rect::from_center_size(pos2(at, y), vec2(half * 1.8, half * 1.8)), CornerRadius::ZERO, c); } + LineMark::Marker(Marker::Char(g)) => glyph(ui.painter(), pos2(at, y), g, (2.0 * half).max(6.0), c), LineMark::Marker(_) => { ui.painter().circle_filled(pos2(at, y), half, c); } @@ -4347,10 +4402,20 @@ fn marker_rows(app: &mut RustGisApp, ui: &mut Ui, i: usize, st: LineStroke, m: L set(app, with(LineMarkers { mark: LineMark::Marker(s), ..m })); } } + let on = matches!(shape, Marker::Char(_)); + if ui.selectable_label(on, tl!("Character")).clicked() && !on { + set(app, with(LineMarkers { mark: LineMark::Marker(Marker::Char(Glyph::new(DEFAULT_CHAR, ""))), ..m })); + } }) .response .labelled_by(input_label.id); ui.end_row(); + if let Marker::Char(g) = shape + && let Some((ch, font)) = char_rows(ui, ("stroke_marker_char", i), g) + { + let mark = LineMark::Marker(Marker::Char(Glyph::new(ch, font.as_deref().unwrap_or_default()))); + set(app, with(LineMarkers { mark, ..m })); + } } let input_label = ui.label(if m.mark == LineMark::Hash { tl!("Length") } else { tl!("Size") }); let mut size = m.size; diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index d8f1bf13b..8afa182ef 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -33,6 +33,17 @@ Select a layer in Layers and open **Style › Symbology** (or the Symbology pane first. Closing the pane or selecting another layer drops the retry. - **Proportional Symbols**: point size grows with a numeric field, chosen as for Graduated Colors. +A point's **Shape** is a circle, square, triangle, diamond or star, or a **Character**: any +character of an installed font, picked under **Font**, as map signs often are (a symbol font's +church, tree or mine). Its **Size** is the font's size, and the character sits as QGIS draws a +font marker: centred across the point, its baseline half the font's ascent below it. A font the +machine lacks draws the character in the label font, and a character no font here has +draws a circle, so the point still shows. By command: + +```text +layer.marker={"layer":"Peaks","char":"▲","font":"DejaVu Sans","size":12} +``` + Pick a colour scheme from the pane's swatches, listed in groups: **Perceptually Uniform** (Viridis, Plasma, Inferno, Magma, Cividis, Batlow and Aurora: even steps in lightness, so they read in greyscale and for colour-blind viewers), **Sequential**, **Diverging** (Red–Blue, Vik, Roma, @@ -166,7 +177,7 @@ layer.strokeLevels={"layer":"Roads","on":true} A stroke can draw markers or hash marks along the line instead of a line, as railways, boundaries and directions of flow are drawn: its **Draw** menu picks **Line**, **Markers** or **Hash marks**. -Markers take a **Shape** and a **Size**; hash marks a **Length**, crossing the line square at an +Markers take a **Shape** (a character of a font too, as for points) and a **Size**; hash marks a **Length**, crossing the line square at an **Angle** of 0°. **Place** puts them at intervals along the line (**Interval** apart, counted from its start), at its vertices or its inner ones, at either end or both, or in its middle. **With the line** turns each mark with the line's direction; the **Angle** turns it further, clockwise. Marks @@ -177,6 +188,7 @@ command, `"markers"` on a stroke: ```text layer.lineStrokes={"layer":"Railways","strokes":[{"color":"#333333","width":1.5},{"color":"#333333","width":1,"markers":{"mark":"hash","size":6,"interval":10}}]} layer.lineStrokes={"layer":"Rivers","strokes":[{"width":1},{"markers":{"mark":"triangle","size":7,"at":"middle"}}]} +layer.lineStrokes={"layer":"Trails","strokes":[{"markers":{"mark":"character","char":"»","font":"DejaVu Sans","size":8,"interval":16}}]} ``` `"at"` takes `"interval"`, `"vertices"`, `"innerVertices"`, `"first"`, `"last"`, `"middle"` and diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index e3d136aa2..8172f2f7e 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -41,8 +41,8 @@ Each layer keeps its look: caps, joins, dashes and the symbol's opacity; a line's simple lines as stacked strokes, its rendering passes as their levels when the renderer draws in symbol levels; marker and hashed lines as strokes of markers or hash marks, at their places (intervals, vertices, the ends, the - middle), turned with the line, their markers' offsets and angles kept (font characters along - lines are left out for now: drawn as dots, they would blot the line where QGIS draws a tick). + middle), turned with the line, their markers' offsets and angles kept; font markers as + characters of their font, on points and along lines. - **Layer settings**: opacity, blending, visibility range, filter (subset string), and the reference scale. - **Rasters**: singleband grey as a stretch from black to white, singleband pseudocolour as a @@ -63,11 +63,10 @@ older names of the same settings are read. QGIS expressions carry over where they are SQL: fields compared with values, `LIKE`, `IN`, `IS NULL`, joined by `AND`, `OR` and `NOT`. What RustGIS doesn't draw as QGIS does is drawn as near as it can, or left out, and said: the import's notes list, layer by layer, what was -approximated (pattern fills drawn as a tint of their colour, font and SVG markers as circles, +approximated (pattern fills drawn as a tint of their colour, SVG markers as circles, dashed polygon outlines solid, label halos in RustGIS's own colour and width, label offsets and wrapping, values outside a graduated renderer's ranges in its first or last class, file options -such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, font markers -along lines, data-defined +such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, data-defined settings, draw effects, label callouts, labels from expressions, contours and other raster renderers RustGIS has no match for, PostGIS, WFS and other sources RustGIS doesn't read, diagrams, joins, virtual fields, a layer's other styles, From 74c781f03e42fd5377c6df7444ae7fc3e8a3259c Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 08:39:29 +0100 Subject: [PATCH 2/2] fix(map): font markers read QGIS's default and escaped characters; tilted views keep characters on the CPU - A FontMarker without chr, or with an empty one, draws QGIS's A; its escaped characters (~!_#!#_!~code~!_#!#_!~) are decoded. - A tilted view with character markers standing up stays on the CPU: the GPU drew them as circles. - A stroke's markers refuse a shape with a char, as layer.marker does; the mark error names "character". - Choosing the label font again runs no command. - A legend patch shows an outline-only character in its outline's colour. --- crates/engine/src/cmd/layer.rs | 6 +++++- crates/engine/src/tests.rs | 4 ++++ crates/map/src/qgis/symbol.rs | 33 +++++++++++++++++++++++++++++---- crates/map/src/qgis/tests.rs | 19 +++++++++++++++++++ crates/map/src/symbology.rs | 10 ++++++++++ crates/render/src/lib.rs | 19 ++++++++++++++++++- crates/ui-egui/src/labelpane.rs | 4 +++- crates/ui-egui/src/panes.rs | 6 +++++- 8 files changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 20e31b39c..17cde498b 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -520,11 +520,15 @@ fn line_markers(v: &Value) -> Result { Some(Value::String(m)) => LineMark::Marker( Marker::ALL.into_iter().find(|s| s.label().eq_ignore_ascii_case(m.trim())).ok_or_else(|| format!("unknown mark '{m}'"))?, ), - Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\" or \"hash\"".into()), + Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\", \"character\" or \"hash\"".into()), }; // A character of a font instead of a shape. let mark = match (character(v)?, o.get("font")) { (Some(_), _) if mark == LineMark::Hash => return Err("hash marks take no char".into()), + // As `layer.marker`: a shape named with a char is a mistake, not the char. + (Some(_), _) if o.get("mark").and_then(Value::as_str).is_some_and(|m| !m.trim().eq_ignore_ascii_case("character")) => { + return Err("give a shape or a char, not both".into()); + } (Some(c), font) => LineMark::Marker(Marker::Char(Glyph::new(c, font.and_then(Value::as_str).unwrap_or_default()))), (None, _) => mark, }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2cfc46dd7..86a107c7e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -509,6 +509,10 @@ fn a_character_of_a_font_is_a_marker_on_points_and_along_lines() { assert!(err.contains("hash marks take no char"), "{err}"); let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "character"}}]})).unwrap_err(); assert!(err.contains("needs its char"), "{err}"); + // A shape with a char is refused, as by `layer.marker`. + let err = + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "circle", "char": "x"}}]})).unwrap_err(); + assert!(err.contains("not both"), "{err}"); assert_eq!(s.undo_depth(), depth); } diff --git a/crates/map/src/qgis/symbol.rs b/crates/map/src/qgis/symbol.rs index c5a2096f4..952c8c692 100644 --- a/crates/map/src/qgis/symbol.rs +++ b/crates/map/src/qgis/symbol.rs @@ -505,10 +505,11 @@ fn marker(layers: &[(Node, Props)], reference: Option, layer: &str, report: Converted { symbol, strokes: Vec::new() } } -/// A font marker's character (`chr`, of the family `font`) as a character marker; `None` for one -/// with none (it draws nothing). A string of several draws its first (noted). -fn font_char(p: &[(String, String)], layer: &str, report: &mut Report) -> Option { - let text = prop(p, "chr").unwrap_or_default(); +/// A font marker's character (`chr`, of the family `font`) as a character marker: QGIS's `A` when +/// it has none, and the characters it saves escaped ([`unescape_chr`]) decoded. A +/// string of several draws its first (noted). +pub(super) fn font_char(p: &[(String, String)], layer: &str, report: &mut Report) -> Option { + let text = unescape_chr(prop(p, "chr").filter(|c| !c.is_empty()).unwrap_or("A")); let mut chars = text.chars(); let ch = chars.next()?; if chars.next().is_some() { @@ -520,6 +521,30 @@ fn font_char(p: &[(String, String)], layer: &str, report: &mut Report) -> Option Some(Marker::Char(Glyph::new(ch, prop(p, "font").unwrap_or_default()))) } +/// `chr` as QGIS reads it: it saves some characters as their code between two `~!_#!#_!~`. +fn unescape_chr(chr: &str) -> String { + const FIX: &str = "~!_#!#_!~"; + let (mut out, mut rest) = (String::new(), chr); + while let Some(i) = rest.find(FIX) { + let after = &rest[i + FIX.len()..]; + let digits = after.len() - after.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + let code = after[..digits].parse::().ok().and_then(|n| char::from_u32(n.into())); + match code { + Some(c) if after[digits..].starts_with(FIX) => { + out.push_str(&rest[..i]); + out.push(c); + rest = &after[digits + FIX.len()..]; + } + _ => { + out.push_str(&rest[..i + FIX.len()]); + rest = after; + } + } + } + out.push_str(rest); + out +} + /// Whether a marker is anchored off its centre (`horizontal_anchor_point`, /// `vertical_anchor_point`: 1 is the centre). fn anchored(p: &[(String, String)]) -> bool { diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index 7b65bb41c..5e296de95 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -647,3 +647,22 @@ fn marker_and_hashed_lines_carry_over_as_strokes_of_marks() { let said = |kind: NoteKind, part: &str| report.notes.iter().any(|n| n.kind == kind && n.text.contains(part)); assert!(said(NoteKind::Approximated, "curve points") && said(NoteKind::Skipped, "\"somethingnew\""), "{:?}", report.notes); } + +#[test] +fn a_font_marker_without_its_character_draws_qgiss_a_and_escaped_ones_are_decoded() { + let props = |chr: Option<&str>| -> Vec<(String, String)> { + chr.map(|c| vec![("chr".to_string(), c.to_string()), ("font".to_string(), "DKM_Symbole".to_string())]).unwrap_or_default() + }; + let char_of = |chr: Option<&str>| match symbol::font_char(&props(chr), "Signs", &mut Report::default()) { + Some(Marker::Char(g)) => g.ch, + other => panic!("{other:?}"), + }; + // QGIS draws its default `A` for a font marker with no character or an empty one. + assert_eq!((char_of(None), char_of(Some(""))), ('A', 'A')); + // It saves some characters as their code between two `~!_#!#_!~`. + assert_eq!(char_of(Some("~!_#!#_!~1~!_#!#_!~")), '\u{1}'); + assert_eq!(char_of(Some("~!_#!#_!~10~!_#!#_!~x")), '\n'); + // A broken escape is read as it is. + assert_eq!((char_of(Some("~!_#!#_!~1")), char_of(Some("~!_#!#_!~x~!_#!#_!~"))), ('~', '~')); + assert_eq!(char_of(Some("@")), '@'); +} diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index a290146f2..16b306847 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1297,6 +1297,16 @@ impl Renderer { Renderer::Proportional { base, .. } => f(base), } } + + /// Whether any symbol is a character of a font ([`Marker::Char`]). + pub fn has_char_markers(&self) -> bool { + let char = |s: &Symbol| matches!(s.marker, Marker::Char(_)); + match self { + Renderer::Single(s) | Renderer::Proportional { base: s, .. } => char(s), + Renderer::Unique { classes, other, .. } => char(other) || classes.iter().any(|c| char(&c.1)), + Renderer::Graduated { symbols, .. } => symbols.iter().any(char), + } + } } /// `new` with the hand-made parts of `old`, a class whose scheme colour was `scheme`: the fill diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7f69a3cd7..09cdcbfa6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1996,7 +1996,7 @@ fn place_tilted_labels(map: &Map, view: &View, mut candidates: Vec, g /// Whether a turned or tilted `view` of `map` may be drawn on the GPU ([`gpu_turned`]): only /// what the GPU draws itself shows (no tiles, rasters, see-through, masked, swiped or blended /// layers, aggregations, graphics or stacked line strokes; no view effects; extrusions only -/// stand up in a tilted one). A cheap check made before any drawing; labels with icons can still +/// stand up in a tilted one; nor characters standing up, which the GPU has no template for). A cheap check made before any drawing; labels with icons can still /// decline the scene once it is drawn. pub fn gpu_may_turn(map: &Map, view: &View) -> bool { if map.projection.is_globe() @@ -2027,6 +2027,7 @@ pub fn gpu_may_turn(map: &Map, view: &View) -> bool { && !l.graphics && !l.has_strokes() && (l.extrusion.is_none() || view.camera.pitch != 0.0) + && !(view.camera.pitch != 0.0 && l.renderer.has_char_markers() && stands_up(map, l)) }) } @@ -5390,6 +5391,22 @@ mod tests { m } + #[test] + fn a_tilted_view_of_characters_standing_up_stays_on_the_cpu() { + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.6)), res: 600.0, bearing: 0.0, pitch: 65.0 }; + let view = View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera, sky: false }; + let mut map = hill_map(); + assert!(gpu_may_turn(&map, &view)); + for l in &mut map.layers { + l.renderer.map_symbols(|s| s.marker = Marker::Char(rustgis_map::Glyph::new('L', ""))); + } + // The GPU has no template for a character: standing up, it would be a circle. + assert!(!gpu_may_turn(&map, &view)); + // Turned, not tilted, the map is drawn flat, where the layer is a CPU picture. + let turned = View { camera: Camera { bearing: 30.0, pitch: 0.0, ..camera }, ..view }; + assert!(gpu_may_turn(&map, &turned)); + } + #[test] fn a_tilted_view_drapes_the_map_over_its_terrain() { let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.6)), res: 600.0, bearing: 0.0, pitch: 65.0 }; diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 995a66975..1aba813da 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -606,7 +606,9 @@ pub(crate) fn family_picker(ui: &mut Ui, salt: impl std::hash::Hash + std::fmt:: .show_ui(ui, |ui| { let search = list_search(ui, search_id, tl!("Search fonts")); if ui.selectable_label(cur.is_none(), tl!("Label font")).clicked() { - chosen = Some(None); + if cur.is_some() { + chosen = Some(None); + } end_search(ui, search_id); } let mut matching = installed.iter().filter(|f| f.listed && (search.is_empty() || f.name.to_lowercase().contains(&search))); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index b5d3de326..2f4d162ef 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -492,7 +492,11 @@ pub fn patch(p: &egui::Painter, r: Rect, s: &Symbol, g: GeometryType) { Stroke::new(s.stroke_width.min(2.0), c32(s.stroke)), )); } - Marker::Char(g) => glyph(p, c, g, (s.size * 1.4).clamp(8.0, 18.0), c32(s.fill)), + // An outline-only character shows in its outline's colour. + Marker::Char(g) => { + let col = if s.fill.a() == 0 && s.stroke_width > 0.0 { s.stroke } else { s.fill }; + glyph(p, c, g, (s.size * 1.4).clamp(8.0, 18.0), c32(col)); + } _ => { p.circle_filled(c, rad, c32(s.fill)); p.circle_stroke(c, rad, Stroke::new(s.stroke_width.min(2.0), c32(s.stroke)));