Skip to content

Commit

Permalink
Merge pull request #81 from hbmartin/curved
Browse files Browse the repository at this point in the history
Straight line edge conversion for (poly)lines and improved curve conversion
  • Loading branch information
hbmartin authored Jul 6, 2024
2 parents 2531762 + 9cec411 commit 295cb67
Show file tree
Hide file tree
Showing 18 changed files with 438 additions and 165 deletions.
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
- [ ] 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

0 comments on commit 295cb67

Please sign in to comment.