From 68be159cfc4e30ac1e9ecfedfc5ad4f8998583a7 Mon Sep 17 00:00:00 2001 From: strebdom Date: Tue, 27 Oct 2026 14:22:07 +0100 Subject: [PATCH 01/10] feat(symbology): stacked polygon fills with hatch lines and markers, from QGIS fill symbols (#882 piece 5a) A polygon layer, or a Unique Values or Graduated Colors class, can stack up to eight fills inside its outline: a colour, hatch lines (angle, spacing, width, offset) or markers on a grid (shape, size, outline, spacing, staggered rows). Patterns are tiles repeated by tiny-skia's pattern shader, anchored to the map's origin so neighbouring polygons share them and they keep their place as the map pans, reference scale included. Fills draw on the CPU; layers with them stay off the GPU paths. - layer.fillLayers sets the layer's or a class's fills (hostile values refused or clamped); one undo step. - The Symbology pane's Fills section adds, orders, edits and removes them. - The QGIS import carries a fill symbol's layers over as a stack: the six line brushes and line pattern fills as hatch lines, point pattern fills as markers, gradients and shapeburst in their first colour, dense brushes, SVG, raster and random fills as a tint, each noted. A single simple fill stays the symbol's fill, as before. - Layers without fills load, save and draw exactly as before. --- crates/engine/src/cmd/embeddings.rs | 2 + crates/engine/src/cmd/layer.rs | 92 +++++++ crates/engine/src/tests.rs | 69 ++++++ crates/map/src/fills.rs | 227 +++++++++++++++++ crates/map/src/lib.rs | 26 ++ crates/map/src/qgis/mod.rs | 6 + crates/map/src/qgis/renderer.rs | 35 ++- crates/map/src/qgis/symbol.rs | 146 ++++++++++- crates/map/src/qgis/tests.rs | 101 +++++++- crates/map/src/symbology.rs | 147 ++++++++++- crates/render/src/layout.rs | 3 + crates/render/src/lib.rs | 127 +++++++++- crates/render/src/pattern_fill.rs | 172 +++++++++++++ crates/ui-egui/src/chrome.rs | 5 +- crates/ui-egui/src/fill_editor.rs | 367 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/panes.rs | 8 +- crates/ui-egui/src/showme.rs | 2 +- docs/user-guide/cartography.md | 25 ++ docs/user-guide/maps.md | 2 +- docs/user-guide/workspace.md | 8 +- 21 files changed, 1546 insertions(+), 28 deletions(-) create mode 100644 crates/map/src/fills.rs create mode 100644 crates/render/src/pattern_fill.rs create mode 100644 crates/ui-egui/src/fill_editor.rs diff --git a/crates/engine/src/cmd/embeddings.rs b/crates/engine/src/cmd/embeddings.rs index 33e1beb4b..3ee34beaa 100644 --- a/crates/engine/src/cmd/embeddings.rs +++ b/crates/engine/src/cmd/embeddings.rs @@ -233,6 +233,8 @@ pub(crate) fn style_points(s: &mut Session, id: u32) { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 2fc38ecd6..badd3fffd 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -179,6 +179,11 @@ pub fn commands() -> Vec { .params( r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"character"|"hash" (a mark across the line), "char"?: text (a character of a font as the marker instead of a shape), "font"?: text (the installed family it is drawn from; none: the label font), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes; on a point layer they are its marks: each stroke's markers drawn at the point in place of the symbol's, "offsetAlong" shifting a mark right and "offset" down, both turned with its "angle", and "rotate" turning it by layer.markerRotation's field), "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.fillLayers", "Fill Layers", "Style › Drawing", fill_layers) + .when(need_polygons) + .params( + r##"{"layer"?, "value"?|"index"?|"other"?: a class of Unique Values (its value, or other: true) or Graduated Colors (its index) to set its own fills (none: the layer's), "fills": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the feature's fill colour, so the fills follow the classes), "pattern"?: "solid" (default)|"hatch" (parallel lines)|"dots" (markers on a grid), "angle"?: degrees anticlockwise from east (hatch), "spacing"?: points between lines (hatch, 1 to 500; default 8) or points across and down between markers, a number or [across, down] (dots), "width"?: a hatch line's width in points (0 to 50; default 1), "offset"?: points across the lines (hatch) or [across, down] (dots), "marker"?: "circle"|"square"|"triangle"|"diamond"|"star" (dots), "size"?: a marker's size in points (default 3), "outline"?: a marker's outline colour or null, "outlineWidth"?: points, "displacement"?: [across, down] points every other row and column are shifted by (dots)}] (bottom first, at most 8; [] for the symbol's single fill; the outline is drawn over them)}"##, + ), C::new("layer.markerRotation", "Marker Rotation", "Style › Drawing", |s, p| { let field = match p.get("field") { None | Some(Value::Null) => None, @@ -608,6 +613,91 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"strokes": n})) } +/// `layer.fillLayers`: a polygon layer's stacked fills (or a class's own, with `value`, `index` +/// or `other`), bottom first; `[]` goes back to the symbol's single fill. +fn fill_layers(s: &mut Session, p: &Value) -> CmdResult { + use rustgis_map::fills::{FillLayer, FillPattern, MAX_FILLS}; + let class = class_ref(p)?; + let list = p.get("fills").and_then(Value::as_array).ok_or("fills is a list of fills ([] for the symbol's single fill)")?; + if list.len() > MAX_FILLS { + return Err(format!("at most {MAX_FILLS} fills")); + } + let mut fills = Vec::with_capacity(list.len()); + for (i, v) in list.iter().enumerate() { + let n = i + 1; + let o = v.as_object().ok_or_else(|| format!("fill {n} is not an object"))?; + let hex = |k: &str| -> Result, String> { + match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(c)) => Rgba::from_hex(c).map(Some).ok_or_else(|| format!("fill {n}: {k} must be #RRGGBB or #RRGGBBAA")), + Some(_) => Err(format!("fill {n}: {k} must be #RRGGBB or #RRGGBBAA")), + } + }; + // Brought into range before narrowing, so 1e300 is the largest, not infinity. + let number = |v: &Value| v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(-1e6, 1e6) as f32); + let num = |k: &str, default: f32| -> Result { + match o.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => number(v).ok_or_else(|| format!("fill {n}: {k} is a number")), + } + }; + // A number for both ways, or [across, down]. + let pair = |k: &str, default: [f32; 2]| -> Result<[f32; 2], String> { + match o.get(k) { + None | Some(Value::Null) => Ok(default), + Some(Value::Array(a)) => match a.as_slice() { + [x, y] => Ok([number(x), number(y)]) + .and_then(|[x, y]| Ok([x.ok_or(())?, y.ok_or(())?])) + .map_err(|()| format!("fill {n}: {k} is a number or [across, down]")), + _ => Err(format!("fill {n}: {k} is a number or [across, down]")), + }, + Some(v) => number(v).map(|x| [x, x]).ok_or_else(|| format!("fill {n}: {k} is a number or [across, down]")), + } + }; + let pattern = match o.get("pattern").map(|v| v.as_str()) { + None | Some(Some("solid")) => FillPattern::Solid, + Some(Some("hatch")) => FillPattern::Hatch { + angle: num("angle", 0.0)?, + spacing: num("spacing", 8.0)?, + width: num("width", 1.0)?, + offset: num("offset", 0.0)?, + }, + Some(Some("dots")) => { + let marker = match o.get("marker") { + None | Some(Value::Null) => Marker::Circle, + Some(Value::String(m)) => Marker::ALL + .into_iter() + .find(|s| s.label().eq_ignore_ascii_case(m.trim())) + .ok_or_else(|| format!("fill {n}: unknown marker '{m}'"))?, + Some(_) => return Err(format!("fill {n}: marker is \"circle\", \"square\", \"triangle\", \"diamond\" or \"star\"")), + }; + FillPattern::Dots { + marker, + size: num("size", 3.0)?, + outline: hex("outline")?, + outline_width: num("outlineWidth", 0.0)?, + spacing: pair("spacing", [8.0, 8.0])?, + displacement: pair("displacement", [0.0, 0.0])?, + offset: pair("offset", [0.0, 0.0])?, + } + } + Some(_) => return Err(format!("fill {n}: pattern is \"solid\", \"hatch\" or \"dots\"")), + }; + fills.push(FillLayer { color: hex("color")?, pattern }.clamped()); + } + let id = s.layer_id(p)?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + if l.geometry_type() != GeometryType::Polygon || l.graphics || l.is_raster() { + return Err("this works on polygon layers".into()); + } + let count = fills.len(); + match class { + Some(c) => l.renderer.set_class_fills(&c, fills)?, + None => l.fill_layers = fills, + } + Ok(json!({"fills": count})) +} + /// A character marker's `char` (its first character), if given. fn character(p: &Value) -> Result, String> { match p.get("char") { @@ -1262,6 +1352,8 @@ fn apply_styles(s: &mut Session, id: u32, styles: &[(String, rustgis_data::kml:: other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4984ee366..2c40c855b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -342,6 +342,75 @@ fn editing_features_and_fields() { assert_eq!(s.map().layer_by_name("Cities").unwrap().dataset.features.len(), n - 1); } +/// A polygon layer stacks fills (a colour, hatch lines, markers on a grid), for the layer or a +/// class of its own; junk is refused and numbers held within range; each change undoes, and the +/// project keeps them (a layer without them saves as before). +#[test] +fn fill_layers_stack_and_survive_save_and_undo() { + use rustgis_map::Rgba; + use rustgis_map::fills::{FillLayer, FillPattern, MAX_FILLS}; + let mut s = world(); + let before = serde_json::to_string(&s.project).unwrap(); + assert!(!before.contains("fill_layers") && !before.contains("class_fills"), "nothing written for layers without them"); + let depth = s.undo_depth(); + let fills = json!([ + {"color": "#E8F0D0", "pattern": "solid"}, + {"color": "#336633", "pattern": "hatch", "angle": 45, "spacing": 6, "width": 0.75}, + {"pattern": "dots", "marker": "square", "size": 2, "spacing": [8, 6], "displacement": [4, 0], "outline": "#000000", "outlineWidth": 0.5} + ]); + assert_eq!(s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": fills})).unwrap(), json!({"fills": 3})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + let got = |s: &Session| s.map().layer_by_name("Countries").unwrap().fill_layers.clone(); + let f = got(&s); + assert_eq!(f[0], FillLayer { color: Some(Rgba::rgb(0xE8, 0xF0, 0xD0)), pattern: FillPattern::Solid }); + assert_eq!(f[1].pattern, FillPattern::Hatch { angle: 45.0, spacing: 6.0, width: 0.75, offset: 0.0 }); + let FillPattern::Dots { marker, spacing, outline, .. } = f[2].pattern else { panic!("{f:?}") }; + assert_eq!((marker, spacing, outline, f[2].color), (rustgis_map::Marker::Square, [8.0, 6.0], Some(Rgba::rgb(0, 0, 0)), None)); + // Kept with the project, and undone. + let text = serde_json::to_string(&s.project).unwrap(); + let reopened = Session::new(serde_json::from_str(&text).unwrap()); + assert_eq!(got(&reopened), f); + s.run("edit.undo", &json!({})).unwrap(); + assert!(got(&s).is_empty()); + // Held within range; a number given for both ways. + s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "hatch", "spacing": 1e300, "width": -4, "angle": -45}, {"pattern": "dots", "spacing": 1e9}]})) + .unwrap(); + let f = got(&s); + assert_eq!(f[0].pattern, FillPattern::Hatch { angle: 315.0, spacing: 500.0, width: 0.0, offset: 0.0 }); + assert!(matches!(f[1].pattern, FillPattern::Dots { spacing: [500.0, 500.0], .. }), "{f:?}"); + // A class's own (Unique Values), then back to the layer's. + s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "continent"})).unwrap(); + let value = match &s.map().layer_by_name("Countries").unwrap().renderer { + rustgis_map::Renderer::Unique { classes, .. } => classes[0].0.clone(), + r => panic!("{r:?}"), + }; + s.run("layer.fillLayers", &json!({"layer": "Countries", "value": value, "fills": [{"pattern": "hatch"}]})).unwrap(); + let class_fills = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { + rustgis_map::Renderer::Unique { class_fills, .. } => class_fills.clone(), + _ => Default::default(), + }; + assert_eq!(class_fills(&s).get(&value).map(Vec::len), Some(1)); + s.run("layer.fillLayers", &json!({"layer": "Countries", "value": value, "fills": []})).unwrap(); + assert!(class_fills(&s).is_empty()); + assert!(s.run("layer.fillLayers", &json!({"layer": "Countries", "value": "no such class", "fills": []})).is_err()); + // Junk, and a layer that isn't polygons. + let many: Vec = (0..=MAX_FILLS).map(|_| json!({})).collect(); + for bad in [ + json!({"layer": "Countries"}), + json!({"layer": "Countries", "fills": "hatch"}), + json!({"layer": "Countries", "fills": many}), + json!({"layer": "Countries", "fills": [7]}), + json!({"layer": "Countries", "fills": [{"pattern": "stripes"}]}), + json!({"layer": "Countries", "fills": [{"color": "red"}]}), + json!({"layer": "Countries", "fills": [{"pattern": "hatch", "spacing": "wide"}]}), + json!({"layer": "Countries", "fills": [{"pattern": "dots", "spacing": [1]}]}), + json!({"layer": "Countries", "fills": [{"pattern": "dots", "marker": "blob"}]}), + json!({"layer": "Rivers", "fills": []}), + ] { + assert!(s.run("layer.fillLayers", &bad).is_err(), "{bad}"); + } +} + #[test] fn line_strokes_stack_and_survive_save_and_undo() { use rustgis_map::{LineCap, LineJoin, LineStroke, Rgba}; diff --git a/crates/map/src/fills.rs b/crates/map/src/fills.rs new file mode 100644 index 000000000..d76b2bac9 --- /dev/null +++ b/crates/map/src/fills.rs @@ -0,0 +1,227 @@ +//! A polygon layer's stacked fills (#882 piece 5): drawn bottom first inside each polygon in place +//! of its symbol's single fill, as QGIS's symbol layers stack. Each fills the polygon with its +//! colour, hatches it with parallel lines (QGIS's line pattern fill, and a simple fill's hatched +//! brush styles), or dots it with markers on a grid (QGIS's point pattern fill). Sizes are in +//! points, as a symbol's, so a pattern keeps its size on screen at every scale. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::color::Rgba; +use crate::symbology::Marker; + +/// The most fills one polygon symbol stacks. +pub const MAX_FILLS: usize = 8; +/// How far apart a pattern's lines or markers may be (points). +pub const PATTERN_SPACING: std::ops::RangeInclusive = 1.0..=500.0; +/// The widest hatch line (points). +pub const MAX_HATCH_WIDTH: f32 = 50.0; +/// The largest pattern marker (points). +pub const MAX_DOT_SIZE: f32 = 200.0; +/// How far a pattern may be shifted each way (points). +pub const MAX_PATTERN_OFFSET: f32 = 500.0; + +/// One fill of a polygon layer's stacked symbol (`Layer::fill_layers`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FillLayer { + /// Its colour (a pattern's lines' or markers'); `None` takes the feature's fill colour from + /// the renderer, so the fills follow Unique Values and Graduated Colors classes. + #[serde(default)] + pub color: Option, + #[serde(default)] + pub pattern: FillPattern, +} + +/// What a fill draws inside its polygon. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FillPattern { + /// The whole polygon in its colour. + #[default] + Solid, + /// Parallel lines `spacing` points apart and `width` points wide, at `angle` degrees + /// anticlockwise from east, shifted `offset` points across them. + Hatch { + #[serde(default)] + angle: f32, + #[serde(default = "hatch_spacing")] + spacing: f32, + #[serde(default = "hatch_width")] + width: f32, + #[serde(default)] + offset: f32, + }, + /// Markers `size` points across on a grid `spacing` points apart (across, down), every other + /// row shifted `displacement[0]` across and every other column `displacement[1]` down (QGIS's + /// displacement), the grid shifted `offset`; outlined in `outline`, `outline_width` wide. + Dots { + #[serde(default)] + marker: Marker, + #[serde(default = "dot_size")] + size: f32, + #[serde(default)] + outline: Option, + #[serde(default)] + outline_width: f32, + #[serde(default = "dot_spacing")] + spacing: [f32; 2], + #[serde(default)] + displacement: [f32; 2], + #[serde(default)] + offset: [f32; 2], + }, +} + +fn hatch_spacing() -> f32 { + 8.0 +} + +fn hatch_width() -> f32 { + 1.0 +} + +fn dot_size() -> f32 { + 3.0 +} + +fn dot_spacing() -> [f32; 2] { + [8.0, 8.0] +} + +/// `v` within `lo..=hi`; one of no number is `none`. +fn within(v: f32, lo: f32, hi: f32, none: f32) -> f32 { + if v.is_finite() { v.clamp(lo, hi) } else { none } +} + +impl FillLayer { + /// A fill in the feature's colour, hatched `angle` degrees `spacing` points apart. + pub fn hatch(angle: f32, spacing: f32, width: f32) -> FillLayer { + FillLayer { color: None, pattern: FillPattern::Hatch { angle, spacing, width, offset: 0.0 } }.clamped() + } + + /// Kept as the command would leave it: sizes within their ranges, an angle within a turn, + /// a displacement within a cell. A project file is hostile input, and every fill is drawn per + /// feature per frame. + pub fn clamped(self) -> FillLayer { + let pattern = match self.pattern { + FillPattern::Solid => FillPattern::Solid, + FillPattern::Hatch { angle, spacing, width, offset } => FillPattern::Hatch { + angle: if angle.is_finite() { angle.rem_euclid(360.0) } else { 0.0 }, + spacing: within(spacing, *PATTERN_SPACING.start(), *PATTERN_SPACING.end(), hatch_spacing()), + width: within(width, 0.0, MAX_HATCH_WIDTH, hatch_width()), + offset: within(offset, -MAX_PATTERN_OFFSET, MAX_PATTERN_OFFSET, 0.0), + }, + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => { + let spacing = spacing.map(|s| within(s, *PATTERN_SPACING.start(), *PATTERN_SPACING.end(), dot_spacing()[0])); + FillPattern::Dots { + marker: if let Marker::Char(_) = marker { Marker::Circle } else { marker }, + size: within(size, 0.0, MAX_DOT_SIZE, dot_size()), + outline, + outline_width: within(outline_width, 0.0, MAX_HATCH_WIDTH, 0.0), + displacement: [0, 1].map(|i| { + let cell = spacing.get(i).copied().unwrap_or(1.0); + displacement.get(i).map_or(0.0, |d| if d.is_finite() { d.rem_euclid(cell) } else { 0.0 }) + }), + offset: offset.map(|o| within(o, -MAX_PATTERN_OFFSET, MAX_PATTERN_OFFSET, 0.0)), + spacing, + } + } + }; + FillLayer { pattern, ..self } + } + + /// Whether it draws anything in a feature of fill `feature`: a colour that shows, and for a + /// pattern, lines or markers of some size. + pub fn draws(&self, feature: Rgba) -> bool { + let shown = self.color.unwrap_or(feature).a() > 0; + match self.pattern { + FillPattern::Solid => shown, + FillPattern::Hatch { width, .. } => shown && width > 0.0, + FillPattern::Dots { size, outline, outline_width, .. } => { + size > 0.0 && (shown || outline.is_some_and(|o| o.a() > 0) && outline_width > 0.0) + } + } + } +} + +/// A layer's fills read leniently: at most [`MAX_FILLS`], each [`FillLayer::clamped`]; one that +/// isn't a fill is left out, and a value that isn't a list is none, so a damaged entry doesn't +/// make the project unreadable. +pub fn de_fills<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(fills_of(serde_json::Value::deserialize(d)?)) +} + +fn fills_of(v: serde_json::Value) -> Vec { + let serde_json::Value::Array(list) = v else { return Vec::new() }; + list.into_iter().filter_map(|f| serde_json::from_value::(f).ok()).take(MAX_FILLS).map(FillLayer::clamped).collect() +} + +/// A renderer's per-class fills read as [`de_fills`] reads a layer's; a class left with none is +/// dropped, and a class that doesn't read is left out. +pub fn de_class_fills<'de, D, K>(d: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + K: serde::de::DeserializeOwned + Ord, +{ + let serde_json::Value::Object(m) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) }; + Ok(m.into_iter() + .filter_map(|(k, v)| { + let key: K = serde_json::from_value(serde_json::Value::String(k.clone())).or_else(|_| serde_json::from_str(&k)).ok()?; + let fills = fills_of(v); + (!fills.is_empty()).then_some((key, fills)) + }) + .collect()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// Fills are kept; crafted ones are held within their ranges, and ones that aren't fills are + /// left out without the rest failing to read. + #[test] + fn fills_read_leniently_and_within_their_ranges() { + let hatch = FillLayer::hatch(45.0, 4.0, 0.5); + let dots = FillLayer { + color: Some(Rgba::rgb(0, 0, 255)), + pattern: FillPattern::Dots { + marker: Marker::Circle, + size: 2.0, + outline: None, + outline_width: 0.0, + spacing: [6.0, 6.0], + displacement: [3.0, 0.0], + offset: [1.0, -1.0], + }, + }; + let v = serde_json::to_value([FillLayer::default(), hatch, dots]).unwrap(); + assert_eq!( + v[1], + serde_json::json!({"color": null, "pattern": {"kind": "hatch", "angle": 45.0, "spacing": 4.0, "width": 0.5, "offset": 0.0}}) + ); + assert_eq!(fills_of(v), vec![FillLayer::default(), hatch, dots]); + // Damaged: not fills, too many, sizes of no number or far too large. + let crafted = serde_json::json!([ + 7, + {"pattern": {"kind": "stripes"}}, + {"pattern": {"kind": "hatch", "angle": -90, "spacing": 1e30, "width": -1, "offset": -1e30}}, + {"pattern": {"kind": "dots", "size": 1e9, "spacing": [0, 1e9], "displacement": [99, -1], "offset": [1e9, 0]}} + ]); + let read = fills_of(crafted); + assert_eq!(read.len(), 2, "{read:?}"); + assert_eq!(read[0].pattern, FillPattern::Hatch { angle: 270.0, spacing: 500.0, width: 0.0, offset: -MAX_PATTERN_OFFSET }); + let FillPattern::Dots { size, spacing, displacement, offset, .. } = read[1].pattern else { panic!("{read:?}") }; + assert_eq!((size, spacing, offset), (MAX_DOT_SIZE, [1.0, 500.0], [MAX_PATTERN_OFFSET, 0.0])); + assert_eq!(displacement, [0.0, 499.0], "within a cell"); + assert_eq!(fills_of(serde_json::json!((0..20).map(|_| serde_json::json!({})).collect::>())).len(), MAX_FILLS); + assert!(fills_of(serde_json::json!("solid")).is_empty()); + // What draws: a colour that shows, and a pattern of some size. + let feature = Rgba::rgb(200, 100, 0); + assert!(FillLayer::default().draws(feature)); + assert!(!FillLayer::default().draws(Rgba::new(0, 0, 0, 0))); + assert!(!FillLayer::hatch(0.0, 4.0, 0.0).draws(feature)); + } +} diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index c16a2ccac..56b6c2185 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -6,6 +6,7 @@ pub mod camera; pub mod color; pub mod design; pub mod effects; +pub mod fills; pub mod globe; pub mod graphics; pub mod layout; @@ -149,6 +150,11 @@ pub struct Layer { /// [`LineStroke`]); a stroke without markers draws nothing there. #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "symbology::de_strokes")] pub line_strokes: Vec, + /// A polygon layer's stacked fills, drawn bottom first inside each polygon in place of its + /// symbol's fill (none: that fill alone); the outline is drawn over them (see + /// [`fills::FillLayer`]). + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "fills::de_fills")] + pub fill_layers: Vec, /// Draws the stacked strokes in levels (symbol levels): every feature's strokes of one /// [`LineStroke::level`] before any of the next, so road casings join under the cores /// instead of cutting across them where lines meet. @@ -313,6 +319,7 @@ impl Layer { aggregation: None, extrusion: None, line_strokes: Vec::new(), + fill_layers: Vec::new(), stroke_levels: false, marker_rotation: None, reference_scale: None, @@ -556,6 +563,20 @@ impl Layer { !self.graphics && (!self.line_strokes.is_empty() || self.renderer.has_class_strokes()) } + /// A polygon row's stacked fills: its class's own, else the layer's `fill_layers` (none: its + /// symbol's single fill). + pub fn fills(&self, row: usize) -> &[fills::FillLayer] { + if self.graphics { + return &[]; + } + self.renderer.fills_for(&self.dataset, row).unwrap_or(&self.fill_layers) + } + + /// Whether some feature may be drawn with stacked fills ([`Layer::fills`]). + pub fn has_fills(&self) -> bool { + !self.graphics && (!self.fill_layers.is_empty() || self.renderer.has_class_fills()) + } + pub fn is_raster(&self) -> bool { self.raster.is_some() } @@ -1926,6 +1947,8 @@ mod tests { other_label: None, class_strokes: [("a".to_string(), wild.clone()), ("b".to_string(), Vec::new())].into_iter().collect(), other_strokes: wild.clone(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let back: Layer = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); let Renderer::Unique { class_strokes, other_strokes, .. } = &back.renderer else { panic!("unique renderer expected") }; @@ -1940,6 +1963,7 @@ mod tests { classes: 1, class_labels: Default::default(), class_strokes: [(0, wild)].into_iter().collect(), + class_fills: Default::default(), }; let back: Layer = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); let Renderer::Graduated { class_strokes, .. } = &back.renderer else { panic!("graduated renderer expected") }; @@ -2158,6 +2182,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let labels = |l: &Layer, scale| l.shown_legend(scale).map(|v| v.into_iter().map(|e| e.label).collect::>()); assert_eq!(labels(l, 1e6), Some(vec!["1".to_string(), "2".to_string()]), "no 9, no "); diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index f64c7088b..3b51594a0 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -103,6 +103,8 @@ pub struct Style { /// A line layer's stacked strokes (a single-symbol line with more than one line, or an /// offset one). pub line_strokes: Option>, + /// A polygon layer's stacked fills (a single-symbol fill with patterns, or several fills). + pub fill_layers: Option>, pub stroke_levels: Option, /// Point markers turned by a field (`Some(None)`: not turned). pub marker_rotation: Option>, @@ -148,6 +150,10 @@ impl Style { if let Some(s) = &self.line_strokes { layer.line_strokes = s.clone(); } + // (Only a polygon layer's fills draw.) + if let Some(f) = self.fill_layers.as_ref().filter(|_| layer.geometry_type() == rustgis_data::GeometryType::Polygon) { + layer.fill_layers = f.clone(); + } if let Some(on) = self.stroke_levels { layer.stroke_levels = on; } diff --git a/crates/map/src/qgis/renderer.rs b/crates/map/src/qgis/renderer.rs index 029b85be3..3510248e2 100644 --- a/crates/map/src/qgis/renderer.rs +++ b/crates/map/src/qgis/renderer.rs @@ -66,11 +66,13 @@ fn read_depth(r: Node, layer: &str, style: &mut Style, report: &mut Report, dept return; }; style.line_strokes = Some(if c.stacked() { c.strokes.clone() } else { Vec::new() }); + style.fill_layers = Some(c.fills.clone()); style.renderer = Some(Renderer::Single(c.symbol)); } "nullSymbol" => { style.renderer = Some(Renderer::Single(symbol::hidden())); style.line_strokes = Some(Vec::new()); + style.fill_layers = Some(Vec::new()); } "categorizedSymbol" => categorized(r, layer, style, report, &sym), "graduatedSymbol" => graduated(r, layer, style, report, &sym), @@ -106,7 +108,7 @@ fn field_of(attr: &str, layer: &str, report: &mut Report) -> Option { /// A switched-off class (`render="false"`): it draws nothing. fn switched_off() -> Converted { - Converted { symbol: symbol::hidden(), strokes: Vec::new(), rotation: None } + Converted { symbol: symbol::hidden(), strokes: Vec::new(), rotation: None, fills: Vec::new() } } type SymbolOf<'a> = dyn Fn(Option<&str>, &mut Report) -> Option + 'a; @@ -116,6 +118,7 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym let Some(field) = field_of(r.attribute("attr").unwrap_or_default(), layer, report) else { // Drawn as one: the first category's symbol. if let Some(c) = cats.first().and_then(|c| sym(c.attribute("symbol"), report)) { + style.fill_layers = Some(c.fills.clone()); style.renderer = Some(Renderer::Single(c.symbol)); } return; @@ -124,6 +127,7 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym let (mut class_labels, mut class_strokes) = (BTreeMap::new(), BTreeMap::new()); let mut other = symbol::hidden(); let mut other_strokes = Vec::new(); + let (mut class_fills, mut other_fills) = (BTreeMap::new(), Vec::new()); for cat in &cats { // A switched-off class draws nothing; its symbol isn't read (its field would turn the rest). let c = if cat.attribute("render") == Some("false") { @@ -147,6 +151,7 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym if v.is_empty() || v == "NULL" { other = c.symbol; other_strokes = if c.stacked() { c.strokes.clone() } else { Vec::new() }; + other_fills.clone_from(&c.fills); continue; } if classes.len() >= MAX_CLASSES { @@ -159,10 +164,14 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym if c.stacked() { class_strokes.insert(v.clone(), c.strokes.clone()); } + if !c.fills.is_empty() { + class_fills.insert(v.clone(), c.fills.clone()); + } classes.push((v, c.symbol)); } } style.line_strokes = Some(Vec::new()); + style.fill_layers = Some(Vec::new()); style.renderer = Some(Renderer::Unique { field, classes, @@ -172,6 +181,8 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym other_label: None, class_strokes, other_strokes, + class_fills, + other_fills, }); } @@ -194,6 +205,7 @@ fn graduated(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: } let Some(field) = field_of(r.attribute("attr").unwrap_or_default(), layer, report) else { if let Some((.., c, _)) = ranges.first() { + style.fill_layers = Some(c.fills.clone()); style.renderer = Some(Renderer::Single(c.symbol)); } return; @@ -212,6 +224,7 @@ fn graduated(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: report.approximated(layer, "values below the first range or above the last, drawn in the first or last class (QGIS leaves them out)"); let mut class_labels = BTreeMap::new(); let mut class_strokes: BTreeMap> = BTreeMap::new(); + let mut class_fills = BTreeMap::new(); for (i, (.., c, label)) in ranges.iter().enumerate() { if let Some(l) = label.as_ref().filter(|l| !l.is_empty()) { class_labels.insert(i, l.clone()); @@ -219,8 +232,12 @@ fn graduated(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: if c.stacked() { class_strokes.insert(i, c.strokes.clone()); } + if !c.fills.is_empty() { + class_fills.insert(i, c.fills.clone()); + } } style.line_strokes = Some(Vec::new()); + style.fill_layers = Some(Vec::new()); style.renderer = Some(Renderer::Graduated { field, breaks: ranges.iter().map(|r| r.1).collect(), @@ -231,6 +248,7 @@ fn graduated(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: classes: ranges.len(), class_labels, class_strokes, + class_fills, }); } @@ -287,20 +305,25 @@ fn rules(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: &Sym } if simple && let Some(field) = field { let mut class_strokes = BTreeMap::new(); + let mut class_fills = BTreeMap::new(); let mut class_labels = BTreeMap::new(); for (v, c, label) in &classes { if c.stacked() { class_strokes.insert(v.clone(), c.strokes.clone()); } + if !c.fills.is_empty() { + class_fills.insert(v.clone(), c.fills.clone()); + } if let Some(l) = label.as_ref().filter(|l| !l.is_empty() && *l != v) { class_labels.insert(v.clone(), l.clone()); } } - let (other_symbol, other_strokes) = match &other { - Some(c) => (c.symbol, if c.stacked() { c.strokes.clone() } else { Vec::new() }), - None => (symbol::hidden(), Vec::new()), + let (other_symbol, other_strokes, other_fills) = match &other { + Some(c) => (c.symbol, if c.stacked() { c.strokes.clone() } else { Vec::new() }, c.fills.clone()), + None => (symbol::hidden(), Vec::new(), Vec::new()), }; style.line_strokes = Some(Vec::new()); + style.fill_layers = Some(Vec::new()); style.renderer = Some(Renderer::Unique { field, classes: classes.into_iter().map(|(v, c, _)| (v, c.symbol)).collect(), @@ -310,6 +333,8 @@ fn rules(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: &Sym other_label: None, class_strokes, other_strokes, + class_fills, + other_fills, }); return; } @@ -317,6 +342,7 @@ fn rules(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: &Sym if drawn.len() == 1 && drawn.first().is_some_and(|r| r.filters.is_empty()) || drawn.is_empty() { if let Some(c) = drawn.first().and_then(|r| sym(r.node.attribute("symbol"), report)) { style.line_strokes = Some(if c.stacked() { c.strokes.clone() } else { Vec::new() }); + style.fill_layers = Some(c.fills.clone()); style.renderer = Some(Renderer::Single(c.symbol)); } return; @@ -325,6 +351,7 @@ fn rules(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: &Sym .approximated(layer, format!("its {} rules (filters beyond one field's values), drawn with the first rule's symbol", drawn.len())); if let Some(c) = drawn.first().and_then(|r| sym(r.node.attribute("symbol"), report)) { style.line_strokes = Some(if c.stacked() { c.strokes.clone() } else { Vec::new() }); + style.fill_layers = Some(c.fills.clone()); style.renderer = Some(Renderer::Single(c.symbol)); } } diff --git a/crates/map/src/qgis/symbol.rs b/crates/map/src/qgis/symbol.rs index 427174c5a..c0d2cd219 100644 --- a/crates/map/src/qgis/symbol.rs +++ b/crates/map/src/qgis/symbol.rs @@ -7,6 +7,7 @@ use roxmltree::Node; use super::{Report, color, data_defined_on, has_effect, points, prop, props, signed_points}; use crate::Rgba; +use crate::fills::{FillLayer, FillPattern}; use crate::symbology::{ Dash, Glyph, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, MarkerRotation, Placements, Symbol, @@ -27,6 +28,9 @@ pub(super) struct Converted { pub strokes: Vec, /// A marker symbol turned by a field (its data-defined angle). pub rotation: Option, + /// A fill symbol's stacked fills, bottom first: empty when its one fill is a colour (the + /// symbol's own), as before patterns were read. + pub fills: Vec, } impl Converted { @@ -101,6 +105,12 @@ pub(super) fn read(sym: Node, reference: Option, turned: bool, layer: &str, m.outline = m.outline.map(fade); } } + for f in &mut out.fills { + f.color = f.color.map(fade); + if let FillPattern::Dots { outline, .. } = &mut f.pattern { + *outline = outline.map(fade); + } + } out } @@ -195,18 +205,53 @@ fn dash(p: &[(String, String)], width: f32, reference: Option, layer: &str, d } +/// A Qt brush's hatch lines (QGIS's simple fill styles), as (angle anticlockwise from east, points +/// across between lines): 1 px lines 8 px apart, the diagonals 8 px apart along a row (measured on +/// QGIS 4.2.3 at 96 dpi: `b_diagonal` rises to the right, `f_diagonal` falls). +fn brush_hatches(style: &str) -> Option<&'static [(f32, f32)]> { + const DIAG: f32 = 8.0 * std::f32::consts::FRAC_1_SQRT_2; + Some(match style { + "horizontal" => &[(0.0, 8.0)], + "vertical" => &[(90.0, 8.0)], + "b_diagonal" => &[(45.0, DIAG)], + "f_diagonal" => &[(135.0, DIAG)], + "cross" => &[(0.0, 8.0), (90.0, 8.0)], + "diagonal_x" => &[(45.0, DIAG), (135.0, DIAG)], + _ => return None, + }) +} + fn fill(layers: &[(Node, Props)], reference: Option, layer: &str, report: &mut Report) -> Converted { let none = Rgba::new(0, 0, 0, 0); let (mut fill, mut stroke, mut width) = (None::, None::, 0.0f32); + // Its fills, bottom first, as QGIS lists its layers; the symbol's fill stays the colour a + // legend shows (a pattern's as a tint of it). + let mut stack: Vec = Vec::new(); + let num = |p: &Props, k: &str| prop(p, k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); for (l, p) in layers { match l.attribute("class").unwrap_or_default() { "SimpleFill" => { let c = prop(p, "color").and_then(color).unwrap_or(none); - fill = Some(match prop(p, "style").unwrap_or("solid") { - "solid" => c, + let style = prop(p, "style").unwrap_or("solid"); + fill = Some(match style { + "solid" => { + stack.push(FillLayer { color: Some(c), pattern: FillPattern::Solid }); + c + } "no" => fill.unwrap_or(none), - _ => { - report.approximated(layer, "hatched fills, drawn as a light tint of their colour"); + hatched => { + match brush_hatches(hatched) { + Some(lines) => { + for &(angle, spacing) in lines { + stack.push(FillLayer { color: Some(c), ..FillLayer::hatch(angle, spacing, 1.0) }); + } + } + // Qt's dense dot brushes. + None => { + report.approximated(layer, "fills of dense dots, drawn as a light tint of their colour"); + stack.push(FillLayer { color: Some(tint(c)), pattern: FillPattern::Solid }); + } + } tint(c) } }); @@ -229,14 +274,89 @@ fn fill(layers: &[(Node, Props)], reference: Option, layer: &str, report: & } "GradientFill" | "ShapeburstFill" => { report.approximated(layer, "gradient fills, drawn in their first colour"); - fill = prop(p, "color").or(prop(p, "color1")).and_then(color).or(fill); + let c = prop(p, "color").or(prop(p, "color1")).and_then(color); + if let Some(c) = c { + stack.push(FillLayer { color: Some(c), pattern: FillPattern::Solid }); + } + fill = c.or(fill); } - class @ ("LinePatternFill" | "PointPatternFill" | "SVGFill" | "RasterFill" | "RandomMarkerFill") => { + // Hatch lines: its line's colour and width, its angle and spacing. + "LinePatternFill" => { + let sub = l.children().find(|c| c.has_tag_name("symbol")).map(|s| read(s, reference, false, layer, report)); + let angle = num(p, "lineangle").unwrap_or(45.0); + // QGIS's default: 5 mm apart. + let spacing = size(p, "distance", reference, layer, report).unwrap_or(5.0 * 96.0 / 25.4); + let shift = offset(p, "offset", reference, layer, report).unwrap_or(0.0); + // A hatch for each of the line's strokes, bottom first (a cased line hatches with + // its casing under its core); a line of one stroke is its symbol's line. + let base = sub.as_ref().map(|s| s.symbol); + let lines: Vec<(Option, Option)> = match sub.as_ref().map(|s| s.strokes.as_slice()) { + Some(strokes) if !strokes.is_empty() => { + if strokes.iter().any(|st| st.dash.is_some() || st.markers.is_some()) { + report.approximated(layer, "dashed and marker lines in line pattern fills, drawn solid"); + } + strokes.iter().map(|st| (st.color, st.width)).collect() + } + _ => vec![(None, None)], + }; + for (c, w) in lines { + let c = c.or_else(|| base.map(|s| s.stroke)).or_else(|| prop(p, "color").and_then(color)).unwrap_or(Rgba::rgb(0, 0, 0)); + let w = w.or_else(|| base.map(|s| s.stroke_width)).unwrap_or(0.26 * 96.0 / 25.4); + let pattern = FillPattern::Hatch { angle, spacing, width: w, offset: shift }; + stack.push(FillLayer { color: Some(c), pattern }.clamped()); + fill = Some(tint(c)); + } + } + // Markers on a grid: its marker, its spacing and shifted rows and columns. + "PointPatternFill" => { + let sub = l.children().find(|c| c.has_tag_name("symbol")).map(|s| read(s, reference, false, layer, report)); + let m = sub.as_ref().map_or_else( + || Symbol { + fill: Rgba::rgb(0, 0, 0), + stroke: Rgba::new(0, 0, 0, 0), + stroke_width: 0.0, + size: 2.0 * 96.0 / 25.4, + marker: Marker::Circle, + }, + |s| s.symbol, + ); + // QGIS's default: 15 mm apart each way. + let apart = |k: &str, report: &mut Report| size(p, k, reference, layer, report).unwrap_or(15.0 * 96.0 / 25.4); + let shift = |k: &str, report: &mut Report| offset(p, k, reference, layer, report).unwrap_or(0.0); + let spacing = [apart("distance_x", report), apart("distance_y", report)]; + let displacement = [shift("displacement_x", report), shift("displacement_y", report)]; + let moved = [shift("offset_x", report), shift("offset_y", report)]; + let marker = if let Marker::Char(_) = m.marker { + report.approximated(layer, "font markers in point pattern fills, drawn as circles"); + Marker::Circle + } else { + m.marker + }; + let outline = (m.stroke.a() > 0 && m.stroke_width > 0.0).then_some(m.stroke); + stack.push( + FillLayer { + color: Some(m.fill), + pattern: FillPattern::Dots { + marker, + size: m.size, + outline, + outline_width: m.stroke_width, + spacing, + displacement, + offset: moved, + }, + } + .clamped(), + ); + fill = Some(tint(if m.fill.a() > 0 { m.fill } else { m.stroke })); + } + class @ ("SVGFill" | "RasterFill" | "RandomMarkerFill") => { // The pattern's own symbol's colour, as a tint over what's below. let sub = l.children().find(|c| c.has_tag_name("symbol")).map(|s| read(s, reference, false, layer, &mut Report::default())); let c = sub.map(|s| if s.symbol.stroke.a() > 0 && s.symbol.fill.a() == 0 { s.symbol.stroke } else { s.symbol.fill }); if let Some(c) = c.or_else(|| prop(p, "color").and_then(color)) { fill = Some(tint(c)); + stack.push(FillLayer { color: Some(tint(c)), pattern: FillPattern::Solid }); } report.approximated(layer, format!("{} fills, drawn as a light tint of their colour", pattern_name(class))); } @@ -244,9 +364,17 @@ fn fill(layers: &[(Node, Props)], reference: Option, layer: &str, report: & other => report.skipped(layer, format!("{other} symbol layers")), } } + // One colour and nothing over it is the symbol's fill alone, as before fills were stacked. + if stack.len() <= 1 && stack.iter().all(|f| f.pattern == FillPattern::Solid) { + stack.clear(); + } + if stack.len() > crate::fills::MAX_FILLS { + report.skipped(layer, format!("fills past the first {}", crate::fills::MAX_FILLS)); + stack.truncate(crate::fills::MAX_FILLS); + } let symbol = Symbol { fill: fill.unwrap_or(none), stroke: stroke.unwrap_or(none), stroke_width: width, size: 0.0, marker: Marker::Circle }; - Converted { symbol, strokes: Vec::new(), rotation: None } + Converted { symbol, strokes: Vec::new(), rotation: None, fills: stack } } fn line(layers: &[(Node, Props)], reference: Option, layer: &str, report: &mut Report) -> Converted { @@ -302,7 +430,7 @@ fn line(layers: &[(Node, Props)], reference: Option, layer: &str, report: & } None => hidden(), }; - Converted { symbol, strokes, rotation: None } + Converted { symbol, strokes, rotation: None, fills: Vec::new() } } /// A marker line (`hash` false) or hashed line as strokes of markers or hash marks along the line: @@ -553,7 +681,7 @@ fn marker(layers: &[(Node, Props)], reference: Option, still: bool, layer: && marks.len() <= 1 && marks.iter().all(|m| m.offset == 0.0 && m.markers.is_some_and(|k| k.offset_along == 0.0 && k.angle == 0.0)); let strokes = if alone { Vec::new() } else { marks.into_iter().map(LineStroke::clamped).collect() }; - Converted { symbol, strokes, rotation: None } + Converted { symbol, strokes, rotation: None, fills: Vec::new() } } /// A point marker layer's offset (`offset`, "x,y" in its unit: right and down) in points, its diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index df1a08b39..22f14dc61 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -187,7 +187,15 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() { // What didn't carry over is said, once for each thing. let said = |layer: &str, kind: NoteKind, part: &str| report.notes.iter().any(|n| n.layer == layer && n.kind == kind && n.text.contains(part)); - assert!(said("Land", NoteKind::Approximated, "line pattern")); + // Its second class's line pattern fill: hatch lines of its blue line, 2 mm apart, at QGIS's + // 45°, half see-through as the symbol is; read, not noted. + assert!(!said("Land", NoteKind::Approximated, "line pattern")); + let Some(Renderer::Graduated { class_fills, .. }) = &land.style.renderer else { panic!() }; + let hatch = class_fills.get(&1).and_then(|f| f.first()).copied().unwrap(); + assert_eq!(hatch.color, Some(Rgba::new(0, 0, 255, 128))); + let crate::fills::FillPattern::Hatch { angle, spacing, .. } = hatch.pattern else { panic!("{hatch:?}") }; + assert!(angle == 45.0 && (spacing - 2.0 * 96.0 / 25.4).abs() < 1e-3, "{hatch:?}"); + assert!(!class_fills.contains_key(&0), "a plain colour is the symbol's fill, as before"); assert!(said("Land", NoteKind::Approximated, "above the last"), "values outside the ranges are noted"); // 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"); @@ -309,6 +317,97 @@ fn merged_categories_and_dashes_carry_over() { assert!(!report.notes.iter().any(|n| n.text.contains("dash")), "{:?}", report.notes); } +/// Pattern fills as stacked fills: a simple fill's hatched brushes as Qt draws them, line and point +/// pattern fills in their units, several fills bottom first; one plain colour stays the symbol's +/// fill alone, as before; a class's fills go with its class. +#[test] +fn pattern_fills_carry_over_as_stacked_fills() { + use crate::fills::{FillLayer, FillPattern}; + let qml = |layers: &str| { + format!( + r#"{layers}"# + ) + }; + let read = |layers: &str| { + let mut report = Report::default(); + let s = read_style(&qml(layers), "Fields", &mut report).unwrap(); + (s.fill_layers.unwrap_or_default(), s.renderer, report) + }; + let simple = |style: &str| { + format!( + r#""# + ) + }; + // One colour: the symbol's fill, no stack, as before. + let (fills, renderer, _) = read(&simple("solid")); + assert!(fills.is_empty()); + assert!(matches!(renderer, Some(Renderer::Single(s)) if s.fill == Rgba::rgb(255, 0, 0))); + // Qt's hatched brushes: 8 points apart, the diagonals 8 along a row; b_diagonal rises. + let angles = |style: &str| -> Vec<(f32, f32)> { + read(&simple(style)) + .0 + .iter() + .map(|f| match f.pattern { + FillPattern::Hatch { angle, spacing, .. } => (angle, (spacing * 100.0).round() / 100.0), + _ => (-1.0, 0.0), + }) + .collect() + }; + assert_eq!(angles("horizontal"), [(0.0, 8.0)]); + assert_eq!(angles("vertical"), [(90.0, 8.0)]); + assert_eq!(angles("b_diagonal"), [(45.0, 5.66)]); + assert_eq!(angles("f_diagonal"), [(135.0, 5.66)]); + assert_eq!(angles("cross"), [(0.0, 8.0), (90.0, 8.0)]); + assert_eq!(angles("diagonal_x"), [(45.0, 5.66), (135.0, 5.66)]); + let (fills, _, report) = read(&simple("dense4")); + assert!(fills.is_empty(), "a dense dot brush is a tint, one colour: {fills:?}"); + assert!(report.notes.iter().any(|n| n.text.contains("dense dots")), "{:?}", report.notes); + // SMV25's point pattern: blue dots 0.7 mm apart, the marker its sub-symbol's, over a fill. + let dots = r#" + "#; + let (fills, renderer, report) = read(&format!("{}{dots}", simple("solid"))); + assert_eq!(fills.len(), 2, "{fills:?}"); + assert_eq!(fills[0], FillLayer { color: Some(Rgba::rgb(255, 0, 0)), pattern: FillPattern::Solid }, "bottom first"); + let mm = |v: f32| v * 96.0 / 25.4; + let FillPattern::Dots { size, spacing, displacement, outline, .. } = fills[1].pattern else { panic!("{fills:?}") }; + assert_eq!(fills[1].color, Some(Rgba::rgb(0, 0, 255))); + assert!((size - mm(0.2)).abs() < 1e-3 && (spacing[0] - mm(0.7)).abs() < 1e-3 && (displacement[0] - mm(0.35)).abs() < 1e-3, "{fills:?}"); + assert_eq!(outline, None); + assert!(!report.notes.iter().any(|n| n.text.contains("point pattern")), "read, not noted: {:?}", report.notes); + assert!(matches!(renderer, Some(Renderer::Single(s)) if s.fill == Rgba::rgb(0, 0, 255).with_alpha(s.fill.a())), "{renderer:?}"); + // A line pattern in its units, with its line's colour and width. + let lines = r#" + "#; + let (fills, ..) = read(lines); + assert_eq!(fills.len(), 1, "a pattern alone is a stack of one"); + let FillPattern::Hatch { angle, spacing, width, offset } = fills[0].pattern else { panic!("{fills:?}") }; + assert_eq!(fills[0].color, Some(Rgba::rgb(0, 128, 0))); + assert!( + angle == 30.0 && (spacing - mm(1.0)).abs() < 1e-3 && (width - mm(0.3)).abs() < 1e-3 && (offset - mm(0.5)).abs() < 1e-3, + "{fills:?}" + ); + // Two colours stacked: both, the top one over the bottom. + let half = r#""#; + let (fills, ..) = read(&format!("{}{half}", simple("solid"))); + assert_eq!(fills.iter().map(|f| f.color).collect::>(), [Some(Rgba::rgb(255, 0, 0)), Some(Rgba::new(0, 0, 0, 64))]); + // In classes: a categorized layer's class with a hatch has fills of its own. + let cat = format!( + r#" + {}{}"#, + simple("solid"), + simple("cross") + ); + let mut report = Report::default(); + let s = read_style(&cat, "Fields", &mut report).unwrap(); + let Some(Renderer::Unique { class_fills, .. }) = &s.renderer else { panic!("{:?}", s.renderer) }; + assert_eq!(class_fills.keys().collect::>(), ["b"]); + assert_eq!(class_fills["b"].len(), 2); + assert_eq!(s.fill_layers, Some(Vec::new()), "the layer's own: none"); +} + #[test] fn a_labels_buffer_and_letter_spacing_carry_over_in_their_units() { let qml = |text: &str| { diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 71f46a38f..9f6a4b820 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -7,6 +7,7 @@ use rustgis_data::{Dataset, FieldType, GeometryType}; use serde::{Deserialize, Serialize}; use crate::color::{Ramp, Rgba}; +use crate::fills::FillLayer; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Marker { @@ -686,6 +687,13 @@ pub enum Renderer { /// The stacked strokes of `other`; empty takes the layer's. #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "de_strokes")] other_strokes: Vec, + /// A polygon layer's stacked fills for one class, by class value: drawn for that class's + /// features instead of the layer's `fill_layers`. A class without its own takes the layer's. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::fills::de_class_fills")] + class_fills: BTreeMap>, + /// The stacked fills of `other`; empty takes the layer's. + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::fills::de_fills")] + other_fills: Vec, }, /// Upper bounds of each class (ascending) and one symbol per class. Graduated { @@ -707,6 +715,9 @@ pub enum Renderer { /// A line layer's stacked strokes for one class, by class index, as Unique Values'. #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "de_class_strokes")] class_strokes: BTreeMap>, + /// A polygon layer's stacked fills for one class, by class index, as Unique Values'. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "crate::fills::de_class_fills")] + class_fills: BTreeMap>, }, /// Marker size grows with the value. Proportional { @@ -828,6 +839,8 @@ impl Renderer { other_label: None, class_strokes: BTreeMap::new(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }) } @@ -915,6 +928,7 @@ impl Renderer { classes: n, class_labels: BTreeMap::new(), class_strokes: BTreeMap::new(), + class_fills: Default::default(), }) } @@ -1004,6 +1018,27 @@ impl Renderer { (!strokes.is_empty()).then_some(strokes.as_slice()) } + /// The stacked fills a polygon feature row's class has of its own (`class_fills`), or `None` + /// when its class has none (the layer's `fill_layers` apply). + pub fn fills_for(&self, ds: &Dataset, row: usize) -> Option<&[FillLayer]> { + let fills = match (self, self.class_of(ds, row)?) { + (Renderer::Unique { classes, class_fills, .. }, Slot::Class(i)) => class_fills.get(&classes.get(i)?.0)?, + (Renderer::Unique { other_fills, .. }, Slot::Other) => other_fills, + (Renderer::Graduated { class_fills, .. }, Slot::Class(i)) => class_fills.get(&i)?, + _ => return None, + }; + (!fills.is_empty()).then_some(fills.as_slice()) + } + + /// Whether any class has stacked fills of its own. + pub fn has_class_fills(&self) -> bool { + match self { + Renderer::Unique { class_fills, other_fills, .. } => !class_fills.is_empty() || !other_fills.is_empty(), + Renderer::Graduated { class_fills, .. } => !class_fills.is_empty(), + Renderer::Single(_) | Renderer::Proportional { .. } => false, + } + } + /// Whether any class has stacked strokes of its own. pub fn has_class_strokes(&self) -> bool { match self { @@ -1013,6 +1048,27 @@ impl Renderer { } } + /// One class's own stacked fills (empty: it takes the layer's), as [`Renderer::class_strokes`]. + pub fn class_fills(&self, class: &ClassRef) -> Result<&[FillLayer], String> { + let none: &[FillLayer] = &[]; + match (self, class) { + (Renderer::Unique { classes, class_fills, .. }, ClassRef::Value(v)) => { + if !classes.iter().any(|(k, _)| k == v) { + return Err(format!("no class with the value '{v}'")); + } + Ok(class_fills.get(v).map_or(none, Vec::as_slice)) + } + (Renderer::Unique { other_fills, .. }, ClassRef::Other) => Ok(other_fills), + (Renderer::Graduated { symbols, class_fills, .. }, ClassRef::Index(i)) => { + if *i >= symbols.len() { + return Err(no_class(*i, symbols.len())); + } + Ok(class_fills.get(i).map_or(none, Vec::as_slice)) + } + _ => Err(class_mismatch(self)), + } + } + /// One class's own stacked strokes (empty when it takes the layer's). pub fn class_strokes(&self, class: &ClassRef) -> Result<&[LineStroke], String> { let none: &[LineStroke] = &[]; @@ -1034,6 +1090,41 @@ impl Renderer { } } + /// Sets one class's stacked fills (empty: the layer's again), as [`Renderer::set_class_strokes`] + /// does strokes. + pub fn set_class_fills(&mut self, class: &ClassRef, fills: Vec) -> Result<(), String> { + use crate::fills::MAX_FILLS; + if fills.len() > MAX_FILLS { + return Err(format!("at most {MAX_FILLS} fills")); + } + let fills: Vec = fills.into_iter().map(FillLayer::clamped).collect(); + match (&mut *self, class) { + (Renderer::Unique { classes, class_fills, .. }, ClassRef::Value(v)) => { + if !classes.iter().any(|(k, _)| k == v) { + return Err(format!("no class with the value '{v}'")); + } + if fills.is_empty() { + class_fills.remove(v); + } else { + class_fills.insert(v.clone(), fills); + } + } + (Renderer::Unique { other_fills, .. }, ClassRef::Other) => *other_fills = fills, + (Renderer::Graduated { symbols, class_fills, .. }, ClassRef::Index(i)) => { + if *i >= symbols.len() { + return Err(no_class(*i, symbols.len())); + } + if fills.is_empty() { + class_fills.remove(i); + } else { + class_fills.insert(*i, fills); + } + } + _ => return Err(class_mismatch(self)), + } + Ok(()) + } + /// Sets one class's own stacked strokes, at most [`MAX_STROKES`], each brought into range; /// none goes back to the layer's. pub fn set_class_strokes(&mut self, class: &ClassRef, strokes: Vec) -> Result<(), String> { @@ -1305,7 +1396,18 @@ impl Renderer { pub fn carry_edits_from(&mut self, old: &Renderer, g: GeometryType) { match (self, old) { ( - Renderer::Unique { field, classes, other, ramp, class_labels, other_label, class_strokes, other_strokes }, + Renderer::Unique { + field, + classes, + other, + ramp, + class_labels, + other_label, + class_strokes, + other_strokes, + class_fills, + other_fills, + }, Renderer::Unique { field: of, classes: oc, @@ -1315,12 +1417,16 @@ impl Renderer { other_label: ool, class_strokes: ost, other_strokes: oos, + class_fills: ocf, + other_fills: oof, }, ) if field == of => { let same_scheme = ramp == or; let n = oc.len(); - // Strokes are drawn by hand, as labels are: a class keeps them whatever the scheme. + // Strokes and fills are drawn by hand, as labels are: a class keeps them whatever + // the scheme. other_strokes.clone_from(oos); + other_fills.clone_from(oof); for (k, s) in classes.iter_mut() { if let Some(t) = ol.get(k.as_str()) { class_labels.insert(k.clone(), t.clone()); @@ -1328,6 +1434,9 @@ impl Renderer { if let Some(st) = ost.get(k.as_str()) { class_strokes.insert(k.clone(), st.clone()); } + if let Some(f) = ocf.get(k.as_str()) { + class_fills.insert(k.clone(), f.clone()); + } if same_scheme && let Some(j) = oc.iter().position(|(ok, _)| ok == k) && let Some((_, os)) = oc.get(j) @@ -1341,8 +1450,18 @@ impl Renderer { } } ( - Renderer::Graduated { field, breaks, symbols, ramp, min, class_labels, class_strokes, .. }, - Renderer::Graduated { field: of, breaks: ob, symbols: os, ramp: or, min: om, class_labels: ol, class_strokes: ost, .. }, + Renderer::Graduated { field, breaks, symbols, ramp, min, class_labels, class_strokes, class_fills, .. }, + Renderer::Graduated { + field: of, + breaks: ob, + symbols: os, + ramp: or, + min: om, + class_labels: ol, + class_strokes: ost, + class_fills: ocf, + .. + }, ) if field == of => { // A Graduated Colors edit belongs to a range: it stays only while the ranges do. if breaks != ob || min != om { @@ -1350,6 +1469,7 @@ impl Renderer { } class_labels.clone_from(ol); class_strokes.clone_from(ost); + class_fills.clone_from(ocf); if ramp == or && symbols.len() == os.len() { let n = os.len(); for (i, (s, o)) in symbols.iter_mut().zip(os).enumerate() { @@ -2602,6 +2722,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let labels = |rows: &[bool]| r.legend_for(&d, rows).into_iter().map(|e| e.label).collect::>(); assert_eq!(labels(&[]), ["a", "b", ""]); @@ -2621,6 +2743,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let shown = twice.legend_for(&d, &[]); assert_eq!(shown.iter().map(|e| e.label.as_str()).collect::>(), ["a", "b", ""]); @@ -2635,6 +2759,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; assert_eq!(gone.legend_for(&d, &[]).len(), 1); // The fallback row is marked as such, whatever a class is called. @@ -2647,6 +2773,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let mut d2 = d.clone(); d2.push(None, vec![Value::Text(OTHER_VALUES.into())]); @@ -2678,6 +2806,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let labels = |r: &Renderer, rows: &[bool]| r.legend_for(&d, rows).into_iter().map(|e| e.label).collect::>(); assert_eq!(labels(&r, &[]), ["a"], "z falls in the invisible fallback"); @@ -2694,6 +2824,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; assert_eq!(labels(&r2, &[]), ["a", OTHER_VALUES]); // A line's fill draws nothing: a fallback with only a fill is invisible on lines. @@ -2710,6 +2842,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; assert!(r3.legend_for(&lines, &[]).is_empty()); assert_eq!(labels(&r3, &[]), ["a", OTHER_VALUES], "a polygon fill still shows"); @@ -2738,6 +2872,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let legend = renderer.legend(); assert_eq!( @@ -2974,6 +3110,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), } } @@ -2988,6 +3126,7 @@ mod tests { classes: breaks.len(), class_labels: Default::default(), class_strokes: Default::default(), + class_fills: Default::default(), } } diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index b90f452c3..7bb8da7dc 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1675,6 +1675,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; } // How far down the legend draws, in pixels. @@ -1782,6 +1784,7 @@ mod tests { classes: 0, class_labels: Default::default(), class_strokes: Default::default(), + class_fills: Default::default(), }; } map.layers.retain(|l| l.id == pts); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e9dce92ea..be53e46ec 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ mod line_labels; mod line_markers; pub mod logo; mod mesh; +mod pattern_fill; pub mod pdf; mod picture; mod point_marks; @@ -380,6 +381,11 @@ impl Drop for Antialias { } } +/// Whether the map being drawn is antialiased. +fn antialiased() -> bool { + ANTIALIAS.with(|a| a.get()) +} + fn paint(c: Rgba, opacity: f32) -> Paint<'static> { let mut p = Paint::default(); let a = (c.a() as f32 * opacity).round().clamp(0.0, 255.0) as u8; @@ -2054,6 +2060,7 @@ pub fn gpu_may_turn(map: &Map, view: &View) -> bool { && l.aggregation.is_none() && !l.graphics && !l.has_strokes() + && !l.has_fills() && l.marker_rotation.is_none() && (l.extrusion.is_none() || view.camera.pitch != 0.0) && !(view.camera.pitch != 0.0 && l.renderer.has_char_markers() && stands_up(map, l)) @@ -3043,13 +3050,15 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // GPU-eligible: a plain vector layer drawn at full opacity with no mask, blend, // aggregation, extrusion or per-row graphics (an inert aggregation/extrusion on the // "wrong" geometry type still falls back — conservative and correct). - // Stacked line strokes are offset in screen space, which the GPU meshes don't do. + // Stacked line strokes are offset in screen space, and stacked fills draw patterns: + // the GPU meshes do neither. let gpu_layer = gpu && !offscreen && layer.aggregation.is_none() && layer.extrusion.is_none() && !layer.graphics && !layer.has_strokes() + && !layer.has_fills() && layer.marker_rotation.is_none() && !upright.contains(&layer.id); // Lyon refuses degenerate geometry tiny-skia draws fine, a layer too dense to mesh @@ -3113,6 +3122,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Symbol levels: lines with stacked strokes wait, and go down a level at a time // after the rest (each batch of a streamed layer on its own). let levels = layer.stroke_levels && layer.has_strokes(); + // Polygons with stacked fills (patterns) draw them in place of their single fill. + let has_fills = layer.has_fills(); let mut stacked: Vec<(&[Vec], Symbol, &[LineStroke])> = Vec::new(); for (row, g) in drawn.iter().enumerate() { let Some(g) = g else { continue }; @@ -3167,6 +3178,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } Geometry::Line(parts) if !strokes.is_empty() && levels => stacked.push((parts, sym, strokes)), Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(ctx, dest, parts, &sym, strokes), + Geometry::Polygon(polys) if has_fills && !ctx.wireframe && !layer.fills(row).is_empty() => { + ctx.filled(dest, polys, &sym, layer.fills(row)) + } _ => ctx.feature(dest, g, &sym), } } @@ -5975,6 +5989,115 @@ mod tests { assert_eq!(p, vec![(0.0, 1.0), (5.0, 1.0)]); } + #[test] + fn stacked_fills_hatch_and_dot_polygons_anchored_to_the_map() { + use rustgis_map::fills::{FillLayer, FillPattern}; + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("fields", GeometryType::Polygon, vec![Field::new("kind", FieldType::Text)]); + let square = |x0: f64, x1: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x0, -0.5), + Coord::new(x1, -0.5), + Coord::new(x1, 0.5), + Coord::new(x0, 0.5), + Coord::new(x0, -0.5), + ]]]) + }; + d.push(Some(square(-0.6, 0.6)), vec![Value::Text("a".into())]); + 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, blue) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255)); + let rgb = |r: &Rendered, x: u32, y: u32| r.pixmap.pixel(x, y).map(|p| (p.red(), p.green(), p.blue())).unwrap(); + let before = render(&mut m, &view).unwrap(); + let plain = rgb(&before, 100, 100); + // Hatched: red lines 6 points apart across the square's light fill, nothing outside it. + m.layer_mut(id).unwrap().fill_layers = vec![ + FillLayer { color: Some(Rgba::rgb(240, 240, 240)), pattern: FillPattern::Solid }, + FillLayer { color: Some(red), pattern: FillPattern::Hatch { angle: 0.0, spacing: 6.0, width: 1.5, offset: 0.0 } }, + ]; + let r = render(&mut m, &view).unwrap(); + let rows: Vec = (60..140).filter(|&y| rgb(&r, 100, y).0 > 200 && rgb(&r, 100, y).1 < 120).collect(); + assert!(rows.len() >= 10, "hatch rows: {rows:?}"); + let starts: Vec = rows.iter().copied().filter(|y| !rows.contains(&(y - 1))).collect(); + assert!(starts.windows(2).all(|w| w[1] - w[0] == 6), "6 points apart: {starts:?}"); + assert_eq!(rgb(&r, 100, 5), rgb(&before, 100, 5), "nothing outside the polygon"); + assert!((60..140).any(|y| rgb(&r, 100, y) == (240, 240, 240)), "the solid fill under the lines"); + // Anchored to the map: panned 3 points, the lines move 3 points with it. + let panned = View { camera: Camera { center: Coord::new(camera.center.x, camera.center.y + 3.0 * camera.res), ..camera }, ..view }; + let r2 = render(&mut m, &panned).unwrap(); + let moved: Vec = (60..140).filter(|&y| rgb(&r2, 100, y).0 > 200 && rgb(&r2, 100, y).1 < 120).collect(); + assert!(moved.iter().all(|y| rows.contains(&(y - 3)) || *y < 63 || *y > 137), "{rows:?} → {moved:?}"); + // Turned 90°: the lines run up and down. + if let FillPattern::Hatch { angle, .. } = &mut m.layer_mut(id).unwrap().fill_layers[1].pattern { + *angle = 90.0; + } + let r = render(&mut m, &view).unwrap(); + let red_at = |x: u32, y: u32| rgb(&r, x, y).0 > 200 && rgb(&r, x, y).1 < 120; + let cols = (60..140).filter(|&x| red_at(x, 100)).count(); + // Between the lines, a column with no red at all. + let clear = (60..140).any(|x| (60..140).all(|y| !red_at(x, y))); + assert!(cols >= 10 && clear, "vertical: {cols} columns"); + // Dots: blue markers on a grid, in the feature's colour when the fill has none. + let dots = FillPattern::Dots { + marker: rustgis_map::Marker::Circle, + size: 3.0, + outline: None, + outline_width: 0.0, + spacing: [10.0, 10.0], + displacement: [5.0, 0.0], + offset: [0.0, 0.0], + }; + m.layer_mut(id).unwrap().fill_layers = vec![FillLayer { color: Some(blue), pattern: dots }]; + let r = render(&mut m, &view).unwrap(); + let bluish = (60..140).flat_map(|y| (60..140).map(move |x| (x, y))).filter(|&(x, y)| rgb(&r, x, y) == (0, 0, 255)).count(); + assert!(bluish > 20, "dots: {bluish}"); + // A class's own fills over the layer's (Unique Values). + let base = rustgis_map::Symbol::default_for(GeometryType::Polygon, id); + m.layer_mut(id).unwrap().renderer = + rustgis_map::Renderer::unique(&m.layer(id).unwrap().dataset, &[], "kind", rustgis_map::Ramp::default(), base).unwrap(); + if let rustgis_map::Renderer::Unique { class_fills, .. } = &mut m.layer_mut(id).unwrap().renderer { + class_fills.insert("a".into(), vec![FillLayer { color: Some(red), pattern: FillPattern::Solid }]); + } + let r = render(&mut m, &view).unwrap(); + assert_eq!(rgb(&r, 100, 100), (255, 0, 0), "its class's fill"); + // None: the symbol's fill, as before. + m.layer_mut(id).unwrap().renderer = rustgis_map::Renderer::Single(rustgis_map::Symbol::default_for(GeometryType::Polygon, id)); + m.layer_mut(id).unwrap().fill_layers.clear(); + assert_eq!(rgb(&render(&mut m, &view).unwrap(), 100, 100), plain); + } + + #[test] + fn reference_scaled_hatch_stays_on_the_map_as_it_pans() { + // With a reference scale twice the map's, a hatch 6 points apart is drawn 12 apart; panned + // 3 points, its lines move 3 points with the polygons, not 6. + use rustgis_map::fills::{FillLayer, FillPattern}; + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("fields", GeometryType::Polygon, vec![]); + let ring = vec![Coord::new(-0.6, -0.5), Coord::new(0.6, -0.5), Coord::new(0.6, 0.5), Coord::new(-0.6, 0.5), Coord::new(-0.6, -0.5)]; + d.push(Some(Geometry::Polygon(vec![vec![ring]])), 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 scale = m.ground_res_at(&camera) / rustgis_geom::PIXEL_M; + let red = Rgba::rgb(255, 0, 0); + let l = m.layer_mut(id).unwrap(); + l.reference_scale = Some(scale * 2.0); + l.fill_layers = + vec![FillLayer { color: Some(red), pattern: FillPattern::Hatch { angle: 0.0, spacing: 6.0, width: 1.5, offset: 0.0 } }]; + let starts = |r: &Rendered| -> Vec { + let red_at = |y: u32| r.pixmap.pixel(100, y).is_some_and(|p| p.red() > 200 && p.green() < 120); + (68..132).filter(|&y| red_at(y) && !red_at(y - 1)).collect() + }; + let before = starts(&render(&mut m, &view).unwrap()); + assert!(before.len() >= 4 && before.windows(2).all(|w| w[1] - w[0] == 12), "12 points apart: {before:?}"); + let panned = View { camera: Camera { center: Coord::new(camera.center.x, camera.center.y + 3.0 * camera.res), ..camera }, ..view }; + let after = starts(&render(&mut m, &panned).unwrap()); + assert!(after.iter().filter(|y| (71..129).contains(*y)).all(|y| before.contains(&(y - 3))), "{before:?} → {after:?}"); + } + #[test] fn stacked_line_strokes_draw_offset_by_direction() { // Two copies of one line in opposite directions (#478): with a stroke offset to the right @@ -6258,6 +6381,8 @@ mod tests { other_label: None, class_strokes: [("road".to_string(), vec![stroke(black, 9.0), stroke(red, 3.0)])].into_iter().collect(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; l.line_strokes = vec![stroke(green, 3.0)]; // 0.3° is about 22 px from the centre at this resolution. diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs new file mode 100644 index 000000000..92fe4491c --- /dev/null +++ b/crates/render/src/pattern_fill.rs @@ -0,0 +1,172 @@ +//! A polygon's stacked fills (`Layer::fill_layers`, #882 piece 5), drawn bottom first inside it +//! in place of its symbol's fill, its outline over them: a fill in a colour, hatch lines, or +//! markers on a grid. A pattern is a small tile repeated by tiny-skia's pattern shader, its +//! lattice anchored to the map's origin (worked out in f64 and reduced to within a tile, so it +//! keeps its place on the map at every zoom and neighbouring polygons share one), turned for a +//! hatch's angle. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use rustgis_geom::Coord; +use rustgis_map::fills::{FillLayer, FillPattern}; +use rustgis_map::{Rgba, Symbol}; +use tiny_skia::{FillRule, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::{Ctx, paint}; + +/// The most pattern tiles kept (they are kept between frames: a map's patterns are few). +const MAX_TILES: usize = 64; +/// The longest side of a tile in pixels: a coarser pattern is drawn at a lower resolution and +/// scaled up, so a tile never takes more than a megabyte. +const MAX_TILE_PX: f32 = 512.0; + +thread_local! { + /// Pattern tiles drawn so far, by what they draw at what size. + static TILES: RefCell, Option>>> = RefCell::new(HashMap::new()); +} + +impl Ctx<'_> { + /// Draws `polys` with its stacked `fills`, bottom first (a fill without a colour of its own + /// takes the symbol's), then the symbol's outline over them. + pub(crate) fn filled(&self, pm: &mut Pixmap, polys: &[Vec>], s: &Symbol, fills: &[FillLayer]) { + let mut pb = PathBuilder::new(); + for rings in polys { + for r in rings { + self.ring_path(&mut pb, r, true); + } + } + let Some(path) = pb.finish() else { return }; + // The map's origin in device pixels, in f64: the patterns' lattice point. + let origin = match self.globe { + Some(_) => (0.0, 0.0), + None => { + let (x, y) = self.view.to_screen(Coord::new(0.0, 0.0)); + // (Placed as the polygons are: by the view's pixels a point, not the layer's + // reference-scaled one, so a pattern keeps its place on the map as it pans.) + let ppp = self.view.pixels_per_point; + (x * ppp, y * ppp) + } + }; + for f in fills.iter().filter(|f| f.draws(s.fill)) { + let color = f.color.unwrap_or(s.fill); + match f.pattern { + FillPattern::Solid => pm.fill_path(&path, &paint(color, 1.0), FillRule::EvenOdd, Transform::identity(), None), + pattern => { + let Some((tile, t)) = pattern_tile(pattern, color, self.ppp, origin) else { continue }; + let shader = tiny_skia::Pattern::new( + tile.as_ref().as_ref(), + tiny_skia::SpreadMode::Repeat, + tiny_skia::FilterQuality::Bilinear, + 1.0, + t, + ); + let fill = Paint { shader, anti_alias: true, ..Paint::default() }; + pm.fill_path(&path, &fill, FillRule::EvenOdd, Transform::identity(), None); + } + } + } + if s.stroke_width > 0.0 && s.stroke.a() > 0 { + let st = Stroke { width: s.stroke_width * self.ppp, line_join: LineJoin::Round, ..Stroke::default() }; + pm.stroke_path(&path, &paint(s.stroke, 1.0), &st, Transform::identity(), None); + } + } +} + +/// `v` modulo `m` (positive), as f32: a lattice offset reduced to within one period. +fn reduce(v: f64, m: f64) -> f32 { + if m > 0.0 && v.is_finite() { v.rem_euclid(m) as f32 } else { 0.0 } +} + +/// `pattern`'s tile in `color` at `ppp` pixels a point, and the transform that repeats it with +/// its lattice through `origin` (device pixels); `None` for a pattern of no size. +fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) -> Option<(Arc, Transform)> { + match pattern { + FillPattern::Solid => None, + FillPattern::Hatch { angle, spacing, width, offset } => { + // A tile a few pixels across and a period high, its line along the middle, turned to + // the angle (anticlockwise from east; the screen's y runs down). + let period = spacing * ppp; + let k = (MAX_TILE_PX / period).min(1.0); + let h = (period * k).ceil().max(1.0); + let tile = tile(&key(&pattern, color, ppp), 4.0, h, |pm| { + let w = (width * ppp * k).max(0.0); + let mid = h / 2.0; + if let Some(r) = tiny_skia::Rect::from_ltrb(-1.0, mid - w / 2.0, 5.0, mid + w / 2.0) { + pm.fill_rect(r, &paint(color, 1.0), Transform::identity(), None); + } + })?; + let rad = -angle.to_radians(); + // The lattice across the lines (their normal) through the origin, shifted by the offset. + let (nx, ny) = (-f64::from(rad.sin()), f64::from(rad.cos())); + let across = reduce(origin.0 * nx + origin.1 * ny + f64::from(offset * ppp), f64::from(period)); + let t = Transform::from_scale(1.0, period / h).post_translate(0.0, across - period / 2.0).post_rotate(rad.to_degrees()); + Some((tile, t)) + } + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => { + // Two cells each way, for the odd rows' and columns' displacement. + let [dx, dy] = spacing.map(|s| s * ppp); + let [sx, sy] = displacement.map(|d| d * ppp); + let (tw, th) = (2.0 * dx, 2.0 * dy); + let k = (MAX_TILE_PX / tw.max(th)).min(1.0); + let symbol = + Symbol { fill: color, stroke: outline.unwrap_or(Rgba::new(0, 0, 0, 0)), stroke_width: outline_width, size, marker }; + let tile = tile(&key(&pattern, color, ppp), (tw * k).ceil().max(1.0), (th * k).ceil().max(1.0), |pm| { + for (col, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + // A marker in each cell's middle, odd rows shifted across, odd columns down; + // drawn again a tile away each way where it crosses the tile's edges. + let x = (col as f32 + 0.5) * dx + if row == 1 { sx } else { 0.0 }; + let y = (row as f32 + 0.5) * dy + if col == 1 { sy } else { 0.0 }; + for (ox, oy) in [-1.0, 0.0, 1.0].into_iter().flat_map(|i| [-1.0, 0.0, 1.0].map(|j| (i * tw, j * th))) { + crate::draw_marker(pm, (x + ox) * k, (y + oy) * k, &symbol, 1.0, None, ppp * k); + } + } + })?; + let (ax, ay) = ( + reduce(origin.0 + f64::from(offset[0] * ppp), f64::from(tw)), + reduce(origin.1 + f64::from(offset[1] * ppp), f64::from(th)), + ); + Some((tile, Transform::from_scale(tw / (tw * k).ceil().max(1.0), th / (th * k).ceil().max(1.0)).post_translate(ax, ay))) + } + } +} + +/// What a tile draws at what size (and whether antialiased), as its cache key. +fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Vec { + let mut k = vec![u32::from_le_bytes(color.0), ppp.to_bits(), u32::from(crate::antialiased())]; + match *pattern { + FillPattern::Solid => k.push(0), + FillPattern::Hatch { spacing, width, .. } => k.extend([1, spacing.to_bits(), width.to_bits()]), + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, .. } => { + k.extend([2, size.to_bits(), outline_width.to_bits(), outline.map_or(0, |o| u32::from_le_bytes(o.0))]); + k.extend(spacing.iter().chain(&displacement).map(|v| v.to_bits())); + // (The marker's shape, by its debug form: a few kinds and a character.) + k.extend(format!("{marker:?}").bytes().map(u32::from)); + } + } + k +} + +/// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for +/// one that can't be made. +fn tile(key: &[u32], w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { + if let Some(t) = TILES.with(|t| t.borrow().get(key).cloned()) { + return t; + } + let made = (w.is_finite() && h.is_finite() && w <= MAX_TILE_PX + 1.0 && h <= MAX_TILE_PX + 1.0) + .then(|| Pixmap::new(w.max(1.0) as u32, h.max(1.0) as u32)) + .flatten() + .map(|mut pm| { + draw(&mut pm); + Arc::new(pm) + }); + TILES.with(|t| { + let mut t = t.borrow_mut(); + if t.len() >= MAX_TILES { + t.clear(); + } + t.insert(key.to_vec(), made.clone()); + }); + made +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 99c83758d..064eb0635 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -720,9 +720,8 @@ fn search_action(id: &str) -> Option { "layer.repairSource" => Dialog("repairSource"), "ui.importShapefile" => Dialog("addShapefile"), "layer.definitionQuery" => Dialog("definitionQuery"), - "layer.symbology" | "raster.symbology" | "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.ramp" => { - Pane(RightPane::Symbology) - } + "layer.symbology" | "raster.symbology" | "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.fillLayers" + | "layer.ramp" => Pane(RightPane::Symbology), "select.byAttributes" => Dialog("selectByAttributes"), "select.rows" => Command("table.open"), "edit.addField" => Dialog("addField"), diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs new file mode 100644 index 000000000..0a3fd2840 --- /dev/null +++ b/crates/ui-egui/src/fill_editor.rs @@ -0,0 +1,367 @@ +//! The Fills section of a polygon layer's symbology (#882 piece 5): fills stacked in one symbol, +//! each a colour, hatch lines or markers on a grid, as the Strokes section stacks a line's. Listed +//! top first, as they are seen; a layer with classes can stack fills for one class of its own, +//! picked at the top. Every change runs `layer.fillLayers`. + +use egui::{CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use rustgis_engine::map::fills::{FillLayer, FillPattern, MAX_DOT_SIZE, MAX_FILLS, MAX_HATCH_WIDTH, MAX_PATTERN_OFFSET, PATTERN_SPACING}; +use rustgis_engine::map::symbology::ClassRef; +use rustgis_engine::map::{Layer, Marker, Symbol}; +use serde_json::{Value, json}; + +use crate::RustGisApp; +use crate::theme::{Tokens, c32, rgba, semibold}; + +/// The fill's settings as `layer.fillLayers` takes them. +fn fill_json(f: &FillLayer) -> Value { + let color = f.color.map(|c| c.hex()); + match f.pattern { + FillPattern::Solid => json!({"color": color, "pattern": "solid"}), + FillPattern::Hatch { angle, spacing, width, offset } => { + json!({"color": color, "pattern": "hatch", "angle": angle, "spacing": spacing, "width": width, "offset": offset}) + } + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => json!({ + "color": color, "pattern": "dots", "marker": marker.label(), "size": size, "outline": outline.map(|c| c.hex()), + "outlineWidth": outline_width, "spacing": spacing, "displacement": displacement, "offset": offset, + }), + } +} + +/// Runs `layer.fillLayers` with `fills` for the class picked (`None`: the layer's). +fn set_fills(app: &mut RustGisApp, class: Option<&ClassRef>, fills: &[FillLayer]) { + let mut p = json!({"fills": fills.iter().map(fill_json).collect::>()}); + match class { + Some(ClassRef::Value(v)) => p["value"] = json!(v), + Some(ClassRef::Index(i)) => p["index"] = json!(i), + Some(ClassRef::Other) => p["other"] = json!(true), + None => {} + } + let _ = app.run("layer.fillLayers", p); +} + +/// A fill's kind, as the Draw list names it. +fn kind(f: &FillLayer) -> &'static str { + match f.pattern { + FillPattern::Solid => "Solid", + FillPattern::Hatch { .. } => "Hatch lines", + FillPattern::Dots { .. } => "Markers", + } +} + +pub(crate) fn fills_editor(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { + let classes = crate::panes::stroke_classes(&l.renderer); + let picked_id = egui::Id::unique(("fill_class", l.id)); + // The class being edited, while the renderer still has it; `None` is the layer's own fills. + let picked: Option = + ui.data(|d| d.get_temp::>(picked_id)).flatten().filter(|c| classes.iter().any(|(k, ..)| k == c)); + let class = picked.as_ref(); + let base = class.and_then(|c| classes.iter().find(|(k, ..)| k == c)).map_or_else(|| l.renderer.base_symbol(), |(.., s)| *s); + let own = class.and_then(|c| l.renderer.class_fills(c).ok()).map(<[FillLayer]>::to_vec); + // What the class draws: its own fills, else the layer's. + let fills = match &own { + Some(v) if !v.is_empty() => v.clone(), + Some(_) | None => l.fill_layers.clone(), + }; + let borrowed = own.as_ref().is_some_and(Vec::is_empty); + egui::Frame::NONE.fill(t.pane_header).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Fills")).font(semibold(12.5))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let add = ui.add_enabled(fills.len() < MAX_FILLS, egui::Button::new(tl!("Add Fill")).small()); + if add.on_hover_text(tl!("Stack another fill on top")).clicked() { + let mut next = fills.clone(); + // The first stands for the symbol's own fill; the next hatches over it. + let first = FillLayer::default(); + next.push(if next.is_empty() { first } else { FillLayer::hatch(45.0, 6.0, 1.0) }); + set_fills(app, class, &next); + } + }); + }); + if !classes.is_empty() { + ui.horizontal(|ui| { + let input_label = ui.label(egui::RichText::new(tl!("Fills for")).size(11.5).color(t.text_dim)); + let name = |c: Option<&ClassRef>| match c.and_then(|c| classes.iter().find(|(k, ..)| k == c)) { + Some((_, label, _)) => label.clone(), + None => tl!("All classes").to_string(), + }; + egui::ComboBox::from_id_salt(("fill_class_pick", l.id)) + .selected_text(name(class)) + .show_ui(ui, |ui| { + if ui.selectable_label(class.is_none(), tl!("All classes")).on_hover_text(tl!("The layer's fills")).clicked() { + ui.data_mut(|d| d.insert_temp::>(picked_id, None)); + } + for (k, label, _) in &classes { + // A class with fills of its own is marked. + let has_own = l.renderer.class_fills(k).is_ok_and(|f| !f.is_empty()); + let text = if has_own { format!("{label} •") } else { label.clone() }; + if ui.selectable_label(class == Some(k), text).clicked() { + ui.data_mut(|d| d.insert_temp(picked_id, Some(k.clone()))); + } + } + }) + .response + .labelled_by(input_label.id); + }); + } + // The stacked fills on a square, bottom first, as the map draws them. + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(4), t.ribbon); + let swatch = Rect::from_center_size(r.center(), vec2(r.width().min(120.0) - 8.0, r.height() - 10.0)); + preview(ui, swatch, &fills, &base); + if borrowed { + ui.label( + egui::RichText::new(tl!("This class is drawn with the layer's fills. Changing them here gives it its own.")) + .size(11.5) + .color(t.text_dim), + ); + } + if fills.is_empty() { + ui.label( + egui::RichText::new(tl!("One fill, from the symbol. Add fills to stack colours, hatch lines and markers.")) + .size(11.5) + .color(t.text_dim), + ); + return; + } + let n = fills.len(); + for i in (0..n).rev() { + let Some(f) = fills.get(i).copied() else { continue }; + ui.add_space(4.0); + ui.separator(); + ui.horizontal(|ui| { + let title = crate::i18n::fmt("Fill {n}", &[("n", &(n - i).to_string())]); + ui.label(egui::RichText::new(title).size(11.5).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "close", tl!("Remove")).clicked() { + let mut next = fills.clone(); + next.remove(i); + set_fills(app, class, &next); + } + if ui.add_enabled(i > 0, egui::Button::new("↓").small()).on_hover_text(tl!("Move down")).clicked() { + let mut next = fills.clone(); + next.swap(i, i - 1); + set_fills(app, class, &next); + } + if ui.add_enabled(i + 1 < n, egui::Button::new("↑").small()).on_hover_text(tl!("Move up")).clicked() { + let mut next = fills.clone(); + next.swap(i, i + 1); + set_fills(app, class, &next); + } + }); + }); + let set = |app: &mut RustGisApp, new: FillLayer| { + let mut next = fills.clone(); + if let Some(slot) = next.get_mut(i) { + *slot = new; + } + set_fills(app, class, &next); + }; + egui::Grid::new(("fill_grid", i)).num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + let input_label = ui.label(tl!("Draw")); + egui::ComboBox::from_id_salt(("fill_draw", i)) + .selected_text(tl!(kind(&f))) + .show_ui(ui, |ui| { + let options = [ + FillLayer { pattern: FillPattern::Solid, ..f }, + FillLayer::hatch(45.0, 6.0, 1.0), + FillLayer { + pattern: FillPattern::Dots { + marker: Marker::Circle, + size: 3.0, + outline: None, + outline_width: 0.0, + spacing: [8.0, 8.0], + displacement: [4.0, 0.0], + offset: [0.0, 0.0], + }, + ..f + }, + ]; + for option in options { + let option = FillLayer { color: f.color, ..option }; + if ui.selectable_label(kind(&option) == kind(&f), tl!(kind(&option))).clicked() && kind(&option) != kind(&f) { + set(app, option); + } + } + }) + .response + .on_hover_text(tl!("A colour, hatch lines, or markers on a grid")) + .labelled_by(input_label.id); + ui.end_row(); + ui.label(tl!("Color")); + ui.horizontal(|ui| { + let c = f.color.unwrap_or(base.fill); + let mut cc = c32(c); + if crate::widgets::color_button(ui, &mut cc).changed() { + set(app, FillLayer { color: Some(rgba(cc)), ..f }); + } + let mut from_symbol = f.color.is_none(); + if ui.checkbox(&mut from_symbol, tl!("From symbol")).on_hover_text(tl!("Use each feature's fill color")).changed() { + set(app, FillLayer { color: if from_symbol { None } else { Some(c) }, ..f }); + } + }); + ui.end_row(); + let number = |ui: &mut Ui, label: &str, v: f32, range: std::ops::RangeInclusive, suffix: &str| -> Option { + let input_label = ui.label(tl!(label)); + let mut x = v; + let r = ui.add(egui::DragValue::new(&mut x).range(range).speed(0.1).suffix(suffix)).labelled_by(input_label.id); + ui.end_row(); + (r.changed() && x != v).then_some(x) + }; + match f.pattern { + FillPattern::Solid => {} + FillPattern::Hatch { angle, spacing, width, offset } => { + let hatch = |angle, spacing, width, offset| FillLayer { + pattern: FillPattern::Hatch { angle, spacing, width, offset }, + ..f + }; + if let Some(a) = number(ui, "Angle", angle, 0.0..=360.0, "°") { + set(app, hatch(a, spacing, width, offset)); + } + if let Some(s) = number(ui, "Spacing", spacing, PATTERN_SPACING, " pt") { + set(app, hatch(angle, s, width, offset)); + } + if let Some(w) = number(ui, "Line width", width, 0.0..=MAX_HATCH_WIDTH, " pt") { + set(app, hatch(angle, spacing, w, offset)); + } + if let Some(o) = number(ui, "Offset", offset, -MAX_PATTERN_OFFSET..=MAX_PATTERN_OFFSET, " pt") { + set(app, hatch(angle, spacing, width, o)); + } + } + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => { + let dots = |marker, size, spacing: [f32; 2], displacement: [f32; 2]| FillLayer { + pattern: FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset }, + ..f + }; + let input_label = ui.label(tl!("Marker")); + egui::ComboBox::from_id_salt(("fill_marker", i)) + .selected_text(tl!(marker.label())) + .show_ui(ui, |ui| { + for m in Marker::ALL { + if ui.selectable_label(m == marker, tl!(m.label())).clicked() && m != marker { + set(app, dots(m, size, spacing, displacement)); + } + } + }) + .response + .labelled_by(input_label.id); + ui.end_row(); + if let Some(s) = number(ui, "Size", size, 0.0..=MAX_DOT_SIZE, " pt") { + set(app, dots(marker, s, spacing, displacement)); + } + if let Some(s) = number(ui, "Spacing across", spacing[0], PATTERN_SPACING, " pt") { + set(app, dots(marker, size, [s, spacing[1]], displacement)); + } + if let Some(s) = number(ui, "Spacing down", spacing[1], PATTERN_SPACING, " pt") { + set(app, dots(marker, size, [spacing[0], s], displacement)); + } + if let Some(d) = number(ui, "Rows shifted", displacement[0], 0.0..=(spacing[0] - 0.01).max(0.0), " pt") { + set(app, dots(marker, size, spacing, [d, displacement[1]])); + } + } + } + }); + } + }); +} + +/// The stacked `fills` on `r`, bottom first: a colour fills it, hatch lines cross it at their +/// angle and spacing, markers sit on their grid (as circles), each in its colour or `base`'s. +fn preview(ui: &Ui, r: Rect, fills: &[FillLayer], base: &Symbol) { + let p = ui.painter().with_clip_rect(r); + let one = [FillLayer::default()]; + let shown = if fills.is_empty() { &one[..] } else { fills }; + for f in shown { + let c = c32(f.color.unwrap_or(base.fill)); + match f.pattern { + FillPattern::Solid => { + p.rect_filled(r, CornerRadius::ZERO, c); + } + FillPattern::Hatch { angle, spacing, width, .. } => { + // Lines across the swatch, `spacing` apart along their normal. + let (sin, cos) = (-angle.to_radians()).sin_cos(); + let (dir, normal) = (vec2(cos, sin), vec2(-sin, cos)); + let reach = r.width().hypot(r.height()); + let step = spacing.max(1.0); + let mut k = -reach; + while k <= reach { + let mid = r.center() + normal * k; + p.line_segment([mid - dir * reach, mid + dir * reach], Stroke::new(width.max(0.5), c)); + k += step; + } + } + FillPattern::Dots { size, spacing, displacement, .. } => { + let [dx, dy] = spacing.map(|s| s.max(1.0)); + let (mut y, mut row) = (r.min.y + dy / 2.0, 0); + while y < r.max.y + dy { + let shift = if row % 2 == 1 { displacement[0] } else { 0.0 }; + let mut x = r.min.x + dx / 2.0 + shift; + while x < r.max.x + dx { + p.circle_filled(pos2(x, y), (size / 2.0).max(0.5), c); + x += dx; + } + y += dy; + row += 1; + } + } + } + } + // The symbol's outline over them. + if base.stroke_width > 0.0 { + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(base.stroke_width.min(3.0), c32(base.stroke)), egui::StrokeKind::Inside); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use egui_kittest::kittest::Queryable; + + use super::*; + + /// Fills are added (the first the symbol's colour, the next hatch lines over it), drawn as + /// markers or removed, each a `layer.fillLayers`. + #[test] + fn fills_are_stacked_and_changed_in_the_editor() { + let app = RustGisApp::with_sample(); + // The section's fonts (its semibold heading) are installed on the first frame, as the app does. + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(420.0, 900.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + return; + } + let Some(l) = app.session.map().layer_by_name("Countries").cloned() else { return }; + fills_editor(app, ui, &l, &Tokens::light()); + }, + app, + ); + h.run(); + let fills = + |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer_by_name("Countries").unwrap().fill_layers.clone(); + assert!(fills(&h).is_empty()); + assert!(h.query_by_label("Fill 1").is_none()); + h.get_by_label("Add Fill").click(); + h.run(); + assert_eq!(fills(&h), [FillLayer::default()], "the symbol's colour first"); + h.get_by_label("Add Fill").click(); + h.run(); + let f = fills(&h); + assert_eq!(f.len(), 2); + assert!(matches!(f[1].pattern, FillPattern::Hatch { .. }), "hatch lines over it: {f:?}"); + assert!(h.query_by_label("Fill 2").is_some() && h.query_by_label("Angle").is_some()); + // The top one (listed first) drawn as markers. + h.query_all_by_label("Draw").next().unwrap().click(); + h.run(); + h.get_by_label("Markers").click(); + h.run(); + assert!(matches!(fills(&h)[1].pattern, FillPattern::Dots { .. }), "{:?}", fills(&h)); + // Removed: the first remove button is the top fill's. + h.query_all_by_label("Remove").next().unwrap().click(); + h.run(); + assert_eq!(fills(&h), [FillLayer::default()]); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fc8d1c52..cfd53f5f9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ mod editpanes; mod embeddingsui; mod failureui; mod fieldmenu; +mod fill_editor; mod geocodeui; mod geometrycalc; mod globe_drape; @@ -1796,10 +1797,11 @@ impl RustGisApp { // updates its step; anything else, a second selection on the same layer included, is a // step of its own. if let Some(last) = steps.last_mut() - && matches!(id, "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.transparency") + && matches!(id, "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.fillLayers" | "layer.transparency") && last.command == id && params.get("layer").is_some() && last.params.get("layer") == params.get("layer") + && ["value", "index", "other"].iter().all(|k| last.params.get(*k) == params.get(*k)) { last.params = params.clone(); return; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 690a48121..5bacea17b 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3709,6 +3709,10 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(8.0); line_strokes_editor(app, ui, &l, t); } + if g == GeometryType::Polygon && !l.graphics { + ui.add_space(8.0); + crate::fill_editor::fills_editor(app, ui, &l, t); + } ui.add_space(10.0); ui.separator(); ui.label(egui::RichText::new(tl!("Layer")).size(11.5).color(t.text_dim)); @@ -4366,7 +4370,7 @@ fn set_strokes(app: &mut RustGisApp, class: Option<&ClassRef>, strokes: &[LineSt } /// The classes a line layer's strokes can be set for, with their legend labels and symbols. -fn stroke_classes(r: &Renderer) -> Vec<(ClassRef, String, Symbol)> { +pub(crate) fn stroke_classes(r: &Renderer) -> Vec<(ClassRef, String, Symbol)> { match r { Renderer::Unique { classes, other, class_labels, other_label, .. } => { // A value listed twice is drawn with its first class, as the legend shows it. @@ -5821,6 +5825,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), }; let listed = stroke_classes(&r); let refs: Vec<&ClassRef> = listed.iter().map(|(c, ..)| c).collect(); diff --git a/crates/ui-egui/src/showme.rs b/crates/ui-egui/src/showme.rs index b3f891ecc..b1aac02ab 100644 --- a/crates/ui-egui/src/showme.rs +++ b/crates/ui-egui/src/showme.rs @@ -36,7 +36,7 @@ const DIALOGS: &[(&str, &str)] = &[("select.byAttributes", "selectByAttributes") /// Starts showing where a step's command is; returns what to tell the user. pub fn start(app: &mut RustGisApp, st: &TaskStep) -> String { let id = st.command.as_str(); - if matches!(id, "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.symbology" | "layer.transparency") { + if matches!(id, "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.fillLayers" | "layer.symbology" | "layer.transparency") { // Making the step's layer the active one is what shows its symbology. It runs on the // session directly, so a recording doesn't keep it as a step. // A null layer (a task from a file may hold one) is the active layer, as the command reads it. diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 1637ffd79..fda980917 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -217,6 +217,31 @@ start, back from its end, on from its middle), `"averageAngle"` turns a mark at 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. +### Fills + +A polygon layer fills with one colour by default. The **Fills** section of the Symbology pane +stacks fills inside the outline, as a wetland gets a pale green under blue hatch lines: **Add +Fill** puts one on top (the first is the symbol's colour, the next hatch lines over it), the +arrows move one up or down and the cross removes it; the top fill is listed first. **Draw** picks +**Solid**, **Hatch lines** (parallel lines at an **Angle**, anticlockwise from east, **Spacing** +apart, of a **Line width**, shifted across by an **Offset**) or **Markers** (a **Marker** shape +of a **Size** on a grid **Spacing across** and **Spacing down** apart, **Rows shifted** to +stagger them). A colour set to **From symbol** follows the renderer, so the fills take each +class's colour. The polygon's outline is drawn over the fills. Patterns are fixed to the map, so +neighbouring polygons' hatch lines meet, and keep their size in points as the map zooms. A Unique +Values or Graduated Colors class can stack fills of its own, picked under **Fills for** as for +strokes. By command, `layer.fillLayers`: + +```text +layer.fillLayers={"layer":"Wetlands","fills":[{"color":"#DDEEDD"},{"color":"#3366CC","pattern":"hatch","angle":0,"spacing":6,"width":0.8}]} +layer.fillLayers={"layer":"Orchards","fills":[{"color":"#F2F5E6"},{"color":"#4E7A2E","pattern":"dots","marker":"circle","size":3,"spacing":[8,8],"displacement":[4,0]}]} +layer.fillLayers={"layer":"Land use","value":"Forest","fills":[{"color":null},{"color":"#2D5A27","pattern":"hatch","angle":45}]} +``` + +`[]` goes back to the single fill (for a class, to the layer's fills). Fills draw on the CPU (on the +globe, patterns keep to the screen rather than the map as it turns); the +legend shows the symbol's colour. + ### 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/maps.md b/docs/user-guide/maps.md index 1fd15d488..f43986aa0 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -417,5 +417,5 @@ GPU Rendering is off by default and remembered across sessions (`ui.map.gpu` is `RUSTGIS_GPU=1` forces it on for development). The globe, view effects and colour-vision simulation stay on the CPU, and exports are unaffected. A rotated or tilted map uses the GPU only when every visible layer can be drawn there: tiles, rasters, terrain, transparency, -masking, swipe, blending, aggregation, graphics, stacked strokes or labels with icons keep it +masking, swipe, blending, aggregation, graphics, stacked strokes or fills or labels with icons keep it on the CPU. Extrusions use the GPU only on a tilted map. diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index eae8b5deb..e14bd8314 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -96,7 +96,11 @@ Each layer keeps its look: middle), turned with the line, their markers' offsets and angles kept; font markers as characters of their font, on points and along lines; a marker symbol of several layers, or one shifted, turned or anchored off its centre, as a point's marks; a marker angle taken from a - field (`"field"` or `- "field"`) as the layer's rotation field. + field (`"field"` or `- "field"`) as the layer's rotation field; a fill symbol of several layers + as stacked fills, its brush patterns (horizontal, vertical, diagonal, cross) and line pattern + fills as hatch lines, point pattern fills as markers on their grid, and the fills not drawn yet + (gradient and shapeburst fills in their first colour; dense brushes, SVG, raster and random fills as + a tint of their colour). - **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 @@ -122,7 +126,7 @@ older names of the same settings are read. QGIS expressions carry over where they are SQL: fields compared with values, `LIKE`, `IN`, `IS NULL`, joined by `AND`, `OR` and `NOT`. What RustGIS doesn't draw as QGIS does is drawn as near as it can, or left out, and said: the import's notes list, layer by layer, what was -approximated (pattern fills drawn as a tint of their colour, SVG markers as circles, +approximated (gradient and shapeburst fills drawn in their first colour, SVG, raster and random fills and dense brushes as a tint of their colour, font markers in point pattern fills drawn as circles, SVG markers as circles, dashed polygon outlines solid, label halos with sharp corners drawn round and blended ones drawn over the map, label backgrounds of other shapes drawn as rectangles, label distances from lines and polygons, a distance around points moved straight up, labels wrapped at a shortest line From 135cea144b36f93c1675862bdd5a2f505f8abe92 Mon Sep 17 00:00:00 2001 From: strebdom Date: Tue, 27 Oct 2026 14:35:55 +0100 Subject: [PATCH 02/10] fix(qgis): keep font markers in point pattern fills as characters They draw as their font's character in the pattern tile (a circle only where no installed font has it). Drawing them as circles turned SMV25's vineyards (a font character 3.3 pt across on a 2.3 pt grid) into solid bands. --- crates/map/src/fills.rs | 2 +- crates/map/src/qgis/symbol.rs | 8 +------- docs/user-guide/workspace.md | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/map/src/fills.rs b/crates/map/src/fills.rs index d76b2bac9..60d71a531 100644 --- a/crates/map/src/fills.rs +++ b/crates/map/src/fills.rs @@ -116,7 +116,7 @@ impl FillLayer { FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => { let spacing = spacing.map(|s| within(s, *PATTERN_SPACING.start(), *PATTERN_SPACING.end(), dot_spacing()[0])); FillPattern::Dots { - marker: if let Marker::Char(_) = marker { Marker::Circle } else { marker }, + marker, size: within(size, 0.0, MAX_DOT_SIZE, dot_size()), outline, outline_width: within(outline_width, 0.0, MAX_HATCH_WIDTH, 0.0), diff --git a/crates/map/src/qgis/symbol.rs b/crates/map/src/qgis/symbol.rs index c0d2cd219..1d46ee503 100644 --- a/crates/map/src/qgis/symbol.rs +++ b/crates/map/src/qgis/symbol.rs @@ -326,18 +326,12 @@ fn fill(layers: &[(Node, Props)], reference: Option, layer: &str, report: & let spacing = [apart("distance_x", report), apart("distance_y", report)]; let displacement = [shift("displacement_x", report), shift("displacement_y", report)]; let moved = [shift("offset_x", report), shift("offset_y", report)]; - let marker = if let Marker::Char(_) = m.marker { - report.approximated(layer, "font markers in point pattern fills, drawn as circles"); - Marker::Circle - } else { - m.marker - }; let outline = (m.stroke.a() > 0 && m.stroke_width > 0.0).then_some(m.stroke); stack.push( FillLayer { color: Some(m.fill), pattern: FillPattern::Dots { - marker, + marker: m.marker, size: m.size, outline, outline_width: m.stroke_width, diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index e14bd8314..7eeef6979 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -126,7 +126,7 @@ older names of the same settings are read. QGIS expressions carry over where they are SQL: fields compared with values, `LIKE`, `IN`, `IS NULL`, joined by `AND`, `OR` and `NOT`. What RustGIS doesn't draw as QGIS does is drawn as near as it can, or left out, and said: the import's notes list, layer by layer, what was -approximated (gradient and shapeburst fills drawn in their first colour, SVG, raster and random fills and dense brushes as a tint of their colour, font markers in point pattern fills drawn as circles, SVG markers as circles, +approximated (gradient and shapeburst fills drawn in their first colour, SVG, raster and random fills and dense brushes as a tint of their colour, SVG markers as circles, dashed polygon outlines solid, label halos with sharp corners drawn round and blended ones drawn over the map, label backgrounds of other shapes drawn as rectangles, label distances from lines and polygons, a distance around points moved straight up, labels wrapped at a shortest line From cadae044070f76738b13c39c5f92dd5276b7d704 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 14:38:22 +0100 Subject: [PATCH 03/10] fix(symbology): stacked fills on polygons under a pixel; a pattern cache that keeps its tiles; one fill lookup per polygon - A polygon smaller than a pixel with stacked fills is drawn with them, not splatted in its symbol's fill colour. - The pattern tile cache lets its oldest tiles go when full (1,024 tiles or 64 MB), instead of emptying itself at 64, which a layer of many hatched classes would hit every frame; its key is a fixed-size value (no allocation per polygon). - A polygon's fills are looked up once, and a layer whose classes have no fills of their own isn't classed for them. - i18n::fmt for the class marker, a plainer [across, down] parser, explicit precedence in FillLayer::draws, and a comma in maps.md. --- crates/engine/src/cmd/layer.rs | 7 +- crates/map/src/fills.rs | 2 +- crates/map/src/lib.rs | 4 ++ crates/render/src/lib.rs | 47 +++++++++++-- crates/render/src/pattern_fill.rs | 113 ++++++++++++++++++++++++------ crates/ui-egui/src/fill_editor.rs | 2 +- docs/user-guide/maps.md | 2 +- 7 files changed, 144 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index badd3fffd..f6c376def 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -646,9 +646,10 @@ fn fill_layers(s: &mut Session, p: &Value) -> CmdResult { match o.get(k) { None | Some(Value::Null) => Ok(default), Some(Value::Array(a)) => match a.as_slice() { - [x, y] => Ok([number(x), number(y)]) - .and_then(|[x, y]| Ok([x.ok_or(())?, y.ok_or(())?])) - .map_err(|()| format!("fill {n}: {k} is a number or [across, down]")), + [x, y] => match (number(x), number(y)) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(format!("fill {n}: {k} is a number or [across, down]")), + }, _ => Err(format!("fill {n}: {k} is a number or [across, down]")), }, Some(v) => number(v).map(|x| [x, x]).ok_or_else(|| format!("fill {n}: {k} is a number or [across, down]")), diff --git a/crates/map/src/fills.rs b/crates/map/src/fills.rs index 60d71a531..75ac451d1 100644 --- a/crates/map/src/fills.rs +++ b/crates/map/src/fills.rs @@ -140,7 +140,7 @@ impl FillLayer { FillPattern::Solid => shown, FillPattern::Hatch { width, .. } => shown && width > 0.0, FillPattern::Dots { size, outline, outline_width, .. } => { - size > 0.0 && (shown || outline.is_some_and(|o| o.a() > 0) && outline_width > 0.0) + size > 0.0 && (shown || (outline.is_some_and(|o| o.a() > 0) && outline_width > 0.0)) } } } diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 56b6c2185..8424db4c9 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -569,6 +569,10 @@ impl Layer { if self.graphics { return &[]; } + // (A layer whose classes have none of their own isn't classed for them.) + if !self.renderer.has_class_fills() { + return &self.fill_layers; + } self.renderer.fills_for(&self.dataset, row).unwrap_or(&self.fill_layers) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index be53e46ec..8eb447024 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3158,12 +3158,17 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } // Its symbol, and the layer's stacked strokes or the feature's class's own. let (sym, strokes) = layer.style(row); + // A polygon's stacked fills (its class's, else the layer's), looked up once; + // none in wireframe, which draws outlines only. + let fills = if has_fills && !ctx.wireframe && matches!(g, Geometry::Polygon(_)) { layer.fills(row) } else { &[] }; // A feature under a pixel across: blend its coverage into the pixels it touches // (a dense layer seen whole is mostly these, and a path each is slow). - // Not stacked strokes: a splat would draw them unshifted, in one colour. + // Not stacked strokes or fills: a splat would draw them unshifted, or in the + // symbol's fill colour. if globe.is_none() && !layer.graphics && strokes.is_empty() + && fills.is_empty() && let Some(e) = extents.get(row) && ctx.splat(dest, e, g, &sym) { @@ -3178,9 +3183,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } Geometry::Line(parts) if !strokes.is_empty() && levels => stacked.push((parts, sym, strokes)), Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(ctx, dest, parts, &sym, strokes), - Geometry::Polygon(polys) if has_fills && !ctx.wireframe && !layer.fills(row).is_empty() => { - ctx.filled(dest, polys, &sym, layer.fills(row)) - } + Geometry::Polygon(polys) if !fills.is_empty() => ctx.filled(dest, polys, &sym, fills), _ => ctx.feature(dest, g, &sym), } } @@ -6068,6 +6071,42 @@ mod tests { assert_eq!(rgb(&render(&mut m, &view).unwrap(), 100, 100), plain); } + #[test] + fn a_polygon_under_a_pixel_is_drawn_with_its_stacked_fills() { + // Smaller than a pixel (about 0.7 across), it isn't blended in the symbol's fill colour: + // its own solid fill shows. + use rustgis_map::fills::{FillLayer, FillPattern}; + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("specks", GeometryType::Polygon, vec![]); + let (x0, x1, y0, y1) = (-0.0045, 0.0045, -0.0045, 0.0045); + let ring = vec![Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0)]; + d.push(Some(Geometry::Polygon(vec![vec![ring]])), 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: 20.0, height: 20.0, pixels_per_point: 1.0, camera, sky: false }; + let symbol = rustgis_map::Symbol { + fill: Rgba::rgb(0, 0, 255), + stroke_width: 0.0, + ..rustgis_map::Symbol::default_for(GeometryType::Polygon, id) + }; + m.layer_mut(id).unwrap().renderer = rustgis_map::Renderer::Single(symbol); + let ink = |m: &mut Map| { + let r = render(m, &view).unwrap(); + r.pixmap + .pixels() + .iter() + .filter(|p| p.red() < 250 || p.green() < 250 || p.blue() < 250) + .map(|p| (p.red(), p.blue())) + .collect::>() + }; + let plain = ink(&mut m); + assert!(!plain.is_empty() && plain.iter().all(|(r, b)| b >= r), "the symbol's blue: {plain:?}"); + m.layer_mut(id).unwrap().fill_layers = vec![FillLayer { color: Some(Rgba::rgb(255, 0, 0)), pattern: FillPattern::Solid }]; + let filled = ink(&mut m); + assert!(!filled.is_empty() && filled.iter().all(|(r, b)| r > b), "its red fill: {filled:?}"); + } + #[test] fn reference_scaled_hatch_stays_on_the_map_as_it_pans() { // With a reference scale twice the map's, a hatch 6 points apart is drawn 12 apart; panned diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index 92fe4491c..0dca3af8b 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -6,25 +6,36 @@ //! hatch's angle. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use rustgis_geom::Coord; use rustgis_map::fills::{FillLayer, FillPattern}; -use rustgis_map::{Rgba, Symbol}; +use rustgis_map::{Marker, Rgba, Symbol}; use tiny_skia::{FillRule, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; use crate::{Ctx, paint}; -/// The most pattern tiles kept (they are kept between frames: a map's patterns are few). -const MAX_TILES: usize = 64; +/// The most pattern tiles kept between frames, and the most bytes they take: past either, the +/// oldest go first (a map with many hatched classes, each colour a tile, keeps most of them +/// rather than drawing them again each frame). +const MAX_TILES: usize = 1024; +const MAX_TILE_BYTES: usize = 64 << 20; /// The longest side of a tile in pixels: a coarser pattern is drawn at a lower resolution and /// scaled up, so a tile never takes more than a megabyte. const MAX_TILE_PX: f32 = 512.0; +/// The pattern tiles drawn so far, by what they draw at what size, the order they were made in, +/// and the bytes they take. +#[derive(Default)] +struct Tiles { + by_key: HashMap>>, + order: VecDeque, + bytes: usize, +} + thread_local! { - /// Pattern tiles drawn so far, by what they draw at what size. - static TILES: RefCell, Option>>> = RefCell::new(HashMap::new()); + static TILES: RefCell = RefCell::new(Tiles::default()); } impl Ctx<'_> { @@ -132,26 +143,42 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) } } -/// What a tile draws at what size (and whether antialiased), as its cache key. -fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Vec { - let mut k = vec![u32::from_le_bytes(color.0), ppp.to_bits(), u32::from(crate::antialiased())]; - match *pattern { - FillPattern::Solid => k.push(0), - FillPattern::Hatch { spacing, width, .. } => k.extend([1, spacing.to_bits(), width.to_bits()]), +/// What a tile draws at what size (and whether antialiased): its colour, size and kind, and the +/// pattern's numbers by their bits; a dot's marker. +#[derive(Clone, PartialEq, Eq, Hash)] +struct Key { + bits: [u32; 11], + marker: Option, +} + +/// The cache key of a tile (made for each polygon drawn: no allocation). +fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Key { + let mut bits = [0; 11]; + bits[..3].copy_from_slice(&[u32::from_le_bytes(color.0), ppp.to_bits(), u32::from(crate::antialiased())]); + let marker = match *pattern { + FillPattern::Solid => None, + FillPattern::Hatch { spacing, width, .. } => { + bits[3..6].copy_from_slice(&[1, spacing.to_bits(), width.to_bits()]); + None + } FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, .. } => { - k.extend([2, size.to_bits(), outline_width.to_bits(), outline.map_or(0, |o| u32::from_le_bytes(o.0))]); - k.extend(spacing.iter().chain(&displacement).map(|v| v.to_bits())); - // (The marker's shape, by its debug form: a few kinds and a character.) - k.extend(format!("{marker:?}").bytes().map(u32::from)); + bits[3..7].copy_from_slice(&[2, size.to_bits(), outline_width.to_bits(), outline.map_or(0, |o| u32::from_le_bytes(o.0))]); + bits[7..11].copy_from_slice(&[ + spacing[0].to_bits(), + spacing[1].to_bits(), + displacement[0].to_bits(), + displacement[1].to_bits(), + ]); + Some(marker) } - } - k + }; + Key { bits, marker } } /// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for /// one that can't be made. -fn tile(key: &[u32], w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { - if let Some(t) = TILES.with(|t| t.borrow().get(key).cloned()) { +fn tile(key: &Key, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { + if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { return t; } let made = (w.is_finite() && h.is_finite() && w <= MAX_TILE_PX + 1.0 && h <= MAX_TILE_PX + 1.0) @@ -161,12 +188,52 @@ fn tile(key: &[u32], w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option= MAX_TILES { - t.clear(); + // The oldest make room, one at a time, so the rest stay. + while !t.order.is_empty() && (t.order.len() >= MAX_TILES || t.bytes + size > MAX_TILE_BYTES) { + if let Some(old) = t.order.pop_front() + && let Some(Some(pm)) = t.by_key.remove(&old) + { + t.bytes = t.bytes.saturating_sub(pm.data().len()); + } } - t.insert(key.to_vec(), made.clone()); + t.bytes += size; + t.order.push_back(key.clone()); + t.by_key.insert(key.clone(), made.clone()); }); made } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_full_tile_cache_lets_its_oldest_go_and_keeps_the_rest() { + let hatch = |i: u32| key(&FillPattern::Hatch { angle: 0.0, spacing: 6.0, width: 1.5, offset: 0.0 }, Rgba(i.to_le_bytes()), 1.0); + let kept = |i: u32| TILES.with(|t| t.borrow().by_key.contains_key(&hatch(i))); + for i in 0..=MAX_TILES as u32 { + assert!(tile(&hatch(i), 4.0, 4.0, |_| {}).is_some()); + } + assert!(!kept(0), "the oldest made room"); + assert!(kept(1) && kept(MAX_TILES as u32), "the rest stay"); + TILES.with(|t| { + let t = t.borrow(); + assert_eq!((t.by_key.len(), t.order.len(), t.bytes), (MAX_TILES, MAX_TILES, MAX_TILES * 4 * 4 * 4)); + }); + // The key of a dot pattern tells its markers apart. + let dots = |marker| FillPattern::Dots { + marker, + size: 3.0, + outline: None, + outline_width: 0.0, + spacing: [10.0, 10.0], + displacement: [5.0, 0.0], + offset: [0.0, 0.0], + }; + let red = Rgba::rgb(255, 0, 0); + assert!(key(&dots(Marker::Circle), red, 1.0) != key(&dots(Marker::Square), red, 1.0)); + } +} diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index 0a3fd2840..d81e42885 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -94,7 +94,7 @@ pub(crate) fn fills_editor(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tok for (k, label, _) in &classes { // A class with fills of its own is marked. let has_own = l.renderer.class_fills(k).is_ok_and(|f| !f.is_empty()); - let text = if has_own { format!("{label} •") } else { label.clone() }; + let text = if has_own { crate::i18n::fmt("{label} •", &[("label", label)]) } else { label.clone() }; if ui.selectable_label(class == Some(k), text).clicked() { ui.data_mut(|d| d.insert_temp(picked_id, Some(k.clone()))); } diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index f43986aa0..b814b8352 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -417,5 +417,5 @@ GPU Rendering is off by default and remembered across sessions (`ui.map.gpu` is `RUSTGIS_GPU=1` forces it on for development). The globe, view effects and colour-vision simulation stay on the CPU, and exports are unaffected. A rotated or tilted map uses the GPU only when every visible layer can be drawn there: tiles, rasters, terrain, transparency, -masking, swipe, blending, aggregation, graphics, stacked strokes or fills or labels with icons keep it +masking, swipe, blending, aggregation, graphics, stacked strokes, fills or labels with icons keep it on the CPU. Extrusions use the GPU only on a tilted map. From 03cd29a87817bda7f813d87e7277231e283a146c Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 15:21:01 +0100 Subject: [PATCH 04/10] fix(symbology): a polygon under a pixel shows its stacked fills' colour; one classing for symbol, strokes and fills - A polygon smaller than a pixel with stacked fills is splatted again (drawn as a path it could lose its area), in the colour its fills make together at that size: each over those under it, a pattern by the share of the area its ink covers (pattern_fill::tint). - Layer::style_and_fills classes a polygon once for its symbol, strokes and fills (Renderer::style_fills_for). - Ctx::polygon_path and Ctx::outline are shared by plain and stacked-fill polygons. --- crates/map/src/lib.rs | 11 ++++ crates/map/src/symbology.rs | 13 ++++- crates/render/src/lib.rs | 66 ++++++++++++++++------- crates/render/src/pattern_fill.rs | 88 +++++++++++++++++++++++++++---- 4 files changed, 148 insertions(+), 30 deletions(-) diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 8424db4c9..ee6567318 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -576,6 +576,17 @@ impl Layer { self.renderer.fills_for(&self.dataset, row).unwrap_or(&self.fill_layers) } + /// A polygon row's symbol, stacked strokes and stacked fills ([`Layer::style`], + /// [`Layer::fills`]), the feature classed once for all three. + pub fn style_and_fills(&self, row: usize) -> (Symbol, &[LineStroke], &[fills::FillLayer]) { + if self.graphics || !self.renderer.has_class_fills() { + let (symbol, strokes) = self.style(row); + return (symbol, strokes, self.fills(row)); + } + let (symbol, strokes, fills) = self.renderer.style_fills_for(&self.dataset, row); + (symbol, strokes.unwrap_or(&self.line_strokes), fills.unwrap_or(&self.fill_layers)) + } + /// Whether some feature may be drawn with stacked fills ([`Layer::fills`]). pub fn has_fills(&self) -> bool { !self.graphics && (!self.fill_layers.is_empty() || self.renderer.has_class_fills()) diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 9f6a4b820..7b4b714ba 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1021,7 +1021,18 @@ impl Renderer { /// The stacked fills a polygon feature row's class has of its own (`class_fills`), or `None` /// when its class has none (the layer's `fill_layers` apply). pub fn fills_for(&self, ds: &Dataset, row: usize) -> Option<&[FillLayer]> { - let fills = match (self, self.class_of(ds, row)?) { + self.fills_in(self.class_of(ds, row)) + } + + /// The symbol, class strokes and class fills of a feature row ([`Renderer::style_for`], + /// [`Renderer::fills_for`]), the feature classed once for all three. + pub fn style_fills_for(&self, ds: &Dataset, row: usize) -> (Symbol, Option<&[LineStroke]>, Option<&[FillLayer]>) { + let slot = self.class_of(ds, row); + (self.symbol_in(ds, row, slot), self.strokes_in(slot), self.fills_in(slot)) + } + + fn fills_in(&self, slot: Option) -> Option<&[FillLayer]> { + let fills = match (self, slot?) { (Renderer::Unique { classes, class_fills, .. }, Slot::Class(i)) => class_fills.get(&classes.get(i)?.0)?, (Renderer::Unique { other_fills, .. }, Slot::Other) => other_fills, (Renderer::Graduated { class_fills, .. }, Slot::Class(i)) => class_fills.get(&i)?, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8eb447024..b30117d15 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -601,21 +601,32 @@ impl Ctx<'_> { } fn polygon(&self, pm: &mut Pixmap, polys: &[Vec>], fill: Option, stroke: Option<(Paint, f32)>) { + let Some(path) = self.polygon_path(polys) else { return }; + if let Some(f) = fill { + pm.fill_path(&path, &f, FillRule::EvenOdd, Transform::identity(), None); + } + if let Some((s, w)) = stroke { + self.outline(pm, &path, &s, w); + } + } + + /// A polygon's rings as one path (its holes cut out by the even-odd rule); `None` for one + /// with nothing to draw. + pub(crate) fn polygon_path(&self, polys: &[Vec>]) -> Option { let mut pb = PathBuilder::new(); for rings in polys { for r in rings { self.ring_path(&mut pb, r, true); } } - let Some(path) = pb.finish() else { return }; - if let Some(f) = fill { - pm.fill_path(&path, &f, FillRule::EvenOdd, Transform::identity(), None); - } - if let Some((s, w)) = stroke - && w > 0.0 - { + pb.finish() + } + + /// A polygon's outline `w` points wide (round joins); none for no width. + pub(crate) fn outline(&self, pm: &mut Pixmap, path: &tiny_skia::Path, p: &Paint, w: f32) { + if w > 0.0 { let st = Stroke { width: w * self.ppp, line_join: LineJoin::Round, ..Stroke::default() }; - pm.stroke_path(&path, &s, &st, Transform::identity(), None); + pm.stroke_path(path, p, &st, Transform::identity(), None); } } @@ -3156,21 +3167,25 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } continue; } - // Its symbol, and the layer's stacked strokes or the feature's class's own. - let (sym, strokes) = layer.style(row); - // A polygon's stacked fills (its class's, else the layer's), looked up once; - // none in wireframe, which draws outlines only. - let fills = if has_fills && !ctx.wireframe && matches!(g, Geometry::Polygon(_)) { layer.fills(row) } else { &[] }; + // Its symbol, and the layer's stacked strokes or the feature's class's own; a + // polygon's stacked fills too (none in wireframe, which draws outlines only), + // the feature classed once for all. + let (sym, strokes, fills) = if has_fills && !ctx.wireframe && matches!(g, Geometry::Polygon(_)) { + layer.style_and_fills(row) + } else { + let (sym, strokes) = layer.style(row); + (sym, strokes, &[] as &[rustgis_map::fills::FillLayer]) + }; // A feature under a pixel across: blend its coverage into the pixels it touches - // (a dense layer seen whole is mostly these, and a path each is slow). - // Not stacked strokes or fills: a splat would draw them unshifted, or in the - // symbol's fill colour. + // (a dense layer seen whole is mostly these, and a path each is slow). Not + // stacked strokes: a splat would draw them unshifted, in one colour. Stacked + // fills show as the colour they make together at that size. + let splat_sym = if fills.is_empty() { sym } else { Symbol { fill: pattern_fill::tint(fills, sym.fill), ..sym } }; if globe.is_none() && !layer.graphics && strokes.is_empty() - && fills.is_empty() && let Some(e) = extents.get(row) - && ctx.splat(dest, e, g, &sym) + && ctx.splat(dest, e, g, &splat_sym) { continue; } @@ -6105,6 +6120,21 @@ mod tests { m.layer_mut(id).unwrap().fill_layers = vec![FillLayer { color: Some(Rgba::rgb(255, 0, 0)), pattern: FillPattern::Solid }]; let filled = ink(&mut m); assert!(!filled.is_empty() && filled.iter().all(|(r, b)| r > b), "its red fill: {filled:?}"); + // Far smaller (0.15 across), still blended in, not lost as a path of no area. + let mut small = Map::new("Map"); + small.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("specks", GeometryType::Polygon, vec![]); + let t = 0.0045 * 0.15 / 0.67; + let ring = vec![Coord::new(-t, -t), Coord::new(t, -t), Coord::new(t, t), Coord::new(-t, t), Coord::new(-t, -t)]; + d.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![]); + let tiny = small.add_dataset(d); + let l = small.layer_mut(tiny).unwrap(); + l.renderer = rustgis_map::Renderer::Single(symbol); + l.fill_layers = vec![FillLayer { color: Some(Rgba::rgb(255, 0, 0)), pattern: FillPattern::Solid }]; + // (A faint speck: about 2 % of a pixel's area.) + let r = render(&mut small, &view).unwrap(); + let specks: Vec<_> = r.pixmap.pixels().iter().filter(|p| p.green() < 255).map(|p| (p.red(), p.green(), p.blue())).collect(); + assert!(!specks.is_empty() && specks.iter().all(|(r, g, b)| r > g && g == b), "a speck of red: {specks:?}"); } #[test] diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index 0dca3af8b..c3bc5089e 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use rustgis_geom::Coord; use rustgis_map::fills::{FillLayer, FillPattern}; use rustgis_map::{Marker, Rgba, Symbol}; -use tiny_skia::{FillRule, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; +use tiny_skia::{FillRule, Paint, Pixmap, Transform}; use crate::{Ctx, paint}; @@ -42,13 +42,7 @@ impl Ctx<'_> { /// Draws `polys` with its stacked `fills`, bottom first (a fill without a colour of its own /// takes the symbol's), then the symbol's outline over them. pub(crate) fn filled(&self, pm: &mut Pixmap, polys: &[Vec>], s: &Symbol, fills: &[FillLayer]) { - let mut pb = PathBuilder::new(); - for rings in polys { - for r in rings { - self.ring_path(&mut pb, r, true); - } - } - let Some(path) = pb.finish() else { return }; + let Some(path) = self.polygon_path(polys) else { return }; // The map's origin in device pixels, in f64: the patterns' lattice point. let origin = match self.globe { Some(_) => (0.0, 0.0), @@ -78,9 +72,8 @@ impl Ctx<'_> { } } } - if s.stroke_width > 0.0 && s.stroke.a() > 0 { - let st = Stroke { width: s.stroke_width * self.ppp, line_join: LineJoin::Round, ..Stroke::default() }; - pm.stroke_path(&path, &paint(s.stroke, 1.0), &st, Transform::identity(), None); + if s.stroke.a() > 0 { + self.outline(pm, &path, &paint(s.stroke, 1.0), s.stroke_width); } } } @@ -143,6 +136,50 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) } } +/// The colour a polygon's stacked `fills` show as together when it is smaller than a pixel (a +/// fill without a colour of its own taking the feature's `feature`): each over those under it, a +/// pattern by the share of the area its lines or markers cover (a marker's outline too), a solid +/// fill by all of it. +pub(crate) fn tint(fills: &[FillLayer], feature: Rgba) -> Rgba { + // Premultiplied, 0 to 1. + let mut out = [0.0f32; 4]; + let mut over = |c: Rgba, share: f32| { + let a = f32::from(c.a()) / 255.0 * if share.is_finite() { share.clamp(0.0, 1.0) } else { 1.0 }; + for (o, v) in out.iter_mut().zip([c.r(), c.g(), c.b()]) { + *o = f32::from(v) / 255.0 * a + *o * (1.0 - a); + } + out[3] = a + out[3] * (1.0 - a); + }; + let per = |ink: f32, cell: f32| if cell > 0.0 { ink / cell } else { 1.0 }; + for f in fills.iter().filter(|f| f.draws(feature)) { + let c = f.color.unwrap_or(feature); + match f.pattern { + FillPattern::Solid => over(c, 1.0), + FillPattern::Hatch { spacing, width, .. } => over(c, per(width, spacing)), + FillPattern::Dots { marker, size, outline, outline_width, spacing, .. } => { + // As drawn: at least a point across; a square covers its whole box, the other + // shapes about a circle's share of it. + let d = size.max(1.0); + let (area, edge) = if marker == Marker::Square { + (d * d, 4.0 * d) + } else { + (std::f32::consts::FRAC_PI_4 * d * d, std::f32::consts::PI * d) + }; + let cell = spacing[0] * spacing[1]; + over(c, per(area, cell)); + if let Some(o) = outline.filter(|_| outline_width > 0.0) { + over(o, per(edge * outline_width, cell)); + } + } + } + } + if out[3] <= 0.0 { + return Rgba::new(0, 0, 0, 0); + } + let byte = |v: f32| (v * 255.0).round().clamp(0.0, 255.0) as u8; + Rgba::new(byte(out[0] / out[3]), byte(out[1] / out[3]), byte(out[2] / out[3]), byte(out[3])) +} + /// What a tile draws at what size (and whether antialiased): its colour, size and kind, and the /// pattern's numbers by their bits; a dot's marker. #[derive(Clone, PartialEq, Eq, Hash)] @@ -236,4 +273,33 @@ mod tests { let red = Rgba::rgb(255, 0, 0); assert!(key(&dots(Marker::Circle), red, 1.0) != key(&dots(Marker::Square), red, 1.0)); } + + #[test] + fn stacked_fills_under_a_pixel_tint_as_their_ink_covers() { + let (red, white) = (Rgba::rgb(255, 0, 0), Rgba::rgb(255, 255, 255)); + let solid = |c| FillLayer { color: Some(c), pattern: FillPattern::Solid }; + // A solid fill is its colour; one over another hides it. + assert_eq!(tint(&[solid(white), solid(red)], white), red); + // Hatch lines 1.5 wide every 6 cover a quarter: a quarter of red over white. + let hatch = FillLayer { color: Some(red), pattern: FillPattern::Hatch { angle: 0.0, spacing: 6.0, width: 1.5, offset: 0.0 } }; + let t = tint(&[solid(white), hatch], white); + assert_eq!((t.r(), t.a()), (255, 255)); + assert!((i32::from(t.g()) - 191).abs() <= 1, "{t:?}"); + // Alone, a quarter as opaque; nothing drawn, nothing. + assert!((i32::from(tint(&[hatch], white).a()) - 64).abs() <= 1); + assert_eq!(tint(&[], white).a(), 0); + assert_eq!(tint(&[FillLayer { color: None, pattern: FillPattern::Solid }], Rgba::new(0, 0, 0, 0)).a(), 0); + // Hollow dots show their outlines' ink. + let hollow = FillPattern::Dots { + marker: Marker::Circle, + size: 3.0, + outline: Some(red), + outline_width: 0.5, + spacing: [10.0, 10.0], + displacement: [5.0, 0.0], + offset: [0.0, 0.0], + }; + let t = tint(&[FillLayer { color: Some(Rgba::new(0, 0, 0, 0)), pattern: hollow }], white); + assert!(t.r() == 255 && t.g() == 0 && t.a() > 0, "{t:?}"); + } } From 329764b75668bb63adb936efe3a672ca81aee37d Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 15:43:19 +0100 Subject: [PATCH 05/10] fix(symbology): character markers in dots fills survive the editor; markers past a tile's edge; a lighter swatch - layer.fillLayers takes a dots fill's "char" and "font" (a character of a font as the marker, as layer.marker does), and the Fills editor writes them back for one imported from a QGIS font marker; it used to send the marker as "Character", which the command refused, so every edit of that layer's fills did nothing. - A dots tile copies each marker two tiles out each way, so one up to about three times its spacing isn't cut at the tile's edge. - The editor's swatch draws dots at least 4 points apart. --- crates/engine/src/cmd/layer.rs | 10 +++++++++- crates/engine/src/tests.rs | 8 ++++++++ crates/render/src/pattern_fill.rs | 4 +++- crates/ui-egui/src/fill_editor.rs | 20 +++++++++++++++----- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f6c376def..7c2df1452 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -182,7 +182,7 @@ pub fn commands() -> Vec { C::new("layer.fillLayers", "Fill Layers", "Style › Drawing", fill_layers) .when(need_polygons) .params( - r##"{"layer"?, "value"?|"index"?|"other"?: a class of Unique Values (its value, or other: true) or Graduated Colors (its index) to set its own fills (none: the layer's), "fills": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the feature's fill colour, so the fills follow the classes), "pattern"?: "solid" (default)|"hatch" (parallel lines)|"dots" (markers on a grid), "angle"?: degrees anticlockwise from east (hatch), "spacing"?: points between lines (hatch, 1 to 500; default 8) or points across and down between markers, a number or [across, down] (dots), "width"?: a hatch line's width in points (0 to 50; default 1), "offset"?: points across the lines (hatch) or [across, down] (dots), "marker"?: "circle"|"square"|"triangle"|"diamond"|"star" (dots), "size"?: a marker's size in points (default 3), "outline"?: a marker's outline colour or null, "outlineWidth"?: points, "displacement"?: [across, down] points every other row and column are shifted by (dots)}] (bottom first, at most 8; [] for the symbol's single fill; the outline is drawn over them)}"##, + r##"{"layer"?, "value"?|"index"?|"other"?: a class of Unique Values (its value, or other: true) or Graduated Colors (its index) to set its own fills (none: the layer's), "fills": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the feature's fill colour, so the fills follow the classes), "pattern"?: "solid" (default)|"hatch" (parallel lines)|"dots" (markers on a grid), "angle"?: degrees anticlockwise from east (hatch), "spacing"?: points between lines (hatch, 1 to 500; default 8) or points across and down between markers, a number or [across, down] (dots), "width"?: a hatch line's width in points (0 to 50; default 1), "offset"?: points across the lines (hatch) or [across, down] (dots), "marker"?: "circle"|"square"|"triangle"|"diamond"|"star" (dots), "char"?: text (dots: a character of a font as the marker instead of a shape), "font"?: text (the installed family it is drawn from; none: the label font), "size"?: a marker's size in points (default 3), "outline"?: a marker's outline colour or null, "outlineWidth"?: points, "displacement"?: [across, down] points every other row and column are shifted by (dots)}] (bottom first, at most 8; [] for the symbol's single fill; the outline is drawn over them)}"##, ), C::new("layer.markerRotation", "Marker Rotation", "Style › Drawing", |s, p| { let field = match p.get("field") { @@ -672,6 +672,14 @@ fn fill_layers(s: &mut Session, p: &Value) -> CmdResult { .ok_or_else(|| format!("fill {n}: unknown marker '{m}'"))?, Some(_) => return Err(format!("fill {n}: marker is \"circle\", \"square\", \"triangle\", \"diamond\" or \"star\"")), }; + // A character of a font instead of a shape, as `layer.marker` takes it. + let marker = match character(v).map_err(|e| format!("fill {n}: {e}"))? { + Some(_) if o.get("marker").is_some_and(|m| !m.is_null()) => { + return Err(format!("fill {n}: give a marker or a char, not both")); + } + Some(c) => Marker::Char(Glyph::new(c, o.get("font").and_then(Value::as_str).unwrap_or_default())), + None => marker, + }; FillPattern::Dots { marker, size: num("size", 3.0)?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2c40c855b..649b4e613 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -378,6 +378,14 @@ fn fill_layers_stack_and_survive_save_and_undo() { let f = got(&s); assert_eq!(f[0].pattern, FillPattern::Hatch { angle: 315.0, spacing: 500.0, width: 0.0, offset: 0.0 }); assert!(matches!(f[1].pattern, FillPattern::Dots { spacing: [500.0, 500.0], .. }), "{f:?}"); + // A character of a font as the dots (a QGIS font marker's), kept with its family; not with a + // shape too. + s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "dots", "char": "★", "font": "Noto Sans"}]})).unwrap(); + let FillPattern::Dots { marker: rustgis_map::Marker::Char(g), .. } = got(&s)[0].pattern else { panic!("{:?}", got(&s)) }; + assert_eq!((g.ch, g.font.as_str()), ('★', "Noto Sans")); + assert!( + s.run("layer.fillLayers", &json!({"layer": "Countries", "fills": [{"pattern": "dots", "marker": "square", "char": "x"}]})).is_err() + ); // A class's own (Unique Values), then back to the layer's. s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "continent"})).unwrap(); let value = match &s.map().layer_by_name("Countries").unwrap().renderer { diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index c3bc5089e..1b4677fd7 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -122,7 +122,9 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) // drawn again a tile away each way where it crosses the tile's edges. let x = (col as f32 + 0.5) * dx + if row == 1 { sx } else { 0.0 }; let y = (row as f32 + 0.5) * dy + if col == 1 { sy } else { 0.0 }; - for (ox, oy) in [-1.0, 0.0, 1.0].into_iter().flat_map(|i| [-1.0, 0.0, 1.0].map(|j| (i * tw, j * th))) { + // Copies two tiles out each way: a marker up to about three times its spacing + // still reaches across the tile's edge whole. + for (ox, oy) in (-2..=2).flat_map(|i| (-2..=2).map(move |j| (i as f32 * tw, j as f32 * th))) { crate::draw_marker(pm, (x + ox) * k, (y + oy) * k, &symbol, 1.0, None, ppp * k); } } diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index d81e42885..397621acf 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -20,10 +20,19 @@ fn fill_json(f: &FillLayer) -> Value { FillPattern::Hatch { angle, spacing, width, offset } => { json!({"color": color, "pattern": "hatch", "angle": angle, "spacing": spacing, "width": width, "offset": offset}) } - FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => json!({ - "color": color, "pattern": "dots", "marker": marker.label(), "size": size, "outline": outline.map(|c| c.hex()), - "outlineWidth": outline_width, "spacing": spacing, "displacement": displacement, "offset": offset, - }), + FillPattern::Dots { marker, size, outline, outline_width, spacing, displacement, offset } => { + let mut o = json!({ + "color": color, "pattern": "dots", "marker": marker.label(), "size": size, "outline": outline.map(|c| c.hex()), + "outlineWidth": outline_width, "spacing": spacing, "displacement": displacement, "offset": offset, + }); + // A character of a font (from a QGIS font marker): its char and family, as the command takes them. + if let Marker::Char(g) = marker { + o["marker"] = Value::Null; + o["char"] = json!(g.ch.to_string()); + o["font"] = json!(g.font.as_str()); + } + o + } } } @@ -292,7 +301,8 @@ fn preview(ui: &Ui, r: Rect, fills: &[FillLayer], base: &Symbol) { } } FillPattern::Dots { size, spacing, displacement, .. } => { - let [dx, dy] = spacing.map(|s| s.max(1.0)); + // (No finer than 4 points in the swatch: it is a picture, not every dot.) + let [dx, dy] = spacing.map(|s| s.max(4.0)); let (mut y, mut row) = (r.min.y + dy / 2.0, 0); while y < r.max.y + dy { let shift = if row % 2 == 1 { displacement[0] } else { 0.0 }; From a9dbc779a10ecf74b3cdd63c08514ad14a30aac2 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 16:38:45 +0100 Subject: [PATCH 06/10] fix(symbology): a character marker's tile isn't kept before the fonts are indexed; pattern edges follow antialiasing; camelCase fill fields - A dots tile of a character marker drawn while the installed families are being indexed (the label font for now) is drawn without being kept, as glyphs::shape keeps no shape then, so it draws in its own family once they are. - A pattern fill's edges are antialiased as the map is (crate::antialiased()), as solid fills are. - FillPattern's fields are saved in camelCase (outlineWidth), as the rest of the format and the command are. - "pattern": null takes the default (solid), as null does for the other keys. --- crates/engine/src/cmd/layer.rs | 2 +- crates/map/src/fills.rs | 2 +- crates/render/src/pattern_fill.rs | 28 +++++++++++++++++++++------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 7c2df1452..ab554be84 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -655,7 +655,7 @@ fn fill_layers(s: &mut Session, p: &Value) -> CmdResult { Some(v) => number(v).map(|x| [x, x]).ok_or_else(|| format!("fill {n}: {k} is a number or [across, down]")), } }; - let pattern = match o.get("pattern").map(|v| v.as_str()) { + let pattern = match o.get("pattern").filter(|v| !v.is_null()).map(|v| v.as_str()) { None | Some(Some("solid")) => FillPattern::Solid, Some(Some("hatch")) => FillPattern::Hatch { angle: num("angle", 0.0)?, diff --git a/crates/map/src/fills.rs b/crates/map/src/fills.rs index 75ac451d1..f575f6149 100644 --- a/crates/map/src/fills.rs +++ b/crates/map/src/fills.rs @@ -36,7 +36,7 @@ pub struct FillLayer { /// What a fill draws inside its polygon. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum FillPattern { /// The whole polygon in its colour. #[default] diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index 1b4677fd7..c58efe910 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -67,7 +67,7 @@ impl Ctx<'_> { 1.0, t, ); - let fill = Paint { shader, anti_alias: true, ..Paint::default() }; + let fill = Paint { shader, anti_alias: crate::antialiased(), ..Paint::default() }; pm.fill_path(&path, &fill, FillRule::EvenOdd, Transform::identity(), None); } } @@ -94,7 +94,7 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) let period = spacing * ppp; let k = (MAX_TILE_PX / period).min(1.0); let h = (period * k).ceil().max(1.0); - let tile = tile(&key(&pattern, color, ppp), 4.0, h, |pm| { + let tile = tile(&key(&pattern, color, ppp), true, 4.0, h, |pm| { let w = (width * ppp * k).max(0.0); let mid = h / 2.0; if let Some(r) = tiny_skia::Rect::from_ltrb(-1.0, mid - w / 2.0, 5.0, mid + w / 2.0) { @@ -116,7 +116,13 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) let k = (MAX_TILE_PX / tw.max(th)).min(1.0); let symbol = Symbol { fill: color, stroke: outline.unwrap_or(Rgba::new(0, 0, 0, 0)), stroke_width: outline_width, size, marker }; - let tile = tile(&key(&pattern, color, ppp), (tw * k).ceil().max(1.0), (th * k).ceil().max(1.0), |pm| { + // A character marker drawn while the installed families are still being indexed is + // in the label font for now (as `glyphs::shape` keeps none then): not kept either. + let settled = match marker { + Marker::Char(g) => crate::text::marker_font(g.font.as_str()).1, + _ => true, + }; + let tile = tile(&key(&pattern, color, ppp), settled, (tw * k).ceil().max(1.0), (th * k).ceil().max(1.0), |pm| { for (col, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] { // A marker in each cell's middle, odd rows shifted across, odd columns down; // drawn again a tile away each way where it crosses the tile's edges. @@ -214,9 +220,9 @@ fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Key { Key { bits, marker } } -/// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for -/// one that can't be made. -fn tile(key: &Key, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { +/// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time (and kept +/// when `keep`); `None` for one that can't be made. +fn tile(key: &Key, keep: bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { return t; } @@ -227,6 +233,9 @@ fn tile(key: &Key, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option Date: Tue, 27 Oct 2026 17:43:45 +0100 Subject: [PATCH 07/10] fix(ui): the Fills swatch shows only the fills the map draws --- crates/ui-egui/src/fill_editor.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index 397621acf..d18d4b5bf 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -281,7 +281,8 @@ fn preview(ui: &Ui, r: Rect, fills: &[FillLayer], base: &Symbol) { let p = ui.painter().with_clip_rect(r); let one = [FillLayer::default()]; let shown = if fills.is_empty() { &one[..] } else { fills }; - for f in shown { + // (Only what the map draws: a hatch of no width, a marker of no size, no colour to show.) + for f in shown.iter().filter(|f| f.draws(base.fill)) { let c = c32(f.color.unwrap_or(base.fill)); match f.pattern { FillPattern::Solid => { From e5dc18d2466462fb1d422d0dd5fdfc18f3efc876 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 18:05:36 +0100 Subject: [PATCH 08/10] fix(symbology): provisional character-marker tiles are kept apart while the fonts are indexed A dots tile of a character marker drawn before the installed families are indexed (in the label font for now) was drawn again for every polygon. It is now kept in a small provisional cache, apart from the tiles, and left behind once its family is settled, when the tile is drawn in its own family and kept as any other. Also: plainer wording on Layer::fills, and the Add Fill push inlined. --- crates/map/src/lib.rs | 3 +- crates/render/src/pattern_fill.rs | 46 +++++++++++++++++++++++++------ crates/ui-egui/src/fill_editor.rs | 3 +- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index ee6567318..f13d08462 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -569,7 +569,8 @@ impl Layer { if self.graphics { return &[]; } - // (A layer whose classes have none of their own isn't classed for them.) + // A class without fills of its own takes the layer's: a layer with no class fills isn't + // classed for them. if !self.renderer.has_class_fills() { return &self.fill_layers; } diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index c58efe910..49b600aac 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -34,8 +34,15 @@ struct Tiles { bytes: usize, } +/// The most provisional tiles kept: those of character markers drawn while the installed +/// families are still being indexed. +const MAX_PROVISIONAL: usize = 64; + thread_local! { static TILES: RefCell = RefCell::new(Tiles::default()); + /// Provisional tiles (in the label font, for now), kept apart and never used once their + /// marker's family is settled, so a layer of them isn't drawn again for each polygon meanwhile. + static PROVISIONAL: RefCell>>> = RefCell::new(HashMap::new()); } impl Ctx<'_> { @@ -220,10 +227,19 @@ fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Key { Key { bits, marker } } -/// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time (and kept -/// when `keep`); `None` for one that can't be made. +/// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for +/// one that can't be made. Kept among the tiles when `keep`; otherwise (a character marker +/// before the fonts are indexed) among the provisional ones, until its family is settled. fn tile(key: &Key, keep: bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { - if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { + if keep { + // Settled now: the provisional one, if any, is left behind. + if key.marker.is_some_and(|m| matches!(m, Marker::Char(_))) { + PROVISIONAL.with(|p| p.borrow_mut().remove(key)); + } + if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { + return t; + } + } else if let Some(t) = PROVISIONAL.with(|p| p.borrow().get(key).cloned()) { return t; } let made = (w.is_finite() && h.is_finite() && w <= MAX_TILE_PX + 1.0 && h <= MAX_TILE_PX + 1.0) @@ -234,6 +250,13 @@ fn tile(key: &Key, keep: bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) - Arc::new(pm) }); if !keep { + PROVISIONAL.with(|p| { + let mut p = p.borrow_mut(); + if p.len() >= MAX_PROVISIONAL { + p.clear(); + } + p.insert(key.clone(), made.clone()); + }); return made; } let size = made.as_ref().map_or(0, |pm| pm.data().len()); @@ -283,11 +306,18 @@ mod tests { }; let red = Rgba::rgb(255, 0, 0); assert!(key(&dots(Marker::Circle), red, 1.0) != key(&dots(Marker::Square), red, 1.0)); - // One not to keep (a character marker drawn before the fonts are indexed) is drawn and - // not kept. - let unsettled = hatch(u32::MAX); - assert!(tile(&unsettled, false, 4.0, 4.0, |_| {}).is_some()); - assert!(!kept(u32::MAX)); + // One not to keep (a character marker drawn before the fonts are indexed) is drawn once + // and kept apart, not among the tiles; settled, it is drawn again and kept. + let glyph = |c| key(&dots(Marker::Char(rustgis_map::Glyph::new(c, "Noto Sans"))), red, 1.0); + let mut drawn = 0; + for _ in 0..3 { + assert!(tile(&glyph('x'), false, 4.0, 4.0, |_| drawn += 1).is_some()); + } + assert_eq!(drawn, 1, "drawn once while the fonts are indexed"); + assert!(TILES.with(|t| !t.borrow().by_key.contains_key(&glyph('x')))); + assert!(tile(&glyph('x'), true, 4.0, 4.0, |_| drawn += 1).is_some()); + assert_eq!(drawn, 2, "drawn again once settled"); + assert!(PROVISIONAL.with(|p| p.borrow().is_empty())); } #[test] diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index d18d4b5bf..ff4326ac8 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -81,8 +81,7 @@ pub(crate) fn fills_editor(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tok if add.on_hover_text(tl!("Stack another fill on top")).clicked() { let mut next = fills.clone(); // The first stands for the symbol's own fill; the next hatches over it. - let first = FillLayer::default(); - next.push(if next.is_empty() { first } else { FillLayer::hatch(45.0, 6.0, 1.0) }); + next.push(if next.is_empty() { FillLayer::default() } else { FillLayer::hatch(45.0, 6.0, 1.0) }); set_fills(app, class, &next); } }); From a5ba5fb0e6b3648c8ae4258d438841f1ba4cf06c Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 18:36:46 +0100 Subject: [PATCH 09/10] fix(symbology): the font is asked only for a tile not kept yet; settled fonts clear every provisional tile; the swatch shifts odd columns down - tile() takes whether a tile may be kept as a closure, asked only when the tile isn't among the kept ones, so a layer of character markers doesn't look its family up for each polygon every frame. - A character marker's tile drawn once the fonts are settled leaves every provisional tile behind (the index is done for all families), not only its own. - The Fills swatch lays dots as the map does: odd rows shifted across, odd columns down (displacement[1] was left out). --- crates/render/src/pattern_fill.rs | 68 +++++++++++++++++++++---------- crates/ui-egui/src/fill_editor.rs | 15 ++++--- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index 49b600aac..a5007d812 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -101,13 +101,19 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) let period = spacing * ppp; let k = (MAX_TILE_PX / period).min(1.0); let h = (period * k).ceil().max(1.0); - let tile = tile(&key(&pattern, color, ppp), true, 4.0, h, |pm| { - let w = (width * ppp * k).max(0.0); - let mid = h / 2.0; - if let Some(r) = tiny_skia::Rect::from_ltrb(-1.0, mid - w / 2.0, 5.0, mid + w / 2.0) { - pm.fill_rect(r, &paint(color, 1.0), Transform::identity(), None); - } - })?; + let tile = tile( + &key(&pattern, color, ppp), + || true, + 4.0, + h, + |pm| { + let w = (width * ppp * k).max(0.0); + let mid = h / 2.0; + if let Some(r) = tiny_skia::Rect::from_ltrb(-1.0, mid - w / 2.0, 5.0, mid + w / 2.0) { + pm.fill_rect(r, &paint(color, 1.0), Transform::identity(), None); + } + }, + )?; let rad = -angle.to_radians(); // The lattice across the lines (their normal) through the origin, shifted by the offset. let (nx, ny) = (-f64::from(rad.sin()), f64::from(rad.cos())); @@ -124,8 +130,9 @@ fn pattern_tile(pattern: FillPattern, color: Rgba, ppp: f32, origin: (f64, f64)) let symbol = Symbol { fill: color, stroke: outline.unwrap_or(Rgba::new(0, 0, 0, 0)), stroke_width: outline_width, size, marker }; // A character marker drawn while the installed families are still being indexed is - // in the label font for now (as `glyphs::shape` keeps none then): not kept either. - let settled = match marker { + // in the label font for now (as `glyphs::shape` keeps none then): not kept among the + // tiles. (Asked only when the tile isn't kept yet.) + let settled = || match marker { Marker::Char(g) => crate::text::marker_font(g.font.as_str()).1, _ => true, }; @@ -228,16 +235,18 @@ fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Key { } /// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for -/// one that can't be made. Kept among the tiles when `keep`; otherwise (a character marker -/// before the fonts are indexed) among the provisional ones, until its family is settled. -fn tile(key: &Key, keep: bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { +/// one that can't be made. Not kept yet, `settled` says whether it may be kept among the tiles: +/// a character marker drawn while the installed families are being indexed (in the label font +/// for now) is kept among the provisional ones instead, which all go once one is settled (the +/// index is done, for every family). +fn tile(key: &Key, settled: impl FnOnce() -> bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { + if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { + return t; + } + let keep = settled(); if keep { - // Settled now: the provisional one, if any, is left behind. if key.marker.is_some_and(|m| matches!(m, Marker::Char(_))) { - PROVISIONAL.with(|p| p.borrow_mut().remove(key)); - } - if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { - return t; + PROVISIONAL.with(|p| p.borrow_mut().clear()); } } else if let Some(t) = PROVISIONAL.with(|p| p.borrow().get(key).cloned()) { return t; @@ -286,7 +295,7 @@ mod tests { let hatch = |i: u32| key(&FillPattern::Hatch { angle: 0.0, spacing: 6.0, width: 1.5, offset: 0.0 }, Rgba(i.to_le_bytes()), 1.0); let kept = |i: u32| TILES.with(|t| t.borrow().by_key.contains_key(&hatch(i))); for i in 0..=MAX_TILES as u32 { - assert!(tile(&hatch(i), true, 4.0, 4.0, |_| {}).is_some()); + assert!(tile(&hatch(i), || true, 4.0, 4.0, |_| {}).is_some()); } assert!(!kept(0), "the oldest made room"); assert!(kept(1) && kept(MAX_TILES as u32), "the rest stay"); @@ -311,13 +320,30 @@ mod tests { let glyph = |c| key(&dots(Marker::Char(rustgis_map::Glyph::new(c, "Noto Sans"))), red, 1.0); let mut drawn = 0; for _ in 0..3 { - assert!(tile(&glyph('x'), false, 4.0, 4.0, |_| drawn += 1).is_some()); + assert!(tile(&glyph('x'), || false, 4.0, 4.0, |_| drawn += 1).is_some()); } + assert!(tile(&glyph('y'), || false, 4.0, 4.0, |_| {}).is_some()); assert_eq!(drawn, 1, "drawn once while the fonts are indexed"); assert!(TILES.with(|t| !t.borrow().by_key.contains_key(&glyph('x')))); - assert!(tile(&glyph('x'), true, 4.0, 4.0, |_| drawn += 1).is_some()); + assert!(tile(&glyph('x'), || true, 4.0, 4.0, |_| drawn += 1).is_some()); assert_eq!(drawn, 2, "drawn again once settled"); - assert!(PROVISIONAL.with(|p| p.borrow().is_empty())); + assert!(PROVISIONAL.with(|p| p.borrow().is_empty()), "every provisional tile left behind"); + // Kept, it isn't asked again whether it may be. + let mut asked = false; + assert!( + tile( + &glyph('x'), + || { + asked = true; + true + }, + 4.0, + 4.0, + |_| drawn += 1 + ) + .is_some() + ); + assert!(!asked && drawn == 2); } #[test] diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index ff4326ac8..4e7c96533 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -303,16 +303,15 @@ fn preview(ui: &Ui, r: Rect, fills: &[FillLayer], base: &Symbol) { FillPattern::Dots { size, spacing, displacement, .. } => { // (No finer than 4 points in the swatch: it is a picture, not every dot.) let [dx, dy] = spacing.map(|s| s.max(4.0)); - let (mut y, mut row) = (r.min.y + dy / 2.0, 0); - while y < r.max.y + dy { - let shift = if row % 2 == 1 { displacement[0] } else { 0.0 }; - let mut x = r.min.x + dx / 2.0 + shift; - while x < r.max.x + dx { + // As the map lays them: odd rows shifted across, odd columns down (a row and a + // column more each way for the shifts). + let (cols, rows) = ((r.width() / dx).ceil() as i32 + 1, (r.height() / dy).ceil() as i32 + 1); + for row in -1..=rows { + for col in -1..=cols { + let x = r.min.x + (col as f32 + 0.5) * dx + if row.rem_euclid(2) == 1 { displacement[0] } else { 0.0 }; + let y = r.min.y + (row as f32 + 0.5) * dy + if col.rem_euclid(2) == 1 { displacement[1] } else { 0.0 }; p.circle_filled(pos2(x, y), (size / 2.0).max(0.5), c); - x += dx; } - y += dy; - row += 1; } } } From 0eaf3f20b3aa82eede62cc35621c9b341aace60e Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 18:38:51 +0100 Subject: [PATCH 10/10] fix(symbology): only a named family's settled marker clears the provisional tiles; the swatch widens shifts with the spacing --- crates/render/src/pattern_fill.rs | 7 ++++--- crates/ui-egui/src/fill_editor.rs | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/render/src/pattern_fill.rs b/crates/render/src/pattern_fill.rs index a5007d812..4e1c68795 100644 --- a/crates/render/src/pattern_fill.rs +++ b/crates/render/src/pattern_fill.rs @@ -237,15 +237,16 @@ fn key(pattern: &FillPattern, color: Rgba, ppp: f32) -> Key { /// The tile `key` names, drawn by `draw` on a `w` × `h` pixel picture the first time; `None` for /// one that can't be made. Not kept yet, `settled` says whether it may be kept among the tiles: /// a character marker drawn while the installed families are being indexed (in the label font -/// for now) is kept among the provisional ones instead, which all go once one is settled (the -/// index is done, for every family). +/// for now) is kept among the provisional ones instead, which all go once one of a named family +/// is settled (the index is done, for every family; the label font's, unnamed, is settled from +/// the start and says nothing about it). fn tile(key: &Key, settled: impl FnOnce() -> bool, w: f32, h: f32, draw: impl FnOnce(&mut Pixmap)) -> Option> { if let Some(t) = TILES.with(|t| t.borrow().by_key.get(key).cloned()) { return t; } let keep = settled(); if keep { - if key.marker.is_some_and(|m| matches!(m, Marker::Char(_))) { + if key.marker.is_some_and(|m| matches!(m, Marker::Char(g) if !g.font.as_str().trim().is_empty())) { PROVISIONAL.with(|p| p.borrow_mut().clear()); } } else if let Some(t) = PROVISIONAL.with(|p| p.borrow().get(key).cloned()) { diff --git a/crates/ui-egui/src/fill_editor.rs b/crates/ui-egui/src/fill_editor.rs index 4e7c96533..8962bb1bb 100644 --- a/crates/ui-egui/src/fill_editor.rs +++ b/crates/ui-egui/src/fill_editor.rs @@ -303,13 +303,15 @@ fn preview(ui: &Ui, r: Rect, fills: &[FillLayer], base: &Symbol) { FillPattern::Dots { size, spacing, displacement, .. } => { // (No finer than 4 points in the swatch: it is a picture, not every dot.) let [dx, dy] = spacing.map(|s| s.max(4.0)); + // (Shifts widened with the spacing, so a half shift stays a half.) + let shift = [0, 1].map(|i| displacement[i] * if spacing[i] > 0.0 { [dx, dy][i] / spacing[i] } else { 1.0 }); // As the map lays them: odd rows shifted across, odd columns down (a row and a // column more each way for the shifts). let (cols, rows) = ((r.width() / dx).ceil() as i32 + 1, (r.height() / dy).ceil() as i32 + 1); for row in -1..=rows { for col in -1..=cols { - let x = r.min.x + (col as f32 + 0.5) * dx + if row.rem_euclid(2) == 1 { displacement[0] } else { 0.0 }; - let y = r.min.y + (row as f32 + 0.5) * dy + if col.rem_euclid(2) == 1 { displacement[1] } else { 0.0 }; + let x = r.min.x + (col as f32 + 0.5) * dx + if row.rem_euclid(2) == 1 { shift[0] } else { 0.0 }; + let y = r.min.y + (row as f32 + 0.5) * dy + if col.rem_euclid(2) == 1 { shift[1] } else { 0.0 }; p.circle_filled(pos2(x, y), (size / 2.0).max(0.5), c); } }