diff --git a/.gitignore b/.gitignore index 7973ac4..45669d6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ __pycache__/ .eggs/ /doc/_generated .svg + +# Pixi +pixi.toml +pixi.lock \ No newline at end of file diff --git a/examples/cubic_bezier_link.py b/examples/cubic_bezier_link.py new file mode 100644 index 0000000..8a7202c --- /dev/null +++ b/examples/cubic_bezier_link.py @@ -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}") diff --git a/examples/cubic_bezier_link.svg b/examples/cubic_bezier_link.svg new file mode 100644 index 0000000..2eee251 --- /dev/null +++ b/examples/cubic_bezier_link.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ewoksdraw/__init__.py b/src/ewoksdraw/__init__.py index f699db1..1c3bb43 100644 --- a/src/ewoksdraw/__init__.py +++ b/src/ewoksdraw/__init__.py @@ -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 diff --git a/src/ewoksdraw/css_styles/css_link_cubic_bezier.css b/src/ewoksdraw/css_styles/css_link_cubic_bezier.css new file mode 100644 index 0000000..e33e3ee --- /dev/null +++ b/src/ewoksdraw/css_styles/css_link_cubic_bezier.css @@ -0,0 +1,8 @@ +.link_cubic_bezier { + fill: none; + stroke: #ffffff; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: none; +} \ No newline at end of file diff --git a/src/ewoksdraw/css_styles/css_task_line.css b/src/ewoksdraw/css_styles/css_task_line.css index 51cdbf8..05a295b 100644 --- a/src/ewoksdraw/css_styles/css_task_line.css +++ b/src/ewoksdraw/css_styles/css_task_line.css @@ -1,4 +1,5 @@ .task_line { - stroke: rgb(255, 255, 255); + stroke: #ffffff; stroke-width: 1; + stroke-dasharray: none; } \ No newline at end of file diff --git a/src/ewoksdraw/geometry/__init__.py b/src/ewoksdraw/geometry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ewoksdraw/geometry/cubic_bezier_path.py b/src/ewoksdraw/geometry/cubic_bezier_path.py new file mode 100644 index 0000000..e9d22d5 --- /dev/null +++ b/src/ewoksdraw/geometry/cubic_bezier_path.py @@ -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: + 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) diff --git a/src/ewoksdraw/svg/__init__.py b/src/ewoksdraw/svg/__init__.py index 7b90d0e..e69de29 100644 --- a/src/ewoksdraw/svg/__init__.py +++ b/src/ewoksdraw/svg/__init__.py @@ -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 diff --git a/src/ewoksdraw/svg/svg_canvas.py b/src/ewoksdraw/svg/svg_canvas.py index f6af204..058b92b 100644 --- a/src/ewoksdraw/svg/svg_canvas.py +++ b/src/ewoksdraw/svg/svg_canvas.py @@ -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): self.width = width self.height = height self.elements: List[Union[SvgElement, SvgGroup]] = [] diff --git a/src/ewoksdraw/svg/svg_element.py b/src/ewoksdraw/svg/svg_element.py index 0ca1c50..ae48ea0 100644 --- a/src/ewoksdraw/svg/svg_element.py +++ b/src/ewoksdraw/svg/svg_element.py @@ -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) + 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 + if self._tag == "circle": attr_x = "cx" attr_y = "cy" diff --git a/src/ewoksdraw/svg/svg_link_cubic_bezier.py b/src/ewoksdraw/svg/svg_link_cubic_bezier.py new file mode 100644 index 0000000..34a520c --- /dev/null +++ b/src/ewoksdraw/svg/svg_link_cubic_bezier.py @@ -0,0 +1,56 @@ +from ..geometry.cubic_bezier_path import CubicBezierPath +from .svg_element import SvgElement + + +class SvgLinkCubicBezier(SvgElement): + """ + Represents an SVG path element for a cubic Bezier link. + + :param path: The cubic Bezier path coordinates. + :param color: The stroke color of the link. + :param stroke_width: The stroke width of the link. + :param stroke_dash: The SVG stroke-dasharray value of the link. + """ + + def __init__( + self, + path: CubicBezierPath, + color: str | None = None, + stroke_width: float | None = None, + stroke_dash: str | None = None, + ): + string_svg = self._convert_path_data_to_svg_attribute(path) + + attr = {"d": string_svg} + styles = [] + + if color is not None: + styles.append(f"stroke:{color}") + + if stroke_width is not None: + styles.append(f"stroke-width:{stroke_width:g}") + + if stroke_dash is not None: + styles.append(f"stroke-dasharray:{stroke_dash}") + + if styles: + attr["style"] = ";".join(styles) + + super().__init__(tag="path", css_class="link_cubic_bezier", attr=attr) + + def _convert_path_data_to_svg_attribute(self, path: CubicBezierPath) -> str: + if not path.segments: + raise ValueError("A cubic Bezier path needs at least one segment.") + + start_x, start_y = path.start + commands = [f"M {start_x},{start_y}"] + + for segment in path.segments: + control1_x, control1_y = segment.control1 + control2_x, control2_y = segment.control2 + end_x, end_y = segment.end + commands.append( + f"C {control1_x},{control1_y} {control2_x},{control2_y} {end_x},{end_y}" + ) + + return " ".join(commands) diff --git a/src/ewoksdraw/tests/test_cubic_bezier_path.py b/src/ewoksdraw/tests/test_cubic_bezier_path.py new file mode 100644 index 0000000..7bdf483 --- /dev/null +++ b/src/ewoksdraw/tests/test_cubic_bezier_path.py @@ -0,0 +1,88 @@ +import pytest + +from ewoksdraw.geometry.cubic_bezier_path import CubicBezierPath +from ewoksdraw.geometry.cubic_bezier_path import _direction +from ewoksdraw.geometry.cubic_bezier_path import _distance +from ewoksdraw.geometry.cubic_bezier_path import _move +from ewoksdraw.geometry.cubic_bezier_path import _straight_segment + + +def test_straight_segment(): + segment = _straight_segment((0, 0), (9, 0)) + assert segment.control1 == pytest.approx((3, 0)) + assert segment.control2 == pytest.approx((6, 0)) + assert segment.end == (9, 0) + + +@pytest.mark.parametrize( + "start, end, expected", + [ + ((0, 0), (5, 0), (1, 0)), + ((5, 0), (0, 0), (-1, 0)), + ((0, 0), (0, 5), (0, 1)), + ((0, 5), (0, 0), (0, -1)), + ], +) +def test_direction(start, end, expected): + assert _direction(start, end) == expected + + +@pytest.mark.parametrize( + "start, end, expected", + [ + ((0, 0), (3, 4), 7), + ((0, 0), (0, 0), 0), + ((-1, -1), (1, 1), 4), + ], +) +def test_distance(start, end, expected): + assert _distance(start, end) == expected + + +@pytest.mark.parametrize( + "point, direction, distance, expected", + [ + ((0, 0), (1, 0), 5, (5, 0)), + ((0, 0), (-1, 0), 5, (-5, 0)), + ((0, 0), (0, 1), 5, (0, 5)), + ((0, 0), (0, 1), -5, (0, -5)), + ], +) +def test_move(point, direction, distance, expected): + assert _move(point, direction, distance) == expected + + +def test_from_points_two_points_is_a_single_straight_segment(): + path = CubicBezierPath.from_points(points=[(0, 0), (10, 0)], radius=2) + + assert path.start == (0, 0) + assert len(path.segments) == 1 + assert path.segments[0].end == (10, 0) + + +def test_from_points_single_corner_produces_three_segments(): + path = CubicBezierPath.from_points(points=[(0, 0), (10, 0), (10, 10)], radius=2) + + assert path.start == (0, 0) + assert len(path.segments) == 3 + + straight_before, corner, straight_after = path.segments + assert straight_before.end == (8, 0) + assert corner.end == (10, 2) + assert straight_after.end == (10, 10) + + +def test_from_points_radius_clamped_to_half_shorter_adjacent_segment(): + path = CubicBezierPath.from_points(points=[(0, 0), (2, 0), (2, 10)], radius=100) + + _, corner, _ = path.segments + assert corner.end == (2, 1) + + +def test_from_points_zero_radius_collapses_corner_to_point(): + path = CubicBezierPath.from_points(points=[(0, 0), (10, 0), (10, 10)], radius=0) + + straight_before, corner, straight_after = path.segments + assert straight_before.end == (10, 0) + assert corner.end == (10, 0) + assert straight_after.end == (10, 10) diff --git a/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py b/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py new file mode 100644 index 0000000..06064fc --- /dev/null +++ b/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py @@ -0,0 +1,71 @@ +import pytest + +from ewoksdraw.geometry.cubic_bezier_path import CubicBezierPath +from ewoksdraw.geometry.cubic_bezier_path import CubicBezierSegment +from ewoksdraw.svg.svg_link_cubic_bezier import SvgLinkCubicBezier + +SIMPLE_PATH = CubicBezierPath( + start=(0, 0), + segments=[CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3))], +) + +TWO_SEGMENT_PATH = CubicBezierPath( + start=(0, 0), + segments=[ + CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3)), + CubicBezierSegment(control1=(4, 4), control2=(5, 5), end=(6, 6)), + ], +) + + +def test_d_attribute_starts_with_move_command(): + link = SvgLinkCubicBezier(SIMPLE_PATH) + assert link.get_attr("d") == "M 0,0 C 1,1 2,2 3,3" + + +def test_d_attribute_has_one_curve_command_per_segment(): + link = SvgLinkCubicBezier(TWO_SEGMENT_PATH) + assert link.get_attr("d") == "M 0,0 C 1,1 2,2 3,3 C 4,4 5,5 6,6" + + +def test_raises_when_path_has_no_segments(): + empty_path = CubicBezierPath(start=(0, 0), segments=[]) + with pytest.raises(ValueError): + SvgLinkCubicBezier(empty_path) + + +def test_style_attribute_has_only_color(): + link = SvgLinkCubicBezier(SIMPLE_PATH, color="#ff0000") + assert link.get_attr("style") == "stroke:#ff0000" + + +def test_style_attribute_has_only_stroke_width(): + link = SvgLinkCubicBezier(SIMPLE_PATH, stroke_width=4.0) + assert link.get_attr("style") == "stroke-width:4" + + +def test_style_attribute_has_only_stroke_dash(): + link = SvgLinkCubicBezier(SIMPLE_PATH, stroke_dash="5 10") + assert link.get_attr("style") == "stroke-dasharray:5 10" + + +def test_style_attribute_combines_color_stroke_width_and_stroke_dash(): + link = SvgLinkCubicBezier( + SIMPLE_PATH, color="#ff0000", stroke_width=4, stroke_dash="5 10" + ) + assert ( + link.get_attr("style") == "stroke:#ff0000;stroke-width:4;stroke-dasharray:5 10" + ) + + +def test_style_attribute_is_none_without_color_stroke_width_or_stroke_dash(): + link = SvgLinkCubicBezier(SIMPLE_PATH) + assert link.get_attr("style") is None + + +def test_xml_element_is_a_path_with_link_class(): + link = SvgLinkCubicBezier(SIMPLE_PATH) + element = link.xml_element + assert element.tag == "path" + assert element.get("class") == "link_cubic_bezier" + assert element.get("d") == "M 0,0 C 1,1 2,2 3,3" diff --git a/src/ewoksdraw/tests/test_svg_path_integration.py b/src/ewoksdraw/tests/test_svg_path_integration.py new file mode 100644 index 0000000..bd5c148 --- /dev/null +++ b/src/ewoksdraw/tests/test_svg_path_integration.py @@ -0,0 +1,48 @@ +from pathlib import Path +from xml.etree import ElementTree + +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 + +PATH = CubicBezierPath( + start=(0, 0), + segments=[CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3))], +) + + +def test_group_translate_moves_path_via_transform_not_d_attribute(): + link = SvgLinkCubicBezier(PATH) + group = SvgGroup() + group.add_elements([link]) + group.translate(5, 5) + + group_element = group.xml_element + assert group_element.get("transform") == "translate(5,5)" + + path_element = group_element[0] + assert path_element.tag == "path" + assert path_element.get("d") == "M 0,0 C 1,1 2,2 3,3" + + +def test_canvas_draw_writes_valid_svg_with_path_element(tmp_path: Path): + output_path = tmp_path / "path.svg" + + link = SvgLinkCubicBezier(PATH, color="#ff0000") + group = SvgGroup() + group.add_elements([link]) + + canvas = SvgCanvas(width=100, height=100) + canvas.add_element(group) + canvas.draw(output_path) + + assert output_path.is_file() + + tree = ElementTree.parse(output_path) + root = tree.getroot() + + path_elements = [element for element in root.iter() if element.tag.endswith("path")] + assert len(path_elements) == 1 + assert path_elements[0].get("d") == "M 0,0 C 1,1 2,2 3,3"