Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ __pycache__/
.eggs/
/doc/_generated
.svg

# Pixi
pixi.toml
pixi.lock
105 changes: 105 additions & 0 deletions examples/cubic_bezier_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import copy
from pathlib import Path

from ewoksdraw.geometry.cubic_bezier_path import CubicBezierPath
from ewoksdraw.geometry.cubic_bezier_path import CubicBezierSegment
from ewoksdraw.svg.svg_canvas import SvgCanvas
from ewoksdraw.svg.svg_group import SvgGroup
from ewoksdraw.svg.svg_link_cubic_bezier import SvgLinkCubicBezier

output_path = Path(__file__).with_suffix(".svg")

letter_e = CubicBezierPath(
start=(10, 130),
segments=[
CubicBezierSegment((40, 115), (75, 95), (105, 90)),
CubicBezierSegment((125, 75), (55, 50), (25, 115)),
CubicBezierSegment((0, 165), (70, 170), (115, 140)),
],
)
letter_w = CubicBezierPath(
start=(135, 84),
segments=[
CubicBezierSegment((142, 124), (145, 154), (158, 156)),
CubicBezierSegment((169, 158), (176, 102), (187, 102)),
CubicBezierSegment((198, 102), (196, 157), (210, 157)),
CubicBezierSegment((224, 157), (236, 105), (244, 84)),
],
)

letter_o = CubicBezierPath(
start=(302, 113),
segments=[
CubicBezierSegment((302, 86), (260, 80), (253, 112)),
CubicBezierSegment((247, 141), (286, 155), (304, 128)),
CubicBezierSegment((317, 108), (307, 89), (290, 87)),
],
)

letter_k = CubicBezierPath(
start=(348, 70),
segments=[
CubicBezierSegment((342, 104), (340, 128), (338, 158)),
CubicBezierSegment((353, 130), (374, 102), (394, 83)),
CubicBezierSegment((373, 107), (369, 127), (394, 154)),
],
)

letter_s = CubicBezierPath(
start=(469, 84),
segments=[
CubicBezierSegment((434, 72), (421, 102), (453, 115)),
CubicBezierSegment((491, 130), (482, 162), (439, 151)),
CubicBezierSegment((420, 146), (427, 130), (448, 133)),
],
)

underline = CubicBezierPath.from_points(
points=[
(40, 175),
(240, 175),
(240, 205),
(480, 205),
(480, 25),
(320, 25),
(320, 205),
],
radius=30,
)
links_group = SvgGroup()
links_group.add_elements(
[
SvgLinkCubicBezier(letter_e),
SvgLinkCubicBezier(
letter_w,
color="#00c2a8",
stroke_width=4,
stroke_dash="5 10",
),
SvgLinkCubicBezier(
letter_o,
color="#ffcc00",
stroke_width=6,
stroke_dash="1 5",
),
SvgLinkCubicBezier(letter_k, color="#7c5cff", stroke_width=4),
SvgLinkCubicBezier(letter_s, color="#54a068", stroke_width=10),
SvgLinkCubicBezier(
underline,
color="#ff00aa",
stroke_width=3,
stroke_dash="12 6",
),
]
)

links_group_2 = copy.copy(links_group)
links_group_2.translate(5, 5)

canvas = SvgCanvas(width=520, height=220)
canvas.add_background()
canvas.add_element(links_group)
canvas.add_element(links_group_2)

canvas.draw(output_path)
print(f"Wrote {output_path}")
35 changes: 35 additions & 0 deletions examples/cubic_bezier_link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/ewoksdraw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from ewokscore.graph.inputs import _get_all_node_inputs
from ewokscore.graph.inputs import _get_all_task_output_names

from .svg import SvgCanvas
from .svg import SvgTask
from .svg.svg_canvas import SvgCanvas
from .svg.svg_task import SvgTask

GAP = 10
GAP = 10.0
DEFAULT_HEIGHT = 500


Expand Down
8 changes: 8 additions & 0 deletions src/ewoksdraw/css_styles/css_link_cubic_bezier.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.link_cubic_bezier {
fill: none;
stroke: #ffffff;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: none;
}
3 changes: 2 additions & 1 deletion src/ewoksdraw/css_styles/css_task_line.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.task_line {
stroke: rgb(255, 255, 255);
stroke: #ffffff;
stroke-width: 1;
stroke-dasharray: none;
}
Empty file.
98 changes: 98 additions & 0 deletions src/ewoksdraw/geometry/cubic_bezier_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from dataclasses import dataclass
from typing import Self

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from typing import Self
import sys
if sys.version_info < (3, 11):
from typing_extensions import Self
else:
from typing import Self

from typing import Sequence

Point = tuple[float, float]
Vector = tuple[float, float]


@dataclass(frozen=True)
class CubicBezierSegment:
control1: Point
control2: Point
end: Point


@dataclass(frozen=True)
class CubicBezierPath:
start: Point
segments: Sequence[CubicBezierSegment]

@classmethod
def from_points(cls, points: Sequence[Point], radius: float) -> Self:
"""
Create a rounded cubic Bezier path from horizontal and vertical points.

:param points: The polyline points to convert.
:param radius: The turn radius around each intermediate point.
"""

