diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 7ef1c0c6df..eb2786f3b4 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -180,6 +180,7 @@ :available-viewer-wasm :stroke-path :stroke-per-side + :stroke-join ;; Exporter only: uses render-wasm for export instead of browser ;; renderer. diff --git a/common/src/app/common/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs index 816c154cab..e14b02f12f 100644 --- a/common/src/app/common/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -69,6 +69,8 @@ style (-> stroke :stroke-style sr/translate-stroke-style) cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + join (-> stroke :stroke-join sr/translate-stroke-join) + miter (or (:stroke-miter-limit stroke) -1) ;; Sentinel -1 means "unset" on the Rust side — keeps the ;; FFI signature flat while letting the renderer fall back ;; to its default dash pattern when no override is stored. @@ -82,6 +84,9 @@ :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap) (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap)) + (when (pos? join) + (h/call wasm/internal-module "_set_shape_stroke_join" join miter)) + ;; Per-side widths (rects/frames). Sides falling back to the ;; uniform width; skipped when all sides end up equal, so the ;; renderer keeps the uniform path (with dash/dot support). diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs index eb2e42f57c..5151c72b5d 100644 --- a/common/src/app/common/render_wasm/api/upload.cljs +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -41,7 +41,7 @@ (def ^:const SECTION-STROKES 0x400) ;; Stroke header before RawFillData (must match upload_batch.rs). -(def ^:const STROKE-HEADER-U8-SIZE 36) +(def ^:const STROKE-HEADER-U8-SIZE 40) (def ^:const STROKE-ALIGN-CENTER 0) (def ^:const STROKE-ALIGN-INNER 1) (def ^:const STROKE-ALIGN-OUTER 2) @@ -246,6 +246,8 @@ STROKE-ALIGN-CENTER) cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + join (-> stroke :stroke-join sr/translate-stroke-join) + miter (or (:stroke-miter-limit stroke) -1) dash (or (:stroke-dash stroke) -1) gap (or (:stroke-gap stroke) -1) per-side? (boolean (:stroke-per-side stroke)) @@ -262,10 +264,12 @@ (buf/write-f32 dview (+ offset 8) dash) (buf/write-f32 dview (+ offset 12) gap) (buf/write-u8 dview (+ offset 16) (if has-sides? 1 0)) + (buf/write-u8 dview (+ offset 17) (d/nilv join 0)) (buf/write-f32 dview (+ offset 20) top) (buf/write-f32 dview (+ offset 24) right) (buf/write-f32 dview (+ offset 28) bottom) (buf/write-f32 dview (+ offset 32) left) + (buf/write-f32 dview (+ offset 36) miter) (write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke) (+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj index 0be29aacf9..a5eda16f98 100644 --- a/common/src/app/common/render_wasm/enums.clj +++ b/common/src/app/common/render_wasm/enums.clj @@ -28,6 +28,7 @@ ["guide-kind" "RawGuideKind"] ["stroke-style" "RawStrokeStyle"] ["stroke-cap" "RawStrokeCap"] + ["stroke-join" "RawStrokeJoin"] ["shape-type" "RawShapeType"] ["constraint-h" "RawConstraintH"] ["constraint-v" "RawConstraintV"] diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs index 02fe6632d2..19044f510a 100644 --- a/common/src/app/common/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -97,6 +97,12 @@ default (unchecked-get values "none")] (d/nilv (unchecked-get values (d/name stroke-cap)) default))) +(defn translate-stroke-join + [stroke-join] + (let [values (unchecked-get wasm/serializers "stroke-join") + default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name stroke-join)) default))) + (defn serialize-path-attrs [svg-attrs] (reduce-kv diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 6a617c21cc..232fd82a06 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -66,6 +66,7 @@ (def stroke-caps-line stroke/stroke-caps-line) (def stroke-caps-marker stroke/stroke-caps-marker) (def stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil)) +(def stroke-joins (conj stroke/stroke-joins nil)) (def shape-types #{:frame @@ -153,6 +154,9 @@ [::sm/one-of stroke-caps]] [:stroke-cap-end {:optional true} [::sm/one-of stroke-caps]] + [:stroke-join {:optional true} + [::sm/one-of stroke-joins]] + [:stroke-miter-limit {:optional true} ::sm/safe-number] [:stroke-color {:optional true} clr/schema:hex-color] [:stroke-color-gradient {:optional true} clr/schema:gradient] [:stroke-image {:optional true} clr/schema:image] diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index 792b56244a..a1280e97ad 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -14,6 +14,7 @@ (def stroke-caps-line #{:round :square}) (def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker}) +(def stroke-joins #{:miter :round :bevel}) (def default-stroke {:stroke-alignment :inner diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index d4e7f587f8..f7f491a897 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -339,7 +339,9 @@ :stroke-width-bottom :stroke-width-left :stroke-cap-start - :stroke-cap-end]) + :stroke-cap-end + :stroke-join + :stroke-miter-limit]) ;; FIXME: this function initializes an empty stroke, maybe we can move ;; it to common.types diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 20defd27f0..4e235c901d 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -118,6 +118,12 @@ (when (not= style :svg) (obj/set! attrs "strokeDasharray" (calculate-dasharray style width dash gap))) + (when-let [join (:stroke-join data)] + (obj/set! attrs "strokeLinejoin" (name join)) + (when-let [limit (:stroke-miter-limit data)] + (when (= join :miter) + (obj/set! attrs "strokeMiterlimit" limit)))) + ;; For simple line caps we use svg stroke-line-cap attribute. This ;; only works if all caps are the same and we are not using the tricks ;; for inner or outer strokes. diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index f32b042779..0949e66588 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -339,7 +339,9 @@ :penpot:stroke-gap ((d/nilf str) (:stroke-gap stroke)) :penpot:stroke-alignment (d/name (:stroke-alignment stroke)) :penpot:stroke-cap-start (d/name (:stroke-cap-start stroke)) - :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}]))])))) + :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke)) + :penpot:stroke-join (d/name (:stroke-join stroke)) + :penpot:stroke-miter-limit ((d/nilf str) (:stroke-miter-limit stroke))}]))])))) (defn- export-interactions-data [{:keys [interactions]}] (when-let [interactions (seq interactions)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index 1bd5bd2270..23932f767e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -39,7 +39,9 @@ :stroke-opacity :stroke-color-gradient :stroke-cap-start - :stroke-cap-end]) + :stroke-cap-end + :stroke-join + :stroke-miter-limit]) (defn- stroke-menu-check-props "A stroke-menu specific memoize check function that only checks if @@ -197,6 +199,20 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (dc/change-stroke-attrs ids {:stroke-gap value} index)))) + join-available? + (contains? cf/flags :stroke-join) + + on-stroke-join-change + (fn [index value] + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-join value} index))) + + on-stroke-miter-limit-change + (fn [index value] + (when (number? value) + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-miter-limit value} index)))) + on-stroke-cap-start-change (fn [index value] (st/emit! (udw/trigger-bounding-box-cloaking ids)) @@ -297,6 +313,9 @@ :on-stroke-cap-start-change on-stroke-cap-start-change :on-stroke-cap-end-change on-stroke-cap-end-change :on-stroke-cap-switch on-stroke-cap-switch + :join-available join-available? + :on-stroke-join-change on-stroke-join-change + :on-stroke-miter-limit-change on-stroke-miter-limit-change :on-toggle-visibility on-toggle-visibility :disable-drag disable-drag :on-focus on-focus diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 1f84979d27..df4964fc5a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -47,6 +47,9 @@ on-stroke-cap-start-change on-stroke-cap-end-change on-stroke-cap-switch + join-available + on-stroke-join-change + on-stroke-miter-limit-change on-toggle-visibility disable-drag on-focus @@ -278,6 +281,27 @@ {:id "round" :value :round :label (tr "workspace.options.stroke-cap.round") :icon i/stroke-rounded} {:id "square" :value :square :label (tr "workspace.options.stroke-cap.square") :icon i/stroke-squared}] + stroke-join (or (:stroke-join stroke) :miter) + + stroke-join-options + (mf/with-memo [stroke-join] + (d/concat-vec + (when (= :multiple stroke-join) + [{:value :multiple :label "--"}]) + [{:id "miter" :value :miter :label (tr "workspace.options.stroke-join.miter")} + {:id "round" :value :round :label (tr "workspace.options.stroke-join.round")} + {:id "bevel" :value :bevel :label (tr "workspace.options.stroke-join.bevel")}])) + + on-join-change + (mf/use-fn + (mf/deps index on-stroke-join-change) + #(on-stroke-join-change index (keyword %))) + + on-miter-limit-change + (mf/use-fn + (mf/deps index on-stroke-miter-limit-change) + #(on-stroke-miter-limit-change index %)) + on-cap-switch (mf/use-fn (mf/deps index on-stroke-cap-switch) @@ -502,4 +526,24 @@ :options stroke-caps-options :data-testid "stroke.cap-end" :disabled hidden? - :on-change on-caps-end-change}]])])) + :on-change on-caps-end-change}]]) + + ;; Stroke Join (miter / round / bevel) + (when join-available + [:div {:class (stl/css :stroke-join-options) + :data-testid "stroke.join-options"} + [:& select {:default-value stroke-join + :options stroke-join-options + :class (stl/css :stroke-join-select) + :disabled hidden? + :on-change on-join-change}] + (when (= :miter stroke-join) + [:> numeric-input-wrapper* {:on-change on-miter-limit-change + :text-icon "MITER" + :min 1 + :on-focus on-focus + :on-blur on-blur + :attr :stroke-miter-limit + :class (stl/css :numeric-input-wrapper) + :property (tr "workspace.options.stroke-join.miter-limit") + :value (:stroke-miter-limit stroke)}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss index 923c0aad7b..b9b425b54b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss @@ -83,6 +83,16 @@ column-gap: var(--sp-xs); } +.stroke-join-options { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: var(--sp-xs); +} + +.stroke-join-select { + grid-column: span 1; +} + .stroke-options-tokens { @include sidebar.option-grid-structure; diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 128f102aa1..026b8ef3dd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8693,6 +8693,22 @@ msgstr "Round" msgid "workspace.options.stroke-cap.square" msgstr "Square" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-join.miter" +msgstr "Miter" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-join.round" +msgstr "Round" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-join.bevel" +msgstr "Bevel" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-join.miter-limit" +msgstr "Miter limit" + #: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs #, unused msgid "workspace.options.stroke-cap.square-marker" diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 2db654e799..c774c6bed3 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -708,6 +708,18 @@ impl Shape { Ok(()) } + pub fn set_last_stroke_join( + &mut self, + join: Option, + miter_limit: Option, + ) -> Result<(), String> { + let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?; + stroke.join = join; + stroke.miter_limit = miter_limit; + self.invalidate_extrect(); + Ok(()) + } + pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> { let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?; stroke.fill = f; diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 2e80abafe0..752490ea33 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -56,6 +56,9 @@ pub struct Stroke { // Per-side widths [top, right, bottom, left] for rects and frames. // `None` means the uniform `width` applies to all sides. pub widths: Option<[f32; 4]>, + // Corner join for the stroke. `None` keeps the SVG-attr/Skia default. + pub join: Option, + pub miter_limit: Option, } impl Stroke { @@ -179,6 +182,8 @@ impl Stroke { dash, gap, widths: None, + join: None, + miter_limit: None, } } @@ -200,6 +205,8 @@ impl Stroke { dash, gap, widths: None, + join: None, + miter_limit: None, } } @@ -221,6 +228,8 @@ impl Stroke { dash, gap, widths: None, + join: None, + miter_limit: None, } } @@ -354,6 +363,18 @@ impl Stroke { } } + if let Some(join) = self.join { + paint.set_stroke_join(match join { + StrokeLineJoin::Miter => skia::paint::Join::Miter, + StrokeLineJoin::Round => skia::paint::Join::Round, + StrokeLineJoin::Bevel => skia::paint::Join::Bevel, + }); + } + + if let Some(limit) = self.miter_limit { + paint.set_stroke_miter(limit); + } + if self.style != StrokeStyle::Solid { let path_effect = match self.style { StrokeStyle::Dotted => { diff --git a/render-wasm/src/wasm/shapes/upload_batch.rs b/render-wasm/src/wasm/shapes/upload_batch.rs index 99051842a4..0092a30a4d 100644 --- a/render-wasm/src/wasm/shapes/upload_batch.rs +++ b/render-wasm/src/wasm/shapes/upload_batch.rs @@ -38,7 +38,7 @@ use crate::wasm::layouts::{ use crate::wasm::paths::bools::RawBoolType; use crate::wasm::shadows::RawShadowStyle; use crate::wasm::shapes::base_props::{apply_base_props, RawBasePropsData, RAW_BASE_PROPS_SIZE}; -use crate::wasm::strokes::{RawStrokeCap, RawStrokeStyle}; +use crate::wasm::strokes::{RawStrokeCap, RawStrokeJoin, RawStrokeStyle}; use crate::wasm::text::RawGrowType; use crate::with_current_shape_mut; use crate::with_state; @@ -402,11 +402,13 @@ fn parse_strokes(cur: &mut Cursor<'_>) -> Result> { let dash = cur.f32()?; let gap = cur.f32()?; let has_sides = cur.u8()? != 0; - let _pad = cur.take(3)?; + let join = cur.u8()?; + let _pad = cur.take(2)?; let top = cur.f32()?; let right = cur.f32()?; let bottom = cur.f32()?; let left = cur.f32()?; + let miter_limit = cur.f32()?; let fill_bytes = cur.take(RAW_FILL_DATA_SIZE)?; let fill = RawFillData::try_from(fill_bytes) .map_err(|e| Error::CriticalError(format!("upload_batch stroke fill: {e}")))?; @@ -446,6 +448,8 @@ fn parse_strokes(cur: &mut Cursor<'_>) -> Result> { if has_sides { stroke.widths = Some([top, right, bottom, left]); } + stroke.join = RawStrokeJoin::from(join).try_into().ok(); + stroke.miter_limit = decode_optional_f32(miter_limit); stroke.fill = fill.into(); strokes.push(stroke); } diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 02f9bf1d6e..4e1ba1dcdd 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::mem; -use crate::shapes::{self, StrokeCap, StrokeStyle}; +use crate::shapes::{self, StrokeCap, StrokeLineJoin, StrokeStyle}; use crate::utils::decode_optional_f32; use crate::with_current_shape_mut; @@ -69,6 +69,35 @@ impl TryFrom for StrokeCap { } } +#[derive(Debug, Clone, Copy, PartialEq, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawStrokeJoin { + None = 0, + Miter = 1, + Round = 2, + Bevel = 3, +} + +impl From for RawStrokeJoin { + fn from(value: u8) -> Self { + unsafe { std::mem::transmute(value) } + } +} + +impl TryFrom for StrokeLineJoin { + type Error = (); + + fn try_from(value: RawStrokeJoin) -> Result { + match value { + RawStrokeJoin::None => Err(()), + RawStrokeJoin::Miter => Ok(StrokeLineJoin::Miter), + RawStrokeJoin::Round => Ok(StrokeLineJoin::Round), + RawStrokeJoin::Bevel => Ok(StrokeLineJoin::Bevel), + } + } +} + #[no_mangle] pub extern "C" fn add_shape_center_stroke( width: f32, @@ -144,6 +173,17 @@ pub extern "C" fn add_shape_outer_stroke( }); } +#[no_mangle] +pub extern "C" fn set_shape_stroke_join(join: u8, miter_limit: f32) { + let join = RawStrokeJoin::from(join); + + with_current_shape_mut!(state, |shape: &mut Shape| { + shape + .set_last_stroke_join(join.try_into().ok(), decode_optional_f32(miter_limit)) + .expect("could not set stroke join"); + }); +} + #[no_mangle] pub extern "C" fn set_shape_stroke_sides(top: f32, right: f32, bottom: f32, left: f32) { with_current_shape_mut!(state, |shape: &mut Shape| {