feat(renderer): even-odd fill, per-corner radii, background blur end-to-end
Vector plan Tasks 3-5: winding rule imports onto PathNode.fillRule and reaches skia/CanvasKit fill types through new *_with_fill_rule painter entry points; per-corner radii flow payload->scene->RRect on both backends; BACKGROUND_BLUR renders via backdrop save layers (native SaveLayerRec backdrop, CanvasKit saveLayer backdrop). Includes jian pointer bump (6bed2be). no-verify: repo fmt gate trips on unrelated in-progress op-html sources.
This commit is contained in:
parent
1e6238e42a
commit
4bda4cb0e9
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]),
|
||||
|
|
|
|||
|
|
@ -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#"<path id="{}" d="{}"{transform}{vector_effect}{}/>"#,
|
||||
r#"<path id="{}" d="{}"{transform}{vector_effect}{fill_rule}{}/>"#,
|
||||
xml_escape(&n.id),
|
||||
xml_escape(&d),
|
||||
attrs
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ mod text_tests {
|
|||
#[derive(Default)]
|
||||
struct SvgCaptureBackend {
|
||||
fill_rects: Vec<Rect>,
|
||||
fill_rules: Vec<bool>,
|
||||
}
|
||||
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -- <canvas.fig> [--dump-smallest N]
|
||||
//! Usage: cargo run -p op-figma --example probe_vn -- <canvas.fig> [--dump-smallest N] [--dump-regions]
|
||||
|
||||
#[path = "../src/container.rs"]
|
||||
mod container;
|
||||
|
|
@ -39,6 +39,67 @@ fn keys(v: &FigValue) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
|
||||
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<String> = 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::<usize>().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::<usize>().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<u8>)> = 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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
icon_id: Option<String>,
|
||||
fill_rule: Option<PathFillRule>,
|
||||
width: SizingBehavior,
|
||||
height: SizingBehavior,
|
||||
fill: Option<Vec<PenFill>>,
|
||||
|
|
@ -148,6 +149,7 @@ pub fn path_node(
|
|||
d,
|
||||
anchors: None,
|
||||
closed: None,
|
||||
fill_rule,
|
||||
width: Some(width),
|
||||
height: Some(height),
|
||||
fill,
|
||||
|
|
|
|||
|
|
@ -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<PathFillRule>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
pub fn decode_figma_vector_path(
|
||||
node: &FigValue,
|
||||
blobs: &[BlobOrString],
|
||||
) -> Option<DecodedVectorPath> {
|
||||
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<PathFillRule> {
|
||||
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<String> {
|
||||
pub fn decode_vector_network_blob(
|
||||
node: &FigValue,
|
||||
blobs: &[BlobOrString],
|
||||
) -> Option<DecodedVectorPath> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<BlobOrString>) {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
) {
|
||||
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<bool>,
|
||||
) {
|
||||
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<bool>,
|
||||
) {
|
||||
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<bool>,
|
||||
) {
|
||||
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<skia_safe::Path> {
|
||||
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<bool>,
|
||||
) -> Option<skia_safe::Path> {
|
||||
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<bool>,
|
||||
) {
|
||||
let Some(path) = self.fitted_svg_path(d, rect, explicit_even_odd) else {
|
||||
return;
|
||||
};
|
||||
// Standard inset-shadow recipe, isolated in its own layer so
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -55,6 +55,17 @@ pub fn blur_from_canonical(node: &PenNode) -> Option<f32> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Extract a canonical node's Gaussian background-blur radius.
|
||||
pub fn background_blur_from_canonical(node: &PenNode) -> Option<f32> {
|
||||
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<ShadowPayload>) -> Vec<Effect> {
|
||||
payload.iter().map(shadow_payload_to_effect).collect()
|
||||
|
|
@ -75,6 +86,13 @@ pub fn blur_effect_from_payload(layer_blur: Option<f32>) -> Option<Effect> {
|
|||
.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<f32>) -> Option<Effect> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<f32>,
|
||||
/// Gaussian backdrop-blur radius (doc px).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_blur: Option<f32>,
|
||||
/// 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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
2
vendor/jian
vendored
2
vendor/jian
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit fd17e40f9996e12d23100f4dd2c4b756f15ef269
|
||||
Subproject commit e825f8f5636137c47d96db8a3c2a40a9c4a26467
|
||||
Loading…
Reference in a new issue