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
68 changes: 65 additions & 3 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::path::Path;

use rustgis_data::{GeometryType, photo};
use rustgis_map::symbology::{ClassRef, ClassStyle, LABEL_SIZE, MAX_STROKES};
use rustgis_map::{ClassMethod, LineCap, LineJoin, LineStroke, Marker, Ramp, Renderer, Rgba};
use rustgis_map::{ClassMethod, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, Marker, Placements, Ramp, Renderer, Rgba};
use serde_json::{Value, json};

use super::{b, f, need_feature_layer, need_layer, ps, uint};
Expand Down Expand Up @@ -177,7 +177,7 @@ pub fn commands() -> Vec<C> {
C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes)
.when(need_lines)
.params(
r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' <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"|"hash" (a mark across the line), "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>)}"##,
),
C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| {
let on = b(p, "on").ok_or("on is true or false")?;
Expand Down Expand Up @@ -451,7 +451,13 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult {
}
Some(_) => return Err(bad_dash()),
};
strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level, dash }.clamped());
let markers = match o.get("markers") {
None | Some(Value::Null) => None,
Some(m) => Some(line_markers(m).map_err(|e| format!("stroke {}: markers: {e}", i + 1))?),
};
strokes.push(
LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level, dash, markers }.clamped(),
);
}
let id = s.layer_id(p)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
Expand All @@ -466,6 +472,62 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult {
Ok(json!({"strokes": n}))
}

/// A stroke's `markers`: what it draws along the line instead of a line, and where.
fn line_markers(v: &Value) -> Result<LineMarkers, String> {
let o = v.as_object().ok_or("an object, such as {\"mark\": \"circle\", \"interval\": 12}")?;
let num = |k: &str| -> Result<Option<f32>, String> {
match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(-1e6, 1e6) as f32)).ok_or_else(|| format!("{k} is a number"))
}
}
};
let d = LineMarkers::default();
let mark = match o.get("mark") {
None | Some(Value::Null) => d.mark,
Some(Value::String(m)) if m.trim().eq_ignore_ascii_case("hash") => LineMark::Hash,
Some(Value::String(m)) => LineMark::Marker(
Marker::ALL.into_iter().find(|s| s.label().eq_ignore_ascii_case(m.trim())).ok_or_else(|| format!("unknown mark '{m}'"))?,
),
Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\" or \"hash\"".into()),
};
let bad_at = || format!("at is a placement or a list of them: {}", Placements::all_codes());
let at = match o.get("at") {
None | Some(Value::Null) => d.at,
Some(Value::String(c)) => Placements::from_code(c).ok_or_else(bad_at)?,
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 16 => a
.iter()
.map(|c| c.as_str().and_then(Placements::from_code).ok_or_else(bad_at))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.fold(Placements::NONE, Placements::with),
Some(_) => return Err(bad_at()),
};
let rotate = match o.get("rotate") {
None | Some(Value::Null) => d.rotate,
Some(Value::Bool(b)) => *b,
Some(_) => return Err("rotate is true or false".into()),
};
let outline = match o.get("outline") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgba::from_hex(c).ok_or("outline must be #RRGGBB or #RRGGBBAA")?),
Some(_) => return Err("outline must be #RRGGBB or #RRGGBBAA".into()),
};
Ok(LineMarkers {
mark,
size: num("size")?.unwrap_or(d.size),
at,
interval: num("interval")?.unwrap_or(d.interval),
offset_along: num("offsetAlong")?.unwrap_or(d.offset_along),
rotate,
average_angle: num("averageAngle")?.unwrap_or(d.average_angle),
angle: num("angle")?.unwrap_or(d.angle),
outline,
}
.clamped())
}

pub(crate) fn s_req<'a>(p: &'a Value, k: &str) -> Result<&'a str, String> {
ps(p, k).ok_or_else(|| format!("{k} is required"))
}
Expand Down
55 changes: 55 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ fn line_strokes_stack_and_survive_save_and_undo() {
join: LineJoin::Round,
level: None,
dash: None,
markers: None,
},
LineStroke {
color: Some(Rgba::rgb(0xFF, 0x8C, 0)),
Expand All @@ -369,6 +370,7 @@ fn line_strokes_stack_and_survive_save_and_undo() {
join: LineJoin::Miter,
level: None,
dash: None,
markers: None,
},
LineStroke { offset: -2.0, ..LineStroke::default() },
];
Expand Down Expand Up @@ -415,6 +417,59 @@ fn line_strokes_stack_and_survive_save_and_undo() {
assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1);
}

