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