Skip to content
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

Straight line edge conversion for (poly)lines and improved curve conversion #81

Merged
merged 9 commits into from
Jul 6, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ __pycache__/
# C extensions
*.so

# VSCode config
.vscode

# Distribution / packaging
.Python
build/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ python -m graphviz2drawio test/directed/hello.gv.txt
- [ ] Text on edge alignment #59
- [ ] Text alignment inside of shape
- [ ] Support for node with `path` shape #47
- [ ] Ensure undirected graphs are not drawn with arrows
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
- [ ] Run ruff in CI
- [ ] Publish api docs to GH pages
- [ ] Restore codecov to test GHA
Expand Down
2 changes: 1 addition & 1 deletion graphviz2drawio/graphviz2drawio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pygraphviz import AGraph

from .models import parse_nodes_edges_clusters
from .models.SvgParser import parse_nodes_edges_clusters
from .mx.MxGraph import MxGraph


Expand Down
2 changes: 1 addition & 1 deletion graphviz2drawio/models/CoordsTranslate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ def __init__(self, x, y) -> None:
self.x = x
self.y = y

def complex_translate(self, cnum):
def complex_translate(self, cnum: complex) -> complex:
return complex(cnum.real + self.x, cnum.imag + self.y)

def translate(self, x, y):
Expand Down
1 change: 0 additions & 1 deletion graphviz2drawio/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# flake8: noqa: F401
from .Arguments import Arguments
from .Rect import Rect
from .SvgParser import parse_nodes_edges_clusters
151 changes: 127 additions & 24 deletions graphviz2drawio/mx/Curve.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,49 @@
from graphviz2drawio.models.Errors import InvalidCbError
import math
from typing import Callable, Any

hbmartin marked this conversation as resolved.
Show resolved Hide resolved
from . import LinearRegression

linear_min_r2 = 0.9
from svg.path import CubicBezier


class Curve:
def __init__(self, start, end, cb, cbset=None) -> None:
"""A complex number representation of a curve.

A curve may either be a straight line or a cubic Bézier as specified by is_bezier.
If the curve is linear then the points list be the polyline anchors.
If the curve is a cubic Bézier then the points list will be the control points.
"""

def __init__(
self, start: complex, end: complex, is_bezier: bool, points: list[complex]
) -> None:
"""Complex numbers for start, end, and list of 4 Bezier control points."""
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
if cbset is None:
cbset = []
self.start = start
self.end = end
if cb is not None and len(cb) != 4: # noqa: PLR2004
raise InvalidCbError
self.cb = cb
self.cbset = cbset
self.start: complex = start
self.end: complex = end
self.is_bezier: bool = is_bezier
self.points: list[complex] = points

def __str__(self) -> str:
control = "[" + (str(self.cb) if self.cb is not None else "None") + "]"
return f"{self.start} , {control}, {self.end}"
# control = "[" + (str(self.cb) if self.cb is not None else "None") + "]"
return f"{self.start} , {self.end}"
hbmartin marked this conversation as resolved.
Show resolved Hide resolved

hbmartin marked this conversation as resolved.
Show resolved Hide resolved
@staticmethod
def is_linear(points: list, threshold: float = linear_min_r2) -> bool:
"""Determine the linearity of complex points for a given threshold.
def is_linear(cb: CubicBezier) -> bool:
if cb.start == cb.end:
return False

Takes a list of complex points and optional minimum R**2 threshold.
Threshold defaults to 0.9.
"""
r2 = LinearRegression.coefficients(points)[2]
return r2 > threshold
y = _line(cb.start, cb.end)

if y is None:
return Curve.is_linear(_rotate_bezier(cb))
hbmartin marked this conversation as resolved.
Show resolved Hide resolved

return math.isclose(
y(cb.control1.real),
cb.control1.imag,
rel_tol=0.01,
) and math.isclose(
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
y(cb.control2.real),
cb.control2.imag,
rel_tol=0.01,
)

def cubic_bezier_coordinates(self, t: float) -> complex:
"""Calculate a complex number representing a point along the cubic Bézier curve.
Expand All @@ -40,6 +54,15 @@ def cubic_bezier_coordinates(self, t: float) -> complex:
y = Curve._cubic_bezier(self._cb("imag"), t)
return complex(x, y)

def cubic_bezier_derivative(self, t: float) -> complex:
"""Calculate a complex number representing a point along the cubic Bézier curve.

Takes parametric parameter t where 0 <= t <= 1
"""
x = Curve._derivative_of_cubic_bezier(self._cb("real"), t)
y = Curve._derivative_of_cubic_bezier(self._cb("imag"), t)
return complex(x, y)

def _cb(self, prop):
return [getattr(x, prop) for x in self.cb]

Expand All @@ -54,8 +77,88 @@ def _cubic_bezier(p: list, t: float):
B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ where 0 ≤ t ≤1
"""
return (
(((1.0 - t) ** 3) * p[0])
+ (3.0 * t * ((1.0 - t) ** 2) * p[1])
(((1.0 - t) ** 3) * p.real)
+ (3.0 * t * ((1.0 - t) ** 2) * p.imag)
+ (3.0 * (t**2) * (1.0 - t) * p[2])
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
+ ((t**3) * p[3])
)

@staticmethod
def _derivative_of_cubic_bezier(p: list, t: float):
"""Calculate the point along the cubic Bézier.

`p` is an ordered list of 4 control points [P0, P1, P2, P3]
`t` is a parametric parameter where 0 <= t <= 1

implements https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves
B`(t) = 3(1-t)²(P₁-P₀) + 6(1-t)t(P₂-P₁) + 3t²(P₃-P₂) where 0 ≤ t ≤1
"""
return (
(3.0 * ((1.0 - t) ** 2) * (p.imag - p.real))
+ (6.0 * t * (1.0 - t) * (p[2] - p.imag))
+ (3.0 * (t**2) * (p[3] - p[2]))
)


def _line(start: complex, end: complex) -> Callable[[float], float] | None:
"""Calculate the slope and y-intercept of a line."""
denom = end.real - start.real
if denom == 0:
return None
m = (end.imag - start.imag) / denom
b = start.imag - (m * start.real)

def y(x: float) -> float:
return (m * x) + b

return y


def _rotate_bezier(cb):
"""Reverse imaginary and real parts for all components."""
return CubicBezier(
complex(cb.start.imag, cb.start.real),
complex(cb.control1.imag, cb.control1.real),
complex(cb.control2.imag, cb.control2.real),
complex(cb.end.imag, cb.end.real),
)


def _lower_cubic_bezier_to_two_quadratics(
P0: complex, P1: complex, P2: complex, P3: complex, t: float
) -> tuple[list[complex], list[complex]]:
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
"""Splits a cubic Bézier curve defined by control points P0-P3 at t,
returning two new cubic Bézier curve segments."""
Q0 = P0
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
Q1 = complex((1 - t) * P0.real + t * P1.real, (1 - t) * P0.imag + t * P1.imag)
Q2 = complex(
(1 - t) ** 2 * P0.real + 2 * (1 - t) * t * P1.real + t**2 * P2.real,
(1 - t) ** 2 * P0.imag + 2 * (1 - t) * t * P1.imag + t**2 * P2.imag,
)
Q3 = complex(
(1 - t) ** 3 * P0.real
+ 3 * (1 - t) ** 2 * t * P1.real
+ 3 * (1 - t) * t**2 * P2.real
+ t**3 * P3.real,
(1 - t) ** 3 * P0.imag
+ 3 * (1 - t) ** 2 * t * P1.imag
+ 3 * (1 - t) * t**2 * P2.imag
+ t**3 * P3.imag,
)
return [Q0, Q1, Q2], [Q2, Q3, P3]
hbmartin marked this conversation as resolved.
Show resolved Hide resolved


