This commit is contained in:
Elena Torro 2026-09-07 18:09:04 +02:00
parent cd98a88c4d
commit 869237d4dd
18 changed files with 206 additions and 8 deletions

View File

@ -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.

View File

@ -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).

View File

@ -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)))

View File

@ -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"]

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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)]

View File

@ -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

View File

@ -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)}])])]))

View File

@ -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;

View File

@ -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"

View File

@ -708,6 +708,18 @@ impl Shape {
Ok(())
}
pub fn set_last_stroke_join(
&mut self,
join: Option<StrokeLineJoin>,
miter_limit: Option<f32>,
) -> 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;

View File

@ -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<StrokeLineJoin>,
pub miter_limit: Option<f32>,
}
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 => {

View File

@ -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<Vec<Stroke>> {
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<Vec<Stroke>> {
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);
}

View File

@ -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<RawStrokeCap> 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<u8> for RawStrokeJoin {
fn from(value: u8) -> Self {
unsafe { std::mem::transmute(value) }
}
}
impl TryFrom<RawStrokeJoin> for StrokeLineJoin {
type Error = ();
fn try_from(value: RawStrokeJoin) -> Result<Self, Self::Error> {
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| {