vecwire 0.3.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.
- vecwire/__init__.py +14 -0
- vecwire/core.py +529 -0
- vecwire/elements/__init__.py +24 -0
- vecwire/elements/ground.py +43 -0
- vecwire/elements/junctions.py +270 -0
- vecwire/elements/passive.py +62 -0
- vecwire/geometry.py +120 -0
- vecwire/node.py +22 -0
- vecwire/py.typed +0 -0
- vecwire-0.3.0.dist-info/METADATA +281 -0
- vecwire-0.3.0.dist-info/RECORD +13 -0
- vecwire-0.3.0.dist-info/WHEEL +4 -0
- vecwire-0.3.0.dist-info/licenses/LICENSE +21 -0
vecwire/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Editable SVG schematics for electrical and circuit-QED circuits.
|
|
2
|
+
|
|
3
|
+
``Circuit`` (in ``vecwire.core``) is the canvas; the symbols live in
|
|
4
|
+
``vecwire.elements`` and are also available as ``Circuit`` methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import elements
|
|
8
|
+
from .core import Circuit
|
|
9
|
+
from .geometry import Box, Branch
|
|
10
|
+
from .node import Node
|
|
11
|
+
|
|
12
|
+
__version__ = "0.3.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["Box", "Branch", "Circuit", "Node", "__version__", "elements"]
|
vecwire/core.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""The schematic canvas: nodes, wires, labels, enclosures, and the SVG document.
|
|
2
|
+
|
|
3
|
+
``Circuit`` owns everything that is not a circuit symbol: the SVG root and its
|
|
4
|
+
ids, placed nodes, wire routing, labels, dashed boxes, and serialization. It
|
|
5
|
+
also offers the small drawing API the symbols in ``vecwire.elements`` are built
|
|
6
|
+
from (``branch``, ``group``, ``line``, ``rect``, ``dot``, ``path``), so a new
|
|
7
|
+
symbol is written against that API alone and never needs a change here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from itertools import pairwise
|
|
15
|
+
from math import acos, hypot, tan
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from lxml import etree
|
|
20
|
+
|
|
21
|
+
from . import elements
|
|
22
|
+
from .geometry import Box, Branch, Point, nonnegative, point, positive
|
|
23
|
+
from .node import Node
|
|
24
|
+
|
|
25
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
26
|
+
|
|
27
|
+
type NodeRef = Node | str
|
|
28
|
+
# A path command letter followed by its numbers, e.g. ``("M", x, y)``.
|
|
29
|
+
type PathCommand = tuple[str, *tuple[float, ...]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class Fragment(Protocol):
|
|
34
|
+
"""An SVG fragment accepted as a label, such as a VecTeX fragment.
|
|
35
|
+
|
|
36
|
+
It has a size in user units and serializes to one SVG element, usually a
|
|
37
|
+
``<g>``, with its origin at the top-left corner. Matching is structural:
|
|
38
|
+
any object with these three members qualifies, so VecTeX stays optional.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def width(self) -> float: ...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def height(self) -> float: ...
|
|
46
|
+
|
|
47
|
+
def to_svg(self) -> str: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _number(value: float) -> str:
|
|
51
|
+
# Twelve significant digits hide floating-point noise, keeping output stable.
|
|
52
|
+
return format(float(value), ".12g")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _tag(name: str) -> str:
|
|
56
|
+
return f"{{{SVG_NS}}}{name}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Circuit:
|
|
60
|
+
"""A schematic drawn in SVG user units (usually points).
|
|
61
|
+
|
|
62
|
+
Nodes are named, positioned connection points. Components include their own
|
|
63
|
+
leads, while ``wire`` joins nodes and optional route points shape the path.
|
|
64
|
+
Horizontal and vertical placement are supported for two-terminal symbols.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
width: float,
|
|
70
|
+
height: float,
|
|
71
|
+
*,
|
|
72
|
+
unit: str = "pt",
|
|
73
|
+
stroke: str = "#262626",
|
|
74
|
+
stroke_width: float = 0.9,
|
|
75
|
+
linecap: str = "round",
|
|
76
|
+
linejoin: str = "round",
|
|
77
|
+
corner_radius: float = 0,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.width = positive(width, "width")
|
|
80
|
+
self.height = positive(height, "height")
|
|
81
|
+
if unit not in {"pt", "px", "mm"}:
|
|
82
|
+
raise ValueError("unit must be 'pt', 'px', or 'mm'")
|
|
83
|
+
self.unit = unit
|
|
84
|
+
self.stroke = stroke
|
|
85
|
+
self.stroke_width = positive(stroke_width, "stroke_width")
|
|
86
|
+
self.linecap = self._linecap(linecap)
|
|
87
|
+
self.linejoin = self._linejoin(linejoin)
|
|
88
|
+
self.corner_radius = nonnegative(corner_radius, "corner_radius")
|
|
89
|
+
self._root = etree.Element(
|
|
90
|
+
_tag("svg"),
|
|
91
|
+
# lxml-stubs types nsmap as Mapping[str, str], but lxml takes the
|
|
92
|
+
# None key for the default namespace; without it every tag is prefixed.
|
|
93
|
+
nsmap={None: SVG_NS}, # ty: ignore[invalid-argument-type]
|
|
94
|
+
width=f"{_number(self.width)}{unit}",
|
|
95
|
+
height=f"{_number(self.height)}{unit}",
|
|
96
|
+
viewBox=f"0 0 {_number(self.width)} {_number(self.height)}",
|
|
97
|
+
)
|
|
98
|
+
self._ids: set[str] = set()
|
|
99
|
+
self._nodes: dict[str, Node] = {}
|
|
100
|
+
self._visible_nodes: list[etree._Element] = []
|
|
101
|
+
|
|
102
|
+
# Built-in symbols. Each is a plain function in ``vecwire.elements`` whose
|
|
103
|
+
# first argument is the circuit, so binding it here makes
|
|
104
|
+
# ``circuit.capacitor(a, b)`` the same call as ``capacitor(circuit, a, b)``.
|
|
105
|
+
capacitor = elements.capacitor
|
|
106
|
+
inductor = elements.inductor
|
|
107
|
+
junction = elements.junction
|
|
108
|
+
harmonic_junction = elements.harmonic_junction
|
|
109
|
+
harmonic_sum = elements.harmonic_sum
|
|
110
|
+
ground = elements.ground
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _linecap(value: str) -> str:
|
|
114
|
+
if value not in {"butt", "round", "square"}:
|
|
115
|
+
raise ValueError("linecap must be butt, round, or square")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _linejoin(value: str) -> str:
|
|
120
|
+
if value not in {"miter", "round", "bevel"}:
|
|
121
|
+
raise ValueError("linejoin must be miter, round, or bevel")
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------ nodes
|
|
125
|
+
|
|
126
|
+
def node(
|
|
127
|
+
self,
|
|
128
|
+
name: str,
|
|
129
|
+
at: Point,
|
|
130
|
+
*,
|
|
131
|
+
dot: bool = False,
|
|
132
|
+
radius: float = 1.6,
|
|
133
|
+
color: str | None = None,
|
|
134
|
+
) -> Node:
|
|
135
|
+
"""Create a positioned connection point and optionally show its dot."""
|
|
136
|
+
x, y = point(at)
|
|
137
|
+
radius = positive(radius, "radius")
|
|
138
|
+
group = self.group("node", name, data={"x": _number(x), "y": _number(y)})
|
|
139
|
+
node = Node(name, x, y)
|
|
140
|
+
self._nodes[name] = node
|
|
141
|
+
if dot:
|
|
142
|
+
self.dot(group, (x, y), radius, color)
|
|
143
|
+
self._visible_nodes.append(group)
|
|
144
|
+
return node
|
|
145
|
+
|
|
146
|
+
def nodes(self, *, dots: Collection[str] = (), **positions: Point) -> dict[str, Node]:
|
|
147
|
+
"""Declare several named nodes together; ``dots`` selects visible junctions."""
|
|
148
|
+
unknown = set(dots) - positions.keys()
|
|
149
|
+
if unknown:
|
|
150
|
+
raise ValueError(f"Dots refer to undeclared nodes: {sorted(unknown)!r}")
|
|
151
|
+
return {name: self.node(name, at, dot=name in dots) for name, at in positions.items()}
|
|
152
|
+
|
|
153
|
+
def row(self, y: float, *, dots: Collection[str] = (), **x_positions: float) -> dict[str, Node]:
|
|
154
|
+
"""Declare nodes sharing a y coordinate; this draws no wire."""
|
|
155
|
+
return self.nodes(dots=dots, **{name: (x, y) for name, x in x_positions.items()})
|
|
156
|
+
|
|
157
|
+
def column(
|
|
158
|
+
self, x: float, *, dots: Collection[str] = (), **y_positions: float
|
|
159
|
+
) -> dict[str, Node]:
|
|
160
|
+
"""Declare nodes sharing an x coordinate; this draws no wire."""
|
|
161
|
+
return self.nodes(dots=dots, **{name: (x, y) for name, y in y_positions.items()})
|
|
162
|
+
|
|
163
|
+
def resolve(self, node: NodeRef) -> Node:
|
|
164
|
+
"""Return the ``Node`` named by ``node``, which must belong to this circuit."""
|
|
165
|
+
if isinstance(node, str):
|
|
166
|
+
if node not in self._nodes:
|
|
167
|
+
raise ValueError(f"Unknown node: {node!r}")
|
|
168
|
+
return self._nodes[node]
|
|
169
|
+
if not isinstance(node, Node) or self._nodes.get(node.name) is not node:
|
|
170
|
+
raise ValueError("Expected a Node created by this Circuit")
|
|
171
|
+
return node
|
|
172
|
+
|
|
173
|
+
def branch(self, start: NodeRef, end: NodeRef) -> Branch:
|
|
174
|
+
"""Return the straight run between two terminals of a two-terminal symbol."""
|
|
175
|
+
return Branch(self.resolve(start), self.resolve(end))
|
|
176
|
+
|
|
177
|
+
def _position(self, at: NodeRef | Point) -> Point:
|
|
178
|
+
return self.resolve(at).position if isinstance(at, (Node, str)) else point(at)
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------ drawing API
|
|
181
|
+
|
|
182
|
+
def group(
|
|
183
|
+
self,
|
|
184
|
+
kind: str,
|
|
185
|
+
id: str | None = None,
|
|
186
|
+
*,
|
|
187
|
+
parent: etree._Element | None = None,
|
|
188
|
+
branch: Branch | None = None,
|
|
189
|
+
data: Mapping[str, object] | None = None,
|
|
190
|
+
) -> etree._Element:
|
|
191
|
+
"""Open a semantic SVG group ``<g data-component=kind>`` for one drawn item.
|
|
192
|
+
|
|
193
|
+
``id`` must be unique in the document; node names share the same ids.
|
|
194
|
+
``branch`` records the terminals as ``data-from`` and ``data-to``, and
|
|
195
|
+
``data`` adds further ``data-*`` attributes in the order given. Groups
|
|
196
|
+
are appended to the document root unless ``parent`` is given.
|
|
197
|
+
"""
|
|
198
|
+
if id is not None:
|
|
199
|
+
if not id or id in self._ids:
|
|
200
|
+
raise ValueError(f"Duplicate or empty SVG id: {id!r}")
|
|
201
|
+
self._ids.add(id)
|
|
202
|
+
attrs = {"data-component": kind}
|
|
203
|
+
if id is not None:
|
|
204
|
+
attrs["id"] = id
|
|
205
|
+
group = etree.SubElement(self._root if parent is None else parent, _tag("g"), attrs)
|
|
206
|
+
if branch is not None:
|
|
207
|
+
group.set("data-from", branch.start.name)
|
|
208
|
+
group.set("data-to", branch.end.name)
|
|
209
|
+
for key, value in (data or {}).items():
|
|
210
|
+
group.set(f"data-{key}", str(value))
|
|
211
|
+
return group
|
|
212
|
+
|
|
213
|
+
def line(
|
|
214
|
+
self,
|
|
215
|
+
parent: etree._Element,
|
|
216
|
+
start: Point,
|
|
217
|
+
end: Point,
|
|
218
|
+
*,
|
|
219
|
+
color: str | None = None,
|
|
220
|
+
width: float | None = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Draw a straight stroke; colour and width default to the circuit ink."""
|
|
223
|
+
etree.SubElement(
|
|
224
|
+
parent,
|
|
225
|
+
_tag("line"),
|
|
226
|
+
{
|
|
227
|
+
"x1": _number(start[0]),
|
|
228
|
+
"y1": _number(start[1]),
|
|
229
|
+
"x2": _number(end[0]),
|
|
230
|
+
"y2": _number(end[1]),
|
|
231
|
+
"stroke": color or self.stroke,
|
|
232
|
+
"stroke-width": _number(width or self.stroke_width),
|
|
233
|
+
"stroke-linecap": self.linecap,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def path(
|
|
238
|
+
self,
|
|
239
|
+
parent: etree._Element,
|
|
240
|
+
commands: Iterable[PathCommand],
|
|
241
|
+
*,
|
|
242
|
+
color: str | None = None,
|
|
243
|
+
width: float | None = None,
|
|
244
|
+
linecap: str | None = None,
|
|
245
|
+
linejoin: str | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Draw an unfilled path from commands such as ``("M", x, y)`` or ``("A", ...)``.
|
|
248
|
+
|
|
249
|
+
Numbers are formatted here, so every symbol serializes coordinates the
|
|
250
|
+
same way and the output stays byte-stable.
|
|
251
|
+
"""
|
|
252
|
+
d = " ".join(
|
|
253
|
+
" ".join([name, *(_number(value) for value in values)]) for name, *values in commands
|
|
254
|
+
)
|
|
255
|
+
etree.SubElement(
|
|
256
|
+
parent,
|
|
257
|
+
_tag("path"),
|
|
258
|
+
{
|
|
259
|
+
"d": d,
|
|
260
|
+
"fill": "none",
|
|
261
|
+
"stroke": color or self.stroke,
|
|
262
|
+
"stroke-width": _number(width or self.stroke_width),
|
|
263
|
+
"stroke-linecap": self.linecap if linecap is None else self._linecap(linecap),
|
|
264
|
+
"stroke-linejoin": self.linejoin if linejoin is None else self._linejoin(linejoin),
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def rect(
|
|
269
|
+
self,
|
|
270
|
+
parent: etree._Element,
|
|
271
|
+
first: Point,
|
|
272
|
+
second: Point,
|
|
273
|
+
*,
|
|
274
|
+
color: str | None = None,
|
|
275
|
+
width: float | None = None,
|
|
276
|
+
corner_radius: float = 0,
|
|
277
|
+
dash: Sequence[float] = (),
|
|
278
|
+
) -> Box:
|
|
279
|
+
"""Draw an unfilled rectangle between two opposite corners; return its bounds."""
|
|
280
|
+
left, right = sorted((first[0], second[0]))
|
|
281
|
+
top, bottom = sorted((first[1], second[1]))
|
|
282
|
+
attrs = {
|
|
283
|
+
"x": _number(left),
|
|
284
|
+
"y": _number(top),
|
|
285
|
+
"width": _number(right - left),
|
|
286
|
+
"height": _number(bottom - top),
|
|
287
|
+
"fill": "none",
|
|
288
|
+
"stroke": color or self.stroke,
|
|
289
|
+
"stroke-width": _number(width or self.stroke_width),
|
|
290
|
+
"stroke-linejoin": self.linejoin,
|
|
291
|
+
}
|
|
292
|
+
if corner_radius:
|
|
293
|
+
attrs["rx"] = attrs["ry"] = _number(corner_radius)
|
|
294
|
+
if dash:
|
|
295
|
+
attrs["stroke-dasharray"] = " ".join(
|
|
296
|
+
_number(positive(value, "dash length")) for value in dash
|
|
297
|
+
)
|
|
298
|
+
etree.SubElement(parent, _tag("rect"), attrs)
|
|
299
|
+
return Box(left, top, right, bottom)
|
|
300
|
+
|
|
301
|
+
def dot(
|
|
302
|
+
self, parent: etree._Element, at: Point, radius: float, color: str | None = None
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Draw a filled circle, e.g. a connection dot."""
|
|
305
|
+
etree.SubElement(
|
|
306
|
+
parent,
|
|
307
|
+
_tag("circle"),
|
|
308
|
+
{
|
|
309
|
+
"cx": _number(at[0]),
|
|
310
|
+
"cy": _number(at[1]),
|
|
311
|
+
"r": _number(radius),
|
|
312
|
+
"fill": color or self.stroke,
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------ wires
|
|
317
|
+
|
|
318
|
+
def wire(
|
|
319
|
+
self,
|
|
320
|
+
start: NodeRef,
|
|
321
|
+
end: NodeRef,
|
|
322
|
+
*through: NodeRef,
|
|
323
|
+
via: tuple[Point, ...] = (),
|
|
324
|
+
id: str | None = None,
|
|
325
|
+
color: str | None = None,
|
|
326
|
+
linecap: str | None = None,
|
|
327
|
+
linejoin: str | None = None,
|
|
328
|
+
corner_radius: float | None = None,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Connect nodes in path order; ``via`` routes between two endpoints without adding nodes.
|
|
331
|
+
|
|
332
|
+
All positional nodes are visited in the order given, so
|
|
333
|
+
``wire("left", "middle", "right")`` draws left to middle to right. A
|
|
334
|
+
route that doubles back on itself is rejected, since it usually means
|
|
335
|
+
the nodes were listed out of order.
|
|
336
|
+
"""
|
|
337
|
+
if through and via:
|
|
338
|
+
raise ValueError("Use either intermediate nodes or via coordinates in one wire")
|
|
339
|
+
connected = [self.resolve(ref) for ref in (start, end, *through)]
|
|
340
|
+
checked = (
|
|
341
|
+
[connected[0].position, *(point(p) for p in via), connected[1].position]
|
|
342
|
+
if via
|
|
343
|
+
else [node.position for node in connected]
|
|
344
|
+
)
|
|
345
|
+
for first, second in pairwise(checked):
|
|
346
|
+
if first == second:
|
|
347
|
+
raise ValueError("A wire segment cannot have zero length")
|
|
348
|
+
# Sliding window over consecutive vertices; the shorter slices end it.
|
|
349
|
+
for prev, vertex, following in zip(checked, checked[1:], checked[2:], strict=False):
|
|
350
|
+
incoming = (vertex[0] - prev[0], vertex[1] - prev[1])
|
|
351
|
+
outgoing = (following[0] - vertex[0], following[1] - vertex[1])
|
|
352
|
+
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
|
|
353
|
+
dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1]
|
|
354
|
+
if abs(cross) <= 1e-12 * hypot(*incoming) * hypot(*outgoing) and dot < 0:
|
|
355
|
+
raise ValueError("A wire cannot double back on itself; list nodes in path order")
|
|
356
|
+
radius = (
|
|
357
|
+
self.corner_radius
|
|
358
|
+
if corner_radius is None
|
|
359
|
+
else nonnegative(corner_radius, "corner_radius")
|
|
360
|
+
)
|
|
361
|
+
cap = self.linecap if linecap is None else self._linecap(linecap)
|
|
362
|
+
join = self.linejoin if linejoin is None else self._linejoin(linejoin)
|
|
363
|
+
commands = self._wire_path(
|
|
364
|
+
checked, radius, set(range(1, len(checked) - 1)) if via else set()
|
|
365
|
+
)
|
|
366
|
+
data = {"from": connected[0].name, "to": connected[-1].name}
|
|
367
|
+
if through:
|
|
368
|
+
data["through"] = " ".join(node.name for node in connected[1:-1])
|
|
369
|
+
group = self.group("wire", id, data=data)
|
|
370
|
+
self.path(group, commands, color=color, linecap=cap, linejoin=join)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _wire_path(points: list[Point], radius: float, roundable: set[int]) -> list[PathCommand]:
|
|
374
|
+
"""Build path commands with circular fillets at routed corners."""
|
|
375
|
+
commands: list[PathCommand] = [("M", *points[0])]
|
|
376
|
+
for index, (prev, vertex, following) in enumerate(
|
|
377
|
+
zip(points, points[1:], points[2:], strict=False), start=1
|
|
378
|
+
):
|
|
379
|
+
if radius == 0 or index not in roundable:
|
|
380
|
+
commands.append(("L", *vertex))
|
|
381
|
+
continue
|
|
382
|
+
incoming_length = hypot(vertex[0] - prev[0], vertex[1] - prev[1])
|
|
383
|
+
outgoing_length = hypot(following[0] - vertex[0], following[1] - vertex[1])
|
|
384
|
+
incoming = (
|
|
385
|
+
(vertex[0] - prev[0]) / incoming_length,
|
|
386
|
+
(vertex[1] - prev[1]) / incoming_length,
|
|
387
|
+
)
|
|
388
|
+
outgoing = (
|
|
389
|
+
(following[0] - vertex[0]) / outgoing_length,
|
|
390
|
+
(following[1] - vertex[1]) / outgoing_length,
|
|
391
|
+
)
|
|
392
|
+
dot = max(-1.0, min(1.0, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]))
|
|
393
|
+
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
|
|
394
|
+
if abs(cross) < 1e-12:
|
|
395
|
+
if dot < 0:
|
|
396
|
+
raise ValueError("A rounded wire cannot reverse direction at a via point")
|
|
397
|
+
commands.append(("L", *vertex))
|
|
398
|
+
continue
|
|
399
|
+
tangent = tan(acos(dot) / 2)
|
|
400
|
+
offset = min(radius * tangent, incoming_length / 2, outgoing_length / 2)
|
|
401
|
+
actual_radius = offset / tangent
|
|
402
|
+
before = (vertex[0] - incoming[0] * offset, vertex[1] - incoming[1] * offset)
|
|
403
|
+
after = (vertex[0] + outgoing[0] * offset, vertex[1] + outgoing[1] * offset)
|
|
404
|
+
commands.append(("L", *before))
|
|
405
|
+
commands.append(("A", actual_radius, actual_radius, 0, 0, int(cross > 0), *after))
|
|
406
|
+
commands.append(("L", *points[-1]))
|
|
407
|
+
return commands
|
|
408
|
+
|
|
409
|
+
# ------------------------------------------------------------ decoration
|
|
410
|
+
|
|
411
|
+
def box(
|
|
412
|
+
self,
|
|
413
|
+
corner: NodeRef | Point,
|
|
414
|
+
opposite: NodeRef | Point,
|
|
415
|
+
*,
|
|
416
|
+
id: str | None = None,
|
|
417
|
+
color: str | None = None,
|
|
418
|
+
dash: Sequence[float] = (2.2, 1.6),
|
|
419
|
+
corner_radius: float = 2,
|
|
420
|
+
stroke_width: float | None = None,
|
|
421
|
+
) -> Box:
|
|
422
|
+
"""Draw a dashed enclosure, e.g. around a subcircuit that acts as one element.
|
|
423
|
+
|
|
424
|
+
The box is decoration only and creates no connection. Pass ``dash=()``
|
|
425
|
+
for a solid outline. Returns the bounds for placing labels.
|
|
426
|
+
"""
|
|
427
|
+
first, second = self._position(corner), self._position(opposite)
|
|
428
|
+
if first[0] == second[0] or first[1] == second[1]:
|
|
429
|
+
raise ValueError("A box needs distinct x and y coordinates")
|
|
430
|
+
group = self.group("box", id)
|
|
431
|
+
return self.rect(
|
|
432
|
+
group,
|
|
433
|
+
first,
|
|
434
|
+
second,
|
|
435
|
+
color=color,
|
|
436
|
+
width=0.75 * self.stroke_width if stroke_width is None else stroke_width,
|
|
437
|
+
corner_radius=nonnegative(corner_radius, "corner_radius"),
|
|
438
|
+
dash=dash,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def label(
|
|
442
|
+
self,
|
|
443
|
+
content: str | Fragment,
|
|
444
|
+
at: NodeRef | Point,
|
|
445
|
+
*,
|
|
446
|
+
id: str | None = None,
|
|
447
|
+
anchor: str = "cc",
|
|
448
|
+
color: str | None = None,
|
|
449
|
+
size: float = 8,
|
|
450
|
+
font_family: str | None = None,
|
|
451
|
+
offset: Point = (0, 0),
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Place plain text or a Vectex-like fragment at an anchored position.
|
|
454
|
+
|
|
455
|
+
``anchor`` combines horizontal ``l/c/r`` and vertical ``t/c/b``.
|
|
456
|
+
Fragments must expose ``width``, ``height``, and ``to_svg()``.
|
|
457
|
+
``size`` is in circuit user units; ``font_family`` selects the typeface
|
|
458
|
+
for plain text. Fragment typography is set when the fragment is rendered.
|
|
459
|
+
``color`` (default: the circuit's stroke) colours text, and a fragment
|
|
460
|
+
drawn in ``currentColor``, as VecTeX's are, unless it carries its own.
|
|
461
|
+
"""
|
|
462
|
+
x, y = self._position(at)
|
|
463
|
+
dx, dy = point(offset)
|
|
464
|
+
x, y = x + dx, y + dy
|
|
465
|
+
if len(anchor) != 2 or anchor[0] not in "lcr" or anchor[1] not in "tcb":
|
|
466
|
+
raise ValueError("anchor must combine l/c/r with t/c/b")
|
|
467
|
+
group = self.group("label", id)
|
|
468
|
+
ink = color or self.stroke
|
|
469
|
+
group.set("fill", ink)
|
|
470
|
+
if isinstance(content, str):
|
|
471
|
+
attrs = {
|
|
472
|
+
"x": _number(x),
|
|
473
|
+
"y": _number(y),
|
|
474
|
+
"font-size": _number(positive(size, "size")),
|
|
475
|
+
"text-anchor": {"l": "start", "c": "middle", "r": "end"}[anchor[0]],
|
|
476
|
+
"dominant-baseline": {"t": "hanging", "c": "middle", "b": "auto"}[anchor[1]],
|
|
477
|
+
}
|
|
478
|
+
if font_family is not None:
|
|
479
|
+
attrs["font-family"] = font_family
|
|
480
|
+
etree.SubElement(group, _tag("text"), attrs).text = content
|
|
481
|
+
return
|
|
482
|
+
if not isinstance(content, Fragment):
|
|
483
|
+
raise TypeError(
|
|
484
|
+
"label expects text or an SVG fragment with width, height, and to_svg()"
|
|
485
|
+
)
|
|
486
|
+
width, height = (
|
|
487
|
+
positive(content.width, "fragment width"),
|
|
488
|
+
positive(content.height, "fragment height"),
|
|
489
|
+
)
|
|
490
|
+
dx = {"l": 0, "c": -width / 2, "r": -width}[anchor[0]]
|
|
491
|
+
dy = {"t": 0, "c": -height / 2, "b": -height}[anchor[1]]
|
|
492
|
+
group.set("transform", f"translate({_number(x + dx)} {_number(y + dy)})")
|
|
493
|
+
# VecTeX paints its glyphs in currentColor, which follows `color`, not
|
|
494
|
+
# `fill`; a fragment rendered with a colour of its own keeps it.
|
|
495
|
+
group.set("color", ink)
|
|
496
|
+
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
|
|
497
|
+
group.append(deepcopy(etree.fromstring(content.to_svg().encode(), parser=parser)))
|
|
498
|
+
|
|
499
|
+
# ------------------------------------------------------------ document
|
|
500
|
+
|
|
501
|
+
def to_svg_document(self) -> str:
|
|
502
|
+
"""Return a standalone SVG string, also accepted by FigWorks's source protocol."""
|
|
503
|
+
root = deepcopy(self._root)
|
|
504
|
+
root.insert(0, _credit())
|
|
505
|
+
if self._visible_nodes:
|
|
506
|
+
# Connection dots are drawn last so that no wire or symbol covers them.
|
|
507
|
+
by_id = {group.get("id"): group for group in root}
|
|
508
|
+
for group in self._visible_nodes:
|
|
509
|
+
root.append(by_id[group.get("id")])
|
|
510
|
+
return etree.tostring(root, encoding="unicode", pretty_print=True).rstrip("\n") + "\n"
|
|
511
|
+
|
|
512
|
+
def write_svg(self, path: str | Path) -> None:
|
|
513
|
+
"""Write the standalone SVG document."""
|
|
514
|
+
Path(path).write_text(self.to_svg_document(), encoding="utf-8")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _credit() -> etree._Element:
|
|
518
|
+
"""Dublin Core metadata naming VecWire the document's creator."""
|
|
519
|
+
from vecwire import __version__
|
|
520
|
+
|
|
521
|
+
return etree.fromstring(
|
|
522
|
+
'<metadata xmlns="http://www.w3.org/2000/svg">'
|
|
523
|
+
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"'
|
|
524
|
+
' xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#">'
|
|
525
|
+
'<cc:Work><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>'
|
|
526
|
+
"<dc:creator><cc:Agent>"
|
|
527
|
+
f"<dc:title>VecWire v{__version__}, https://github.com/maiani/vecwire</dc:title>"
|
|
528
|
+
"</cc:Agent></dc:creator></cc:Work></rdf:RDF></metadata>"
|
|
529
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The circuit-symbol library.
|
|
2
|
+
|
|
3
|
+
Every symbol is a plain function whose first argument is the ``Circuit`` it
|
|
4
|
+
draws on, e.g. ``capacitor(circuit, "top", "bottom", id="C")``. Symbols draw
|
|
5
|
+
only through the circuit's public drawing API (``branch``, ``group``, ``line``,
|
|
6
|
+
``rect``, ``dot``, ``path``) and the helpers in ``vecwire.geometry``, so adding
|
|
7
|
+
one never requires a change to the core canvas. The built-in symbols are also
|
|
8
|
+
bound as ``Circuit`` methods: ``circuit.capacitor("top", "bottom")``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .ground import ground
|
|
12
|
+
from .junctions import JUNCTION_KINDS, harmonic_glyph, harmonic_junction, harmonic_sum, junction
|
|
13
|
+
from .passive import capacitor, inductor
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"JUNCTION_KINDS",
|
|
17
|
+
"capacitor",
|
|
18
|
+
"ground",
|
|
19
|
+
"harmonic_glyph",
|
|
20
|
+
"harmonic_junction",
|
|
21
|
+
"harmonic_sum",
|
|
22
|
+
"inductor",
|
|
23
|
+
"junction",
|
|
24
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Ground symbol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..geometry import offset
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..core import Circuit, NodeRef
|
|
11
|
+
|
|
12
|
+
# Direction of the stem, and the direction the bars extend across it.
|
|
13
|
+
_FRAMES = {
|
|
14
|
+
"down": ((0, 1), (1, 0)),
|
|
15
|
+
"up": ((0, -1), (1, 0)),
|
|
16
|
+
"left": ((-1, 0), (0, 1)),
|
|
17
|
+
"right": ((1, 0), (0, 1)),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ground(
|
|
22
|
+
circuit: Circuit,
|
|
23
|
+
node: NodeRef,
|
|
24
|
+
*,
|
|
25
|
+
id: str | None = None,
|
|
26
|
+
direction: str = "down",
|
|
27
|
+
color: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Draw a ground symbol connected to ``node``: a short stem and three bars."""
|
|
30
|
+
connected = circuit.resolve(node)
|
|
31
|
+
if direction not in _FRAMES:
|
|
32
|
+
raise ValueError("direction must be down, up, left, or right")
|
|
33
|
+
axis, across = _FRAMES[direction]
|
|
34
|
+
origin = connected.position
|
|
35
|
+
group = circuit.group("ground", id, data={"node": connected.name})
|
|
36
|
+
circuit.line(group, origin, offset(origin, axis, across, 4), color=color)
|
|
37
|
+
for distance, half_width in ((4, 6), (6.4, 3.8), (8.8, 1.6)):
|
|
38
|
+
circuit.line(
|
|
39
|
+
group,
|
|
40
|
+
offset(origin, axis, across, distance, -half_width),
|
|
41
|
+
offset(origin, axis, across, distance, half_width),
|
|
42
|
+
color=color,
|
|
43
|
+
)
|