segments = []
current = points[0]

for index in range(1, len(points) - 1):
previous_point = points[index - 1]
corner_point = points[index]
next_point = points[index + 1]

previous_direction = _direction(previous_point, corner_point)
next_direction = _direction(corner_point, next_point)

# we might not have the space to turn if distance is too small
turn_radius = min(
radius,
_distance(previous_point, corner_point) / 2,
_distance(corner_point, next_point) / 2,
)

corner_start = _move(corner_point, previous_direction, -turn_radius)
corner_end = _move(corner_point, next_direction, turn_radius)

segments.append(_straight_segment(current, corner_start))
segments.append(
CubicBezierSegment(
control1=_move(corner_start, previous_direction, turn_radius / 2),
control2=_move(corner_end, next_direction, -turn_radius / 2),
end=corner_end,
)
)
current = corner_end

segments.append(_straight_segment(current, points[-1]))
return cls(start=points[0], segments=segments)


def _straight_segment(start: Point, end: Point) -> CubicBezierSegment:
"""Create a cubic Bezier segment that renders as a straight line."""
start_x, start_y = start
end_x, end_y = end

# Control points are set to 1/2; 2/3 arbitrarly so they are not combine with
# start and end points.
return CubicBezierSegment(
control1=(start_x + (end_x - start_x) / 3, start_y + (end_y - start_y) / 3),
control2=(
start_x + 2 * (end_x - start_x) / 3,
start_y + 2 * (end_y - start_y) / 3,
),
end=end,
)


def _direction(start: Point, end: Point) -> Vector:
"""
Return the horizontal or vertical direction from start to end.
Example : (1, 0) right; (-1, 0) left ...
"""
if start[0] == end[0]:
return (0, 1 if end[1] > start[1] else -1)
return (1 if end[0] > start[0] else -1, 0)


def _distance(start: Point, end: Point) -> float:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This distance is computed with the L1 norm, not the usual L2. Is this normal?

return abs(end[0] - start[0]) + abs(end[1] - start[1])


def _move(point: Point, direction: Vector, distance: float) -> Point:
"""Move a point along a direction by a distance."""
return (point[0] + direction[0] * distance, point[1] + direction[1] * distance)
10 changes: 0 additions & 10 deletions src/ewoksdraw/svg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +0,0 @@
from .svg_background import SvgBackground # noqa: F401
from .svg_canvas import SvgCanvas # noqa: F401
from .svg_element import SvgElement # noqa: F401
from .svg_group import SvgGroup # noqa: F401
from .svg_task import SvgTask # noqa: F401
from .svg_task_anchor_link import SvgTaskAnchorLink # noqa: F401
from .svg_task_box import SvgTaskBox # noqa: F401
from .svg_task_io import SvgTaskIO # noqa: F401
from .svg_task_title import SvgTaskTitle # noqa: F401
from .svg_text import SvgText # noqa: F401
2 changes: 1 addition & 1 deletion src/ewoksdraw/svg/svg_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class SvgCanvas:
SVG XML file.
"""

def __init__(self, width: int, height: int):
def __init__(self, width: int | float, height: int | float):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.width = width
self.height = height
self.elements: List[Union[SvgElement, SvgGroup]] = []
Expand Down
25 changes: 19 additions & 6 deletions src/ewoksdraw/svg/svg_element.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import warnings
from pathlib import Path
from typing import Final
from typing import Literal
from typing import Optional
from typing import get_args
from xml.etree.ElementTree import Element

SvgTag = Literal["rect", "circle", "text", "line", "path"]
SUPPORTED_TAGS: Final[tuple[SvgTag, ...]] = get_args(SvgTag)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't know about get_args, pretty sweet.

Final is probably overkill since it does not raise an error at runtime and we don't run type checking.



class SvgElement:
"""
Expand All @@ -16,16 +22,14 @@ class SvgElement:

def __init__(
self,
tag: Literal["rect", "circle", "text", "line"],
tag: SvgTag,
css_class: Optional[str] = None,
attr: Optional[dict] = None,
text: Optional[str] = None,
):
if tag not in ("rect", "circle", "text", "line"):
raise ValueError(
f"Invalid SVG tag: {tag}. Supported tags are 'rect', 'circle', 'text',"
" 'line'."
)
if tag not in SUPPORTED_TAGS:
supported = ", ".join(SUPPORTED_TAGS)
raise ValueError(f"Invalid SVG tag: {tag}. Supported tags are {supported}.")
self._tag = tag

self._css_class = css_class
Expand All @@ -41,6 +45,15 @@ def set_position(
:param x: The x-coordinate to set. If None, the x attribute is not changed.
:param y: The y-coordinate to set. If None, the y attribute is not changed.
"""

if self._tag == "path":
warnings.warn(
"set_position() has no effect on 'path' elements; position is "
"defined by the path data instead.",
stacklevel=2,
)
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we print a warning there?

@LudoBroche LudoBroche Jul 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely

        if self._tag == "path":
            warnings.warn(
                "set_position() has no effect on 'path' elements; position is "
                "defined by the path data instead.",
                stacklevel=2,
            )
            return


if self._tag == "circle":
attr_x = "cx"
attr_y = "cy"
Expand Down
Loading
Loading