Compare commits

...

3 Commits

Author SHA1 Message Date
Alejandro Alonso
79aff449ca WIP 2026-02-10 14:17:43 +01:00
Alejandro Alonso
1ee07225b5 WIP 2026-02-10 13:38:03 +01:00
Alejandro Alonso
de49a1e980 🐛 Fix dot/dahs/mixed strokes 2026-02-10 12:43:06 +01:00
6 changed files with 302 additions and 28 deletions

View File

@@ -82,7 +82,7 @@ export default defineConfig({
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
timeout: 2 * 60 * 1000,
expect: {
timeout: process.env.CI ? 20000 : 10000,
timeout: process.env.CI ? 20000 : 100000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},

View File

@@ -305,3 +305,19 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json");
await workspace.goToWorkspace({
id: "b888b894-3697-80d3-8006-51cc8a55c200",
pageId: "b888b894-3697-80d3-8006-51cc8a55c210",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 318 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -1,7 +1,8 @@
use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, StrokeStyle, SvgAttrs,
Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
@@ -9,6 +10,41 @@ use super::{filters, RenderState, SurfaceId};
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
fn point_on_rect_perimeter(rect: &Rect, mut distance: f32) -> Point {
let width = rect.width();
let height = rect.height();
let perimeter = 2.0 * (width + height);
if perimeter <= 0.0 {
return Point::new(rect.left, rect.top);
}
// Envuelve la distancia dentro del perímetro
distance = distance % perimeter;
let left = rect.left;
let top = rect.top;
let right = rect.right;
let bottom = rect.bottom;
if distance <= width {
return Point::new(left + distance, top);
}
if distance <= width + height {
let d = distance - width;
return Point::new(right, top + d);
}
if distance <= width + height + width {
let d = distance - width - height;
return Point::new(right - d, bottom);
}
let d = distance - width - height - width;
Point::new(left, bottom - d)
}
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_rect(
@@ -26,10 +62,151 @@ fn draw_stroke_on_rect(
// Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to:
// - The same rect if it's a center stroke
// - A bigger rect if it's an outer stroke
// - A smaller rect if it's an outer stroke
// - A smaller rect if it's an inner stroke
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Para estilos sólidos seguimos usando el path/stroke normal.
// Para estilos no sólidos (dotted/dashed/mixed) empezamos a
// personalizar la lógica por forma. Empezamos por los rectángulos
// sin esquinas redondeadas con estilo dotted.
if matches!(stroke.style, StrokeStyle::Dotted) && corners.is_none() {
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Queremos dibujar círculos rellenos, no un stroke con path effect.
paint.set_style(skia::PaintStyle::Fill);
paint.set_path_effect(None);
// Aplicamos blur y shadow igual que antes.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// Rect base donde colocamos los puntos. Para dotted inner/outer,
// outer_rect ya está ajustado para que el path esté en el borde
// original de la forma.
let border_rect = stroke.outer_rect(rect);
let radius = stroke.width / 2.0;
let advance = stroke.width + 5.0;
if border_rect.width() <= 0.0 || border_rect.height() <= 0.0 || radius <= 0.0 {
return;
}
let width = border_rect.width();
let height = border_rect.height();
let left = border_rect.left;
let top = border_rect.top;
let right = border_rect.right;
let bottom = border_rect.bottom;
let mut draw_dots = |canvas: &skia::Canvas| {
// 1) Círculos completos en cada vértice
let corner_points = [
Point::new(left, top),
Point::new(right, top),
Point::new(right, bottom),
Point::new(left, bottom),
];
for p in &corner_points {
canvas.draw_circle((p.x, p.y), radius, &paint);
}
// Helper para dibujar media circunferencia orientada según lado/kind
let mut draw_half_circle =
|canvas: &skia::Canvas, center: Point, side: u8, kind: StrokeKind| {
if matches!(kind, StrokeKind::Center) {
canvas.draw_circle((center.x, center.y), radius, &paint);
return;
}
// Path base: media circunferencia con normal hacia +Y (abajo)
let mut path = skia::Path::new();
let rect = skia::Rect::from_xywh(
-radius,
-radius,
radius * 2.0,
radius * 2.0,
);
path.add_arc(rect, 0.0, 180.0);
// Ángulo de la normal "hacia dentro" según el lado
let inward_normal_angle = match side {
// top: interior hacia abajo
0 => 90.0,
// right: interior hacia la izquierda
1 => 180.0,
// bottom: interior hacia arriba
2 => 270.0,
// left: interior hacia la derecha
_ => 0.0,
};
// La normal del path base apunta a +Y
let base_normal_angle = 90.0;
// Para inner usamos la normal hacia dentro;
// para outer la invertimos (180 grados).
let mut normal_angle = inward_normal_angle;
if matches!(kind, StrokeKind::Outer) {
normal_angle += 180.0;
}
let angle = normal_angle - base_normal_angle;
let mut matrix = Matrix::new_identity();
matrix.pre_rotate(angle, Point::new(0.0, 0.0));
matrix.post_translate(center);
path.transform(&matrix);
canvas.draw_path(&path, &paint);
};
// 2) Entre vértices, puntos intermedios por cada lado
// Lado 0: top (left -> right)
if width > advance {
let mut t = advance;
while t < width {
let p = Point::new(left + t, top);
draw_half_circle(canvas, p, 0, stroke.kind);
t += advance;
}
}
// Lado 1: right (top -> bottom)
if height > advance {
let mut t = advance;
while t < height {
let p = Point::new(right, top + t);
draw_half_circle(canvas, p, 1, stroke.kind);
t += advance;
}
}
// Lado 2: bottom (right -> left)
if width > advance {
let mut t = advance;
while t < width {
let p = Point::new(right - t, bottom);
draw_half_circle(canvas, p, 2, stroke.kind);
t += advance;
}
}
// Lado 3: left (bottom -> top)
if height > advance {
let mut t = advance;
while t < height {
let p = Point::new(left, bottom - t);
draw_half_circle(canvas, p, 3, stroke.kind);
t += advance;
}
}
};
draw_dots(canvas);
return;
}
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
@@ -62,15 +239,62 @@ fn draw_stroke_on_circle(
// Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to:
// - The same oval if it's a center stroke
// - A bigger oval if it's an outer stroke
// - A smaller oval if it's an outer stroke
// - A smaller oval if it's an inner stroke
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
canvas.draw_oval(stroke_rect, &paint);
// Helper to draw the stroke without any clipping logic.
let draw_simple = |canvas: &skia::Canvas| {
canvas.draw_oval(stroke_rect, &paint);
};
// Igual que en el caso de los rectángulos: con blur activo, el clipping
// introduce un corte visible en el blur. Para evitarlo, cuando hay blur
// volvemos al comportamiento sin clipping.
if blur.is_some() {
draw_simple(canvas);
return;
}
// Para la mayoría de estilos sin blur, basta con dibujar sin clipping.
// Solo mantenemos clipping para el caso problemático de dotted inner/outer
// (evitar ver “medios puntos” cruzando el borde).
if !matches!(stroke.style, StrokeStyle::Dotted)
|| matches!(stroke.kind, StrokeKind::Center)
{
draw_simple(canvas);
return;
}
// For inner/outer dotted strokes, we need clipping to prevent stroke pattern
// (like dotted circles) from appearing in wrong areas
match stroke.kind {
StrokeKind::Inner => {
// Inner: clip to original rect to hide parts outside boundary
canvas.save();
let mut clip_path = skia::Path::new();
clip_path.add_oval(rect, None);
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
}
StrokeKind::Outer => {
// Outer: clip to exclude original rect to hide parts inside boundary
canvas.save();
let mut clip_path = skia::Path::new();
clip_path.add_oval(rect, None);
canvas.clip_path(&clip_path, skia::ClipOp::Difference, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
}
StrokeKind::Center => {
// Center strokes don't need clipping
canvas.draw_oval(stroke_rect, &paint);
}
}
}
fn draw_outer_stroke_path(

View File

@@ -128,20 +128,28 @@ impl Stroke {
}
pub fn outer_rect(&self, rect: &Rect) -> Rect {
match self.kind {
StrokeKind::Inner => Rect::from_xywh(
rect.left + (self.width / 2.),
rect.top + (self.width / 2.),
rect.width() - self.width,
rect.height() - self.width,
),
StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()),
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
match (self.kind, self.style) {
(StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => {
// Boundary so circles center on it and semicircles match after clipping
*rect
}
_ => match self.kind {
StrokeKind::Inner => Rect::from_xywh(
rect.left + (self.width / 2.),
rect.top + (self.width / 2.),
rect.width() - self.width,
rect.height() - self.width,
),
StrokeKind::Center => {
Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height())
}
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
},
}
}
@@ -155,6 +163,11 @@ impl Stroke {
}
pub fn outer_corners(&self, corners: &Corners) -> Corners {
if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
// Path at boundary so no corner offset
return *corners;
}
let offset = match self.kind {
StrokeKind::Center => 0.0,
StrokeKind::Inner => -self.width / 2.0,
@@ -200,18 +213,39 @@ impl Stroke {
let path_effect = match self.style {
StrokeStyle::Dotted => {
let mut circle_path = skia::Path::new();
let width = match self.kind {
StrokeKind::Inner => self.width,
StrokeKind::Center => self.width / 2.0,
StrokeKind::Outer => self.width,
};
circle_path.add_circle((0.0, 0.0), width, None);
// Radio base para el patrón de puntos
let radius = self.width / 2.0;
// Usar media circunferencia que viva solo “después” del
// origen del path (x >= 0) para que no haya nada del
// patrón antes de que empiece el trazo.
// - Inner: arco superior (y > 0)
// - Outer: arco inferior (y < 0)
// - Center: círculo completo como antes
match self.kind {
StrokeKind::Inner => {
let rect =
skia::Rect::from_xywh(0.0, -radius, radius * 2.0, radius * 2.0);
// De (0, 0) a (2r, 0) pasando por y > 0
circle_path.add_arc(rect, 180.0, 180.0);
}
StrokeKind::Outer => {
let rect =
skia::Rect::from_xywh(0.0, -radius, radius * 2.0, radius * 2.0);
// De (0, 0) a (2r, 0) pasando por y < 0
circle_path.add_arc(rect, 180.0, -180.0);
}
StrokeKind::Center => {
circle_path.add_circle((0.0, 0.0), radius, None);
}
}
let advance = self.width + 5.0;
skia::PathEffect::path_1d(
&circle_path,
advance,
0.0,
skia::path_1d_path_effect::Style::Translate,
skia::path_1d_path_effect::Style::Rotate,
)
}
StrokeStyle::Dashed => {