Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,38 @@ pub fn commands() -> Vec<C> {
.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' <all other 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' <all other 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)?;
Expand Down Expand Up @@ -392,6 +420,22 @@ fn need_polygons(s: &Session) -> Option<&'static str> {
}

/// A line layer is active.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /// A line layer is active. doc comment now sits on need_lines_or_points, and need_lines below has lost its doc. Move it back and document the new functions:

Suggested change
/// A line layer is active.
/// A line or point layer is active.

and add /// A point layer is active. above need_points, and /// A line layer is active. above need_lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right: the doc comment landed on need_lines_or_points, and need_lines lost its own. This PR merged before the fix could go in. I'll leave it for a small follow-up.

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,
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 55 additions & 1 deletion crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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();
Expand Down
46 changes: 44 additions & 2 deletions crates/map/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -144,14 +144,20 @@ pub struct Layer {
#[serde(default)]
pub extrusion: Option<effects::Extrusion>,
/// 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<symbology::LineStroke>,
/// 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.
#[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<symbology::MarkerRotation>,
/// 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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

marker_turn is called per row in the render hot loop, and MarkerRotation::turn does ds.field_index(&self.field) (a name lookup) for every point. On a layer with hundreds of thousands of points, resolving the index once per layer render would avoid this. Confidence: medium. It only matters for large layers, and the layer is already forced onto the CPU path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. field_index is a scan over the layer's fields (a few dozen at most) per point, on a path that then builds and fills a path for each mark. That fill costs far more than the scan. Resolving the index once per render would mean passing it through marker_turn's callers, which the cost doesn't justify.

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.
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion crates/map/src/qgis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -104,6 +104,8 @@ pub struct Style {
/// offset one).
pub line_strokes: Option<Vec<LineStroke>>,
pub stroke_levels: Option<bool>,
/// Point markers turned by a field (`Some(None)`: not turned).
pub marker_rotation: Option<Option<MarkerRotation>>,
pub reference_scale: Option<f64>,
pub labels: Option<Labeling>,
/// 0 opaque to 100 invisible.
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading