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 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
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/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

def main() -> None:

args = Arguments(__version__).parse_args()
args = Arguments(__version__).parse_args() # pytype: disable=not-callable

if not args.stdout:
stderr.write("This is beta software, please report issues to:\n")
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
108 changes: 62 additions & 46 deletions graphviz2drawio/mx/Curve.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,77 @@
from graphviz2drawio.models.Errors import InvalidCbError
import math
from collections.abc import Callable

from . import LinearRegression
from svg.path import CubicBezier

linear_min_r2 = 0.9
LINE_TOLERANCE = 0.01


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."""
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}"
return f"{self.start} -> {self.end} {self.is_bezier} ({self.points})"

@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:
"""Determine if the cubic Bézier is a straight line."""
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)

def cubic_bezier_coordinates(self, t: float) -> complex:
"""Calculate a complex number representing a point along the cubic Bézier curve.
if y is None:
return Curve.is_linear(_rotate_bezier(cb))

Takes parametric parameter t where 0 <= t <= 1
"""
x = Curve._cubic_bezier(self._cb("real"), t)
y = Curve._cubic_bezier(self._cb("imag"), t)
return complex(x, y)
return math.isclose(
y(cb.control1.real),
cb.control1.imag,
rel_tol=LINE_TOLERANCE,
) and math.isclose(
y(cb.control2.real),
cb.control2.imag,
rel_tol=LINE_TOLERANCE,
)

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

@staticmethod
def _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) = (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])
+ (3.0 * (t**2) * (1.0 - t) * p[2])
+ ((t**3) * p[3])
)
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),
)
72 changes: 47 additions & 25 deletions graphviz2drawio/mx/CurveFactory.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,58 @@
from svg.path import CubicBezier, Move, Path, parse_path
import cmath

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

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

CLOSE_POINT_TOLERANCE = 0.1


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 segment in path:
if isinstance(segment, QuadraticBezier):
points.append(self.coords.complex_translate(segment.control))
elif isinstance(segment, CubicBezier):
if Curve.is_linear(segment):
points.append(self.coords.complex_translate(segment.start))
else:
split_cubes = subdivide_inflections(
segment.start,
segment.control1,
segment.control2,
segment.end,
)
split_controls = [
self.coords.complex_translate(
approximate_cubic_bezier_as_quadratic(*cube)[1],
)
for cube in split_cubes
if cube
]
points.extend(split_controls)

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=CLOSE_POINT_TOLERANCE,
):
points = points[1:]

return Curve(start=start, end=end, is_bezier=is_bezier, points=points)


def _is_cubic(p):
return isinstance(p, CubicBezier)
6 changes: 4 additions & 2 deletions graphviz2drawio/mx/Edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@


class Edge(GraphObj):
"""An edge connecting two nodes in the graph."""

def __init__(
self,
sid: str,
Expand All @@ -17,7 +19,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 +36,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