From 9aeb9fb75207e615e34a2dffcca778a77088280c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:58:00 +0200 Subject: [PATCH] Cap stroke width in the outliner so huge weights don't panic Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/huge_stroke_tests.rs | 28 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/pathops/src/lib.rs | 18 ++++++++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/huge_stroke_tests.rs diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 00000000..7efabb48 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a68a78d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..d4071ca1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -258,11 +258,16 @@ pub(crate) fn kjoin(j: StrokeJoin) -> Join { } } -/// Plain centred stroke of `path` at `width`. +/// The widest stroke outlined: past this the rasteriser's fixed-point maths overflows and panics, +/// and nothing visible changes (a stroke this wide covers any canvas already). +pub const MAX_STROKE_WIDTH: f64 = 1e6; + +/// Plain centred stroke of `path` at `width` (capped at [`MAX_STROKE_WIDTH`]). pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath { if !(width.is_finite() && width > 0.0 && finite(path)) { return BezPath::new(); } + let width = width.min(MAX_STROKE_WIDTH); let mut style = kurbo::Stroke::new(width) .with_join(kjoin(props.join)) .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) @@ -284,10 +289,17 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) - /// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke /// path): points with a cap or join of their own draw them there. pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath { - let w = props.weight; - if !(w.is_finite() && w > 0.0) { + if !(props.weight.is_finite() && props.weight > 0.0) { return BezPath::new(); } + let capped; + let props = if props.weight > MAX_STROKE_WIDTH { + capped = StrokeProps { weight: MAX_STROKE_WIDTH, ..props.clone() }; + &capped + } else { + props + }; + let w = props.weight; let styled = net.and_then(|n| points::Styled::new(n, props)); // Centred at `width` (twice the weight for Inside/Outside, which clip half of it away). let centred = |width: f64| match (&styled, &props.brush, props.profile()) {