pathview-plus 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pathview/__init__.py +124 -0
- pathview/color_mapping.py +153 -0
- pathview/constants.py +27 -0
- pathview/databases.py +309 -0
- pathview/examples.py +342 -0
- pathview/highlighting.py +375 -0
- pathview/id_mapping.py +170 -0
- pathview/kegg_api.py +143 -0
- pathview/kgml_parser.py +189 -0
- pathview/mol_data.py +168 -0
- pathview/node_mapping.py +99 -0
- pathview/pathview.py +316 -0
- pathview/rendering.py +409 -0
- pathview/sbgn_parser.py +353 -0
- pathview/splines.py +304 -0
- pathview/svg_rendering.py +305 -0
- pathview/test_all_features.py +343 -0
- pathview/utils.py +80 -0
- pathview_plus-2.0.0.data/scripts/pathview-cli.py +252 -0
- pathview_plus-2.0.0.dist-info/METADATA +661 -0
- pathview_plus-2.0.0.dist-info/RECORD +23 -0
- pathview_plus-2.0.0.dist-info/WHEEL +5 -0
- pathview_plus-2.0.0.dist-info/top_level.txt +1 -0
pathview/sbgn_parser.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sbgn_parser.py
|
|
3
|
+
Parse SBGN-ML (Systems Biology Graphical Notation ML) files.
|
|
4
|
+
|
|
5
|
+
SBGN is used by Reactome, MetaCyc, MetaCrop, PANTHER, and SMPDB.
|
|
6
|
+
Supports Process Description (PD), Entity Relationship (ER), and Activity Flow (AF) languages.
|
|
7
|
+
|
|
8
|
+
Public API
|
|
9
|
+
----------
|
|
10
|
+
parse_sbgn : Path → SBGNPathway
|
|
11
|
+
sbgn_to_df : SBGNPathway → pl.DataFrame (unified with KGML format)
|
|
12
|
+
|
|
13
|
+
SBGN vs KGML differences:
|
|
14
|
+
- Glyphs (nodes) instead of entries
|
|
15
|
+
- Arcs (edges) with Bezier splines
|
|
16
|
+
- Compartments (cellular locations)
|
|
17
|
+
- Clone markers for repeated entities
|
|
18
|
+
- Process nodes (reactions, associations)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
from xml.etree import ElementTree as ET
|
|
27
|
+
|
|
28
|
+
import polars as pl
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Dataclasses
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SBGNGlyph:
|
|
37
|
+
"""One <glyph> element from SBGN-ML."""
|
|
38
|
+
glyph_id: str
|
|
39
|
+
glyph_class: str # macromolecule, simple chemical, process, etc.
|
|
40
|
+
label: str = ""
|
|
41
|
+
x: Optional[float] = None
|
|
42
|
+
y: Optional[float] = None
|
|
43
|
+
width: Optional[float] = None
|
|
44
|
+
height: Optional[float] = None
|
|
45
|
+
compartment: Optional[str] = None
|
|
46
|
+
clone_marker: bool = False
|
|
47
|
+
state_variables: list[dict] = field(default_factory=list)
|
|
48
|
+
unit_of_information: list[dict] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SBGNArc:
|
|
53
|
+
"""One <arc> element from SBGN-ML."""
|
|
54
|
+
arc_id: str
|
|
55
|
+
arc_class: str # production, consumption, catalysis, inhibition, etc.
|
|
56
|
+
source: str
|
|
57
|
+
target: str
|
|
58
|
+
spline_points: list[tuple[float, float]] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SBGNPathway:
|
|
63
|
+
"""Container for all parsed elements of an SBGN-ML file."""
|
|
64
|
+
pathway_id: str
|
|
65
|
+
pathway_name: str
|
|
66
|
+
glyphs: dict[str, SBGNGlyph] = field(default_factory=dict)
|
|
67
|
+
arcs: list[SBGNArc] = field(default_factory=list)
|
|
68
|
+
compartments: dict[str, SBGNGlyph] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# SBGN glyph classes mapping
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# Map SBGN glyph classes to simplified types for unified interface
|
|
76
|
+
_GLYPH_TYPE_MAP = {
|
|
77
|
+
# Entity pool nodes (EPN)
|
|
78
|
+
"macromolecule": "gene",
|
|
79
|
+
"simple chemical": "compound",
|
|
80
|
+
"nucleic acid feature": "gene",
|
|
81
|
+
"complex": "gene",
|
|
82
|
+
"multimer": "gene",
|
|
83
|
+
"unspecified entity": "gene",
|
|
84
|
+
|
|
85
|
+
# Process nodes (PN)
|
|
86
|
+
"process": "process",
|
|
87
|
+
"omitted process": "process",
|
|
88
|
+
"uncertain process": "process",
|
|
89
|
+
"association": "process",
|
|
90
|
+
"dissociation": "process",
|
|
91
|
+
"phenotype": "process",
|
|
92
|
+
|
|
93
|
+
# Container nodes
|
|
94
|
+
"compartment": "compartment",
|
|
95
|
+
"submap": "map",
|
|
96
|
+
|
|
97
|
+
# Logical operators
|
|
98
|
+
"and": "operator",
|
|
99
|
+
"or": "operator",
|
|
100
|
+
"not": "operator",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# XML parsing helpers
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _parse_bbox(elem: ET.Element) -> dict:
|
|
109
|
+
"""Extract bounding box from <bbox> child element."""
|
|
110
|
+
bbox = elem.find(".//bbox", namespaces={"": "http://sbgn.org/libsbgn/0.2"})
|
|
111
|
+
if bbox is None:
|
|
112
|
+
bbox = elem.find("bbox") # Try without namespace
|
|
113
|
+
|
|
114
|
+
if bbox is not None:
|
|
115
|
+
return {
|
|
116
|
+
"x": float(bbox.get("x", 0)) + float(bbox.get("w", 46)) / 2,
|
|
117
|
+
"y": float(bbox.get("y", 0)) + float(bbox.get("h", 17)) / 2,
|
|
118
|
+
"width": float(bbox.get("w", 46)),
|
|
119
|
+
"height": float(bbox.get("h", 17)),
|
|
120
|
+
}
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_label(elem: ET.Element) -> str:
|
|
125
|
+
"""Extract label text from <label> child element."""
|
|
126
|
+
label = elem.find(".//label", namespaces={"": "http://sbgn.org/libsbgn/0.2"})
|
|
127
|
+
if label is None:
|
|
128
|
+
label = elem.find("label")
|
|
129
|
+
|
|
130
|
+
if label is not None:
|
|
131
|
+
return label.get("text", "")
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_state_variables(elem: ET.Element) -> list[dict]:
|
|
136
|
+
"""Parse state variable glyphs (child glyphs with class='state variable')."""
|
|
137
|
+
states = []
|
|
138
|
+
for child in elem.findall(".//glyph[@class='state variable']"):
|
|
139
|
+
states.append({
|
|
140
|
+
"variable": child.get("variable", ""),
|
|
141
|
+
"value": child.get("value", ""),
|
|
142
|
+
"label": _parse_label(child),
|
|
143
|
+
})
|
|
144
|
+
return states
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_spline(arc_elem: ET.Element) -> list[tuple[float, float]]:
|
|
148
|
+
"""
|
|
149
|
+
Parse spline curve from arc.
|
|
150
|
+
|
|
151
|
+
SBGN-ML can have:
|
|
152
|
+
1. Straight lines: <start> and <end> points
|
|
153
|
+
2. Bezier curves: <start>, multiple <next>, <end> with control points
|
|
154
|
+
"""
|
|
155
|
+
points = []
|
|
156
|
+
|
|
157
|
+
# Start point
|
|
158
|
+
start = arc_elem.find("start")
|
|
159
|
+
if start is not None:
|
|
160
|
+
points.append((float(start.get("x", 0)), float(start.get("y", 0))))
|
|
161
|
+
|
|
162
|
+
# Intermediate points (could be Bezier control points)
|
|
163
|
+
for pt in arc_elem.findall("next"):
|
|
164
|
+
points.append((float(pt.get("x", 0)), float(pt.get("y", 0))))
|
|
165
|
+
|
|
166
|
+
# End point
|
|
167
|
+
end = arc_elem.find("end")
|
|
168
|
+
if end is not None:
|
|
169
|
+
points.append((float(end.get("x", 0)), float(end.get("y", 0))))
|
|
170
|
+
|
|
171
|
+
return points
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Main parsing functions
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def parse_sbgn(filepath: str | Path) -> SBGNPathway:
|
|
179
|
+
"""
|
|
180
|
+
Parse an SBGN-ML file and return a populated SBGNPathway.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
filepath: Path to the .sbgn or .xml SBGN-ML file
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
SBGNPathway object with all glyphs, arcs, and compartments
|
|
189
|
+
|
|
190
|
+
Example
|
|
191
|
+
-------
|
|
192
|
+
>>> pathway = parse_sbgn("R-HSA-109582.sbgn")
|
|
193
|
+
>>> print(f"Found {len(pathway.glyphs)} glyphs")
|
|
194
|
+
>>> df = sbgn_to_df(pathway)
|
|
195
|
+
"""
|
|
196
|
+
tree = ET.parse(filepath)
|
|
197
|
+
root = tree.getroot()
|
|
198
|
+
|
|
199
|
+
# Handle namespace (SBGN files often use xmlns)
|
|
200
|
+
ns = {"sbgn": "http://sbgn.org/libsbgn/0.2"}
|
|
201
|
+
map_elem = root.find(".//sbgn:map", ns)
|
|
202
|
+
if map_elem is None:
|
|
203
|
+
map_elem = root.find("map")
|
|
204
|
+
if map_elem is None:
|
|
205
|
+
map_elem = root # Fall back to root
|
|
206
|
+
|
|
207
|
+
pathway = SBGNPathway(
|
|
208
|
+
pathway_id=map_elem.get("id", Path(filepath).stem),
|
|
209
|
+
pathway_name=map_elem.get("language", "SBGN-PD"),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Parse all glyphs
|
|
213
|
+
for glyph_elem in map_elem.findall(".//glyph"):
|
|
214
|
+
glyph_id = glyph_elem.get("id", "")
|
|
215
|
+
glyph_class = glyph_elem.get("class", "")
|
|
216
|
+
|
|
217
|
+
if not glyph_id:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
bbox = _parse_bbox(glyph_elem)
|
|
221
|
+
label = _parse_label(glyph_elem)
|
|
222
|
+
|
|
223
|
+
glyph = SBGNGlyph(
|
|
224
|
+
glyph_id=glyph_id,
|
|
225
|
+
glyph_class=glyph_class,
|
|
226
|
+
label=label,
|
|
227
|
+
x=bbox.get("x"),
|
|
228
|
+
y=bbox.get("y"),
|
|
229
|
+
width=bbox.get("width"),
|
|
230
|
+
height=bbox.get("height"),
|
|
231
|
+
compartment=glyph_elem.get("compartmentRef"),
|
|
232
|
+
clone_marker="clone" in glyph_elem.attrib.get("clone", "").lower(),
|
|
233
|
+
state_variables=_parse_state_variables(glyph_elem),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if glyph_class == "compartment":
|
|
237
|
+
pathway.compartments[glyph_id] = glyph
|
|
238
|
+
else:
|
|
239
|
+
pathway.glyphs[glyph_id] = glyph
|
|
240
|
+
|
|
241
|
+
# Parse all arcs
|
|
242
|
+
for arc_elem in map_elem.findall(".//arc"):
|
|
243
|
+
arc_id = arc_elem.get("id", "")
|
|
244
|
+
arc_class = arc_elem.get("class", "")
|
|
245
|
+
source = arc_elem.get("source", "")
|
|
246
|
+
target = arc_elem.get("target", "")
|
|
247
|
+
|
|
248
|
+
if not all([arc_id, source, target]):
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
arc = SBGNArc(
|
|
252
|
+
arc_id=arc_id,
|
|
253
|
+
arc_class=arc_class,
|
|
254
|
+
source=source,
|
|
255
|
+
target=target,
|
|
256
|
+
spline_points=_parse_spline(arc_elem),
|
|
257
|
+
)
|
|
258
|
+
pathway.arcs.append(arc)
|
|
259
|
+
|
|
260
|
+
return pathway
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def sbgn_to_df(pathway: SBGNPathway) -> pl.DataFrame:
|
|
264
|
+
"""
|
|
265
|
+
Convert SBGN pathway to a unified Polars DataFrame (compatible with KGML format).
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
pathway: Parsed SBGNPathway object
|
|
270
|
+
|
|
271
|
+
Returns
|
|
272
|
+
-------
|
|
273
|
+
DataFrame with columns matching KGML node_info format:
|
|
274
|
+
entry_id, name, type, x, y, width, height, label, shape, etc.
|
|
275
|
+
|
|
276
|
+
This allows SBGN pathways to use the same rendering pipeline as KEGG.
|
|
277
|
+
"""
|
|
278
|
+
records = []
|
|
279
|
+
|
|
280
|
+
for glyph_id, glyph in pathway.glyphs.items():
|
|
281
|
+
# Map SBGN class to simplified type
|
|
282
|
+
node_type = _GLYPH_TYPE_MAP.get(glyph.glyph_class, "unknown")
|
|
283
|
+
|
|
284
|
+
# Determine shape
|
|
285
|
+
shape_map = {
|
|
286
|
+
"macromolecule": "roundedrectangle",
|
|
287
|
+
"simple chemical": "ellipse",
|
|
288
|
+
"complex": "octagon",
|
|
289
|
+
"process": "square",
|
|
290
|
+
}
|
|
291
|
+
shape = shape_map.get(glyph.glyph_class, "rectangle")
|
|
292
|
+
|
|
293
|
+
records.append({
|
|
294
|
+
"entry_id": glyph_id,
|
|
295
|
+
"name": glyph_id, # SBGN IDs are typically database IDs
|
|
296
|
+
"type": node_type,
|
|
297
|
+
"x": glyph.x,
|
|
298
|
+
"y": glyph.y,
|
|
299
|
+
"width": glyph.width,
|
|
300
|
+
"height": glyph.height,
|
|
301
|
+
"bgcolor": "#FFFFFF",
|
|
302
|
+
"label": glyph.label or glyph_id,
|
|
303
|
+
"shape": shape,
|
|
304
|
+
"reaction": "",
|
|
305
|
+
"component": "",
|
|
306
|
+
"size": 1,
|
|
307
|
+
"kegg_names": glyph_id, # For ID mapping
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
return pl.DataFrame(records) if records else pl.DataFrame()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# SBGN glyph class reference
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
SBGN_GLYPH_CLASSES = {
|
|
318
|
+
# Entity Pool Nodes (EPN)
|
|
319
|
+
"macromolecule": "Protein, gene product",
|
|
320
|
+
"simple chemical": "Small molecule, metabolite",
|
|
321
|
+
"nucleic acid feature": "DNA, RNA fragment",
|
|
322
|
+
"complex": "Molecular complex",
|
|
323
|
+
"multimer": "Homogeneous multimer",
|
|
324
|
+
"unspecified entity": "Unknown entity type",
|
|
325
|
+
|
|
326
|
+
# Process Nodes (PN)
|
|
327
|
+
"process": "Biochemical process",
|
|
328
|
+
"omitted process": "Process details omitted",
|
|
329
|
+
"uncertain process": "Uncertain process",
|
|
330
|
+
"association": "Complex formation",
|
|
331
|
+
"dissociation": "Complex dissociation",
|
|
332
|
+
"phenotype": "Observable phenotype",
|
|
333
|
+
|
|
334
|
+
# Containers
|
|
335
|
+
"compartment": "Cellular compartment",
|
|
336
|
+
"submap": "Link to another map",
|
|
337
|
+
|
|
338
|
+
# Logical operators
|
|
339
|
+
"and": "Logical AND",
|
|
340
|
+
"or": "Logical OR",
|
|
341
|
+
"not": "Logical NOT",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
SBGN_ARC_CLASSES = {
|
|
345
|
+
"production": "Product of process",
|
|
346
|
+
"consumption": "Consumed by process",
|
|
347
|
+
"catalysis": "Catalyzes process",
|
|
348
|
+
"modulation": "Modulates process",
|
|
349
|
+
"stimulation": "Stimulates process",
|
|
350
|
+
"inhibition": "Inhibits process",
|
|
351
|
+
"necessary stimulation": "Required stimulator",
|
|
352
|
+
"logic arc": "Logical operator connection",
|
|
353
|
+
}
|
pathview/splines.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
splines.py
|
|
3
|
+
Bezier curve (spline) rendering for pathway edges.
|
|
4
|
+
|
|
5
|
+
Provides smoother, more aesthetically pleasing edge routing compared to
|
|
6
|
+
straight lines. Particularly useful for complex pathways with many crossings.
|
|
7
|
+
|
|
8
|
+
Public API
|
|
9
|
+
----------
|
|
10
|
+
cubic_bezier : Calculate points along a cubic Bezier curve
|
|
11
|
+
quadratic_bezier : Calculate points along a quadratic Bezier curve
|
|
12
|
+
catmull_rom_spline : Calculate smooth curve through control points
|
|
13
|
+
route_edge_spline : Auto-route an edge avoiding obstacles
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Bezier curves
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def cubic_bezier(
|
|
28
|
+
p0: tuple[float, float],
|
|
29
|
+
p1: tuple[float, float],
|
|
30
|
+
p2: tuple[float, float],
|
|
31
|
+
p3: tuple[float, float],
|
|
32
|
+
n_points: int = 50,
|
|
33
|
+
) -> np.ndarray:
|
|
34
|
+
"""
|
|
35
|
+
Calculate points along a cubic Bezier curve.
|
|
36
|
+
|
|
37
|
+
A cubic Bezier is defined by 4 control points:
|
|
38
|
+
- p0: start point
|
|
39
|
+
- p1: first control point
|
|
40
|
+
- p2: second control point
|
|
41
|
+
- p3: end point
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
p0, p1, p2, p3: Control points as (x, y) tuples
|
|
46
|
+
n_points: Number of points to sample along the curve
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
Array of shape (n_points, 2) containing (x, y) coordinates.
|
|
51
|
+
|
|
52
|
+
Example
|
|
53
|
+
-------
|
|
54
|
+
>>> curve = cubic_bezier((0, 0), (1, 2), (3, 2), (4, 0), n_points=100)
|
|
55
|
+
>>> plt.plot(curve[:, 0], curve[:, 1])
|
|
56
|
+
"""
|
|
57
|
+
t = np.linspace(0, 1, n_points)[:, np.newaxis]
|
|
58
|
+
|
|
59
|
+
# Cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
|
60
|
+
p0 = np.array(p0)
|
|
61
|
+
p1 = np.array(p1)
|
|
62
|
+
p2 = np.array(p2)
|
|
63
|
+
p3 = np.array(p3)
|
|
64
|
+
|
|
65
|
+
curve = (
|
|
66
|
+
(1 - t)**3 * p0
|
|
67
|
+
+ 3 * (1 - t)**2 * t * p1
|
|
68
|
+
+ 3 * (1 - t) * t**2 * p2
|
|
69
|
+
+ t**3 * p3
|
|
70
|
+
)
|
|
71
|
+
return curve
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def quadratic_bezier(
|
|
75
|
+
p0: tuple[float, float],
|
|
76
|
+
p1: tuple[float, float],
|
|
77
|
+
p2: tuple[float, float],
|
|
78
|
+
n_points: int = 50,
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
"""
|
|
81
|
+
Calculate points along a quadratic Bezier curve.
|
|
82
|
+
|
|
83
|
+
A quadratic Bezier is defined by 3 control points:
|
|
84
|
+
- p0: start point
|
|
85
|
+
- p1: control point
|
|
86
|
+
- p2: end point
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
p0, p1, p2: Control points as (x, y) tuples
|
|
91
|
+
n_points: Number of points to sample
|
|
92
|
+
|
|
93
|
+
Returns array of shape (n_points, 2).
|
|
94
|
+
"""
|
|
95
|
+
t = np.linspace(0, 1, n_points)[:, np.newaxis]
|
|
96
|
+
|
|
97
|
+
# Quadratic Bezier: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
|
|
98
|
+
p0 = np.array(p0)
|
|
99
|
+
p1 = np.array(p1)
|
|
100
|
+
p2 = np.array(p2)
|
|
101
|
+
|
|
102
|
+
curve = (1 - t)**2 * p0 + 2 * (1 - t) * t * p1 + t**2 * p2
|
|
103
|
+
return curve
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Catmull-Rom splines
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def catmull_rom_spline(
|
|
111
|
+
points: list[tuple[float, float]],
|
|
112
|
+
n_points: int = 50,
|
|
113
|
+
alpha: float = 0.5,
|
|
114
|
+
) -> np.ndarray:
|
|
115
|
+
"""
|
|
116
|
+
Calculate a smooth Catmull-Rom spline through control points.
|
|
117
|
+
|
|
118
|
+
Catmull-Rom splines pass through all control points (interpolating spline)
|
|
119
|
+
and produce smooth curves. The 'alpha' parameter controls the
|
|
120
|
+
parameterization:
|
|
121
|
+
- alpha = 0.0: uniform (can produce loops)
|
|
122
|
+
- alpha = 0.5: centripetal (most common, no loops/cusps)
|
|
123
|
+
- alpha = 1.0: chordal
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
points: List of (x, y) control points to interpolate
|
|
128
|
+
n_points: Number of points to sample between each pair
|
|
129
|
+
alpha: Parameterization (0.5 = centripetal, recommended)
|
|
130
|
+
|
|
131
|
+
Returns array of shape (total_points, 2).
|
|
132
|
+
|
|
133
|
+
Example
|
|
134
|
+
-------
|
|
135
|
+
>>> control_pts = [(0, 0), (1, 2), (3, 1), (4, 3)]
|
|
136
|
+
>>> smooth_curve = catmull_rom_spline(control_pts, n_points=30)
|
|
137
|
+
"""
|
|
138
|
+
if len(points) < 2:
|
|
139
|
+
return np.array(points)
|
|
140
|
+
|
|
141
|
+
# Add phantom points at start and end
|
|
142
|
+
p = [points[0]] + points + [points[-1]]
|
|
143
|
+
curves = []
|
|
144
|
+
|
|
145
|
+
for i in range(len(p) - 3):
|
|
146
|
+
p0, p1, p2, p3 = p[i], p[i+1], p[i+2], p[i+3]
|
|
147
|
+
|
|
148
|
+
# Calculate segment lengths
|
|
149
|
+
t0 = 0
|
|
150
|
+
t1 = t0 + _distance(p0, p1) ** alpha
|
|
151
|
+
t2 = t1 + _distance(p1, p2) ** alpha
|
|
152
|
+
t3 = t2 + _distance(p2, p3) ** alpha
|
|
153
|
+
|
|
154
|
+
# Sample points in the valid range [t1, t2]
|
|
155
|
+
t = np.linspace(t1, t2, n_points)
|
|
156
|
+
|
|
157
|
+
# Catmull-Rom basis functions
|
|
158
|
+
for ti in t:
|
|
159
|
+
a1 = (t1 - ti) / (t1 - t0) * np.array(p0) + (ti - t0) / (t1 - t0) * np.array(p1)
|
|
160
|
+
a2 = (t2 - ti) / (t2 - t1) * np.array(p1) + (ti - t1) / (t2 - t1) * np.array(p2)
|
|
161
|
+
a3 = (t3 - ti) / (t3 - t2) * np.array(p2) + (ti - t2) / (t3 - t2) * np.array(p3)
|
|
162
|
+
|
|
163
|
+
b1 = (t2 - ti) / (t2 - t0) * a1 + (ti - t0) / (t2 - t0) * a2
|
|
164
|
+
b2 = (t3 - ti) / (t3 - t1) * a2 + (ti - t1) / (t3 - t1) * a3
|
|
165
|
+
|
|
166
|
+
c = (t2 - ti) / (t2 - t1) * b1 + (ti - t1) / (t2 - t1) * b2
|
|
167
|
+
curves.append(c)
|
|
168
|
+
|
|
169
|
+
return np.array(curves)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _distance(p1: tuple[float, float], p2: tuple[float, float]) -> float:
|
|
173
|
+
"""Euclidean distance between two points."""
|
|
174
|
+
return np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Auto-routing
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def route_edge_spline(
|
|
182
|
+
source: tuple[float, float],
|
|
183
|
+
target: tuple[float, float],
|
|
184
|
+
obstacles: Optional[list[tuple[float, float, float, float]]] = None,
|
|
185
|
+
routing_mode: str = "orthogonal",
|
|
186
|
+
) -> np.ndarray:
|
|
187
|
+
"""
|
|
188
|
+
Auto-route an edge between source and target, avoiding obstacles.
|
|
189
|
+
|
|
190
|
+
This is a simplified routing algorithm. For production use, consider
|
|
191
|
+
more sophisticated routing like:
|
|
192
|
+
- A* pathfinding
|
|
193
|
+
- Visibility graphs
|
|
194
|
+
- Force-directed edge bundling
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
source: (x, y) starting point
|
|
199
|
+
target: (x, y) ending point
|
|
200
|
+
obstacles: List of (x, y, width, height) rectangles to avoid
|
|
201
|
+
routing_mode: "straight", "orthogonal", or "curved"
|
|
202
|
+
|
|
203
|
+
Returns array of points defining the routed path.
|
|
204
|
+
"""
|
|
205
|
+
if routing_mode == "straight" or obstacles is None:
|
|
206
|
+
return np.array([source, target])
|
|
207
|
+
|
|
208
|
+
if routing_mode == "orthogonal":
|
|
209
|
+
# Simple orthogonal routing (Manhattan-style)
|
|
210
|
+
sx, sy = source
|
|
211
|
+
tx, ty = target
|
|
212
|
+
midx = (sx + tx) / 2
|
|
213
|
+
|
|
214
|
+
control_points = [
|
|
215
|
+
source,
|
|
216
|
+
(midx, sy),
|
|
217
|
+
(midx, ty),
|
|
218
|
+
target,
|
|
219
|
+
]
|
|
220
|
+
return catmull_rom_spline(control_points, n_points=20)
|
|
221
|
+
|
|
222
|
+
elif routing_mode == "curved":
|
|
223
|
+
# Gentle S-curve
|
|
224
|
+
sx, sy = source
|
|
225
|
+
tx, ty = target
|
|
226
|
+
|
|
227
|
+
# Control points for cubic Bezier
|
|
228
|
+
dx = tx - sx
|
|
229
|
+
dy = ty - sy
|
|
230
|
+
c1 = (sx + dx * 0.3, sy + dy * 0.1)
|
|
231
|
+
c2 = (sx + dx * 0.7, sy + dy * 0.9)
|
|
232
|
+
|
|
233
|
+
return cubic_bezier(source, c1, c2, target, n_points=30)
|
|
234
|
+
|
|
235
|
+
return np.array([source, target])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# SVG path generation
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def bezier_to_svg_path(
|
|
243
|
+
curve: np.ndarray,
|
|
244
|
+
close: bool = False,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Convert a Bezier curve to SVG path data.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
curve: Array of shape (n, 2) containing (x, y) points
|
|
252
|
+
close: Whether to close the path (Z command)
|
|
253
|
+
|
|
254
|
+
Returns SVG path data string (for use in <path d="..."/>)
|
|
255
|
+
|
|
256
|
+
Example
|
|
257
|
+
-------
|
|
258
|
+
>>> curve = cubic_bezier((10, 10), (50, 80), (150, 80), (200, 10))
|
|
259
|
+
>>> path_data = bezier_to_svg_path(curve)
|
|
260
|
+
>>> svg = f'<path d="{path_data}" stroke="black" fill="none"/>'
|
|
261
|
+
"""
|
|
262
|
+
if len(curve) == 0:
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
path_parts = [f"M {curve[0, 0]:.2f} {curve[0, 1]:.2f}"]
|
|
266
|
+
|
|
267
|
+
for point in curve[1:]:
|
|
268
|
+
path_parts.append(f"L {point[0]:.2f} {point[1]:.2f}")
|
|
269
|
+
|
|
270
|
+
if close:
|
|
271
|
+
path_parts.append("Z")
|
|
272
|
+
|
|
273
|
+
return " ".join(path_parts)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def smooth_path_svg(
|
|
277
|
+
points: list[tuple[float, float]],
|
|
278
|
+
tension: float = 0.5,
|
|
279
|
+
) -> str:
|
|
280
|
+
"""
|
|
281
|
+
Generate smooth SVG path using quadratic Bezier commands.
|
|
282
|
+
|
|
283
|
+
Parameters
|
|
284
|
+
----------
|
|
285
|
+
points: List of (x, y) waypoints
|
|
286
|
+
tension: Curve tension (0 = sharp corners, 1 = very smooth)
|
|
287
|
+
|
|
288
|
+
Returns SVG path data using S (smooth cubic bezier) commands.
|
|
289
|
+
"""
|
|
290
|
+
if len(points) < 2:
|
|
291
|
+
return ""
|
|
292
|
+
|
|
293
|
+
path_parts = [f"M {points[0][0]} {points[0][1]}"]
|
|
294
|
+
|
|
295
|
+
for i in range(1, len(points)):
|
|
296
|
+
x, y = points[i]
|
|
297
|
+
if i == 1:
|
|
298
|
+
# First curve segment uses Q (quadratic)
|
|
299
|
+
path_parts.append(f"Q {x} {y} {x} {y}")
|
|
300
|
+
else:
|
|
301
|
+
# Subsequent segments use T (smooth continuation)
|
|
302
|
+
path_parts.append(f"T {x} {y}")
|
|
303
|
+
|
|
304
|
+
return " ".join(path_parts)
|