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
23 changes: 21 additions & 2 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
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)}] (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)}] (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 @@ -423,7 +423,26 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult {
.ok_or_else(|| format!("stroke {}: level is a whole number from 0 to 255", i + 1))?,
),
};
strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level }.clamped());
let bad_dash = || {
format!(
"stroke {}: dash is a list of 1 to {} lengths in points (dash, gap, …), each 0 to {}, not all 0",
i + 1,
rustgis_map::symbology::MAX_DASH_STEPS,
rustgis_map::symbology::MAX_DASH_STEP
)
};
// A pattern out of range is an error here, so a script learns of it; a project file's is
// brought into range instead (`de_dash`), so a hand-edited file still opens.
let dash = match o.get("dash") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => {
let steps: Vec<f32> =
a.iter().map(|v| v.as_f64().map(|x| x.clamp(-1e6, 1e6) as f32)).collect::<Option<_>>().ok_or_else(bad_dash)?;
Some(rustgis_map::symbology::Dash::new(&steps).ok_or_else(bad_dash)?)
}
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 id = s.layer_id(p)?;
let l = s.map_mut().layer_mut(id).ok_or("no such layer")?;
Expand Down
18 changes: 18 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ fn line_strokes_stack_and_survive_save_and_undo() {
cap: LineCap::Round,
join: LineJoin::Round,
level: None,
dash: None,
},
LineStroke {
color: Some(Rgba::rgb(0xFF, 0x8C, 0)),
Expand All @@ -359,6 +360,7 @@ fn line_strokes_stack_and_survive_save_and_undo() {
cap: LineCap::Butt,
join: LineJoin::Miter,
level: None,
dash: None,
},
LineStroke { offset: -2.0, ..LineStroke::default() },
];
Expand Down Expand Up @@ -405,6 +407,22 @@ fn line_strokes_stack_and_survive_save_and_undo() {
assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1);
}

#[test]
fn a_stroke_is_dashed_by_command() {
let mut s = world();
let stroke = |dash: Value| json!({"layer": "Rivers", "strokes": [{"color": "#000000", "width": 2, "dash": dash}]});
s.run("layer.lineStrokes", &stroke(json!([4, 2.5]))).unwrap();
let dash = |s: &Session| s.map().layer_by_name("Rivers").unwrap().line_strokes[0].dash.map(|d| d.steps().to_vec());
assert_eq!(dash(&s), Some(vec![4.0, 2.5]));
let depth = s.undo_depth();
for bad in [json!([]), json!([0, 0]), json!([-1, 2]), json!([1, "2"]), json!("4 2"), json!([1, 1, 1, 1, 1, 1, 1, 1, 1]), json!([1e9])] {
assert!(s.run("layer.lineStrokes", &stroke(bad.clone())).is_err(), "{bad}");
}
assert_eq!((s.undo_depth(), dash(&s)), (depth, Some(vec![4.0, 2.5])), "a refused pattern changes nothing");
s.run("layer.lineStrokes", &stroke(Value::Null)).unwrap();
assert_eq!(dash(&s), None, "null: solid");
}

#[test]
fn a_class_stacks_strokes_of_its_own() {
use rustgis_map::symbology::ClassRef;
Expand Down
34 changes: 34 additions & 0 deletions crates/map/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,40 @@ mod tests {
);
}

#[test]
fn dash_patterns_are_kept_and_hostile_ones_brought_into_range() {
use symbology::{Dash, MAX_DASH_STEP, MAX_DASH_STEPS};
// Built: in range, not all 0, at most MAX_DASH_STEPS.
assert_eq!(Dash::new(&[4.0, 2.0]).map(|d| d.steps().to_vec()), Some(vec![4.0, 2.0]));
assert_eq!(Dash::new(&[3.0]).map(|d| (d.even_steps(), d.period())), Some((vec![3.0, 3.0], 6.0)), "odd: twice over");
for bad in [&[][..], &[0.0, 0.0], &[-1.0, 2.0], &[f32::NAN, 1.0], &[MAX_DASH_STEP + 1.0], &[1.0; MAX_DASH_STEPS + 1]] {
assert!(Dash::new(bad).is_none(), "{bad:?}");
}
// Saved as a plain list and read back.
let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![]));
l.line_strokes = vec![symbology::LineStroke { dash: Dash::new(&[4.0, 2.0, 1.0, 2.0]), ..Default::default() }];
let text = serde_json::to_string(&l).unwrap();
assert!(text.contains(r#""dash":[4.0,2.0,1.0,2.0]"#), "{text}");
assert_eq!(serde_json::from_str::<Layer>(&text).unwrap().line_strokes, l.line_strokes);
// A crafted project's: steps clamped, too many cut, and one without length solid.
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].dash;
v["line_strokes"][0]["dash"] = serde_json::json!([1e30, -5, 2]);
assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 0.0, 2.0]));
// Too large for an f32 (infinite once read): the longest step, as a large one is.
v["line_strokes"][0]["dash"] = serde_json::json!([1e40, 2]);
assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 2.0]));
v["line_strokes"][0]["dash"] = serde_json::json!(vec![1.0; 5000]);
assert_eq!(read(&v).map(|d| d.steps().len()), Some(MAX_DASH_STEPS));
for solid in [serde_json::json!([0, 0]), serde_json::json!([]), serde_json::json!([-3])] {
v["line_strokes"][0]["dash"] = solid.clone();
assert_eq!(read(&v), None, "{solid}");
// Read as a stroke alone too, so no empty pattern is ever made.
let st = serde_json::from_value::<symbology::LineStroke>(v["line_strokes"][0].clone()).unwrap();
assert_eq!(st.dash, None, "{solid}");
}
}

