diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 17cde498b..4b2a6fdce 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -175,10 +175,38 @@ pub fn commands() -> Vec { .when(need_feature_layer) .params(r##"{"layer"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "width"?: number}"##), C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes) - .when(need_lines) + .when(need_lines_or_points) .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), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, + r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"character"|"hash" (a mark across the line), "char"?: text (a character of a font as the marker instead of a shape), "font"?: text (the installed family it is drawn from; none: the label font), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes; 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.markerRotation", "Marker Rotation", "Style › Drawing", |s, p| { + let field = match p.get("field") { + None | Some(Value::Null) => None, + Some(Value::String(f)) if f.is_empty() => None, + Some(Value::String(f)) => Some(f.clone()), + Some(_) => return Err("field is a field's name, or null for none".into()), + }; + let counterclockwise = match p.get("counterclockwise") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("counterclockwise is true or false".into()), + }; + let id = s.layer_id(p)?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + if l.geometry_type() != GeometryType::Point || l.graphics { + return Err("this works on point layers".into()); + } + if let Some(f) = &field { + let fi = l.dataset.field_index(f).ok_or_else(|| format!("no field named '{f}'"))?; + if !l.dataset.fields.get(fi).is_some_and(|fd| fd.ty.is_numeric()) { + return Err(format!("'{f}' is not a numeric field")); + } + } + l.marker_rotation = field.map(|field| rustgis_map::MarkerRotation { field, counterclockwise }); + Ok(Value::Null) + }) + .when(need_points) + .params(r#"{"layer"?, "field": numeric field name|null (each point's marker turned by the angle in it, in degrees; null: not turned), "counterclockwise"?: bool (the angles run counterclockwise, as in mathematics; default clockwise, as on a compass)}"#), C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| { let on = b(p, "on").ok_or("on is true or false")?; let id = s.layer_id(p)?; @@ -392,6 +420,22 @@ fn need_polygons(s: &Session) -> Option<&'static str> { } /// A line layer is active. +fn need_lines_or_points(s: &Session) -> Option<&'static str> { + match s.active_layer.and_then(|id| s.map().layer(id)) { + Some(l) if matches!(l.geometry_type(), GeometryType::Polyline | GeometryType::Point) && !l.graphics => None, + Some(_) => Some("this works on line and point layers"), + None => Some("select a layer in the Layers pane first"), + } +} + +fn need_points(s: &Session) -> Option<&'static str> { + match s.active_layer.and_then(|id| s.map().layer(id)) { + Some(l) if l.geometry_type() == GeometryType::Point && !l.graphics => None, + Some(_) => Some("this works on point layers"), + None => Some("select a layer in the Layers pane first"), + } +} + fn need_lines(s: &Session) -> Option<&'static str> { match s.active_layer.and_then(|id| s.map().layer(id)) { Some(l) if l.geometry_type() == GeometryType::Polyline && !l.graphics => None, @@ -475,8 +519,13 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { } let id = s.layer_id(p)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; - if l.geometry_type() != GeometryType::Polyline || l.graphics { - return Err("this works on line layers".into()); + let points = l.geometry_type() == GeometryType::Point; + if !(points || l.geometry_type() == GeometryType::Polyline) || l.graphics { + return Err("this works on line and point layers".into()); + } + // A point draws marks only: a line stroke would draw nothing there. + if let Some(i) = strokes.iter().position(|st| st.markers.is_none()).filter(|_| points) { + return Err(format!("stroke {}: a point layer's strokes are markers", i + 1)); } let n = strokes.len(); match class { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 86a107c7e..30298db7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -405,11 +405,14 @@ fn line_strokes_stack_and_survive_save_and_undo() { json!({"layer": "Rivers", "strokes": [{"width": "wide"}]}), json!({"layer": "Rivers", "strokes": [7]}), json!({"layer": "Rivers", "strokes": vec![json!({}); 17]}), - json!({"layer": "Cities", "strokes": []}), + // Polygons have no strokes of their own (points have marks). + json!({"layer": "Countries", "strokes": []}), ] { assert!(s.run("layer.lineStrokes", &bad).is_err(), "{bad}"); } assert_eq!(s.undo_depth(), depth); + s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": []})).unwrap(); + s.run("edit.undo", &Value::Null).unwrap(); // [] goes back to the single stroke; undo brings the stack back. s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": []})).unwrap(); assert!(s.map().layer_by_name("Rivers").unwrap().line_strokes.is_empty()); @@ -516,6 +519,57 @@ fn a_character_of_a_font_is_a_marker_on_points_and_along_lines() { assert_eq!(s.undo_depth(), depth); } +#[test] +fn a_point_takes_stacked_marks_and_turns_by_a_field() { + use rustgis_map::{Glyph, LineMark, Marker}; + let mut s = world(); + // A white disc under a black character, and a red square 6 pt right of the point. + let marks = json!([ + {"color": "#FFFFFF", "markers": {"mark": "circle", "size": 14}}, + {"color": "#000000", "markers": {"mark": "character", "char": "A", "size": 12}}, + {"color": "#FF0000", "markers": {"mark": "square", "size": 4, "offsetAlong": 6}} + ]); + let mark = s.notice_mark(); + s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": marks})).unwrap(); + assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); + let st = s.map().layer_by_name("Cities").unwrap().line_strokes.clone(); + assert_eq!(st.len(), 3); + assert_eq!(st[1].markers.unwrap().mark, LineMark::Marker(Marker::Char(Glyph::new('A', "")))); + assert_eq!(st[2].markers.unwrap().offset_along, 6.0); + // A line stroke would draw nothing at a point. + let err = s.run("layer.lineStrokes", &json!({"layer": "Cities", "strokes": [{"width": 2}]})).unwrap_err(); + assert!(err.contains("stroke 1: a point layer's strokes are markers"), "{err}"); + let err = s.run("layer.lineStrokes", &json!({"layer": "Countries", "strokes": marks})).unwrap_err(); + assert!(err.contains("line and point layers"), "{err}"); + // Turned by a numeric field, counterclockwise. + s.run("layer.markerRotation", &json!({"layer": "Cities", "field": "pop_max", "counterclockwise": true})).unwrap(); + let l = s.map().layer_by_name("Cities").unwrap(); + let r = l.marker_rotation.clone().unwrap(); + assert_eq!((r.field.as_str(), r.counterclockwise), ("pop_max", true)); + let fi = l.dataset.field_index("pop_max").unwrap(); + let v = l.dataset.value(0, fi).as_f64().unwrap(); + assert_eq!(l.marker_turn(0), -(v.rem_euclid(360.0) as f32)); + // Drawn in an export. + let dir = std::env::temp_dir().join(format!("rustgis-marks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let depth = s.undo_depth(); + for (bad, says) in [ + (json!({"layer": "Cities", "field": "name"}), "not a numeric field"), + (json!({"layer": "Cities", "field": "nowhere"}), "no field named"), + (json!({"layer": "Cities", "field": 3}), "field is a field's name"), + (json!({"layer": "Rivers", "field": "pop_max"}), "point layers"), + ] { + let err = s.run("layer.markerRotation", &bad).unwrap_err(); + assert!(err.contains(says), "{bad}: {err}"); + } + assert_eq!(s.undo_depth(), depth); + // None turns them back. + s.run("layer.markerRotation", &json!({"layer": "Cities", "field": null})).unwrap(); + assert!(s.map().layer_by_name("Cities").unwrap().marker_rotation.is_none()); +} + #[test] fn a_stroke_is_dashed_by_command() { let mut s = world(); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 955d8797a..3d1cb0b19 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -32,7 +32,7 @@ pub use globe::{Globe, Projection}; pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch}; pub use symbology::{ ClassMethod, FontName, Glyph, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, - LineStroke, Marker, Placements, Renderer, Symbol, + LineStroke, Marker, MarkerRotation, Placements, Renderer, Symbol, }; pub use tiles::{TileKind, TileLayer}; @@ -144,7 +144,9 @@ pub struct Layer { #[serde(default)] pub extrusion: Option, /// A line layer's stacked strokes, drawn bottom first in place of its single stroke (none: - /// the renderer's stroke alone). + /// the renderer's stroke alone). A point layer's are its marks: each stroke of markers draws + /// its marker at the point, bottom first, in place of the symbol's (see + /// [`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, /// Draws the stacked strokes in levels (symbol levels): every feature's strokes of one @@ -152,6 +154,10 @@ pub struct Layer { /// instead of cutting across them where lines meet. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stroke_levels: bool, + /// Turns each point's marker (and its marks, their offsets with them) by the angle in a + /// field, as QGIS's data-defined symbol angle does. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "symbology::de_marker_rotation")] + pub marker_rotation: Option, /// The scale (1:N) its symbol and label sizes are set for: drawn at another scale they grow /// and shrink with the map, as on paper (QGIS's reference scale). `None`: they keep their /// size on screen. @@ -308,6 +314,7 @@ impl Layer { extrusion: None, line_strokes: Vec::new(), stroke_levels: false, + marker_rotation: None, reference_scale: None, priority: 0, design: design::Design::default(), @@ -503,6 +510,13 @@ impl Layer { if self.graphics { graphics::symbol(&self.dataset, row) } else { self.renderer.symbol_for(&self.dataset, row) } } + /// How far a point row's marker is turned, in degrees clockwise ([`Layer::marker_rotation`]): 0 + /// without, or where the row's value isn't a number. + pub fn marker_turn(&self, row: usize) -> f32 { + let Some(r) = self.marker_rotation.as_ref().filter(|_| !self.graphics) else { return 0.0 }; + r.turn(&self.dataset, row) + } + /// The stacked strokes a line feature row is drawn with: its class's own /// ([`Renderer::strokes_for`]), else the layer's `line_strokes`; none (the single stroke) for /// a graphics layer. @@ -1737,6 +1751,34 @@ mod tests { d } + #[test] + fn a_marker_rotation_is_kept_and_a_crafted_one_reads_as_none() { + let mut l = + Layer::new(1, Dataset::new("p", GeometryType::Point, vec![rustgis_data::Field::new("a", rustgis_data::FieldType::Double)])); + l.dataset.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![rustgis_data::Value::Float(450.0)]); + // None isn't written: an older RustGIS reads the layer as before. + assert!(serde_json::to_value(&l).unwrap().get("marker_rotation").is_none()); + l.marker_rotation = Some(symbology::MarkerRotation { field: "a".into(), counterclockwise: true }); + let v = serde_json::to_value(&l).unwrap(); + let back: Layer = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(back.marker_rotation, l.marker_rotation); + assert_eq!(back.marker_turn(0), -90.0, "450° counterclockwise"); + for junk in [ + serde_json::json!(5), + serde_json::json!({"field": 3}), + serde_json::json!({"field": ""}), + serde_json::json!({"field": "x".repeat(10_000)}), + ] { + let mut v = v.clone(); + v["marker_rotation"] = junk.clone(); + let read: Layer = serde_json::from_value(v).unwrap(); + assert!(read.marker_rotation.is_none(), "{junk}"); + } + // A field the data lacks turns nothing. + l.marker_rotation = Some(symbology::MarkerRotation { field: "gone".into(), counterclockwise: false }); + assert_eq!(l.marker_turn(0), 0.0); + } + #[test] fn hostile_line_strokes_in_a_project_are_brought_into_range() { // A crafted project file: too many strokes, and widths and offsets far out of range. diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index b6b48f2a3..5430fee7c 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -25,7 +25,7 @@ use serde::Serialize; use crate::Rgba; use crate::effects::Blend; -use crate::symbology::{Labeling, LineStroke, Renderer}; +use crate::symbology::{Labeling, LineStroke, MarkerRotation, Renderer}; pub use expr::to_sql; pub use source::{Source, is_drive_relative, is_relative}; @@ -104,6 +104,8 @@ pub struct Style { /// offset one). pub line_strokes: Option>, pub stroke_levels: Option, + /// Point markers turned by a field (`Some(None)`: not turned). + pub marker_rotation: Option>, pub reference_scale: Option, pub labels: Option, /// 0 opaque to 100 invisible. @@ -149,6 +151,10 @@ impl Style { if let Some(on) = self.stroke_levels { layer.stroke_levels = on; } + // Only a point layer's markers turn. + if let Some(r) = &self.marker_rotation { + layer.marker_rotation = r.clone().filter(|_| layer.geometry_type() == rustgis_data::GeometryType::Point); + } if let Some(r) = self.reference_scale { layer.reference_scale = Some(r); } diff --git a/crates/map/src/qgis/renderer.rs b/crates/map/src/qgis/renderer.rs index 72a48b859..9affc4231 100644 --- a/crates/map/src/qgis/renderer.rs +++ b/crates/map/src/qgis/renderer.rs @@ -46,14 +46,23 @@ fn read_depth(r: Node, layer: &str, style: &mut Style, report: &mut Report, dept .collect() }) .unwrap_or_default(); - let sym = |name: Option<&str>, report: &mut Report| -> Option { - symbols.get(name?).map(|n| symbol::read(*n, reference, layer, report)) + // The first symbol turned by a field turns the layer's markers. + let rotation = std::cell::RefCell::new(None); + let turned = symbols.values().any(|n| symbol::turns_by_field(*n)); + let read = |n: Node, report: &mut Report| { + let c = symbol::read(n, reference, turned, layer, report); + if let (Some(r), Ok(mut had)) = (&c.rotation, rotation.try_borrow_mut()) + && *had.get_or_insert_with(|| r.clone()) != *r + { + report.approximated(layer, "marker layers turned by different fields, all turned by the first"); + } + c }; + let sym = |name: Option<&str>, report: &mut Report| -> Option { symbols.get(name?).map(|n| read(*n, report)) }; let kind = r.attribute("type").unwrap_or_default(); match kind { "singleSymbol" => { - let Some(c) = sym(Some("0"), report).or_else(|| symbols.values().next().map(|n| symbol::read(*n, reference, layer, report))) - else { + let Some(c) = sym(Some("0"), report).or_else(|| symbols.values().next().map(|n| read(*n, report))) else { return; }; style.line_strokes = Some(if c.stacked() { c.strokes.clone() } else { Vec::new() }); @@ -75,6 +84,12 @@ fn read_depth(r: Node, layer: &str, style: &mut Style, report: &mut Report, dept "heatmapRenderer" => report.skipped(layer, "its heatmap renderer"), other => report.skipped(layer, format!("its {other} renderer")), } + // A renderer read here (not by the one inside a wrapper) says whether its markers turn. + let wrapper = + matches!(kind, "pointCluster" | "pointDisplacement" | "invertedPolygonRenderer" | "mergedFeatureRenderer" | "25dRenderer"); + if style.renderer.is_some() && !wrapper { + style.marker_rotation = Some(rotation.try_borrow().ok().and_then(|r| r.clone())); + } } /// A renderer's classifying attribute as a field: `name` or `"name"`; `None` for an expression. @@ -89,6 +104,11 @@ fn field_of(attr: &str, layer: &str, report: &mut Report) -> Option { None } +/// A switched-off class (`render="false"`): it draws nothing. +fn switched_off() -> Converted { + Converted { symbol: symbol::hidden(), strokes: Vec::new(), rotation: None } +} + type SymbolOf<'a> = dyn Fn(Option<&str>, &mut Report) -> Option + 'a; fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym: &SymbolOf) { @@ -105,10 +125,13 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym let mut other = symbol::hidden(); let mut other_strokes = Vec::new(); for cat in &cats { - let Some(mut c) = sym(cat.attribute("symbol"), report) else { continue }; - if cat.attribute("render") == Some("false") { - c = Converted { symbol: symbol::hidden(), strokes: Vec::new() }; - } + // 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") { + switched_off() + } else { + let Some(c) = sym(cat.attribute("symbol"), report) else { continue }; + c + }; // A category of several values (QGIS's merged categories) lists them as s, or as a // list "# + ) + }; + let font = |chr: &str, color: &str, size: &str| { + format!( + r#"{}"#, + dd("- "WINKEL"") + ) + }; + let qml = format!( + r#"{}{}"#, + font(";", "255,255,255,255", "0.9"), + font("L", "0,136,208,255", "1") + ); + let mut report = Report::default(); + let s = read_style(&qml, "Signs", &mut report).unwrap(); + let mm = 96.0 / 25.4; + let near = |a: f32, b: f32| (a - b).abs() < 1e-3; + let marks = s.line_strokes.clone().unwrap(); + assert_eq!(marks.len(), 2, "{marks:?}"); + let (under, over) = (marks[0].markers.unwrap(), marks[1].markers.unwrap()); + assert_eq!(under.mark, LineMark::Marker(Marker::Char(Glyph::new(';', "DKM_Symbole")))); + assert_eq!(marks[0].color, Some(Rgba::rgb(255, 255, 255))); + assert_eq!(over.mark, LineMark::Marker(Marker::Char(Glyph::new('L', "DKM_Symbole")))); + assert!(near(under.size, 0.9 * mm) && near(over.size, mm) && under.rotate && over.rotate, "they turn"); + // The top layer is the symbol (the legend's). + let Some(Renderer::Single(sym)) = &s.renderer else { panic!() }; + assert_eq!((sym.marker, sym.fill), (Marker::Char(Glyph::new('L', "DKM_Symbole")), Rgba::rgb(0, 136, 208))); + assert_eq!(s.marker_rotation, Some(Some(crate::symbology::MarkerRotation { field: "WINKEL".into(), counterclockwise: true }))); + assert!(!report.notes.iter().any(|n| n.text.contains("data-defined")), "{:?}", report.notes); + // Applied to a point layer, its markers turn. + let mut layer = crate::Layer::new(1, rustgis_data::Dataset::new("signs", rustgis_data::GeometryType::Point, vec![])); + s.apply(&mut layer); + assert_eq!(layer.marker_rotation.as_ref().map(|r| r.field.as_str()), Some("WINKEL")); + assert_eq!(layer.line_strokes.len(), 2); + + // A square anchored at its top left, offset and at an angle: one mark, shifted by half its size. + let qml = r#" + + "#; + let mut report = Report::default(); + let s = read_style(qml, "Signs", &mut report).unwrap(); + let marks = s.line_strokes.unwrap(); + let m = marks[0].markers.unwrap(); + assert_eq!(marks.len(), 1); + assert!(near(m.offset_along, 3.0 * mm) && near(marks[0].offset, 0.0) && m.angle == 30.0, "{marks:?}"); + assert_eq!(s.marker_rotation, Some(None), "not turned by a field"); + assert!(!report.notes.iter().any(|n| n.text.contains("turned markers")), "{:?}", report.notes); + + // One plain layer stays the symbol alone, as before. + let qml = r#" + + "#; + let s = read_style(qml, "Signs", &mut Report::default()).unwrap(); + assert_eq!(s.line_strokes, Some(Vec::new())); + let Some(Renderer::Single(sym)) = &s.renderer else { panic!() }; + assert_eq!(sym.marker, Marker::Star); + + // An angle from any other expression is noted, and turns nothing. + let other = qml.replace("", &format!("{}", dd(""A" * 2"))); + let mut report = Report::default(); + let s = read_style(&other, "Signs", &mut report).unwrap(); + assert_eq!(s.marker_rotation, Some(None)); + assert!(report.notes.iter().any(|n| n.text.contains("data-defined")), "{:?}", report.notes); + + // Classes of one layer: an antenna that keeps still where a cliff turns by the field. The + // antenna's single plain character becomes a mark that doesn't turn; the cliff's turns. + let qml = format!( + r#" + + {}{}"#, + font("D", "0,0,0,255", "1.3").replace(&dd("- "WINKEL""), ""), + font("P", "0,0,0,255", "1.5") + ); + let mut report = Report::default(); + let s = read_style(&qml, "Signs", &mut report).unwrap(); + assert_eq!(s.marker_rotation.clone().flatten().map(|r| r.field), Some("WINKEL".into())); + let Some(Renderer::Unique { class_strokes, .. }) = &s.renderer else { panic!("{:?}", s.renderer) }; + let still = class_strokes.get("Antenne").unwrap(); + assert_eq!(still.len(), 1); + assert!(!still[0].markers.unwrap().rotate, "the antenna keeps still"); + // The cliff's one centred character turns as the symbol does: no marks of its own. + assert!(!class_strokes.contains_key("Fels"), "{class_strokes:?}"); + let turned = |r: &Report| r.notes.iter().any(|n| n.text.contains("turned by different fields")); + assert!(!turned(&report), "{:?}", report.notes); + // Classes turned by different fields all turn by the first: said. + let qml = format!( + r#" + + {}{}"#, + font("D", "0,0,0,255", "1.3"), + font("P", "0,0,0,255", "1.5").replace(&dd("- "WINKEL""), &dd(""RICHTUNG"")) + ); + let mut report = Report::default(); + let s = read_style(&qml, "Signs", &mut report).unwrap(); + assert_eq!(s.marker_rotation.flatten().map(|r| r.field), Some("WINKEL".into())); + assert!(turned(&report), "{:?}", report.notes); + // A switched-off class's field turns nothing: the shown one's does, and nothing is said. + let qml = qml.replace(r#"label="Antenne" render="true""#, r#"label="Antenne" render="false""#).replace("WINKEL", "W2"); + let mut report = Report::default(); + let s = read_style(&qml, "Signs", &mut report).unwrap(); + assert_eq!(s.marker_rotation.flatten().map(|r| r.field), Some("RICHTUNG".into())); + assert!(!turned(&report), "{:?}", report.notes); + + // QGIS 2's `dd_properties` are still noted. + let old = r#" + + + "#; + let mut report = Report::default(); + read_style(old, "Signs", &mut report).unwrap(); + assert!(report.notes.iter().any(|n| n.text.contains("data-defined")), "{:?}", report.notes); +} diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 16b306847..2c3b7ab49 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -427,6 +427,42 @@ impl<'de> Deserialize<'de> for Placements { } } +/// A point layer's markers turned by a field's angle ([`crate::Layer::marker_rotation`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MarkerRotation { + /// The field the angle is read from, in degrees. + pub field: String, + /// The angle runs counterclockwise (as in mathematics), not clockwise (as on a compass). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub counterclockwise: bool, +} + +/// Reads a layer's marker rotation leniently: one that isn't (a crafted file's) is none, and the rest +/// of the layer still reads. A field name is at most [`MAX_FONT_NAME`]·2 characters. +pub fn de_marker_rotation<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Read { + Rotation(MarkerRotation), + Other(serde::de::IgnoredAny), + } + Ok(match Option::::deserialize(d)? { + Some(Read::Rotation(r)) if !r.field.is_empty() && r.field.chars().count() <= 2 * MAX_FONT_NAME => Some(r), + _ => None, + }) +} + +impl MarkerRotation { + /// Row `row`'s turn in degrees clockwise: 0 where its value isn't a number (or the field is gone). + pub fn turn(&self, ds: &Dataset, row: usize) -> f32 { + let Some(v) = ds.field_index(&self.field).and_then(|fi| ds.value(row, fi).as_f64()).filter(|v| v.is_finite()) else { + return 0.0; + }; + let a = v.rem_euclid(360.0) as f32; + if self.counterclockwise { -a } else { a } + } +} + /// What a stroke draws along a line instead of a line: a marker, or a hash mark across it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 09cdcbfa6..771b01fe4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,6 +18,7 @@ pub mod logo; mod mesh; pub mod pdf; mod picture; +mod point_marks; pub mod post; mod raster; pub mod report; @@ -865,7 +866,18 @@ impl Ctx<'_> { true } - fn highlight(&self, pm: &mut Pixmap, g: &Geometry, s: &Symbol, strokes: &[LineStroke]) { + /// A point's marks, or its marker turned ([`point_marks::draw`]), unless it lies off the picture. + fn marks(&self, pm: &mut Pixmap, c: Coord, s: &Symbol, strokes: &[LineStroke], turn: f32, halo: Option) { + let (x, y) = self.px(c); + let reach = (point_marks::reach(s, strokes) + 4.0) * self.ppp; + if !(x.is_finite() && y.is_finite()) || x < -reach || y < -reach || x > pm.width() as f32 + reach || y > pm.height() as f32 + reach + { + return; + } + point_marks::draw(pm, x, y, s, strokes, turn, halo, self.ppp); + } + + fn highlight(&self, pm: &mut Pixmap, g: &Geometry, s: &Symbol, strokes: &[LineStroke], turn: f32) { let cyan = paint(SELECTION, 1.0); match g { Geometry::Line(parts) if !strokes.is_empty() => { @@ -893,6 +905,10 @@ impl Ctx<'_> { self.stacked_line(pm, parts, s, &halo); self.stacked_line(pm, parts, s, strokes); } + Geometry::Point(c) if !point_marks::plain(strokes, turn) => self.marks(pm, *c, s, strokes, turn, Some(SELECTION)), + Geometry::MultiPoint(v) if !point_marks::plain(strokes, turn) => { + v.iter().for_each(|c| self.marks(pm, *c, s, strokes, turn, Some(SELECTION))) + } Geometry::Point(c) => self.marker(pm, *c, s, 1.0, Some(SELECTION)), Geometry::MultiPoint(v) => v.iter().for_each(|c| self.marker(pm, *c, s, 1.0, Some(SELECTION))), Geometry::Line(parts) => { @@ -2026,6 +2042,7 @@ pub fn gpu_may_turn(map: &Map, view: &View) -> bool { && l.aggregation.is_none() && !l.graphics && !l.has_strokes() + && 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)) }) @@ -2271,6 +2288,9 @@ fn stands_up(map: &Map, layer: &rustgis_map::Layer) -> bool { && layer.mask.is_none() && layer.blend == rustgis_map::effects::Blend::Normal && map.swipe.is_none_or(|(id, _)| id != layer.id) + // Marks and turned markers stay on the ground, drawn as the flat map draws them. + && !layer.has_strokes() + && layer.marker_rotation.is_none() && rows.is_some_and(|n| n <= MAX_UPRIGHT_POINTS as u64) } @@ -2921,7 +2941,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } else { Default::default() }; - let mut highlights: Vec<(usize, Geometry, Symbol, Vec)> = Vec::new(); + let mut highlights: Vec<(usize, Geometry, Symbol, Vec, f32)> = Vec::new(); // The GPU mesh vertices the layer has left: a streamed layer's batches share them, so // one too dense to mesh goes to the CPU however it is split. let mut mesh_room = mesh::MAX_LAYER_VERTICES; @@ -3018,6 +3038,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac && layer.extrusion.is_none() && !layer.graphics && !layer.has_strokes() + && layer.marker_rotation.is_none() && !upright.contains(&layer.id); // Lyon refuses degenerate geometry tiny-skia draws fine, and a layer too dense to mesh // (`MAX_LAYER_VERTICES`) is quicker splatted: collect_layer reports both, and the layer @@ -3081,7 +3102,21 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let mut stacked: Vec<(&[Vec], Symbol, &[LineStroke])> = Vec::new(); for (row, g) in drawn.iter().enumerate() { let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || !visible(&extents.get(row).copied().unwrap_or_else(|| g.extent())) { + let e = extents.get(row).copied().unwrap_or_else(|| g.extent()); + // A point's marks, shifted from it, may show while the point is off the view. + let marked_in = || { + if globe.is_some() || !matches!(g, Geometry::Point(_) | Geometry::MultiPoint(_)) { + return false; + } + let (sym, strokes) = layer.style(row); + if strokes.is_empty() { + return false; + } + // In the layer's pixels (`ctx` is scaled by its size factor). + let r = f64::from(point_marks::reach(&sym, strokes) * ctx.ppp) * map_per_px; + Extent::new(e.min_x - r, e.min_y - r, e.max_x + r, e.max_y + r).intersects(&ctx.cull) + }; + if rows.get(row) != Some(&true) || !(visible(&e) || marked_in()) { continue; } // A picture draws in its row's turn, so graphics keep their order. (On the globe @@ -3109,7 +3144,13 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac { continue; } + // A point's marks, or its marker turned by a field. + let turn = layer.marker_turn(row); match g { + Geometry::Point(c) if !point_marks::plain(strokes, turn) => ctx.marks(dest, *c, &sym, strokes, turn, None), + Geometry::MultiPoint(v) if !point_marks::plain(strokes, turn) => { + v.iter().for_each(|c| ctx.marks(dest, *c, &sym, strokes, turn, None)) + } 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), _ => ctx.feature(dest, g, &sym), @@ -3158,12 +3199,12 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if complete_source { let rank = ds_ptr.features.get(*row).and_then(|f| selected_order.get(&f.id)).copied().unwrap_or(usize::MAX); let (symbol, strokes) = layer.style(*row); - highlights.push((rank, (**g).clone(), symbol, strokes.to_vec())); + highlights.push((rank, (**g).clone(), symbol, strokes.to_vec(), layer.marker_turn(*row))); } else if gpu_done && mesh::collect_highlight(&mut cmds, g, &layer.symbol(*row), ctx.ppp, map_per_px).is_ok() { // highlight tessellated into the scene, right after its layer } else { let (symbol, strokes) = layer.style(*row); - ctx.highlight(&mut pm, g, &symbol, strokes); + ctx.highlight(&mut pm, g, &symbol, strokes, layer.marker_turn(*row)); cpu_dirty = true; } } @@ -3385,8 +3426,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } } highlights.sort_by_key(|(rank, ..)| *rank); - for (_, geometry, symbol, strokes) in highlights { - ctx.highlight(&mut pm, &geometry, &symbol, &strokes); + for (_, geometry, symbol, strokes, turn) in highlights { + ctx.highlight(&mut pm, &geometry, &symbol, &strokes, turn); cpu_dirty = true; } } @@ -5797,6 +5838,57 @@ mod tests { assert_eq!(r.pixmap.pixel(100, 100).unwrap(), blank, "nothing on the centreline"); } + #[test] + fn a_points_marks_and_rotation_draw_on_the_cpu_and_old_layers_stay_on_the_gpu() { + use rustgis_map::{LineMark, LineMarkers, LineStroke, MarkerRotation}; + // One point at the centre of a 200 px view, its angle 90 in field "a". + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("signs", GeometryType::Point, vec![rustgis_data::Field::new("a", rustgis_data::FieldType::Double)]); + d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![rustgis_data::Value::Float(90.0)]); + 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 }; + // A plain point stays a GPU instance, as before. + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut m, &view, Backend::Gpu) else { panic!("gpu scene") }; + assert!(cmd_kinds(&scene).contains(&"points")); + // A red square 30 pt right of the point, turned with the field to 30 pt below it. + let red = Rgba::rgb(255, 0, 0); + let square = LineMarkers { mark: LineMark::Marker(Marker::Square), size: 10.0, offset_along: 30.0, ..LineMarkers::default() }; + let l = m.layer_mut(id).unwrap(); + l.line_strokes = vec![LineStroke { color: Some(red), width: Some(0.0), markers: Some(square), ..LineStroke::default() }]; + let is_red = |r: &Rendered, x: u32, y: u32| r.pixmap.pixel(x, y).is_some_and(|p| (p.red(), p.green(), p.alpha()) == (255, 0, 255)); + let r = render(&mut m, &view).unwrap(); + assert!(is_red(&r, 130, 100) && !is_red(&r, 100, 100), "right of the point, the symbol's own marker gone"); + // The point 20 px left of the view, past its cull margin: its mark still shows, 10 px in. + let left = View { camera: Camera { center: Coord::new(camera.center.x + 120.0 * camera.res, camera.center.y), ..camera }, ..view }; + let r = render(&mut m, &left).unwrap(); + assert!(is_red(&r, 10, 100), "a mark shifted into the view"); + // At twice its reference scale's size: the point 60 px left, its mark 60 px right of it. + let far = View { camera: Camera { center: Coord::new(camera.center.x + 160.0 * camera.res, camera.center.y), ..camera }, ..view }; + let scale = m.ground_res_at(&far.camera) / rustgis_geom::PIXEL_M; + m.layer_mut(id).unwrap().reference_scale = Some(2.0 * scale); + let r = render(&mut m, &far).unwrap(); + assert!(is_red(&r, 4, 100), "an enlarged mark shifted into the view"); + m.layer_mut(id).unwrap().reference_scale = None; + m.layer_mut(id).unwrap().marker_rotation = Some(MarkerRotation { field: "a".into(), counterclockwise: false }); + let r = render(&mut m, &view).unwrap(); + assert!(is_red(&r, 100, 130) && !is_red(&r, 130, 100), "turned to below it"); + // Selected: a halo round it, where it is. + m.layer_mut(id).unwrap().selection = vec![0]; + let r = render(&mut m, &view).unwrap(); + let p = r.pixmap.pixel(100, 136).unwrap(); + assert!((p.red(), p.green(), p.blue()) != (255, 255, 255) && p.alpha() > 0, "{p:?}"); + m.layer_mut(id).unwrap().selection.clear(); + // Turned or marked, the layer draws on the CPU. + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut m, &view, Backend::Gpu) else { panic!("gpu scene") }; + let kinds = cmd_kinds(&scene); + assert!(kinds.contains(&"image") && !kinds.contains(&"points"), "{kinds:?}"); + m.layer_mut(id).unwrap().line_strokes.clear(); + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut m, &view, Backend::Gpu) else { panic!("gpu scene") }; + assert!(!cmd_kinds(&scene).contains(&"points"), "turned alone, too"); + } + #[test] fn a_character_marker_draws_its_character_where_qgis_does_and_on_the_cpu() { use ab_glyph::Font; diff --git a/crates/render/src/point_marks.rs b/crates/render/src/point_marks.rs new file mode 100644 index 000000000..9347021df --- /dev/null +++ b/crates/render/src/point_marks.rs @@ -0,0 +1,195 @@ +//! A point's marks (a point layer's stacked strokes of markers, `Layer::line_strokes`) and markers +//! turned by a field (`Layer::marker_rotation`), as QGIS draws a marker symbol of several layers: +//! each mark at the point, bottom first, shifted by its offset (right and down), and turned +//! clockwise by its own angle and, where it turns ([`LineMarkers::rotate`]), the point's: its +//! offset turned with it. + +#[cfg(doc)] +use rustgis_map::LineMarkers; +use rustgis_map::{LineMark, LineStroke, Rgba, Symbol}; +use tiny_skia::{FillRule, LineCap, LineJoin, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::{marker_path, paint}; + +/// One mark: its shape, size (points), fill, outline (colour and width in points), offset (points, +/// right and down) and angle (degrees clockwise). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Mark { + shape: LineMark, + size: f32, + fill: Rgba, + outline: Option<(Rgba, f32)>, + offset: (f32, f32), + angle: f32, + /// Turned by the point's angle too. + turns: bool, +} + +/// The marks a point of symbol `sym` draws: `strokes`' marks (a stroke's colour, else the symbol's +/// fill; its width, else the symbol's outline width), else the symbol's own marker. +fn marks(sym: &Symbol, strokes: &[LineStroke]) -> Vec { + if strokes.is_empty() { + let outline = (sym.stroke_width > 0.0 && sym.stroke.a() > 0).then_some((sym.stroke, sym.stroke_width)); + return vec![Mark { + shape: LineMark::Marker(sym.marker), + size: sym.size.max(1.0), + fill: sym.fill, + outline, + offset: (0.0, 0.0), + angle: 0.0, + turns: true, + }]; + } + strokes + .iter() + .filter_map(|st| { + let m = st.markers?; + let width = st.width.unwrap_or(sym.stroke_width).max(0.0); + let fill = st.color.unwrap_or(sym.fill); + Some(Mark { + shape: m.mark, + size: m.size.max(0.0), + // A hash mark is a line: its colour, as wide as the stroke. + fill, + outline: match m.mark { + LineMark::Hash => Some((fill, width)), + LineMark::Marker(_) => m.outline.filter(|c| c.a() > 0 && width > 0.0).map(|c| (c, width)), + }, + offset: (m.offset_along, st.offset), + angle: m.angle, + turns: m.rotate, + }) + }) + .collect() +} + +/// Draws a point of symbol `sym` at (`x`, `y`) device pixels: `strokes`' marks, else its marker, +/// turned `turn` degrees clockwise about the point; with `halo`, a selection halo round each. +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw(pm: &mut Pixmap, x: f32, y: f32, sym: &Symbol, strokes: &[LineStroke], turn: f32, halo: Option, ppp: f32) { + if !(x.is_finite() && y.is_finite()) { + return; + } + let marks = marks(sym, strokes); + // Every mark's halo under all the marks, so none covers the one below it. + for (m, ink) in marks.iter().map(|m| (m, false)).chain(marks.iter().map(|m| (m, true))) { + if !ink && halo.is_none() { + continue; + } + let angle = if m.turns { turn } else { 0.0 } + m.angle; + let (sin, cos) = angle.to_radians().sin_cos(); + let (ox, oy) = (m.offset.0 * ppp, m.offset.1 * ppp); + // Turned clockwise on the screen (y down), as the mark is. + let (dx, dy) = (ox * cos - oy * sin, ox * sin + oy * cos); + let t = Transform::from_rotate(angle).post_translate(x + dx, y + dy); + let size = m.size * ppp; + let hash = matches!(m.shape, LineMark::Hash); + let path = match m.shape { + LineMark::Marker(shape) => marker_path(0.0, 0.0, size.max(ppp) / 2.0, shape), + // Across a line running east: up and down. + LineMark::Hash => { + let mut pb = PathBuilder::new(); + pb.move_to(0.0, -size / 2.0); + pb.line_to(0.0, size / 2.0); + pb.finish() + } + }; + let Some(path) = path else { continue }; + if !ink { + let h = halo.unwrap_or(Rgba::new(0, 0, 0, 0)); + let width = 3.5 * ppp + if hash { m.outline.map_or(0.0, |o| o.1 * ppp) } else { 0.0 }; + let st = Stroke { width, line_join: LineJoin::Round, line_cap: LineCap::Round, ..Stroke::default() }; + pm.stroke_path(&path, &paint(h, 1.0), &st, t, None); + continue; + } + if !hash && m.fill.a() > 0 { + pm.fill_path(&path, &paint(m.fill, 1.0), FillRule::Winding, t, None); + } + if let Some((c, w)) = m.outline { + let cap = if hash { LineCap::Butt } else { LineCap::Round }; + let st = Stroke { width: (w * ppp).max(0.5), line_join: LineJoin::Round, line_cap: cap, ..Stroke::default() }; + pm.stroke_path(&path, &paint(c, 1.0), &st, t, None); + } + } +} + +/// How far from the point (points) any of the marks reaches, for culling: a mark's offset and its +/// size (a character as far as its outline). +pub(crate) fn reach(sym: &Symbol, strokes: &[LineStroke]) -> f32 { + marks(sym, strokes) + .iter() + .map(|m| { + let extent = match m.shape { + LineMark::Marker(shape) => crate::marker_reach(shape).max(0.6), + LineMark::Hash => 0.5, + }; + m.offset.0.hypot(m.offset.1) + m.size * extent + m.outline.map_or(0.0, |o| o.1) + }) + .fold(0.0, f32::max) +} + +/// Whether a symbol's own marker would draw the same as [`draw`] does without strokes and turn (the +/// fast paths, sprites and GPU instances, draw it then). +pub(crate) fn plain(strokes: &[LineStroke], turn: f32) -> bool { + strokes.is_empty() && turn == 0.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use rustgis_data::GeometryType; + use rustgis_map::{Glyph, LineMarkers, Marker}; + + fn ink(pm: &Pixmap, x: u32, y: u32) -> bool { + pm.pixel(x, y).is_some_and(|p| p.alpha() > 200) + } + + fn mark(shape: Marker, color: Rgba, size: f32, offset: (f32, f32), angle: f32) -> LineStroke { + let m = LineMarkers { mark: LineMark::Marker(shape), size, offset_along: offset.0, angle, ..LineMarkers::default() }; + LineStroke { color: Some(color), width: Some(0.0), offset: offset.1, markers: Some(m), ..LineStroke::default() } + } + + #[test] + fn marks_stack_at_the_point_shifted_and_turned_as_qgis_turns_them() { + let sym = Symbol { size: 6.0, ..Symbol::default_for(GeometryType::Point, 0) }; + let (red, black) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 0)); + // A big red square under a small black one, and a black one 20 pt to the right. + let strokes = [mark(Marker::Square, red, 20.0, (0.0, 0.0), 0.0), mark(Marker::Square, black, 6.0, (20.0, 0.0), 0.0)]; + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &sym, &strokes, 0.0, None, 1.0); + let at = |pm: &Pixmap, x, y| pm.pixel(x, y).map(|p| (p.red(), p.alpha())); + assert_eq!(at(&pm, 50, 50), Some((255, 255)), "the red one at the point"); + assert_eq!(at(&pm, 70, 50), Some((0, 255)), "the black one 20 pt right"); + // Turned 90° clockwise: the offset turns with it, to 20 pt below. + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &sym, &strokes, 90.0, None, 1.0); + assert!(ink(&pm, 50, 70) && !ink(&pm, 70, 50)); + // A mark's own angle turns its offset too, and adds to the point's. + let own = [mark(Marker::Square, black, 6.0, (20.0, 0.0), 90.0)]; + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &sym, &own, -90.0, None, 1.0); + assert!(ink(&pm, 70, 50), "-90 + 90: not turned"); + // A mark that doesn't turn keeps its own angle alone. + let mut fixed = own; + if let Some(m) = &mut fixed[0].markers { + m.rotate = false; + } + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &sym, &fixed, -90.0, None, 1.0); + assert!(ink(&pm, 50, 70) && !ink(&pm, 70, 50), "its own 90° only"); + // No strokes: the symbol's marker, turned (a triangle's tip goes from up to right). + let tri = Symbol { marker: Marker::Triangle, size: 30.0, stroke_width: 0.0, ..Symbol::default_for(GeometryType::Point, 0) }; + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &tri, &[], 90.0, None, 1.0); + assert!(ink(&pm, 64, 50) && !ink(&pm, 50, 35)); + // A stroke of no markers draws nothing at a point; a character draws. + let line = LineStroke::default(); + let mut pm = Pixmap::new(100, 100).unwrap(); + draw(&mut pm, 50.0, 50.0, &sym, &[line], 0.0, None, 1.0); + assert!(pm.pixels().iter().all(|p| p.alpha() == 0)); + let chr = [mark(Marker::Char(Glyph::new('I', "")), black, 40.0, (0.0, 0.0), 0.0)]; + draw(&mut pm, 50.0, 50.0, &sym, &chr, 0.0, None, 1.0); + assert!(pm.pixels().iter().any(|p| p.alpha() > 200)); + assert!(reach(&sym, &strokes) >= 23.0 && plain(&[], 0.0) && !plain(&strokes, 0.0) && !plain(&[], 1.0)); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2f4d162ef..8dc0eee03 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3673,7 +3673,11 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { legend_list(app, ui, &l, t); } } - if g == GeometryType::Polyline && !l.graphics { + if g == GeometryType::Point && !l.graphics { + ui.add_space(8.0); + marker_rotation_row(app, ui, &l); + } + if matches!(g, GeometryType::Polyline | GeometryType::Point) && !l.graphics { ui.add_space(8.0); line_strokes_editor(app, ui, &l, t); } @@ -4394,7 +4398,16 @@ fn marker_preview(ui: &Ui, m: &LineMarkers, x: (f32, f32), y: f32, k: f32, c: eg /// The stroke editor's rows for a stroke of markers or hash marks (`st`, its markers `m`): the /// shape, its size, where it goes and how far apart, and whether it turns with the line. -fn marker_rows(app: &mut RustGisApp, ui: &mut Ui, i: usize, st: LineStroke, m: LineMarkers, set: &impl Fn(&mut RustGisApp, LineStroke)) { +#[allow(clippy::too_many_arguments)] +fn marker_rows( + app: &mut RustGisApp, + ui: &mut Ui, + i: usize, + st: LineStroke, + m: LineMarkers, + points: bool, + set: &impl Fn(&mut RustGisApp, LineStroke), +) { let with = |m: LineMarkers| LineStroke { markers: Some(m), ..st }; if let LineMark::Marker(shape) = m.mark { let input_label = ui.label(tl!("Shape")); @@ -4431,30 +4444,42 @@ fn marker_rows(app: &mut RustGisApp, ui: &mut Ui, i: usize, st: LineStroke, m: L set(app, with(LineMarkers { size, ..m })); } ui.end_row(); - let input_label = ui.label(tl!("Place")); - let places = marker_places(); - let shown = - places.iter().find(|(_, p)| *p == m.at).map_or_else(|| tl!("Several places").to_string(), |(label, _)| tl!(label).to_string()); - egui::ComboBox::from_id_salt(("stroke_marker_place", i)) - .selected_text(shown) - .show_ui(ui, |ui| { - for (label, p) in places { - if ui.selectable_label(p == m.at, tl!(label)).clicked() { - set(app, with(LineMarkers { at: p, ..m })); + // A point's mark sits at the point, shifted right (and down, by the stroke's offset). + if points { + let input_label = ui.label(tl!("Offset right")); + let mut along = m.offset_along; + let drag = egui::DragValue::new(&mut along).range(-MAX_STROKE_OFFSET..=MAX_STROKE_OFFSET).speed(0.05).suffix(" pt"); + if ui.add(drag).on_hover_text(tl!("Shifts the mark right of the point (left when negative)")).labelled_by(input_label.id).changed() + { + set(app, with(LineMarkers { offset_along: along, ..m })); + } + ui.end_row(); + } else { + let input_label = ui.label(tl!("Place")); + let places = marker_places(); + let shown = + places.iter().find(|(_, p)| *p == m.at).map_or_else(|| tl!("Several places").to_string(), |(label, _)| tl!(label).to_string()); + egui::ComboBox::from_id_salt(("stroke_marker_place", i)) + .selected_text(shown) + .show_ui(ui, |ui| { + for (label, p) in places { + if ui.selectable_label(p == m.at, tl!(label)).clicked() { + set(app, with(LineMarkers { at: p, ..m })); + } } + }) + .response + .labelled_by(input_label.id); + ui.end_row(); + if m.at.has(Placements::INTERVAL) { + let input_label = ui.label(tl!("Interval")); + let mut interval = m.interval; + let drag = egui::DragValue::new(&mut interval).range(MIN_MARKER_INTERVAL..=MAX_MARKER_INTERVAL).speed(0.2).suffix(" pt"); + if ui.add(drag).on_hover_text(tl!("The distance between two marks along the line")).labelled_by(input_label.id).changed() { + set(app, with(LineMarkers { interval, ..m })); } - }) - .response - .labelled_by(input_label.id); - ui.end_row(); - if m.at.has(Placements::INTERVAL) { - let input_label = ui.label(tl!("Interval")); - let mut interval = m.interval; - let drag = egui::DragValue::new(&mut interval).range(MIN_MARKER_INTERVAL..=MAX_MARKER_INTERVAL).speed(0.2).suffix(" pt"); - if ui.add(drag).on_hover_text(tl!("The distance between two marks along the line")).labelled_by(input_label.id).changed() { - set(app, with(LineMarkers { interval, ..m })); + ui.end_row(); } - ui.end_row(); } ui.label(tl!("Angle")); ui.horizontal(|ui| { @@ -4465,17 +4490,88 @@ fn marker_rows(app: &mut RustGisApp, ui: &mut Ui, i: usize, st: LineStroke, m: L set(app, with(LineMarkers { angle, ..m })); } let mut rotate = m.rotate; - if ui.checkbox(&mut rotate, tl!("With the line")).on_hover_text(tl!("Turn each mark with the line's direction")).changed() { + let (label, tip) = if points { + (tl!("With the field"), tl!("Turn the mark by the layer's rotation field, its offset with it")) + } else { + (tl!("With the line"), tl!("Turn each mark with the line's direction")) + }; + if ui.checkbox(&mut rotate, label).on_hover_text(tip).changed() { set(app, with(LineMarkers { rotate, ..m })); } }); ui.end_row(); } +/// A point symbol's marker as a mark (the first of a point layer's marks): its shape, size, fill and +/// outline, taking its colours from the symbol. +fn symbol_mark(s: &Symbol) -> LineStroke { + let outline = (s.stroke_width > 0.0 && s.stroke.a() > 0).then_some(s.stroke); + let m = LineMarkers { mark: LineMark::Marker(s.marker), size: s.size, outline, at: Placements::FIRST, ..LineMarkers::default() }; + LineStroke { markers: Some(m), ..LineStroke::default() } +} + +/// A point layer's marks at the middle of `r`, bottom first, shifted as they are on the map (their +/// offsets turned with their angles). +fn marks_preview(ui: &Ui, r: Rect, strokes: &[LineStroke], base: &Symbol) { + let shown: Vec = if strokes.is_empty() { vec![symbol_mark(base)] } else { strokes.to_vec() }; + let reach = shown.iter().filter_map(|st| st.markers.map(|m| m.offset_along.hypot(st.offset) + m.size / 2.0)).fold(1.0_f32, f32::max); + let k = (18.0 / reach).min(1.0); + for st in &shown { + let Some(m) = st.markers else { continue }; + let (c, w) = st.resolve(base); + // A mark of no colour of its own takes the symbol's fill. + let c = if st.color.is_none() { base.fill } else { c }; + let (sin, cos) = m.angle.to_radians().sin_cos(); + let (ox, oy) = (m.offset_along * k, st.offset * k); + let at = r.center() + vec2(ox * cos - oy * sin, ox * sin + oy * cos); + let one = LineMarkers { at: Placements::FIRST, ..m }; + marker_preview(ui, &one, (at.x, at.x), at.y, k, c32(c), (w * k).max(0.5)); + } +} + +/// The field a point layer's markers turn by (`layer.markerRotation`): none, or one of its number +/// fields, its angles clockwise or counterclockwise. +fn marker_rotation_row(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::map::Layer) { + let cur = l.marker_rotation.as_ref(); + ui.horizontal(|ui| { + let input_label = ui.label(tl!("Rotation")); + let shown = cur.map_or_else(|| tl!("None").to_string(), |r| r.field.clone()); + egui::ComboBox::from_id_salt(("marker_rotation", l.id)) + .selected_text(shown) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), tl!("None")).clicked() && cur.is_some() { + let _ = app.run("layer.markerRotation", json!({"field": null})); + } + for f in l.dataset.fields.iter().filter(|f| f.ty.is_numeric()) { + let on = cur.is_some_and(|r| r.field == f.name); + if ui.selectable_label(on, &f.name).clicked() && !on { + let ccw = cur.is_some_and(|r| r.counterclockwise); + let _ = app.run("layer.markerRotation", json!({"field": f.name, "counterclockwise": ccw})); + } + } + }) + .response + .on_hover_text(tl!("Turn each point's marker by the angle in a field, in degrees")) + .labelled_by(input_label.id); + if let Some(r) = cur { + let mut ccw = r.counterclockwise; + if ui + .checkbox(&mut ccw, tl!("Counterclockwise")) + .on_hover_text(tl!("The angles run counterclockwise, as in mathematics")) + .changed() + { + let _ = app.run("layer.markerRotation", json!({"field": r.field, "counterclockwise": ccw})); + } + } + }); +} + /// The Strokes section of a line layer's symbology: strokes stacked in one symbol, each with /// its own colour, width, sideways offset, cap and join. Listed top first, as they are seen. A -/// layer with classes can stack strokes for one class of its own, picked at the top. +/// layer with classes can stack strokes for one class of its own, picked at the top. A point +/// layer's are its Marks: markers stacked at the point, each shifted and turned. fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::map::Layer, t: &Tokens) { + let points = l.geometry_type() == GeometryType::Point; let classes = stroke_classes(&l.renderer); let picked_id = egui::Id::unique(("stroke_class", l.id)); // The class being edited, while the renderer still has it; `None` is the layer's own strokes. @@ -4494,20 +4590,28 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma 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!("Strokes")).font(semibold(12.5))); + ui.label(egui::RichText::new(if points { tl!("Marks") } else { tl!("Strokes") }).font(semibold(12.5))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let add = ui.add_enabled(strokes.len() < MAX_STROKES, egui::Button::new(tl!("Add Stroke")).small()); - if add.on_hover_text(tl!("Stack another stroke on top")).clicked() { + let (label, tip) = if points { + (tl!("Add Mark"), tl!("Stack another marker on top")) + } else { + (tl!("Add Stroke"), tl!("Stack another stroke on top")) + }; + let add = ui.add_enabled(strokes.len() < MAX_STROKES, egui::Button::new(label).small()); + if add.on_hover_text(tip).clicked() { let mut next = strokes.clone(); - // The first stroke stands for the symbol's own; later ones copy the top one. - next.push(strokes.last().copied().unwrap_or_default()); + // The first stroke stands for the symbol's own; later ones copy the top one. A + // point's first mark is its symbol's marker. + let first = if points { symbol_mark(&base) } else { LineStroke::default() }; + next.push(strokes.last().copied().unwrap_or(first)); set_strokes(app, class, &next); } }); }); if !classes.is_empty() { ui.horizontal(|ui| { - let input_label = ui.label(egui::RichText::new(tl!("Strokes for")).size(11.5).color(t.text_dim)); + let input_label = + ui.label(egui::RichText::new(if points { tl!("Marks for") } else { tl!("Strokes 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(), @@ -4531,7 +4635,7 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma .labelled_by(input_label.id); }); } - if l.has_strokes() { + if l.has_strokes() && !points { let mut on = l.stroke_levels; let tip = tl!("Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet"); if ui.checkbox(&mut on, tl!("Draw strokes in levels")).on_hover_text(tip).changed() { @@ -4541,6 +4645,9 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma // The composed symbol along a line drawn left to right: a positive offset shows below it. let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(4), t.ribbon); + if points { + marks_preview(ui, r, &strokes, &base); + } let mut preview: Vec = if strokes.is_empty() { vec![LineStroke::default()] } else { strokes.clone() }; // In levels, a stroke shows over the lower levels' (ties keep their stack order), as the map has it. if l.stroke_levels { @@ -4555,7 +4662,7 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma let k = (18.0 / reach).min(1.0); let (x0, x1) = (r.min.x + 14.0, r.max.x - 14.0); let cap_pad = |st: &LineStroke, w: f32| if st.cap == LineCap::Butt { 0.0 } else { w / 2.0 }; - for st in &preview { + for st in preview.iter().filter(|_| !points) { let (c, w) = st.resolve(&base); let w = (w * k).max(0.5); let y = r.center().y + st.offset * k; @@ -4594,21 +4701,33 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } // The line's direction. let (ay, ax) = (r.max.y - 6.0, r.max.x - 8.0); - ui.painter().line_segment([pos2(ax - 10.0, ay), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); - ui.painter().line_segment([pos2(ax - 3.0, ay - 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); - ui.painter().line_segment([pos2(ax - 3.0, ay + 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); + if points { + // (A point has none.) + } else { + ui.painter().line_segment([pos2(ax - 10.0, ay), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); + ui.painter().line_segment([pos2(ax - 3.0, ay - 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); + ui.painter().line_segment([pos2(ax - 3.0, ay + 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); + } if borrowed { ui.label( - egui::RichText::new(tl!("This class is drawn with the layer's strokes. Changing them here gives it its own.")) - .size(11.5) - .color(t.text_dim), + egui::RichText::new(if points { + tl!("This class is drawn with the layer's marks. Changing them here gives it its own.") + } else { + tl!("This class is drawn with the layer's strokes. Changing them here gives it its own.") + }) + .size(11.5) + .color(t.text_dim), ); } if strokes.is_empty() { ui.label( - egui::RichText::new(tl!("One stroke, from the symbol. Add strokes for casings, parallel lines and offsets.")) - .size(11.5) - .color(t.text_dim), + egui::RichText::new(if points { + tl!("One marker, from the symbol. Add marks to stack markers, or to shift or turn them.") + } else { + tl!("One stroke, from the symbol. Add strokes for casings, parallel lines and offsets.") + }) + .size(11.5) + .color(t.text_dim), ); return; } @@ -4618,7 +4737,7 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma ui.add_space(4.0); ui.separator(); ui.horizontal(|ui| { - let title = crate::i18n::fmt("Stroke {n}", &[("n", &(n - i).to_string())]); + let title = crate::i18n::fmt(if points { "Mark {n}" } else { "Stroke {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() { @@ -4647,32 +4766,35 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma }; egui::Grid::new(("stroke_grid", i)).num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { let (c, w) = st.resolve(&base); - let input_label = ui.label(tl!("Draw")); - let kind = |st: &LineStroke| match st.markers.map(|m| m.mark) { - None => tl!("Line"), - Some(LineMark::Hash) => tl!("Hash marks"), - Some(LineMark::Marker(_)) => tl!("Markers"), - }; - egui::ComboBox::from_id_salt(("stroke_draw", i)) - .selected_text(kind(&st)) - .show_ui(ui, |ui| { - let line = LineStroke { markers: None, ..st }; - let markers = LineStroke { - markers: Some(LineMarkers { mark: LineMark::Marker(Marker::Circle), ..st.markers.unwrap_or_default() }), - ..st - }; - let hash = - LineStroke { markers: Some(LineMarkers { mark: LineMark::Hash, ..st.markers.unwrap_or_default() }), ..st }; - for option in [line, markers, hash] { - if ui.selectable_label(kind(&option) == kind(&st), kind(&option)).clicked() && kind(&option) != kind(&st) { - set(app, option); + // A point's marks are markers. + if !points { + let input_label = ui.label(tl!("Draw")); + let kind = |st: &LineStroke| match st.markers.map(|m| m.mark) { + None => tl!("Line"), + Some(LineMark::Hash) => tl!("Hash marks"), + Some(LineMark::Marker(_)) => tl!("Markers"), + }; + egui::ComboBox::from_id_salt(("stroke_draw", i)) + .selected_text(kind(&st)) + .show_ui(ui, |ui| { + let line = LineStroke { markers: None, ..st }; + let markers = LineStroke { + markers: Some(LineMarkers { mark: LineMark::Marker(Marker::Circle), ..st.markers.unwrap_or_default() }), + ..st + }; + let hash = + LineStroke { markers: Some(LineMarkers { mark: LineMark::Hash, ..st.markers.unwrap_or_default() }), ..st }; + for option in [line, markers, hash] { + if ui.selectable_label(kind(&option) == kind(&st), kind(&option)).clicked() && kind(&option) != kind(&st) { + set(app, option); + } } - } - }) - .response - .on_hover_text(tl!("A line, or markers or hash marks along it")) - .labelled_by(input_label.id); - ui.end_row(); + }) + .response + .on_hover_text(tl!("A line, or markers or hash marks along it")) + .labelled_by(input_label.id); + ui.end_row(); + } ui.label(tl!("Color")); ui.horizontal(|ui| { let mut cc = c32(c); @@ -4698,19 +4820,20 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } }); ui.end_row(); - ui.label(tl!("Offset")); + ui.label(if points { tl!("Offset down") } else { tl!("Offset") }); let mut off = st.offset; let drag = egui::DragValue::new(&mut off).range(-MAX_STROKE_OFFSET..=MAX_STROKE_OFFSET).speed(0.05).suffix(" pt"); - if ui - .add(drag) - .on_hover_text(tl!("Shifts the stroke sideways: positive to the right of the line's direction, negative to the left")) - .changed() - { + let tip = if points { + tl!("Shifts the mark below the point (above when negative)") + } else { + tl!("Shifts the stroke sideways: positive to the right of the line's direction, negative to the left") + }; + if ui.add(drag).on_hover_text(tip).changed() { set(app, LineStroke { offset: off, ..st }); } ui.end_row(); if let Some(m) = st.markers { - marker_rows(app, ui, i, st, m, &set); + marker_rows(app, ui, i, st, m, points, &set); } else { let input_label = ui.label(tl!("Cap")); egui::ComboBox::from_id_salt(("stroke_cap", i)) diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 8afa182ef..c60ad0cfb 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -44,6 +44,21 @@ draws a circle, so the point still shows. By command: layer.marker={"layer":"Peaks","char":"▲","font":"DejaVu Sans","size":12} ``` +**Rotation** turns each point's marker by the angle in a number field, in degrees clockwise +(**Counterclockwise** for angles as mathematics counts them), as wind arrows or the signs of a +topographic map turn. **Marks** stack several markers at a point, bottom first, in place of the +symbol's: a white disc under a sign, say, so it reads on any background. Each mark has its own +colour, outline, shape, size and angle, and is shifted right (**Offset right**) and down +(**Offset down**); its offset turns with its angle, as QGIS turns it. **With the field** turns a +mark by the rotation field too, or keeps it still. A layer with classes can have marks for one +class of its own, as line strokes can. By command, `layer.markerRotation` and the point layer's +strokes of markers: + +```text +layer.markerRotation={"layer":"Signs","field":"angle","counterclockwise":true} +layer.lineStrokes={"layer":"Signs","strokes":[{"color":"#FFFFFF","markers":{"mark":"circle","size":14}},{"color":"#000000","markers":{"mark":"character","char":"▲","size":10,"offsetAlong":0}}]} +``` + Pick a colour scheme from the pane's swatches, listed in groups: **Perceptually Uniform** (Viridis, Plasma, Inferno, Magma, Cividis, Batlow and Aurora: even steps in lightness, so they read in greyscale and for colour-blind viewers), **Sequential**, **Diverging** (Red–Blue, Vik, Roma, diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 8172f2f7e..7f59f915e 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -42,7 +42,9 @@ Each layer keeps its look: rendering passes as their levels when the renderer draws in symbol levels; marker and hashed lines as strokes of markers or hash marks, at their places (intervals, vertices, the ends, the middle), turned with the line, their markers' offsets and angles kept; font markers as - characters of their font, on points and along lines. + 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. - **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 @@ -66,7 +68,7 @@ near as it can, or left out, and said: the import's notes list, layer by layer, approximated (pattern fills drawn as a tint of their colour, SVG markers as circles, dashed polygon outlines solid, label halos in RustGIS's own colour and width, label offsets and wrapping, values outside a graduated renderer's ranges in its first or last class, file options -such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, data-defined +such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, other data-defined settings, draw effects, label callouts, labels from expressions, contours and other raster renderers RustGIS has no match for, PostGIS, WFS and other sources RustGIS doesn't read, diagrams, joins, virtual fields, a layer's other styles,