-
Notifications
You must be signed in to change notification settings - Fork 0
[Feature]: Implement B-spline SVG path component for workflow links #15 #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0fd4e25
7fdf37e
9c47ebf
e94627f
d2b0c45
f235daf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,3 +18,7 @@ __pycache__/ | |
| .eggs/ | ||
| /doc/_generated | ||
| .svg | ||
|
|
||
| # Pixi | ||
| pixi.toml | ||
| pixi.lock | ||
| 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}") |
| 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; | ||
| } |
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| from dataclasses import dataclass | ||
| 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can use |
||
| self.width = width | ||
| self.height = height | ||
| self.elements: List[Union[SvgElement, SvgGroup]] = [] | ||
|
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't know about
|
||
|
|
||
|
|
||
| class SvgElement: | ||
| """ | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we print a warning there?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.