diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 271c61e97..20684ee6b 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, LineStroke, Marker, Ramp, Renderer, Rgba}; +use rustgis_map::{ClassMethod, 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)}] (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"|"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' )}"##, ), C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| { let on = b(p, "on").ok_or("on is true or false")?; @@ -451,7 +451,13 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { } Some(_) => return Err(bad_dash()), }; - strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level, dash }.clamped()); + let markers = match o.get("markers") { + None | Some(Value::Null) => None, + Some(m) => Some(line_markers(m).map_err(|e| format!("stroke {}: markers: {e}", i + 1))?), + }; + strokes.push( + LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level, dash, markers }.clamped(), + ); } let id = s.layer_id(p)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; @@ -466,6 +472,62 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"strokes": n})) } +/// 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}")?; + let num = |k: &str| -> Result, String> { + match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(-1e6, 1e6) as f32)).ok_or_else(|| format!("{k} is a number")) + } + } + }; + let d = LineMarkers::default(); + 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, + 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()), + }; + 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, + Some(Value::String(c)) => Placements::from_code(c).ok_or_else(bad_at)?, + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 16 => a + .iter() + .map(|c| c.as_str().and_then(Placements::from_code).ok_or_else(bad_at)) + .collect::, _>>()? + .into_iter() + .fold(Placements::NONE, Placements::with), + Some(_) => return Err(bad_at()), + }; + let rotate = match o.get("rotate") { + None | Some(Value::Null) => d.rotate, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("rotate is true or false".into()), + }; + let outline = match o.get("outline") { + None | Some(Value::Null) => None, + Some(Value::String(c)) => Some(Rgba::from_hex(c).ok_or("outline must be #RRGGBB or #RRGGBBAA")?), + Some(_) => return Err("outline must be #RRGGBB or #RRGGBBAA".into()), + }; + Ok(LineMarkers { + mark, + size: num("size")?.unwrap_or(d.size), + at, + interval: num("interval")?.unwrap_or(d.interval), + offset_along: num("offsetAlong")?.unwrap_or(d.offset_along), + rotate, + average_angle: num("averageAngle")?.unwrap_or(d.average_angle), + angle: num("angle")?.unwrap_or(d.angle), + outline, + } + .clamped()) +} + pub(crate) fn s_req<'a>(p: &'a Value, k: &str) -> Result<&'a str, String> { ps(p, k).ok_or_else(|| format!("{k} is required")) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fedf9c715..9be9ce8e4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -360,6 +360,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { join: LineJoin::Round, level: None, dash: None, + markers: None, }, LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), @@ -369,6 +370,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { join: LineJoin::Miter, level: None, dash: None, + markers: None, }, LineStroke { offset: -2.0, ..LineStroke::default() }, ]; @@ -415,6 +417,59 @@ fn line_strokes_stack_and_survive_save_and_undo() { assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1); } +#[test] +fn line_strokes_take_markers_and_hash_marks_along_the_line() { + use rustgis_map::{LineMark, LineMarkers, Marker, Placements, Rgba}; + let mut s = world(); + // A river: its line, triangles at both ends turned with it, and ticks across it. + let strokes = json!([ + {"color": "#1E5AA8", "width": 2}, + {"color": "#1E5AA8", "markers": {"mark": "Triangle", "size": 8, "at": ["first", "last"], "outline": "#FFFFFF"}, "width": 1}, + {"color": "#1E5AA8", "width": 1, "markers": {"mark": "hash", "size": 6, "interval": 10, "angle": 30, "rotate": false}} + ]); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); + let st = s.map().layer_by_name("Rivers").unwrap().line_strokes.clone(); + assert_eq!(st[0].markers, None); + let ends = LineMarkers { + mark: LineMark::Marker(Marker::Triangle), + size: 8.0, + at: Placements::FIRST.with(Placements::LAST), + outline: Some(Rgba::rgb(255, 255, 255)), + ..LineMarkers::default() + }; + assert_eq!(st[1].markers, Some(ends)); + let ticks = LineMarkers { mark: LineMark::Hash, size: 6.0, interval: 10.0, angle: 30.0, rotate: false, ..LineMarkers::default() }; + assert_eq!(st[2].markers, Some(ticks)); + // A single place is a list of one; out-of-range numbers are brought into range. + let one = json!([{"markers": {"at": "middle", "size": 1e9, "interval": 0, "angle": 450}}]); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": one})).unwrap(); + let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap(); + assert_eq!((m.at, m.size, m.interval, m.angle), (Placements::MIDDLE, 100.0, 0.5, 90.0)); + // Drawn in an export. + let dir = std::env::temp_dir().join(format!("rustgis-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); + 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(); + // Junk is refused, saying where, and changes nothing. + let depth = s.undo_depth(); + for (bad, says) in [ + (json!({"markers": 5}), "stroke 1: markers: an object"), + (json!({"markers": {"mark": "hexagon"}}), "unknown mark 'hexagon'"), + (json!({"markers": {"mark": 3}}), "mark is"), + (json!({"markers": {"at": "sideways"}}), "at is a placement"), + (json!({"markers": {"at": []}}), "at is a placement"), + (json!({"markers": {"at": ["first", 2]}}), "at is a placement"), + (json!({"markers": {"rotate": "yes"}}), "rotate is true or false"), + (json!({"markers": {"size": "big"}}), "size is a number"), + (json!({"markers": {"outline": "white"}}), "outline must be"), + ] { + let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [bad]})).unwrap_err(); + assert!(err.contains(says), "{bad}: {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 cd6ecffe5..df07f9aff 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -31,7 +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, LineStroke, Marker, Renderer, Symbol, + ClassMethod, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, + Marker, Placements, Renderer, Symbol, }; pub use tiles::{TileKind, TileLayer}; @@ -1783,6 +1784,56 @@ mod tests { } } + #[test] + fn markers_along_lines_are_kept_and_hostile_ones_brought_into_range() { + use symbology::{LineMark, LineMarkers, MAX_LINE_MARKER_SIZE, MAX_MARKER_INTERVAL, MIN_MARKER_INTERVAL, Placements}; + let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); + let m = LineMarkers { + mark: LineMark::Marker(Marker::Triangle), + size: 6.0, + at: Placements::FIRST.with(Placements::LAST), + interval: 20.0, + offset_along: -2.0, + rotate: false, + average_angle: 15.0, + angle: 45.0, + outline: Some(Rgba::rgb(0, 0, 0)), + }; + l.line_strokes = vec![ + symbology::LineStroke { markers: Some(m), ..Default::default() }, + symbology::LineStroke { markers: Some(LineMarkers { mark: LineMark::Hash, ..LineMarkers::default() }), ..Default::default() }, + ]; + // Saved with the places as codes, and read back as they were. + let text = serde_json::to_string(&l).unwrap(); + assert!(text.contains(r#""at":["first","last"]"#) && text.contains(r#""mark":"hash""#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap().line_strokes, l.line_strokes); + // A stroke without markers is written as before. + let plain = serde_json::to_string(&symbology::LineStroke::default()).unwrap(); + assert!(!plain.contains("markers"), "{plain}"); + + // A crafted project's: sizes and intervals clamped, angles within a turn, places it doesn't + // know left out (none at all: every interval). + let mut v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let read = |v: &serde_json::Value| serde_json::from_value::(v.clone()).unwrap().line_strokes[0].markers.unwrap(); + let mk = &mut v["line_strokes"][0]["markers"]; + mk["size"] = serde_json::json!(1e30); + mk["interval"] = serde_json::json!(0); + mk["offset_along"] = serde_json::json!(-1e40); + mk["average_angle"] = serde_json::json!(-3); + mk["angle"] = serde_json::json!(-90); + mk["at"] = serde_json::json!(["last", "sideways"]); + let got = read(&v); + assert_eq!((got.size, got.interval, got.offset_along), (MAX_LINE_MARKER_SIZE, MIN_MARKER_INTERVAL, -MAX_MARKER_INTERVAL)); + assert_eq!((got.average_angle, got.angle, got.at), (0.0, 270.0, Placements::LAST)); + v["line_strokes"][0]["markers"]["at"] = serde_json::json!(["sideways"]); + assert_eq!(read(&v).at, Placements::INTERVAL); + v["line_strokes"][0]["markers"]["at"] = serde_json::json!(vec!["first"; 5000]); + assert_eq!(read(&v).at, Placements::FIRST); + // Only what's given: the rest is the default. + v["line_strokes"][0]["markers"] = serde_json::json!({}); + assert_eq!(read(&v), LineMarkers::default()); + } + #[test] fn a_reference_scale_scales_sizes_with_the_map_and_hostile_ones_are_dropped() { let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index 3646e952d..b6b48f2a3 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -573,7 +573,18 @@ pub(crate) fn color(s: &str) -> Option { /// no fixed size on screen: they are taken at the layer's reference scale when it has one /// (`reference`, RustGIS's), else as metres at 1:25,000, and noted. fn points(v: f64, unit: Option<&str>, reference: Option, layer: &str, report: &mut Report) -> f32 { - let pt = match unit.unwrap_or("MM") { + let pt = in_points(v, unit, reference, layer, report); + if pt.is_finite() { pt.clamp(0.0, 1000.0) as f32 } else { 0.0 } +} + +/// [`points`] for a distance that may be negative, such as an offset to the left. +fn signed_points(v: f64, unit: Option<&str>, reference: Option, layer: &str, report: &mut Report) -> f32 { + let pt = in_points(v, unit, reference, layer, report); + if pt.is_finite() { pt.clamp(-1000.0, 1000.0) as f32 } else { 0.0 } +} + +fn in_points(v: f64, unit: Option<&str>, reference: Option, layer: &str, report: &mut Report) -> f64 { + match unit.unwrap_or("MM") { "MM" => v * 96.0 / 25.4, "Point" => v * 96.0 / 72.0, "Pixel" => v, @@ -586,6 +597,5 @@ fn points(v: f64, unit: Option<&str>, reference: Option, layer: &str, repor report.approximated(layer, format!("sizes in {other}, taken as millimetres")); v * 96.0 / 25.4 } - }; - if pt.is_finite() { pt.clamp(0.0, 1000.0) as f32 } else { 0.0 } + } } diff --git a/crates/map/src/qgis/symbol.rs b/crates/map/src/qgis/symbol.rs index 333d5d26d..b8b7eb6d0 100644 --- a/crates/map/src/qgis/symbol.rs +++ b/crates/map/src/qgis/symbol.rs @@ -5,9 +5,11 @@ use roxmltree::Node; -use super::{Report, color, data_defined_on, has_effect, points, prop, props}; +use super::{Report, color, data_defined_on, has_effect, points, prop, props, signed_points}; use crate::Rgba; -use crate::symbology::{Dash, LineCap, LineJoin, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Symbol}; +use crate::symbology::{ + Dash, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Placements, Symbol, +}; /// A symbol layer's settings by name. type Props = Vec<(String, String)>; @@ -26,9 +28,9 @@ pub(super) struct Converted { impl Converted { /// Whether the strokes say more than the symbol's single line does: more than one, or one - /// shifted sideways or dashed. + /// shifted sideways, dashed or of markers. pub fn stacked(&self) -> bool { - self.strokes.len() > 1 || self.strokes.iter().any(|s| s.offset != 0.0 || s.dash.is_some()) + self.strokes.len() > 1 || self.strokes.iter().any(|s| s.offset != 0.0 || s.dash.is_some() || s.markers.is_some()) } } @@ -67,6 +69,9 @@ pub(super) fn read(sym: Node, reference: Option, layer: &str, report: &mut out.symbol.stroke = fade(out.symbol.stroke); for s in &mut out.strokes { s.color = s.color.map(fade); + if let Some(m) = &mut s.markers { + m.outline = m.outline.map(fade); + } } out } @@ -108,6 +113,13 @@ fn size(p: &[(String, String)], k: &str, reference: Option, layer: &str, re Some(points(v, unit, reference, layer, report)) } +/// An offset setting `k` (with its `k_unit`) of a symbol layer, in points: negative to the left. +fn offset(p: &[(String, String)], k: &str, reference: Option, layer: &str, report: &mut Report) -> Option { + let v = prop(p, k)?.trim().parse::().ok().filter(|v| v.is_finite())?; + let unit = prop(p, &format!("{k}_unit")).or(prop(p, SYMBOL_UNIT)); + Some(signed_points(v, unit, reference, layer, report)) +} + /// Whether a line style (`line_style`, `outline_style`) is dashed or dotted. fn dashed(style: Option<&str>) -> bool { !matches!(style, None | Some("solid" | "no")) @@ -226,7 +238,7 @@ fn line(layers: &[(Node, Props)], reference: Option, layer: &str, report: & color: Some(c), width: Some(width), // Positive to the right of the line's direction on the screen, in both. - offset: size(p, "offset", reference, layer, report).unwrap_or(0.0), + offset: offset(p, "offset", reference, layer, report).unwrap_or(0.0), cap: match prop(p, "capstyle") { Some("flat") => LineCap::Butt, Some("square") => LineCap::Square, @@ -239,11 +251,12 @@ fn line(layers: &[(Node, Props)], reference: Option, layer: &str, report: & }, level: pass, dash: dash(p, width, reference, layer, report), + markers: None, } .clamped(), ); } - "MarkerLine" | "HashLine" => report.skipped(layer, "markers and hash marks along lines"), + class @ ("MarkerLine" | "HashLine") => strokes.extend(along(*l, p, class == "HashLine", reference, layer, report)), "ArrowLine" => report.skipped(layer, "arrow lines"), other => report.skipped(layer, format!("{other} line symbol layers")), } @@ -264,6 +277,188 @@ fn line(layers: &[(Node, Props)], reference: Option, layer: &str, report: & Converted { symbol, strokes } } +/// A marker line (`hash` false) or hashed line as strokes of markers or hash marks along the line: +/// one for each layer of the symbol it places, bottom first, all at its places. +fn along(l: Node, p: &[(String, String)], hash: bool, reference: Option, layer: &str, report: &mut Report) -> Vec { + let num = |k: &str| prop(p, k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let at = placements(p, layer, report); + if at.is_empty() { + return Vec::new(); + } + if prop(p, "place_on_every_part") == Some("false") { + report.approximated(layer, "markers along only the first part of a line, drawn along every part"); + } + if num("trim_distance_start").is_some_and(|v| v != 0.0) || num("trim_distance_end").is_some_and(|v| v != 0.0) { + report.approximated(layer, "markers along lines trimmed at their ends, drawn along the whole line"); + } + let base = LineMarkers { + at, + interval: size(p, "interval", reference, layer, report).unwrap_or(3.0 * 96.0 / 25.4), + offset_along: offset(p, "offset_along_line", reference, layer, report).unwrap_or(0.0), + rotate: prop(p, "rotate") != Some("0"), + // QGIS's default stretch is 4 mm. + average_angle: size(p, "average_angle_length", reference, layer, report).unwrap_or(4.0 * 96.0 / 25.4), + ..LineMarkers::default() + }; + let across = offset(p, "offset", reference, layer, report).unwrap_or(0.0); + let pass = l.attribute("pass").and_then(|v| v.trim().parse::().ok()); + let Some(sym) = l.children().find(|c| c.has_tag_name("symbol")) else { return Vec::new() }; + let alpha = sym.attribute("alpha").and_then(|a| a.trim().parse::().ok()).filter(|a| a.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); + let fade = |c: Rgba| c.with_alpha((f32::from(c.a()) * alpha).round() as u8); + let unit = sym.attribute("outputUnit"); + let mut out = Vec::new(); + for sub in sym.children().filter(|c| c.has_tag_name("layer") && c.attribute("enabled") != Some("0")).take(MAX_STROKES) { + if data_defined_on(sub) { + report.skipped(layer, "data-defined symbol settings (from fields or expressions)"); + } + if has_effect(sub) { + report.skipped(layer, "draw effects (shadows, glows, blurs and the like)"); + } + let sp = settings(sub, unit); + let class = sub.attribute("class").unwrap_or_default(); + let stroke = if hash { + hash_mark(class, &sp, p, base, reference, layer, report) + } else { + marker_mark(class, &sp, base, reference, layer, report) + }; + if let Some(mut st) = stroke { + st.offset += across; + st.level = pass; + st.color = st.color.map(fade); + if let Some(m) = &mut st.markers { + m.outline = m.outline.map(fade); + } + out.push(st.clamped()); + } + } + out +} + +/// Where a marker or hashed line puts its marks: `placements` (QGIS 3.24 on, several at once) or +/// `placement` (before); none for a placement RustGIS has no match for (noted). +fn placements(p: &[(String, String)], layer: &str, report: &mut Report) -> Placements { + let text = prop(p, "placements").or(prop(p, "placement")).unwrap_or("Interval"); + let mut at = Placements::NONE; + for code in text.split('|').map(str::trim).filter(|c| !c.is_empty()) { + at = at.with(match code.to_ascii_lowercase().as_str() { + "interval" => Placements::INTERVAL, + "vertex" => Placements::VERTICES, + "innervertices" => Placements::INNER_VERTICES, + "firstvertex" => Placements::FIRST, + "lastvertex" => Placements::LAST, + "centralpoint" => Placements::MIDDLE, + "segmentcenter" => Placements::SEGMENT_MIDDLES, + "curvepoint" => { + report.approximated(layer, "markers at curve points, drawn at the vertices"); + Placements::VERTICES + } + other => { + report.skipped(layer, format!("markers along lines placed at \"{other}\"")); + Placements::NONE + } + }); + } + at +} + +/// A marker line's marker layer `class` (its settings `p`) as a stroke of markers at `base`'s +/// places, or `None` for one RustGIS has no match for (noted). +fn marker_mark( + class: &str, + p: &[(String, String)], + base: LineMarkers, + reference: Option, + layer: &str, + report: &mut Report, +) -> Option { + let shape = match class { + "SimpleMarker" => { + let name = prop(p, "name").unwrap_or("circle"); + shape_of(name).unwrap_or_else(|| { + report.approximated(layer, format!("{name} markers, drawn as circles")); + 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; + } + "SvgMarker" | "RasterMarker" | "EllipseMarker" | "FilledMarker" => { + report.approximated(layer, format!("{} markers, drawn as circles in their colour", marker_name(class))); + Marker::Circle + } + other => { + report.skipped(layer, format!("{other} marker symbol layers")); + return None; + } + }; + 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 }; + let (along_x, across) = marker_offset(p, base.rotate, reference, layer, report); + let markers = LineMarkers { + mark: LineMark::Marker(shape), + size: size(p, "size", reference, layer, report).unwrap_or(2.0 * 96.0 / 25.4), + offset_along: base.offset_along + along_x, + angle: prop(p, "angle").and_then(|a| a.trim().parse::().ok()).unwrap_or(0.0), + outline, + ..base + }; + Some(LineStroke { color: Some(fill), width: Some(width), offset: across, markers: Some(markers), ..LineStroke::default() }) +} + +/// A hashed line's line layer `class` (its settings `p`; the hashed line's `hp`) as a stroke of +/// hash marks at `base`'s places, or `None` for one RustGIS has no match for (noted). +fn hash_mark( + class: &str, + p: &[(String, String)], + hp: &[(String, String)], + base: LineMarkers, + reference: Option, + layer: &str, + report: &mut Report, +) -> Option { + if class != "SimpleLine" { + report.skipped(layer, format!("{class} line symbol layers in hash marks")); + return None; + } + if prop(p, "line_style") == Some("no") { + return None; + } + if dashed(prop(p, "line_style")) || prop(p, "use_custom_dash") == Some("1") { + report.approximated(layer, "dashed hash marks, drawn solid"); + } + let c = prop(p, "line_color").and_then(color)?; + let markers = LineMarkers { + mark: LineMark::Hash, + size: size(hp, "hash_length", reference, layer, report).unwrap_or(3.0 * 96.0 / 25.4), + angle: prop(hp, "hash_angle").and_then(|a| a.trim().parse::().ok()).unwrap_or(0.0), + ..base + }; + let width = size(p, "line_width", reference, layer, report).unwrap_or(0.26 * 96.0 / 25.4); + let across = offset(p, "offset", reference, layer, report).unwrap_or(0.0); + Some(LineStroke { color: Some(c), width: Some(width), offset: across, markers: Some(markers), ..LineStroke::default() }) +} + +/// A marker's offset (`offset`, "x,y" in its unit) along and across the line, in points: when it +/// turns with the line its x runs along it and its y to the right of it; when it doesn't, taken +/// the same way (noted). +fn marker_offset(p: &[(String, String)], rotate: bool, reference: Option, layer: &str, report: &mut Report) -> (f32, f32) { + let Some((x, y)) = prop(p, "offset").and_then(|o| o.split_once(',')) else { return (0.0, 0.0) }; + let parse = |v: &str| v.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x, y) = (parse(x), parse(y)); + if x == 0.0 && y == 0.0 { + return (0.0, 0.0); + } + if !rotate { + report.approximated(layer, "offsets of upright markers along lines, taken along and across the line"); + } + let unit = prop(p, "offset_unit").or(prop(p, SYMBOL_UNIT)); + (signed_points(x, unit, reference, layer, report), signed_points(y, unit, reference, layer, report)) +} + fn marker(layers: &[(Node, Props)], reference: Option, layer: &str, report: &mut Report) -> Converted { let mut symbol = hidden(); for (l, p) in layers { diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index bd7d19dc1..cd2fbbf9b 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, Marker}; +use crate::symbology::{LabelPlacement, LineCap, LineMark, Marker, Placements}; const PROJECT: &str = r#" @@ -190,7 +190,8 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() { // A QGIS font family is the label class's font, drawn in it where it's installed. assert!(!said("Land", NoteKind::Approximated, "Liberation Sans Narrow"), "no longer approximated"); assert!(said("Roads", NoteKind::Approximated, "dashed")); - assert!(said("Roads", NoteKind::Skipped, "markers and hash marks")); + // 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")); // A switched-off label rule is never drawn, so its filter isn't noted. @@ -572,3 +573,72 @@ fn every_corpus_file_reads() { assert!(!format!("{project:?} {report:?}").contains("secret"), "{}: the password came through", path.display()); } } + +#[test] +fn marker_and_hashed_lines_carry_over_as_strokes_of_marks() { + // As SMV25 writes them: a line (offset to the left), then marks at its ends in three layers (a + // picture, a triangle and a font character, offset), then QGIS 2's hash marks at an interval. + let qml = r#" + + + + + + + + + + + + + + "#; + let mut report = Report::default(); + let s = read_style(qml, "Rail", &mut report).unwrap(); + 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!(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]); + + // The picture: a circle in its colour (the symbol's opacity on it), offset along and across. + let (pic, m) = (strokes[1], strokes[1].markers.unwrap()); + assert_eq!((m.mark, m.at), (LineMark::Marker(Marker::Circle), Placements::FIRST.with(Placements::LAST))); + assert_eq!(pic.color, Some(Rgba::new(242, 242, 242, 128))); + assert!(near(m.offset_along, 0.12 * mm) && near(pic.offset, (0.5 - 0.35) * mm), "{pic:?}"); + assert!(near(m.size, mm) && near(m.average_angle, 4.0 * mm) && m.rotate && m.outline.is_none()); + assert_eq!(pic.level, Some(2)); + + // The triangle: its outline, its angle, offset only by the line's. + let (tri, m) = (strokes[2], strokes[2].markers.unwrap()); + 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)); + + // QGIS 2's hash marks: upright, at their angle, the line's colour and width. + let (hash, m) = (strokes[3], strokes[3].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); + 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"); + let mut report = Report::default(); + let s = read_style(&other, "Rail", &mut report).unwrap(); + assert_eq!(s.line_strokes.unwrap()[1].markers.unwrap().at, Placements::VERTICES); + 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); +} diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 79f44cf70..866260a86 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -251,11 +251,202 @@ pub struct LineStroke { /// Dashed: the pattern along the line, in points; `None` is a solid line. #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "de_dash")] pub dash: Option, + /// Markers or hash marks along the line instead of the stroke: in the stroke's colour, the + /// width their outline's (a hash mark's line's), shifted sideways by its offset. Cap, join + /// and dash don't apply. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub markers: Option, } impl Default for LineStroke { fn default() -> Self { - LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None, dash: None } + LineStroke { + color: None, + width: None, + offset: 0.0, + cap: LineCap::Round, + join: LineJoin::Round, + level: None, + dash: None, + markers: None, + } + } +} + +/// The largest marker or hash mark along a line, in points. +pub const MAX_LINE_MARKER_SIZE: f32 = 100.0; +/// The shortest and longest interval between markers along a line, in points. +pub const MIN_MARKER_INTERVAL: f32 = 0.5; +pub const MAX_MARKER_INTERVAL: f32 = 10_000.0; + +/// Where a stroke's markers go on each part of a line, several places at once. Written to a +/// project as their codes, `["first", "last"]`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Placements(u8); + +impl Placements { + /// At every interval along the line, from its start. + pub const INTERVAL: Placements = Placements(1); + pub const VERTICES: Placements = Placements(1 << 1); + /// Every vertex but the first and the last. + pub const INNER_VERTICES: Placements = Placements(1 << 2); + pub const FIRST: Placements = Placements(1 << 3); + pub const LAST: Placements = Placements(1 << 4); + /// Halfway along the line. + pub const MIDDLE: Placements = Placements(1 << 5); + /// Halfway along each segment. + pub const SEGMENT_MIDDLES: Placements = Placements(1 << 6); + pub const NONE: Placements = Placements(0); + + const CODES: [(Placements, &'static str); 7] = [ + (Placements::INTERVAL, "interval"), + (Placements::VERTICES, "vertices"), + (Placements::INNER_VERTICES, "innerVertices"), + (Placements::FIRST, "first"), + (Placements::LAST, "last"), + (Placements::MIDDLE, "middle"), + (Placements::SEGMENT_MIDDLES, "segmentMiddles"), + ]; + + pub fn has(self, p: Placements) -> bool { + self.0 & p.0 != 0 + } + + pub fn with(self, p: Placements) -> Placements { + Placements(self.0 | p.0) + } + + pub fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Its codes, in a fixed order. + pub fn codes(self) -> Vec<&'static str> { + Self::CODES.iter().filter(|(p, _)| self.has(*p)).map(|(_, c)| *c).collect() + } + + /// The placement a code names ("first", "middle", …), whatever its case. + pub fn from_code(code: &str) -> Option { + Self::CODES.iter().find(|(_, c)| c.eq_ignore_ascii_case(code.trim())).map(|(p, _)| *p) + } + + /// Every code there is, for messages. + pub fn all_codes() -> String { + Self::CODES.iter().map(|(_, c)| format!("\"{c}\"")).collect::>().join(", ") + } +} + +impl Default for Placements { + fn default() -> Self { + Placements::INTERVAL + } +} + +impl Serialize for Placements { + fn serialize(&self, s: S) -> Result { + self.codes().serialize(s) + } +} + +/// Read from a project leniently: codes it doesn't know are left out, and none at all is the +/// default (at every interval). +impl<'de> Deserialize<'de> for Placements { + fn deserialize>(d: D) -> Result { + let codes: Vec = Vec::deserialize(d)?; + let p = codes.iter().take(16).filter_map(|c| Placements::from_code(c)).fold(Placements::NONE, Placements::with); + Ok(if p.is_empty() { Placements::default() } else { p }) + } +} + +/// What a stroke draws along a line instead of a line: a marker, or a hash mark across it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LineMark { + Marker(Marker), + Hash, +} + +impl Default for LineMark { + fn default() -> Self { + LineMark::Marker(Marker::Circle) + } +} + +/// Markers or hash marks along a line (a [`LineStroke::markers`]), as QGIS's marker and hashed +/// lines draw them. Lengths are in points. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct LineMarkers { + #[serde(default)] + pub mark: LineMark, + /// A marker's size, or a hash mark's length. + #[serde(default = "LineMarkers::default_size")] + pub size: f32, + #[serde(default)] + pub at: Placements, + /// The distance between two markers placed at every interval. + #[serde(default = "LineMarkers::default_interval")] + pub interval: f32, + /// How far along the line the markers move: from its start (at every interval, the first + /// vertex), back from its end (the last vertex), or on from the middle. + #[serde(default)] + pub offset_along: f32, + /// Turned with the line, so a marker's top faces left of the line's direction; otherwise + /// upright. + #[serde(default = "LineMarkers::yes")] + pub rotate: bool, + /// The stretch of line, centred on a marker placed at an interval, whose direction it turns + /// with: smoother on a line of many short bends. 0 is the direction of the segment it sits on. + #[serde(default)] + pub average_angle: f32, + /// Turned further, in degrees clockwise. A hash mark at 0 crosses the line square. + #[serde(default)] + pub angle: f32, + /// A marker's outline colour; `None` draws none. Its width is the stroke's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outline: Option, +} + +impl Default for LineMarkers { + fn default() -> Self { + LineMarkers { + mark: LineMark::default(), + size: Self::default_size(), + at: Placements::default(), + interval: Self::default_interval(), + offset_along: 0.0, + rotate: true, + average_angle: 0.0, + angle: 0.0, + outline: None, + } + } +} + +impl LineMarkers { + fn default_size() -> f32 { + 4.0 + } + + fn default_interval() -> f32 { + 12.0 + } + + fn yes() -> bool { + true + } + + /// Brought into range: a size from 0 to [`MAX_LINE_MARKER_SIZE`], an interval from + /// [`MIN_MARKER_INTERVAL`] to [`MAX_MARKER_INTERVAL`], an offset along the line and an + /// averaging stretch within the longest interval, an angle within a turn. + pub fn clamped(mut self) -> LineMarkers { + // Too large for an f32 (infinite once read) is the nearest bound, as a large value is. + let fit = |v: f32, lo: f32, hi: f32, or: f32| if v.is_nan() { or } else { v.clamp(lo, hi) }; + self.size = fit(self.size, 0.0, MAX_LINE_MARKER_SIZE, Self::default_size()); + self.interval = fit(self.interval, MIN_MARKER_INTERVAL, MAX_MARKER_INTERVAL, Self::default_interval()); + self.offset_along = fit(self.offset_along, -MAX_MARKER_INTERVAL, MAX_MARKER_INTERVAL, 0.0); + self.average_angle = fit(self.average_angle, 0.0, MAX_MARKER_INTERVAL, 0.0); + self.angle = if self.angle.is_finite() { self.angle.rem_euclid(360.0) } else { 0.0 }; + self } } @@ -275,6 +466,7 @@ impl LineStroke { pub fn clamped(mut self) -> LineStroke { self.width = self.width.map(|w| if w.is_finite() { w.clamp(0.0, MAX_STROKE_WIDTH) } else { 1.0 }); self.offset = if self.offset.is_finite() { self.offset.clamp(-MAX_STROKE_OFFSET, MAX_STROKE_OFFSET) } else { 0.0 }; + self.markers = self.markers.map(LineMarkers::clamped); self } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ee4d5115d..d7076f5c8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ pub mod fonts; pub mod gpu_scene; pub mod layout; mod line_labels; +mod line_markers; pub mod logo; mod mesh; pub mod pdf; @@ -621,7 +622,7 @@ impl Ctx<'_> { self.ring_path_at(&mut pb, part, false, &mut starts); } let Some(path) = pb.finish() else { return }; - self.stacked_path(pm, &path, s, strokes, &starts); + self.stacked_path(pm, &path, s, strokes, &starts, parts); } /// A stroke's dash pattern in pixels for `path`, or `None` to draw it solid: a pattern under a @@ -643,11 +644,29 @@ impl Ctx<'_> { /// line cut at the clip box keeps its dashes where they'd be uncut. An offset stroke takes the /// same starts: exact on a straight line, a little off after bends, where the shifted line is /// longer or shorter than the line. - fn stacked_path(&self, pm: &mut Pixmap, path: &tiny_skia::Path, s: &Symbol, strokes: &[LineStroke], starts: &DashStarts) { + /// + /// `parts` is the line `path` was made from: markers along it are placed along the line as a + /// whole ([`line_markers`]). + fn stacked_path( + &self, + pm: &mut Pixmap, + path: &tiny_skia::Path, + s: &Symbol, + strokes: &[LineStroke], + starts: &DashStarts, + parts: &[Vec], + ) { // Shifted once per offset: a casing and its core usually share one. let mut shifted: Vec<(f32, Option)> = Vec::new(); + // The line in device pixels, made for the first stroke of markers. + let mut runs: Option> = None; for st in strokes { let (c, w) = st.resolve(s); + if let Some(m) = &st.markers { + let runs = runs.get_or_insert_with(|| self.marker_runs(parts)); + line_markers::draw(pm, runs, m, c, if w.is_nan() { 0.0 } else { w }, st.offset, self.ppp); + continue; + } if w.is_nan() || w <= 0.0 || c.a() == 0 { continue; } @@ -699,6 +718,20 @@ impl Ctx<'_> { } } + /// A line's parts in device pixels, for markers along it: each broken where the projection + /// can't show a point, as the line is drawn. + fn marker_runs(&self, parts: &[Vec]) -> Vec { + let ppp = self.view.pixels_per_point; + let mut runs = Vec::new(); + for part in parts { + let pts: Vec> = part.iter().map(|c| self.screen(*c).map(|(x, y)| (x * ppp, y * ppp))).collect(); + for run in pts.split(|p| p.is_none_or(|(x, y)| !x.is_finite() || !y.is_finite())) { + runs.extend(line_markers::Run::new(run.iter().flatten().copied())); + } + } + runs + } + fn marker(&self, pm: &mut Pixmap, c: Coord, s: &Symbol, opacity: f32, halo: Option) { let (x, y) = self.px(c); if !x.is_finite() || !y.is_finite() { @@ -813,9 +846,21 @@ impl Ctx<'_> { .iter() .filter(|st| { let (c, w) = st.resolve(s); - w > 0.0 && c.a() > 0 + // A hollow marker shows by its outline alone. + let outlined = w > 0.0 + && st + .markers + .is_some_and(|m| !matches!(m.mark, rustgis_map::LineMark::Hash) && m.outline.is_some_and(|o| o.a() > 0)); + (w > 0.0 || st.markers.is_some_and(|m| !matches!(m.mark, rustgis_map::LineMark::Hash))) && (c.a() > 0 || outlined) + }) + .map(|st| LineStroke { + // A hollow marker's halo is its outline alone, so it stays hollow. + color: Some(if st.markers.is_some() && st.resolve(s).0.a() == 0 { Rgba::new(0, 0, 0, 0) } else { SELECTION }), + width: Some(st.resolve(s).1.max(0.0) + 3.0), + // A marker's halo is its outline. + markers: st.markers.map(|m| rustgis_map::LineMarkers { outline: Some(SELECTION), ..m }), + ..*st }) - .map(|st| LineStroke { color: Some(SELECTION), width: Some(st.resolve(s).1 + 3.0), ..*st }) .collect(); self.stacked_line(pm, parts, s, &halo); self.stacked_line(pm, parts, s, strokes); @@ -1148,11 +1193,10 @@ const MAX_SPRITES: usize = 1024; /// gains little from one. With [`MAX_SPRITES`], the cache stays under about 10 MB. const MAX_SPRITE_REACH: i64 = 24; -/// 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; +/// A marker's outline at (`x`, `y`), `r` its radius in pixels. +pub(crate) fn marker_path(x: f32, y: f32, r: f32, marker: Marker) -> Option { let mut pb = PathBuilder::new(); - match s.marker { + match marker { Marker::Circle => pb.push_circle(x, y, r), Marker::Square => { if let Some(rect) = tiny_skia::Rect::from_xywh(x - r * 0.9, y - r * 0.9, r * 1.8, r * 1.8) { @@ -1182,7 +1226,13 @@ fn draw_marker(pm: &mut Pixmap, x: f32, y: f32, s: &Symbol, opacity: f32, halo: pb.close(); } } - let Some(path) = pb.finish() else { return }; + pb.finish() +} + +/// 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; + let Some(path) = marker_path(x, y, r, s.marker) else { return }; if let Some(h) = halo { let st = Stroke { width: 3.5 * ppp, line_join: LineJoin::Round, ..Stroke::default() }; pm.stroke_path(&path, &paint(h, 1.0), &st, Transform::identity(), None); @@ -5682,6 +5732,63 @@ mod tests { assert_eq!(r.pixmap.pixel(100, 100).unwrap(), blank, "nothing on the centreline"); } + #[test] + fn markers_and_hash_marks_draw_along_a_line() { + use rustgis_map::{LineMark, LineMarkers, LineStroke, Placements}; + // A line from lon -1 to 1 at 1,500 m a pixel: x 25.8 to 174.2, along y 100. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("rail", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.0), Coord::new(1.0, 0.0)]])), vec![]); + let id = m.add_dataset(d); + 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 red = Rgba::rgb(255, 0, 0); + let draw = |m: &mut Map, markers: LineMarkers, offset: f32| { + m.layer_mut(id).unwrap().line_strokes = + vec![LineStroke { color: Some(red), width: Some(2.0), offset, markers: Some(markers), ..Default::default() }]; + render(m, &view).unwrap() + }; + let is_red = |r: &Rendered, x: u32, y: u32| { + let p = r.pixmap.pixel(x, y).unwrap(); + (p.red(), p.green(), p.blue()) == (255, 0, 0) + }; + let squares = LineMarkers { mark: LineMark::Marker(Marker::Square), size: 8.0, interval: 30.0, ..LineMarkers::default() }; + // Every 30 pt from the start (x 25.8, 55.8, 85.8 …), nothing between, no line. + let r = draw(&mut m, squares, 0.0); + assert!([26, 56, 86, 116, 146].iter().all(|x| is_red(&r, *x, 100))); + assert!([41, 71, 101, 131, 161].iter().all(|x| !is_red(&r, *x, 100)), "nothing between"); + // At the ends and the middle only. + let places = LineMarkers { at: Placements::FIRST.with(Placements::LAST).with(Placements::MIDDLE), ..squares }; + let r = draw(&mut m, places, 0.0); + assert!(is_red(&r, 26, 100) && is_red(&r, 100, 100) && is_red(&r, 174, 100)); + assert!(!is_red(&r, 56, 100) && !is_red(&r, 146, 100)); + // Shifted 10 pt to the right of the line's direction (down the screen, going east). + let r = draw(&mut m, squares, 10.0); + assert!(is_red(&r, 56, 110) && !is_red(&r, 56, 100)); + // Hash marks: across the line, 10 pt long, 2 pt wide. + let hash = LineMarkers { mark: LineMark::Hash, size: 10.0, ..squares }; + let r = draw(&mut m, hash, 0.0); + assert!(is_red(&r, 55, 96) && is_red(&r, 55, 104) && !is_red(&r, 60, 100) && !is_red(&r, 55, 108)); + // Selected: a halo around each marker. + m.layer_mut(id).unwrap().selection = vec![0]; + let r = draw(&mut m, squares, 0.0); + let p = r.pixmap.pixel(61, 100).unwrap(); + assert!(is_red(&r, 56, 100) && (p.red(), p.green(), p.blue()) != (255, 255, 255), "{p:?}"); + m.layer_mut(id).unwrap().selection.clear(); + // Zoomed far in on a line round the world, a mark every half point: only those that show + // are visited (this would be 80 billion marks), and they draw. + let mut d = Dataset::new("long", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-170.0, 0.0), Coord::new(170.0, 0.0)]])), vec![]); + let long = m.add_dataset(d); + m.layer_mut(id).unwrap().visible = false; + let tiny = LineMarkers { size: 0.4, interval: 0.5, ..squares }; + m.layer_mut(long).unwrap().line_strokes = vec![LineStroke { color: Some(red), markers: Some(tiny), ..Default::default() }]; + let near = View { camera: Camera { res: 0.001, ..camera }, ..view }; + let r = render(&mut m, &near).unwrap(); + assert!((0..200).any(|x| r.pixmap.pixel(x, 100).unwrap().red() > 0), "marks show"); + } + #[test] fn a_class_draws_its_own_stacked_strokes() { // Two lines of two Unique Values classes: "road" has a black casing and a red core of its @@ -5790,7 +5897,7 @@ mod tests { }; let s = Symbol::default_for(GeometryType::Polyline, 0); let mut drawn = Pixmap::new(100, 20).unwrap(); - ctx.stacked_path(&mut drawn, &cut, &s, &[stroke], &starts); + ctx.stacked_path(&mut drawn, &cut, &s, &[stroke], &starts, &[]); let mut whole = Pixmap::new(100, 20).unwrap(); let mut pb = PathBuilder::new(); let (x0, y) = view.to_screen(line[0]); diff --git a/crates/render/src/line_markers.rs b/crates/render/src/line_markers.rs new file mode 100644 index 000000000..1e33877b4 --- /dev/null +++ b/crates/render/src/line_markers.rs @@ -0,0 +1,326 @@ +//! Markers and hash marks along a line (a stroke's [`LineMarkers`]), as QGIS's marker and hashed +//! lines draw them. They are placed along each part of the line as a whole, in device pixels, so +//! an interval counts from the line's start and its middle is the whole line's however much of it +//! shows; only the marks near the image are drawn. + +use rustgis_map::{LineMark, LineMarkers, Placements, Rgba}; +use tiny_skia::{FillRule, LineCap, Path, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::{marker_path, 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. +const MAX_MARKS: usize = 20_000; + +/// A part of a line in device pixels, and how far along it each vertex is. Vertices on top of the +/// one before are left out, so every segment has a direction. +pub(crate) struct Run { + pts: Vec<(f64, f64)>, + at: Vec, +} + +impl Run { + /// The run through `pts`, or `None` when it has no length. + pub(crate) fn new(pts: impl IntoIterator) -> Option { + let (mut out, mut at) = (Vec::<(f64, f64)>::new(), Vec::new()); + for p in pts { + if !(p.0.is_finite() && p.1.is_finite()) { + continue; + } + match out.last() { + Some(q) => { + let d = (p.0 - q.0).hypot(p.1 - q.1); + if d > 1e-9 { + at.push(at.last().copied().unwrap_or(0.0) + d); + out.push(p); + } + } + None => { + at.push(0.0); + out.push(p); + } + } + } + (out.len() >= 2).then_some(Run { pts: out, at }) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The segment `d` along the line falls on (the first or the last past its ends). + fn segment(&self, d: f64) -> usize { + let i = self.at.partition_point(|a| *a <= d); + i.saturating_sub(1).min(self.pts.len().saturating_sub(2)) + } + + /// The unit direction of segment `i`. + fn dir(&self, i: usize) -> (f64, f64) { + match (self.pts.get(i), self.pts.get(i + 1)) { + (Some(a), Some(b)) => { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let l = dx.hypot(dy); + if l > 0.0 { (dx / l, dy / l) } else { (1.0, 0.0) } + } + _ => (1.0, 0.0), + } + } + + /// The point `d` along the line (kept on it) and the direction there. + fn at_dist(&self, d: f64) -> ((f64, f64), (f64, f64)) { + let d = d.clamp(0.0, self.len()); + let i = self.segment(d); + let (Some(a), Some(a0)) = (self.pts.get(i), self.at.get(i)) else { return ((0.0, 0.0), (1.0, 0.0)) }; + let dir = self.dir(i); + let t = d - a0; + ((a.0 + dir.0 * t, a.1 + dir.1 * t), dir) + } + + /// The direction from `stretch / 2` before `d` to as far after it, or the segment's where that + /// has no length. + fn averaged(&self, d: f64, stretch: f64) -> (f64, f64) { + let ((a, _), (b, _)) = (self.at_dist(d - stretch / 2.0), self.at_dist(d + stretch / 2.0)); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let l = dx.hypot(dy); + if l > 1e-9 { (dx / l, dy / l) } else { self.at_dist(d).1 } + } + + /// The direction at vertex `i`: halfway between its segments' at an inner vertex. + fn vertex_dir(&self, i: usize) -> (f64, f64) { + let after = self.dir(i.min(self.pts.len().saturating_sub(2))); + if i == 0 || i + 1 >= self.pts.len() { + return after; + } + let before = self.dir(i - 1); + let (x, y) = (before.0 + after.0, before.1 + after.1); + let l = x.hypot(y); + if l > 1e-9 { (x / l, y / l) } else { before } + } +} + +/// Where marks show: the image, widened by how far a mark reaches, in device pixels. +#[derive(Clone, Copy)] +struct Window { + x0: f64, + y0: f64, + x1: f64, + y1: f64, +} + +impl Window { + fn contains(&self, p: (f64, f64)) -> bool { + p.0 >= self.x0 && p.0 <= self.x1 && p.1 >= self.y0 && p.1 <= self.y1 + } + + /// The stretch of the segment from `a` to `b` inside it, as fractions of the way from `a` + /// (Liang–Barsky); `None` when it passes outside. + fn meets(&self, a: (f64, f64), b: (f64, f64)) -> Option<(f64, f64)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a.0 - self.x0), (dx, self.x1 - a.0), (-dy, a.1 - self.y0), (dy, self.y1 - 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 > t1 { + return None; + } + } + } + Some((t0, t1)) + } +} + +/// The places along `run` a stroke's marks go, in turn: each point and the line's direction +/// there. At most `room`, and only those in `near`; at every interval, without visiting the +/// segments outside it. +fn places(run: &Run, m: &LineMarkers, ppp: f64, near: Window, room: usize) -> Vec<((f64, f64), (f64, f64))> { + let mut out = Vec::new(); + let len = run.len(); + let along = f64::from(m.offset_along) * ppp; + let push = |out: &mut Vec<_>, p: ((f64, f64), (f64, f64))| { + if out.len() < room && near.contains(p.0) { + out.push(p); + } + }; + let last = run.pts.len().saturating_sub(1); + if m.at.has(Placements::INTERVAL) { + let step = f64::from(m.interval).max(f64::from(rustgis_map::symbology::MIN_MARKER_INTERVAL)) * ppp; + let stretch = f64::from(m.average_angle) * ppp; + // Segment by segment, skipping those off the image without visiting their marks. + for i in 0..last { + let (Some(a), Some(b), Some(d0), Some(d1)) = (run.pts.get(i), run.pts.get(i + 1), run.at.get(i), run.at.get(i + 1)) else { + continue; + }; + // Only the stretch of it near the image. + let Some((t0, t1)) = near.meets(*a, *b) else { continue }; + let (from, to) = (d0 + (d1 - d0) * t0, d0 + (d1 - d0) * t1); + let mut k = ((from - along) / step).ceil().max(0.0); + while out.len() < room { + let d = along + k * step; + // Each mark on one segment: past its end it's the next one's (the last keeps its end). + if d > to || (d == *d1 && i + 1 < last) { + break; + } + let (p, dir) = run.at_dist(d); + let dir = if stretch > 0.0 { run.averaged(d, stretch) } else { dir }; + push(&mut out, (p, dir)); + k += 1.0; + } + } + } + let vertices = m.at.has(Placements::VERTICES); + if vertices || m.at.has(Placements::INNER_VERTICES) { + for i in 0..=last { + if !vertices && (i == 0 || i == last) { + continue; + } + let (Some(p), Some(d)) = (run.pts.get(i), run.at.get(i)) else { continue }; + if along == 0.0 { push(&mut out, (*p, run.vertex_dir(i))) } else { push(&mut out, run.at_dist(d + along)) } + } + } + // A first or last vertex already marked as a vertex isn't marked again. + if m.at.has(Placements::FIRST) && !vertices { + push(&mut out, run.at_dist(along)); + } + if m.at.has(Placements::LAST) && !vertices { + let (p, dir) = run.at_dist(len - along); + push(&mut out, (p, if along == 0.0 { run.dir(last.saturating_sub(1)) } else { dir })); + } + if m.at.has(Placements::MIDDLE) { + push(&mut out, run.at_dist(len / 2.0 + along)); + } + if m.at.has(Placements::SEGMENT_MIDDLES) { + for i in 0..last { + let (Some(d0), Some(d1)) = (run.at.get(i), run.at.get(i + 1)) else { continue }; + push(&mut out, run.at_dist((d0 + d1) / 2.0 + along)); + } + } + out +} + +/// Draws a stroke's marks (`m`) along each of `runs`: in `color`, `width` their outline's or a hash +/// mark's line's, `offset` points to the right of the line. `ppp` is pixels per point. +pub(crate) fn draw(pm: &mut Pixmap, runs: &[Run], m: &LineMarkers, color: Rgba, width: f32, offset: f32, ppp: f32) { + let outline = m.outline.filter(|c| c.a() > 0 && width > 0.0); + let hash = matches!(m.mark, LineMark::Hash); + if (hash && (width <= 0.0 || color.a() == 0)) || (!hash && color.a() == 0 && outline.is_none()) { + return; + } + let size = m.size.max(0.0) * ppp; + // The mark at the origin, the line running along x: a marker, or a hash mark across it. + let shape: Option = match m.mark { + LineMark::Marker(shape) => marker_path(0.0, 0.0, size.max(ppp) / 2.0, shape), + LineMark::Hash => { + let mut pb = PathBuilder::new(); + pb.move_to(0.0, -size / 2.0); + pb.line_to(0.0, size / 2.0); + pb.finish() + } + }; + 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; + 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; + for run in runs { + for ((x, y), (dx, dy)) in places(run, m, f64::from(ppp), near, room) { + room = room.saturating_sub(1); + // Shifted to the right of the line's direction (on screen, y down). + let (x, y) = (x - dy * offset, y + dx * offset); + let turn = if m.rotate { dy.atan2(dx).to_degrees() as f32 } else { 0.0 } + m.angle; + let t = Transform::from_rotate(turn).post_translate(x as f32, y as f32); + if hash { + pm.stroke_path(&shape, &paint(color, 1.0), &stroke, t, None); + continue; + } + if color.a() > 0 { + pm.fill_path(&shape, &paint(color, 1.0), FillRule::Winding, t, None); + } + if let Some(o) = outline { + pm.stroke_path(&shape, &paint(o, 1.0), &stroke, t, None); + } + } + if room == 0 { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: Window = Window { x0: -1e12, y0: -1e12, x1: 1e12, y1: 1e12 }; + + fn run(pts: &[(f64, f64)]) -> Run { + Run::new(pts.iter().copied()).unwrap() + } + + fn at(r: &Run, m: LineMarkers) -> Vec<(f64, f64)> { + places(r, &m, 1.0, ALL, MAX_MARKS).into_iter().map(|(p, _)| p).collect() + } + + #[test] + fn marks_go_at_every_interval_from_the_start_and_at_each_place_asked() { + let r = run(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + let every = LineMarkers { interval: 5.0, ..LineMarkers::default() }; + assert_eq!(at(&r, every), vec![(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 5.0), (10.0, 10.0)]); + let shifted = LineMarkers { interval: 5.0, offset_along: 2.0, ..LineMarkers::default() }; + assert_eq!(at(&r, shifted), vec![(2.0, 0.0), (7.0, 0.0), (10.0, 2.0), (10.0, 7.0)]); + let ends = LineMarkers { at: Placements::FIRST.with(Placements::LAST), ..LineMarkers::default() }; + assert_eq!(at(&r, ends), vec![(0.0, 0.0), (10.0, 10.0)]); + let middle = LineMarkers { at: Placements::MIDDLE, ..LineMarkers::default() }; + assert_eq!(at(&r, middle), vec![(10.0, 0.0)]); + let inner = LineMarkers { at: Placements::INNER_VERTICES.with(Placements::SEGMENT_MIDDLES), ..LineMarkers::default() }; + assert_eq!(at(&r, inner), vec![(10.0, 0.0), (5.0, 0.0), (10.0, 5.0)]); + // A vertex mark doesn't double the first and last. + let vertices = LineMarkers { at: Placements::VERTICES.with(Placements::FIRST), ..LineMarkers::default() }; + assert_eq!(at(&r, vertices).len(), 3); + } + + #[test] + fn marks_turn_with_the_line_and_halfway_at_a_bend() { + let r = run(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + let m = LineMarkers { at: Placements::VERTICES, ..LineMarkers::default() }; + let dirs: Vec<(f64, f64)> = places(&r, &m, 1.0, ALL, 10).into_iter().map(|(_, d)| d).collect(); + let h = std::f64::consts::FRAC_1_SQRT_2; + assert_eq!(dirs.len(), 3); + assert!((dirs[0].0 - 1.0).abs() < 1e-12 && dirs[0].1.abs() < 1e-12); + assert!((dirs[1].0 - h).abs() < 1e-12 && (dirs[1].1 - h).abs() < 1e-12); + assert!(dirs[2].0.abs() < 1e-12 && (dirs[2].1 - 1.0).abs() < 1e-12); + // Averaged over a stretch, a mark right at the bend turns halfway too. + let avg = LineMarkers { interval: 10.0, average_angle: 4.0, ..LineMarkers::default() }; + let d = places(&r, &avg, 1.0, ALL, 10); + assert!((d[1].1.0 - h).abs() < 1e-12 && (d[1].1.1 - h).abs() < 1e-12); + } + + #[test] + fn marks_off_the_image_are_skipped_without_visiting_them() { + // A line a hundred million pixels long, a mark every pixel: only those near x 0..100 count. + let r = run(&[(-5e7, 0.0), (5e7, 0.0)]); + let m = LineMarkers { interval: 1.0, ..LineMarkers::default() }; + let near = Window { x0: 0.0, y0: -10.0, x1: 100.0, y1: 10.0 }; + assert!(near.meets((-5e7, 0.0), (5e7, 0.0)).is_some() && near.meets((-5e7, 20.0), (5e7, 20.0)).is_none()); + let got = places(&r, &m, 1.0, near, MAX_MARKS); + assert_eq!(got.len(), 101); + // And however many show, no more than the room. + let all = places(&r, &m, 1.0, ALL, MAX_MARKS); + assert_eq!(all.len(), MAX_MARKS); + } + + #[test] + fn a_run_without_length_has_no_marks() { + assert!(Run::new([(1.0, 1.0), (1.0, 1.0)]).is_none()); + assert!(Run::new([(f64::NAN, 0.0), (1.0, 1.0)]).is_none()); + } +} diff --git a/crates/render/src/stroke_paths.rs b/crates/render/src/stroke_paths.rs index 866116b5e..70589b4a5 100644 --- a/crates/render/src/stroke_paths.rs +++ b/crates/render/src/stroke_paths.rs @@ -19,7 +19,7 @@ impl Paths { ctx.ring_path_at(&mut builder, part, false, &mut starts); } let Some(path) = builder.finish() else { return }; - ctx.stacked_path(pm, &path, symbol, strokes, &starts); + ctx.stacked_path(pm, &path, symbol, strokes, &starts, parts); self.remember(path); } fn remember(&mut self, path: tiny_skia::Path) { diff --git a/crates/render/src/stroke_paths/tests.rs b/crates/render/src/stroke_paths/tests.rs index 714989355..d5db9bd90 100644 --- a/crates/render/src/stroke_paths/tests.rs +++ b/crates/render/src/stroke_paths/tests.rs @@ -29,6 +29,7 @@ fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { cap, level: None, dash: None, + markers: None, }, LineStroke { offset: -2.0, @@ -38,8 +39,18 @@ fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { cap, level: None, dash: None, + markers: None, + }, + LineStroke { + offset: 2.0, + width: Some(1.0), + color: Some(Rgba::rgb(30, 40, 50)), + join, + cap, + level: None, + dash: None, + markers: None, }, - LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap, level: None, dash: None }, ]; let mut batch = Paths::default(); for i in 0..180 { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index aec080af7..74d9e3c0c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -9,8 +9,8 @@ use rustgis_engine::analysis::{ParamKind, tools}; use rustgis_engine::data::GeometryType; use rustgis_engine::map::raster::{Normalizer, RasterLegend, RasterStyle}; use rustgis_engine::map::symbology::{ - ClassRef, ClassStyle, Dash, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, - OTHER_VALUES, normalize_label, + ClassRef, ClassStyle, Dash, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_LABEL, MAX_LINE_MARKER_SIZE, MAX_MARKER_INTERVAL, + MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, MIN_MARKER_INTERVAL, OTHER_VALUES, Placements, normalize_label, }; use rustgis_engine::map::tiles::{TileKind, TileLayer}; use rustgis_engine::map::{ @@ -4170,9 +4170,41 @@ fn stroke_json(st: &LineStroke) -> Value { "join": st.join.code(), "level": st.level, "dash": st.dash.map(|d| d.steps().to_vec()), + "markers": st.markers.map(|m| markers_json(&m)), }) } +/// A stroke's markers as `layer.lineStrokes` takes them. +fn markers_json(m: &LineMarkers) -> Value { + json!({ + "mark": match m.mark { + LineMark::Hash => "hash".to_string(), + LineMark::Marker(shape) => shape.label().to_ascii_lowercase(), + }, + "size": m.size, + "at": m.at.codes(), + "interval": m.interval, + "offsetAlong": m.offset_along, + "rotate": m.rotate, + "averageAngle": m.average_angle, + "angle": m.angle, + "outline": m.outline.map(|c| c.hex8()), + }) +} + +/// The places along a line the stroke editor offers for markers. +fn marker_places() -> [(&'static str, Placements); 7] { + [ + ("At intervals", Placements::INTERVAL), + ("At vertices", Placements::VERTICES), + ("At inner vertices", Placements::INNER_VERTICES), + ("At both ends", Placements::FIRST.with(Placements::LAST)), + ("At the start", Placements::FIRST), + ("At the end", Placements::LAST), + ("In the middle", Placements::MIDDLE), + ] +} + /// Sets the layer's stacked strokes, or `class`'s own. fn set_strokes(app: &mut RustGisApp, class: Option<&ClassRef>, strokes: &[LineStroke]) { let mut p = json!({"strokes": strokes.iter().map(stroke_json).collect::>()}); @@ -4210,6 +4242,116 @@ fn stroke_classes(r: &Renderer) -> Vec<(ClassRef, String, Symbol)> { } } +/// A stroke's markers along the editor's preview line, from `x.0` to `x.1` at `y`, scaled by `k`: +/// markers as dots (squares as squares), hash marks as ticks, `w` wide. A sketch, not the map's +/// drawing: other shapes and outlines aren't shown, and inner placements are marked at the middle. +fn marker_preview(ui: &Ui, m: &LineMarkers, x: (f32, f32), y: f32, k: f32, c: egui::Color32, w: f32) { + let mut xs = Vec::new(); + if m.at.has(Placements::INTERVAL) { + let step = (m.interval * k).max(4.0); + let mut at = x.0 + (m.offset_along * k).rem_euclid(step); + while at <= x.1 && xs.len() < 256 { + xs.push(at); + at += step; + } + } + if m.at.has(Placements::VERTICES) || m.at.has(Placements::FIRST) { + xs.push(x.0); + } + if m.at.has(Placements::VERTICES) || m.at.has(Placements::LAST) { + xs.push(x.1); + } + if m.at.has(Placements::MIDDLE) || m.at.has(Placements::INNER_VERTICES) || m.at.has(Placements::SEGMENT_MIDDLES) { + xs.push((x.0 + x.1) / 2.0); + } + let half = (m.size * k).max(1.0) / 2.0; + for at in xs { + match m.mark { + LineMark::Hash => { + let (sin, cos) = m.angle.to_radians().sin_cos(); + let (dx, dy) = (half * sin, half * cos); + ui.painter().line_segment([pos2(at - dx, y + dy), pos2(at + dx, y - dy)], Stroke::new(w, c)); + } + 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(_) => { + ui.painter().circle_filled(pos2(at, y), half, c); + } + } + } +} + +/// The stroke editor's rows for a stroke of markers or hash marks (`st`, its markers `m`): the +/// shape, its size, where it goes and how far apart, and whether it turns with the line. +fn marker_rows(app: &mut RustGisApp, ui: &mut Ui, i: usize, st: LineStroke, m: LineMarkers, set: &impl Fn(&mut RustGisApp, LineStroke)) { + let with = |m: LineMarkers| LineStroke { markers: Some(m), ..st }; + if let LineMark::Marker(shape) = m.mark { + let input_label = ui.label(tl!("Shape")); + egui::ComboBox::from_id_salt(("stroke_marker_shape", i)) + .selected_text(tl!(shape.label())) + .show_ui(ui, |ui| { + for s in Marker::ALL { + if ui.selectable_label(s == shape, tl!(s.label())).clicked() { + set(app, with(LineMarkers { mark: LineMark::Marker(s), ..m })); + } + } + }) + .response + .labelled_by(input_label.id); + ui.end_row(); + } + let input_label = ui.label(if m.mark == LineMark::Hash { tl!("Length") } else { tl!("Size") }); + let mut size = m.size; + if ui + .add(egui::DragValue::new(&mut size).range(0.0..=MAX_LINE_MARKER_SIZE).speed(0.1).suffix(" pt")) + .labelled_by(input_label.id) + .changed() + { + set(app, with(LineMarkers { size, ..m })); + } + ui.end_row(); + let input_label = ui.label(tl!("Place")); + let places = marker_places(); + let shown = + places.iter().find(|(_, p)| *p == m.at).map_or_else(|| tl!("Several places").to_string(), |(label, _)| tl!(label).to_string()); + egui::ComboBox::from_id_salt(("stroke_marker_place", i)) + .selected_text(shown) + .show_ui(ui, |ui| { + for (label, p) in places { + if ui.selectable_label(p == m.at, tl!(label)).clicked() { + set(app, with(LineMarkers { at: p, ..m })); + } + } + }) + .response + .labelled_by(input_label.id); + ui.end_row(); + if m.at.has(Placements::INTERVAL) { + let input_label = ui.label(tl!("Interval")); + let mut interval = m.interval; + let drag = egui::DragValue::new(&mut interval).range(MIN_MARKER_INTERVAL..=MAX_MARKER_INTERVAL).speed(0.2).suffix(" pt"); + if ui.add(drag).on_hover_text(tl!("The distance between two marks along the line")).labelled_by(input_label.id).changed() { + set(app, with(LineMarkers { interval, ..m })); + } + ui.end_row(); + } + ui.label(tl!("Angle")); + ui.horizontal(|ui| { + let mut angle = m.angle; + let drag = egui::DragValue::new(&mut angle).range(0.0..=359.9).speed(1.0).suffix("°"); + let tip = if m.mark == LineMark::Hash { tl!("0° crosses the line square") } else { tl!("Turned further, clockwise") }; + if ui.add(drag).on_hover_text(tip).changed() { + set(app, with(LineMarkers { angle, ..m })); + } + let mut rotate = m.rotate; + if ui.checkbox(&mut rotate, tl!("With the line")).on_hover_text(tl!("Turn each mark with the line's direction")).changed() { + set(app, with(LineMarkers { rotate, ..m })); + } + }); + ui.end_row(); +} + /// The Strokes section of a line layer's symbology: strokes stacked in one symbol, each with /// its own colour, width, sideways offset, cap and join. Listed top first, as they are seen. A /// layer with classes can stack strokes for one class of its own, picked at the top. @@ -4286,7 +4428,10 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma levelled.sort_by_key(|(level, _)| *level); preview = levelled.into_iter().map(|(_, st)| st).collect(); } - let reach = preview.iter().map(|st| st.offset.abs() + st.resolve(&base).1 / 2.0).fold(1.0_f32, f32::max); + let reach = preview + .iter() + .map(|st| st.offset.abs() + st.resolve(&base).1.max(st.markers.map_or(0.0, |m| m.size)) / 2.0) + .fold(1.0_f32, f32::max); let k = (18.0 / reach).min(1.0); let (x0, x1) = (r.min.x + 14.0, r.max.x - 14.0); let cap_pad = |st: &LineStroke, w: f32| if st.cap == LineCap::Butt { 0.0 } else { w / 2.0 }; @@ -4294,6 +4439,10 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma let (c, w) = st.resolve(&base); let w = (w * k).max(0.5); let y = r.center().y + st.offset * k; + if let Some(m) = &st.markers { + marker_preview(ui, m, (x0, x1), y, k, c32(c), w); + continue; + } let pad = cap_pad(st, w); // The dashes along the preview, scaled as the strokes are. Solid when the map draws the // pattern solid: under a pixel a round on this screen, at the pattern's own size. @@ -4378,6 +4527,32 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma }; egui::Grid::new(("stroke_grid", i)).num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { let (c, w) = st.resolve(&base); + let input_label = ui.label(tl!("Draw")); + let kind = |st: &LineStroke| match st.markers.map(|m| m.mark) { + None => tl!("Line"), + Some(LineMark::Hash) => tl!("Hash marks"), + Some(LineMark::Marker(_)) => tl!("Markers"), + }; + egui::ComboBox::from_id_salt(("stroke_draw", i)) + .selected_text(kind(&st)) + .show_ui(ui, |ui| { + let line = LineStroke { markers: None, ..st }; + let markers = LineStroke { + markers: Some(LineMarkers { mark: LineMark::Marker(Marker::Circle), ..st.markers.unwrap_or_default() }), + ..st + }; + let hash = + LineStroke { markers: Some(LineMarkers { mark: LineMark::Hash, ..st.markers.unwrap_or_default() }), ..st }; + for option in [line, markers, hash] { + if ui.selectable_label(kind(&option) == kind(&st), kind(&option)).clicked() && kind(&option) != kind(&st) { + set(app, option); + } + } + }) + .response + .on_hover_text(tl!("A line, or markers or hash marks along it")) + .labelled_by(input_label.id); + ui.end_row(); ui.label(tl!("Color")); ui.horizontal(|ui| { let mut cc = c32(c); @@ -4414,76 +4589,86 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma set(app, LineStroke { offset: off, ..st }); } ui.end_row(); - let input_label = ui.label(tl!("Cap")); - egui::ComboBox::from_id_salt(("stroke_cap", i)) - .selected_text(tl!(st.cap.label())) - .show_ui(ui, |ui| { - for c in LineCap::ALL { - if ui.selectable_label(c == st.cap, tl!(c.label())).clicked() { - set(app, LineStroke { cap: c, ..st }); - } - } - }) - .response - .labelled_by(input_label.id); - ui.end_row(); - let input_label = ui.label(tl!("Join")); - egui::ComboBox::from_id_salt(("stroke_join", i)) - .selected_text(tl!(st.join.label())) - .show_ui(ui, |ui| { - for j in LineJoin::ALL { - if ui.selectable_label(j == st.join, tl!(j.label())).clicked() { - set(app, LineStroke { join: j, ..st }); + if let Some(m) = st.markers { + marker_rows(app, ui, i, st, m, &set); + } else { + let input_label = ui.label(tl!("Cap")); + egui::ComboBox::from_id_salt(("stroke_cap", i)) + .selected_text(tl!(st.cap.label())) + .show_ui(ui, |ui| { + for c in LineCap::ALL { + if ui.selectable_label(c == st.cap, tl!(c.label())).clicked() { + set(app, LineStroke { cap: c, ..st }); + } } - } - }) - .response - .labelled_by(input_label.id); - ui.end_row(); - ui.label(tl!("Dash")); - ui.horizontal(|ui| { - // The pattern as typed, kept while the box has focus; set when it loses it. The - // draft remembers whose stroke it is, so it never shows or lands on another - // layer's or class's stroke at the same place. - let id = ui.scope_id().with(("stroke_dash", i)); - let owner = (l.id, class.cloned()); - let shown = st.dash.map(|d| dash_text(&d)).unwrap_or_default(); - let draft = draft_for(ui.data_mut(|m| m.get_temp::(id)), &owner); - let typed = draft.is_some(); - let mut text = draft.unwrap_or(shown); - let edit = egui::TextEdit::singleline(&mut text).hint_text(tl!("Solid")).desired_width(84.0); - let r = - ui.add(edit).on_hover_text(tl!("Dash and gap lengths in points, in turn, such as 4 2. Empty for a solid line.")); - if r.changed() { - ui.data_mut(|m| m.insert_temp::(id, (owner.0, owner.1.clone(), text.clone()))); - } - // Text that isn't a pattern is outlined: it won't be kept when the box loses focus. - if parse_dash(&text).is_none() { - ui.painter().rect_stroke(r.rect, CornerRadius::same(2), egui::Stroke::new(1.5, t.red), egui::StrokeKind::Outside); - } - if r.lost_focus() { - ui.data_mut(|m| m.remove::(id)); - // As the class label box does, Escape drops what was typed. - let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); - if let Some(dash) = dash_commit(typed || r.changed(), escaped, &text, st.dash) { - set(app, LineStroke { dash, ..st }); - } - } - let preset = egui::ComboBox::from_id_salt(("stroke_dash_preset", i)).selected_text("").width(24.0); - preset + }) + .response + .labelled_by(input_label.id); + ui.end_row(); + let input_label = ui.label(tl!("Join")); + egui::ComboBox::from_id_salt(("stroke_join", i)) + .selected_text(tl!(st.join.label())) .show_ui(ui, |ui| { - for (label, steps) in DASH_PRESETS { - // Scaled by the stroke's width, so a thick line's dashes stay in proportion. - let dash = steps.and_then(|s| Dash::new(&s.iter().map(|v| v * w.max(1.0)).collect::>())); - if ui.selectable_label(dash == st.dash, tl!(label)).clicked() { - set(app, LineStroke { dash, ..st }); + for j in LineJoin::ALL { + if ui.selectable_label(j == st.join, tl!(j.label())).clicked() { + set(app, LineStroke { join: j, ..st }); } } }) .response - .on_hover_text(tl!("Dash")); - }); - ui.end_row(); + .labelled_by(input_label.id); + ui.end_row(); + ui.label(tl!("Dash")); + ui.horizontal(|ui| { + // The pattern as typed, kept while the box has focus; set when it loses it. The + // draft remembers whose stroke it is, so it never shows or lands on another + // layer's or class's stroke at the same place. + let id = ui.scope_id().with(("stroke_dash", i)); + let owner = (l.id, class.cloned()); + let shown = st.dash.map(|d| dash_text(&d)).unwrap_or_default(); + let draft = draft_for(ui.data_mut(|m| m.get_temp::(id)), &owner); + let typed = draft.is_some(); + let mut text = draft.unwrap_or(shown); + let edit = egui::TextEdit::singleline(&mut text).hint_text(tl!("Solid")).desired_width(84.0); + let r = ui + .add(edit) + .on_hover_text(tl!("Dash and gap lengths in points, in turn, such as 4 2. Empty for a solid line.")); + if r.changed() { + ui.data_mut(|m| m.insert_temp::(id, (owner.0, owner.1.clone(), text.clone()))); + } + // Text that isn't a pattern is outlined: it won't be kept when the box loses focus. + if parse_dash(&text).is_none() { + ui.painter().rect_stroke( + r.rect, + CornerRadius::same(2), + egui::Stroke::new(1.5, t.red), + egui::StrokeKind::Outside, + ); + } + if r.lost_focus() { + ui.data_mut(|m| m.remove::(id)); + // As the class label box does, Escape drops what was typed. + let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if let Some(dash) = dash_commit(typed || r.changed(), escaped, &text, st.dash) { + set(app, LineStroke { dash, ..st }); + } + } + let preset = egui::ComboBox::from_id_salt(("stroke_dash_preset", i)).selected_text("").width(24.0); + preset + .show_ui(ui, |ui| { + for (label, steps) in DASH_PRESETS { + // Scaled by the stroke's width, so a thick line's dashes stay in proportion. + let dash = steps.and_then(|s| Dash::new(&s.iter().map(|v| v * w.max(1.0)).collect::>())); + if ui.selectable_label(dash == st.dash, tl!(label)).clicked() { + set(app, LineStroke { dash, ..st }); + } + } + }) + .response + .on_hover_text(tl!("Dash")); + }); + ui.end_row(); + } if l.stroke_levels { ui.label(tl!("Level")); ui.horizontal(|ui| { diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index ec46f7455..6eb50723f 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -159,6 +159,27 @@ layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A 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 +**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 +take the stroke's colour, its width as a hash mark's line (or a marker's outline, by command), and +its offset to either side. Stack them with line strokes: a casing, a core and ticks across it. By +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"}}]} +``` + +`"at"` takes `"interval"`, `"vertices"`, `"innerVertices"`, `"first"`, `"last"`, `"middle"` and +`"segmentMiddles"`, or a list of them; `"offsetAlong"` moves the marks along the line (from its +start, back from its end, on from its middle), `"averageAngle"` turns a mark at an interval with +the line over that stretch of it (smoother on a line of many short bends), `"rotate": false` +keeps marks upright, and `"outline"` gives markers an outline as wide as the stroke. + ### Reference scale Symbol and label sizes are set in points, and normally keep that size on screen at every scale. diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 054c0d295..38ff56757 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -39,7 +39,10 @@ Each layer keeps its look: displacement and other renderers that wrap one are drawn by the one inside. - **Symbols**: simple fills, lines and markers with their colours, widths in any unit, offsets, 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. + 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). - **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,7 +66,8 @@ near as it can, or left out, and said: the import's notes list, layer by layer, approximated (pattern fills drawn as a tint of their colour, font and 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 (marker lines, data-defined +such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, font markers +along 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,