#[test]
fn line_strokes_take_markers_and_hash_marks_along_the_line() {
use rustgis_map::{LineMark, LineMarkers, Marker, Placements, Rgba};
let mut s = world();
// A river: its line, triangles at both ends turned with it, and ticks across it.
let strokes = json!([
{"color": "#1E5AA8", "width": 2},
{"color": "#1E5AA8", "markers": {"mark": "Triangle", "size": 8, "at": ["first", "last"], "outline": "#FFFFFF"}, "width": 1},
{"color": "#1E5AA8", "width": 1, "markers": {"mark": "hash", "size": 6, "interval": 10, "angle": 30, "rotate": false}}
]);
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap();
let st = s.map().layer_by_name("Rivers").unwrap().line_strokes.clone();
assert_eq!(st[0].markers, None);
let ends = LineMarkers {
mark: LineMark::Marker(Marker::Triangle),
size: 8.0,
at: Placements::FIRST.with(Placements::LAST),
outline: Some(Rgba::rgb(255, 255, 255)),
..LineMarkers::default()
};
assert_eq!(st[1].markers, Some(ends));
let ticks = LineMarkers { mark: LineMark::Hash, size: 6.0, interval: 10.0, angle: 30.0, rotate: false, ..LineMarkers::default() };
assert_eq!(st[2].markers, Some(ticks));
// A single place is a list of one; out-of-range numbers are brought into range.
let one = json!([{"markers": {"at": "middle", "size": 1e9, "interval": 0, "angle": 450}}]);
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": one})).unwrap();
let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap();
assert_eq!((m.at, m.size, m.interval, m.angle), (Placements::MIDDLE, 100.0, 0.5, 90.0));
// Drawn in an export.
let dir = std::env::temp_dir().join(format!("rustgis-markers-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).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();
// Junk is refused, saying where, and changes nothing.
let depth = s.undo_depth();
for (bad, says) in [
(json!({"markers": 5}), "stroke 1: markers: an object"),
(json!({"markers": {"mark": "hexagon"}}), "unknown mark 'hexagon'"),
(json!({"markers": {"mark": 3}}), "mark is"),
(json!({"markers": {"at": "sideways"}}), "at is a placement"),
(json!({"markers": {"at": []}}), "at is a placement"),
(json!({"markers": {"at": ["first", 2]}}), "at is a placement"),
(json!({"markers": {"rotate": "yes"}}), "rotate is true or false"),
(json!({"markers": {"size": "big"}}), "size is a number"),
(json!({"markers": {"outline": "white"}}), "outline must be"),
] {
let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [bad]})).unwrap_err();
assert!(err.contains(says), "{bad}: {err}");
}
assert_eq!(s.undo_depth(), depth);
}

#[test]
fn a_stroke_is_dashed_by_command() {
let mut s = world();
Expand Down
53 changes: 52 additions & 1 deletion crates/map/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ pub use color::{Ramp, RampGroup, Rgba};
pub use globe::{Globe, Projection};
pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch};
pub use symbology::{
ClassMethod, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineStroke, Marker, Renderer, Symbol,
ClassMethod, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, LineStroke,
Marker, Placements, Renderer, Symbol,
};
pub use tiles::{TileKind, TileLayer};

Expand Down Expand Up @@ -1783,6 +1784,56 @@ mod tests {
}
}

