diff --git a/Cargo.lock b/Cargo.lock
index 0d64d0821..76e1a2f72 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2267,6 +2267,7 @@ dependencies = [
name = "jian-ops-schema"
version = "0.0.1"
dependencies = [
+ "base64",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -2277,6 +2278,7 @@ dependencies = [
name = "jian-scene"
version = "0.0.1"
dependencies = [
+ "jian-ops-schema",
"jian-widgets",
]
diff --git a/crates/op-editor-core/src/command_node.rs b/crates/op-editor-core/src/command_node.rs
index cf6f12975..0e53c05a9 100644
--- a/crates/op-editor-core/src/command_node.rs
+++ b/crates/op-editor-core/src/command_node.rs
@@ -193,6 +193,7 @@ pub fn build_leaf_node(
d: None,
anchors: Some(Vec::new()),
closed: None,
+ fill_rule: None,
width: Some(w),
height: Some(h),
fill: None,
diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs
index 81835adda..10dfe14f8 100644
--- a/crates/op-editor-core/src/host_support.rs
+++ b/crates/op-editor-core/src/host_support.rs
@@ -391,6 +391,7 @@ impl EditorState {
d: Some(d.to_string()),
anchors: None,
closed: None,
+ fill_rule: None,
width: Some(SizingBehavior::Number(SIZE)),
height: Some(SizingBehavior::Number(SIZE)),
fill: Some(vec![jian_ops_schema::style::PenFill::Solid(
@@ -589,6 +590,7 @@ impl EditorState {
d: Some(d),
anchors: None,
closed: Some(true),
+ fill_rule: None,
width: Some(SizingBehavior::Number(width)),
height: Some(SizingBehavior::Number(height)),
fill: style.fill,
diff --git a/crates/op-editor-core/src/pen_node_ext.rs b/crates/op-editor-core/src/pen_node_ext.rs
index a31e1a7c1..60b7a789c 100644
--- a/crates/op-editor-core/src/pen_node_ext.rs
+++ b/crates/op-editor-core/src/pen_node_ext.rs
@@ -394,6 +394,7 @@ pub fn make_path(id: String, name: &str, first: (f64, f64)) -> PenNode {
point_type: None,
}]),
closed: None,
+ fill_rule: None,
width: None,
height: None,
fill: None,
diff --git a/crates/op-editor-core/src/svg_import.rs b/crates/op-editor-core/src/svg_import.rs
index 7031e7475..c28531caa 100644
--- a/crates/op-editor-core/src/svg_import.rs
+++ b/crates/op-editor-core/src/svg_import.rs
@@ -925,6 +925,7 @@ fn path_node_from_pen_anchors(
d: None,
anchors: Some(anchors),
closed: Some(closed),
+ fill_rule: None,
width: Some(SizingBehavior::Number((max_x - min_x).max(0.0))),
height: Some(SizingBehavior::Number((max_y - min_y).max(0.0))),
fill: None,
@@ -963,6 +964,7 @@ fn path_node_from_svg_d(
d: Some(local_d.clone()),
anchors: None,
closed: Some(local_d.contains('Z') || local_d.contains('z')),
+ fill_rule: None,
width: Some(SizingBehavior::Number(bounds.w)),
height: Some(SizingBehavior::Number(bounds.h)),
fill: fill_hex.map(|hex| vec![solid_fill(&hex)]),
diff --git a/crates/op-editor-ui/src/svg_export.rs b/crates/op-editor-ui/src/svg_export.rs
index 7061c5bd7..33f042ce0 100644
--- a/crates/op-editor-ui/src/svg_export.rs
+++ b/crates/op-editor-ui/src/svg_export.rs
@@ -620,9 +620,14 @@ fn emit_path(out: &mut String, n: &SceneNode) {
} else {
""
};
+ let fill_rule = if n.even_odd_fill {
+ r#" fill-rule="evenodd""#
+ } else {
+ ""
+ };
let _ = write!(
out,
- r#""#,
+ r#""#,
xml_escape(&n.id),
xml_escape(&d),
attrs
diff --git a/crates/op-editor-ui/src/svg_export_tests.rs b/crates/op-editor-ui/src/svg_export_tests.rs
index ccf317cc6..9aa7440d6 100644
--- a/crates/op-editor-ui/src/svg_export_tests.rs
+++ b/crates/op-editor-ui/src/svg_export_tests.rs
@@ -139,6 +139,18 @@ fn svg_path_export_matches_canvas_tight_bounds_fitting() {
);
}
+#[test]
+fn even_odd_path_export_emits_fill_rule() {
+ let mut path = SceneNode::leaf("ring", NodeKind::Path);
+ path.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
+ path.fill = Some(Color::BLACK);
+ path.svg_path = Some("M0 0H100V100H0Z M25 25H75V75H25Z".into());
+ path.even_odd_fill = true;
+
+ let body = serialize_node_svg(&scene_with(vec![path]), "ring").expect("svg");
+ assert!(body.contains(r#"fill-rule="evenodd""#), "{body}");
+}
+
#[test]
fn fitted_svg_path_keeps_stroke_width_in_document_space() {
let mut path = SceneNode::leaf("stroked-scaled-path", NodeKind::Path);
diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs
index 8890a4a1e..b6fff8f7f 100644
--- a/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs
+++ b/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs
@@ -115,6 +115,14 @@ pub fn paint_fill_then_stroke(
) {
let r = node.corner_radius * zoom;
let use_round = r > 0.5;
+ let per_corner = node.corner_radii.map(|radii| radii.map(|v| v * zoom)).filter(
+ |radii| {
+ radii
+ .iter()
+ .skip(1)
+ .any(|radius| (*radius - radii[0]).abs() > f32::EPSILON)
+ },
+ );
// A native SkSL shader fill wins over everything (gradient / solid)
// when present. Gradients in turn win over solid `fill`. The scene
// builder leaves the first stop's / fallback colour in `fill` for
@@ -126,7 +134,12 @@ pub fn paint_fill_then_stroke(
paint_gradient_rect(cx, gradient, world_rect, if use_round { r } else { 0.0 });
} else if let Some(fill) = fill {
if use_round {
- cx.backend.fill_round_rect(world_rect, r, fill);
+ if let Some(radii) = per_corner {
+ cx.backend
+ .fill_round_rect_per_corner(world_rect, radii, fill);
+ } else {
+ cx.backend.fill_round_rect(world_rect, r, fill);
+ }
} else {
cx.backend.fill_rect(world_rect, fill);
}
@@ -141,7 +154,16 @@ pub fn paint_fill_then_stroke(
let w = stroke.width * zoom;
let (rect, r) = align_stroke_rect(world_rect, r, w, stroke.align);
if use_round {
- cx.backend.stroke_round_rect(rect, r, stroke.color, w);
+ if let Some(radii) = per_corner {
+ cx.backend.stroke_round_rect_per_corner(
+ rect,
+ align_corner_radii(radii, w, stroke.align),
+ stroke.color,
+ w,
+ );
+ } else {
+ cx.backend.stroke_round_rect(rect, r, stroke.color, w);
+ }
} else {
cx.backend.stroke_rect(rect, stroke.color, w);
}
@@ -149,6 +171,25 @@ pub fn paint_fill_then_stroke(
}
}
+fn align_corner_radii(
+ radii: [f32; 4],
+ width: f32,
+ align: SceneStrokeAlign,
+) -> [f32; 4] {
+ let half = width / 2.0;
+ match align {
+ SceneStrokeAlign::Center => radii,
+ SceneStrokeAlign::Inside => radii.map(|radius| (radius - half).max(0.0)),
+ SceneStrokeAlign::Outside => radii.map(|radius| {
+ if radius > 0.0 {
+ radius + half
+ } else {
+ 0.0
+ }
+ }),
+ }
+}
+
/// Backends stroke centered on the rect path; INSIDE / OUTSIDE
/// alignment shifts the path by half a width so the painted band
/// lands inside / outside the node edge (Figma defaults to INSIDE).
diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs
index 3c27d2684..25c7f998e 100644
--- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs
+++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs
@@ -607,6 +607,34 @@ fn paint_node_inner<'a>(
let is_hovered = options.hovered == Some(node.id.as_str());
let mut hits = PaintNodeHits::for_node(node, options, transforms, parent_hovered);
+ // Background blur filters content already painted behind this
+ // node, clipped to the node silhouette. Keep the backdrop layer
+ // open while the node paints so translucent fills and children
+ // composite over the filtered copy.
+ let background_blur_sigma = node.effects.iter().find_map(|effect| match effect {
+ Effect::BackgroundBlur { radius } if *radius > 0.0 => Some(*radius * 0.5 * zoom),
+ _ => None,
+ });
+ let background_blur_pushed = if let Some(sigma) = background_blur_sigma.filter(|_| {
+ world_rect.size.x > 0.0 && world_rect.size.y > 0.0
+ }) {
+ cx.backend.save();
+ let radius = if node.kind == NodeKind::Ellipse {
+ world_rect.size.x.min(world_rect.size.y) / 2.0
+ } else {
+ node.corner_radius * zoom
+ };
+ if radius > 0.5 {
+ cx.backend.clip_round_rect(world_rect, radius);
+ } else {
+ cx.backend.clip_rect(world_rect);
+ }
+ cx.backend.push_backdrop_blur_layer(sigma);
+ true
+ } else {
+ false
+ };
+
// Gaussian layer blur (Figma "Layer blur"): capture the node's
// whole rendered output — shadows, fill, stroke, children — into
// an offscreen layer and blur it on the matching `restore`. The
@@ -801,6 +829,10 @@ fn paint_node_inner<'a>(
if blur_sigma.is_some() {
cx.backend.restore();
}
+ if background_blur_pushed {
+ cx.backend.restore();
+ cx.backend.restore();
+ }
if transformed {
cx.backend.restore();
}
@@ -866,6 +898,10 @@ fn paint_node_inner<'a>(
if blur_sigma.is_some() {
cx.backend.restore();
}
+ if background_blur_pushed {
+ cx.backend.restore();
+ cx.backend.restore();
+ }
if transformed {
cx.backend.restore();
}
@@ -925,7 +961,14 @@ pub(crate) fn paint_svg_path_node(
let flat: Vec<(f32, crate::Color)> =
stops.iter().map(|s| (s.offset, s.color)).collect();
cx.backend
- .fill_svg_path_in_rect_linear_gradient(d, world_rect, &flat, *angle_deg, *opacity);
+ .fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ d,
+ world_rect,
+ &flat,
+ *angle_deg,
+ *opacity,
+ node.even_odd_fill,
+ );
}
Some(SceneGradient::Radial {
cx: gx,
@@ -936,9 +979,17 @@ pub(crate) fn paint_svg_path_node(
}) => {
let flat: Vec<(f32, crate::Color)> =
stops.iter().map(|s| (s.offset, s.color)).collect();
- cx.backend.fill_svg_path_in_rect_radial_gradient(
- d, world_rect, &flat, *gx, *cy, *radius, *opacity,
- );
+ cx.backend
+ .fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ d,
+ world_rect,
+ &flat,
+ *gx,
+ *cy,
+ *radius,
+ *opacity,
+ node.even_odd_fill,
+ );
}
// Mesh gradients are a round-rect-only feature in v1 — there's
// no per-vertex SVG-path fill path. Degrade to the node's
@@ -946,7 +997,12 @@ pub(crate) fn paint_svg_path_node(
// mesh-filled path still paints.
Some(SceneGradient::Mesh { .. }) | None => {
if let Some(fill) = node.fill {
- cx.backend.fill_svg_path_in_rect(d, world_rect, fill);
+ cx.backend.fill_svg_path_in_rect_with_fill_rule(
+ d,
+ world_rect,
+ fill,
+ node.even_odd_fill,
+ );
}
}
}
@@ -958,13 +1014,14 @@ pub(crate) fn paint_svg_path_node(
continue;
};
if s.inner {
- cx.backend.fill_inner_shadow_svg_path(
+ cx.backend.fill_inner_shadow_svg_path_with_fill_rule(
d,
world_rect,
s.offset_x * zoom,
s.offset_y * zoom,
s.blur * zoom,
s.color,
+ node.even_odd_fill,
);
}
}
diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs
index e7f55c84a..1d41a0ef8 100644
--- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs
+++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs
@@ -334,6 +334,7 @@ mod text_tests {
#[derive(Default)]
struct SvgCaptureBackend {
fill_rects: Vec,
+ fill_rules: Vec,
}
impl RenderBackend for SvgCaptureBackend {
@@ -353,6 +354,16 @@ mod text_tests {
fn fill_svg_path_in_rect(&mut self, _: &str, rect: Rect, _: Color) {
self.fill_rects.push(rect);
}
+ fn fill_svg_path_in_rect_with_fill_rule(
+ &mut self,
+ _: &str,
+ rect: Rect,
+ _: Color,
+ even_odd: bool,
+ ) {
+ self.fill_rects.push(rect);
+ self.fill_rules.push(even_odd);
+ }
fn draw_image(&mut self, _: Rect, _: u64, _: &[u8]) {}
fn draw_image_with_mode(&mut self, _: Rect, _: u64, _: &[u8], _: ImageDrawMode) {}
fn resize(&mut self, _: u32, _: u32) {}
@@ -374,6 +385,29 @@ mod text_tests {
paint_svg_path_node(&mut cx, &node, rect, 1.0, "M10 0 L0 -5 L0 5 Z");
assert_eq!(backend.fill_rects, vec![rect]);
+ assert_eq!(backend.fill_rules, vec![false]);
+ }
+
+ #[test]
+ fn svg_path_node_forwards_even_odd_fill_rule() {
+ let mut node = SceneNode::leaf("ring", NodeKind::Path);
+ node.fill = Some(Color::BLACK);
+ node.even_odd_fill = true;
+ let rect = Rect::xywh(10.0, 20.0, 28.0, 28.0);
+ let mut backend = SvgCaptureBackend::default();
+ let mut cx = PaintCx {
+ backend: &mut backend,
+ };
+
+ paint_svg_path_node(
+ &mut cx,
+ &node,
+ rect,
+ 1.0,
+ "M0 0H28V28H0Z M7 7H21V21H7Z",
+ );
+
+ assert_eq!(backend.fill_rules, vec![true]);
}
#[derive(Default)]
@@ -845,3 +879,167 @@ mod stroke_align_tests {
assert_eq!(strokes, vec![(0.0, 0.0, 100.0, 50.0, 4.0)]);
}
}
+
+
+mod per_corner_radius_tests {
+ use crate::layout_scene::{NodeKind, SceneNode, SceneStroke, SceneStrokeAlign};
+ use crate::widgets::canvas_viewport_overlay::paint_fill_then_stroke;
+ use crate::widgets::PaintCx;
+ use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
+
+ #[derive(Default)]
+ struct RadiusCaptureBackend {
+ uniform_fills: usize,
+ per_corner_fills: Vec<[f32; 4]>,
+ uniform_strokes: usize,
+ per_corner_strokes: Vec<[f32; 4]>,
+ }
+
+ impl RenderBackend for RadiusCaptureBackend {
+ fn begin_frame(&mut self) {}
+ fn end_frame(&mut self) {}
+ fn fill_rect(&mut self, _: Rect, _: Color) {}
+ fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
+ fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
+ fn clip_rect(&mut self, _: Rect) {}
+ fn save(&mut self) {}
+ fn restore(&mut self) {}
+ fn translate(&mut self, _: Point2D) {}
+ fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
+ fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {
+ self.uniform_fills += 1;
+ }
+ fn fill_round_rect_per_corner(&mut self, _: Rect, radii: [f32; 4], _: Color) {
+ self.per_corner_fills.push(radii);
+ }
+ fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {
+ self.uniform_strokes += 1;
+ }
+ fn stroke_round_rect_per_corner(
+ &mut self,
+ _: Rect,
+ radii: [f32; 4],
+ _: Color,
+ _: f32,
+ ) {
+ self.per_corner_strokes.push(radii);
+ }
+ fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
+ fn resize(&mut self, _: u32, _: u32) {}
+ fn dpi_scale(&self) -> f32 {
+ 1.0
+ }
+ }
+
+ fn painted(radii: [f32; 4]) -> RadiusCaptureBackend {
+ let mut node = SceneNode::leaf("r", NodeKind::Rect);
+ node.corner_radius = radii.iter().copied().fold(0.0, f32::max);
+ node.corner_radii = Some(radii);
+ node.fill = Some(Color::BLACK);
+ node.stroke = Some(SceneStroke {
+ color: Color::RED,
+ width: 2.0,
+ sides: None,
+ align: SceneStrokeAlign::Center,
+ });
+ let mut backend = RadiusCaptureBackend::default();
+ paint_fill_then_stroke(
+ &mut PaintCx {
+ backend: &mut backend,
+ },
+ &node,
+ Rect::xywh(0.0, 0.0, 100.0, 50.0),
+ 1.0,
+ node.fill,
+ );
+ backend
+ }
+
+ #[test]
+ fn differing_radii_use_per_corner_backend_calls() {
+ let backend = painted([8.0, 0.0, 8.0, 0.0]);
+ assert_eq!(backend.per_corner_fills, vec![[8.0, 0.0, 8.0, 0.0]]);
+ assert_eq!(backend.per_corner_strokes, vec![[8.0, 0.0, 8.0, 0.0]]);
+ assert_eq!((backend.uniform_fills, backend.uniform_strokes), (0, 0));
+ }
+
+ #[test]
+ fn equal_radii_keep_uniform_backend_calls() {
+ let backend = painted([8.0; 4]);
+ assert!(backend.per_corner_fills.is_empty());
+ assert!(backend.per_corner_strokes.is_empty());
+ assert_eq!((backend.uniform_fills, backend.uniform_strokes), (1, 1));
+ }
+}
+
+
+mod background_blur_tests {
+ use crate::layout_scene::{Effect, NodeKind, SceneNode};
+ use crate::widgets::canvas_viewport_paint::paint_node;
+ use crate::widgets::PaintCx;
+ use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
+
+ #[derive(Default)]
+ struct BackdropCaptureBackend {
+ ops: Vec<&'static str>,
+ }
+
+ impl RenderBackend for BackdropCaptureBackend {
+ fn begin_frame(&mut self) {}
+ fn end_frame(&mut self) {}
+ fn fill_rect(&mut self, _: Rect, _: Color) {
+ self.ops.push("fill");
+ }
+ fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
+ fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
+ fn clip_rect(&mut self, _: Rect) {
+ self.ops.push("clip");
+ }
+ fn clip_round_rect(&mut self, _: Rect, _: f32) {
+ self.ops.push("clip_round");
+ }
+ fn save(&mut self) {
+ self.ops.push("save");
+ }
+ fn restore(&mut self) {
+ self.ops.push("restore");
+ }
+ fn push_backdrop_blur_layer(&mut self, _: f32) {
+ self.ops.push("backdrop");
+ }
+ fn translate(&mut self, _: Point2D) {}
+ fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
+ fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {
+ self.ops.push("fill");
+ }
+ fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
+ fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
+ fn resize(&mut self, _: u32, _: u32) {}
+ fn dpi_scale(&self) -> f32 {
+ 1.0
+ }
+ }
+
+ #[test]
+ fn background_blur_clips_and_filters_before_node_fill() {
+ let mut node = SceneNode::leaf("glass", NodeKind::Rect);
+ node.bounds = Rect::xywh(0.0, 0.0, 100.0, 60.0);
+ node.corner_radius = 8.0;
+ node.fill = Some(Color::BLACK);
+ node.effects = vec![Effect::BackgroundBlur { radius: 12.0 }];
+ let mut backend = BackdropCaptureBackend::default();
+ paint_node(
+ &mut PaintCx {
+ backend: &mut backend,
+ },
+ &node,
+ Point2D::ZERO,
+ 1.0,
+ Rect::xywh(-100.0, -100.0, 1000.0, 1000.0),
+ );
+ assert_eq!(
+ backend.ops,
+ vec!["save", "clip_round", "backdrop", "fill", "restore", "restore"]
+ );
+ }
+}
diff --git a/crates/op-editor-ui/src/widgets/text_input_backend.rs b/crates/op-editor-ui/src/widgets/text_input_backend.rs
index 7a6b1caee..2622a7c61 100644
--- a/crates/op-editor-ui/src/widgets/text_input_backend.rs
+++ b/crates/op-editor-ui/src/widgets/text_input_backend.rs
@@ -58,10 +58,25 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
self.inner.fill_round_rect(rect, radius, color);
}
+ fn fill_round_rect_per_corner(&mut self, rect: Rect, radii: [f32; 4], color: Color) {
+ self.inner.fill_round_rect_per_corner(rect, radii, color);
+ }
+
fn stroke_round_rect(&mut self, rect: Rect, radius: f32, color: Color, width: f32) {
self.inner.stroke_round_rect(rect, radius, color, width);
}
+ fn stroke_round_rect_per_corner(
+ &mut self,
+ rect: Rect,
+ radii: [f32; 4],
+ color: Color,
+ width: f32,
+ ) {
+ self.inner
+ .stroke_round_rect_per_corner(rect, radii, color, width);
+ }
+
fn stroke_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, color: Color, width: f32) {
self.inner.stroke_svg_path(d, top_left, size, color, width);
}
@@ -70,10 +85,34 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
self.inner.fill_svg_path(d, top_left, size, viewbox, color);
}
+ fn fill_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ top_left: Point2D,
+ size: f32,
+ viewbox: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_with_fill_rule(d, top_left, size, viewbox, color, even_odd);
+ }
+
fn fill_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color) {
self.inner.fill_svg_path_in_rect(d, rect, color);
}
+ fn fill_svg_path_in_rect_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_in_rect_with_fill_rule(d, rect, color, even_odd);
+ }
+
fn stroke_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color, width: f32) {
self.inner.stroke_svg_path_in_rect(d, rect, color, width);
}
@@ -90,6 +129,22 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
.fill_svg_path_in_rect_linear_gradient(d, rect, stops, angle_deg, opacity);
}
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ angle_deg: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ d, rect, stops, angle_deg, opacity, even_odd,
+ );
+ }
+
#[allow(clippy::too_many_arguments)]
fn fill_inner_shadow_svg_path(
&mut self,
@@ -104,6 +159,22 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
.fill_inner_shadow_svg_path(d, rect, offset_x, offset_y, blur, color);
}
+ #[allow(clippy::too_many_arguments)]
+ fn fill_inner_shadow_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ offset_x: f32,
+ offset_y: f32,
+ blur: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner.fill_inner_shadow_svg_path_with_fill_rule(
+ d, rect, offset_x, offset_y, blur, color, even_odd,
+ );
+ }
+
#[allow(clippy::too_many_arguments)]
fn fill_svg_path_in_rect_radial_gradient(
&mut self,
@@ -126,6 +197,31 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
);
}
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ cx_frac: f32,
+ cy_frac: f32,
+ radius_frac: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ d,
+ rect,
+ stops,
+ cx_frac,
+ cy_frac,
+ radius_frac,
+ opacity,
+ even_odd,
+ );
+ }
+
fn fill_drop_shadow(&mut self, rect: Rect, radius: f32, blur: f32, color: Color) {
self.inner.fill_drop_shadow(rect, radius, blur, color);
}
@@ -251,6 +347,14 @@ impl RenderBackend for BaselineAdjustingBackend<'_> {
self.inner.save();
}
+ fn push_blur_layer(&mut self, sigma: f32) {
+ self.inner.push_blur_layer(sigma);
+ }
+
+ fn push_backdrop_blur_layer(&mut self, sigma: f32) {
+ self.inner.push_backdrop_blur_layer(sigma);
+ }
+
fn restore(&mut self) {
self.inner.restore();
}
diff --git a/crates/op-figma/examples/probe_vn.rs b/crates/op-figma/examples/probe_vn.rs
index 5ced3404f..9ec34e117 100644
--- a/crates/op-figma/examples/probe_vn.rs
+++ b/crates/op-figma/examples/probe_vn.rs
@@ -6,7 +6,7 @@
//! B (fig2sketch): u32 V; u32 S; u32 R; V*(u32 styleID,f32 x,f32 y);
//! S*(u32 styleID,u32 start,f32 tsx,f32 tsy,u32 end,f32 tex,f32 tey); regions...
//!
-//! Usage: cargo run -p op-figma --example probe_vn -- [--dump-smallest N]
+//! Usage: cargo run -p op-figma --example probe_vn -- [--dump-smallest N] [--dump-regions]
#[path = "../src/container.rs"]
mod container;
@@ -39,6 +39,67 @@ fn keys(v: &FigValue) -> Vec {
}
}
+fn dump_region_records(blob: &[u8], blob_index: usize, name: &str) -> bool {
+ let (Some(vertices), Some(segments), Some(regions)) =
+ (u32_le(blob, 0), u32_le(blob, 4), u32_le(blob, 8))
+ else {
+ return false;
+ };
+ if regions == 0 {
+ return false;
+ }
+ let Some(mut off) = 12usize
+ .checked_add(vertices as usize * 12)
+ .and_then(|value| value.checked_add(segments as usize * 28))
+ else {
+ return false;
+ };
+ let region_start = off;
+ let mut decoded = Vec::new();
+ for region_index in 0..regions as usize {
+ let Some(winding) = u32_le(blob, off) else {
+ return false;
+ };
+ let Some(loop_count) = u32_le(blob, off + 4) else {
+ return false;
+ };
+ off += 8;
+ let mut loops = Vec::new();
+ for _ in 0..loop_count as usize {
+ let Some(index_count) = u32_le(blob, off) else {
+ return false;
+ };
+ off += 4;
+ let mut indices = Vec::with_capacity(index_count as usize);
+ for _ in 0..index_count as usize {
+ let Some(segment_index) = u32_le(blob, off) else {
+ return false;
+ };
+ if segment_index >= segments {
+ return false;
+ }
+ indices.push(segment_index);
+ off += 4;
+ }
+ loops.push(indices);
+ }
+ decoded.push((region_index, winding, loops));
+ }
+ let Some(raw) = blob.get(region_start..off) else {
+ return false;
+ };
+ println!(
+ "REGION SAMPLE blobIdx={blob_index} node={name:?} V={vertices} S={segments} R={regions} regionOffset={region_start} parsedEnd={off} blobLen={}",
+ blob.len()
+ );
+ println!(" raw region bytes: {raw:02x?}");
+ for (region_index, winding, loops) in decoded {
+ println!(" region[{region_index}] winding={winding} loops={loops:?}");
+ }
+ println!(" exact consumption: {}", off == blob.len());
+ true
+}
+
fn main() {
let args: Vec = std::env::args().skip(1).collect();
let dump_smallest = args
@@ -46,6 +107,7 @@ fn main() {
.find(|pair| pair[0] == "--dump-smallest")
.and_then(|pair| pair[1].parse::().ok())
.unwrap_or(0);
+ let dump_regions = args.iter().any(|arg| arg == "--dump-regions");
let path = args
.iter()
.find(|arg| !arg.starts_with("--") && arg.parse::().is_err())
@@ -59,6 +121,7 @@ fn main() {
let mut total = 0usize;
let mut printed = 0usize;
let mut failing_blobs: Vec<(usize, usize, String, Vec)> = Vec::new();
+ let mut region_samples = 0usize;
for nc in &decoded.node_changes {
let ty = nc.get_str("type").unwrap_or("");
@@ -75,6 +138,24 @@ fn main() {
continue;
};
+ if dump_regions
+ && region_samples < 5
+ && dump_region_records(
+ blob,
+ idx as usize,
+ nc.get_str("name").unwrap_or(""),
+ )
+ {
+ let fill_rules: Vec<&str> = nc
+ .get_array("fillGeometry")
+ .unwrap_or(&[])
+ .iter()
+ .filter_map(|geometry| geometry.get_str("windingRule"))
+ .collect();
+ println!(" fillGeometry windingRule values: {fill_rules:?}");
+ region_samples += 1;
+ }
+
// Only look at nodes the current pipeline FAILS on (no geometry decode).
let ok = decode_figma_vector_path(nc, &decoded.blobs)
.map(|d| !d.is_empty())
diff --git a/crates/op-figma/src/converters.rs b/crates/op-figma/src/converters.rs
index f35858f13..6c20d8380 100644
--- a/crates/op-figma/src/converters.rs
+++ b/crates/op-figma/src/converters.rs
@@ -19,7 +19,9 @@ use crate::node_build::{
};
use crate::text_mapper::map_figma_text_props;
use crate::tree::{guid_to_string, TreeNode};
-use crate::vector_decoder::{compute_svg_path_bounds, decode_figma_vector_path};
+use crate::vector_decoder::{
+ compute_svg_path_bounds, decode_figma_vector_path, DecodedVectorPath,
+};
use jian_ops_schema::node::base::NumberOrExpression;
use jian_ops_schema::node::container::ContainerProps;
use jian_ops_schema::node::PenNode;
@@ -542,7 +544,8 @@ fn convert_vector(
return build_icon_path_node(figma, id, parent_stack_mode, ctx, icon);
}
- let path_d = decode_figma_vector_path(figma, &ctx.blobs).unwrap_or_default();
+ let DecodedVectorPath { d: path_d, fill_rule } =
+ decode_figma_vector_path(figma, &ctx.blobs).unwrap_or_default();
if !path_d.is_empty() {
let mut base = common_props(figma, id);
@@ -596,6 +599,7 @@ fn convert_vector(
base,
Some(path_d),
None,
+ fill_rule,
width,
height,
map_figma_fills(figma.get_array("strokePaints")),
@@ -607,6 +611,7 @@ fn convert_vector(
base,
Some(path_d),
None,
+ fill_rule,
width,
height,
map_figma_fills(figma.get_array("fillPaints")),
@@ -624,6 +629,7 @@ fn convert_vector(
common_props(figma, id),
None,
None,
+ None,
resolve_width(figma, parent_stack_mode, ctx),
resolve_height(figma, parent_stack_mode, ctx),
None,
@@ -719,6 +725,7 @@ fn build_icon_path_node(
common_props(figma, id),
Some(icon.d),
icon.icon_id,
+ None,
icon_w,
icon_h,
fill,
diff --git a/crates/op-figma/src/converters/tests.rs b/crates/op-figma/src/converters/tests.rs
index 84560e6a4..967f71d23 100644
--- a/crates/op-figma/src/converters/tests.rs
+++ b/crates/op-figma/src/converters/tests.rs
@@ -162,6 +162,43 @@ fn degenerate_boolean_without_geometry_imports_as_invisible_path() {
clear_icon_lookup();
}
+#[test]
+fn odd_fill_geometry_sets_evenodd_path_fill_rule() {
+ let _g = LOOKUP_GUARD.lock().unwrap();
+ set_icon_lookup(|_| None);
+
+ let mut tree = vector_node("ring");
+ let FigValue::Object(pairs) = &mut tree.figma else {
+ unreachable!();
+ };
+ pairs.push((
+ "fillGeometry".into(),
+ FigValue::Array(vec![obj(vec![
+ ("commandsBlob", FigValue::Uint(0)),
+ ("windingRule", FigValue::Str("ODD".into())),
+ ])]),
+ ));
+ let mut blob = vec![0x01];
+ blob.extend_from_slice(&0.0f32.to_le_bytes());
+ blob.extend_from_slice(&0.0f32.to_le_bytes());
+ blob.push(0x02);
+ blob.extend_from_slice(&10.0f32.to_le_bytes());
+ blob.extend_from_slice(&0.0f32.to_le_bytes());
+ let mut ctx = fresh_ctx();
+ ctx.blobs = vec![BlobOrString::Bytes(blob)];
+
+ let node = convert_vector(&tree, None, &mut ctx);
+ let PenNode::Path(path) = node else {
+ panic!("expected Path");
+ };
+ assert_eq!(
+ path.fill_rule,
+ Some(jian_ops_schema::node::PathFillRule::Evenodd)
+ );
+
+ clear_icon_lookup();
+}
+
#[test]
fn icon_stroke_thickness_scales_for_small_icons() {
let _g = LOOKUP_GUARD.lock().unwrap();
diff --git a/crates/op-figma/src/lib.rs b/crates/op-figma/src/lib.rs
index d66798f0b..6ff635a3d 100644
--- a/crates/op-figma/src/lib.rs
+++ b/crates/op-figma/src/lib.rs
@@ -488,6 +488,7 @@ fn build_node(variant: FigmaNodeVariant, base: PenNodeBase) -> PenNode {
d: None,
anchors: None,
closed: None,
+ fill_rule: None,
width: None,
height: None,
fill: None,
diff --git a/crates/op-figma/src/node_build.rs b/crates/op-figma/src/node_build.rs
index 38f85b40d..cf12d5d04 100644
--- a/crates/op-figma/src/node_build.rs
+++ b/crates/op-figma/src/node_build.rs
@@ -6,8 +6,8 @@
use crate::text_mapper::TextProps;
use jian_ops_schema::node::text::{FontStyleKind as TextFontStyle, FontWeight};
use jian_ops_schema::node::{
- ContainerProps, EllipseNode, FrameNode, GroupNode, LineNode, PathNode, PenNode, PenNodeBase,
- RectangleNode, RefNode, TextNode,
+ ContainerProps, EllipseNode, FrameNode, GroupNode, LineNode, PathFillRule, PathNode, PenNode,
+ PenNodeBase, RectangleNode, RefNode, TextNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use jian_ops_schema::style::{FontStyleKind as StyleFontStyle, PenEffect, PenFill, PenStroke};
@@ -136,6 +136,7 @@ pub fn path_node(
base: PenNodeBase,
d: Option,
icon_id: Option,
+ fill_rule: Option,
width: SizingBehavior,
height: SizingBehavior,
fill: Option>,
@@ -148,6 +149,7 @@ pub fn path_node(
d,
anchors: None,
closed: None,
+ fill_rule,
width: Some(width),
height: Some(height),
fill,
diff --git a/crates/op-figma/src/vector_decoder.rs b/crates/op-figma/src/vector_decoder.rs
index 515780826..7b8775a66 100644
--- a/crates/op-figma/src/vector_decoder.rs
+++ b/crates/op-figma/src/vector_decoder.rs
@@ -5,6 +5,7 @@
use crate::figma_types::BlobOrString;
use crate::kiwi::FigValue;
+use jian_ops_schema::node::PathFillRule;
use std::collections::HashMap;
/// Approximate path bounding box (control points included).
@@ -16,6 +17,28 @@ pub struct PathBounds {
pub max_y: f64,
}
+/// SVG geometry decoded from a Figma vector plus the fill rule that
+/// must be used when painting its subpaths.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct DecodedVectorPath {
+ pub d: String,
+ pub fill_rule: Option,
+}
+
+impl std::ops::Deref for DecodedVectorPath {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.d
+ }
+}
+
+impl std::fmt::Display for DecodedVectorPath {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.d)
+ }
+}
+
/// Format a coordinate: snap near-zero to `0`, else 4-decimal round
/// with trailing zeros stripped.
fn r(n: f64) -> String {
@@ -211,7 +234,10 @@ fn any_visible(paints: Option<&[FigValue]>) -> bool {
/// Decode a Figma vector node into an SVG path string. Prefers
/// geometry blobs (stroke centerline for stroke-only shapes), falling
/// back to the vector-network table.
-pub fn decode_figma_vector_path(node: &FigValue, blobs: &[BlobOrString]) -> Option {
+pub fn decode_figma_vector_path(
+ node: &FigValue,
+ blobs: &[BlobOrString],
+) -> Option {
let has_fills = any_visible(node.get_array("fillPaints"));
let has_strokes = any_visible(node.get_array("strokePaints"));
@@ -243,7 +269,18 @@ pub fn decode_figma_vector_path(node: &FigValue, blobs: &[BlobOrString]) -> Opti
return decode_vector_network_blob(node, blobs);
}
// Geometry coords are already node-local — no scaling.
- Some(path_parts.join(" "))
+ Some(DecodedVectorPath {
+ d: path_parts.join(" "),
+ fill_rule: fill_geometry_rule(node),
+ })
+}
+
+fn fill_geometry_rule(node: &FigValue) -> Option {
+ node.get_array("fillGeometry")?
+ .iter()
+ .filter_map(|geometry| geometry.get_str("windingRule"))
+ .any(|rule| rule.eq_ignore_ascii_case("ODD"))
+ .then_some(PathFillRule::Evenodd)
}
struct VnSegment {
@@ -256,7 +293,10 @@ struct VnSegment {
/// Decode the vertex/segment vector-network blob — the fallback when
/// no geometry blob is present. Coordinates are scaled by
/// `nodeSize / normalizedSize`; tangents are start/end-relative.
-pub fn decode_vector_network_blob(node: &FigValue, blobs: &[BlobOrString]) -> Option {
+pub fn decode_vector_network_blob(
+ node: &FigValue,
+ blobs: &[BlobOrString],
+) -> Option {
let vector_data = node.get("vectorData")?;
let blob_idx = vector_data.get_f64("vectorNetworkBlob")? as usize;
let BlobOrString::Bytes(blob) = blobs.get(blob_idx)? else {
@@ -362,7 +402,10 @@ pub fn decode_vector_network_blob(node: &FigValue, blobs: &[BlobOrString]) -> Op
if result.is_empty() {
None
} else {
- Some(result)
+ Some(DecodedVectorPath {
+ d: result,
+ fill_rule: None,
+ })
}
}
diff --git a/crates/op-figma/src/vector_decoder/tests.rs b/crates/op-figma/src/vector_decoder/tests.rs
index 2b91f3c24..e32f32ea6 100644
--- a/crates/op-figma/src/vector_decoder/tests.rs
+++ b/crates/op-figma/src/vector_decoder/tests.rs
@@ -98,11 +98,54 @@ fn vector_path_from_fill_geometry() {
]);
let blobs = [BlobOrString::Bytes(blob)];
assert_eq!(
- decode_figma_vector_path(&node, &blobs).as_deref(),
+ decode_figma_vector_path(&node, &blobs)
+ .as_ref()
+ .map(|decoded| decoded.d.as_str()),
Some("M0 0 L8 0")
);
}
+#[test]
+fn fill_geometry_winding_rule_is_exposed_on_decode_result() {
+ let mut blob = Vec::new();
+ blob.push(0x01);
+ push_f32(&mut blob, 0.0);
+ push_f32(&mut blob, 0.0);
+ blob.push(0x02);
+ push_f32(&mut blob, 8.0);
+ push_f32(&mut blob, 0.0);
+ let geometry = |winding_rule: Option<&str>| {
+ let mut fields = vec![("commandsBlob", FigValue::Uint(0))];
+ if let Some(rule) = winding_rule {
+ fields.push(("windingRule", FigValue::Str(rule.into())));
+ }
+ obj(fields)
+ };
+
+ for (winding_rule, expected) in [
+ (
+ Some("ODD"),
+ Some(jian_ops_schema::node::PathFillRule::Evenodd),
+ ),
+ (Some("NONZERO"), None),
+ (None, None),
+ ] {
+ let node = obj(vec![
+ (
+ "fillPaints",
+ FigValue::Array(vec![obj(vec![("type", FigValue::Str("SOLID".into()))])]),
+ ),
+ (
+ "fillGeometry",
+ FigValue::Array(vec![geometry(winding_rule)]),
+ ),
+ ]);
+ let decoded = decode_figma_vector_path(&node, &[BlobOrString::Bytes(blob.clone())])
+ .expect("geometry decodes");
+ assert_eq!(decoded.fill_rule, expected);
+ }
+}
+
fn vector_network_node(blob: &[u8]) -> (FigValue, Vec) {
let node = obj(vec![(
"vectorData",
@@ -137,7 +180,7 @@ fn vn_layout_header_and_strides_decode() {
path.contains('L') || path.contains('C'),
"emits the segment: {path}"
);
- assert_eq!(path, "M0 0 L10 0");
+ assert_eq!(path.d, "M0 0 L10 0");
}
#[test]
diff --git a/crates/op-host-native/src/backend/frame_backend.rs b/crates/op-host-native/src/backend/frame_backend.rs
index bdc0c787a..35d09036f 100644
--- a/crates/op-host-native/src/backend/frame_backend.rs
+++ b/crates/op-host-native/src/backend/frame_backend.rs
@@ -53,6 +53,10 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
self.inner.push_blur_layer(self.canvas, sigma);
}
+ fn push_backdrop_blur_layer(&mut self, sigma: f32) {
+ self.inner.push_backdrop_blur_layer(self.canvas, sigma);
+ }
+
fn restore(&mut self) {
self.inner.restore(self.canvas);
}
@@ -77,6 +81,11 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
self.inner.fill_round_rect(self.canvas, rect, radius, color);
}
+ fn fill_round_rect_per_corner(&mut self, rect: Rect, radii: [f32; 4], color: Color) {
+ self.inner
+ .fill_round_rect_per_corner(self.canvas, rect, radii, color);
+ }
+
fn fill_drop_shadow(&mut self, rect: Rect, radius: f32, blur: f32, color: Color) {
self.inner
.fill_drop_shadow(self.canvas, rect, radius, blur, color);
@@ -87,6 +96,17 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
.stroke_round_rect(self.canvas, rect, radius, color, width);
}
+ fn stroke_round_rect_per_corner(
+ &mut self,
+ rect: Rect,
+ radii: [f32; 4],
+ color: Color,
+ width: f32,
+ ) {
+ self.inner
+ .stroke_round_rect_per_corner(self.canvas, rect, radii, color, width);
+ }
+
fn stroke_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, color: Color, width: f32) {
self.inner
.stroke_svg_path(self.canvas, d, top_left, size, color, width);
@@ -97,11 +117,47 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
.fill_svg_path(self.canvas, d, top_left, size, viewbox, color);
}
+ fn fill_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ top_left: Point2D,
+ size: f32,
+ viewbox: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner.fill_svg_path_with_fill_rule(
+ self.canvas,
+ d,
+ top_left,
+ size,
+ viewbox,
+ color,
+ even_odd,
+ );
+ }
+
fn fill_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color) {
self.inner
.fill_svg_path_in_rect(self.canvas, d, rect, color);
}
+ fn fill_svg_path_in_rect_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner.fill_svg_path_in_rect_with_fill_rule(
+ self.canvas,
+ d,
+ rect,
+ color,
+ even_odd,
+ );
+ }
+
fn stroke_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color, width: f32) {
self.inner
.stroke_svg_path_in_rect(self.canvas, d, rect, color, width);
@@ -125,6 +181,27 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
);
}
+ fn fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ angle_deg: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ self.canvas,
+ d,
+ rect,
+ stops,
+ angle_deg,
+ opacity,
+ even_odd,
+ );
+ }
+
#[allow(clippy::too_many_arguments)]
fn fill_svg_path_in_rect_radial_gradient(
&mut self,
@@ -148,6 +225,32 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
);
}
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ cx_frac: f32,
+ cy_frac: f32,
+ radius_frac: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.inner
+ .fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ self.canvas,
+ d,
+ rect,
+ stops,
+ cx_frac,
+ cy_frac,
+ radius_frac,
+ opacity,
+ even_odd,
+ );
+ }
+
#[allow(clippy::too_many_arguments)]
fn fill_inner_shadow_svg_path(
&mut self,
@@ -169,6 +272,29 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
);
}
+ #[allow(clippy::too_many_arguments)]
+ fn fill_inner_shadow_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ offset_x: f32,
+ offset_y: f32,
+ blur: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.inner.fill_inner_shadow_svg_path_with_fill_rule(
+ self.canvas,
+ d,
+ rect,
+ offset_x,
+ offset_y,
+ blur,
+ color,
+ even_odd,
+ );
+ }
+
fn fill_oval(&mut self, bounds: Rect, color: Color) {
self.inner.fill_oval(self.canvas, bounds, color);
}
diff --git a/crates/op-host-native/src/backend/skia.rs b/crates/op-host-native/src/backend/skia.rs
index 589486c63..1255403eb 100644
--- a/crates/op-host-native/src/backend/skia.rs
+++ b/crates/op-host-native/src/backend/skia.rs
@@ -344,6 +344,20 @@ impl NativeBackend {
canvas.draw_round_rect(to_sk_rect(rect), radius, radius, &paint);
}
+ pub fn fill_round_rect_per_corner(
+ &self,
+ canvas: &skia_safe::Canvas,
+ rect: Rect,
+ radii: [f32; 4],
+ color: Color,
+ ) {
+ let vectors = radii.map(|radius| skia_safe::Vector::new(radius, radius));
+ let rrect = skia_safe::RRect::new_rect_radii(to_sk_rect(rect), &vectors);
+ let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
+ paint.set_anti_alias(true);
+ canvas.draw_rrect(rrect, &paint);
+ }
+
/// Drop shadow — a gaussian-blurred filled rounded rectangle.
/// `blur` is the CSS-style blur radius (doc-px × zoom, applied
/// by the caller); skia's mask-filter takes a sigma, and the
@@ -387,6 +401,24 @@ impl NativeBackend {
canvas.draw_round_rect(to_sk_rect(rect), radius, radius, &paint);
}
+
+ pub fn stroke_round_rect_per_corner(
+ &self,
+ canvas: &skia_safe::Canvas,
+ rect: Rect,
+ radii: [f32; 4],
+ color: Color,
+ width: f32,
+ ) {
+ let vectors = radii.map(|radius| skia_safe::Vector::new(radius, radius));
+ let rrect = skia_safe::RRect::new_rect_radii(to_sk_rect(rect), &vectors);
+ let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
+ paint.set_stroke(true);
+ paint.set_stroke_width(width);
+ paint.set_anti_alias(true);
+ canvas.draw_rrect(rrect, &paint);
+ }
+
/// Filled ellipse inscribed in `bounds`. Uses skia's native
/// oval primitive so the curve is properly anti-aliased.
pub fn fill_oval(&self, canvas: &skia_safe::Canvas, bounds: Rect, color: Color) {
@@ -515,6 +547,26 @@ impl NativeBackend {
canvas.save_layer(&rec);
}
+ /// Begin a layer initialized from a Gaussian-filtered copy of the
+ /// already-painted canvas. The caller establishes the node clip
+ /// before this call and balances the layer with `restore`.
+ pub fn push_backdrop_blur_layer(&self, canvas: &skia_safe::Canvas, sigma: f32) {
+ if sigma <= 0.0 {
+ canvas.save();
+ return;
+ }
+ let Some(filter) =
+ skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Clamp, None, None)
+ else {
+ canvas.save();
+ return;
+ };
+ let rec = skia_safe::canvas::SaveLayerRec::default()
+ .backdrop(&filter)
+ .backdrop_tile_mode(skia_safe::TileMode::Clamp);
+ canvas.save_layer(&rec);
+ }
+
/// Pop the most recent save.
pub fn restore(&self, canvas: &skia_safe::Canvas) {
canvas.restore();
diff --git a/crates/op-host-native/src/backend/skia/path.rs b/crates/op-host-native/src/backend/skia/path.rs
index 426a082da..7538b25af 100644
--- a/crates/op-host-native/src/backend/skia/path.rs
+++ b/crates/op-host-native/src/backend/skia/path.rs
@@ -18,6 +18,7 @@ pub(super) struct SvgPathCacheEntry {
pub(super) struct SvgRasterKey {
path_key: u64,
d_len: usize,
+ even_odd: bool,
color_rgba: u32,
size_bits: u32,
viewbox_bits: u32,
@@ -115,6 +116,7 @@ impl NativeBackend {
let key = SvgRasterKey {
path_key,
d_len,
+ even_odd,
color_rgba: color_key(color),
size_bits: size.to_bits(),
viewbox_bits: viewbox.to_bits(),
@@ -228,9 +230,46 @@ impl NativeBackend {
viewbox: f32,
color: Color,
) {
- let Some((path_key, path, even_odd)) = self.cached_svg_path(d) else {
+ self.fill_svg_path_impl(canvas, d, top_left, size, viewbox, color, None);
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn fill_svg_path_with_fill_rule(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ top_left: Point2D,
+ size: f32,
+ viewbox: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.fill_svg_path_impl(
+ canvas,
+ d,
+ top_left,
+ size,
+ viewbox,
+ color,
+ Some(even_odd),
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_impl(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ top_left: Point2D,
+ size: f32,
+ viewbox: f32,
+ color: Color,
+ explicit_even_odd: Option,
+ ) {
+ let Some((path_key, path, inferred_even_odd)) = self.cached_svg_path(d) else {
return;
};
+ let even_odd = explicit_even_odd.unwrap_or(inferred_even_odd);
if let Some(raster) = self.cached_raster_svg_path(SvgRasterRequest {
path_key,
d_len: d.len(),
@@ -271,9 +310,32 @@ impl NativeBackend {
rect: Rect,
color: Color,
) {
- let Some((_, path, even_odd)) = self.cached_svg_path(d) else {
+ self.fill_svg_path_in_rect_impl(canvas, d, rect, color, None);
+ }
+
+ pub fn fill_svg_path_in_rect_with_fill_rule(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.fill_svg_path_in_rect_impl(canvas, d, rect, color, Some(even_odd));
+ }
+
+ fn fill_svg_path_in_rect_impl(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ color: Color,
+ explicit_even_odd: Option,
+ ) {
+ let Some((_, path, inferred_even_odd)) = self.cached_svg_path(d) else {
return;
};
+ let even_odd = explicit_even_odd.unwrap_or(inferred_even_odd);
let mut path = fit_path_to_rect(&path, rect);
if even_odd {
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
@@ -297,11 +359,49 @@ impl NativeBackend {
stops: &[(f32, Color)],
angle_deg: f32,
opacity: f32,
+ ) {
+ self.fill_svg_path_in_rect_linear_gradient_impl(
+ canvas, d, rect, stops, angle_deg, opacity, None,
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ angle_deg: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.fill_svg_path_in_rect_linear_gradient_impl(
+ canvas,
+ d,
+ rect,
+ stops,
+ angle_deg,
+ opacity,
+ Some(even_odd),
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_in_rect_linear_gradient_impl(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ angle_deg: f32,
+ opacity: f32,
+ explicit_even_odd: Option,
) {
if stops.is_empty() {
return;
}
- let Some(path) = self.fitted_svg_path(d, rect) else {
+ let Some(path) = self.fitted_svg_path(d, rect, explicit_even_odd) else {
return;
};
let (start, end) = super::gradient::linear_gradient_endpoints(rect, angle_deg);
@@ -334,11 +434,63 @@ impl NativeBackend {
cy_frac: f32,
radius_frac: f32,
opacity: f32,
+ ) {
+ self.fill_svg_path_in_rect_radial_gradient_impl(
+ canvas,
+ d,
+ rect,
+ stops,
+ cx_frac,
+ cy_frac,
+ radius_frac,
+ opacity,
+ None,
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ cx_frac: f32,
+ cy_frac: f32,
+ radius_frac: f32,
+ opacity: f32,
+ even_odd: bool,
+ ) {
+ self.fill_svg_path_in_rect_radial_gradient_impl(
+ canvas,
+ d,
+ rect,
+ stops,
+ cx_frac,
+ cy_frac,
+ radius_frac,
+ opacity,
+ Some(even_odd),
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn fill_svg_path_in_rect_radial_gradient_impl(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ cx_frac: f32,
+ cy_frac: f32,
+ radius_frac: f32,
+ opacity: f32,
+ explicit_even_odd: Option,
) {
if stops.is_empty() {
return;
}
- let Some(path) = self.fitted_svg_path(d, rect) else {
+ let Some(path) = self.fitted_svg_path(d, rect, explicit_even_odd) else {
return;
};
let center = skia_safe::Point::new(
@@ -364,8 +516,14 @@ impl NativeBackend {
/// Resolve `d` to a path fitted into `rect`, honouring the cached
/// even-odd fill rule. `None` when the path string fails to parse.
- fn fitted_svg_path(&mut self, d: &str, rect: Rect) -> Option {
- let (_, path, even_odd) = self.cached_svg_path(d)?;
+ pub(super) fn fitted_svg_path(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ explicit_even_odd: Option,
+ ) -> Option {
+ let (_, path, inferred_even_odd) = self.cached_svg_path(d)?;
+ let even_odd = explicit_even_odd.unwrap_or(inferred_even_odd);
let mut path = fit_path_to_rect(&path, rect);
if even_odd {
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
@@ -416,7 +574,48 @@ impl NativeBackend {
blur: f32,
color: Color,
) {
- let Some(path) = self.fitted_svg_path(d, rect) else {
+ self.fill_inner_shadow_svg_path_impl(
+ canvas, d, rect, offset_x, offset_y, blur, color, None,
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn fill_inner_shadow_svg_path_with_fill_rule(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ offset_x: f32,
+ offset_y: f32,
+ blur: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
+ self.fill_inner_shadow_svg_path_impl(
+ canvas,
+ d,
+ rect,
+ offset_x,
+ offset_y,
+ blur,
+ color,
+ Some(even_odd),
+ );
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn fill_inner_shadow_svg_path_impl(
+ &mut self,
+ canvas: &skia_safe::Canvas,
+ d: &str,
+ rect: Rect,
+ offset_x: f32,
+ offset_y: f32,
+ blur: f32,
+ color: Color,
+ explicit_even_odd: Option,
+ ) {
+ let Some(path) = self.fitted_svg_path(d, rect, explicit_even_odd) else {
return;
};
// Standard inset-shadow recipe, isolated in its own layer so
diff --git a/crates/op-host-native/src/backend/skia/tests.rs b/crates/op-host-native/src/backend/skia/tests.rs
index df0d76080..f2c92cab5 100644
--- a/crates/op-host-native/src/backend/skia/tests.rs
+++ b/crates/op-host-native/src/backend/skia/tests.rs
@@ -408,6 +408,59 @@ fn svg_path_cache_reuses_parsed_paths() {
assert_eq!(be.svg_path_cache_len(), 1);
}
+#[test]
+fn explicit_even_odd_rule_sets_skia_path_fill_type() {
+ let mut be = NativeBackend::with_dpi(1.0);
+ let d = "M0 0H20V20H0Z M5 5H15V15H5Z";
+ let rect = Rect::xywh(0.0, 0.0, 20.0, 20.0);
+
+ let nonzero = be
+ .fitted_svg_path(d, rect, Some(false))
+ .expect("nonzero path");
+ let evenodd = be
+ .fitted_svg_path(d, rect, Some(true))
+ .expect("even-odd path");
+
+ assert_eq!(nonzero.fill_type(), skia_safe::PathFillType::Winding);
+ assert_eq!(evenodd.fill_type(), skia_safe::PathFillType::EvenOdd);
+}
+
+#[test]
+fn native_backdrop_blur_changes_pixels_inside_clip() {
+ fn render(blur: bool) -> Vec {
+ let backend = NativeBackend::with_dpi(1.0);
+ let mut surface = skia_safe::surfaces::raster_n32_premul((64, 32)).unwrap();
+ let canvas = surface.canvas();
+ canvas.clear(skia_safe::Color::WHITE);
+ for x in (0..64).step_by(8) {
+ let color = if (x / 8) % 2 == 0 {
+ skia_safe::Color::RED
+ } else {
+ skia_safe::Color::BLUE
+ };
+ let color4f = skia_safe::Color4f::from(color);
+ let paint = skia_safe::Paint::new(color4f, None);
+ canvas.draw_rect(skia_safe::Rect::from_xywh(x as f32, 0.0, 8.0, 32.0), &paint);
+ }
+ if blur {
+ canvas.save();
+ backend.clip_round_rect(canvas, Rect::xywh(8.0, 4.0, 48.0, 24.0), 6.0);
+ backend.push_backdrop_blur_layer(canvas, 4.0);
+ canvas.restore();
+ canvas.restore();
+ }
+ let image = surface.image_snapshot();
+ image
+ .peek_pixels()
+ .expect("raster pixels")
+ .bytes()
+ .expect("pixel bytes")
+ .to_vec()
+ }
+
+ assert_ne!(render(false), render(true));
+}
+
#[test]
fn complex_svg_fill_uses_raster_cache_after_first_paint() {
let mut be = NativeBackend::with_dpi(1.0);
diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs
index 19c6a66f6..1450e95fd 100644
--- a/crates/op-host-web/src/canvaskit.rs
+++ b/crates/op-host-web/src/canvaskit.rs
@@ -54,6 +54,22 @@ extern "C" {
b: f32,
a: f32,
);
+ #[wasm_bindgen(method, js_name = fillRoundRectPerCorner)]
+ fn fill_round_rect_per_corner(
+ this: &OpCk,
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+ top_left: f32,
+ top_right: f32,
+ bottom_right: f32,
+ bottom_left: f32,
+ r: f32,
+ g: f32,
+ b: f32,
+ a: f32,
+ );
#[wasm_bindgen(method, js_name = strokeRoundRect)]
fn stroke_round_rect(
this: &OpCk,
@@ -68,6 +84,23 @@ extern "C" {
a: f32,
sw: f32,
);
+ #[wasm_bindgen(method, js_name = strokeRoundRectPerCorner)]
+ fn stroke_round_rect_per_corner(
+ this: &OpCk,
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+ top_left: f32,
+ top_right: f32,
+ bottom_right: f32,
+ bottom_left: f32,
+ r: f32,
+ g: f32,
+ b: f32,
+ a: f32,
+ sw: f32,
+ );
#[wasm_bindgen(method, js_name = fillOval)]
fn fill_oval(this: &OpCk, x: f32, y: f32, w: f32, h: f32, r: f32, g: f32, b: f32, a: f32);
#[wasm_bindgen(method, js_name = strokeOval)]
@@ -250,6 +283,8 @@ extern "C" {
fn clip_round_rect(this: &OpCk, x: f32, y: f32, w: f32, h: f32, rad: f32);
#[wasm_bindgen(method)]
fn save(this: &OpCk);
+ #[wasm_bindgen(method, js_name = pushBackdropBlurLayer)]
+ fn push_backdrop_blur_layer(this: &OpCk, sigma: f32);
#[wasm_bindgen(method)]
fn restore(this: &OpCk);
#[wasm_bindgen(method)]
@@ -410,6 +445,22 @@ impl RenderBackend for CanvasKitBackend {
color.a,
);
}
+ fn fill_round_rect_per_corner(&mut self, rect: Rect, radii: [f32; 4], color: Color) {
+ self.ck.fill_round_rect_per_corner(
+ rect.origin.x,
+ rect.origin.y,
+ rect.size.x,
+ rect.size.y,
+ radii[0],
+ radii[1],
+ radii[2],
+ radii[3],
+ color.r,
+ color.g,
+ color.b,
+ color.a,
+ );
+ }
fn stroke_round_rect(&mut self, rect: Rect, radius: f32, color: Color, width: f32) {
self.ck.stroke_round_rect(
rect.origin.x,
@@ -424,6 +475,29 @@ impl RenderBackend for CanvasKitBackend {
width,
);
}
+ fn stroke_round_rect_per_corner(
+ &mut self,
+ rect: Rect,
+ radii: [f32; 4],
+ color: Color,
+ width: f32,
+ ) {
+ self.ck.stroke_round_rect_per_corner(
+ rect.origin.x,
+ rect.origin.y,
+ rect.size.x,
+ rect.size.y,
+ radii[0],
+ radii[1],
+ radii[2],
+ radii[3],
+ color.r,
+ color.g,
+ color.b,
+ color.a,
+ width,
+ );
+ }
fn fill_oval(&mut self, bounds: Rect, color: Color) {
self.ck.fill_oval(
bounds.origin.x,
@@ -482,6 +556,17 @@ impl RenderBackend for CanvasKitBackend {
}
fn fill_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, viewbox: f32, color: Color) {
let even_odd = svg_path_even_odd(d);
+ self.fill_svg_path_with_fill_rule(d, top_left, size, viewbox, color, even_odd);
+ }
+ fn fill_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ top_left: Point2D,
+ size: f32,
+ viewbox: f32,
+ color: Color,
+ even_odd: bool,
+ ) {
self.ck.fill_svg_path(
d,
top_left.x,
@@ -496,6 +581,15 @@ impl RenderBackend for CanvasKitBackend {
}
fn fill_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color) {
let even_odd = svg_path_even_odd(d);
+ self.fill_svg_path_in_rect_with_fill_rule(d, rect, color, even_odd);
+ }
+ fn fill_svg_path_in_rect_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ color: Color,
+ even_odd: bool,
+ ) {
self.ck.fill_svg_path_in_rect(
d,
rect.origin.x,
@@ -530,6 +624,24 @@ impl RenderBackend for CanvasKitBackend {
stops: &[(f32, Color)],
angle_deg: f32,
opacity: f32,
+ ) {
+ self.fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ d,
+ rect,
+ stops,
+ angle_deg,
+ opacity,
+ svg_path_even_odd(d),
+ );
+ }
+ fn fill_svg_path_in_rect_linear_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ angle_deg: f32,
+ opacity: f32,
+ even_odd: bool,
) {
if stops.is_empty() {
return;
@@ -541,7 +653,7 @@ impl RenderBackend for CanvasKitBackend {
rect.origin.y,
rect.size.x,
rect.size.y,
- svg_path_even_odd(d),
+ even_odd,
&flat,
angle_deg,
opacity,
@@ -556,6 +668,28 @@ impl RenderBackend for CanvasKitBackend {
cy_frac: f32,
radius_frac: f32,
opacity: f32,
+ ) {
+ self.fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ d,
+ rect,
+ stops,
+ cx_frac,
+ cy_frac,
+ radius_frac,
+ opacity,
+ svg_path_even_odd(d),
+ );
+ }
+ fn fill_svg_path_in_rect_radial_gradient_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ stops: &[(f32, Color)],
+ cx_frac: f32,
+ cy_frac: f32,
+ radius_frac: f32,
+ opacity: f32,
+ even_odd: bool,
) {
if stops.is_empty() {
return;
@@ -567,7 +701,7 @@ impl RenderBackend for CanvasKitBackend {
rect.origin.y,
rect.size.x,
rect.size.y,
- svg_path_even_odd(d),
+ even_odd,
&flat,
cx_frac,
cy_frac,
@@ -583,6 +717,26 @@ impl RenderBackend for CanvasKitBackend {
offset_y: f32,
blur: f32,
color: Color,
+ ) {
+ self.fill_inner_shadow_svg_path_with_fill_rule(
+ d,
+ rect,
+ offset_x,
+ offset_y,
+ blur,
+ color,
+ svg_path_even_odd(d),
+ );
+ }
+ fn fill_inner_shadow_svg_path_with_fill_rule(
+ &mut self,
+ d: &str,
+ rect: Rect,
+ offset_x: f32,
+ offset_y: f32,
+ blur: f32,
+ color: Color,
+ even_odd: bool,
) {
self.ck.fill_inner_shadow_svg_path(
d,
@@ -590,7 +744,7 @@ impl RenderBackend for CanvasKitBackend {
rect.origin.y,
rect.size.x,
rect.size.y,
- svg_path_even_odd(d),
+ even_odd,
offset_x,
offset_y,
blur,
@@ -673,6 +827,9 @@ impl RenderBackend for CanvasKitBackend {
fn save(&mut self) {
self.ck.save();
}
+ fn push_backdrop_blur_layer(&mut self, sigma: f32) {
+ self.ck.push_backdrop_blur_layer(sigma);
+ }
fn restore(&mut self) {
self.ck.restore();
}
diff --git a/crates/op-host-web/src/op_ck_bridge.js b/crates/op-host-web/src/op_ck_bridge.js
index 1ed253a91..61991855d 100644
--- a/crates/op-host-web/src/op_ck_bridge.js
+++ b/crates/op-host-web/src/op_ck_bridge.js
@@ -582,7 +582,15 @@ export async function opCkInit(canvasId) {
fillRect(x, y, w, h, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawRect(CK.LTRBRect(x, y, x + w, y + h), p); },
strokeRect(x, y, w, h, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawRect(CK.LTRBRect(x, y, x + w, y + h), p); },
fillRoundRect(x, y, w, h, rad, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), p); },
+ fillRoundRectPerCorner(x, y, w, h, tl, tr, br, bl, r, g, b, a) {
+ const rr = Float32Array.of(x, y, x + w, y + h, tl, tl, tr, tr, br, br, bl, bl);
+ const p = fillPaint(r, g, b, a); canvas.drawRRect(rr, p);
+ },
strokeRoundRect(x, y, w, h, rad, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), p); },
+ strokeRoundRectPerCorner(x, y, w, h, tl, tr, br, bl, r, g, b, a, sw) {
+ const rr = Float32Array.of(x, y, x + w, y + h, tl, tl, tr, tr, br, br, bl, bl);
+ const p = strokePaint(r, g, b, a, sw); canvas.drawRRect(rr, p);
+ },
fillOval(x, y, w, h, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawOval(CK.LTRBRect(x, y, x + w, y + h), p); },
strokeOval(x, y, w, h, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawOval(CK.LTRBRect(x, y, x + w, y + h), p); },
strokeLine(x1, y1, x2, y2, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawLine(x1, y1, x2, y2, p); },
@@ -819,6 +827,19 @@ export async function opCkInit(canvasId) {
clipRect(x, y, w, h) { canvas.clipRect(CK.LTRBRect(x, y, x + w, y + h), CK.ClipOp.Intersect, true); },
clipRoundRect(x, y, w, h, rad) { canvas.clipRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), CK.ClipOp.Intersect, true); },
save() { canvas.save(); },
+ pushBackdropBlurLayer(sigma) {
+ if (!(sigma > 0) || !CK.ImageFilter || !CK.ImageFilter.MakeBlur) {
+ canvas.save();
+ return;
+ }
+ const filter = CK.ImageFilter.MakeBlur(sigma, sigma, CK.TileMode.Clamp, null);
+ if (!filter) {
+ canvas.save();
+ return;
+ }
+ canvas.saveLayer(null, null, filter, 0, CK.TileMode.Clamp);
+ if (filter.delete) filter.delete();
+ },
restore() { canvas.restore(); },
translate(x, y) { canvas.translate(x, y); },
scale(sx, sy) { canvas.scale(sx, sy); },
diff --git a/crates/op-orchestrator/src/validation_fixes_b3.rs b/crates/op-orchestrator/src/validation_fixes_b3.rs
index e03335887..6ec453b20 100644
--- a/crates/op-orchestrator/src/validation_fixes_b3.rs
+++ b/crates/op-orchestrator/src/validation_fixes_b3.rs
@@ -430,6 +430,7 @@ fn build_path_node(
d: None, // TODO: icon resolver
anchors: None,
closed: None,
+ fill_rule: None,
width,
height,
fill: Some(vec![]), // stroke-style: empty fill
diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs
index ff4d43935..774f09b16 100644
--- a/crates/op-pen-loader/src/adapter.rs
+++ b/crates/op-pen-loader/src/adapter.rs
@@ -558,9 +558,10 @@ fn node_to_payload_with_text_context(
// Carry canonical drop-shadow effects across — without this a
// `.op` authored with shadows lost them on import (codex
// stop-gate). Gaussian layer blur is carried via `layer_blur`;
- // background (backdrop) blur is still skipped.
+ // backdrop blur is carried separately via `background_blur`.
p.effects = crate::effects::shadows_from_canonical(node);
p.layer_blur = crate::effects::blur_from_canonical(node);
+ p.background_blur = crate::effects::background_blur_from_canonical(node);
p
}
@@ -856,6 +857,10 @@ fn polygon_to_payload(n: &PolygonNode) -> NodePayload {
fn path_to_payload(n: &PathNode) -> NodePayload {
let mut p = base_payload(&n.base, "path");
p.path_closed = n.closed.unwrap_or(false);
+ p.even_odd_fill = matches!(
+ n.fill_rule,
+ Some(jian_ops_schema::node::PathFillRule::Evenodd)
+ );
p.w = sizing_to_f32(&n.width);
p.h = sizing_to_f32(&n.height);
assign_first_fill(&mut p, n.fill.as_deref());
diff --git a/crates/op-pen-loader/src/adapter_tests.rs b/crates/op-pen-loader/src/adapter_tests.rs
index 21e9b5858..fdbbd8b22 100644
--- a/crates/op-pen-loader/src/adapter_tests.rs
+++ b/crates/op-pen-loader/src/adapter_tests.rs
@@ -168,6 +168,32 @@ fn path_anchors_absolutize_to_canvas_coords() {
assert_eq!(path_node.points[1], [-880.0, 2080.0]);
}
+#[test]
+fn path_payload_carries_even_odd_fill_rule() {
+ let src = r#"{
+ "version":"1.0.0","pages":[{"id":"p","name":"P","children":[
+ {"type":"path","id":"ring","width":100,"height":100,
+ "d":"M0 0H100V100H0Z M25 25H75V75H25Z","fillRule":"evenodd"}
+ ]}],"children":[]
+ }"#;
+ let loaded = load(src);
+ assert!(loaded.payload.pages[0].children[0].even_odd_fill);
+}
+
+#[test]
+fn per_corner_radius_payload_keeps_array_and_legacy_maximum() {
+ let src = r#"{
+ "version":"1.0.0","pages":[{"id":"p","name":"P","children":[
+ {"type":"rectangle","id":"r","width":100,"height":50,
+ "cornerRadius":[8,0,6,2]}
+ ]}],"children":[]
+ }"#;
+ let loaded = load(src);
+ let rect = &loaded.payload.pages[0].children[0];
+ assert_eq!(rect.corner_radii, Some([8.0, 0.0, 6.0, 2.0]));
+ assert_eq!(rect.corner_radius, 8.0);
+}
+
#[test]
fn path_bounds_include_bezier_curve_extrema() {
// Two anchors at y=0 with NEGATIVE-y handles (handle.y=-60).
diff --git a/crates/op-pen-loader/src/effects.rs b/crates/op-pen-loader/src/effects.rs
index 3e9a30fe8..0c0a3a30b 100644
--- a/crates/op-pen-loader/src/effects.rs
+++ b/crates/op-pen-loader/src/effects.rs
@@ -55,6 +55,17 @@ pub fn blur_from_canonical(node: &PenNode) -> Option {
})
}
+/// Extract a canonical node's Gaussian background-blur radius.
+pub fn background_blur_from_canonical(node: &PenNode) -> Option {
+ canonical_node_effects(node)?
+ .iter()
+ .rev()
+ .find_map(|e| match e {
+ PenEffect::BackgroundBlur(b) if b.radius > 0.0 => Some(b.radius),
+ _ => None,
+ })
+}
+
/// Rebuild a node's `effects` from payload form.
pub fn effects_from_payload(payload: Vec) -> Vec {
payload.iter().map(shadow_payload_to_effect).collect()
@@ -75,6 +86,13 @@ pub fn blur_effect_from_payload(layer_blur: Option) -> Option {
.map(|radius| Effect::Blur(jian_scene::layout_scene::BlurEffect { radius }))
}
+/// Append a backdrop blur scene effect for a positive payload radius.
+pub fn background_blur_effect_from_payload(radius: Option) -> Option {
+ radius
+ .filter(|radius| *radius > 0.0)
+ .map(|radius| Effect::BackgroundBlur { radius })
+}
+
fn shadow_payload_to_effect(s: &ShadowPayload) -> Effect {
Effect::DropShadow(DropShadow {
offset_x: s.offset_x,
@@ -283,6 +301,13 @@ mod tests {
serde_json::from_str(&json).expect("ellipse-with-blur JSON parses")
}
+ fn rectangle_with_background_blur(radius: f32) -> PenNode {
+ let json = format!(
+ r#"{{"type":"rectangle","id":"r1","width":20,"height":20,"effects":[{{"type":"background_blur","radius":{radius}}}]}}"#
+ );
+ serde_json::from_str(&json).expect("rectangle-with-background-blur JSON parses")
+ }
+
#[test]
fn canonical_layer_blur_extracts_radius() {
assert_eq!(blur_from_canonical(&ellipse_with_blur(40.0)), Some(40.0));
@@ -303,6 +328,19 @@ mod tests {
assert!(blur_effect_from_payload(Some(0.0)).is_none());
}
+ #[test]
+ fn canonical_background_blur_maps_to_scene_effect() {
+ assert_eq!(
+ background_blur_from_canonical(&rectangle_with_background_blur(12.0)),
+ Some(12.0)
+ );
+ assert_eq!(
+ background_blur_effect_from_payload(Some(12.0)),
+ Some(Effect::BackgroundBlur { radius: 12.0 })
+ );
+ assert!(background_blur_effect_from_payload(Some(0.0)).is_none());
+ }
+
#[test]
fn effects_to_payload_drops_blur_but_keeps_shadow() {
use jian_scene::layout_scene::BlurEffect;
diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs
index 994bb48ab..d15950188 100644
--- a/crates/op-pen-loader/src/layout_scene.rs
+++ b/crates/op-pen-loader/src/layout_scene.rs
@@ -249,13 +249,7 @@ fn node_payload_to_scene(
flip_x: node.flip_x,
flip_y: node.flip_y,
corner_radius: node.corner_radius,
- // Per-corner radius overrides + even-odd path fill are new jian
- // scene fields (responsive-m1a merge); `NodePayload` doesn't carry
- // authored per-corner radii or a path fill-rule yet, so this stays
- // on the pre-merge uniform-corner-radius / nonzero-winding
- // behavior until a follow-up threads them through the loader.
- corner_radii: None,
- even_odd_fill: false,
+ corner_radii: node.corner_radii,
clip_content: node.clip_content,
// Paint-time `$ref` resolution: a registered fill ref wins,
// else the node's own fill. Same precedence as the canvas
@@ -307,6 +301,7 @@ fn node_payload_to_scene(
.collect(),
path_anchors: node.path_anchors.iter().map(anchor_to_scene).collect(),
path_closed: node.path_closed,
+ even_odd_fill: node.even_odd_fill,
svg_path: node.svg_path.clone(),
arc_start_angle: node.arc_start_angle,
arc_sweep_angle: node.arc_sweep_angle,
@@ -323,6 +318,9 @@ fn node_payload_to_scene(
effects: crate::effects::effects_from_payload_ref(&node.effects)
.into_iter()
.chain(crate::effects::blur_effect_from_payload(node.layer_blur))
+ .chain(crate::effects::background_blur_effect_from_payload(
+ node.background_blur,
+ ))
.map(|e| scale_effect_opacity(e, cum_opacity))
.collect(),
hidden: node.hidden,
diff --git a/crates/op-pen-loader/src/layout_scene_tests.rs b/crates/op-pen-loader/src/layout_scene_tests.rs
index 81d61d577..1e41609b7 100644
--- a/crates/op-pen-loader/src/layout_scene_tests.rs
+++ b/crates/op-pen-loader/src/layout_scene_tests.rs
@@ -1,6 +1,33 @@
//! Unit tests for [`editor_state_to_layout_scene`].
use super::*;
+
+#[test]
+fn layout_scene_carries_even_odd_path_fill_rule() {
+ let src = r#"{
+ "version":"1.0.0","pages":[{"id":"p","name":"P","children":[
+ {"type":"path","id":"ring","width":100,"height":100,
+ "d":"M0 0H100V100H0Z M25 25H75V75H25Z","fillRule":"evenodd"}
+ ]}],"children":[]
+ }"#;
+ let scene = editor_state_to_layout_scene(&state_from(src));
+ assert!(scene.active_page().unwrap().children[0].even_odd_fill);
+}
+
+#[test]
+fn layout_scene_carries_per_corner_radii() {
+ let src = r#"{
+ "version":"1.0.0","pages":[{"id":"p","name":"P","children":[
+ {"type":"rectangle","id":"r","width":100,"height":50,
+ "cornerRadius":[8,0,6,2]}
+ ]}],"children":[]
+ }"#;
+ let scene = editor_state_to_layout_scene(&state_from(src));
+ assert_eq!(
+ scene.active_page().unwrap().children[0].corner_radii,
+ Some([8.0, 0.0, 6.0, 2.0])
+ );
+}
use op_editor_core::EditorState;
/// Build an `EditorState` from a `.op` JSON source — mirrors how the
diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs
index be3124ed0..08b52104b 100644
--- a/crates/op-pen-loader/src/payload.rs
+++ b/crates/op-pen-loader/src/payload.rs
@@ -69,6 +69,10 @@ pub struct NodePayload {
pub opacity: f32,
#[serde(default)]
pub corner_radius: f32,
+ /// Per-corner radii in top-left, top-right, bottom-right,
+ /// bottom-left order. `corner_radius` remains the legacy maximum.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub corner_radii: Option<[f32; 4]>,
/// Container clips its children to its bounds (canonical
/// `clipContent`). The page builders also force this on for root
/// frames, which clip like artboards (TS flattener parity).
@@ -119,6 +123,9 @@ pub struct NodePayload {
/// back to the first).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub path_closed: bool,
+ /// Whether SVG path fills use the even-odd winding rule.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub even_odd_fill: bool,
/// Preserved SVG path data for imported path nodes. Coordinates
/// are local doc-px relative to the node origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -166,6 +173,9 @@ pub struct NodePayload {
/// shadow-only) so the save format stays additive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_blur: Option,
+ /// Gaussian backdrop-blur radius (doc px).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub background_blur: Option,
/// Source URL (`data:image/...;base64,...` or file path) when the
/// node is an `Image` — the canvas painter decodes the inline
/// bytes and draws them with `RenderBackend::draw_image`. `None`
diff --git a/crates/op-pen-loader/src/style_payload.rs b/crates/op-pen-loader/src/style_payload.rs
index e1953525c..d78b13a48 100644
--- a/crates/op-pen-loader/src/style_payload.rs
+++ b/crates/op-pen-loader/src/style_payload.rs
@@ -31,6 +31,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
flip_y: base.flip_y.unwrap_or(false),
opacity: base_opacity(base),
corner_radius: 0.0,
+ corner_radii: None,
clip_content: false,
arc_start_angle: None,
arc_sweep_angle: None,
@@ -45,6 +46,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
points: Vec::new(),
path_anchors: Vec::new(),
path_closed: false,
+ even_odd_fill: false,
svg_path: None,
font_size: 0.0,
font_weight: 0,
@@ -59,6 +61,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
text_wrap: false,
effects: Vec::new(),
layer_blur: None,
+ background_blur: None,
image_src: None,
image_fit: None,
image_adjustments: None,
@@ -82,9 +85,15 @@ pub(crate) fn apply_container_style(
) {
assign_first_fill(p, fill);
p.stroke = stroke_to_payload(stroke);
+ p.corner_radii = match corner_radius {
+ Some(CornerRadius::PerCorner(corners)) => Some(corners.map(|radius| radius as f32)),
+ _ => None,
+ };
p.corner_radius = match corner_radius {
Some(CornerRadius::Uniform(r)) => *r as f32,
- Some(CornerRadius::PerCorner(corners)) => corners[0] as f32,
+ Some(CornerRadius::PerCorner(corners)) => {
+ corners.iter().copied().fold(0.0_f64, f64::max) as f32
+ }
None => 0.0,
};
}
diff --git a/vendor/jian b/vendor/jian
index fd17e40f9..e825f8f56 160000
--- a/vendor/jian
+++ b/vendor/jian
@@ -1 +1 @@
-Subproject commit fd17e40f9996e12d23100f4dd2c4b756f15ef269
+Subproject commit e825f8f5636137c47d96db8a3c2a40a9c4a26467