(
(
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(2 + 2j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(1.0956739766575936 + 3.808652046684813j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(3.053667401045204 + 3.5727902021338993j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(5.100619597768561 + 3.326212294969724j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(
(5.100619597768561 + 3.326212294969724j),
(7.580690054131715 + 3.027460529428433j),
(10.191347953315187 + 2.7129780700272192j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
(8 + 6j),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
),
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
)
60 changes: 35 additions & 25 deletions graphviz2drawio/mx/CurveFactory.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
from svg.path import CubicBezier, Move, Path, parse_path
import cmath

from svg.path import CubicBezier, Path, parse_path, QuadraticBezier

from .Curve import Curve
from .bezier import subdivide_inflections, approximate_cubic_bezier_as_quadratic
from ..models.CoordsTranslate import CoordsTranslate


class CurveFactory:
def __init__(self, coords) -> None:
def __init__(self, coords: CoordsTranslate) -> None:
super().__init__()
self.coords = coords

def from_svg(self, svg_path: str) -> Curve:
path: Path | list = parse_path(svg_path)
path: Path = parse_path(svg_path)
points: list[complex] = []
is_bezier = not all(map(Curve.is_linear, filter(_is_cubic, path)))

for p in path:
if isinstance(p, QuadraticBezier):
points.append(self.coords.complex_translate(p.control))
elif isinstance(p, CubicBezier):
if Curve.is_linear(p):
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
points.append(self.coords.complex_translate(p.start))
else:
split_cubes = subdivide_inflections(
p.start, p.control1, p.control2, p.end
)
for cube in split_cubes:
points.append(
hbmartin marked this conversation as resolved.
Show resolved Hide resolved
self.coords.complex_translate(
approximate_cubic_bezier_as_quadratic(*cube)[1]
)
)

start = self.coords.complex_translate(path[0].start)
end = self.coords.complex_translate(path[-1].end)
cb = None
cbset = []
if isinstance(path[0], Move):
path = [path[i] for i in range(1, len(path))]
if isinstance(path[0], CubicBezier):
# TODO: needs to account for multiple bezier in path
points = [path[0].start, path[0].control1, path[0].control2, path[0].end]
if not Curve.is_linear(points):
cb = [self.coords.complex_translate(p) for p in points]

if len(path) > 1: # set of curves/points
for p in path:
cbset.append(
(
self.coords.translate(
p.start.real,
p.start.imag,
),
self.coords.translate(p.end.real, p.end.imag),
),
)
return Curve(start=start, end=end, cb=cb, cbset=cbset)

if len(points) > 0 and cmath.isclose(start, points[0], rel_tol=0.1):
points = points[1:]

Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Magic number in cmath.isclose

The relative tolerance value 0.1 is a magic number. Consider defining it as a constant or making it a parameter.

Suggested change
if len(points) > 0 and cmath.isclose(start, points[0], rel_tol=0.1):
points = points[1:]
REL_TOLERANCE = 0.1
if len(points) > 0 and cmath.isclose(start, points[0], rel_tol=REL_TOLERANCE):
points = points[1:]

hbmartin marked this conversation as resolved.
Show resolved Hide resolved
return Curve(start=start, end=end, is_bezier=is_bezier, points=points)

def _is_cubic(p):
return isinstance(p, CubicBezier)
4 changes: 2 additions & 2 deletions graphviz2drawio/mx/Edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(
self.fr = fr
self.to = to
self.curve = curve
self.style = None
self.line_style = None
self.dir = None
self.arrowtail = None
self.label = label
Expand All @@ -34,5 +34,5 @@ def key_for_label(self) -> str:
def __repr__(self) -> str:
return (
f"{self.fr}->{self.to}: "
f"{self.label}, {self.style}, {self.dir}, {self.arrowtail}"
f"{self.label}, {self.line_style}, {self.dir}, {self.arrowtail}"
)
46 changes: 0 additions & 46 deletions graphviz2drawio/mx/LinearRegression.py

This file was deleted.

2 changes: 2 additions & 0 deletions graphviz2drawio/mx/MxConst.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@
CURVE_INTERVALS = [0.25, 0.5, 0.75]

NONE = "none"
CURVED = "curved=1;"
SHARP = "rounded=0;"
Loading
Loading