#[test]
fn markers_along_lines_are_kept_and_hostile_ones_brought_into_range() {
use symbology::{LineMark, LineMarkers, MAX_LINE_MARKER_SIZE, MAX_MARKER_INTERVAL, MIN_MARKER_INTERVAL, Placements};
let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![]));
let m = LineMarkers {
mark: LineMark::Marker(Marker::Triangle),
size: 6.0,
at: Placements::FIRST.with(Placements::LAST),
interval: 20.0,
offset_along: -2.0,
rotate: false,
average_angle: 15.0,
angle: 45.0,
outline: Some(Rgba::rgb(0, 0, 0)),
};
l.line_strokes = vec![
symbology::LineStroke { markers: Some(m), ..Default::default() },
symbology::LineStroke { markers: Some(LineMarkers { mark: LineMark::Hash, ..LineMarkers::default() }), ..Default::default() },
];
// Saved with the places as codes, and read back as they were.
let text = serde_json::to_string(&l).unwrap();
assert!(text.contains(r#""at":["first","last"]"#) && text.contains(r#""mark":"hash""#), "{text}");
assert_eq!(serde_json::from_str::<Layer>(&text).unwrap().line_strokes, l.line_strokes);
// A stroke without markers is written as before.
let plain = serde_json::to_string(&symbology::LineStroke::default()).unwrap();
assert!(!plain.contains("markers"), "{plain}");

// A crafted project's: sizes and intervals clamped, angles within a turn, places it doesn't
// know left out (none at all: every interval).
let mut v: serde_json::Value = serde_json::from_str(&text).unwrap();
let read = |v: &serde_json::Value| serde_json::from_value::<Layer>(v.clone()).unwrap().line_strokes[0].markers.unwrap();
let mk = &mut v["line_strokes"][0]["markers"];
mk["size"] = serde_json::json!(1e30);
mk["interval"] = serde_json::json!(0);
mk["offset_along"] = serde_json::json!(-1e40);
mk["average_angle"] = serde_json::json!(-3);
mk["angle"] = serde_json::json!(-90);
mk["at"] = serde_json::json!(["last", "sideways"]);
let got = read(&v);
assert_eq!((got.size, got.interval, got.offset_along), (MAX_LINE_MARKER_SIZE, MIN_MARKER_INTERVAL, -MAX_MARKER_INTERVAL));
assert_eq!((got.average_angle, got.angle, got.at), (0.0, 270.0, Placements::LAST));
v["line_strokes"][0]["markers"]["at"] = serde_json::json!(["sideways"]);
assert_eq!(read(&v).at, Placements::INTERVAL);
v["line_strokes"][0]["markers"]["at"] = serde_json::json!(vec!["first"; 5000]);
assert_eq!(read(&v).at, Placements::FIRST);
// Only what's given: the rest is the default.
v["line_strokes"][0]["markers"] = serde_json::json!({});
assert_eq!(read(&v), LineMarkers::default());
}

#[test]
fn a_reference_scale_scales_sizes_with_the_map_and_hostile_ones_are_dropped() {
let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![]));
Expand Down
16 changes: 13 additions & 3 deletions crates/map/src/qgis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,18 @@ pub(crate) fn color(s: &str) -> Option<Rgba> {
/// no fixed size on screen: they are taken at the layer's reference scale when it has one
/// (`reference`, RustGIS's), else as metres at 1:25,000, and noted.
fn points(v: f64, unit: Option<&str>, reference: Option<f64>, layer: &str, report: &mut Report) -> f32 {
let pt = match unit.unwrap_or("MM") {
let pt = in_points(v, unit, reference, layer, report);
if pt.is_finite() { pt.clamp(0.0, 1000.0) as f32 } else { 0.0 }
}

/// [`points`] for a distance that may be negative, such as an offset to the left.
fn signed_points(v: f64, unit: Option<&str>, reference: Option<f64>, layer: &str, report: &mut Report) -> f32 {
let pt = in_points(v, unit, reference, layer, report);
if pt.is_finite() { pt.clamp(-1000.0, 1000.0) as f32 } else { 0.0 }
}

fn in_points(v: f64, unit: Option<&str>, reference: Option<f64>, layer: &str, report: &mut Report) -> f64 {
match unit.unwrap_or("MM") {
"MM" => v * 96.0 / 25.4,
"Point" => v * 96.0 / 72.0,
"Pixel" => v,
Expand All @@ -586,6 +597,5 @@ fn points(v: f64, unit: Option<&str>, reference: Option<f64>, layer: &str, repor
report.approximated(layer, format!("sizes in {other}, taken as millimetres"));
v * 96.0 / 25.4
}
};
if pt.is_finite() { pt.clamp(0.0, 1000.0) as f32 } else { 0.0 }
}
}
Loading
Loading