diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 3b2e032fb..44aed5602 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -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)}] (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)}] (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")?; @@ -423,7 +423,26 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { .ok_or_else(|| format!("stroke {}: level is a whole number from 0 to 255", i + 1))?, ), }; - strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level }.clamped()); + let bad_dash = || { + format!( + "stroke {}: dash is a list of 1 to {} lengths in points (dash, gap, …), each 0 to {}, not all 0", + i + 1, + rustgis_map::symbology::MAX_DASH_STEPS, + rustgis_map::symbology::MAX_DASH_STEP + ) + }; + // A pattern out of range is an error here, so a script learns of it; a project file's is + // brought into range instead (`de_dash`), so a hand-edited file still opens. + let dash = match o.get("dash") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let steps: Vec = + a.iter().map(|v| v.as_f64().map(|x| x.clamp(-1e6, 1e6) as f32)).collect::>().ok_or_else(bad_dash)?; + Some(rustgis_map::symbology::Dash::new(&steps).ok_or_else(bad_dash)?) + } + 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 id = s.layer_id(p)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a3cea6946..e850a7c18 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -351,6 +351,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { cap: LineCap::Round, join: LineJoin::Round, level: None, + dash: None, }, LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), @@ -359,6 +360,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { cap: LineCap::Butt, join: LineJoin::Miter, level: None, + dash: None, }, LineStroke { offset: -2.0, ..LineStroke::default() }, ]; @@ -405,6 +407,22 @@ fn line_strokes_stack_and_survive_save_and_undo() { assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1); } +#[test] +fn a_stroke_is_dashed_by_command() { + let mut s = world(); + let stroke = |dash: Value| json!({"layer": "Rivers", "strokes": [{"color": "#000000", "width": 2, "dash": dash}]}); + s.run("layer.lineStrokes", &stroke(json!([4, 2.5]))).unwrap(); + let dash = |s: &Session| s.map().layer_by_name("Rivers").unwrap().line_strokes[0].dash.map(|d| d.steps().to_vec()); + assert_eq!(dash(&s), Some(vec![4.0, 2.5])); + let depth = s.undo_depth(); + for bad in [json!([]), json!([0, 0]), json!([-1, 2]), json!([1, "2"]), json!("4 2"), json!([1, 1, 1, 1, 1, 1, 1, 1, 1]), json!([1e9])] { + assert!(s.run("layer.lineStrokes", &stroke(bad.clone())).is_err(), "{bad}"); + } + assert_eq!((s.undo_depth(), dash(&s)), (depth, Some(vec![4.0, 2.5])), "a refused pattern changes nothing"); + s.run("layer.lineStrokes", &stroke(Value::Null)).unwrap(); + assert_eq!(dash(&s), None, "null: solid"); +} + #[test] fn a_class_stacks_strokes_of_its_own() { use rustgis_map::symbology::ClassRef; diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 9bb76f2e6..ccdd4a768 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -1645,6 +1645,40 @@ mod tests { ); } + #[test] + fn dash_patterns_are_kept_and_hostile_ones_brought_into_range() { + use symbology::{Dash, MAX_DASH_STEP, MAX_DASH_STEPS}; + // Built: in range, not all 0, at most MAX_DASH_STEPS. + assert_eq!(Dash::new(&[4.0, 2.0]).map(|d| d.steps().to_vec()), Some(vec![4.0, 2.0])); + assert_eq!(Dash::new(&[3.0]).map(|d| (d.even_steps(), d.period())), Some((vec![3.0, 3.0], 6.0)), "odd: twice over"); + for bad in [&[][..], &[0.0, 0.0], &[-1.0, 2.0], &[f32::NAN, 1.0], &[MAX_DASH_STEP + 1.0], &[1.0; MAX_DASH_STEPS + 1]] { + assert!(Dash::new(bad).is_none(), "{bad:?}"); + } + // Saved as a plain list and read back. + let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); + l.line_strokes = vec![symbology::LineStroke { dash: Dash::new(&[4.0, 2.0, 1.0, 2.0]), ..Default::default() }]; + let text = serde_json::to_string(&l).unwrap(); + assert!(text.contains(r#""dash":[4.0,2.0,1.0,2.0]"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap().line_strokes, l.line_strokes); + // A crafted project's: steps clamped, too many cut, and one without length solid. + 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].dash; + v["line_strokes"][0]["dash"] = serde_json::json!([1e30, -5, 2]); + assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 0.0, 2.0])); + // Too large for an f32 (infinite once read): the longest step, as a large one is. + v["line_strokes"][0]["dash"] = serde_json::json!([1e40, 2]); + assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 2.0])); + v["line_strokes"][0]["dash"] = serde_json::json!(vec![1.0; 5000]); + assert_eq!(read(&v).map(|d| d.steps().len()), Some(MAX_DASH_STEPS)); + for solid in [serde_json::json!([0, 0]), serde_json::json!([]), serde_json::json!([-3])] { + v["line_strokes"][0]["dash"] = solid.clone(); + assert_eq!(read(&v), None, "{solid}"); + // Read as a stroke alone too, so no empty pattern is ever made. + let st = serde_json::from_value::(v["line_strokes"][0].clone()).unwrap(); + assert_eq!(st.dash, None, "{solid}"); + } + } + #[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/symbology.rs b/crates/map/src/symbology.rs index 2b79dba9d..c5cb4dc6a 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -162,6 +162,66 @@ pub const MAX_STROKES: usize = 16; /// The widest stroke and the farthest offset, in points. pub const MAX_STROKE_WIDTH: f32 = 50.0; pub const MAX_STROKE_OFFSET: f32 = 100.0; +/// The most steps a dash pattern has, and the longest step, in points. +pub const MAX_DASH_STEPS: usize = 8; +pub const MAX_DASH_STEP: f32 = 500.0; + +/// A stroke's dash pattern: lengths in points, dash and gap in turn, starting with a dash (as +/// SVG's `stroke-dasharray`: an odd number of steps is repeated to make it even). Written to a +/// project as that list, `[4, 2]`. Built only through [`Dash::new`], so its steps are always +/// in range and add up to more than nothing. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(into = "Vec")] +pub struct Dash { + steps: [f32; MAX_DASH_STEPS], + len: u8, +} + +impl Dash { + /// The pattern of `steps` (at most [`MAX_DASH_STEPS`], each 0 to [`MAX_DASH_STEP`]), or + /// `None` for none: no steps, a step that isn't a number from 0 to [`MAX_DASH_STEP`], too + /// many, or all of them 0. + pub fn new(steps: &[f32]) -> Option { + let ok = |v: &f32| v.is_finite() && (0.0..=MAX_DASH_STEP).contains(v); + if steps.is_empty() || steps.len() > MAX_DASH_STEPS || !steps.iter().all(ok) || steps.iter().sum::() <= 0.0 { + return None; + } + let mut d = Dash { steps: [0.0; MAX_DASH_STEPS], len: u8::try_from(steps.len()).ok()? }; + d.steps.get_mut(..steps.len())?.copy_from_slice(steps); + Some(d) + } + + /// Its steps, as given. + pub fn steps(&self) -> &[f32] { + self.steps.get(..usize::from(self.len)).unwrap_or(&[]) + } + + /// Its steps made even (an odd number twice over), as a renderer takes them. + pub fn even_steps(&self) -> Vec { + let s = self.steps(); + if s.len().is_multiple_of(2) { s.to_vec() } else { s.iter().chain(s).copied().collect() } + } + + /// One round of the pattern, in points. + pub fn period(&self) -> f32 { + self.even_steps().iter().sum() + } +} + +/// Reads a stroke's pattern from a project, brought into range: at most [`MAX_DASH_STEPS`] steps, +/// each clamped to 0 to [`MAX_DASH_STEP`] (one too large for a number, infinite, the longest); +/// one left without length is `None`, solid (a project file is hostile input). +fn de_dash<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let Some(v) = Option::>::deserialize(d)? else { return Ok(None) }; + let steps: Vec = v.iter().take(MAX_DASH_STEPS).map(|x| if x.is_nan() { 0.0 } else { x.clamp(0.0, MAX_DASH_STEP) }).collect(); + Ok(Dash::new(&steps)) +} + +impl From for Vec { + fn from(d: Dash) -> Vec { + d.steps().to_vec() + } +} /// One stroke of a line layer's stacked symbol (`Layer::line_strokes`). Widths and offsets are /// in points, so they keep their size on screen at every scale; the geometry is left as it is. @@ -188,11 +248,14 @@ pub struct LineStroke { /// join under the cores. `None` is its place in the stack, the bottom one 0. #[serde(default, skip_serializing_if = "Option::is_none")] pub level: Option, + /// 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, } impl Default for LineStroke { fn default() -> Self { - LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None } + LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None, dash: None } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f448e075b..5cf9c9693 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -358,6 +358,9 @@ fn paint(c: Rgba, opacity: f32) -> Paint<'static> { p } +/// The most dashes a dashed stroke is cut into along one path (`Ctx::dash`). +const MAX_DASHES: f32 = 100_000.0; + struct Ctx<'a> { view: &'a View, ppp: f32, @@ -499,6 +502,12 @@ impl Ctx<'_> { } fn ring_path(&self, pb: &mut PathBuilder, ring: &[Coord], close: bool) { + self.ring_path_at(pb, ring, close, &mut DashStarts::default()); + } + + /// [`Self::ring_path`], noting in `starts` how far along its line each piece it adds begins, + /// so a dash pattern can carry on across a cut. + fn ring_path_at(&self, pb: &mut PathBuilder, ring: &[Coord], close: bool, starts: &mut DashStarts) { if let Some(g) = &self.globe { if close { self.globe_ring(g, pb, ring) @@ -528,16 +537,19 @@ impl Ctx<'_> { } if clip.contains(Coord::new(b.min_x, b.min_y)) && clip.contains(Coord::new(b.max_x, b.max_y)) { emit_run(pb, run.iter().map(dev), close); + starts.push(0.0); } else if close { // Zoomed in on a big polygon: its far vertices would be millions of pixels away, // which the rasteriser can't take (and needn't walk). Clipping each ring to a box // well beyond the image keeps the even-odd fill the same inside it. let pts: Vec<(f64, f64)> = run.iter().map(dev).collect(); emit_run(pb, clip_ring(&pts, &clip).into_iter(), true); + starts.push(0.0); } else { let pts: Vec<(f64, f64)> = run.iter().map(dev).collect(); - for part in clip_line(&pts, &clip) { + for (start, part) in clip_line(&pts, &clip) { emit_run(pb, part.into_iter(), false); + starts.push(start as f32); } } } @@ -584,15 +596,34 @@ impl Ctx<'_> { /// A line drawn as its stacked strokes (`Layer::strokes`), bottom first, each /// shifted sideways by its offset. fn stacked_line(&self, pm: &mut Pixmap, parts: &[Vec], s: &Symbol, strokes: &[LineStroke]) { - let mut pb = PathBuilder::new(); + let (mut pb, mut starts) = (PathBuilder::new(), DashStarts::default()); for part in parts { - self.ring_path(&mut pb, part, false); + self.ring_path_at(&mut pb, part, false, &mut starts); } let Some(path) = pb.finish() else { return }; - self.stacked_path(pm, &path, s, strokes); + self.stacked_path(pm, &path, s, strokes, &starts); + } + + /// A stroke's dash pattern in pixels for `path`, or `None` to draw it solid: a pattern under a + /// pixel a round, or one that would cut the path into more than [`MAX_DASHES`] dashes (each + /// is a path of its own), would only cost time. The whole path counts, on screen or not: the + /// dashing doesn't skip what's off screen. + fn dash(&self, d: &rustgis_map::symbology::Dash, path: &tiny_skia::Path) -> Option> { + let steps: Vec = d.even_steps().iter().map(|v| v * self.ppp).collect(); + let period: f32 = steps.iter().sum(); + // Each round of the pattern draws one dash per pair of steps. + let dashes = path_length(path) / period * (steps.len() / 2) as f32; + if !(period >= 1.0 && dashes <= MAX_DASHES) { + return None; + } + Some(steps) } - fn stacked_path(&self, pm: &mut Pixmap, path: &tiny_skia::Path, s: &Symbol, strokes: &[LineStroke]) { + /// `starts`: where along its line each of `path`'s pieces begins ([`Self::ring_path_at`]), so a + /// 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) { // Shifted once per offset: a casing and its core usually share one. let mut shifted: Vec<(f32, Option)> = Vec::new(); for st in strokes { @@ -622,7 +653,29 @@ impl Ctx<'_> { }, ..Stroke::default() }; - pm.stroke_path(drawn, &paint(c, 1.0), &stroke, Transform::identity(), None); + let steps = st.dash.and_then(|d| self.dash(&d, drawn)); + let period: f32 = steps.iter().flatten().sum(); + // A dashed line cut into pieces: each piece starts its pattern where its line is. Only + // while every piece has its start: one off would shift every later piece's dashes. + let pieces: Vec<(tiny_skia::Path, f32)> = match (&steps, starts.of(drawn)) { + (Some(_), Some(at)) => { + let c = contours(drawn); + if c.len() == at.len() { c.into_iter().zip(at.iter().copied()).collect() } else { Vec::new() } + } + _ => Vec::new(), + }; + match steps { + Some(steps) if !pieces.is_empty() => { + for (piece, at) in &pieces { + let dash = tiny_skia::StrokeDash::new(steps.clone(), at.rem_euclid(period)); + pm.stroke_path(piece, &paint(c, 1.0), &Stroke { dash, ..stroke.clone() }, Transform::identity(), None); + } + } + steps => { + let dash = steps.and_then(|steps| tiny_skia::StrokeDash::new(steps, 0.0)); + pm.stroke_path(drawn, &paint(c, 1.0), &Stroke { dash, ..stroke }, Transform::identity(), None); + } + } } } @@ -758,6 +811,35 @@ impl Ctx<'_> { } } +/// The length of `path` in pixels, at most a little over: a curve counts as its control polygon, +/// which is never shorter than the curve. +fn path_length(path: &tiny_skia::Path) -> f32 { + let (mut len, mut last, mut start) = (0.0, None, None); + for seg in path.segments() { + let pts = match seg { + tiny_skia::PathSegment::MoveTo(p) => { + (last, start) = (Some(p), Some(p)); + continue; + } + tiny_skia::PathSegment::LineTo(p) => [p, p, p], + tiny_skia::PathSegment::QuadTo(a, p) => [a, p, p], + tiny_skia::PathSegment::CubicTo(a, b, p) => [a, b, p], + tiny_skia::PathSegment::Close => match start { + Some(p) => [p, p, p], + None => continue, + }, + }; + if let Some(mut q) = last { + for p in pts { + len += q.distance(p); + q = p; + } + } + last = Some(pts[2]); + } + len +} + /// `path`'s polylines shifted `d` pixels to the right of their direction (screen y down). fn offset_path(path: &tiny_skia::Path, d: f32) -> Option { let mut pb = PathBuilder::new(); @@ -899,13 +981,19 @@ fn clip_ring(pts: &[(f64, f64)], b: &Extent) -> Vec<(f64, f64)> { if cur.len() < 3 { Vec::new() } else { cur } } -/// The parts of a line inside a box (each segment clipped by Liang–Barsky). -fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { - let mut parts: Vec> = Vec::new(); +/// The parts of a line inside a box (each segment clipped by Liang–Barsky), each with how far +/// along the line it starts. +fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec<(f64, Vec<(f64, f64)>)> { + let mut parts: Vec<(f64, Vec<(f64, f64)>)> = Vec::new(); let mut cur: Vec<(f64, f64)> = Vec::new(); + // Length of the line before this segment, and where the part being built starts. + let (mut along, mut start) = (0.0f64, 0.0f64); for w in pts.windows(2) { let (Some(&p), Some(&q)) = (w.first(), w.get(1)) else { continue }; let (dx, dy) = (q.0 - p.0, q.1 - p.1); + let len = dx.hypot(dy); + let before = along; + along += len; let (mut t0, mut t1) = (0.0f64, 1.0f64); let mut keep = true; for (num, den) in [(p.0 - b.min_x, -dx), (b.max_x - p.0, dx), (p.1 - b.min_y, -dy), (b.max_y - p.1, dy)] { @@ -925,7 +1013,7 @@ fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { } if !keep || t0 > t1 { if cur.len() > 1 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } cur.clear(); continue; @@ -934,21 +1022,75 @@ fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { let z = (p.0 + dx * t1, p.1 + dy * t1); if cur.is_empty() || t0 > 0.0 { if cur.len() > 1 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } cur = vec![a]; + start = before + len * t0; } cur.push(z); if t1 < 1.0 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } } if cur.len() > 1 { - parts.push(cur); + parts.push((start, cur)); } parts } +/// Where along its line each piece of a path begins, in device pixels, as [`Ctx::ring_path_at`] +/// adds them. Nothing is kept while every piece begins at its line's start (nothing was cut): +/// pieces starting at 0, 0 keep `at` empty; a third starting at 41 makes it [0, 0, 41]. `pieces` +/// counts every piece pushed, so the zeros before the first cut can be filled in then. +#[derive(Default)] +pub(crate) struct DashStarts { + pieces: usize, + at: Vec, +} + +impl DashStarts { + fn push(&mut self, at: f32) { + if self.at.is_empty() && at != 0.0 { + self.at.resize(self.pieces, 0.0); + } + if !self.at.is_empty() || at != 0.0 { + self.at.push(at); + } + self.pieces += 1; + } + + /// The starts for `path`'s pieces, when some piece doesn't begin at its line's start and they + /// match the path's pieces one to one. + fn of(&self, path: &tiny_skia::Path) -> Option<&[f32]> { + let n = path.segments().filter(|s| matches!(s, tiny_skia::PathSegment::MoveTo(_))).count(); + (!self.at.is_empty() && self.at.len() == n).then_some(self.at.as_slice()) + } +} + +/// A path's pieces (from each move-to), each a path of its own. +fn contours(path: &tiny_skia::Path) -> Vec { + let mut out = Vec::new(); + let mut pb = PathBuilder::new(); + for seg in path.segments() { + match seg { + tiny_skia::PathSegment::MoveTo(p) => { + if let Some(done) = std::mem::replace(&mut pb, PathBuilder::new()).finish() { + out.push(done); + } + pb.move_to(p.x, p.y); + } + tiny_skia::PathSegment::LineTo(p) => pb.line_to(p.x, p.y), + tiny_skia::PathSegment::QuadTo(a, p) => pb.quad_to(a.x, a.y, p.x, p.y), + tiny_skia::PathSegment::CubicTo(a, b, p) => pb.cubic_to(a.x, a.y, b.x, b.y, p.x, p.y), + tiny_skia::PathSegment::Close => pb.close(), + } + } + if let Some(done) = pb.finish() { + out.push(done); + } + out +} + /// Blends `color` at `coverage` (in pixels, spread bilinearly) around (`cx`, `cy`) device pixels. fn splat_color(pm: &mut Pixmap, cx: f32, cy: f32, color: Rgba, coverage: f32) { if color.a() == 0 || coverage.is_nan() || coverage <= 0.0 || !cx.is_finite() || !cy.is_finite() { @@ -4894,7 +5036,7 @@ mod tests { assert_eq!(clip_ring(&inside, &b), inside.to_vec()); // A line in and out of the box comes back as its two parts inside. let parts = clip_line(&[(-5.0, 2.0), (5.0, 2.0), (5.0, 20.0), (8.0, 20.0), (8.0, 5.0), (15.0, 5.0)], &b); - assert_eq!(parts, vec![vec![(0.0, 2.0), (5.0, 2.0), (5.0, 10.0)], vec![(8.0, 10.0), (8.0, 5.0), (10.0, 5.0)]]); + assert_eq!(parts, vec![(5.0, vec![(0.0, 2.0), (5.0, 2.0), (5.0, 10.0)]), (41.0, vec![(8.0, 10.0), (8.0, 5.0), (10.0, 5.0)])]); assert!(clip_line(&[(-5.0, -5.0), (-1.0, 20.0)], &b).is_empty()); } @@ -5475,6 +5617,119 @@ mod tests { assert_eq!(colour(&r, 60), (0, 0, 0)); } + #[test] + fn a_line_cut_at_the_clip_box_keeps_its_dashes_where_they_were() { + // A dashed line from far left of the image (beyond the clip box, where it's cut) across it: + // drawn cut, its dashes lie where the whole line, drawn uncut, has them. So do an offset + // stroke's, shifted beside it (straight, so the shifted line is as long as the line). + let camera = Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 100.0, height: 20.0, pixels_per_point: 1.0, camera, sky: false }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + // 1,003.5 px of line before the image, not a whole number of the pattern's 7 px rounds. + let line = [Coord::new(-1053.5, 0.0), Coord::new(60.0, 0.0)]; + let (mut pb, mut starts) = (PathBuilder::new(), DashStarts::default()); + ctx.ring_path_at(&mut pb, &line, false, &mut starts); + let cut = pb.finish().unwrap(); + assert!(cut.bounds().left() > -200.0, "cut at the clip box: {:?}", cut.bounds()); + for offset in [0.0, 3.0] { + let stroke = LineStroke { + color: Some(Rgba::rgb(0, 0, 0)), + width: Some(2.0), + offset, + cap: rustgis_map::LineCap::Butt, + dash: rustgis_map::symbology::Dash::new(&[4.0, 3.0]), + ..Default::default() + }; + 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); + let mut whole = Pixmap::new(100, 20).unwrap(); + let mut pb = PathBuilder::new(); + let (x0, y) = view.to_screen(line[0]); + let (x1, _) = view.to_screen(line[1]); + // Right of the line's direction: left to right, that's down the screen. + let y = y as f32 + offset; + pb.move_to(x0 as f32, y); + pb.line_to(x1 as f32, y); + let st = + Stroke { width: 2.0, line_cap: LineCap::Butt, dash: tiny_skia::StrokeDash::new(vec![4.0, 3.0], 0.0), ..Stroke::default() }; + whole.stroke_path(&pb.finish().unwrap(), &paint(Rgba::rgb(0, 0, 0), 1.0), &st, Transform::identity(), None); + let row = |pm: &Pixmap| (0..100).map(|x| pm.pixel(x, (y as u32).min(19)).unwrap().alpha() > 128).collect::>(); + assert!(row(&whole).iter().any(|d| *d) && row(&whole).iter().any(|d| !*d), "dashed"); + let differ = row(&drawn).iter().zip(row(&whole)).filter(|(a, b)| **a != *b).count(); + assert!(differ <= 2, "offset {offset}: {differ} pixels differ:\n{:?}\n{:?}", row(&drawn), row(&whole)); + } + } + + #[test] + fn a_winding_path_counts_its_length_against_the_dash_limit() { + // A zigzag inside a 10-pixel square, 20,000 times across it: 200,000 pixels of line in + // bounds whose span is 40. + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + for i in 0..20_000 { + pb.line_to(if i % 2 == 0 { 10.0 } else { 0.0 }, 0.0); + } + let zigzag = pb.finish().unwrap(); + assert!((path_length(&zigzag) - 200_000.0).abs() < 1.0); + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(30.0, 0.0); + pb.line_to(30.0, 40.0); + pb.close(); + assert!((path_length(&pb.finish().unwrap()) - 120.0).abs() < 1e-3); + let view = View { + width: 100.0, + height: 100.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + // A 1-pixel period would cut it into 200,000 dashes: drawn solid. A 4-pixel one is fine. + assert!(ctx.dash(&rustgis_map::symbology::Dash::new(&[0.5, 0.5]).unwrap(), &zigzag).is_none()); + assert!(ctx.dash(&rustgis_map::symbology::Dash::new(&[2.0, 2.0]).unwrap(), &zigzag).is_some()); + // Dashes count, not rounds of the pattern: four dashes a 4-pixel round is 200,000 too. + let four = rustgis_map::symbology::Dash::new(&[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]).unwrap(); + assert!(ctx.dash(&four, &zigzag).is_none()); + } + + #[test] + fn a_dashed_stroke_leaves_gaps() { + // A line across the view, one 4-point black stroke: solid it covers the middle of the row; + // dashed 10 on, 10 off (flat caps), about half of it, in runs about 10 pixels long. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("road", 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 stroke = rustgis_map::LineStroke { + color: Some(Rgba::rgb(0, 0, 0)), + width: Some(4.0), + cap: rustgis_map::LineCap::Butt, + ..Default::default() + }; + let dark = |m: &mut Map| { + let r = render(m, &view).unwrap(); + (40..160).map(|x| r.pixmap.pixel(x, 100).unwrap().red() < 128).collect::>() + }; + m.layer_mut(id).unwrap().line_strokes = vec![stroke]; + assert!(dark(&mut m).iter().all(|d| *d), "solid"); + let dash = rustgis_map::symbology::Dash::new(&[10.0, 10.0]); + m.layer_mut(id).unwrap().line_strokes = vec![rustgis_map::LineStroke { dash, ..stroke }]; + let row = dark(&mut m); + let on = row.iter().filter(|d| **d).count(); + assert!((50..=70).contains(&on), "{on} of 120 pixels dark"); + let runs = row.windows(2).filter(|w| w[0] != w[1]).count(); + assert!((10..=13).contains(&runs), "{runs} changes along the row"); + // A pattern under a pixel a round draws solid rather than cut into slivers. + let dash = rustgis_map::symbology::Dash::new(&[0.2, 0.2]); + m.layer_mut(id).unwrap().line_strokes = vec![rustgis_map::LineStroke { dash, ..stroke }]; + assert!(dark(&mut m).iter().all(|d| *d), "too fine: solid"); + } + #[test] fn a_reference_scale_scales_lines_with_the_map() { // A 4-point black line: as wide as set without a reference scale or at it, twice as wide diff --git a/crates/render/src/stroke_paths.rs b/crates/render/src/stroke_paths.rs index 2a71ddade..866116b5e 100644 --- a/crates/render/src/stroke_paths.rs +++ b/crates/render/src/stroke_paths.rs @@ -14,11 +14,12 @@ impl Paths { if let Some(path) = self.cached.take() { builder = path.clear(); } + let mut starts = super::DashStarts::default(); for part in parts { - ctx.ring_path(&mut builder, part, false); + ctx.ring_path_at(&mut builder, part, false, &mut starts); } let Some(path) = builder.finish() else { return }; - ctx.stacked_path(pm, &path, symbol, strokes); + ctx.stacked_path(pm, &path, symbol, strokes, &starts); 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 d289ecba3..714989355 100644 --- a/crates/render/src/stroke_paths/tests.rs +++ b/crates/render/src/stroke_paths/tests.rs @@ -21,9 +21,25 @@ fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { expected.fill(tiny_skia::Color::WHITE); actual.fill(tiny_skia::Color::WHITE); let strokes = [ - LineStroke { offset: 2.0, width: Some(3.0), color: Some(Rgba::new(40, 70, 180, 150)), join, cap, level: None }, - LineStroke { offset: -2.0, width: Some(1.0), color: Some(Rgba::new(220, 30, 40, 200)), join, cap, level: None }, - LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap, level: None }, + LineStroke { + offset: 2.0, + width: Some(3.0), + color: Some(Rgba::new(40, 70, 180, 150)), + join, + cap, + level: None, + dash: None, + }, + LineStroke { + offset: -2.0, + width: Some(1.0), + color: Some(Rgba::new(220, 30, 40, 200)), + join, + cap, + level: None, + dash: 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 4a03c1a0a..3b76e5048 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -9,7 +9,7 @@ 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, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, + ClassRef, ClassStyle, Dash, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, OTHER_VALUES, normalize_label, }; use rustgis_engine::map::tiles::{TileKind, TileLayer}; @@ -3898,6 +3898,49 @@ fn symbol_editor(app: &mut RustGisApp, ui: &mut Ui, s: Symbol, g: GeometryType, }); } +/// The dash patterns the stroke editor offers, in stroke widths (dash, gap, …). +const DASH_PRESETS: [(&str, Option<&[f32]>); 5] = [ + ("Solid", None), + ("Dashed", Some(&[4.0, 2.0])), + ("Dotted", Some(&[1.0, 2.0])), + ("Dash-dot", Some(&[4.0, 2.0, 1.0, 2.0])), + ("Long dash", Some(&[8.0, 3.0])), +]; + +/// A dash pattern being typed in the stroke editor: the layer, the class (`None` for the layer's +/// own strokes) and the text. +type DashDraft = (u32, Option, String); + +/// The text of `draft` if it was typed for `owner`'s stroke, else `None` (the stroke's own pattern +/// shows). +fn draft_for(draft: Option, owner: &(u32, Option)) -> Option { + draft.filter(|(layer, class, _)| *layer == owner.0 && *class == owner.1).map(|(.., text)| text) +} + +/// A dash pattern as the stroke editor shows it: `4 2`. Each length is written as it is kept, so +/// the text reads back as the same pattern. +fn dash_text(d: &Dash) -> String { + d.steps().iter().map(|v| v.to_string()).collect::>().join(" ") +} + +/// The pattern the dash box sets when it loses focus: `None` unless the text was typed (focusing +/// and leaving the box is no change), Escape wasn't what ended it, and it is a pattern other than +/// `current`. +fn dash_commit(typed: bool, escaped: bool, text: &str, current: Option) -> Option> { + parse_dash(text).filter(|d| typed && !escaped && *d != current) +} + +/// A dash pattern typed in the stroke editor: `Some(None)` for none (solid), `None` when it isn't +/// one ([`Dash::new`]). Lengths are separated by spaces, commas or semicolons. +fn parse_dash(text: &str) -> Option> { + let parts: Vec<&str> = text.split([' ', ',', ';']).filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + return Some(None); + } + let steps: Vec = parts.iter().map(|p| p.parse::().ok()).collect::>()?; + Dash::new(&steps).map(Some) +} + /// A stroke as `layer.lineStrokes` takes it. fn stroke_json(st: &LineStroke) -> Value { json!({ @@ -3907,6 +3950,7 @@ fn stroke_json(st: &LineStroke) -> Value { "cap": st.cap.code(), "join": st.join.code(), "level": st.level, + "dash": st.dash.map(|d| d.steps().to_vec()), }) } @@ -4028,12 +4072,33 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma let w = (w * k).max(0.5); let y = r.center().y + st.offset * k; let pad = cap_pad(st, w); - if st.cap == LineCap::Round { - ui.painter().circle_filled(pos2(x0, y), w / 2.0, c32(c)); - ui.painter().circle_filled(pos2(x1, y), w / 2.0, c32(c)); + // 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. + let steps: Vec = st.dash.map(|d| d.even_steps().iter().map(|v| v * k).collect()).unwrap_or_default(); + let mut runs = Vec::new(); + if st.dash.is_some_and(|d| d.period() * ui.ctx().pixels_per_point() >= 1.0) && steps.iter().sum::() > 0.0 { + let (mut x, mut on) = (x0, true); + for step in steps.iter().cycle().take(4096) { + if x >= x1 { + break; + } + let end = (x + step).min(x1); + if on { + runs.push((x, end)); + } + (x, on) = (end, !on); + } + } else { + runs.push((x0, x1)); + } + for (from, to) in runs { + if st.cap == LineCap::Round { + ui.painter().circle_filled(pos2(from, y), w / 2.0, c32(c)); + ui.painter().circle_filled(pos2(to, y), w / 2.0, c32(c)); + } + let (a, b) = if st.cap == LineCap::Square { (from - pad, to + pad) } else { (from, to) }; + ui.painter().line_segment([pos2(a, y), pos2(b, y)], Stroke::new(w, c32(c))); } - let (a, b) = if st.cap == LineCap::Square { (x0 - pad, x1 + pad) } else { (x0, x1) }; - ui.painter().line_segment([pos2(a, y), pos2(b, y)], Stroke::new(w, c32(c))); } // The line's direction. let (ay, ax) = (r.max.y - 6.0, r.max.x - 8.0); @@ -4144,6 +4209,47 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } }); 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 }); + } + } + }); + }); + ui.end_row(); if l.stroke_levels { ui.label(tl!("Level")); ui.horizontal(|ui| { @@ -4943,6 +5049,35 @@ mod tests { assert_eq!(f.values.get("output").map(String::as_str), Some("kept"), "not given"); } + /// A dash being typed shows only on the stroke of the layer and class it was typed for. + #[test] + fn a_dash_draft_stays_with_its_layer_and_class() { + let draft = || Some((3, Some(ClassRef::Index(1)), "4 2".to_string())); + assert_eq!(draft_for(draft(), &(3, Some(ClassRef::Index(1)))).as_deref(), Some("4 2")); + // Another layer, another class, or the layer's own strokes show their own pattern. + assert_eq!(draft_for(draft(), &(4, Some(ClassRef::Index(1)))), None); + assert_eq!(draft_for(draft(), &(3, Some(ClassRef::Index(2)))), None); + assert_eq!(draft_for(draft(), &(3, None)), None); + assert_eq!(draft_for(None, &(3, None)), None); + } + + /// The dash box shows a pattern as kept, and sets one only when it was typed and not escaped: + /// focusing and leaving it never rounds the stroke's pattern. + #[test] + fn the_dash_box_sets_only_a_typed_pattern() { + let kept = Dash::new(&[5.332, 2.666]); + let text = dash_text(&kept.unwrap()); + assert_eq!(text, "5.332 2.666"); + assert_eq!(parse_dash(&text), Some(kept), "the shown text reads back as the same pattern"); + assert_eq!(dash_commit(false, false, &text, kept), None, "focused and left"); + assert_eq!(dash_commit(false, false, "4 2", kept), None, "not typed"); + assert_eq!(dash_commit(true, true, "4 2", kept), None, "Escape drops it"); + assert_eq!(dash_commit(true, false, "4 2", kept), Some(Dash::new(&[4.0, 2.0]))); + assert_eq!(dash_commit(true, false, "", kept), Some(None), "emptied: solid"); + assert_eq!(dash_commit(true, false, "4 x", kept), None, "not a pattern"); + assert_eq!(dash_commit(true, false, "5.332 2.666", kept), None, "typed back as it was"); + } + /// The Strokes for picker lists a value listed twice once, for its first class (the one its /// features draw with), then ``. #[test] diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index a50178c82..b2388998b 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -127,6 +127,16 @@ same). By command, name the class as `layer.classStyle` does, with `value`, `ind layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7},{"color":"#F28C28","width":5},{"color":"#FFFFFF","width":0.6}]} ``` +A stroke can be dashed: **Dash** takes the lengths in points, dash and gap in turn (`4 2`, or +`4 2 1 2` for dash-dot; an odd list is repeated, as in SVG), and its menu offers dashed, +dotted, dash-dot and long-dash patterns sized to the stroke's width. Empty is a solid line. With +flat caps the dashes are as long as given; round and square caps add half the width at each end. +By command, `"dash"` on a stroke (`null` for solid): + +```text +layer.lineStrokes={"layer":"Paths","strokes":[{"color":"#000000","width":1.2,"cap":"flat","dash":[6,3]}]} +``` + By default, each feature draws its whole stack in turn, so where two roads meet, the second one's casing cuts across the first one's core. **Draw strokes in levels** (symbol levels) draws all stacked strokes at a lower level before those at a higher one, so the casings join underneath