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..ab554be84 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), "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") { None | Some(Value::Null) => None, @@ -608,6 +613,100 @@ 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] => 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]")), + } + }; + 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)?, + 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\"")), + }; + // 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)?, + 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 +1361,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..649b4e613 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -342,6 +342,83 @@ 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 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 { + 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..f575f6149 --- /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", rename_all_fields = "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, + 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..f13d08462 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,36 @@ 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 &[]; + } + // 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; + } + 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()) + } + pub fn is_raster(&self) -> bool { self.raster.is_some() } @@ -1926,6 +1963,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 +1979,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 +2198,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..1d46ee503 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,83 @@ 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); + } + // 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 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: m.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 @ ("LinePatternFill" | "PointPatternFill" | "SVGFill" | "RasterFill" | "RandomMarkerFill") => { + 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 +358,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 +424,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 +675,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..7b4b714ba 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,38 @@ 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]> { + 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)?, + _ => 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 +1059,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 +1101,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 +1407,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 +1428,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 +1445,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 +1461,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 +1480,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 +2733,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 +2754,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 +2770,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 +2784,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 +2817,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 +2835,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 +2853,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 +2883,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 +3121,8 @@ mod tests { other_label: None, class_strokes: Default::default(), other_strokes: Vec::new(), + class_fills: Default::default(), + other_fills: Vec::new(), } } @@ -2988,6 +3137,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..b30117d15 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; @@ -595,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); } } @@ -2054,6 +2071,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 +3061,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 +3133,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 }; @@ -3145,16 +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); + // 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: a splat would draw them unshifted, in one 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() && let Some(e) = extents.get(row) - && ctx.splat(dest, e, g, &sym) + && ctx.splat(dest, e, g, &splat_sym) { continue; } @@ -3167,6 +3198,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 !fills.is_empty() => ctx.filled(dest, polys, &sym, fills), _ => ctx.feature(dest, g, &sym), } } @@ -5975,6 +6007,166 @@ 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 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:?}"); + // 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] + 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 +6450,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..4e1c68795 --- /dev/null +++ b/crates/render/src/pattern_fill.rs @@ -0,0 +1,378 @@ +//! 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, VecDeque}; +use std::sync::Arc; + +use rustgis_geom::Coord; +use rustgis_map::fills::{FillLayer, FillPattern}; +use rustgis_map::{Marker, Rgba, Symbol}; +use tiny_skia::{FillRule, Paint, Pixmap, Transform}; + +use crate::{Ctx, paint}; + +/// 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, +} + +/// 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<'_> { + /// 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 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), + 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: crate::antialiased(), ..Paint::default() }; + pm.fill_path(&path, &fill, FillRule::EvenOdd, Transform::identity(), None); + } + } + } + if s.stroke.a() > 0 { + self.outline(pm, &path, &paint(s.stroke, 1.0), s.stroke_width); + } + } +} + +/// `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), + || 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())); + 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 }; + // 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 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, + }; + 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. + 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 }; + // 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); + } + } + })?; + 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))) + } + } +} + +/// 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)] +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, .. } => { + 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) + } + }; + 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. 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 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(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()) { + 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) + }); + 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()); + TILES.with(|t| { + let mut t = t.borrow_mut(); + // 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.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), || 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"); + 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)); + // 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!(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_eq!(drawn, 2, "drawn again once settled"); + 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] + 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:?}"); + } +} 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..8962bb1bb --- /dev/null +++ b/crates/ui-egui/src/fill_editor.rs @@ -0,0 +1,378 @@ +//! 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 } => { + 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 + } + } +} + +/// 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. + next.push(if next.is_empty() { FillLayer::default() } 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 { 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()))); + } + } + }) + .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 }; + // (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 => { + 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, .. } => { + // (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 { 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); + } + } + } + } + } + // 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..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 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. diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index eae8b5deb..7eeef6979 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, 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