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 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
+ )