#[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
65 changes: 64 additions & 1 deletion crates/map/src/symbology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,66 @@ pub const MAX_STROKES: usize = 16;
/// The widest stroke and the farthest offset, in points.
pub const MAX_STROKE_WIDTH: f32 = 50.0;
pub const MAX_STROKE_OFFSET: f32 = 100.0;
/// The most steps a dash pattern has, and the longest step, in points.
pub const MAX_DASH_STEPS: usize = 8;
pub const MAX_DASH_STEP: f32 = 500.0;

/// A stroke's dash pattern: lengths in points, dash and gap in turn, starting with a dash (as
/// SVG's `stroke-dasharray`: an odd number of steps is repeated to make it even). Written to a
/// project as that list, `[4, 2]`. Built only through [`Dash::new`], so its steps are always
/// in range and add up to more than nothing.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(into = "Vec<f32>")]
pub struct Dash {
steps: [f32; MAX_DASH_STEPS],
len: u8,
}

impl Dash {
/// The pattern of `steps` (at most [`MAX_DASH_STEPS`], each 0 to [`MAX_DASH_STEP`]), or
/// `None` for none: no steps, a step that isn't a number from 0 to [`MAX_DASH_STEP`], too
Comment thread
giswqs marked this conversation as resolved.
/// many, or all of them 0.
pub fn new(steps: &[f32]) -> Option<Dash> {
let ok = |v: &f32| v.is_finite() && (0.0..=MAX_DASH_STEP).contains(v);
if steps.is_empty() || steps.len() > MAX_DASH_STEPS || !steps.iter().all(ok) || steps.iter().sum::<f32>() <= 0.0 {
return None;
}
let mut d = Dash { steps: [0.0; MAX_DASH_STEPS], len: u8::try_from(steps.len()).ok()? };
d.steps.get_mut(..steps.len())?.copy_from_slice(steps);
Some(d)
}

/// Its steps, as given.
pub fn steps(&self) -> &[f32] {
self.steps.get(..usize::from(self.len)).unwrap_or(&[])
}

/// Its steps made even (an odd number twice over), as a renderer takes them.
pub fn even_steps(&self) -> Vec<f32> {
let s = self.steps();
if s.len().is_multiple_of(2) { s.to_vec() } else { s.iter().chain(s).copied().collect() }
}

/// One round of the pattern, in points.
pub fn period(&self) -> f32 {
self.even_steps().iter().sum()
}
}

/// Reads a stroke's pattern from a project, brought into range: at most [`MAX_DASH_STEPS`] steps,
/// each clamped to 0 to [`MAX_DASH_STEP`] (one too large for a number, infinite, the longest);
/// one left without length is `None`, solid (a project file is hostile input).
fn de_dash<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Dash>, D::Error> {
let Some(v) = Option::<Vec<f32>>::deserialize(d)? else { return Ok(None) };
let steps: Vec<f32> = v.iter().take(MAX_DASH_STEPS).map(|x| if x.is_nan() { 0.0 } else { x.clamp(0.0, MAX_DASH_STEP) }).collect();
Ok(Dash::new(&steps))
}

impl From<Dash> for Vec<f32> {
fn from(d: Dash) -> Vec<f32> {
d.steps().to_vec()
}
}

/// One stroke of a line layer's stacked symbol (`Layer::line_strokes`). Widths and offsets are
/// in points, so they keep their size on screen at every scale; the geometry is left as it is.
Expand All @@ -188,11 +248,14 @@ pub struct LineStroke {
/// join under the cores. `None` is its place in the stack, the bottom one 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<u8>,
/// Dashed: the pattern along the line, in points; `None` is a solid line.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "de_dash")]
pub dash: Option<Dash>,
}

impl Default for LineStroke {
fn default() -> Self {
LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None }
LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None, dash: None }
}
}

Expand Down
Loading
Loading