From c378e00bbc5d21dae96f2a9350a1fe7e5be10412 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 11 Aug 2024 11:13:45 -0400 Subject: [PATCH 001/101] formatter --- .vscode/settings.json | 2 ++ schemascii/edgemarks.py | 72 +++++++++++++++++++---------------------- scripts/monkeypatch.py | 2 ++ scripts/release.py | 30 ++++++++--------- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2157f82..e01a2a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "cSpell.words": [ "Cbox", + "clrall", + "clrmask", "MOSFET", "NFET", "PFET", diff --git a/schemascii/edgemarks.py b/schemascii/edgemarks.py index d9bd19c..bdc64ef 100644 --- a/schemascii/edgemarks.py +++ b/schemascii/edgemarks.py @@ -1,65 +1,59 @@ from itertools import chain -from types import FunctionType +from typing import Callable from .utils import Cbox, Flag, Side, Terminal from .grid import Grid -def over_edges(box: Cbox) -> list: +def over_edges(box: Cbox, func: Callable[[complex, Side], list | None]): "Decorator - Runs around the edges of the box on the grid." - - def inner_over_edges(func: FunctionType): - out = [] - for p, s in chain( - # Top side - ( - (complex(xx, int(box.p1.imag) - 1), Side.TOP) - for xx in range(int(box.p1.real), int(box.p2.real) + 1) - ), - # Right side - ( - (complex(int(box.p2.real) + 1, yy), Side.RIGHT) - for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) - ), - # Bottom side - ( - (complex(xx, int(box.p2.imag) + 1), Side.BOTTOM) - for xx in range(int(box.p1.real), int(box.p2.real) + 1) - ), - # Left side - ( - (complex(int(box.p1.real) - 1, yy), Side.LEFT) - for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) - ), - ): - result = func(p, s) - if result is not None: - out.append(result) - return out - - return inner_over_edges + out = [] + for p, s in chain( + # Top side + ( + (complex(xx, int(box.p1.imag) - 1), Side.TOP) + for xx in range(int(box.p1.real), int(box.p2.real) + 1) + ), + # Right side + ( + (complex(int(box.p2.real) + 1, yy), Side.RIGHT) + for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) + ), + # Bottom side + ( + (complex(xx, int(box.p2.imag) + 1), Side.BOTTOM) + for xx in range(int(box.p1.real), int(box.p2.real) + 1) + ), + # Left side + ( + (complex(int(box.p1.real) - 1, yy), Side.LEFT) + for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) + ), + ): + result = func(p, s) + if result is not None: + out.append(result) + return out def take_flags(grid: Grid, box: Cbox) -> list[Flag]: """Runs around the edges of the component box, collects the flags, and masks them off to wires.""" - @over_edges(box) - def flags(p: complex, s: Side) -> Flag | None: + def get_flags(p: complex, s: Side) -> Flag | None: c = grid.get(p) if c in " -|()*": return None grid.setmask(p, "*") return Flag(p, c, s) - return flags + return over_edges(box, get_flags) def find_edge_marks(grid: Grid, box: Cbox) -> list[Terminal]: "Finds all the terminals on the box in the grid." flags = take_flags(grid, box) - @over_edges(box) - def terminals(p: complex, s: Side) -> Terminal | None: + def get_terminals(p: complex, s: Side) -> Terminal | None: c = grid.get(p) if (c in "*|()" and s in (Side.TOP, Side.BOTTOM)) or ( c in "*-" and s in (Side.LEFT, Side.RIGHT) @@ -70,4 +64,4 @@ def terminals(p: complex, s: Side) -> Terminal | None: return Terminal(p, None, s) return None - return terminals + return over_edges(box, get_terminals) diff --git a/scripts/monkeypatch.py b/scripts/monkeypatch.py index affc0fd..00c911b 100644 --- a/scripts/monkeypatch.py +++ b/scripts/monkeypatch.py @@ -7,6 +7,7 @@ print("monkeypatching... ", end="") + def patched(src): with warnings.catch_warnings(record=True) as captured_warnings: out = schemascii.render("", src) @@ -14,4 +15,5 @@ def patched(src): print("warning:", warn.message) return out + schemascii.patched_render = patched diff --git a/scripts/release.py b/scripts/release.py index bfc6ab6..649c4c2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -10,16 +10,16 @@ def cmd(sh_line): print(sh_line) if code := os.system(sh_line): - print("***Error", code, file=sys.stderr) + print("*** Error", code, file=sys.stderr) sys.exit(code) -def readfile(file): +def slurp(file): with open(file) as f: return f.read() -def writefile(file, text): +def spit(file, text): with open(file, "w") as f: f.write(text) @@ -29,15 +29,15 @@ def writefile(file, text): args = a.parse_args() # Patch new version into files -pp_text = readfile("pyproject.toml") -writefile("pyproject.toml", - re.sub(r'version = "[\d.]+"', - f'version = "{args.version}"', pp_text)) +pp_text = slurp("pyproject.toml") +spit("pyproject.toml", + re.sub(r'version = "[\d.]+"', + f'version = "{args.version}"', pp_text)) -init_text = readfile("schemascii/__init__.py") -writefile("schemascii/__init__.py", - re.sub(r'__version__ = "[\d.]+"', - f'__version__ = "{args.version}"', init_text)) +init_text = slurp("schemascii/__init__.py") +spit("schemascii/__init__.py", + re.sub(r'__version__ = "[\d.]+"', + f'__version__ = "{args.version}"', init_text)) cmd("scripts/docs.py") cmd("python3 -m build --sdist") @@ -50,10 +50,10 @@ def writefile(file, text): cmd("convert test_data/test_charge_pump.txt.svg test_data/test_charge_pump.png") -svg_content = readfile("test_data/test_charge_pump.txt.svg") -css_content = readfile("schemascii_example.css") -writefile("test_data/test_charge_pump_css.txt.svg", - svg_content.replace("", f'')) +svg_content = slurp("test_data/test_charge_pump.txt.svg") +css_content = slurp("schemascii_example.css") +spit("test_data/test_charge_pump_css.txt.svg", + svg_content.replace("", f'')) cmd("convert test_data/test_charge_pump_css.txt.svg test_data/test_charge_pump_css.png") # cmd("git add -A") From eae3b2d5ef9c03b3e6033da3582e63743bf9eca0 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 11 Aug 2024 11:15:47 -0400 Subject: [PATCH 002/101] implement shrink method will be needed for fitting box if BOM data is outside of drawing bounding box --- schemascii/grid.py | 73 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/schemascii/grid.py b/schemascii/grid.py index 85e39d4..3e8f8fe 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -2,7 +2,7 @@ class Grid: """Helper class for managing a 2-D grid of ASCII art.""" - def __init__(self, filename: str, data: str = None): + def __init__(self, filename: str, data: str | None = None): if data is None: with open(filename, encoding="ascii") as f: data = f.read() @@ -10,9 +10,10 @@ def __init__(self, filename: str, data: str = None): self.raw: str = data lines: list[str] = data.split("\n") maxlen: int = max(len(line) for line in lines) - self.data: list[list[str]] = [list(line.ljust(maxlen, " ")) for line in lines] + self.data: list[list[str]] = [list(line.ljust(maxlen, " ")) + for line in lines] self.masks: list[list[bool | str]] = [ - [False for x in range(maxlen)] for y in range(len(lines)) + [False for _ in range(maxlen)] for _ in range(len(lines)) ] self.width = maxlen self.height = len(self.data) @@ -31,12 +32,12 @@ def get(self, p: complex) -> str: return self.getmask(p) or self.data[int(p.imag)][int(p.real)] @property - def lines(self): + def lines(self) -> tuple[str]: "The current contents, with masks applied." - return [ + return tuple([ "".join(self.get(complex(x, y)) for x in range(self.width)) for y in range(self.height) - ] + ]) def getmask(self, p: complex) -> str | bool: """Sees the mask applied to the specified point; @@ -57,7 +58,8 @@ def clrmask(self, p: complex): def clrall(self): "Clears all the masks at once." - self.masks = [[False for x in range(self.width)] for y in range(self.height)] + self.masks = [[False for _ in range(self.width)] + for _ in range(self.height)] def clip(self, p1: complex, p2: complex): """Returns a sub-grid with the contents bounded by the p1 and p2 box. @@ -67,8 +69,48 @@ def clip(self, p1: complex, p2: complex): d = "\n".join("".join(ln[ls]) for ln in self.data[cs]) return Grid(self.filename, d) + def shrink(self): + """Shrinks self so that there is not any space between the edges and + the next non-printing character. Takes masks into account.""" + # clip the top lines + while all(self.get(complex(x, 0)).isspace() + for x in range(self.width)): + self.height -= 1 + self.data.pop(0) + self.masks.pop(0) + # clip the bottom lines + while all(self.get(complex(x, self.height - 1)).isspace() + for x in range(self.width)): + self.height -= 1 + self.data.pop() + self.masks.pop() + # find the max indent space on left + min_indent = self.width + for line in self.lines: + this_indent = len(line) - len(line.lstrip()) + min_indent = min(min_indent, this_indent) + # chop the space + if min_indent > 0: + self.width -= min_indent + for line in self.data: + del line[0:min_indent] + for line in self.masks: + del line[0:min_indent] + # find the max indent space on right + min_indent = self.width + for line in self.lines: + this_indent = len(line) - len(line.rstrip()) + min_indent = min(min_indent, this_indent) + # chop the space + if min_indent > 0: + self.width -= min_indent + for line in self.data: + del line[len(line)-min_indent:] + for line in self.masks: + del line[len(line)-min_indent:] + def __repr__(self): - return f"Grid({self.filename!r}, '''\n{chr(10).join(self.lines)}''')" + return f"Grid({self.filename!r}, \"""\n{chr(10).join(self.lines)}\""")" __str__ = __repr__ @@ -86,5 +128,16 @@ def spark(self, *points): if __name__ == "__main__": - x = Grid("", " \n \n ") - x.spark(0, 1, 2, 1j, 2j, 1 + 2j, 2 + 2j, 2 + 1j) + x = Grid("", """ + + xx--- + hha-- + a awq + +"""[1:-1]) + x.spark(0, complex(x.width - 1, 0), complex(0, x.height - 1), + complex(x.width - 1, x.height - 1)) + x.shrink() + print() + x.spark(0, complex(x.width - 1, 0), complex(0, x.height - 1), + complex(x.width - 1, x.height - 1)) From 7cb9b79b0cd8877975fc2324cec9eb0a934c6574 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 11 Aug 2024 12:29:35 -0400 Subject: [PATCH 003/101] refactor metric code * allows tera/T/10^12 prefix now * allows ranges of numbers --- schemascii/metric.py | 135 ++++++++++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 40 deletions(-) diff --git a/schemascii/metric.py b/schemascii/metric.py index 031b86c..64f4e4d 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -1,73 +1,128 @@ import re from decimal import Decimal -METRIC_NUMBER = re.compile(r"^(\d*\.?\d+)([pnumKkMG]?)$") # cSpell:ignore pnum +METRIC_NUMBER = re.compile( + r"(\d*\.?\d+)([pnumKkMGT]?)") # cSpell:ignore pnum +METRIC_RANGE = re.compile( + r"(\d*\.?\d+[pnumKkMGT]?)-(\d*\.?\d+[pnumKkMGT]?)") ENG_NUMBER = re.compile(r"^(\d*\.?\d+)[Ee]?([+-]?\d*)$") -def exponent_to_prefix(exponent: int) -> str | None: - """Turns the 10-power into a Metric prefix. +def exponent_to_multiplier(exponent: int) -> str | None: + """Turns the 10-power into a Metric multiplier. E.g. 3 --> "k" (kilo) - E.g. 0 --> "" (no prefix) + E.g. 0 --> "" (no multiplier) E.g. -6 --> "u" (micro) If it is not a multiple of 3, returns None.""" if exponent % 3 != 0: return None index = (exponent // 3) + 4 # pico is -12 --> 0 # cSpell:ignore pico - return "pnum kMG"[index].strip() + return "pnum kMGT"[index].strip() -def prefix_to_exponent(prefix: int) -> str: - """Turns the Metric prefix into its exponent. +def multiplier_to_exponent(multiplier: str) -> int: + """Turns the Metric multiplier into its exponent. E.g. "k" --> 3 (kilo) - E.g. " " --> 0 (no prefix) + E.g. " " --> 0 (no multiplier) E.g. "u" --> -6 (micro)""" - if prefix in (" ", ""): + if multiplier in (" ", ""): return 0 - if prefix == "µ": - prefix = "u" # allow unicode - if prefix == "K": - prefix = prefix.lower() # special case (preferred is lowercase) - i = "pnum kMG".index(prefix) + if multiplier == "µ": + multiplier = "u" # allow unicode + if multiplier == "K": + multiplier = multiplier.lower() + # special case (preferred is lowercase) + i = "pnum kMGT".index(multiplier) return (i - 4) * 3 -def format_metric_unit(num: str, unit: str = "", six: bool = False) -> str: - "Normalizes the Metric unit on the number." - num = num.strip() - match = METRIC_NUMBER.match(num) - if not match: - return num - digits_str, prefix = match.group(1), match.group(2) - digits_decimal = Decimal(digits_str) - digits_decimal *= Decimal("10") ** Decimal(prefix_to_exponent(prefix)) - res = ENG_NUMBER.match(digits_decimal.to_eng_string()) +def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: + """Finds the best exponent for the number. + Returns a tuple (digits, best_exponent)""" + res = ENG_NUMBER.match(num.to_eng_string()) if not res: - raise RuntimeError + raise RuntimeError("blooey!") # cSpell: ignore blooey digits, exp = Decimal(res.group(1)), int(res.group(2) or "0") assert exp % 3 == 0, "failed to make engineering notation" possibilities = [] - for d_e in range(-6, 9, 3): - if (exp + d_e) % 6 == 0 or not six: - new_exp = exp - d_e - new_digits = str(digits * (Decimal("10") ** Decimal(d_e))) + for cnd_exp_off in range(-12, 9, 3): + if (exp + cnd_exp_off) % 6 == 0 or not six: + new_exp = exp - cnd_exp_off + new_digits = str(digits * (Decimal(10) ** Decimal(cnd_exp_off))) if "e" in new_digits.lower(): + # we're trying to avoid getting exponential notation here continue if "." in new_digits: new_digits = new_digits.rstrip("0").removesuffix(".") - possibilities.append((new_exp, new_digits)) - # heuristic: shorter is better, prefer no decimal point - exp, digits = sorted( - possibilities, key=lambda x: len(x[1]) + (0.5 * ("." in x[1])) + possibilities.append((new_digits, new_exp)) + # heuristics: + # * shorter is better + # * prefer no decimal point + # * prefer no Metric multiplier if possible + return sorted( + possibilities, key=lambda x: ((10 * len(x[0])) + + (2 * ("." in x[0])) + + (5 * (x[1] != 0))) )[0] - out = digits + " " + exponent_to_prefix(exp) + unit - return out.replace(" u", " µ") + + +def normalize_metric(num: str, six: bool, unicode: bool) -> tuple[str, str]: + """Parses the metric number, normalizes the unit, and returns + a tuple (normalized_digits, metric_multiplier).""" + match = METRIC_NUMBER.match(num) + if not match: + return num + digits_str, multiplier = match.group(1), match.group(2) + digits_decimal = Decimal(digits_str) + digits_decimal *= Decimal(10) ** Decimal( + multiplier_to_exponent(multiplier)) + digits, exp = best_exponent(digits_decimal, six) + unit = exponent_to_multiplier(exp) + if unicode and unit == "u": + unit = "µ" + return digits, unit + + +def format_metric_unit( + num: str, + unit: str = "", + six: bool = False, + unicode: bool = True) -> str: + """Normalizes the Metric multiplier on the number, then adds the unit. + + * If there is a suffix on num, moves it to after the unit. + * If there is a range of numbers, formats each number in the range + and adds the unit afterwards. + * If there is no number in num, returns num unchanged. + * If unicode is True, uses 'µ' for micro instead of 'u'.""" + num = num.strip() + match = METRIC_RANGE.match(num) + if match: + # format the range by calling recursively + num0, num1 = match.group(1), match.group(2) + suffix = num[match.span(0)[1]:] + digits0, exp0 = normalize_metric(num0, six, unicode) + digits1, exp1 = normalize_metric(num1, six, unicode) + if exp0 != exp1: + # different multiplier so use multiplier and unit on both + return f"{digits0} {exp0}{unit} - {digits1} {exp1}{unit} {suffix}".rstrip() + return f"{digits0}-{digits1} {exp0}{unit} {suffix}".rstrip() + match = METRIC_NUMBER.match(num) + if not match: + return num + suffix = num[match.span(0)[1]:] + digits, exp = normalize_metric(match.group(0), six, unicode) + return f"{digits} {exp}{unit} {suffix}".rstrip() if __name__ == "__main__": - print(">>", format_metric_unit("2.5", "V")) - print(">>", format_metric_unit("50n", "F", True)) - print(">>", format_metric_unit("1234", "Ω")) - print(">>", format_metric_unit("2200u", "F", True)) - print(">>", format_metric_unit("Gain", "Ω")) + def test(*args): + print(">>> format_metric_unit", args) + print(repr(format_metric_unit(*args))) + test("2.5-3500", "V") + test("50n", "F", True) + test("50M-1000M", "Hz") + test(".1", "Ω") + test("2200u", "F", True) + test("Gain", "Ω") From b615ce83e87b377061abcd4b6ff75d3da3993c97 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 11 Aug 2024 12:29:53 -0400 Subject: [PATCH 004/101] tweaks --- schemascii/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/schemascii/utils.py b/schemascii/utils.py index 4b07dcd..469a94f 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -35,6 +35,8 @@ def force_int(p: complex) -> complex: def sharpness_score(points: list[complex]) -> float: """Returns a number indicating how twisty the line is -- higher means the corners are sharper.""" + if len(points) < 3: + return float("nan") score = 0 prev_pt = points.imag prev_ph = phase(points.imag - points[0]) @@ -97,6 +99,8 @@ def merge_colinear(links: list[tuple[complex, complex]]): def iterate_line(p1: complex, p2: complex, step: float = 1.0): "Yields complex points along a line." + # this isn't Bresenham's algorithm but I only use it for vertical or + # horizontal lines, so it works well enough vec = p2 - p1 point = p1 while abs(vec) > abs(point - p1): @@ -357,3 +361,10 @@ def sort_for_flags(terminals: list[Terminal], box: Cbox, *flags: list[str]) -> l out = *out, terminal terminals.remove(terminal) return out + + +if __name__ == '__main__': + from .grid import Grid + w, h, = 22, 23 + x = Grid("", "\n".join("".join(" " for _ in range(w)) for _ in range(h))) + x.spark(*iterate_line(0, complex(w, h))) From 8cace8144fd6364414607e7d2833fa8c3495e13d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 11 Aug 2024 16:32:21 -0400 Subject: [PATCH 005/101] add Wire ast node --- schemascii/utils.py | 18 +++++----- schemascii/wire.py | 83 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 schemascii/wire.py diff --git a/schemascii/utils.py b/schemascii/utils.py index 469a94f..8c0b829 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -56,26 +56,24 @@ def intersecting(a, b, p, q): return sort_a <= sort_p <= sort_b or sort_p <= sort_b <= sort_q -def take_next_group(links: list[tuple[complex, complex]]) -> list[tuple[complex, complex]]: - """Pops the longes possible link off of the `links` list and returns it, +def take_next_group(links: list[tuple[complex, complex]]) -> list[ + tuple[complex, complex]]: + """Pops the longest possible path off of the `links` list and returns it, mutating the input list.""" best = [links.pop()] while True: for pair in links: if best[0][0] == pair[1]: best.insert(0, pair) - links.remove(pair) elif best[0][0] == pair[0]: best.insert(0, (pair[1], pair[0])) - links.remove(pair) elif best[-1][1] == pair[0]: best.append(pair) - links.remove(pair) elif best[-1][1] == pair[1]: best.append((pair[1], pair[0])) - links.remove(pair) else: continue + links.remove(pair) break else: break @@ -86,11 +84,12 @@ def merge_colinear(links: list[tuple[complex, complex]]): "Merges line segments that are colinear. Mutates the input list." i = 1 while True: - if i == len(links): + if i >= len(links): break elif links[i][0] == links[i][1]: links.remove(links[i]) - elif links[i-1][1] == links[i][0] and colinear(links[i-1][0], links[i][0], links[i][1]): + elif links[i-1][1] == links[i][0] and colinear( + links[i-1][0], links[i][0], links[i][1]): links[i-1] = (links[i-1][0], links[i][1]) links.remove(links[i]) else: @@ -188,7 +187,8 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: - "Collapse the pairs of points and return the smallest number of s." + """Collapse the pairs of points and return + the smallest number of s.""" lines = [] while pairs: group = take_next_group(pairs) diff --git a/schemascii/wire.py b/schemascii/wire.py new file mode 100644 index 0000000..826e578 --- /dev/null +++ b/schemascii/wire.py @@ -0,0 +1,83 @@ +from collections import defaultdict +from itertools import combinations +from typing import Literal + +from .grid import Grid +from .utils import bunch_o_lines + +# This is a map of the direction coming into the cell +# to the set of directions coming "out" of the cell. +DirStr = Literal["^", "v", "<", ">"] +NOWHERE = None +EVERYWHERE: defaultdict[DirStr, str] = defaultdict(lambda: "<>^v") +IDENTITY: dict[DirStr, str] = {">": ">", "^": "^", "<": "<", "v": "v"} +WIRE_DIRECTIONS: defaultdict[str, defaultdict[DirStr, str]] = defaultdict( + lambda: NOWHERE, { + "-": IDENTITY, + "|": IDENTITY, + "(": IDENTITY, + ")": IDENTITY, + "*": EVERYWHERE, + }) +WIRE_STARTS: defaultdict[str, str] = defaultdict(lambda: NOWHERE, { + "-": "<>", + "|": "^v", + "(": "^v", + ")": "^v", + "*": "<>^v" +}) + +CHAR2DIR: dict[DirStr, complex] = {">": -1, "<": 1, "^": 1j, "v": -1j} + + +class Wire(list[complex]): + """List of grid points along a wire.""" + + @classmethod + def get_from_grid(cls, grid: Grid, start: complex): + seen: set[complex] = set() + pts: list[complex] = [] + stack: list[tuple[complex, DirStr]] = [ + (start, WIRE_STARTS[grid.get(start)])] + while stack: + point, directions = stack.pop() + if point in seen: + continue + seen.add(point) + pts.append(point) + for dir in directions: + next_pt = point + CHAR2DIR[dir] + if ((next_dirs := WIRE_DIRECTIONS[grid.get(next_pt)]) + is not NOWHERE): + stack.append((next_pt, next_dirs[dir])) + return cls(pts) + + def to_xml_string(self, **options) -> str: + # create lines for all of the neighbor pairs + links = [] + for p1, p2 in combinations(self, 2): + if abs(p1 - p2) == 1: + links.append((p1, p2)) + return bunch_o_lines(links, **options) + + +if __name__ == '__main__': + x = Grid("", """ +. + + * -------------------------* + | | + *----------||||----* -------* + | | + ----------- | + | | + -------*----------*---* + | | + *-----------------*---* + | + +. +""".strip()) + pts = Wire.get_from_grid(x, 2+4j) + x.spark(*pts) + print(pts.to_xml_string(scale=10, stroke_width=2, stroke="black")) From b17d2f1856f922e2b907deb4e10335c726f11780 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:53:27 -0400 Subject: [PATCH 006/101] tweaks --- schemascii/configs.py | 20 ++++++++++---------- schemascii/wire.py | 5 ++++- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/schemascii/configs.py b/schemascii/configs.py index d2376d7..4eeea8b 100644 --- a/schemascii/configs.py +++ b/schemascii/configs.py @@ -8,16 +8,16 @@ class ConfigConfig: name: str clazz: type | list default: object - description: str + help: str OPTIONS = [ - ConfigConfig("padding", float, 10, "Amount of padding to add on the edges."), - ConfigConfig( - "scale", float, 15, "Scale at which to enlarge the entire diagram by." - ), - ConfigConfig("stroke_width", float, 2, "Width of the lines"), - ConfigConfig("stroke", str, "black", "Color of the lines."), + ConfigConfig("padding", float, 10, "Amount of padding to add " + "to the edges around the drawing."), + ConfigConfig("scale", float, 15, "Scale by which to enlarge " + "the entire diagram by."), + ConfigConfig("stroke_width", float, 2, "Width of lines."), + ConfigConfig("stroke", str, "black", "Color of lines."), ConfigConfig( "label", ["L", "V", "VL"], @@ -39,20 +39,20 @@ def add_config_arguments(a: argparse.ArgumentParser): if isinstance(opt.clazz, list): a.add_argument( "--" + opt.name, - help=opt.description, + help=opt.help, choices=opt.clazz, default=opt.default, ) elif opt.clazz is bool: a.add_argument( "--" + opt.name, - help=opt.description, + help=opt.help, action="store_false" if opt.default else "store_true", ) else: a.add_argument( "--" + opt.name, - help=opt.description, + help=opt.help, type=opt.clazz, default=opt.default, ) diff --git a/schemascii/wire.py b/schemascii/wire.py index 826e578..d7b5a18 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -1,3 +1,4 @@ +from __future__ import annotations from collections import defaultdict from itertools import combinations from typing import Literal @@ -17,6 +18,8 @@ "|": IDENTITY, "(": IDENTITY, ")": IDENTITY, + "~": IDENTITY, + ":": IDENTITY, "*": EVERYWHERE, }) WIRE_STARTS: defaultdict[str, str] = defaultdict(lambda: NOWHERE, { @@ -34,7 +37,7 @@ class Wire(list[complex]): """List of grid points along a wire.""" @classmethod - def get_from_grid(cls, grid: Grid, start: complex): + def get_from_grid(cls, grid: Grid, start: complex) -> Wire: seen: set[complex] = set() pts: list[complex] = [] stack: list[tuple[complex, DirStr]] = [ From d5e762171088611630e2c218471c6879dc7ab9fd Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:53:51 -0400 Subject: [PATCH 007/101] add top-level drawing node --- schemascii/drawing.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 schemascii/drawing.py diff --git a/schemascii/drawing.py b/schemascii/drawing.py new file mode 100644 index 0000000..bc65f85 --- /dev/null +++ b/schemascii/drawing.py @@ -0,0 +1,33 @@ +from __future__ import annotations + + +class Drawing: + """A Schemascii drawing document.""" + + def __init__( + self, + wire_nets, # list of nets of wires + components, # list of components found + annotations, # boxes, lines, comments, etc + data): + self.nets = wire_nets + self.components = components + self.annotations = annotations + self.data = data + + @classmethod + def parse_from_string(cls, data: str, **options) -> Drawing: + lines = data.splitlines() + marker = options.get("data-marker", "---") + try: + marker_pos = lines.index(marker) + except ValueError: + raise SyntaxError( + "data-marker must be present in a drawing! " + f"(current data-marker is: {marker!r})") + drawing_area = "\n".join(lines[:marker_pos]) + data_area = "\n".join(lines[marker_pos+1:]) + raise NotImplementedError + + def to_xml_string(self, **options) -> str: + raise NotImplementedError From 612cb34b42d1ff9e680ed7fe2f748ee0084866ce Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:29:04 -0400 Subject: [PATCH 008/101] broke Grid __repr__ --- schemascii/grid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemascii/grid.py b/schemascii/grid.py index 3e8f8fe..d927b43 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -110,7 +110,8 @@ def shrink(self): del line[len(line)-min_indent:] def __repr__(self): - return f"Grid({self.filename!r}, \"""\n{chr(10).join(self.lines)}\""")" + return (f"Grid({self.filename!r}, \"\"\"" + f"\n{chr(10).join(self.lines)}\"\"\")") __str__ = __repr__ From 79fe28a9d57aab40984781ab541c0e6c52a1c045 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:30:02 -0400 Subject: [PATCH 009/101] add TOML-powered data parser prototype --- README.md | 125 +-------------------------------------- pyproject.toml | 4 +- schemascii/data_parse.py | 66 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 126 deletions(-) create mode 100644 schemascii/data_parse.py diff --git a/README.md b/README.md index a404808..6b2390d 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,5 @@ # Schemascii -[![GitHub issues](https://img.shields.io/github/issues/dragoncoder047/schemascii)](https://github.com/dragoncoder047/schemascii/issues) -![GitHub commit activity](https://img.shields.io/github/commit-activity/w/dragoncoder047/schemascii) -![GitHub last commit](https://img.shields.io/github/last-commit/dragoncoder047/schemascii) -![GitHub repo file count](https://img.shields.io/github/directory-file-count/dragoncoder047/schemascii) -![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue) - -A command-line tool and library for converting ASCII-art diagrams into beautiful SVG circuit schematics. - -Turn this: - -```none -*--BAT1+--*-------*---* -| | | | -| R1 .~~~. | -| | : :-* -| *-----: :---+C2--*--D2+--*----------J1 -| | :U1 : | | -| R2 :555: | | -| | *-: :-* | | -| C1 | : : | + C3 -| | *-: : C4 D1 + -| *---* .~~~. | | | -| | | | | | -*---------*-------*---*------*-------*----------J2 - -BAT1:5 -R1:10k -R2:100k -C1:10000p -C2:10u -C3:100u -C4:10p -D1:1N4001 -D2:1N4001 -U1:NE555,7,6,2,1,5,3,4,8 -J1:-5V -J2:GND -``` - -Into this: - -![image](test_data/test_charge_pump.png) - -And with a little CSS, this: - -![image](test_data/test_charge_pump_css.png) - -Works with Python 3.10+. It uses the new `match` feature in a few places. If you need to run Schemascii on an older version of Python, feel free to fork it and send me a pull request. - -## Installation - -Not published to PyPI yet, so you have two options: - -1. Install using pip's VCS support: - ```bash - pip install git+https://github.com/dragoncoder047/schemascii - ``` -2. Install from source: - ```bash - git clone https://github.com/dragoncoder047/schemascii - cd schemascii - pip install . - ``` - -You can also add `git+https://github.com/dragoncoder047/schemascii` to your `requirements.txt` if you have one. - -## Command line usage - -```usage -usage: schemascii [-h] [-V] [-o OUT_FILE] [--padding PADDING] [--scale SCALE] [--stroke_width STROKE_WIDTH] [--stroke STROKE] - [--label {L,V,VL}] [--nolabels] - in_file - -Render ASCII-art schematics into SVG. - -positional arguments: - in_file File to process. - -options: - -h, --help show this help message and exit - -V, --version show program's version number and exit - -o OUT_FILE, --out OUT_FILE - Output SVG file. (default input file plus .svg) - --padding PADDING Amount of padding to add on the edges. - --scale SCALE Scale at which to enlarge the entire diagram by. - --stroke_width STROKE_WIDTH - Width of the lines - --stroke STROKE Color of the lines. - --label {L,V,VL} Component label style (L=include label, V=include value, VL=both) - --nolabels Turns off labels on all components, except for part numbers on ICs. -``` - -## Python usage - -```python -import schemascii - -# Render a file -svg = schemascii.render("my_circuit.txt") - -# Render a string -text = ... # this is the text of your file -svg = schemascii.render("", text) - -# Provide options -svg = schemascii.render("my_circuit.txt", - padding=10, - scale=15, - stroke_width=2, - stroke="black", - label="LV", - nolabels=False) -# these are the defaults -``` - -## Contributing Tips - -Make sure you have an *editable* install, so you can edit and still be able to use the `schemascii` command to test it: - -```bash -pip uninstall schemascii -cd path/to/your/schemascii/checkout -pip install -e . -``` +## This is the NEXT branch which contains unstable breaking changes. Don't install this branch if you want Schemascii to work. diff --git a/pyproject.toml b/pyproject.toml index 91486a9..53e6e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,14 +14,14 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Topic :: Multimedia :: Graphics :: Graphics Conversion", "Topic :: Scientific/Engineering", ] keywords = ["schematic", "electronics", "circuit", "diagram"] -requires-python = ">=3.10" +requires-python = ">=3.11" [project.urls] Homepage = "https://github.com/dragoncoder047/schemascii" diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py new file mode 100644 index 0000000..0a681ae --- /dev/null +++ b/schemascii/data_parse.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import fnmatch +import re +import tomllib +from dataclasses import dataclass + +from .errors import DiagramSyntaxError + + +@dataclass +class Section(dict): + """Section of data relevant to one portion of the drawing.""" + + header: str + data: dict + + def __getitem__(self, key): + return self.data[key] + + def matches(self, name) -> bool: + """True if self.header matches the name.""" + return fnmatch.fnmatch(name, self.header) + + +@dataclass +class Data: + """Class that holds the data of a drawing.""" + + sections: list[Section] + + @classmethod + def parse_from_string(cls, text: str, startline=1, filename="") -> Data: + # add newlines so that the line number is + # correct in TOML parse error messages + corrected_text = ("\n" * (startline - 1)) + text + try: + data = tomllib.loads(corrected_text) + except tomllib.TOMLDecodeError as e: + if filename: + e.add_note( + f"note: while parsing data section in file: {filename}") + raise + sections = [] + for key in data: + sections.append(Section(key, data[key])) + return cls(sections) + + +if __name__ == '__main__': + text = r""" + +[drawing] +color = "black" +width = 2 +padding = 20 +format = "symbol" + +[R1] +value = 10 +tolerance = 0.05 +wattage = 0.25 + +[R2] +""" + print(Data.parse_from_string(text)) From dac599b37283fa367a797fdbbec6af2885237715 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:59:57 -0400 Subject: [PATCH 010/101] remove outdated imports --- schemascii/data_parse.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py index 0a681ae..f847735 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data_parse.py @@ -1,12 +1,9 @@ from __future__ import annotations import fnmatch -import re import tomllib from dataclasses import dataclass -from .errors import DiagramSyntaxError - @dataclass class Section(dict): From 217352d7c8146d25be573471d87e3cd92474a9ce Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 17:48:09 -0400 Subject: [PATCH 011/101] toml is weird so *gasp* I rolled my own parser --- pyproject.toml | 4 +- schemascii/data_parse.py | 218 ++++++++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53e6e80..91486a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,14 +14,14 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.10", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Topic :: Multimedia :: Graphics :: Graphics Conversion", "Topic :: Scientific/Engineering", ] keywords = ["schematic", "electronics", "circuit", "diagram"] -requires-python = ">=3.11" +requires-python = ">=3.10" [project.urls] Homepage = "https://github.com/dragoncoder047/schemascii" diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py index f847735..4cf141b 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data_parse.py @@ -1,9 +1,23 @@ from __future__ import annotations - +import re import fnmatch -import tomllib from dataclasses import dataclass +from .errors import DiagramSyntaxError + +TOKEN_PAT = re.compile("|".join([ + r"[\n{};=]", # special one-character + "%%", # comment marker + r"(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?", # number + r"""\"(?:\\"|[^"])+\"|\s+""", # string + r"""(?:(?!["\s{};=]).)+""", # anything else +])) +SPECIAL = {";", "\n", "%%", "{", "}"} + + +def tokenize(stuff: str) -> list[str]: + return TOKEN_PAT.findall(stuff) + @dataclass class Section(dict): @@ -28,36 +42,190 @@ class Data: @classmethod def parse_from_string(cls, text: str, startline=1, filename="") -> Data: - # add newlines so that the line number is - # correct in TOML parse error messages - corrected_text = ("\n" * (startline - 1)) + text - try: - data = tomllib.loads(corrected_text) - except tomllib.TOMLDecodeError as e: - if filename: - e.add_note( - f"note: while parsing data section in file: {filename}") - raise + tokens = tokenize(text) + lines = (text + "\n").splitlines() + col = line = index = 0 + lastsig = (0, 0, 0) + + def complain(msg): + raise DiagramSyntaxError( + f"{filename} line {line+startline}: {msg}\n" + f" {lines[line]}\n" + f" {' ' * col}{'^'*len(look())}".lstrip()) + + def complain_eof(): + restore(lastsig) + skip_space(True) + if index >= len(tokens): + complain("unexpected EOF") + complain("cannot parse after this") + + def look(): + if index >= len(tokens): + return "\0" + return tokens[index] + + def eat(): + nonlocal line + nonlocal col + nonlocal index + if index >= len(tokens): + complain_eof() + token = tokens[index] + index += 1 + if token == "\n": + line += 1 + col = 0 + else: + col += len(token) + # import inspect + # calledfrom = inspect.currentframe().f_back.f_lineno + # print("** ate token", repr(token), + # "called from line", calledfrom) + return token + + def save(): + return (index, line, col) + + def restore(dat): + nonlocal index + nonlocal line + nonlocal col + index, line, col = dat + + def mark_used(): + nonlocal lastsig + lastsig = save() + + def skip_space(newlines: bool = False): + rv = False + while look().isspace() and (newlines or look() != "\n"): + eat() + rv = True + return rv + + def skip_comment(): + if look() == "%%": + while look() != "\n": + eat() + + def skip_i(newlines: bool = True): + while True: + if newlines and look() == "\n": + eat() + skip_space() + elif look() == "%%": + skip_comment() + else: + if not skip_space(): + return + + def expect(expected: set[str]): + got = look() + if got in expected: + eat() + mark_used() + return + complain(f"expected {' or '.join(map(repr, expected))}") + + def expect_not(disallowed: set[str]): + got = look() + if got not in disallowed: + return + complain(f"unexpected {got!r}") + + def parse_section() -> Section: + expect_not(SPECIAL) + name = eat() + # print("** starting section", repr(name)) + mark_used() + skip_i() + expect({"{"}) + data = {} + while look() != "}": + data |= parse_kv_pair() + eat() # the "}" + skip_i() + return Section(name, data) + + def parse_kv_pair() -> dict: + skip_i() + if look() == "}": + # handle case of ";}" + # print("**** got a ';}'") + return {} + expect_not(SPECIAL) + key = eat() + mark_used() + skip_i() + expect({"="}) + skip_space() + expect_not(SPECIAL) + value = "" + while True: + value += eat() + mark_used() + here = save() + skip_i(False) + ahead = look() + # print("* ahead", repr(ahead), repr(value)) + restore(here) + if ahead in SPECIAL: + break + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + value = bytes(value, "utf-8").decode("unicode-escape") + else: + # try to make a number if possible + try: + temp = value + value = float(temp) + value = int(temp) + except ValueError: + pass + # don't eat the ending "}" + if look() != "}": + expect({"\n", ";"}) + # print("*** got KV", repr(key), repr(value)) + return {key: value} + + skip_i() sections = [] - for key in data: - sections.append(Section(key, data[key])) + while index < len(tokens): + sections.append(parse_section()) return cls(sections) + def get_values_for(self, name: str) -> dict: + out = {} + for section in self.sections: + if section.matches(name): + out |= section.data + return out + + def global_options(self) -> dict: + return self.get_values_for(":all") + if __name__ == '__main__': + import pprint + text = "" text = r""" +:all { + %% these are global config options + color = black + width = 2; padding = 20; + format = symbol + mystring = "hello\nworld" +} -[drawing] -color = "black" -width = 2 -padding = 20 -format = "symbol" -[R1] -value = 10 -tolerance = 0.05 -wattage = 0.25 +R* {tolerance = .05; wattage = 0.25} -[R2] +R1 { + resistance = 0 - 10k; + %% trailing comment +} """ - print(Data.parse_from_string(text)) + my_data = Data.parse_from_string(text) + pprint.pprint(my_data) + pprint.pprint(my_data.get_values_for("R1")) From 0fb00a998da753b019e2930b678bd302ba725aae Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 17:55:30 -0400 Subject: [PATCH 012/101] formatting etc --- schemascii/metric.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/schemascii/metric.py b/schemascii/metric.py index 64f4e4d..2fdecbd 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -46,16 +46,17 @@ def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: digits, exp = Decimal(res.group(1)), int(res.group(2) or "0") assert exp % 3 == 0, "failed to make engineering notation" possibilities = [] - for cnd_exp_off in range(-12, 9, 3): - if (exp + cnd_exp_off) % 6 == 0 or not six: - new_exp = exp - cnd_exp_off - new_digits = str(digits * (Decimal(10) ** Decimal(cnd_exp_off))) - if "e" in new_digits.lower(): - # we're trying to avoid getting exponential notation here - continue - if "." in new_digits: - new_digits = new_digits.rstrip("0").removesuffix(".") - possibilities.append((new_digits, new_exp)) + for push in range(-12, 9, 3): + if six and (exp + push) % 6 != 0: + continue + new_exp = exp - push + new_digits = str(digits * (Decimal(10) ** Decimal(push))) + if "e" in new_digits.lower(): + # we're trying to avoid getting exponential notation here + continue + if "." in new_digits: + new_digits = new_digits.rstrip("0").removesuffix(".") + possibilities.append((new_digits, new_exp)) # heuristics: # * shorter is better # * prefer no decimal point @@ -72,7 +73,7 @@ def normalize_metric(num: str, six: bool, unicode: bool) -> tuple[str, str]: a tuple (normalized_digits, metric_multiplier).""" match = METRIC_NUMBER.match(num) if not match: - return num + return num, None digits_str, multiplier = match.group(1), match.group(2) digits_decimal = Decimal(digits_str) digits_decimal *= Decimal(10) ** Decimal( @@ -101,28 +102,29 @@ def format_metric_unit( if match: # format the range by calling recursively num0, num1 = match.group(1), match.group(2) - suffix = num[match.span(0)[1]:] + suffix = num[match.span()[1]:] digits0, exp0 = normalize_metric(num0, six, unicode) digits1, exp1 = normalize_metric(num1, six, unicode) if exp0 != exp1: # different multiplier so use multiplier and unit on both - return f"{digits0} {exp0}{unit} - {digits1} {exp1}{unit} {suffix}".rstrip() + return (f"{digits0} {exp0}{unit} - " + f"{digits1} {exp1}{unit} {suffix}").rstrip() return f"{digits0}-{digits1} {exp0}{unit} {suffix}".rstrip() match = METRIC_NUMBER.match(num) if not match: return num suffix = num[match.span(0)[1]:] - digits, exp = normalize_metric(match.group(0), six, unicode) + digits, exp = normalize_metric(match.group(), six, unicode) return f"{digits} {exp}{unit} {suffix}".rstrip() if __name__ == "__main__": def test(*args): - print(">>> format_metric_unit", args) + print(">>> format_metric_unit", args, sep="") print(repr(format_metric_unit(*args))) test("2.5-3500", "V") test("50n", "F", True) - test("50M-1000M", "Hz") + test("50M-1000000000000000000000p", "Hz") test(".1", "Ω") test("2200u", "F", True) test("Gain", "Ω") From 3dd8f0ef6cc12394e78ff516b4c2590b031a6ecc Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 12 Aug 2024 18:12:52 -0400 Subject: [PATCH 013/101] add refdes finder --- schemascii/refdes.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 schemascii/refdes.py diff --git a/schemascii/refdes.py b/schemascii/refdes.py new file mode 100644 index 0000000..38ea224 --- /dev/null +++ b/schemascii/refdes.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import re +from dataclasses import dataclass +from typing import Generic, TypeVar + +from .grid import Grid + +T = TypeVar("T") + +REFDES_PAT = re.compile(r"([A-Z]+)(\d+)([A-Z\d]*)") + + +@dataclass +class RefDes(Generic[T]): + """Object representing a component reference designator; + i.e. the letter+number+suffix combination uniquely identifying + the component on the diagram.""" + + letter: str + number: int + suffix: str + left: complex + right: complex + + @classmethod + def find_all(cls, grid: Grid) -> list[RefDes]: + out = [] + for row, line in enumerate(grid.lines): + for match in REFDES_PAT.finditer(line): + left_col, right_col = match.span() + letter, number, suffix = match.groups() + number = int(number) + out.append(cls( + letter, + number, + suffix, + complex(left_col, row), + complex(right_col - 1, row))) + return out + + +if __name__ == '__main__': + import pprint + gg = Grid("test_data/test_charge_pump.txt") + rds = RefDes.find_all(gg) + pts = [p for r in rds for p in [r.left, r.right]] + gg.spark(*pts) + pprint.pprint(rds) From cc4de5afe44500581e6b04c76f8e3974c50a88b4 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 17:58:37 -0400 Subject: [PATCH 014/101] add component flood finder --- schemascii/component.py | 74 +++++++++++++++++++++++++++++++++++++++++ schemascii/utils.py | 13 ++++++++ 2 files changed, 87 insertions(+) create mode 100644 schemascii/component.py diff --git a/schemascii/component.py b/schemascii/component.py new file mode 100644 index 0000000..9d9043b --- /dev/null +++ b/schemascii/component.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from .grid import Grid +from .refdes import RefDes +from .utils import iterate_line, perimeter + + +@dataclass +class Component: + all_components: ClassVar[list[type[Component]]] + + rd: RefDes + blobs: list[list[complex]] # to support multiple parts. + # flags: list[Flag] + + @classmethod + def from_rd(cls, rd: RefDes, grid: Grid) -> Component: + blobs = [] + seen = set() + + def flood(starting: list[complex], moore: bool) -> list[complex]: + frontier = list(starting) + out = [] + directions = [1, -1, 1j, -1j] + if moore: + directions.extend([-1+1j, 1+1j, -1-1j, 1-1j]) + while frontier: + point = frontier.pop(0) + out.append(point) + seen.add(point) + for d in directions: + newpoint = point + d + if grid.get(newpoint) != "#": + continue + if newpoint not in seen: + frontier.append(newpoint) + return out + + # add in the RD's bounds and find the main blob + blobs.append(flood(iterate_line(rd.left, rd.right), False)) + # now find all of the auxillary blobs + for pt in perimeter(blobs[0]): + for d in [-1+1j, 1+1j, -1-1j, 1-1j]: + cx = pt + d + if cx not in seen and grid.get(cx) == "#": + # we found another blob + blobs.append(flood([cx], True)) + # find all of the flags + pass + # done + return cls(rd, blobs) + + +if __name__ == '__main__': + testgrid = Grid("", """ + + [xor gate] [op amp] + +# ###### # + # ######## ### + # ######### ##### + # #U1G1##### #U2A### + # ######### ##### + # ######## ### +# ###### # +""") + for rd in RefDes.find_all(testgrid): + c = Component.from_rd(rd, testgrid) + print(c) + for blob in c.blobs: + testgrid.spark(*blob) diff --git a/schemascii/utils.py b/schemascii/utils.py index 8c0b829..9d4c74e 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -22,6 +22,19 @@ class Side(IntEnum): BOTTOM = 3 +def perimeter(pts: list[complex]) -> list[complex]: + """The set of points that are on the boundary of + the grid-aligned set pts.""" + out = [] + for pt in pts: + for d in [-1, -1-1j, -1j, 1, 1-1j, 1+1j, 1j, 1]: + xp = pt + d + if xp not in pts: + out.append(pt) + break + return out + + def colinear(*points: complex) -> bool: "Returns true if all the points are in the same line." return len(set(phase(p - points[0]) for p in points[1:])) == 1 From 40f25c6d6aec7f5692344c238086ab7abee16235 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:14:24 -0400 Subject: [PATCH 015/101] formatter --- schemascii/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 9d4c74e..e3d6cd6 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -1,12 +1,13 @@ +import re +from cmath import phase, rect from collections import namedtuple -from itertools import groupby, chain from enum import IntEnum +from itertools import chain, groupby from math import pi -from cmath import phase, rect from typing import Callable -import re -from .metric import format_metric_unit + from .errors import TerminalsError +from .metric import format_metric_unit Cbox = namedtuple("Cbox", "p1 p2 type id") BOMData = namedtuple("BOMData", "type id data") From 228249e66551c64dd7901deaae5d4f29dd78e560 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:14:32 -0400 Subject: [PATCH 016/101] add component registry --- schemascii/component.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/schemascii/component.py b/schemascii/component.py index 9d9043b..d90fa77 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -10,14 +10,22 @@ @dataclass class Component: - all_components: ClassVar[list[type[Component]]] + all_components: ClassVar[dict[str, type[Component]]] = {} rd: RefDes blobs: list[list[complex]] # to support multiple parts. # flags: list[Flag] + # terminals: list[Terminal] @classmethod def from_rd(cls, rd: RefDes, grid: Grid) -> Component: + # find the right component class + for cname in cls.all_components: + if cname == rd.letter: + cls = cls.all_components[cname] + break + + # now flood-fill to find the blobs blobs = [] seen = set() @@ -53,8 +61,24 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: # done return cls(rd, blobs) + def __init_subclass__(cls, names: list[str]): + """Register the component subclass in the component registry.""" + for name in names: + if not (name.isalpha() and name.upper() == name): + raise ValueError( + f"invalid reference designator letters: {name!r}") + if name in cls.all_components: + raise ValueError( + f"duplicate reference designator letters: {name!r}") + cls.all_components[name] = cls + if __name__ == '__main__': + class FooComponent(Component, names=["U", "FOO"]): + pass + + print(Component.all_components) + testgrid = Grid("", """ [xor gate] [op amp] @@ -72,3 +96,6 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: print(c) for blob in c.blobs: testgrid.spark(*blob) + + class BarComponent(Component, names=["FOO"]): + pass From aeb6cdbc96f4b8880f42d86dd64c5470128ffc1d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:07:00 -0400 Subject: [PATCH 017/101] make the linter happier --- schemascii/__init__.py | 8 ++- schemascii/components_render.py | 100 ++++++++++++++++++-------------- schemascii/edgemarks.py | 7 ++- schemascii/utils.py | 33 +++++++++-- 4 files changed, 95 insertions(+), 53 deletions(-) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 96d7308..058bd11 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -6,12 +6,13 @@ from .components_render import render_component from .wires import get_wires from .utils import XML -from .errors import * +from .errors import (Error, DiagramSyntaxError, TerminalsError, + BOMError, UnsupportedComponentError, ArgumentError) __version__ = "0.3.2" -def render(filename: str, text: str = None, **options) -> str: +def render(filename: str, text: str | None = None, **options) -> str: "Render the Schemascii diagram to an SVG string." if text is None: with open(filename, encoding="ascii") as f: @@ -20,7 +21,8 @@ def render(filename: str, text: str = None, **options) -> str: grid = Grid(filename, text) # Passed-in options override diagram inline options options = apply_config_defaults( - options | get_inline_configs(grid) | options.get("override_options", {}) + options | get_inline_configs( + grid) | options.get("override_options", {}) ) components, bom_data = find_all(grid) terminals = {c: find_edge_marks(grid, c) for c in components} diff --git a/schemascii/components_render.py b/schemascii/components_render.py index 46fd3bf..07b9822 100644 --- a/schemascii/components_render.py +++ b/schemascii/components_render.py @@ -2,6 +2,7 @@ from cmath import phase, rect from math import pi from warnings import warn +from functools import wraps from .utils import ( Cbox, Terminal, @@ -46,9 +47,10 @@ def n_terminal(n_terminals: int) -> Callable: "Ensures the component has N terminals." def n_inner(func: Callable) -> Callable: + @wraps(func) def n_check( - box: Cbox, terminals: list[Terminal], bom_data: list[BOMData], **options - ): + box: Cbox, terminals: list[Terminal], + bom_data: list[BOMData], **options): if len(terminals) != n_terminals: raise TerminalsError( f"{box.type}{box.id} component can only " @@ -56,7 +58,6 @@ def n_check( ) return func(box, terminals, bom_data, **options) - n_check.__doc__ = func.__doc__ return n_check return n_inner @@ -65,9 +66,10 @@ def n_check( def no_ambiguous(func: Callable) -> Callable: "Ensures the component has exactly one BOM data marker, and unwraps it." + @wraps(func) def de_ambiguous( - box: Cbox, terminals: list[Terminal], bom_data: list[BOMData], **options - ): + box: Cbox, terminals: list[Terminal], + bom_data: list[BOMData], **options): if len(bom_data) > 1: raise BOMError( f"Ambiguous BOM data for {box.type}{box.id}: {bom_data!r}") @@ -75,7 +77,6 @@ def de_ambiguous( bom_data = [BOMData(box.type, box.id, "")] return func(box, terminals, bom_data[0], **options) - de_ambiguous.__doc__ = func.__doc__ return de_ambiguous @@ -83,9 +84,10 @@ def polarized(func: Callable) -> Callable: """Ensures the component has 2 terminals, and then sorts them so the + terminal is first.""" + @wraps(func) def sort_terminals( - box: Cbox, terminals: list[Terminal], bom_data: list[BOMData], **options - ): + box: Cbox, terminals: list[Terminal], + bom_data: list[BOMData], **options): if len(terminals) != 2: raise TerminalsError( f"{box.type}{box.id} component can only " f"have 2 terminals" @@ -101,7 +103,8 @@ def sort_terminals( @component("R", "RV", "VR") @n_terminal(2) @no_ambiguous -def resistor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): +def resistor(box: Cbox, terminals: list[Terminal], + bom_data: BOMData, **options): """Resistor, Variable resistor, etc. bom:ohms[,watts]""" t1, t2 = terminals[0].pt, terminals[1].pt @@ -132,7 +135,8 @@ def resistor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options) @component("C", "CV", "VC") @n_terminal(2) @no_ambiguous -def capacitor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): +def capacitor(box: Cbox, terminals: list[Terminal], + bom_data: BOMData, **options): """Draw a capacitor, variable capacitor, etc. bom:farads[,volts] flags:+=positive""" @@ -167,7 +171,8 @@ def capacitor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options @component("L", "VL", "LV") @no_ambiguous -def inductor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): +def inductor(box: Cbox, terminals: list[Terminal], + bom_data: BOMData, **options): """Draw an inductor (coil, choke, etc) bom:henries""" t1, t2 = terminals[0].pt, terminals[1].pt @@ -202,7 +207,8 @@ def inductor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options) @component("B", "BT", "BAT") @polarized @no_ambiguous -def battery(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): +def battery(box: Cbox, terminals: list[Terminal], + bom_data: BOMData, **options): """Draw a battery cell. bom:volts[,amp-hours] flags:+=positive""" @@ -296,8 +302,9 @@ def integrated_circuit( ) for term in terminals: out += bunch_o_lines( - [(term.pt, term.pt + rect(1, SIDE_TO_ANGLE_MAP[term.side]))], **options - ) + [(term.pt, + term.pt + rect(1, SIDE_TO_ANGLE_MAP[term.side]))], + **options) if "V" in label_style and part_num: out += XML.text( XML.tspan(part_num, class_="part-num"), @@ -351,7 +358,8 @@ def jack(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): sc_t2 = t2 * scale sc_text_pt = sc_t2 + rect(scale / 2, SIDE_TO_ANGLE_MAP[terminals[0].side]) style = "input" if terminals[0].side in (Side.LEFT, Side.TOP) else "output" - if any(bom_data.data.endswith(x) for x in (",circle", ",input", ",output")): + if any(bom_data.data.endswith(x) + for x in (",circle", ",input", ",output")): style = bom_data.data.split(",")[-1] bom_data = BOMData( bom_data.type, @@ -381,13 +389,14 @@ def jack(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): @component("Q", "MOSFET", "MOS", "FET") @n_terminal(3) @no_ambiguous -def transistor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): +def transistor(box: Cbox, terminals: list[Terminal], + bom_data: BOMData, **options): """Draw a bipolar transistor (PNP/NPN) or FET (NFET/PFET). bom:{npn/pnp/nfet/pfet}:part-number flags:s=source,d=drain,g=gate,e=emitter,c=collector,b=base""" if not any( - bom_data.data.lower().startswith(x) for x in ("pnp", "npn", "nfet", "pfet") - ): + bom_data.data.lower().startswith(x) + for x in ("pnp", "npn", "nfet", "pfet")): raise BOMError(f"Need type of transistor for {box.type}{box.id}") silicon_type, *part_num = bom_data.data.split(":") part_num = ":".join(part_num) @@ -407,9 +416,10 @@ def transistor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **option # From wolfram alpha "solve m*(x-x1)+y1=(-1/m)*(x-x2)+y2 for x" # x = (m^2 x1 - m y1 + m y2 + x2)/(m^2 + 1) slope = diff.imag / diff.real - mid_x = ( - slope**2 * ap.real - slope * ap.imag + slope * ctl.pt.imag + ctl.pt.real - ) / (slope**2 + 1) + mid_x = (slope**2 * ap.real + - slope * ap.imag + + slope * ctl.pt.imag + + ctl.pt.real) / (slope**2 + 1) mid = complex(mid_x, slope * (mid_x - ap.real) + ap.imag) theta = phase(ap - sp) backwards = 1 if is_clockwise([ae, se, ctl]) else -1 @@ -459,9 +469,12 @@ def transistor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **option ] ) out_lines.append((mid + rect(1, thetaquarter), ctl.pt)) - return id_text( - box, bom_data, [ae, se], None, make_text_point(ap, sp, **options), **options - ) + bunch_o_lines(out_lines, **options) + return (id_text(box, + bom_data, + [ae, se], + None, + make_text_point(ap, sp, **options), **options) + + bunch_o_lines(out_lines, **options)) @component("G", "GND") @@ -507,23 +520,22 @@ def switch(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): t1, t2 = terminals[0].pt, terminals[1].pt mid = (t1 + t2) / 2 angle = phase(t1 - t2) - quad_angle = angle + pi / 2 scale = options["scale"] - out = (XML.circle( - cx=(rect(-scale, angle) + mid * scale).real, - cy=(rect(-scale, angle) + mid * scale).imag, - r=scale / 4, - stroke="transparent", - fill=options["stroke"], - class_="filled", - ) + XML.circle( - cx=(rect(scale, angle) + mid * scale).real, - cy=(rect(scale, angle) + mid * scale).imag, - r=scale / 4, - stroke="transparent", - fill=options["stroke"], - class_="filled", - ) + bunch_o_lines([(t1, mid + rect(1, angle)), (t2, mid + rect(-1, angle))], **options)) + out = (XML.circle(cx=(rect(-scale, angle) + mid * scale).real, + cy=(rect(-scale, angle) + mid * scale).imag, + r=scale / 4, + stroke="transparent", + fill=options["stroke"], + class_="filled") + + XML.circle(cx=(rect(scale, angle) + mid * scale).real, + cy=(rect(scale, angle) + mid * scale).imag, + r=scale / 4, + stroke="transparent", + fill=options["stroke"], + class_="filled") + + bunch_o_lines([ + (t1, mid + rect(1, angle)), + (t2, mid + rect(-1, angle))], **options)) sc = 1 match icon_type: case "nc": @@ -533,11 +545,15 @@ def switch(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): sc = 1.9 case "ncm": points = [(.3-1j, .3+1j)] - out += polylinegon(deep_transform([-.5+.6j, -.5-.6j, .3-.6j, .3+.6j], mid, angle), True, **options) + out += polylinegon( + deep_transform([-.5+.6j, -.5-.6j, .3-.6j, .3+.6j], mid, angle), + True, **options) sc = 1.3 case "nom": points = [(-.5-1j, -.5+1j)] - out += polylinegon(deep_transform([-1+.6j, -1-.6j, -.5-.6j, -.5+.6j], mid, angle), True, **options) + out += polylinegon( + deep_transform([-1+.6j, -1-.6j, -.5-.6j, -.5+.6j], mid, angle), + True, **options) sc = 2.5 case _: raise BOMError(f"Unknown switch symbol type: {icon_type}") diff --git a/schemascii/edgemarks.py b/schemascii/edgemarks.py index bdc64ef..8dab27c 100644 --- a/schemascii/edgemarks.py +++ b/schemascii/edgemarks.py @@ -1,10 +1,13 @@ from itertools import chain -from typing import Callable +from typing import Callable, TypeVar from .utils import Cbox, Flag, Side, Terminal from .grid import Grid +T = TypeVar("T") -def over_edges(box: Cbox, func: Callable[[complex, Side], list | None]): + +def over_edges(box: Cbox, + func: Callable[[complex, Side], list[T] | None]) -> list[T]: "Decorator - Runs around the edges of the box on the grid." out = [] for p, s in chain( diff --git a/schemascii/utils.py b/schemascii/utils.py index e3d6cd6..062bfa3 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -1,18 +1,39 @@ +from __future__ import annotations + import re from cmath import phase, rect -from collections import namedtuple from enum import IntEnum from itertools import chain, groupby from math import pi -from typing import Callable +from typing import Callable, NamedTuple from .errors import TerminalsError from .metric import format_metric_unit -Cbox = namedtuple("Cbox", "p1 p2 type id") -BOMData = namedtuple("BOMData", "type id data") -Flag = namedtuple("Flag", "pt char side") -Terminal = namedtuple("Terminal", "pt flag side") + +class Cbox(NamedTuple): + p1: complex + p2: complex + type: str + id: str + + +class BOMData(NamedTuple): + type: str + id: str + data: str + + +class Flag(NamedTuple): + pt: complex + char: str + side: Side + + +class Terminal(NamedTuple): + pt: complex + flag: str | None + side: Side class Side(IntEnum): From 3e2e65bc22aa2a74944575ded43fa90046ed6811 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:08:19 -0400 Subject: [PATCH 018/101] more making the linter happy --- schemascii/utils.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 062bfa3..09e2a3b 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -150,7 +150,9 @@ def deep_transform(data, origin: complex, theta: float): if isinstance(data, list | tuple): return [deep_transform(d, origin, theta) for d in data] if isinstance(data, complex): - return origin + rect(data.real, theta + pi / 2) + rect(data.imag, theta) + return (origin + + rect(data.real, theta + pi / 2) + + rect(data.imag, theta)) try: return deep_transform(complex(data), origin, theta) except TypeError as err: @@ -192,7 +194,8 @@ def mk_tag(*contents: str, **attrs: str) -> str: del XMLClass -def polylinegon(points: list[complex], is_polygon: bool = False, **options) -> str: +def polylinegon( + points: list[complex], is_polygon: bool = False, **options) -> str: "Turn the list of points into a or ." scale = options["scale"] w = options["stroke_width"] @@ -200,7 +203,9 @@ def polylinegon(points: list[complex], is_polygon: bool = False, **options) -> s pts = " ".join(f"{x.real * scale},{x.imag * scale}" for x in points) if is_polygon: return XML.polygon(points=pts, fill=c, class_="filled") - return XML.polyline(points=pts, fill="transparent", stroke__width=w, stroke=c) + return XML.polyline( + points=pts, fill="transparent", + stroke__width=w, stroke=c) def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: @@ -299,7 +304,8 @@ def make_text_point(t1: complex, t2: complex, **options) -> complex: return text_pt -def make_plus(terminals: list[Terminal], center: complex, theta: float, **options) -> str: +def make_plus(terminals: list[Terminal], center: complex, + theta: float, **options) -> str: "Make a + sign if the terminals indicate the component is polarized." if all(t.flag != "+" for t in terminals): return "" @@ -327,13 +333,15 @@ def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: ] -def make_variable(center: complex, theta: float, is_variable: bool = True, **options) -> str: +def make_variable(center: complex, theta: float, + is_variable: bool = True, **options) -> str: "Draw a 'variable' arrow across the component." if not is_variable: return "" - return bunch_o_lines( - deep_transform(arrow_points(-1, 1), center, (theta % pi) + pi / 4), **options - ) + return bunch_o_lines(deep_transform(arrow_points(-1, 1), + center, + (theta % pi) + pi / 4), + **options) def light_arrows(center: complex, theta: float, out: bool, **options): @@ -376,7 +384,8 @@ def is_clockwise(terminals: list[Terminal]) -> bool: return False -def sort_for_flags(terminals: list[Terminal], box: Cbox, *flags: list[str]) -> list[Terminal]: +def sort_for_flags(terminals: list[Terminal], + box: Cbox, *flags: list[str]) -> list[Terminal]: """Sorts out the terminals in the specified order using the flags. Raises and error if the flags are absent.""" out = () From be578e8859995edb9b9e892b139d644dd0bf530d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:10:41 -0400 Subject: [PATCH 019/101] finished making the linter happy --- schemascii/components_render.py | 3 ++- scripts/release.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/schemascii/components_render.py b/schemascii/components_render.py index 07b9822..389478d 100644 --- a/schemascii/components_render.py +++ b/schemascii/components_render.py @@ -575,7 +575,8 @@ def switch(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): # if they aren't the path will be transformed { # fuse - "F": "M0-.9A.1.1 0 000-1.1.1.1 0 000-.9ZM0-1Q.5-.5 0 0T0 1Q-.5.5 0 0T0-1ZM0 1.1A.1.1 0 000 .9.1.1 0 000 1.1Z", + "F": ("M0-.9A.1.1 0 000-1.1.1.1 0 000-.9ZM0-1Q.5-.5 0 0T0 1Q-.5.5 0 " + "0T0-1ZM0 1.1A.1.1 0 000 .9.1.1 0 000 1.1Z"), # jumper pads "JP": "M0-1Q-1-1-1-.25H1Q1-1 0-1ZM0 1Q-1 1-1 .25H1Q1 1 0 1", # loudspeaker diff --git a/scripts/release.py b/scripts/release.py index 649c4c2..2827333 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -43,18 +43,22 @@ def spit(file, text): cmd("python3 -m build --sdist") cmd("python3 -m build --wheel") -cmd("schemascii test_data/test_charge_pump.txt --out test_data/test_charge_pump.txt.svg") +cmd("schemascii test_data/test_charge_pump.txt --out " + "test_data/test_charge_pump.txt.svg") -print("for some reason convert isn't working with the css, so aborting the auto-rendering") +print("for some reason convert isn't working with the css, " + "so aborting the auto-rendering") sys.exit(0) -cmd("convert test_data/test_charge_pump.txt.svg test_data/test_charge_pump.png") +cmd("convert test_data/test_charge_pump.txt.svg " + "test_data/test_charge_pump.png") svg_content = slurp("test_data/test_charge_pump.txt.svg") css_content = slurp("schemascii_example.css") spit("test_data/test_charge_pump_css.txt.svg", svg_content.replace("", f'')) -cmd("convert test_data/test_charge_pump_css.txt.svg test_data/test_charge_pump_css.png") +cmd("convert test_data/test_charge_pump_css.txt.svg " + "test_data/test_charge_pump_css.png") # cmd("git add -A") # cmd("git commit -m 'blah'") From ad1cd6a2754f14ef4b0af60512831dcf6ddca5f4 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:15:13 -0400 Subject: [PATCH 020/101] missed typed --- schemascii/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 09e2a3b..f5278ca 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -83,7 +83,7 @@ def sharpness_score(points: list[complex]) -> float: return score -def intersecting(a, b, p, q): +def intersecting(a: complex, b: complex, p: complex, q: complex): """Return true if colinear line segments AB and PQ intersect.""" a, b, p, q = a.real, b.real, p.real, q.real sort_a, sort_b = min(a, b), max(a, b) @@ -172,7 +172,7 @@ def fix_number(n: float) -> str: class XMLClass: - def __getattr__(self, tag) -> Callable: + def __getattr__(self, tag: str) -> Callable: def mk_tag(*contents: str, **attrs: str) -> str: out = f"<{tag} " for k, v in attrs.items(): From 2c169283ababf6b4b22a6fb5d4919df9865e832e Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:19:08 -0400 Subject: [PATCH 021/101] modify to include wire configurations on Wire class --- schemascii/wire.py | 63 ++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/schemascii/wire.py b/schemascii/wire.py index d7b5a18..ed6f548 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -1,7 +1,9 @@ from __future__ import annotations + from collections import defaultdict +from dataclasses import dataclass from itertools import combinations -from typing import Literal +from typing import ClassVar, Literal from .grid import Grid from .utils import bunch_o_lines @@ -9,56 +11,63 @@ # This is a map of the direction coming into the cell # to the set of directions coming "out" of the cell. DirStr = Literal["^", "v", "<", ">"] -NOWHERE = None EVERYWHERE: defaultdict[DirStr, str] = defaultdict(lambda: "<>^v") IDENTITY: dict[DirStr, str] = {">": ">", "^": "^", "<": "<", "v": "v"} -WIRE_DIRECTIONS: defaultdict[str, defaultdict[DirStr, str]] = defaultdict( - lambda: NOWHERE, { - "-": IDENTITY, - "|": IDENTITY, - "(": IDENTITY, - ")": IDENTITY, - "~": IDENTITY, - ":": IDENTITY, - "*": EVERYWHERE, - }) -WIRE_STARTS: defaultdict[str, str] = defaultdict(lambda: NOWHERE, { - "-": "<>", - "|": "^v", - "(": "^v", - ")": "^v", - "*": "<>^v" -}) CHAR2DIR: dict[DirStr, complex] = {">": -1, "<": 1, "^": 1j, "v": -1j} -class Wire(list[complex]): +@dataclass +class Wire: """List of grid points along a wire.""" + directions: ClassVar[ + defaultdict[str, defaultdict[DirStr, str]]] = defaultdict( + lambda: None, { + "-": IDENTITY, + "|": IDENTITY, + "(": IDENTITY, + ")": IDENTITY, + "~": IDENTITY, + ":": IDENTITY, + "*": EVERYWHERE, + }) + starting_directions: ClassVar[ + defaultdict[str, str]] = defaultdict( + lambda: None, { + "-": "<>", + "|": "^v", + "(": "^v", + ")": "^v", + "*": "<>^v" + }) + + # the sole member + points: list[complex] + @classmethod def get_from_grid(cls, grid: Grid, start: complex) -> Wire: seen: set[complex] = set() - pts: list[complex] = [] + points: list[complex] = [] stack: list[tuple[complex, DirStr]] = [ - (start, WIRE_STARTS[grid.get(start)])] + (start, cls.starting_directions[grid.get(start)])] while stack: point, directions = stack.pop() if point in seen: continue seen.add(point) - pts.append(point) + points.append(point) for dir in directions: next_pt = point + CHAR2DIR[dir] - if ((next_dirs := WIRE_DIRECTIONS[grid.get(next_pt)]) - is not NOWHERE): + if ((next_dirs := cls.directions[grid.get(next_pt)]) + is not None): stack.append((next_pt, next_dirs[dir])) - return cls(pts) + return cls(points) def to_xml_string(self, **options) -> str: # create lines for all of the neighbor pairs links = [] - for p1, p2 in combinations(self, 2): + for p1, p2 in combinations(self.points, 2): if abs(p1 - p2) == 1: links.append((p1, p2)) return bunch_o_lines(links, **options) From ac9e6dd6359076990f44d1a16ef32e802daf2ae8 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:24:14 -0400 Subject: [PATCH 022/101] whoops that's going to be the box... --- schemascii/wire.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/schemascii/wire.py b/schemascii/wire.py index ed6f548..e5429ec 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -28,8 +28,6 @@ class Wire: "|": IDENTITY, "(": IDENTITY, ")": IDENTITY, - "~": IDENTITY, - ":": IDENTITY, "*": EVERYWHERE, }) starting_directions: ClassVar[ From 91db204b371871c17801ec2551f698e9b00e989d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:24:36 -0400 Subject: [PATCH 023/101] add components terminals finder --- schemascii/component.py | 80 ++++++++++++++++++++++----------- schemascii/components_render.py | 4 +- schemascii/utils.py | 64 +++++++++++++++++++++----- 3 files changed, 108 insertions(+), 40 deletions(-) diff --git a/schemascii/component.py b/schemascii/component.py index d90fa77..7248451 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -5,7 +5,9 @@ from .grid import Grid from .refdes import RefDes -from .utils import iterate_line, perimeter +from .utils import (DIAGONAL, ORTHAGONAL, Side, Terminal, iterate_line, + perimeter) +from .wire import Wire @dataclass @@ -14,8 +16,7 @@ class Component: rd: RefDes blobs: list[list[complex]] # to support multiple parts. - # flags: list[Flag] - # terminals: list[Terminal] + terminals: list[Terminal] @classmethod def from_rd(cls, rd: RefDes, grid: Grid) -> Component: @@ -26,15 +27,15 @@ def from_rd(cls, rd: RefDes, grid: Grid) -> Component: break # now flood-fill to find the blobs - blobs = [] - seen = set() + blobs: list[list[complex]] = [] + seen: set[complex] = set() def flood(starting: list[complex], moore: bool) -> list[complex]: frontier = list(starting) - out = [] - directions = [1, -1, 1j, -1j] + out: list[complex] = [] + directions = ORTHAGONAL if moore: - directions.extend([-1+1j, 1+1j, -1-1j, 1-1j]) + directions += DIAGONAL while frontier: point = frontier.pop(0) out.append(point) @@ -50,16 +51,45 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: # add in the RD's bounds and find the main blob blobs.append(flood(iterate_line(rd.left, rd.right), False)) # now find all of the auxillary blobs - for pt in perimeter(blobs[0]): - for d in [-1+1j, 1+1j, -1-1j, 1-1j]: - cx = pt + d - if cx not in seen and grid.get(cx) == "#": + for perimeter_pt in perimeter(blobs[0]): + for d in DIAGONAL: + poss_aux_blob_pt = perimeter_pt + d + if (poss_aux_blob_pt not in seen + and grid.get(poss_aux_blob_pt) == "#"): # we found another blob - blobs.append(flood([cx], True)) - # find all of the flags - pass + blobs.append(flood([poss_aux_blob_pt], True)) + # find all of the terminals + terminals: list[Terminal] = [] + for perimeter_pt in perimeter(seen): + # these get masked with wires because they are like wires + for d in ORTHAGONAL: + poss_term_pt = perimeter_pt + d + ch = grid.get(poss_term_pt) + if ch != "#" and not ch.isspace(): + # candidate for terminal + # search around again to see if a wire connects + # to it + for d in ORTHAGONAL: + if (grid.get(d + poss_term_pt) + in Wire.starting_directions.keys()): + # there is a neighbor with a wire, so it must + # be a terminal + break + # now d holds the direction of the terminal + else: + # no nearby wires - must just be something + # like the reference designator or other junk + continue + if any(t.pt == poss_term_pt for t in terminals): + # already found this one + continue + if ch in Wire.starting_directions.keys(): + # it is just a connected wire, not a flag + ch = None + terminals.append( + Terminal(poss_term_pt, ch, Side.from_phase(d))) # done - return cls(rd, blobs) + return cls(rd, blobs, terminals) def __init_subclass__(cls, names: list[str]): """Register the component subclass in the component registry.""" @@ -83,19 +113,17 @@ class FooComponent(Component, names=["U", "FOO"]): [xor gate] [op amp] -# ###### # - # ######## ### - # ######### ##### - # #U1G1##### #U2A### - # ######### ##### - # ######## ### -# ###### # + # ###### # + # ######## ### + ----# ######### ----+##### + # #U1G1#####---- #U2A###----- + ----# ######### -----##### + # ######## ### + # ###### # """) for rd in RefDes.find_all(testgrid): c = Component.from_rd(rd, testgrid) print(c) for blob in c.blobs: testgrid.spark(*blob) - - class BarComponent(Component, names=["FOO"]): - pass + testgrid.spark(*(t.pt for t in c.terminals)) diff --git a/schemascii/components_render.py b/schemascii/components_render.py index 389478d..74c7a26 100644 --- a/schemascii/components_render.py +++ b/schemascii/components_render.py @@ -17,7 +17,7 @@ deep_transform, make_plus, make_variable, - sort_counterclockwise, + sort_terminals_counterclockwise, light_arrows, sort_for_flags, is_clockwise, @@ -324,7 +324,7 @@ def integrated_circuit( font__size=options["scale"], fill=options["stroke"], ) - s_terminals = sort_counterclockwise(terminals) + s_terminals = sort_terminals_counterclockwise(terminals) for terminal, label in zip(s_terminals, pin_labels): sc_text_pt = terminal.pt * scale out += XML.text( diff --git a/schemascii/utils.py b/schemascii/utils.py index f5278ca..4de2e4c 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -1,16 +1,19 @@ from __future__ import annotations import re -from cmath import phase, rect +from cmath import phase, rect, pi from enum import IntEnum from itertools import chain, groupby -from math import pi from typing import Callable, NamedTuple from .errors import TerminalsError from .metric import format_metric_unit +ORTHAGONAL = (1, -1, 1j, -1j) +DIAGONAL = (-1+1j, 1+1j, -1-1j, 1-1j) + + class Cbox(NamedTuple): p1: complex p2: complex @@ -43,18 +46,52 @@ class Side(IntEnum): LEFT = 2 BOTTOM = 3 + @classmethod + def from_phase(cls, pt: complex) -> Side: + ops = { + -pi: Side.LEFT, + pi: Side.LEFT, + pi / 2: Side.TOP, + -pi / 2: Side.BOTTOM, + 0: Side.RIGHT + } + pph = phase(pt) + best_err = float("inf") + best_side = None + for ph, s in ops.items(): + err = abs(ph - pph) + if best_err > err: + best_err = err + best_side = s + return best_side + def perimeter(pts: list[complex]) -> list[complex]: """The set of points that are on the boundary of the grid-aligned set pts.""" out = [] for pt in pts: - for d in [-1, -1-1j, -1j, 1, 1-1j, 1+1j, 1j, 1]: + for d in ORTHAGONAL + DIAGONAL: xp = pt + d if xp not in pts: out.append(pt) break - return out + return out # sort_counterclockwise(out, centroid(pts)) + + +def centroid(pts: list[complex]) -> complex: + """Return the centroid of the set of points pts.""" + return sum(pts) / len(pts) + + +def sort_counterclockwise(pts: list[complex], + center: complex | None = None) -> list[complex]: + """Returns pts sorted so that the points + progress clockwise around the center, starting with the + rightmost point.""" + if center is None: + center = centroid(pts) + return sorted(pts, key=lambda p: phase(p - center)) def colinear(*points: complex) -> bool: @@ -73,8 +110,8 @@ def sharpness_score(points: list[complex]) -> float: if len(points) < 3: return float("nan") score = 0 - prev_pt = points.imag - prev_ph = phase(points.imag - points[0]) + prev_pt = points[1] + prev_ph = phase(points[1] - points[0]) for p in points[2:]: ph = phase(p - prev_pt) score += abs(prev_ph - ph) @@ -358,7 +395,8 @@ def light_arrows(center: complex, theta: float, out: bool, **options): ) -def sort_counterclockwise(terminals: list[Terminal]) -> list[Terminal]: +def sort_terminals_counterclockwise( + terminals: list[Terminal]) -> list[Terminal]: "Sort the terminals in counterclockwise order." partitioned = { side: list(filtered_terminals) @@ -376,7 +414,7 @@ def sort_counterclockwise(terminals: list[Terminal]) -> list[Terminal]: def is_clockwise(terminals: list[Terminal]) -> bool: "Return true if the terminals are clockwise order." - sort = sort_counterclockwise(terminals) + sort = sort_terminals_counterclockwise(terminals) for _ in range(len(sort)): if sort == terminals: return True @@ -408,7 +446,9 @@ def sort_for_flags(terminals: list[Terminal], if __name__ == '__main__': - from .grid import Grid - w, h, = 22, 23 - x = Grid("", "\n".join("".join(" " for _ in range(w)) for _ in range(h))) - x.spark(*iterate_line(0, complex(w, h))) + import pprint + pts = [] + n = 100 + for x in range(n): + pts.append(force_int(rect(n, 2 * pi * x / n))) + pprint.pprint(sort_counterclockwise(pts)) From c7a1544bc0052245e93169fa1173b894f05b0437 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:26:27 -0400 Subject: [PATCH 024/101] need to mask terminal that is not already a wire --- schemascii/component.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schemascii/component.py b/schemascii/component.py index 7248451..b9c87f4 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -86,6 +86,9 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: if ch in Wire.starting_directions.keys(): # it is just a connected wire, not a flag ch = None + else: + # mask the wire + grid.setmask(poss_term_pt, "*") terminals.append( Terminal(poss_term_pt, ch, Side.from_phase(d))) # done From 40bb0d9fbe867e9dd54ab3303de2efb71a66abb4 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:26:39 -0400 Subject: [PATCH 025/101] linter --- schemascii/wires.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schemascii/wires.py b/schemascii/wires.py index ef5cb94..e74a4b9 100644 --- a/schemascii/wires.py +++ b/schemascii/wires.py @@ -8,7 +8,9 @@ DIRECTIONS = [1, -1, 1j, -1j] -def next_in_dir(grid: Grid, point: complex, dydx: complex) -> tuple[complex, complex] | None: +def next_in_dir(grid: Grid, + point: complex, + dydx: complex) -> tuple[complex, complex] | None: """Follows the wire starting at the point in the specified direction, until some interesting change (a corner, junction, or end). Returns the tuple (new, old).""" From d908063c2f4b4794ac97ce3f407c247c45c4c3f6 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:40:29 -0400 Subject: [PATCH 026/101] refactor --- schemascii/component.py | 2 +- schemascii/wire.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/schemascii/component.py b/schemascii/component.py index b9c87f4..1042322 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -83,7 +83,7 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: if any(t.pt == poss_term_pt for t in terminals): # already found this one continue - if ch in Wire.starting_directions.keys(): + if Wire.is_wire_character(ch): # it is just a connected wire, not a flag ch = None else: diff --git a/schemascii/wire.py b/schemascii/wire.py index e5429ec..76a07ca 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -70,6 +70,10 @@ def to_xml_string(self, **options) -> str: links.append((p1, p2)) return bunch_o_lines(links, **options) + @classmethod + def is_wire_character(cls, ch: str) -> bool: + return ch in cls.starting_directions + if __name__ == '__main__': x = Grid("", """ From 70e92371710830d668d369021917792d37ff4688 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:02:54 -0400 Subject: [PATCH 027/101] add partial net and general drawing parse code --- schemascii/drawing.py | 47 +++++++++++++++++++++++++++++-------------- schemascii/net.py | 30 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 schemascii/net.py diff --git a/schemascii/drawing.py b/schemascii/drawing.py index bc65f85..cc28156 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -1,33 +1,50 @@ from __future__ import annotations +from dataclasses import dataclass +from .component import Component +from .data_parse import Data +from .errors import DiagramSyntaxError +from .grid import Grid +from .net import Net +from .refdes import RefDes + + +@dataclass class Drawing: """A Schemascii drawing document.""" - def __init__( - self, - wire_nets, # list of nets of wires - components, # list of components found - annotations, # boxes, lines, comments, etc - data): - self.nets = wire_nets - self.components = components - self.annotations = annotations - self.data = data + nets: list # [Net] + components: list[Component] + annotations: list # [Annotation] + data: Data @classmethod - def parse_from_string(cls, data: str, **options) -> Drawing: + def parse_from_string(cls, + filename: str, + data: str | None = None, + **options) -> Drawing: + if data is None: + with open(filename) as f: + data = f.read() lines = data.splitlines() marker = options.get("data-marker", "---") try: marker_pos = lines.index(marker) - except ValueError: - raise SyntaxError( + except ValueError as e: + raise DiagramSyntaxError( "data-marker must be present in a drawing! " - f"(current data-marker is: {marker!r})") + f"(current data-marker is: {marker!r})") from e drawing_area = "\n".join(lines[:marker_pos]) data_area = "\n".join(lines[marker_pos+1:]) - raise NotImplementedError + grid = Grid(filename, drawing_area) + nets = Net.find_all(grid) + components = [Component.from_rd(r, grid) + for r in RefDes.find_all(grid)] + # todo: annotations! + annotations = [] + data = Data.parse_from_string(data_area, marker_pos + 1, filename) + return cls(nets, components, annotations, data) def to_xml_string(self, **options) -> str: raise NotImplementedError diff --git a/schemascii/net.py b/schemascii/net.py new file mode 100644 index 0000000..bc42edc --- /dev/null +++ b/schemascii/net.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .grid import Grid +from .wire import Wire + + +@dataclass +class Net: + """Grouping of wires that are + electrically connected.""" + + wires: list[Wire] + # annotation: WireTag | None + + @classmethod + def find_all(cls, grid: Grid) -> list[Net]: + seen_points: set[complex] = set() + all_nets: list[cls] = [] + + for y, line in enumerate(grid.lines): + for x, ch in enumerate(line): + if Wire.is_wire_character(ch): + wire = Wire.get_from_grid(grid, complex(x, y)) + if all(p in seen_points for p in wire.points): + continue + all_nets.append(cls([wire])) + seen_points.update(wire.points) + return all_nets From f90d6d25949208edd12a618f43acdf1add4061cc Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 16:56:00 -0400 Subject: [PATCH 028/101] change import method "from .xxx import yyy" caused circular imports (see next commit) --- schemascii/component.py | 45 ++++++++++++++++++++-------------------- schemascii/configs.py | 7 ++++--- schemascii/data_parse.py | 7 ++++--- schemascii/drawing.py | 29 +++++++++++++------------- schemascii/net.py | 12 +++++------ schemascii/refdes.py | 11 ++++------ schemascii/wire.py | 21 ++++++++++--------- 7 files changed, 66 insertions(+), 66 deletions(-) diff --git a/schemascii/component.py b/schemascii/component.py index 1042322..b40a6b4 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -3,23 +3,22 @@ from dataclasses import dataclass from typing import ClassVar -from .grid import Grid -from .refdes import RefDes -from .utils import (DIAGONAL, ORTHAGONAL, Side, Terminal, iterate_line, - perimeter) -from .wire import Wire +import schemascii.grid as _grid +import schemascii.refdes as _rd +import schemascii.utils as _utils +import schemascii.wire as _wire @dataclass class Component: all_components: ClassVar[dict[str, type[Component]]] = {} - rd: RefDes + rd: _rd.RefDes blobs: list[list[complex]] # to support multiple parts. - terminals: list[Terminal] + terminals: list[_utils.Terminal] @classmethod - def from_rd(cls, rd: RefDes, grid: Grid) -> Component: + def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # find the right component class for cname in cls.all_components: if cname == rd.letter: @@ -33,9 +32,9 @@ def from_rd(cls, rd: RefDes, grid: Grid) -> Component: def flood(starting: list[complex], moore: bool) -> list[complex]: frontier = list(starting) out: list[complex] = [] - directions = ORTHAGONAL + directions = _utils.ORTHAGONAL if moore: - directions += DIAGONAL + directions += _utils.DIAGONAL while frontier: point = frontier.pop(0) out.append(point) @@ -49,29 +48,29 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: return out # add in the RD's bounds and find the main blob - blobs.append(flood(iterate_line(rd.left, rd.right), False)) + blobs.append(flood(_utils.iterate_line(rd.left, rd.right), False)) # now find all of the auxillary blobs - for perimeter_pt in perimeter(blobs[0]): - for d in DIAGONAL: + for perimeter_pt in _utils.perimeter(blobs[0]): + for d in _utils.DIAGONAL: poss_aux_blob_pt = perimeter_pt + d if (poss_aux_blob_pt not in seen and grid.get(poss_aux_blob_pt) == "#"): # we found another blob blobs.append(flood([poss_aux_blob_pt], True)) # find all of the terminals - terminals: list[Terminal] = [] - for perimeter_pt in perimeter(seen): + terminals: list[_utils.Terminal] = [] + for perimeter_pt in _utils.perimeter(seen): # these get masked with wires because they are like wires - for d in ORTHAGONAL: + for d in _utils.ORTHAGONAL: poss_term_pt = perimeter_pt + d ch = grid.get(poss_term_pt) if ch != "#" and not ch.isspace(): # candidate for terminal # search around again to see if a wire connects # to it - for d in ORTHAGONAL: + for d in _utils.ORTHAGONAL: if (grid.get(d + poss_term_pt) - in Wire.starting_directions.keys()): + in _wire.Wire.starting_directions.keys()): # there is a neighbor with a wire, so it must # be a terminal break @@ -83,14 +82,14 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: if any(t.pt == poss_term_pt for t in terminals): # already found this one continue - if Wire.is_wire_character(ch): + if _wire.Wire.is_wire_character(ch): # it is just a connected wire, not a flag ch = None else: # mask the wire grid.setmask(poss_term_pt, "*") - terminals.append( - Terminal(poss_term_pt, ch, Side.from_phase(d))) + terminals.append(_utils.Terminal( + poss_term_pt, ch, _utils.Side.from_phase(d))) # done return cls(rd, blobs, terminals) @@ -112,7 +111,7 @@ class FooComponent(Component, names=["U", "FOO"]): print(Component.all_components) - testgrid = Grid("", """ + testgrid = _grid.Grid("", """ [xor gate] [op amp] @@ -124,7 +123,7 @@ class FooComponent(Component, names=["U", "FOO"]): # ######## ### # ###### # """) - for rd in RefDes.find_all(testgrid): + for rd in _rd.RefDes.find_all(testgrid): c = Component.from_rd(rd, testgrid) print(c) for blob in c.blobs: diff --git a/schemascii/configs.py b/schemascii/configs.py index 4eeea8b..a3ec2bd 100644 --- a/schemascii/configs.py +++ b/schemascii/configs.py @@ -1,6 +1,7 @@ import argparse from dataclasses import dataclass -from .errors import ArgumentError + +import schemascii.errors as _errors @dataclass @@ -66,7 +67,7 @@ def apply_config_defaults(options: dict) -> dict: continue if isinstance(opt.clazz, list): if options[opt.name] not in opt.clazz: - raise ArgumentError( + raise _errors.ArgumentError( f"config option {opt.name}: " f"invalid choice: {options[opt.name]} " f"(valid options are {', '.join(map(repr, opt.clazz))})" @@ -75,7 +76,7 @@ def apply_config_defaults(options: dict) -> dict: try: options[opt.name] = opt.clazz(options[opt.name]) except ValueError as err: - raise ArgumentError( + raise _errors.ArgumentError( f"config option {opt.name}: " f"invalid {opt.clazz.__name__} value: " f"{options[opt.name]}" diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py index 4cf141b..ac50b18 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data_parse.py @@ -1,9 +1,10 @@ from __future__ import annotations -import re + import fnmatch +import re from dataclasses import dataclass -from .errors import DiagramSyntaxError +import schemascii.errors as _errors TOKEN_PAT = re.compile("|".join([ r"[\n{};=]", # special one-character @@ -48,7 +49,7 @@ def parse_from_string(cls, text: str, startline=1, filename="") -> Data: lastsig = (0, 0, 0) def complain(msg): - raise DiagramSyntaxError( + raise _errors.DiagramSyntaxError( f"{filename} line {line+startline}: {msg}\n" f" {lines[line]}\n" f" {' ' * col}{'^'*len(look())}".lstrip()) diff --git a/schemascii/drawing.py b/schemascii/drawing.py index cc28156..ff28e0f 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -2,12 +2,12 @@ from dataclasses import dataclass -from .component import Component -from .data_parse import Data -from .errors import DiagramSyntaxError -from .grid import Grid -from .net import Net -from .refdes import RefDes +import schemascii.component as _component +import schemascii.data_parse as _data +import schemascii.errors as _errors +import schemascii.grid as _grid +import schemascii.net as _net +import schemascii.refdes as _rd @dataclass @@ -15,9 +15,9 @@ class Drawing: """A Schemascii drawing document.""" nets: list # [Net] - components: list[Component] + components: list[_component.Component] annotations: list # [Annotation] - data: Data + data: _data.Data @classmethod def parse_from_string(cls, @@ -32,18 +32,19 @@ def parse_from_string(cls, try: marker_pos = lines.index(marker) except ValueError as e: - raise DiagramSyntaxError( + raise _errors.DiagramSyntaxError( "data-marker must be present in a drawing! " f"(current data-marker is: {marker!r})") from e drawing_area = "\n".join(lines[:marker_pos]) data_area = "\n".join(lines[marker_pos+1:]) - grid = Grid(filename, drawing_area) - nets = Net.find_all(grid) - components = [Component.from_rd(r, grid) - for r in RefDes.find_all(grid)] + grid = _grid.Grid(filename, drawing_area) + nets = _net.Net.find_all(grid) + components = [_component.Component.from_rd(r, grid) + for r in _rd.RefDes.find_all(grid)] # todo: annotations! annotations = [] - data = Data.parse_from_string(data_area, marker_pos + 1, filename) + data = _data.Data.parse_from_string( + data_area, marker_pos + 1, filename) return cls(nets, components, annotations, data) def to_xml_string(self, **options) -> str: diff --git a/schemascii/net.py b/schemascii/net.py index bc42edc..966af60 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -2,8 +2,8 @@ from dataclasses import dataclass -from .grid import Grid -from .wire import Wire +import schemascii.grid as _grid +import schemascii.wire as _wire @dataclass @@ -11,18 +11,18 @@ class Net: """Grouping of wires that are electrically connected.""" - wires: list[Wire] + wires: list[_wire.Wire] # annotation: WireTag | None @classmethod - def find_all(cls, grid: Grid) -> list[Net]: + def find_all(cls, grid: _grid.Grid) -> list[Net]: seen_points: set[complex] = set() all_nets: list[cls] = [] for y, line in enumerate(grid.lines): for x, ch in enumerate(line): - if Wire.is_wire_character(ch): - wire = Wire.get_from_grid(grid, complex(x, y)) + if _wire.Wire.is_wire_character(ch): + wire = _wire.Wire.get_from_grid(grid, complex(x, y)) if all(p in seen_points for p in wire.points): continue all_nets.append(cls([wire])) diff --git a/schemascii/refdes.py b/schemascii/refdes.py index 38ea224..000be23 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -1,17 +1,14 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import Generic, TypeVar -from .grid import Grid - -T = TypeVar("T") +import schemascii.grid as _grid REFDES_PAT = re.compile(r"([A-Z]+)(\d+)([A-Z\d]*)") @dataclass -class RefDes(Generic[T]): +class RefDes: """Object representing a component reference designator; i.e. the letter+number+suffix combination uniquely identifying the component on the diagram.""" @@ -23,7 +20,7 @@ class RefDes(Generic[T]): right: complex @classmethod - def find_all(cls, grid: Grid) -> list[RefDes]: + def find_all(cls, grid: _grid.Grid) -> list[RefDes]: out = [] for row, line in enumerate(grid.lines): for match in REFDES_PAT.finditer(line): @@ -41,7 +38,7 @@ def find_all(cls, grid: Grid) -> list[RefDes]: if __name__ == '__main__': import pprint - gg = Grid("test_data/test_charge_pump.txt") + gg = _grid.Grid("test_data/test_charge_pump.txt") rds = RefDes.find_all(gg) pts = [p for r in rds for p in [r.left, r.right]] gg.spark(*pts) diff --git a/schemascii/wire.py b/schemascii/wire.py index 76a07ca..37a51d1 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -5,12 +5,11 @@ from itertools import combinations from typing import ClassVar, Literal -from .grid import Grid -from .utils import bunch_o_lines +import schemascii.grid as _grid +import schemascii.utils as _utils +import schemascii.wire_tag as _wt -# This is a map of the direction coming into the cell -# to the set of directions coming "out" of the cell. -DirStr = Literal["^", "v", "<", ">"] +DirStr = Literal["^", "v", "<", ">"] | None EVERYWHERE: defaultdict[DirStr, str] = defaultdict(lambda: "<>^v") IDENTITY: dict[DirStr, str] = {">": ">", "^": "^", "<": "<", "v": "v"} @@ -21,6 +20,8 @@ class Wire: """List of grid points along a wire.""" + # This is a map of the direction coming into the cell + # to the set of directions coming "out" of the cell. directions: ClassVar[ defaultdict[str, defaultdict[DirStr, str]]] = defaultdict( lambda: None, { @@ -40,11 +41,11 @@ class Wire: "*": "<>^v" }) - # the sole member points: list[complex] + tag: _wt.WireTag | None @classmethod - def get_from_grid(cls, grid: Grid, start: complex) -> Wire: + def get_from_grid(cls, grid: _grid.Grid, start: complex) -> Wire: seen: set[complex] = set() points: list[complex] = [] stack: list[tuple[complex, DirStr]] = [ @@ -68,7 +69,7 @@ def to_xml_string(self, **options) -> str: for p1, p2 in combinations(self.points, 2): if abs(p1 - p2) == 1: links.append((p1, p2)) - return bunch_o_lines(links, **options) + return _utils.bunch_o_lines(links, **options) @classmethod def is_wire_character(cls, ch: str) -> bool: @@ -76,12 +77,12 @@ def is_wire_character(cls, ch: str) -> bool: if __name__ == '__main__': - x = Grid("", """ + x = _grid.Grid("", """ . * -------------------------* | | - *----------||||----* -------* + *----------||||----* -------*----=foo> | | ----------- | | | From c1089f6628790b275c59dfbeebb0c590d368b491 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 16:56:24 -0400 Subject: [PATCH 029/101] add wire tag parsing need to integrate into wire parsing and net generation --- schemascii/wire_tag.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 schemascii/wire_tag.py diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py new file mode 100644 index 0000000..15661fa --- /dev/null +++ b/schemascii/wire_tag.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal + +import schemascii.grid as _grid +import schemascii.utils as _utils +import schemascii.wire as _wire + +WIRE_TAG_PAT = re.compile(r"<([^\s=]+)=|=([^\s>]+)>") + + +@dataclass +class WireTag: + """A wire tag is a named flag on the end of the + wire, that gives it a name and also indicates what + direction information flows. + + Wire tags currently only support horizontal connections + as of right now.""" + + name: str + position: complex + attach_side: Literal[_utils.Side.LEFT, _utils.Side.RIGHT] + point_dir: Literal[_utils.Side.LEFT, _utils.Side.RIGHT] + + @classmethod + def find_all(cls, grid: _grid.Grid) -> list[WireTag]: + out: list[WireTag] = [] + for y, line in enumerate(grid.lines): + for match in WIRE_TAG_PAT.finditer(line): + left_grp, right_grp = match.groups() + x_start, x_end = match.span() + left_pos = complex(x_start, y) + right_pos = complex(x_end - 1, y) + if left_grp is not None: + point_dir = _utils.Side.LEFT + name = left_grp + else: + point_dir = _utils.Side.RIGHT + name = right_grp + if _wire.Wire.is_wire_character(grid.get(left_pos - 1)): + attach_side = _utils.Side.LEFT + position = left_pos + else: + attach_side = _utils.Side.RIGHT + position = right_pos + out.append(WireTag(name, position, attach_side, point_dir)) + return out + + +if __name__ == '__main__': + import pprint + g = _grid.Grid("foo.txt", """ +-------=foo[0:9]> + + =$rats>-------- +""") + tags = WireTag.find_all(g) + pprint.pprint(tags) + g.spark(*(x.position for x in tags)) From 25947c3a232643c44eaf119998611148eb2323a0 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 17:07:39 -0400 Subject: [PATCH 030/101] connect wire_tag to wire --- schemascii/wire.py | 23 ++++++++++++++++++----- schemascii/wire_tag.py | 8 ++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/schemascii/wire.py b/schemascii/wire.py index 37a51d1..334dfda 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -45,7 +45,9 @@ class Wire: tag: _wt.WireTag | None @classmethod - def get_from_grid(cls, grid: _grid.Grid, start: complex) -> Wire: + def get_from_grid(cls, grid: _grid.Grid, + start: complex, tags: list[_wt.WireTag]) -> Wire: + """tags will be mutated""" seen: set[complex] = set() points: list[complex] = [] stack: list[tuple[complex, DirStr]] = [ @@ -61,7 +63,17 @@ def get_from_grid(cls, grid: _grid.Grid, start: complex) -> Wire: if ((next_dirs := cls.directions[grid.get(next_pt)]) is not None): stack.append((next_pt, next_dirs[dir])) - return cls(points) + self_tag = None + for point in points: + for t in tags: + if t.connect_pt == point: + self_tag = t + tags.remove(t) + break + else: + continue + break + return cls(points, self_tag) def to_xml_string(self, **options) -> str: # create lines for all of the neighbor pairs @@ -93,6 +105,7 @@ def is_wire_character(cls, ch: str) -> bool: . """.strip()) - pts = Wire.get_from_grid(x, 2+4j) - x.spark(*pts) - print(pts.to_xml_string(scale=10, stroke_width=2, stroke="black")) + wire = Wire.get_from_grid(x, 2+4j, _wt.WireTag.find_all(x)) + print(wire) + x.spark(*wire.points) + print(wire.to_xml_string(scale=10, stroke_width=2, stroke="black")) diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index 15661fa..f83335e 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -24,6 +24,7 @@ class WireTag: position: complex attach_side: Literal[_utils.Side.LEFT, _utils.Side.RIGHT] point_dir: Literal[_utils.Side.LEFT, _utils.Side.RIGHT] + connect_pt: complex @classmethod def find_all(cls, grid: _grid.Grid) -> list[WireTag]: @@ -43,10 +44,13 @@ def find_all(cls, grid: _grid.Grid) -> list[WireTag]: if _wire.Wire.is_wire_character(grid.get(left_pos - 1)): attach_side = _utils.Side.LEFT position = left_pos + connect_pt = position - 1 else: attach_side = _utils.Side.RIGHT position = right_pos - out.append(WireTag(name, position, attach_side, point_dir)) + connect_pt = position + 1 + out.append(WireTag(name, position, attach_side, + point_dir, connect_pt)) return out @@ -59,4 +63,4 @@ def find_all(cls, grid: _grid.Grid) -> list[WireTag]: """) tags = WireTag.find_all(g) pprint.pprint(tags) - g.spark(*(x.position for x in tags)) + g.spark(*(x.connect_pt for x in tags)) From d69c908c69905e1f82d6ee7ad760a2097bdd1a62 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:05:43 -0400 Subject: [PATCH 031/101] add points2path to make path output smaller old: and points are all in absolute coordinates new: and points are relative --- schemascii/utils.py | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 4de2e4c..8b4aada 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -231,17 +231,52 @@ def mk_tag(*contents: str, **attrs: str) -> str: del XMLClass +def points2path(points: list[complex], close: bool = False) -> str: + """Converts list of points into SVG commands + to draw the set of lines.""" + def fix(number: float) -> float | int: + return int(number) if number.is_integer() else number + + def pad(number: float | int) -> str: + if number < 0: + return str(number) + return " " + str(number) + + if not points: + return "z" + data = f"M{fix(points[0].real)}{pad(fix(points[0].imag))}" + prev_pt = points[0] + for pt in points[1:]: + diff = pt - prev_pt + if diff.real == 0 and diff.imag == 0: + continue + if diff.imag == 0: + data += f"h{fix(diff.real)}" + elif diff.real == 0: + data += f"v{fix(diff.imag)}" + else: + data += f"l{fix(diff.real)}{pad(fix(diff.imag))}" + prev_pt = pt + if close: + data += "z" + return data + + def polylinegon( points: list[complex], is_polygon: bool = False, **options) -> str: - "Turn the list of points into a or ." + """Turn the list of points into a line or filled area. + + If is_polygon is true, stroke color is used as fill color instead + and stroke width is ignored.""" scale = options["scale"] w = options["stroke_width"] c = options["stroke"] - pts = " ".join(f"{x.real * scale},{x.imag * scale}" for x in points) + scaled_pts = [x * scale for x in points] if is_polygon: - return XML.polygon(points=pts, fill=c, class_="filled") - return XML.polyline( - points=pts, fill="transparent", + return XML.path(d=points2path(scaled_pts, True), + fill=c, class_="filled") + return XML.path( + d=points2path(scaled_pts, False), fill="transparent", stroke__width=w, stroke=c) From c37d58c4f91b2837f6c49df6f4f6f49989b6baec Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:18:14 -0400 Subject: [PATCH 032/101] add net grouping by wire tag --- schemascii/net.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/schemascii/net.py b/schemascii/net.py index 966af60..9c09da4 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -4,6 +4,7 @@ import schemascii.grid as _grid import schemascii.wire as _wire +import schemascii.wire_tag as _wt @dataclass @@ -18,13 +19,36 @@ class Net: def find_all(cls, grid: _grid.Grid) -> list[Net]: seen_points: set[complex] = set() all_nets: list[cls] = [] + all_tags = _wt.WireTag.find_all(grid) for y, line in enumerate(grid.lines): for x, ch in enumerate(line): if _wire.Wire.is_wire_character(ch): - wire = _wire.Wire.get_from_grid(grid, complex(x, y)) + wire = _wire.Wire.get_from_grid( + grid, complex(x, y), all_tags) if all(p in seen_points for p in wire.points): continue - all_nets.append(cls([wire])) + # find existing net or make a new one + for net in all_nets: + if any(w.tag is not None + and wire.tag is not None + and w.tag.name == wire.tag.name + for w in net.wires): + net.wires.append(wire) + break + else: + all_nets.append(cls([wire])) seen_points.update(wire.points) return all_nets + + +if __name__ == '__main__': + g = _grid.Grid("", """ +=wrap1>------C1------=wrap1> + Date: Sat, 17 Aug 2024 18:28:34 -0400 Subject: [PATCH 033/101] allow refdes without number also underscores allows for non-ID'ed type component such as "GND" --- schemascii/refdes.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/schemascii/refdes.py b/schemascii/refdes.py index 000be23..c381856 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -4,7 +4,7 @@ import schemascii.grid as _grid -REFDES_PAT = re.compile(r"([A-Z]+)(\d+)([A-Z\d]*)") +REFDES_PAT = re.compile(r"([A-Z_]+)(\d*)([A-Z_\d]*)") @dataclass @@ -26,7 +26,7 @@ def find_all(cls, grid: _grid.Grid) -> list[RefDes]: for match in REFDES_PAT.finditer(line): left_col, right_col = match.span() letter, number, suffix = match.groups() - number = int(number) + number = int(number) if number else 0 out.append(cls( letter, number, @@ -38,7 +38,13 @@ def find_all(cls, grid: _grid.Grid) -> list[RefDes]: if __name__ == '__main__': import pprint - gg = _grid.Grid("test_data/test_charge_pump.txt") + gg = _grid.Grid("", """ +C1 + BAT3V3 + U3A + Q1G1 + GND +""") rds = RefDes.find_all(gg) pts = [p for r in rds for p in [r.left, r.right]] gg.spark(*pts) From d4d5bf09ffc30b4af345f8acdfc93797fc0d10cc Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:37:43 -0400 Subject: [PATCH 034/101] Update grid.py --- schemascii/grid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemascii/grid.py b/schemascii/grid.py index d927b43..ae5af33 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -133,9 +133,9 @@ def spark(self, *points): xx--- hha-- - a awq + a awq -"""[1:-1]) +""") x.spark(0, complex(x.width - 1, 0), complex(0, x.height - 1), complex(x.width - 1, x.height - 1)) x.shrink() From 0549d427add44963c4e9ea79a2c38250098da359 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 18:42:52 -0400 Subject: [PATCH 035/101] use cls instead of hardcoded class --- schemascii/wire_tag.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index f83335e..ea1b6fc 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -28,7 +28,7 @@ class WireTag: @classmethod def find_all(cls, grid: _grid.Grid) -> list[WireTag]: - out: list[WireTag] = [] + out: list[cls] = [] for y, line in enumerate(grid.lines): for match in WIRE_TAG_PAT.finditer(line): left_grp, right_grp = match.groups() @@ -49,8 +49,8 @@ def find_all(cls, grid: _grid.Grid) -> list[WireTag]: attach_side = _utils.Side.RIGHT position = right_pos connect_pt = position + 1 - out.append(WireTag(name, position, attach_side, - point_dir, connect_pt)) + out.append(cls(name, position, attach_side, + point_dir, connect_pt)) return out From 549d078e70da195937042ee23e599693a2fd0614 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 21:43:57 -0400 Subject: [PATCH 036/101] wrong directions --- schemascii/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 8b4aada..268d4f1 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -51,8 +51,8 @@ def from_phase(cls, pt: complex) -> Side: ops = { -pi: Side.LEFT, pi: Side.LEFT, - pi / 2: Side.TOP, - -pi / 2: Side.BOTTOM, + -pi / 2: Side.TOP, + pi / 2: Side.BOTTOM, 0: Side.RIGHT } pph = phase(pt) From e4e71df5238572128ae5a9189b820051a58dda10 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 17 Aug 2024 21:52:58 -0400 Subject: [PATCH 037/101] fix terminal-finding bugs & add comment annotations --- schemascii/annotation.py | 22 ++++++++++++++++++++++ schemascii/component.py | 31 ++++++++++++++++--------------- schemascii/drawing.py | 40 ++++++++++++++++++++++++++++++---------- schemascii/wire.py | 6 +++--- test_data/stresstest.txt | 11 +++++++++++ 5 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 schemascii/annotation.py create mode 100644 test_data/stresstest.txt diff --git a/schemascii/annotation.py b/schemascii/annotation.py new file mode 100644 index 0000000..a56a316 --- /dev/null +++ b/schemascii/annotation.py @@ -0,0 +1,22 @@ +import re +from dataclasses import dataclass + +import schemascii.grid as _grid + +ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") + + +@dataclass +class Annotation: + position: complex + content: str + + @classmethod + def find_all(cls, grid: _grid.Grid): + out: list[cls] = [] + for y, line in enumerate(grid.lines): + for match in ANNOTATION_RE.finditer(line): + x = match.span()[0] + text = match.group(1) + out.append(cls(complex(x, y), text)) + return out diff --git a/schemascii/component.py b/schemascii/component.py index b40a6b4..301806b 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -63,31 +63,32 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: # these get masked with wires because they are like wires for d in _utils.ORTHAGONAL: poss_term_pt = perimeter_pt + d + if poss_term_pt in seen: + continue ch = grid.get(poss_term_pt) if ch != "#" and not ch.isspace(): # candidate for terminal - # search around again to see if a wire connects - # to it - for d in _utils.ORTHAGONAL: - if (grid.get(d + poss_term_pt) - in _wire.Wire.starting_directions.keys()): - # there is a neighbor with a wire, so it must - # be a terminal - break - # now d holds the direction of the terminal - else: - # no nearby wires - must just be something - # like the reference designator or other junk + # look to see if a wire connects + # to it in the expected direction + nch = grid.get(d + poss_term_pt) + if not _wire.Wire.is_wire_character(nch): + # no connecting wire - must just be something + # like a close packed neighbor component or other junk + continue + if not any(_wire.CHAR2DIR[c] == -d + for c in _wire.Wire.start_dirs[nch]): + # the connecting wire is not really connecting! continue if any(t.pt == poss_term_pt for t in terminals): # already found this one continue if _wire.Wire.is_wire_character(ch): + if not any(_wire.CHAR2DIR[c] == -d + for c in _wire.Wire.start_dirs[ch]): + # the terminal wire is not really connecting! + continue # it is just a connected wire, not a flag ch = None - else: - # mask the wire - grid.setmask(poss_term_pt, "*") terminals.append(_utils.Terminal( poss_term_pt, ch, _utils.Side.from_phase(d))) # done diff --git a/schemascii/drawing.py b/schemascii/drawing.py index ff28e0f..558f1a2 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +import schemascii.annotation as _a import schemascii.component as _component import schemascii.data_parse as _data import schemascii.errors as _errors @@ -14,16 +15,17 @@ class Drawing: """A Schemascii drawing document.""" - nets: list # [Net] + nets: list[_net.Net] components: list[_component.Component] - annotations: list # [Annotation] + annotations: list[_a.Annotation] data: _data.Data + grid: _grid.Grid @classmethod - def parse_from_string(cls, - filename: str, - data: str | None = None, - **options) -> Drawing: + def load(cls, + filename: str, + data: str | None = None, + **options) -> Drawing: if data is None: with open(filename) as f: data = f.read() @@ -41,11 +43,29 @@ def parse_from_string(cls, nets = _net.Net.find_all(grid) components = [_component.Component.from_rd(r, grid) for r in _rd.RefDes.find_all(grid)] - # todo: annotations! - annotations = [] + annotations = _a.Annotation.find_all(grid) data = _data.Data.parse_from_string( - data_area, marker_pos + 1, filename) - return cls(nets, components, annotations, data) + data_area, marker_pos, filename) + grid.clrall() + return cls(nets, components, annotations, data, grid) def to_xml_string(self, **options) -> str: raise NotImplementedError + + +if __name__ == '__main__': + import pprint + import itertools + d = Drawing.load("test_data/stresstest.txt") + pprint.pprint(d) + for net in d.nets: + print("\n---net---") + for wire in net.wires: + d.grid.spark(*wire.points) + for comp in d.components: + print("\n---component---") + pprint.pprint(comp) + d.grid.spark(*itertools.chain.from_iterable(comp.blobs)) + for t in comp.terminals: + d.grid.spark(t.pt) + print() diff --git a/schemascii/wire.py b/schemascii/wire.py index 334dfda..5e49b04 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -31,7 +31,7 @@ class Wire: ")": IDENTITY, "*": EVERYWHERE, }) - starting_directions: ClassVar[ + start_dirs: ClassVar[ defaultdict[str, str]] = defaultdict( lambda: None, { "-": "<>", @@ -51,7 +51,7 @@ def get_from_grid(cls, grid: _grid.Grid, seen: set[complex] = set() points: list[complex] = [] stack: list[tuple[complex, DirStr]] = [ - (start, cls.starting_directions[grid.get(start)])] + (start, cls.start_dirs[grid.get(start)])] while stack: point, directions = stack.pop() if point in seen: @@ -85,7 +85,7 @@ def to_xml_string(self, **options) -> str: @classmethod def is_wire_character(cls, ch: str) -> bool: - return ch in cls.starting_directions + return ch in cls.start_dirs if __name__ == '__main__': diff --git a/test_data/stresstest.txt b/test_data/stresstest.txt new file mode 100644 index 0000000..4130aab --- /dev/null +++ b/test_data/stresstest.txt @@ -0,0 +1,11 @@ +J2-------C1--------* +J1--|||---C2+------* +J999999999999------ +[this is a comment] +--- +* { + stroke-width = 2 +} +C1 { + value = 100u +} From 7d420bcefbf77912faf1053f74cb044891b59842 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 09:52:08 -0400 Subject: [PATCH 038/101] bunch_o_lines output is now even shorter --- schemascii/utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 268d4f1..6006325 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -308,7 +308,15 @@ def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: # make it a polyline pts = [group[0][0]] + [p[1] for p in group] lines.append(pts) - return "".join(polylinegon(line, **options) for line in lines) + scale = options["scale"] + w = options["stroke_width"] + c = options["stroke"] + data = "" + for line in lines: + data += points2path([x * scale for x in line], False) + return XML.path( + d=data, fill="transparent", + stroke__width=w, stroke=c) def id_text( From 140392bacada34d0f29e7692d27b8daa6d27ad5e Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 10:33:48 -0400 Subject: [PATCH 039/101] refactor duplicated flood fill functions --- schemascii/component.py | 40 +++++++---------- schemascii/utils.py | 99 ++++++++++++++++++++++++++++++----------- schemascii/wire.py | 67 +++++++++------------------- 3 files changed, 112 insertions(+), 94 deletions(-) diff --git a/schemascii/component.py b/schemascii/component.py index 301806b..5466c9b 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass from typing import ClassVar @@ -29,26 +30,17 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: blobs: list[list[complex]] = [] seen: set[complex] = set() - def flood(starting: list[complex], moore: bool) -> list[complex]: - frontier = list(starting) - out: list[complex] = [] - directions = _utils.ORTHAGONAL - if moore: - directions += _utils.DIAGONAL - while frontier: - point = frontier.pop(0) - out.append(point) - seen.add(point) - for d in directions: - newpoint = point + d - if grid.get(newpoint) != "#": - continue - if newpoint not in seen: - frontier.append(newpoint) - return out + start_orth = defaultdict( + lambda: _utils.ORTHAGONAL) + start_moore = defaultdict( + lambda: _utils.ORTHAGONAL + _utils.DIAGONAL) + cont_orth = defaultdict(lambda: None, {"#": _utils.EVERYWHERE}) + cont_moore = defaultdict(lambda: None, {"#": _utils.EVERYWHERE_MOORE}) # add in the RD's bounds and find the main blob - blobs.append(flood(_utils.iterate_line(rd.left, rd.right), False)) + blobs.append(_utils.flood_walk( + grid, list(_utils.iterate_line(rd.left, rd.right)), + start_orth, cont_orth, seen)) # now find all of the auxillary blobs for perimeter_pt in _utils.perimeter(blobs[0]): for d in _utils.DIAGONAL: @@ -56,7 +48,9 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: if (poss_aux_blob_pt not in seen and grid.get(poss_aux_blob_pt) == "#"): # we found another blob - blobs.append(flood([poss_aux_blob_pt], True)) + blobs.append(_utils.flood_walk( + grid, [poss_aux_blob_pt], start_moore, + cont_moore, seen)) # find all of the terminals terminals: list[_utils.Terminal] = [] for perimeter_pt in _utils.perimeter(seen): @@ -75,16 +69,14 @@ def flood(starting: list[complex], moore: bool) -> list[complex]: # no connecting wire - must just be something # like a close packed neighbor component or other junk continue - if not any(_wire.CHAR2DIR[c] == -d - for c in _wire.Wire.start_dirs[nch]): + if not any(c == -d for c in _wire.Wire.start_dirs[nch]): # the connecting wire is not really connecting! continue if any(t.pt == poss_term_pt for t in terminals): # already found this one continue if _wire.Wire.is_wire_character(ch): - if not any(_wire.CHAR2DIR[c] == -d - for c in _wire.Wire.start_dirs[ch]): + if not any(c == -d for c in _wire.Wire.start_dirs[ch]): # the terminal wire is not really connecting! continue # it is just a connected wire, not a flag @@ -114,7 +106,7 @@ class FooComponent(Component, names=["U", "FOO"]): testgrid = _grid.Grid("", """ - [xor gate] [op amp] + [xor gate] [op amp] # ###### # # ######## ### diff --git a/schemascii/utils.py b/schemascii/utils.py index 6006325..61e4f09 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -1,50 +1,63 @@ from __future__ import annotations +import enum +import itertools import re -from cmath import phase, rect, pi -from enum import IntEnum -from itertools import chain, groupby -from typing import Callable, NamedTuple +import typing +from cmath import phase, pi, rect +from collections import defaultdict -from .errors import TerminalsError -from .metric import format_metric_unit +import schemascii.errors as _errors +import schemascii.grid as _grid +import schemascii.metric as _metric - -ORTHAGONAL = (1, -1, 1j, -1j) +LEFT_RIGHT = (-1, 1) +UP_DOWN = (-1j, 1j) +ORTHAGONAL = LEFT_RIGHT + UP_DOWN DIAGONAL = (-1+1j, 1+1j, -1-1j, 1-1j) - - -class Cbox(NamedTuple): +EVERYWHERE: defaultdict[complex, list[complex]] = defaultdict( + lambda: ORTHAGONAL) +EVERYWHERE_MOORE: defaultdict[complex, list[complex]] = defaultdict( + lambda: ORTHAGONAL + DIAGONAL) +IDENTITY: dict[complex, list[complex]] = { + 1: [1], + 1j: [1j], + -1: [-1], + -1j: [-1j], +} + + +class Cbox(typing.NamedTuple): p1: complex p2: complex type: str id: str -class BOMData(NamedTuple): +class BOMData(typing.NamedTuple): type: str id: str data: str -class Flag(NamedTuple): +class Flag(typing.NamedTuple): pt: complex char: str side: Side -class Terminal(NamedTuple): +class Terminal(typing.NamedTuple): pt: complex flag: str | None side: Side -class Side(IntEnum): +class Side(enum.Enum): "Which edge the flag was found on." RIGHT = 0 - TOP = 1 - LEFT = 2 - BOTTOM = 3 + TOP = -pi / 2 + LEFT = pi + BOTTOM = pi / 2 @classmethod def from_phase(cls, pt: complex) -> Side: @@ -66,6 +79,41 @@ def from_phase(cls, pt: complex) -> Side: return best_side +def flood_walk( + grid: _grid.Grid, + seed: list[complex], + start_dirs: defaultdict[str, list[complex] | None], + directions: defaultdict[str, defaultdict[ + complex, list[complex] | None]], + seen: set[complex]) -> list[complex]: + """Flood-fills the area on the grid starting from seed, only following + connections in the directions allowed by start_dirs and directions. + + Updates the set seen for points that were walked into + and returns the list of walked-into points.""" + points: list[complex] = [] + stack: list[tuple[complex, list[complex]]] = [ + (p, start_dirs[grid.get(p)]) + for p in seed] + while stack: + point, dirs = stack.pop() + if point in seen: + continue + if not dirs: + # invalid point + continue + seen.add(point) + points.append(point) + if dirs: + for dir in dirs: + next_pt = point + dir + next_dirs = directions[grid.get(next_pt)] + if next_dirs is None: + next_dirs = defaultdict(lambda: None) + stack.append((next_pt, next_dirs[dir])) + return points + + def perimeter(pts: list[complex]) -> list[complex]: """The set of points that are on the boundary of the grid-aligned set pts.""" @@ -209,7 +257,7 @@ def fix_number(n: float) -> str: class XMLClass: - def __getattr__(self, tag: str) -> Callable: + def __getattr__(self, tag: str) -> typing.Callable: def mk_tag(*contents: str, **attrs: str) -> str: out = f"<{tag} " for k, v in attrs.items(): @@ -340,11 +388,11 @@ def id_text( if unit is None: pass elif isinstance(unit, str): - text = format_metric_unit(text, unit) + text = _metric.format_metric_unit(text, unit) classy = "cmp-value" else: text = " ".join( - format_metric_unit(x, y, six) + _metric.format_metric_unit(x, y, six) for x, (y, six) in zip(text.split(","), unit) ) classy = "cmp-value" @@ -443,10 +491,11 @@ def sort_terminals_counterclockwise( "Sort the terminals in counterclockwise order." partitioned = { side: list(filtered_terminals) - for side, filtered_terminals in groupby(terminals, lambda t: t.side) + for side, filtered_terminals in itertools.groupby( + terminals, lambda t: t.side) } return list( - chain( + itertools.chain( sorted(partitioned.get(Side.LEFT, []), key=lambda t: t.pt.imag), sorted(partitioned.get(Side.BOTTOM, []), key=lambda t: t.pt.real), sorted(partitioned.get(Side.RIGHT, []), key=lambda t: -t.pt.imag), @@ -473,12 +522,12 @@ def sort_for_flags(terminals: list[Terminal], for flag in flags: matching_terminals = list(filter(lambda t: t.flag == flag, terminals)) if len(matching_terminals) > 1: - raise TerminalsError( + raise _errors.TerminalsError( f"Multiple terminals with the same flag {flag} " f"on component {box.type}{box.id}" ) if len(matching_terminals) == 0: - raise TerminalsError( + raise _errors.TerminalsError( f"Need a terminal with the flag {flag} " f"on component {box.type}{box.id}" ) diff --git a/schemascii/wire.py b/schemascii/wire.py index 5e49b04..10f3720 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -1,20 +1,14 @@ from __future__ import annotations +import itertools from collections import defaultdict from dataclasses import dataclass -from itertools import combinations -from typing import ClassVar, Literal +from typing import ClassVar import schemascii.grid as _grid import schemascii.utils as _utils import schemascii.wire_tag as _wt -DirStr = Literal["^", "v", "<", ">"] | None -EVERYWHERE: defaultdict[DirStr, str] = defaultdict(lambda: "<>^v") -IDENTITY: dict[DirStr, str] = {">": ">", "^": "^", "<": "<", "v": "v"} - -CHAR2DIR: dict[DirStr, complex] = {">": -1, "<": 1, "^": 1j, "v": -1j} - @dataclass class Wire: @@ -23,22 +17,22 @@ class Wire: # This is a map of the direction coming into the cell # to the set of directions coming "out" of the cell. directions: ClassVar[ - defaultdict[str, defaultdict[DirStr, str]]] = defaultdict( + defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( lambda: None, { - "-": IDENTITY, - "|": IDENTITY, - "(": IDENTITY, - ")": IDENTITY, - "*": EVERYWHERE, + "-": _utils.IDENTITY, + "|": _utils.IDENTITY, + "(": _utils.IDENTITY, + ")": _utils.IDENTITY, + "*": _utils.EVERYWHERE, }) start_dirs: ClassVar[ - defaultdict[str, str]] = defaultdict( + defaultdict[str, list[complex]]] = defaultdict( lambda: None, { - "-": "<>", - "|": "^v", - "(": "^v", - ")": "^v", - "*": "<>^v" + "-": _utils.LEFT_RIGHT, + "|": _utils.UP_DOWN, + "(": _utils.UP_DOWN, + ")": _utils.UP_DOWN, + "*": _utils.ORTHAGONAL, }) points: list[complex] @@ -48,37 +42,20 @@ class Wire: def get_from_grid(cls, grid: _grid.Grid, start: complex, tags: list[_wt.WireTag]) -> Wire: """tags will be mutated""" - seen: set[complex] = set() - points: list[complex] = [] - stack: list[tuple[complex, DirStr]] = [ - (start, cls.start_dirs[grid.get(start)])] - while stack: - point, directions = stack.pop() - if point in seen: - continue - seen.add(point) - points.append(point) - for dir in directions: - next_pt = point + CHAR2DIR[dir] - if ((next_dirs := cls.directions[grid.get(next_pt)]) - is not None): - stack.append((next_pt, next_dirs[dir])) + points = _utils.flood_walk( + grid, [start], cls.start_dirs, cls.directions, set()) self_tag = None - for point in points: - for t in tags: - if t.connect_pt == point: - self_tag = t - tags.remove(t) - break - else: - continue - break + for point, t in itertools.product(points, tags): + if t.connect_pt == point: + self_tag = t + tags.remove(t) + break return cls(points, self_tag) def to_xml_string(self, **options) -> str: # create lines for all of the neighbor pairs links = [] - for p1, p2 in combinations(self.points, 2): + for p1, p2 in itertools.combinations(self.points, 2): if abs(p1 - p2) == 1: links.append((p1, p2)) return _utils.bunch_o_lines(links, **options) From e6c3fbf0212a9fcce95f7f3bd5f52dc43103d3c5 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 14:24:27 -0400 Subject: [PATCH 040/101] clean up --- schemascii/net.py | 1 - schemascii/utils.py | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/schemascii/net.py b/schemascii/net.py index 9c09da4..53d9e15 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -13,7 +13,6 @@ class Net: electrically connected.""" wires: list[_wire.Wire] - # annotation: WireTag | None @classmethod def find_all(cls, grid: _grid.Grid) -> list[Net]: diff --git a/schemascii/utils.py b/schemascii/utils.py index 61e4f09..49c00f0 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -2,7 +2,6 @@ import enum import itertools -import re import typing from cmath import phase, pi, rect from collections import defaultdict @@ -265,9 +264,12 @@ def mk_tag(*contents: str, **attrs: str) -> str: continue if isinstance(v, float): v = fix_number(v) - elif isinstance(v, str): - v = re.sub(r"\b\d+(\.\d+)\b", - lambda m: fix_number(float(m.group())), v) + # XXX: this gets called on every XML level + # XXX: which means that it will be called multiple times + # XXX: unnecessarily + # elif isinstance(v, str): + # v = re.sub(r"\b\d+(\.\d+)\b", + # lambda m: fix_number(float(m.group())), v) out += f'{k.removesuffix("_").replace("__", "-")}="{v}" ' out = out.rstrip() + ">" + "".join(contents) return out + f"" From e306475c111d9bee1af8c40073d087b8a0b86066 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 14:24:44 -0400 Subject: [PATCH 041/101] add annotation boxes need to hook into main drawing --- schemascii/annobox.py | 89 +++++++++++++++++++++++++++++++++++++++++++ schemascii/wire.py | 3 ++ 2 files changed, 92 insertions(+) create mode 100644 schemascii/annobox.py diff --git a/schemascii/annobox.py b/schemascii/annobox.py new file mode 100644 index 0000000..c9d8eca --- /dev/null +++ b/schemascii/annobox.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from collections import defaultdict +from dataclasses import dataclass +from typing import ClassVar +import schemascii.utils as _utils +import schemascii.grid as _grid + + +@dataclass +class AnnotationLine: + """Class that implements the ability to + draw annotation lines on the drawing + without having to use a disconnected wire.""" + + directions: ClassVar[ + defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( + lambda: None, { + # allow jumps over actual wires + "-": _utils.IDENTITY, + "|": _utils.IDENTITY, + "(": _utils.IDENTITY, + ")": _utils.IDENTITY, + ":": _utils.IDENTITY, + "~": _utils.IDENTITY, + ".": { + -1: [1j, 1], + 1j: [], + -1j: [-1, 1], + 1: [1j, -1] + }, + "'": { + -1: [-1j, 1], + -1j: [], + 1j: [-1, 1], + 1: [-1j, -1] + } + }) + start_dirs: ClassVar[ + defaultdict[str, list[complex]]] = defaultdict( + lambda: None, { + "~": _utils.LEFT_RIGHT, + ":": _utils.UP_DOWN, + ".": (-1, 1, -1j), + "'": (-1, 1, 1j), + }) + + points: list[complex] + + @classmethod + def get_from_grid(cls, grid: _grid.Grid, start: complex) -> AnnotationLine: + points = _utils.flood_walk( + grid, [start], cls.start_dirs, cls.directions, set()) + return cls(points) + + @classmethod + def is_annoline_character(cls, ch: str) -> bool: + return ch in cls.start_dirs + + @classmethod + def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: + seen_points: set[complex] = set() + all_lines: list[cls] = [] + + for y, line in enumerate(grid.lines): + for x, ch in enumerate(line): + if cls.is_annoline_character(ch): + line = cls.get_from_grid(grid, complex(x, y)) + if all(p in seen_points for p in line.points): + continue + all_lines.append(cls([line])) + seen_points.update(line.points) + return all_lines + + +if __name__ == '__main__': + x = _grid.Grid("", """ + | | + ----------- .~~~|~~~~~~. + | : | : + -------*------:---*---* : + : | | : + *-------------:---*---* '~~~~. + | : : + '~~~~~~~~~~~~~~~' + +""") + line = AnnotationLine.get_from_grid(x, 30+2j) + print(line) + x.spark(*line.points) diff --git a/schemascii/wire.py b/schemascii/wire.py index 10f3720..b2fb9e9 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -24,6 +24,9 @@ class Wire: "(": _utils.IDENTITY, ")": _utils.IDENTITY, "*": _utils.EVERYWHERE, + # allow jumps through annotation lines + ":": _utils.IDENTITY, + "~": _utils.IDENTITY, }) start_dirs: ClassVar[ defaultdict[str, list[complex]]] = defaultdict( From bcd21afb9118bd51d82a7e10feb6bca1c7f1fffd Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:04:11 -0400 Subject: [PATCH 042/101] change global options name --- schemascii/data_parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py index ac50b18..0d1da81 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data_parse.py @@ -204,7 +204,7 @@ def get_values_for(self, name: str) -> dict: return out def global_options(self) -> dict: - return self.get_values_for(":all") + return self.get_values_for("*") if __name__ == '__main__': From 4b09b29234ff9aa8cc95259a74f1f469620e8eb9 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:06:27 -0400 Subject: [PATCH 043/101] change global options name --- schemascii/{annobox.py => annoline.py} | 0 schemascii/component.py | 17 ++--------- schemascii/data_parse.py | 4 +-- schemascii/drawing.py | 15 ++++++---- test_data/stresstest.txt | 40 ++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 22 deletions(-) rename schemascii/{annobox.py => annoline.py} (100%) diff --git a/schemascii/annobox.py b/schemascii/annoline.py similarity index 100% rename from schemascii/annobox.py rename to schemascii/annoline.py diff --git a/schemascii/component.py b/schemascii/component.py index 5466c9b..7bbcc87 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -101,21 +101,10 @@ def __init_subclass__(cls, names: list[str]): if __name__ == '__main__': class FooComponent(Component, names=["U", "FOO"]): pass - print(Component.all_components) - - testgrid = _grid.Grid("", """ - - [xor gate] [op amp] - - # ###### # - # ######## ### - ----# ######### ----+##### - # #U1G1#####---- #U2A###----- - ----# ######### -----##### - # ######## ### - # ###### # -""") + testgrid = _grid.Grid("test_data/stresstest.txt") + # this will erroneously search the DATA section too but that's OK + # for this test for rd in _rd.RefDes.find_all(testgrid): c = Component.from_rd(rd, testgrid) print(c) diff --git a/schemascii/data_parse.py b/schemascii/data_parse.py index ac50b18..fc739cb 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data_parse.py @@ -204,14 +204,14 @@ def get_values_for(self, name: str) -> dict: return out def global_options(self) -> dict: - return self.get_values_for(":all") + return self.get_values_for("*") if __name__ == '__main__': import pprint text = "" text = r""" -:all { +* { %% these are global config options color = black width = 2; padding = 20; diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 558f1a2..30d5312 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import schemascii.annotation as _a +import schemascii.annoline as _annoline import schemascii.component as _component import schemascii.data_parse as _data import schemascii.errors as _errors @@ -18,14 +19,15 @@ class Drawing: nets: list[_net.Net] components: list[_component.Component] annotations: list[_a.Annotation] + annotation_lines: list[_annoline.AnnotationLine] data: _data.Data grid: _grid.Grid @classmethod - def load(cls, - filename: str, - data: str | None = None, - **options) -> Drawing: + def from_file(cls, + filename: str, + data: str | None = None, + **options) -> Drawing: if data is None: with open(filename) as f: data = f.read() @@ -44,10 +46,11 @@ def load(cls, components = [_component.Component.from_rd(r, grid) for r in _rd.RefDes.find_all(grid)] annotations = _a.Annotation.find_all(grid) + annotation_lines = _annoline.AnnotationLine.find_all(grid) data = _data.Data.parse_from_string( data_area, marker_pos, filename) grid.clrall() - return cls(nets, components, annotations, data, grid) + return cls(nets, components, annotations, annotation_lines, data, grid) def to_xml_string(self, **options) -> str: raise NotImplementedError @@ -56,7 +59,7 @@ def to_xml_string(self, **options) -> str: if __name__ == '__main__': import pprint import itertools - d = Drawing.load("test_data/stresstest.txt") + d = Drawing.from_file("test_data/stresstest.txt") pprint.pprint(d) for net in d.nets: print("\n---net---") diff --git a/test_data/stresstest.txt b/test_data/stresstest.txt index 4130aab..7af4107 100644 --- a/test_data/stresstest.txt +++ b/test_data/stresstest.txt @@ -2,6 +2,30 @@ J2-------C1--------* J1--|||---C2+------* J999999999999------ [this is a comment] + + [xor gate] [op amp] + + # ###### # + # ######## ### + ----# ######### ----+##### + # #U1G1#####---- #U2A###----- + ----# ######### -----##### + # ######## ### + # ###### # + + + [wires and lines test] + + | | + ----------- .~~~|~~~~~~. + | : | : + -------*------:---*---* : + : | | : + *-------------:---*---* '~~~~. + | : : + '~~~~~~~~~~~~~~~' + + --- * { stroke-width = 2 @@ -9,3 +33,19 @@ J999999999999------ C1 { value = 100u } + +* { + %% these are global config options + color = black + width = 2; padding = 20; + format = symbol + mystring = "hello\nworld" +} + + +R* {tolerance = .05; wattage = 0.25} + +R1 { + resistance = 0 - 10k; + %% trailing comment +} From 923e80443470064b5c63e45283ce27d89618c5c9 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:20:40 -0400 Subject: [PATCH 044/101] make keywords obvious --- schemascii/utils.py | 53 +++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 49c00f0..ff8e703 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -313,21 +313,19 @@ def pad(number: float | int) -> str: def polylinegon( - points: list[complex], is_polygon: bool = False, **options) -> str: + points: list[complex], is_polygon: bool = False, + *, scale: int, stroke_width: int, stroke: str) -> str: """Turn the list of points into a line or filled area. If is_polygon is true, stroke color is used as fill color instead and stroke width is ignored.""" - scale = options["scale"] - w = options["stroke_width"] - c = options["stroke"] scaled_pts = [x * scale for x in points] if is_polygon: return XML.path(d=points2path(scaled_pts, True), - fill=c, class_="filled") + fill=stroke, class_="filled") return XML.path( d=points2path(scaled_pts, False), fill="transparent", - stroke__width=w, stroke=c) + stroke__width=stroke_width, stroke=stroke) def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: @@ -348,7 +346,9 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: return [pt for pt, count in seen.items() if count > 3] -def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: +def bunch_o_lines( + pairs: list[tuple[complex, complex]], + *, scale: int, stroke_width: int, stroke: str) -> str: """Collapse the pairs of points and return the smallest number of s.""" lines = [] @@ -358,15 +358,12 @@ def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: # make it a polyline pts = [group[0][0]] + [p[1] for p in group] lines.append(pts) - scale = options["scale"] - w = options["stroke_width"] - c = options["stroke"] data = "" for line in lines: data += points2path([x * scale for x in line], False) return XML.path( d=data, fill="transparent", - stroke__width=w, stroke=c) + stroke__width=stroke_width, stroke=stroke) def id_text( @@ -375,12 +372,15 @@ def id_text( terminals: list[Terminal], unit: str | list[str] | None, point: complex | None = None, - **options, + *, + label: typing.Literal["L", "V", "LV"], + nolabels: bool, + scale: int, + stroke: str ) -> str: "Format the component ID and value around the point." - if options["nolabels"]: + if nolabels: return "" - label_style = options["label"] if point is None: point = sum(t.pt for t in terminals) / len(terminals) data = "" @@ -413,23 +413,23 @@ def id_text( Side.TOP, Side.BOTTOM) else "start" return XML.text( (XML.tspan(f"{box.type}{box.id}", class_="cmp-id") - * bool("L" in label_style)), - " " * (bool(data) and "L" in label_style), - data * bool("V" in label_style), + * bool("L" in label)), + " " * (bool(data) and "L" in label), + data * bool("V" in label), x=point.real, y=point.imag, text__anchor=textach, - font__size=options["scale"], - fill=options["stroke"], + font__size=scale, + fill=stroke, ) -def make_text_point(t1: complex, t2: complex, **options) -> complex: +def make_text_point(t1: complex, t2: complex, + *, scale: int, offset_scale: int = 1) -> complex: "Compute the scaled coordinates of the text anchor point." quad_angle = phase(t1 - t2) + pi / 2 - scale = options["scale"] text_pt = (t1 + t2) * scale / 2 - offset = rect(scale / 2 * options.get("offset_scaler", 1), quad_angle) + offset = rect(scale / 2 * offset_scale, quad_angle) text_pt += complex(abs(offset.real), -abs(offset.imag)) return text_pt @@ -481,10 +481,11 @@ def light_arrows(center: complex, theta: float, out: bool, **options): if out: a, b = b, a return bunch_o_lines( - deep_transform(arrow_points(a, b), center, theta - pi / 2), **options - ) + bunch_o_lines( - deep_transform(arrow_points(a - 0.5, b - 0.5), center, theta - pi / 2), - **options, + deep_transform(arrow_points(a, b), + center, theta - pi / 2) + + deep_transform(arrow_points(a - 0.5, b - 0.5), + center, theta - pi / 2), + **options ) From 852ae5faa1ad3b4e05455507c3a3badc86edcedd Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 21 Aug 2024 10:21:58 -0400 Subject: [PATCH 045/101] docstrings (PEP 257) --- schemascii/__init__.py | 3 ++- schemascii/annoline.py | 10 ++++++-- schemascii/annotation.py | 3 +++ schemascii/configs.py | 4 +-- schemascii/{data_parse.py => data.py} | 37 ++++++++++++++++++++++----- schemascii/errors.py | 18 ++++++++----- schemascii/grid.py | 32 +++++++++++++++-------- schemascii/metric.py | 34 ++++++++++++++---------- schemascii/net.py | 5 +++- schemascii/refdes.py | 10 +++++++- schemascii/wire.py | 9 +++++-- schemascii/wire_tag.py | 4 ++- 12 files changed, 120 insertions(+), 49 deletions(-) rename schemascii/{data_parse.py => data.py} (83%) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 058bd11..56d02ed 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -7,7 +7,8 @@ from .wires import get_wires from .utils import XML from .errors import (Error, DiagramSyntaxError, TerminalsError, - BOMError, UnsupportedComponentError, ArgumentError) + BOMError, UnsupportedComponentError, NoDataError, + DataTypeError) __version__ = "0.3.2" diff --git a/schemascii/annoline.py b/schemascii/annoline.py index c9d8eca..8aaa529 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -10,7 +10,8 @@ class AnnotationLine: """Class that implements the ability to draw annotation lines on the drawing - without having to use a disconnected wire.""" + without having to use a disconnected wire. + """ directions: ClassVar[ defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( @@ -48,16 +49,21 @@ class AnnotationLine: @classmethod def get_from_grid(cls, grid: _grid.Grid, start: complex) -> AnnotationLine: + """Return an AnnotationLine that starts at the specified point.""" points = _utils.flood_walk( grid, [start], cls.start_dirs, cls.directions, set()) return cls(points) @classmethod def is_annoline_character(cls, ch: str) -> bool: + """Return true if ch is a valid character + to make up an AnnotationLine. + """ return ch in cls.start_dirs @classmethod def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: + """Return all of the annotation lines found in the grid.""" seen_points: set[complex] = set() all_lines: list[cls] = [] @@ -78,7 +84,7 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: ----------- .~~~|~~~~~~. | : | : -------*------:---*---* : - : | | : + ~~~~~~~~~~~~~~~:~~~|~~~~~~~~~~~~~ *-------------:---*---* '~~~~. | : : '~~~~~~~~~~~~~~~' diff --git a/schemascii/annotation.py b/schemascii/annotation.py index a56a316..fd460ab 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -8,11 +8,14 @@ @dataclass class Annotation: + """A chunk of text that will be rendered verbatim in the output SVG.""" + position: complex content: str @classmethod def find_all(cls, grid: _grid.Grid): + """Return all of the text annotations present in the grid.""" out: list[cls] = [] for y, line in enumerate(grid.lines): for match in ANNOTATION_RE.finditer(line): diff --git a/schemascii/configs.py b/schemascii/configs.py index a3ec2bd..0a5a229 100644 --- a/schemascii/configs.py +++ b/schemascii/configs.py @@ -67,7 +67,7 @@ def apply_config_defaults(options: dict) -> dict: continue if isinstance(opt.clazz, list): if options[opt.name] not in opt.clazz: - raise _errors.ArgumentError( + raise _errors.DataTypeError( f"config option {opt.name}: " f"invalid choice: {options[opt.name]} " f"(valid options are {', '.join(map(repr, opt.clazz))})" @@ -76,7 +76,7 @@ def apply_config_defaults(options: dict) -> dict: try: options[opt.name] = opt.clazz(options[opt.name]) except ValueError as err: - raise _errors.ArgumentError( + raise _errors.DataTypeError( f"config option {opt.name}: " f"invalid {opt.clazz.__name__} value: " f"{options[opt.name]}" diff --git a/schemascii/data_parse.py b/schemascii/data.py similarity index 83% rename from schemascii/data_parse.py rename to schemascii/data.py index fc739cb..b8b274e 100644 --- a/schemascii/data_parse.py +++ b/schemascii/data.py @@ -3,9 +3,11 @@ import fnmatch import re from dataclasses import dataclass +from typing import Any, TypeVar import schemascii.errors as _errors +T = TypeVar("T") TOKEN_PAT = re.compile("|".join([ r"[\n{};=]", # special one-character "%%", # comment marker @@ -14,6 +16,7 @@ r"""(?:(?!["\s{};=]).)+""", # anything else ])) SPECIAL = {";", "\n", "%%", "{", "}"} +_NOT_SET = object() def tokenize(stuff: str) -> list[str]: @@ -43,6 +46,11 @@ class Data: @classmethod def parse_from_string(cls, text: str, startline=1, filename="") -> Data: + """Parses the data from the text. + + startline and filename are only used when throwing an error + message. Otherwise, returns the Data instance. + """ tokens = tokenize(text) lines = (text + "\n").splitlines() col = line = index = 0 @@ -131,9 +139,8 @@ def expect(expected: set[str]): def expect_not(disallowed: set[str]): got = look() - if got not in disallowed: - return - complain(f"unexpected {got!r}") + if got in disallowed: + complain(f"unexpected {got!r}") def parse_section() -> Section: expect_not(SPECIAL) @@ -196,15 +203,29 @@ def parse_kv_pair() -> dict: sections.append(parse_section()) return cls(sections) - def get_values_for(self, name: str) -> dict: + def get_values_for(self, namespace: str) -> dict: out = {} for section in self.sections: - if section.matches(name): + if section.matches(namespace): out |= section.data return out - def global_options(self) -> dict: - return self.get_values_for("*") + def getopt(self, namespace: str, name: str, default: T = _NOT_SET) -> T: + values = self.get_values_for(namespace) + value = values.get(name, _NOT_SET) + if value is _NOT_SET: + if default is _NOT_SET: + raise _errors.NoDataError( + f"value for {namespace}.{name} is required") + return default + return value + + def __or__(self, other: Data | dict[str, Any] | Any) -> Data: + if isinstance(other, dict): + other = Data([Section("*", other)]) + if not isinstance(other, Data): + return NotImplemented + return Data(self.sections + other.sections) if __name__ == '__main__': @@ -225,8 +246,10 @@ def global_options(self) -> dict: R1 { resistance = 0 - 10k; %% trailing comment + %% foo = "bar\n\tnop" } """ my_data = Data.parse_from_string(text) pprint.pprint(my_data) pprint.pprint(my_data.get_values_for("R1")) + print(my_data.getopt("R1", "foo")) diff --git a/schemascii/errors.py b/schemascii/errors.py index 6a33ed1..7837178 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -1,22 +1,26 @@ class Error(Exception): - "A generic Schemascii error." + """A generic Schemascii error.""" class DiagramSyntaxError(SyntaxError, Error): - "Bad formatting in Schemascii diagram syntax." + """Bad formatting in Schemascii diagram syntax.""" class TerminalsError(TypeError, Error): - "Incorrect usage of terminals on this component." + """Incorrect usage of terminals on this component.""" class BOMError(ValueError, Error): - "Problem with BOM data for a component." + """Problem with BOM data for a component.""" class UnsupportedComponentError(NameError, Error): - "Component type is not supported." + """Component type is not supported.""" -class ArgumentError(ValueError, Error): - "Invalid config argument value." +class NoDataError(NameError, Error): + """Data item is required, but not present.""" + + +class DataTypeError(ValueError, Error): + """Invalid data value.""" diff --git a/schemascii/grid.py b/schemascii/grid.py index ae5af33..7b4f569 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -1,6 +1,7 @@ class Grid: """Helper class for managing a 2-D - grid of ASCII art.""" + grid of ASCII art. + """ def __init__(self, filename: str, data: str | None = None): if data is None: @@ -19,29 +20,31 @@ def __init__(self, filename: str, data: str | None = None): self.height = len(self.data) def validbounds(self, p: complex) -> bool: - "Returns true if the point is within the bounds of this grid." + """Returns true if the point is within the bounds of this grid.""" return 0 <= p.real < self.width and 0 <= p.imag < self.height def get(self, p: complex) -> str: """Returns the current character at that point -- space if out of bounds, the mask character if it was set, - otherwise the original character.""" + otherwise the original character. + """ if not self.validbounds(p): return " " return self.getmask(p) or self.data[int(p.imag)][int(p.real)] @property def lines(self) -> tuple[str]: - "The current contents, with masks applied." + """The current contents, with masks applied.""" return tuple([ "".join(self.get(complex(x, y)) for x in range(self.width)) for y in range(self.height) ]) def getmask(self, p: complex) -> str | bool: - """Sees the mask applied to the specified point; - False if it was not set.""" + """Return the mask applied to the specified point + (False if it was not set). + """ if not self.validbounds(p): return False return self.masks[int(p.imag)][int(p.real)] @@ -53,17 +56,18 @@ def setmask(self, p: complex, mask: str | bool = " "): self.masks[int(p.imag)][int(p.real)] = mask def clrmask(self, p: complex): - "Shortcut for `self.setmask(p, False)`" + """Shortcut for `self.setmask(p, False)`""" self.setmask(p, False) def clrall(self): - "Clears all the masks at once." + """Clears all the masks at once.""" self.masks = [[False for _ in range(self.width)] for _ in range(self.height)] def clip(self, p1: complex, p2: complex): """Returns a sub-grid with the contents bounded by the p1 and p2 box. - Masks are not copied.""" + Masks are not copied. + """ ls = slice(int(p1.real), int(p2.real)) cs = slice(int(p1.imag), int(p2.imag) + 1) d = "\n".join("".join(ln[ls]) for ln in self.data[cs]) @@ -71,7 +75,8 @@ def clip(self, p1: complex, p2: complex): def shrink(self): """Shrinks self so that there is not any space between the edges and - the next non-printing character. Takes masks into account.""" + the next non-printing character. Takes masks into account. + """ # clip the top lines while all(self.get(complex(x, 0)).isspace() for x in range(self.width)): @@ -116,7 +121,12 @@ def __repr__(self): __str__ = __repr__ def spark(self, *points): - "print the grid highliting the specified points" + """Print the grid highliting the specified points. + (Used for debugging.) + + This won't work in IDLE since it relies on + ANSI terminal escape sequences. + """ for y in range(self.height): for x in range(self.width): point = complex(x, y) diff --git a/schemascii/metric.py b/schemascii/metric.py index 2fdecbd..d01b86a 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -10,10 +10,13 @@ def exponent_to_multiplier(exponent: int) -> str | None: """Turns the 10-power into a Metric multiplier. - E.g. 3 --> "k" (kilo) - E.g. 0 --> "" (no multiplier) - E.g. -6 --> "u" (micro) - If it is not a multiple of 3, returns None.""" + + * 3 --> "k" (kilo) + * 0 --> "" (no multiplier) + * -6 --> "u" (micro) + + If it is not a multiple of 3, returns None. + """ if exponent % 3 != 0: return None index = (exponent // 3) + 4 # pico is -12 --> 0 @@ -23,9 +26,11 @@ def exponent_to_multiplier(exponent: int) -> str | None: def multiplier_to_exponent(multiplier: str) -> int: """Turns the Metric multiplier into its exponent. - E.g. "k" --> 3 (kilo) - E.g. " " --> 0 (no multiplier) - E.g. "u" --> -6 (micro)""" + + * "k" --> 3 (kilo) + * " " --> 0 (no multiplier) + * "u" --> -6 (micro) + """ if multiplier in (" ", ""): return 0 if multiplier == "µ": @@ -39,10 +44,10 @@ def multiplier_to_exponent(multiplier: str) -> int: def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: """Finds the best exponent for the number. - Returns a tuple (digits, best_exponent)""" + Returns a tuple (digits, best_exponent) + """ res = ENG_NUMBER.match(num.to_eng_string()) - if not res: - raise RuntimeError("blooey!") # cSpell: ignore blooey + assert res digits, exp = Decimal(res.group(1)), int(res.group(2) or "0") assert exp % 3 == 0, "failed to make engineering notation" possibilities = [] @@ -64,13 +69,13 @@ def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: return sorted( possibilities, key=lambda x: ((10 * len(x[0])) + (2 * ("." in x[0])) - + (5 * (x[1] != 0))) - )[0] + + (5 * (x[1] != 0))))[0] def normalize_metric(num: str, six: bool, unicode: bool) -> tuple[str, str]: """Parses the metric number, normalizes the unit, and returns - a tuple (normalized_digits, metric_multiplier).""" + a tuple (normalized_digits, metric_multiplier). + """ match = METRIC_NUMBER.match(num) if not match: return num, None @@ -96,7 +101,8 @@ def format_metric_unit( * If there is a range of numbers, formats each number in the range and adds the unit afterwards. * If there is no number in num, returns num unchanged. - * If unicode is True, uses 'µ' for micro instead of 'u'.""" + * If unicode is True, uses 'µ' for micro instead of 'u'. + """ num = num.strip() match = METRIC_RANGE.match(num) if match: diff --git a/schemascii/net.py b/schemascii/net.py index 53d9e15..9e5f923 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -10,12 +10,15 @@ @dataclass class Net: """Grouping of wires that are - electrically connected.""" + electrically connected. + """ wires: list[_wire.Wire] @classmethod def find_all(cls, grid: _grid.Grid) -> list[Net]: + """Return a list of all the wire nets found on the grid. + """ seen_points: set[complex] = set() all_nets: list[cls] = [] all_tags = _wt.WireTag.find_all(grid) diff --git a/schemascii/refdes.py b/schemascii/refdes.py index c381856..ae4bdb7 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -11,7 +11,8 @@ class RefDes: """Object representing a component reference designator; i.e. the letter+number+suffix combination uniquely identifying - the component on the diagram.""" + the component on the diagram. + """ letter: str number: int @@ -21,6 +22,9 @@ class RefDes: @classmethod def find_all(cls, grid: _grid.Grid) -> list[RefDes]: + """Finds all of the reference designators present in the + grid. + """ out = [] for row, line in enumerate(grid.lines): for match in REFDES_PAT.finditer(line): @@ -35,6 +39,10 @@ def find_all(cls, grid: _grid.Grid) -> list[RefDes]: complex(right_col - 1, row))) return out + @property + def name(self) -> str: + return f"{self.letter}{self.number}{self.suffix}" + if __name__ == '__main__': import pprint diff --git a/schemascii/wire.py b/schemascii/wire.py index b2fb9e9..69f9bfb 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -12,7 +12,9 @@ @dataclass class Wire: - """List of grid points along a wire.""" + """List of grid points along a wire that are + electrically connected. + """ # This is a map of the direction coming into the cell # to the set of directions coming "out" of the cell. @@ -44,7 +46,10 @@ class Wire: @classmethod def get_from_grid(cls, grid: _grid.Grid, start: complex, tags: list[_wt.WireTag]) -> Wire: - """tags will be mutated""" + """Return the wire starting at the grid point specified. + + tags will be mutated if any of the tags connects to this wire. + """ points = _utils.flood_walk( grid, [start], cls.start_dirs, cls.directions, set()) self_tag = None diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index ea1b6fc..7bc2e07 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -18,7 +18,8 @@ class WireTag: direction information flows. Wire tags currently only support horizontal connections - as of right now.""" + as of right now. + """ name: str position: complex @@ -28,6 +29,7 @@ class WireTag: @classmethod def find_all(cls, grid: _grid.Grid) -> list[WireTag]: + """Find all of the wire tags present in the grid.""" out: list[cls] = [] for y, line in enumerate(grid.lines): for match in WIRE_TAG_PAT.finditer(line): From 5614335593c004d9c611e2004b2244980fc06df0 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 21 Aug 2024 10:22:34 -0400 Subject: [PATCH 046/101] add some hook methods and renderers to component and drawing class --- schemascii/component.py | 72 ++++++++++++++++++++++++++++++++++------- schemascii/drawing.py | 53 +++++++++++++++++++----------- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/schemascii/component.py b/schemascii/component.py index 7bbcc87..f0f759e 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -1,18 +1,24 @@ from __future__ import annotations +import abc from collections import defaultdict from dataclasses import dataclass from typing import ClassVar +import schemascii.data as _data +import schemascii.errors as _errors import schemascii.grid as _grid +import schemascii.net as _net import schemascii.refdes as _rd import schemascii.utils as _utils import schemascii.wire as _wire @dataclass -class Component: +class Component(abc.ABC): + """An icon representing a single electronic component.""" all_components: ClassVar[dict[str, type[Component]]] = {} + human_name: ClassVar[str] = "" rd: _rd.RefDes blobs: list[list[complex]] # to support multiple parts. @@ -20,11 +26,19 @@ class Component: @classmethod def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: + """Find the outline of the component and its terminals + on the grid, starting with the location of the reference designator. + + Will raise an error if the reference designator's letters do not + have a corresponding renderer implemented. + """ # find the right component class for cname in cls.all_components: if cname == rd.letter: cls = cls.all_components[cname] break + else: + raise _errors.UnsupportedComponentError(rd.letter) # now flood-fill to find the blobs blobs: list[list[complex]] = [] @@ -75,31 +89,66 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: if any(t.pt == poss_term_pt for t in terminals): # already found this one continue + terminal_side = _utils.Side.from_phase(d) if _wire.Wire.is_wire_character(ch): if not any(c == -d for c in _wire.Wire.start_dirs[ch]): # the terminal wire is not really connecting! continue # it is just a connected wire, not a flag ch = None - terminals.append(_utils.Terminal( - poss_term_pt, ch, _utils.Side.from_phase(d))) + else: + # mask the special character to be a normal wire so the + # wire will reach the terminal + if terminal_side in (_utils.Side.LEFT, + _utils.Side.RIGHT): + mask_ch = "-" + else: + mask_ch = "|" + grid.setmask(poss_term_pt, mask_ch) + terminals.append( + _utils.Terminal(poss_term_pt, ch, terminal_side)) # done return cls(rd, blobs, terminals) - def __init_subclass__(cls, names: list[str]): + def __init_subclass__(cls, ids: list[str], id_letters: str | None = None): """Register the component subclass in the component registry.""" - for name in names: - if not (name.isalpha() and name.upper() == name): + for id_letters in ids: + if not (id_letters.isalpha() and id_letters.upper() == id_letters): raise ValueError( - f"invalid reference designator letters: {name!r}") - if name in cls.all_components: + f"invalid reference designator letters: {id_letters!r}") + if id_letters in cls.all_components: raise ValueError( - f"duplicate reference designator letters: {name!r}") - cls.all_components[name] = cls + f"duplicate reference designator letters: {id_letters!r}") + cls.all_components[id_letters] = cls + cls.human_name = id_letters or cls.__name__ + + def to_xml_string(self, options: _data.Data) -> str: + """Render this component to a string of SVG XML.""" + return _utils.XML.g( + self.render(options.get_values_for(self.rd.name)), + class_=f"component {self.rd.letter}") + + @abc.abstractmethod + def render(self, options: dict) -> str: + """Render this component to a string of XML using the options. + Component subclasses should implement this method. + + This is a private method and should not be called directly. Instead, + use the `to_xml_string` method which performs a few more + transformations and wraps the output in a nicely formatted ``. + """ + raise NotImplementedError + + @classmethod + def process_nets(self, nets: list[_net.Net]): + """Hook method called to do stuff with the nets that this + component type connects to. By itself it does nothing. + """ + pass if __name__ == '__main__': - class FooComponent(Component, names=["U", "FOO"]): + class FooComponent(Component, ids=["U", "FOO"]): pass print(Component.all_components) testgrid = _grid.Grid("test_data/stresstest.txt") @@ -111,3 +160,4 @@ class FooComponent(Component, names=["U", "FOO"]): for blob in c.blobs: testgrid.spark(*blob) testgrid.spark(*(t.pt for t in c.terminals)) + Component(None, None, None) diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 30d5312..77813e8 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -2,14 +2,15 @@ from dataclasses import dataclass -import schemascii.annotation as _a import schemascii.annoline as _annoline +import schemascii.annotation as _a import schemascii.component as _component -import schemascii.data_parse as _data +import schemascii.data as _data import schemascii.errors as _errors import schemascii.grid as _grid import schemascii.net as _net import schemascii.refdes as _rd +import schemascii.utils as _utils @dataclass @@ -27,18 +28,26 @@ class Drawing: def from_file(cls, filename: str, data: str | None = None, - **options) -> Drawing: + *, + data_marker: str = "---") -> Drawing: + """Loads the Schemascii diagram from a file. + + If data is not provided, the file at filename is read in + text mode. + + The data_marker argument is the sigil line that separates the + graphics section from the data section. + """ if data is None: with open(filename) as f: data = f.read() lines = data.splitlines() - marker = options.get("data-marker", "---") try: - marker_pos = lines.index(marker) + marker_pos = lines.index(data_marker) except ValueError as e: raise _errors.DiagramSyntaxError( "data-marker must be present in a drawing! " - f"(current data-marker is: {marker!r})") from e + f"(current data-marker is: {data_marker!r})") from e drawing_area = "\n".join(lines[:marker_pos]) data_area = "\n".join(lines[marker_pos+1:]) grid = _grid.Grid(filename, drawing_area) @@ -52,23 +61,29 @@ def from_file(cls, grid.clrall() return cls(nets, components, annotations, annotation_lines, data, grid) - def to_xml_string(self, **options) -> str: + def to_xml_string(self, fudge: _data.Data | None = None) -> str: + """Render the entire diagram to a string and return the element. + """ + data = self.data + if fudge: + data |= fudge + scale = data.getopt("*", "scale", 10) + padding = data.getopt("*", "padding", 10) + content = "" raise NotImplementedError + return _utils.XML.svg( + content, + width=self.grid.width * scale + padding * 2, + height=self.grid.height * scale + padding * 2, + viewBox=f"{-padding} {-padding} " + f"{self.grid.width * scale + padding * 2} " + f"{self.grid.height * scale + padding * 2}", + xmlns="http://www.w3.org/2000/svg", + class_="schemascii") if __name__ == '__main__': import pprint - import itertools d = Drawing.from_file("test_data/stresstest.txt") pprint.pprint(d) - for net in d.nets: - print("\n---net---") - for wire in net.wires: - d.grid.spark(*wire.points) - for comp in d.components: - print("\n---component---") - pprint.pprint(comp) - d.grid.spark(*itertools.chain.from_iterable(comp.blobs)) - for t in comp.terminals: - d.grid.spark(t.pt) - print() + print(d.to_xml_string()) From a7044525be350ace41aec478d4693e52f99fd9a7 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 21 Aug 2024 21:08:09 -0400 Subject: [PATCH 047/101] pep 257 on utils.py --- schemascii/utils.py | 122 +++++++++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 42 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index ff8e703..917eb49 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -27,6 +27,10 @@ class Cbox(typing.NamedTuple): + """Component bounding box. Also holds the letter + and number of the reference designator. + """ + # XXX is this still used? p1: complex p2: complex type: str @@ -34,25 +38,28 @@ class Cbox(typing.NamedTuple): class BOMData(typing.NamedTuple): + """Data to link the BOM data entry with the reference designator.""" type: str id: str data: str class Flag(typing.NamedTuple): + """Data indicating the non-wire character next to a component.""" pt: complex char: str side: Side class Terminal(typing.NamedTuple): + """Data indicating what and where wires connect to the component.""" pt: complex flag: str | None side: Side class Side(enum.Enum): - "Which edge the flag was found on." + """One of the four cardinal directions.""" RIGHT = 0 TOP = -pi / 2 LEFT = pi @@ -60,6 +67,9 @@ class Side(enum.Enum): @classmethod def from_phase(cls, pt: complex) -> Side: + """Return the side that is closest to pt, if it is interpreted as + a vector originating from the origin. + """ ops = { -pi: Side.LEFT, pi: Side.LEFT, @@ -85,11 +95,12 @@ def flood_walk( directions: defaultdict[str, defaultdict[ complex, list[complex] | None]], seen: set[complex]) -> list[complex]: - """Flood-fills the area on the grid starting from seed, only following - connections in the directions allowed by start_dirs and directions. + """Flood-fill the area on the grid starting from seed, only following + connections in the directions allowed by start_dirs and directions, and + return the list of reached points. - Updates the set seen for points that were walked into - and returns the list of walked-into points.""" + Also updates the set seen for points that were walked into. + """ points: list[complex] = [] stack: list[tuple[complex, list[complex]]] = [ (p, start_dirs[grid.get(p)]) @@ -108,14 +119,16 @@ def flood_walk( next_pt = point + dir next_dirs = directions[grid.get(next_pt)] if next_dirs is None: + # shortcut next_dirs = defaultdict(lambda: None) stack.append((next_pt, next_dirs[dir])) return points def perimeter(pts: list[complex]) -> list[complex]: - """The set of points that are on the boundary of - the grid-aligned set pts.""" + """Return the set of points that are on the boundary of + the grid-aligned set pts. + """ out = [] for pt in pts: for d in ORTHAGONAL + DIAGONAL: @@ -133,29 +146,32 @@ def centroid(pts: list[complex]) -> complex: def sort_counterclockwise(pts: list[complex], center: complex | None = None) -> list[complex]: - """Returns pts sorted so that the points + """Return pts sorted so that the points progress clockwise around the center, starting with the - rightmost point.""" + rightmost point. + """ if center is None: center = centroid(pts) return sorted(pts, key=lambda p: phase(p - center)) def colinear(*points: complex) -> bool: - "Returns true if all the points are in the same line." + """Return true if all the points are in the same line.""" return len(set(phase(p - points[0]) for p in points[1:])) == 1 def force_int(p: complex) -> complex: - "Force the coordinates of the complex number to lie on the integer grid." + """Return p with the coordinates rounded to lie on the integer grid.""" return complex(round(p.real), round(p.imag)) def sharpness_score(points: list[complex]) -> float: - """Returns a number indicating how twisty the line is -- higher means - the corners are sharper.""" + """Return a number indicating how twisty the line is -- higher means + the corners are sharper. The result is 0 if the line is degenerate or + has no corners. + """ if len(points) < 3: - return float("nan") + return 0 score = 0 prev_pt = points[1] prev_ph = phase(points[1] - points[0]) @@ -167,8 +183,12 @@ def sharpness_score(points: list[complex]) -> float: return score -def intersecting(a: complex, b: complex, p: complex, q: complex): - """Return true if colinear line segments AB and PQ intersect.""" +def intersecting(a: complex, b: complex, p: complex, q: complex) -> bool: + """Return true if colinear line segments AB and PQ intersect. + + If the line segments are not colinear, the result is undefined and + unpredictable. + """ a, b, p, q = a.real, b.real, p.real, q.real sort_a, sort_b = min(a, b), max(a, b) sort_p, sort_q = min(p, q), max(p, q) @@ -177,8 +197,9 @@ def intersecting(a: complex, b: complex, p: complex, q: complex): def take_next_group(links: list[tuple[complex, complex]]) -> list[ tuple[complex, complex]]: - """Pops the longest possible path off of the `links` list and returns it, - mutating the input list.""" + """Pop the longest possible continuous path off of the `links` list and + return it, mutating the input list. + """ best = [links.pop()] while True: for pair in links: @@ -200,9 +221,11 @@ def take_next_group(links: list[tuple[complex, complex]]) -> list[ def merge_colinear(links: list[tuple[complex, complex]]): - "Merges line segments that are colinear. Mutates the input list." + """Merge adjacent line segments that are colinear, mutating the input + list. + """ i = 1 - while True: + while links: if i >= len(links): break elif links[i][0] == links[i][1]: @@ -216,9 +239,13 @@ def merge_colinear(links: list[tuple[complex, complex]]): def iterate_line(p1: complex, p2: complex, step: float = 1.0): - "Yields complex points along a line." - # this isn't Bresenham's algorithm but I only use it for vertical or - # horizontal lines, so it works well enough + """Yield complex points along a line. Like range() but for complex + numbers. + + This isn't Bresenham's algorithm but I only use it for perfectly vertical + or perfectly horizontal lines, so it works well enough. If the line is + diagonal then weird stuff happens. + """ vec = p2 - p1 point = p1 while abs(vec) > abs(point - p1): @@ -229,8 +256,11 @@ def iterate_line(p1: complex, p2: complex, step: float = 1.0): def deep_transform(data, origin: complex, theta: float): """Transform the point or points first by translating by origin, - then rotating by theta. Returns an identical data structure, - but with the transformed points substituted.""" + then rotating by theta. Return an identical data structure, + but with the transformed points substituted. + + TODO: add type statements for the data argument. This is really weird. + """ if isinstance(data, list | tuple): return [deep_transform(d, origin, theta) for d in data] if isinstance(data, complex): @@ -246,7 +276,9 @@ def deep_transform(data, origin: complex, theta: float): def fix_number(n: float) -> str: """If n is an integer, remove the trailing ".0". - Otherwise round it to 2 digits.""" + Otherwise round it to 2 digits, and return the stringified + number. + """ if n.is_integer(): return str(int(n)) n = round(n, 2) @@ -282,8 +314,9 @@ def mk_tag(*contents: str, **attrs: str) -> str: def points2path(points: list[complex], close: bool = False) -> str: - """Converts list of points into SVG commands - to draw the set of lines.""" + """Convert the list of points into SVG commands + to draw the set of lines. + """ def fix(number: float) -> float | int: return int(number) if number.is_integer() else number @@ -318,7 +351,8 @@ def polylinegon( """Turn the list of points into a line or filled area. If is_polygon is true, stroke color is used as fill color instead - and stroke width is ignored.""" + and stroke width is ignored. + """ scaled_pts = [x * scale for x in points] if is_polygon: return XML.path(d=points2path(scaled_pts, True), @@ -329,7 +363,7 @@ def polylinegon( def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: - "Finds all the points where there are 4 or more connecting wires." + """Find all the points where there are 4 or more connecting wires.""" seen = {} for p1, p2 in points: if p1 == p2: @@ -349,8 +383,9 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: def bunch_o_lines( pairs: list[tuple[complex, complex]], *, scale: int, stroke_width: int, stroke: str) -> str: - """Collapse the pairs of points and return - the smallest number of s.""" + """Combine the pairs (p1, p2) into a set of SVG commands + to draw all of the lines. + """ lines = [] while pairs: group = take_next_group(pairs) @@ -378,7 +413,7 @@ def id_text( scale: int, stroke: str ) -> str: - "Format the component ID and value around the point." + """Format the component ID and value around the point.""" if nolabels: return "" if point is None: @@ -426,7 +461,7 @@ def id_text( def make_text_point(t1: complex, t2: complex, *, scale: int, offset_scale: int = 1) -> complex: - "Compute the scaled coordinates of the text anchor point." + """Compute the scaled coordinates of the text anchor point.""" quad_angle = phase(t1 - t2) + pi / 2 text_pt = (t1 + t2) * scale / 2 offset = rect(scale / 2 * offset_scale, quad_angle) @@ -436,7 +471,7 @@ def make_text_point(t1: complex, t2: complex, def make_plus(terminals: list[Terminal], center: complex, theta: float, **options) -> str: - "Make a + sign if the terminals indicate the component is polarized." + """Make a + sign if the terminals indicate the component is polarized.""" if all(t.flag != "+" for t in terminals): return "" return XML.g( @@ -453,7 +488,7 @@ def make_plus(terminals: list[Terminal], center: complex, def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: - "Return points to make an arrow from p1 pointing to p2." + """Return points to make an arrow from p1 pointing to p2.""" angle = phase(p2 - p1) tick_len = min(0.5, abs(p2 - p1)) return [ @@ -465,7 +500,7 @@ def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: def make_variable(center: complex, theta: float, is_variable: bool = True, **options) -> str: - "Draw a 'variable' arrow across the component." + """Draw a 'variable' arrow across the component.""" if not is_variable: return "" return bunch_o_lines(deep_transform(arrow_points(-1, 1), @@ -476,7 +511,8 @@ def make_variable(center: complex, theta: float, def light_arrows(center: complex, theta: float, out: bool, **options): """Draw arrows towards or away from the component - (i.e. light-emitting or light-dependent).""" + (i.e. light-emitting or light-dependent). + """ a, b = 1j, 0.3 + 0.3j if out: a, b = b, a @@ -491,7 +527,7 @@ def light_arrows(center: complex, theta: float, out: bool, **options): def sort_terminals_counterclockwise( terminals: list[Terminal]) -> list[Terminal]: - "Sort the terminals in counterclockwise order." + """Sort the terminals in counterclockwise order.""" partitioned = { side: list(filtered_terminals) for side, filtered_terminals in itertools.groupby( @@ -508,7 +544,7 @@ def sort_terminals_counterclockwise( def is_clockwise(terminals: list[Terminal]) -> bool: - "Return true if the terminals are clockwise order." + """Return true if the terminals are clockwise order.""" sort = sort_terminals_counterclockwise(terminals) for _ in range(len(sort)): if sort == terminals: @@ -520,7 +556,8 @@ def is_clockwise(terminals: list[Terminal]) -> bool: def sort_for_flags(terminals: list[Terminal], box: Cbox, *flags: list[str]) -> list[Terminal]: """Sorts out the terminals in the specified order using the flags. - Raises and error if the flags are absent.""" + Raises an error if the flags are absent. + """ out = () for flag in flags: matching_terminals = list(filter(lambda t: t.flag == flag, terminals)) @@ -536,7 +573,8 @@ def sort_for_flags(terminals: list[Terminal], ) (terminal,) = matching_terminals out = *out, terminal - terminals.remove(terminal) + # terminals.remove(terminal) + # is this necessary with the checks above? return out From f9c3c06b78fb1bca26d23cfa24785859ac144f43 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 25 Aug 2024 20:44:58 -0400 Subject: [PATCH 048/101] add DataConsumer bases to simplify data-getting from Data instances --- schemascii/annoline.py | 29 ++++++-- schemascii/annotation.py | 10 ++- schemascii/component.py | 37 +++++------ schemascii/data.py | 42 +++++++----- schemascii/data_consumer.py | 129 ++++++++++++++++++++++++++++++++++++ schemascii/drawing.py | 38 +++++++++-- schemascii/errors.py | 2 +- schemascii/net.py | 6 +- schemascii/refdes.py | 4 ++ schemascii/utils.py | 34 +++++----- schemascii/wire.py | 10 ++- schemascii/wire_tag.py | 8 ++- 12 files changed, 273 insertions(+), 76 deletions(-) create mode 100644 schemascii/data_consumer.py diff --git a/schemascii/annoline.py b/schemascii/annoline.py index 8aaa529..6a1af95 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -1,19 +1,26 @@ from __future__ import annotations + +import itertools +import typing from collections import defaultdict from dataclasses import dataclass -from typing import ClassVar -import schemascii.utils as _utils + +import schemascii.data_consumer as _dc import schemascii.grid as _grid +import schemascii.utils as _utils @dataclass -class AnnotationLine: +class AnnotationLine(_dc.DataConsumer, + namespaces=(":annotation", ":annotation-line")): """Class that implements the ability to draw annotation lines on the drawing without having to use a disconnected wire. """ - directions: ClassVar[ + css_class = "annotation annotation-line" + + directions: typing.ClassVar[ defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( lambda: None, { # allow jumps over actual wires @@ -36,7 +43,7 @@ class AnnotationLine: 1: [-1j, -1] } }) - start_dirs: ClassVar[ + start_dirs: typing.ClassVar[ defaultdict[str, list[complex]]] = defaultdict( lambda: None, { "~": _utils.LEFT_RIGHT, @@ -45,6 +52,7 @@ class AnnotationLine: "'": (-1, 1, 1j), }) + # the sole member points: list[complex] @classmethod @@ -77,6 +85,15 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: seen_points.update(line.points) return all_lines + def render(self, **options) -> str: + # copy-pasted from wire.py except class changed at bottom + # create lines for all of the neighbor pairs + links = [] + for p1, p2 in itertools.combinations(self.points, 2): + if abs(p1 - p2) == 1: + links.append((p1, p2)) + return _utils.bunch_o_lines(links, **options) + if __name__ == '__main__': x = _grid.Grid("", """ @@ -90,6 +107,6 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: '~~~~~~~~~~~~~~~' """) - line = AnnotationLine.get_from_grid(x, 30+2j) + line, = AnnotationLine.find_all(x) print(line) x.spark(*line.points) diff --git a/schemascii/annotation.py b/schemascii/annotation.py index fd460ab..35ebd39 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -1,18 +1,22 @@ import re from dataclasses import dataclass +import schemascii.data_consumer as _dc import schemascii.grid as _grid +import schemascii.utils as _utils ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") @dataclass -class Annotation: +class Annotation(_dc.DataConsumer, namespaces=(":annotation",)): """A chunk of text that will be rendered verbatim in the output SVG.""" position: complex content: str + css_class = "annotation" + @classmethod def find_all(cls, grid: _grid.Grid): """Return all of the text annotations present in the grid.""" @@ -23,3 +27,7 @@ def find_all(cls, grid: _grid.Grid): text = match.group(1) out.append(cls(complex(x, y), text)) return out + + def render(self, **options) -> str: + raise NotImplementedError + return _utils.XML.text() diff --git a/schemascii/component.py b/schemascii/component.py index f0f759e..8587b96 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -1,11 +1,10 @@ from __future__ import annotations -import abc +import typing from collections import defaultdict from dataclasses import dataclass -from typing import ClassVar -import schemascii.data as _data +import schemascii.data_consumer as _dc import schemascii.errors as _errors import schemascii.grid as _grid import schemascii.net as _net @@ -15,15 +14,19 @@ @dataclass -class Component(abc.ABC): +class Component(_dc.DataConsumer, namespaces=(":component",)): """An icon representing a single electronic component.""" - all_components: ClassVar[dict[str, type[Component]]] = {} - human_name: ClassVar[str] = "" + all_components: typing.ClassVar[dict[str, type[Component]]] = {} + human_name: typing.ClassVar[str] = "" rd: _rd.RefDes blobs: list[list[complex]] # to support multiple parts. terminals: list[_utils.Terminal] + @property + def namespaces(self) -> tuple[str, ...]: + return self.rd.name, self.rd.short_name, self.rd.letter, ":component" + @classmethod def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: """Find the outline of the component and its terminals @@ -122,27 +125,17 @@ def __init_subclass__(cls, ids: list[str], id_letters: str | None = None): cls.all_components[id_letters] = cls cls.human_name = id_letters or cls.__name__ - def to_xml_string(self, options: _data.Data) -> str: - """Render this component to a string of SVG XML.""" - return _utils.XML.g( - self.render(options.get_values_for(self.rd.name)), - class_=f"component {self.rd.letter}") - - @abc.abstractmethod - def render(self, options: dict) -> str: - """Render this component to a string of XML using the options. - Component subclasses should implement this method. - - This is a private method and should not be called directly. Instead, - use the `to_xml_string` method which performs a few more - transformations and wraps the output in a nicely formatted ``. - """ - raise NotImplementedError + @property + def css_class(self) -> str: + return f"component {self.rd.letter}" @classmethod def process_nets(self, nets: list[_net.Net]): """Hook method called to do stuff with the nets that this component type connects to. By itself it does nothing. + + If a subclass implements this method to do something, it should + mutate the list in-place and return None. """ pass diff --git a/schemascii/data.py b/schemascii/data.py index b8b274e..0d7eee4 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -2,12 +2,13 @@ import fnmatch import re +import typing from dataclasses import dataclass -from typing import Any, TypeVar +import schemascii.data_consumer as _dc import schemascii.errors as _errors -T = TypeVar("T") +T = typing.TypeVar("T") TOKEN_PAT = re.compile("|".join([ r"[\n{};=]", # special one-character "%%", # comment marker @@ -16,7 +17,6 @@ r"""(?:(?!["\s{};=]).)+""", # anything else ])) SPECIAL = {";", "\n", "%%", "{", "}"} -_NOT_SET = object() def tokenize(stuff: str) -> list[str]: @@ -40,10 +40,28 @@ def matches(self, name) -> bool: @dataclass class Data: - """Class that holds the data of a drawing.""" + """Class that manages data defining drawing parameters. + + The class object itself manages what data options are allowed for + what namespaces (e.g. to generate a help message) and can parse the data. + + Instances of this class represent a collection of data sections that were + found in a drawing. + """ sections: list[Section] + allowed_options: typing.ClassVar[dict[str, list[_dc.Option]]] = {} + + @classmethod + def define_option(cls, ns: str, opt: _dc.Option): + if ns in cls.allowed_options: + if any(eo.name == opt.name for eo in cls.allowed_options[ns]): + raise ValueError(f"duplicate option name {opt.name!r}") + cls.allowed_options[ns].append(opt) + else: + cls.allowed_options[ns] = [opt] + @classmethod def parse_from_string(cls, text: str, startline=1, filename="") -> Data: """Parses the data from the text. @@ -54,7 +72,7 @@ def parse_from_string(cls, text: str, startline=1, filename="") -> Data: tokens = tokenize(text) lines = (text + "\n").splitlines() col = line = index = 0 - lastsig = (0, 0, 0) + lastsig: tuple[int, int, int] = (0, 0, 0) def complain(msg): raise _errors.DiagramSyntaxError( @@ -96,7 +114,7 @@ def eat(): def save(): return (index, line, col) - def restore(dat): + def restore(dat: tuple[int, int, int]): nonlocal index nonlocal line nonlocal col @@ -210,17 +228,7 @@ def get_values_for(self, namespace: str) -> dict: out |= section.data return out - def getopt(self, namespace: str, name: str, default: T = _NOT_SET) -> T: - values = self.get_values_for(namespace) - value = values.get(name, _NOT_SET) - if value is _NOT_SET: - if default is _NOT_SET: - raise _errors.NoDataError( - f"value for {namespace}.{name} is required") - return default - return value - - def __or__(self, other: Data | dict[str, Any] | Any) -> Data: + def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data: if isinstance(other, dict): other = Data([Section("*", other)]) if not isinstance(other, Data): diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py new file mode 100644 index 0000000..3d7a92b --- /dev/null +++ b/schemascii/data_consumer.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import abc +import typing +from dataclasses import dataclass + +import schemascii.data as _data +import schemascii.errors as _errors +import schemascii.utils as _utils + +T = typing.TypeVar("T") +_NOT_SET = object() + + +@dataclass +class Option(typing.Generic[T]): + """Represents an allowed name used in Schemascii's internals + somewhere. Normal users have no need for this class. + """ + + name: str + type: type[T] | list[T] + help: str + default: T = _NOT_SET + + +class DataConsumer(abc.ABC): + """Base class for any Schemascii AST node that needs data + to be rendered. This class registers the options that the class + declares with Data so that they can be checked, automatically pulls + the needed options when to_xml_string() is called, and passes the dict of + options to render(). + """ + + options: typing.ClassVar[list[Option + | typing.Literal["inherit"] + | tuple[str, ...]]] = [ + Option("scale", float, "Scale by which to enlarge the " + "entire diagram by", 15), + Option("linewidth", float, "Width of drawn lines", 2), + Option("color", str, "black", "Default color for everything"), + ] + namepaces: tuple[str, ...] + css_class: typing.ClassVar[str] = "" + + def __init_subclass__(cls, namespaces: tuple[str, ...] = ("*",)): + + if not hasattr(cls, "namespaces"): + # don't clobber it if a subclass defines it as a @property! + cls.namespaces = namespaces + + for b in cls.mro(): + if (b is not cls + and issubclass(b, DataConsumer) + and b.options is cls.options): + # if we literally just inherit the attribute, + # don't bother reprocessing it + return + + def coalesce_options(cls: type[DataConsumer]) -> list[Option]: + if DataConsumer not in cls.mro(): + return [] + seen_inherit = False + opts = [] + for opt in cls.options: + if opt == "inherit": + if seen_inherit: + raise ValueError("can't use 'inherit' twice") + + seen_inherit = True + elif isinstance(opt, tuple): + for base in cls.__bases__: + opts.extend(o for o in coalesce_options(base) + if o.name in opt) + elif isinstance(opt, Option): + opts.append(opt) + else: + raise TypeError(f"unknown option definition: {opt!r}") + return opts + + cls.options = coalesce_options(cls) + for ns in namespaces: + for option in cls.options: + _data.Data.define_option(ns, option) + + def to_xml_string(self, data: _data.Data) -> str: + """Pull options relevant to this node from data, calls + self.render(), and wraps the output in a .""" + values = {} + for name in self.namespaces: + values |= data.get_values_for(name) + # validate the options + for opt in self.options: + if opt.name not in values: + if opt.default is _NOT_SET: + raise _errors.NoDataError( + f"value for {self.namespaces[0]}.{name} is required") + values[opt.name] = opt.default + continue + if isinstance(opt.type, list): + if values[opt.name] not in opt.type: + raise _errors.DataTypeError( + f"option {self.namespaces[0]}.{opt.name}: " + f"invalid choice: {values[opt.name]} " + f"(valid options are " + f"{', '.join(map(repr, opt.type))})") + continue + try: + values[opt.name] = opt.type(values[opt.name]) + except ValueError as err: + raise _errors.DataTypeError( + f"option {self.namespaces[0]}.{opt.name}: " + f"invalid {opt.type.__name__} value: " + f"{values[opt.name]}") from err + # render + result = self.render(**values, data=data) + if self.css_class: + result = _utils.XML.g(result, class_=self.css_class) + return result + + @abc.abstractmethod + def render(self, data: _data.Data, **options) -> str: + """Render self to a string of XML. This is a private method and should + not be called by non-Schemascii-extending code. External callers should + call to_xml_string() instead. + + Subclasses must implement this method. + """ + raise NotImplementedError diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 77813e8..0f89134 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -1,7 +1,9 @@ from __future__ import annotations +import typing from dataclasses import dataclass +import schemascii.data_consumer as _dc import schemascii.annoline as _annoline import schemascii.annotation as _a import schemascii.component as _component @@ -14,9 +16,15 @@ @dataclass -class Drawing: +class Drawing(_dc.DataConsumer, namespaces=(":root",)): """A Schemascii drawing document.""" + options = [ + ("scale",), + _dc.Option("padding", float, + "Margin around the border of the drawing", 10), + ] + nets: list[_net.Net] components: list[_component.Component] annotations: list[_a.Annotation] @@ -50,6 +58,7 @@ def from_file(cls, f"(current data-marker is: {data_marker!r})") from e drawing_area = "\n".join(lines[:marker_pos]) data_area = "\n".join(lines[marker_pos+1:]) + # find everything grid = _grid.Grid(filename, drawing_area) nets = _net.Net.find_all(grid) components = [_component.Component.from_rd(r, grid) @@ -58,19 +67,36 @@ def from_file(cls, annotation_lines = _annoline.AnnotationLine.find_all(grid) data = _data.Data.parse_from_string( data_area, marker_pos, filename) + # process nets + for comp in components: + comp.process_nets(nets) grid.clrall() return cls(nets, components, annotations, annotation_lines, data, grid) - def to_xml_string(self, fudge: _data.Data | None = None) -> str: + def to_xml_string( + self, + fudge: _data.Data | dict[str, typing.Any] | None = None) -> str: """Render the entire diagram to a string and return the element. """ data = self.data if fudge: data |= fudge - scale = data.getopt("*", "scale", 10) - padding = data.getopt("*", "padding", 10) - content = "" - raise NotImplementedError + return super().to_xml_string(data) + + def render(self, data, scale: float, padding: float) -> str: + # render everything + content = _utils.XML.g( + _utils.XML.g( + *(net.to_xml_string(data) for net in self.nets), + class_="wires"), + _utils.XML.g( + *(comp.to_xml_string(data) for comp in self.components), + class_="components"), + class_="electrical") + content += _utils.XML.g( + *(line.to_xml_string(data) for line in self.annotation_lines), + *(anno.to_xml_string(data) for anno in self.annotations), + class_="annotations") return _utils.XML.svg( content, width=self.grid.width * scale + padding * 2, diff --git a/schemascii/errors.py b/schemascii/errors.py index 7837178..7981622 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -1,5 +1,5 @@ class Error(Exception): - """A generic Schemascii error.""" + """A generic Schemascii error encountered when rendering a drawing.""" class DiagramSyntaxError(SyntaxError, Error): diff --git a/schemascii/net.py b/schemascii/net.py index 9e5f923..29429f0 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -2,13 +2,14 @@ from dataclasses import dataclass +import schemascii.data_consumer as _dc import schemascii.grid as _grid import schemascii.wire as _wire import schemascii.wire_tag as _wt @dataclass -class Net: +class Net(_dc.DataConsumer, namespaces=(":net",)): """Grouping of wires that are electrically connected. """ @@ -43,6 +44,9 @@ def find_all(cls, grid: _grid.Grid) -> list[Net]: seen_points.update(wire.points) return all_nets + def render(self, data) -> str: + return "".join(w.to_xml_string(data) for w in self.wires) + if __name__ == '__main__': g = _grid.Grid("", """ diff --git a/schemascii/refdes.py b/schemascii/refdes.py index ae4bdb7..1848f6d 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -41,6 +41,10 @@ def find_all(cls, grid: _grid.Grid) -> list[RefDes]: @property def name(self) -> str: + return f"{self.short_name}{self.suffix}" + + @property + def short_name(self) -> str: return f"{self.letter}{self.number}{self.suffix}" diff --git a/schemascii/utils.py b/schemascii/utils.py index 917eb49..4ddef32 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -347,7 +347,7 @@ def pad(number: float | int) -> str: def polylinegon( points: list[complex], is_polygon: bool = False, - *, scale: int, stroke_width: int, stroke: str) -> str: + *, scale: float, stroke_width: float, stroke: str) -> str: """Turn the list of points into a line or filled area. If is_polygon is true, stroke color is used as fill color instead @@ -382,7 +382,7 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: def bunch_o_lines( pairs: list[tuple[complex, complex]], - *, scale: int, stroke_width: int, stroke: str) -> str: + *, scale: float, stroke_width: float, stroke: str) -> str: """Combine the pairs (p1, p2) into a set of SVG commands to draw all of the lines. """ @@ -402,17 +402,16 @@ def bunch_o_lines( def id_text( - box: Cbox, - bom_data: BOMData, - terminals: list[Terminal], - unit: str | list[str] | None, - point: complex | None = None, - *, - label: typing.Literal["L", "V", "LV"], - nolabels: bool, - scale: int, - stroke: str -) -> str: + box: Cbox, + bom_data: BOMData, + terminals: list[Terminal], + unit: str | list[str] | None, + point: complex | None = None, + *, + label: typing.Literal["L", "V", "LV"], + nolabels: bool, + scale: float, + stroke: str) -> str: """Format the component ID and value around the point.""" if nolabels: return "" @@ -460,7 +459,7 @@ def id_text( def make_text_point(t1: complex, t2: complex, - *, scale: int, offset_scale: int = 1) -> complex: + *, scale: float, offset_scale: float) -> complex: """Compute the scaled coordinates of the text anchor point.""" quad_angle = phase(t1 - t2) + pi / 2 text_pt = (t1 + t2) * scale / 2 @@ -558,7 +557,7 @@ def sort_for_flags(terminals: list[Terminal], """Sorts out the terminals in the specified order using the flags. Raises an error if the flags are absent. """ - out = () + out = [] for flag in flags: matching_terminals = list(filter(lambda t: t.flag == flag, terminals)) if len(matching_terminals) > 1: @@ -571,9 +570,8 @@ def sort_for_flags(terminals: list[Terminal], f"Need a terminal with the flag {flag} " f"on component {box.type}{box.id}" ) - (terminal,) = matching_terminals - out = *out, terminal - # terminals.remove(terminal) + out.append(matching_terminals[0]) + # terminals.remove(matching_terminals[0]) # is this necessary with the checks above? return out diff --git a/schemascii/wire.py b/schemascii/wire.py index 69f9bfb..adc9584 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -5,17 +5,20 @@ from dataclasses import dataclass from typing import ClassVar +import schemascii.data_consumer as _dc import schemascii.grid as _grid import schemascii.utils as _utils import schemascii.wire_tag as _wt @dataclass -class Wire: +class Wire(_dc.DataConsumer, namespaces=(":wire",)): """List of grid points along a wire that are electrically connected. """ + css_class = "wire" + # This is a map of the direction coming into the cell # to the set of directions coming "out" of the cell. directions: ClassVar[ @@ -60,13 +63,14 @@ def get_from_grid(cls, grid: _grid.Grid, break return cls(points, self_tag) - def to_xml_string(self, **options) -> str: + def render(self, data, **options) -> str: # create lines for all of the neighbor pairs links = [] for p1, p2 in itertools.combinations(self.points, 2): if abs(p1 - p2) == 1: links.append((p1, p2)) - return _utils.bunch_o_lines(links, **options) + return (_utils.bunch_o_lines(links, **options) + + (self.tag.to_xml_string(data) if self.tag else "")) @classmethod def is_wire_character(cls, ch: str) -> bool: diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index 7bc2e07..8227a55 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Literal +import schemascii.data_consumer as _dc import schemascii.grid as _grid import schemascii.utils as _utils import schemascii.wire as _wire @@ -12,7 +13,7 @@ @dataclass -class WireTag: +class WireTag(_dc.DataConsumer, namespaces=(":wire-tag",)): """A wire tag is a named flag on the end of the wire, that gives it a name and also indicates what direction information flows. @@ -21,6 +22,8 @@ class WireTag: as of right now. """ + css_class = "wire-tag" + name: str position: complex attach_side: Literal[_utils.Side.LEFT, _utils.Side.RIGHT] @@ -55,6 +58,9 @@ def find_all(cls, grid: _grid.Grid) -> list[WireTag]: point_dir, connect_pt)) return out + def render(self, data) -> str: + raise NotImplementedError + if __name__ == '__main__': import pprint From ba5cd302385d6889ee562624a42945513d87c7e4 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:37:31 -0400 Subject: [PATCH 049/101] implement annotation renderer --- schemascii/annotation.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/schemascii/annotation.py b/schemascii/annotation.py index 35ebd39..b438995 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -1,3 +1,4 @@ +import html import re from dataclasses import dataclass @@ -12,6 +13,11 @@ class Annotation(_dc.DataConsumer, namespaces=(":annotation",)): """A chunk of text that will be rendered verbatim in the output SVG.""" + options = [ + ("scale",), + _dc.Option("font", str, "Text font", "monospace"), + ] + position: complex content: str @@ -28,6 +34,10 @@ def find_all(cls, grid: _grid.Grid): out.append(cls(complex(x, y), text)) return out - def render(self, **options) -> str: - raise NotImplementedError - return _utils.XML.text() + def render(self, scale, font) -> str: + return _utils.XML.text( + html.escape(self.content), + x=self.position.real * scale, + y=self.position.imag * scale, + style=f"font-family:{font}", + alignment__baseline="middle") From 29903501881cc46a3a8379eeec50e9186ed68932 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:40:24 -0400 Subject: [PATCH 050/101] need to also call superclass method --- schemascii/component.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schemascii/component.py b/schemascii/component.py index 8587b96..f5eecbc 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -113,8 +113,10 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # done return cls(rd, blobs, terminals) - def __init_subclass__(cls, ids: list[str], id_letters: str | None = None): + def __init_subclass__( + cls, ids: list[str], id_letters: str | None = None, **kwargs): """Register the component subclass in the component registry.""" + super().__init_subclass__(**kwargs) for id_letters in ids: if not (id_letters.isalpha() and id_letters.upper() == id_letters): raise ValueError( From 554f88aab137ee9c3b91588a6be84a0b2e7bd301 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:43:46 -0400 Subject: [PATCH 051/101] still need to add it to the namespaces --- schemascii/data_consumer.py | 52 +++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 3d7a92b..d1925f4 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -54,31 +54,33 @@ def __init_subclass__(cls, namespaces: tuple[str, ...] = ("*",)): and issubclass(b, DataConsumer) and b.options is cls.options): # if we literally just inherit the attribute, - # don't bother reprocessing it - return - - def coalesce_options(cls: type[DataConsumer]) -> list[Option]: - if DataConsumer not in cls.mro(): - return [] - seen_inherit = False - opts = [] - for opt in cls.options: - if opt == "inherit": - if seen_inherit: - raise ValueError("can't use 'inherit' twice") - - seen_inherit = True - elif isinstance(opt, tuple): - for base in cls.__bases__: - opts.extend(o for o in coalesce_options(base) - if o.name in opt) - elif isinstance(opt, Option): - opts.append(opt) - else: - raise TypeError(f"unknown option definition: {opt!r}") - return opts - - cls.options = coalesce_options(cls) + # don't bother reprocessing it - just assign it in the + # namespaces + break + else: + def coalesce_options(cls: type[DataConsumer]) -> list[Option]: + if DataConsumer not in cls.mro(): + return [] + seen_inherit = False + opts = [] + for opt in cls.options: + if opt == "inherit": + if seen_inherit: + raise ValueError("can't use 'inherit' twice") + + seen_inherit = True + elif isinstance(opt, tuple): + for base in cls.__bases__: + opts.extend(o for o in coalesce_options(base) + if o.name in opt) + elif isinstance(opt, Option): + opts.append(opt) + else: + raise TypeError(f"unknown option definition: {opt!r}") + return opts + + cls.options = coalesce_options(cls) + for ns in namespaces: for option in cls.options: _data.Data.define_option(ns, option) From 3b6cfa08dbc9a1f2f289700f6eead55b5d8605aa Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:44:01 -0400 Subject: [PATCH 052/101] prevent unexpected keyword argument error --- schemascii/annoline.py | 2 +- schemascii/annotation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/schemascii/annoline.py b/schemascii/annoline.py index 6a1af95..ceb394d 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -85,7 +85,7 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: seen_points.update(line.points) return all_lines - def render(self, **options) -> str: + def render(self, data, **options) -> str: # copy-pasted from wire.py except class changed at bottom # create lines for all of the neighbor pairs links = [] diff --git a/schemascii/annotation.py b/schemascii/annotation.py index b438995..1576173 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -34,7 +34,7 @@ def find_all(cls, grid: _grid.Grid): out.append(cls(complex(x, y), text)) return out - def render(self, scale, font) -> str: + def render(self, data, scale, font) -> str: return _utils.XML.text( html.escape(self.content), x=self.position.real * scale, From cb8782814b037faa13468239ecee8149481533bd Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 26 Aug 2024 18:03:25 -0400 Subject: [PATCH 053/101] don't complain if it's the same exact option --- schemascii/data.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/schemascii/data.py b/schemascii/data.py index 0d7eee4..4d63654 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -56,9 +56,11 @@ class Data: @classmethod def define_option(cls, ns: str, opt: _dc.Option): if ns in cls.allowed_options: - if any(eo.name == opt.name for eo in cls.allowed_options[ns]): + if any(eo.name == opt.name and eo != opt + for eo in cls.allowed_options[ns]): raise ValueError(f"duplicate option name {opt.name!r}") - cls.allowed_options[ns].append(opt) + if opt not in cls.allowed_options[ns]: + cls.allowed_options[ns].append(opt) else: cls.allowed_options[ns] = [opt] From 210f50b91cfcb0f6a00db5cbc790d3ba848ea51d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:34:09 -0400 Subject: [PATCH 054/101] "inherit" didn't do anything --- schemascii/data_consumer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index d1925f4..c2b6223 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -67,7 +67,8 @@ def coalesce_options(cls: type[DataConsumer]) -> list[Option]: if opt == "inherit": if seen_inherit: raise ValueError("can't use 'inherit' twice") - + for base in cls.__bases__: + opts.extend(coalesce_options(base)) seen_inherit = True elif isinstance(opt, tuple): for base in cls.__bases__: From b56db42bd3f16f311548b46e5e587f2cb5cbb181 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:46:31 -0400 Subject: [PATCH 055/101] delete old files --- schemascii/__init__.py | 13 +--- schemascii/components.py | 112 ---------------------------- schemascii/configs.py | 84 --------------------- schemascii/edgemarks.py | 70 ------------------ schemascii/inline_config.py | 35 --------- schemascii/wires.py | 144 ------------------------------------ 6 files changed, 2 insertions(+), 456 deletions(-) delete mode 100644 schemascii/components.py delete mode 100644 schemascii/configs.py delete mode 100644 schemascii/edgemarks.py delete mode 100644 schemascii/inline_config.py delete mode 100644 schemascii/wires.py diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 56d02ed..8387867 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,20 +1,11 @@ -from .inline_config import get_inline_configs -from .configs import apply_config_defaults -from .grid import Grid -from .components import find_all -from .edgemarks import find_edge_marks -from .components_render import render_component -from .wires import get_wires -from .utils import XML -from .errors import (Error, DiagramSyntaxError, TerminalsError, - BOMError, UnsupportedComponentError, NoDataError, - DataTypeError) + __version__ = "0.3.2" def render(filename: str, text: str | None = None, **options) -> str: "Render the Schemascii diagram to an SVG string." + raise NotImplementedError if text is None: with open(filename, encoding="ascii") as f: text = f.read() diff --git a/schemascii/components.py b/schemascii/components.py deleted file mode 100644 index 3bbeaa0..0000000 --- a/schemascii/components.py +++ /dev/null @@ -1,112 +0,0 @@ -import re -from .grid import Grid -from .utils import Cbox, BOMData -from .errors import DiagramSyntaxError, BOMError - - -SMALL_COMPONENT_OR_BOM = re.compile(r"#*([A-Z]+)(\d*|\.\w+)(:[^\s]+)?#*") - - -def find_small(grid: Grid) -> tuple[list[Cbox], list[BOMData]]: - """Searches for small components' RDs and BOM-data sections, and - blanks them out.""" - components: list[Cbox] = [] - boms: list[BOMData] = [] - for i, line in enumerate(grid.lines): - for m in SMALL_COMPONENT_OR_BOM.finditer(line): - ident = m.group(2) or "0" - if m.group(3): - boms.append(BOMData(m.group(1), ident, m.group(3)[1:])) - else: - components.append( - Cbox( - complex(m.start(), i), - complex(m.end() - 1, i), - m.group(1), - ident, - ) - ) - for z in range(*m.span(0)): - grid.setmask(complex(z, i)) - return components, boms - - -TOP_OF_BOX = re.compile(r"\.~+\.") - - -def find_big(grid: Grid) -> tuple[list[Cbox], list[BOMData]]: - """Searches for all the large (i.e. box-style components) - and returns them, and masks them on the grid.""" - boxes: list[Cbox] = [] - boms: list[BOMData] = [] - while True: - for i, line in enumerate(grid.lines): - if m1 := TOP_OF_BOX.search(line): - tb = m1.group() - x1, x2 = m1.span() - y1 = i - y2 = None - for j, l in enumerate(grid.lines): - if j <= y1: - continue - cs = l[x1:x2] - if cs == tb: - y2 = j - break - if not cs[0] == cs[-1] == ":": - raise DiagramSyntaxError( - f"{grid.filename}: Fragmented box " - f"starting at line {y1 + 1}, col {x1 + 1}" - ) - else: - raise DiagramSyntaxError( - f"{grid.filename}: Unfinished box " - f"starting at line {y1 + 1}, col {x1 + 1}" - ) - inside = grid.clip(complex(x1, y1), complex(x2, y2)) - results, resb = find_small(inside) - if len(results) == 0 and len(resb) == 0: - raise BOMError( - f"{grid.filename}: Box starting at " - f"line {y1 + 1}, col {x1 + 1} is " - f"missing reference designator" - ) - if len(results) != 1 and len(resb) != 1: - raise BOMError( - f"{grid.filename}: Box starting at " - f"line {y1 + 1}, col {x1 + 1} has " - f"multiple reference designators" - ) - if not results: - merd = resb[0] - else: - merd = results[0] - boxes.append( - Cbox(complex(x1, y1), complex(x2 - 1, y2), merd.type, merd.id) - ) - boms.extend(resb) - # mark everything - for i in range(x1, x2): - for j in range(y1, y2 + 1): - grid.setmask(complex(i, j)) - break - else: - break - return boxes, boms - - -def find_all(grid: Grid) -> tuple[list[Cbox], list[BOMData]]: - """Finds all the marked components and reference designators, - and masks off all of them, leaving only wires and extraneous text.""" - b1, l1 = find_big(grid) - b2, l2 = find_small(grid) - return b1 + b2, l1 + l2 - - -if __name__ == "__main__": - test_grid = Grid("test_data/test_resistors.txt") - bbb, _ = find_all(test_grid) - all_pts = [] - for box in bbb: - all_pts.extend([box.p1, box.p2]) - test_grid.spark(*all_pts) diff --git a/schemascii/configs.py b/schemascii/configs.py deleted file mode 100644 index 0a5a229..0000000 --- a/schemascii/configs.py +++ /dev/null @@ -1,84 +0,0 @@ -import argparse -from dataclasses import dataclass - -import schemascii.errors as _errors - - -@dataclass -class ConfigConfig: - name: str - clazz: type | list - default: object - help: str - - -OPTIONS = [ - ConfigConfig("padding", float, 10, "Amount of padding to add " - "to the edges around the drawing."), - ConfigConfig("scale", float, 15, "Scale by which to enlarge " - "the entire diagram by."), - ConfigConfig("stroke_width", float, 2, "Width of lines."), - ConfigConfig("stroke", str, "black", "Color of lines."), - ConfigConfig( - "label", - ["L", "V", "VL"], - "VL", - "Component label style (L=include label, V=include value, VL=both)", - ), - ConfigConfig( - "nolabels", - bool, - False, - "Turns off labels on all components, except for part numbers on ICs.", - ), -] - - -def add_config_arguments(a: argparse.ArgumentParser): - "Register all the config options on the argument parser." - for opt in OPTIONS: - if isinstance(opt.clazz, list): - a.add_argument( - "--" + opt.name, - help=opt.help, - choices=opt.clazz, - default=opt.default, - ) - elif opt.clazz is bool: - a.add_argument( - "--" + opt.name, - help=opt.help, - action="store_false" if opt.default else "store_true", - ) - else: - a.add_argument( - "--" + opt.name, - help=opt.help, - type=opt.clazz, - default=opt.default, - ) - - -def apply_config_defaults(options: dict) -> dict: - "Merge the defaults and ensure the options are the right type." - for opt in OPTIONS: - if opt.name not in options: - options[opt.name] = opt.default - continue - if isinstance(opt.clazz, list): - if options[opt.name] not in opt.clazz: - raise _errors.DataTypeError( - f"config option {opt.name}: " - f"invalid choice: {options[opt.name]} " - f"(valid options are {', '.join(map(repr, opt.clazz))})" - ) - continue - try: - options[opt.name] = opt.clazz(options[opt.name]) - except ValueError as err: - raise _errors.DataTypeError( - f"config option {opt.name}: " - f"invalid {opt.clazz.__name__} value: " - f"{options[opt.name]}" - ) from err - return options diff --git a/schemascii/edgemarks.py b/schemascii/edgemarks.py deleted file mode 100644 index 8dab27c..0000000 --- a/schemascii/edgemarks.py +++ /dev/null @@ -1,70 +0,0 @@ -from itertools import chain -from typing import Callable, TypeVar -from .utils import Cbox, Flag, Side, Terminal -from .grid import Grid - -T = TypeVar("T") - - -def over_edges(box: Cbox, - func: Callable[[complex, Side], list[T] | None]) -> list[T]: - "Decorator - Runs around the edges of the box on the grid." - out = [] - for p, s in chain( - # Top side - ( - (complex(xx, int(box.p1.imag) - 1), Side.TOP) - for xx in range(int(box.p1.real), int(box.p2.real) + 1) - ), - # Right side - ( - (complex(int(box.p2.real) + 1, yy), Side.RIGHT) - for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) - ), - # Bottom side - ( - (complex(xx, int(box.p2.imag) + 1), Side.BOTTOM) - for xx in range(int(box.p1.real), int(box.p2.real) + 1) - ), - # Left side - ( - (complex(int(box.p1.real) - 1, yy), Side.LEFT) - for yy in range(int(box.p1.imag), int(box.p2.imag) + 1) - ), - ): - result = func(p, s) - if result is not None: - out.append(result) - return out - - -def take_flags(grid: Grid, box: Cbox) -> list[Flag]: - """Runs around the edges of the component box, collects - the flags, and masks them off to wires.""" - - def get_flags(p: complex, s: Side) -> Flag | None: - c = grid.get(p) - if c in " -|()*": - return None - grid.setmask(p, "*") - return Flag(p, c, s) - - return over_edges(box, get_flags) - - -def find_edge_marks(grid: Grid, box: Cbox) -> list[Terminal]: - "Finds all the terminals on the box in the grid." - flags = take_flags(grid, box) - - def get_terminals(p: complex, s: Side) -> Terminal | None: - c = grid.get(p) - if (c in "*|()" and s in (Side.TOP, Side.BOTTOM)) or ( - c in "*-" and s in (Side.LEFT, Side.RIGHT) - ): - maybe_flag = [f for f in flags if f.pt == p] - if maybe_flag: - return Terminal(p, maybe_flag[0].char, s) - return Terminal(p, None, s) - return None - - return over_edges(box, get_terminals) diff --git a/schemascii/inline_config.py b/schemascii/inline_config.py deleted file mode 100644 index a37d629..0000000 --- a/schemascii/inline_config.py +++ /dev/null @@ -1,35 +0,0 @@ -import re -from .grid import Grid - -INLINE_CONFIG_RE = re.compile(r"!([a-z]+)=([^!]*)!", re.I) - - -def get_inline_configs(grid: Grid) -> dict: - "Extract all the inline config options into a dict and blank them out." - out = {} - for y, line in enumerate(grid.lines): - for m in INLINE_CONFIG_RE.finditer(line): - interval = m.span() - key = m.group(1) - val = m.group(2) - for x in range(*interval): - grid.setmask(complex(x, y)) - try: - val = float(val) - except ValueError: - pass - out[key] = val - return out - - -if __name__ == "__main__": - g = Grid( - "null", - """ -foobar -------C1------- -!padding=30!!label=! -!foobar=bar! -""", - ) - print(get_inline_configs(g)) - print(g) diff --git a/schemascii/wires.py b/schemascii/wires.py deleted file mode 100644 index e74a4b9..0000000 --- a/schemascii/wires.py +++ /dev/null @@ -1,144 +0,0 @@ -from cmath import phase, rect -from math import pi -from .grid import Grid -from .utils import force_int, iterate_line, bunch_o_lines, XML, find_dots - -# cSpell:ignore dydx - -DIRECTIONS = [1, -1, 1j, -1j] - - -def next_in_dir(grid: Grid, - point: complex, - dydx: complex) -> tuple[complex, complex] | None: - """Follows the wire starting at the point in the specified direction, - until some interesting change (a corner, junction, or end). Returns the - tuple (new, old).""" - old_point = point - match grid.get(point): - case "|" | "(" | ")": - # extend up or down - if dydx in (1j, -1j): - while grid.get(point) in "-|()": - point += dydx - if grid.get(point) != "*": - point -= dydx - else: - return None # The vertical wires do not connect horizontally - case "-": - # extend sideways - if dydx in (1, -1): - while grid.get(point) in "-|()": - point += dydx - if grid.get(point) != "*": - point -= dydx - else: - return None # The horizontal wires do not connect vertically - case "*": - # can extend any direction - if grid.get(point + dydx) in "|()-*": - point += dydx - res = next_in_dir(grid, point, dydx) - if res is not None: - point = res[0] - elif dydx in (1j, -1j) and grid.get(point) == "-": - return None - elif dydx in (1, -1) and grid.get(point) in "|()": - return None - else: - return None - case _: - return None - if point == old_point: - return None - return point, old_point - - -def search_wire(grid: Grid, point: complex) -> list[tuple[complex, complex]]: - """Flood-fills the grid starting at the point, and returns - the list of all the straight pieces of wire encountered.""" - seen = [point] - out = [] - frontier = [point] - # find all the points - while frontier: - here = frontier.pop() - for d in DIRECTIONS: - line = next_in_dir(grid, here, d) - if line is None or abs(line[1] - line[0]) == 0: - continue - p = line[0] - if p not in seen and p != here: - frontier.append(p) - seen.append(p) - out.append(line) - return out - - -def blank_wire(grid: Grid, p1: complex, p2: complex): - "Blank out the wire from p1 to p2." - # Crazy math!! - way = int(phase(p1 - p2) / pi % 1.0 * 2) - side = force_int(rect(1, phase(p1 - p2) + pi / 2)) - # way: 0: Horizontal, 1: Vertical - # Don't mask out wire crosses - cross = ["|()", "-"][way] - swap = "|-"[way] - for px in iterate_line(p1, p2): - if grid.get(px + side) in cross and grid.get(px - side) in cross: - grid.setmask(px, swap) - else: - grid.setmask(px) - - -def next_wire(grid: Grid, **options) -> str | None: - """Returns a SVG string of the next line in the grid, - or None if there are no more. The line is masked off.""" - scale = options["scale"] - stroke_width = options["stroke_width"] - color = options["stroke"] - # Find the first wire or return None - for i, line in enumerate(grid.lines): - indexes = [line.index(c) for c in "-|()*" if c in line] - if len(indexes) > 0: - line_pieces = search_wire(grid, complex(min(indexes), i)) - if line_pieces: - break - else: - return None - # Blank out the used wire - for p1, p2 in line_pieces: - blank_wire(grid, p1, p2) - if p1 == p2: - raise RuntimeError("0-length wire") - dots = find_dots(line_pieces) - return XML.g( - bunch_o_lines(line_pieces, **options), - *( - XML.circle( - cx=pt.real * scale, - cy=pt.imag * scale, - r=2 * stroke_width, - stroke="none", - fill=color, - ) - for pt in dots - ), - class_="wire" - ) - - -def get_wires(grid: Grid, **options) -> str: - "Finds all the wires and masks them out, returns an SVG string." - out = "" - w = next_wire(grid, **options) - while w is not None: - out += w - w = next_wire(grid, **options) - return out - - -if __name__ == "__main__": - xg = Grid("test_data/test_resistors.txt") - print(get_wires(xg, scale=20)) - print(xg) From 24e13c5647f67d5094c0999a271dc40ab33c080e Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 27 Aug 2024 16:26:52 -0400 Subject: [PATCH 056/101] implement first component renderer classes - Resistors --- schemascii/annoline.py | 2 +- schemascii/annotation.py | 4 +- schemascii/component.py | 61 +++++++++++++++-------- schemascii/components/__init__.py | 48 ++++++++++++++++++ schemascii/components/resistor.py | 64 ++++++++++++++++++++++++ schemascii/components_render.py | 32 ------------ schemascii/data_consumer.py | 38 +++++++++++---- schemascii/drawing.py | 2 +- schemascii/errors.py | 6 +-- schemascii/metric.py | 5 +- schemascii/utils.py | 81 ++++++++++++++++--------------- schemascii/wire_tag.py | 2 +- 12 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 schemascii/components/__init__.py create mode 100644 schemascii/components/resistor.py diff --git a/schemascii/annoline.py b/schemascii/annoline.py index ceb394d..6a1af95 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -85,7 +85,7 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: seen_points.update(line.points) return all_lines - def render(self, data, **options) -> str: + def render(self, **options) -> str: # copy-pasted from wire.py except class changed at bottom # create lines for all of the neighbor pairs links = [] diff --git a/schemascii/annotation.py b/schemascii/annotation.py index 1576173..0b22849 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -15,7 +15,7 @@ class Annotation(_dc.DataConsumer, namespaces=(":annotation",)): options = [ ("scale",), - _dc.Option("font", str, "Text font", "monospace"), + _dc.Option("font", str, "Text font", "sans-serif"), ] position: complex @@ -34,7 +34,7 @@ def find_all(cls, grid: _grid.Grid): out.append(cls(complex(x, y), text)) return out - def render(self, data, scale, font) -> str: + def render(self, scale, font, **options) -> str: return _utils.XML.text( html.escape(self.content), x=self.position.real * scale, diff --git a/schemascii/component.py b/schemascii/component.py index f5eecbc..8068a84 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -17,7 +17,15 @@ class Component(_dc.DataConsumer, namespaces=(":component",)): """An icon representing a single electronic component.""" all_components: typing.ClassVar[dict[str, type[Component]]] = {} - human_name: typing.ClassVar[str] = "" + + options = [ + "inherit", + _dc.Option("offset_scale", float, + "How far to offset the label from the center of the " + "component. Relative to the global scale option.", 1), + _dc.Option("font", str, "Text font for labels", "monospace"), + + ] rd: _rd.RefDes blobs: list[list[complex]] # to support multiple parts. @@ -113,10 +121,13 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # done return cls(rd, blobs, terminals) - def __init_subclass__( - cls, ids: list[str], id_letters: str | None = None, **kwargs): + def __init_subclass__(cls, ids: tuple[str, ...] = None, + namespaces: tuple[str, ...] = None, **kwargs): """Register the component subclass in the component registry.""" - super().__init_subclass__(**kwargs) + super().__init_subclass__(namespaces=(namespaces or ()), **kwargs) + if not ids: + # allow anonymous helper classes + return for id_letters in ids: if not (id_letters.isalpha() and id_letters.upper() == id_letters): raise ValueError( @@ -125,7 +136,6 @@ def __init_subclass__( raise ValueError( f"duplicate reference designator letters: {id_letters!r}") cls.all_components[id_letters] = cls - cls.human_name = id_letters or cls.__name__ @property def css_class(self) -> str: @@ -134,25 +144,34 @@ def css_class(self) -> str: @classmethod def process_nets(self, nets: list[_net.Net]): """Hook method called to do stuff with the nets that this - component type connects to. By itself it does nothing. + component type connects to. By default it does nothing. If a subclass implements this method to do something, it should - mutate the list in-place and return None. + mutate the list in-place (the return value is ignored). """ pass + def get_terminals( + self, *flags_names: str) -> list[_utils.Terminal]: + """Return the component's terminals sorted so that the terminals with + the specified flags appear first in the order specified and the + remaining terminals come after. -if __name__ == '__main__': - class FooComponent(Component, ids=["U", "FOO"]): - pass - print(Component.all_components) - testgrid = _grid.Grid("test_data/stresstest.txt") - # this will erroneously search the DATA section too but that's OK - # for this test - for rd in _rd.RefDes.find_all(testgrid): - c = Component.from_rd(rd, testgrid) - print(c) - for blob in c.blobs: - testgrid.spark(*blob) - testgrid.spark(*(t.pt for t in c.terminals)) - Component(None, None, None) + Raises an error if a terminal with the specified flag could not be + found, or there were multiple terminals with the requested flag + (ambiguous). + """ + out = [] + for flag in flags_names: + matching_terminals = [t for t in self.terminals if t.flag == flag] + if len(matching_terminals) > 1: + raise _errors.TerminalsError( + f"{self.rd.name}: multiple terminals with the " + f"same flag {flag!r}") + if len(matching_terminals) == 0: + raise _errors.TerminalsError( + f"{self.rd.name}: need a terminal with flag {flag!r}") + out.append(matching_terminals[0]) + out.extend(t for t in self.terminals if t.flag not in flags_names) + assert set(self.terminals) == set(out) + return out diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py new file mode 100644 index 0000000..bedf783 --- /dev/null +++ b/schemascii/components/__init__.py @@ -0,0 +1,48 @@ +import typing +from dataclasses import dataclass + +import schemascii.component as _c +import schemascii.errors as _errors + +# TODO: import all of the component subclasses to register them + + +@dataclass +class NTerminalComponent(_c.Component): + """Represents a component that only ever has N terminals. + + The class must have an attribute n_terminals with the number + of terminals.""" + n_terminals: typing.ClassVar[int] + + def __post_init__(self): + if self.n_terminals != len(self.terminals): + raise _errors.TerminalsError( + f"{self.rd.name}: can only have {self.n_terminals} terminals " + f"(found {len(self.terminals)})") + + +class TwoTerminalComponent(NTerminalComponent): + """Shortcut to define a component with two terminals.""" + n_terminals: typing.Final = 2 + + +@dataclass +class PolarizedTwoTerminalComponent(TwoTerminalComponent): + """Helper class that ensures that a component has only two terminals, + and if provided, sorts the terminals so that the "+" terminal comes + first in the list. + """ + + always_polarized: typing.ClassVar[bool] = False + + def __post_init__(self): + super().__post_init__() # check count of terminals + num_plus = sum(t.flag == "+" for t in self.terminals) + if (self.always_polarized and num_plus != 1) or num_plus > 1: + raise _errors.TerminalsError( + f"{self.rd.name}: need '+' on only one terminal to indicate " + "polarization") + if self.terminals[1].flag == "+": + # swap first and last + self.terminals.insert(0, self.terminals.pop(-1)) diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py new file mode 100644 index 0000000..2c91b9d --- /dev/null +++ b/schemascii/components/resistor.py @@ -0,0 +1,64 @@ +from cmath import phase, pi, rect + +import schemascii.components as _c +import schemascii.data_consumer as _dc +import schemascii.utils as _utils +import schemascii.errors as _errors + +# TODO: IEC rectangular symbol +# see here: https://eepower.com/resistor-guide/resistor-standards-and-codes/resistor-symbols/ # noqa: E501 + + +def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]: + vec = t1 - t2 + length = abs(vec) + angle = phase(vec) + quad_angle = angle + pi / 2 + points = [t1] + for i in range(1, 4 * int(length)): + points.append(t1 - rect(i / 4, angle) + + (rect(1/4, quad_angle) * pow(-1, i))) + points.append(t2) + return points + + +class Resistor(_c.TwoTerminalComponent, ids=("R",), namespaces=(":resistor",)): + options = [ + "inherit", + _dc.Option("value", str, "Resistance in ohms"), + _dc.Option("power", str, "Maximum power dissipation in watts " + "(i.e. size of the resistor)", None) + ] + + is_variable = False + + def render(self, value: str, power: str, **options) -> str: + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + points = _ansi_resistor_squiggle(t1, t2) + try: + id_text = _utils.id_text(self.rd.name, self.terminals, + ((value, "Ω", False, self.is_variable), + (power, "W", False)), + _utils.make_text_point(t1, t2, **options), + **options) + except ValueError as e: + raise _errors.BOMError( + f"{self.rd.name}: Range of values not allowed " + "on fixed resistor") from e + return _utils.polylinegon(points, **options) + id_text + + +class VariableResistor(Resistor, ids=("VR", "RV")): + is_variable = True + + def render(self, **options): + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + return (super().render(**options) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) + +# TODO: potentiometers + + +if __name__ == "__main__": + print(Resistor.all_components) diff --git a/schemascii/components_render.py b/schemascii/components_render.py index 74c7a26..243719f 100644 --- a/schemascii/components_render.py +++ b/schemascii/components_render.py @@ -100,38 +100,6 @@ def sort_terminals( return sort_terminals -@component("R", "RV", "VR") -@n_terminal(2) -@no_ambiguous -def resistor(box: Cbox, terminals: list[Terminal], - bom_data: BOMData, **options): - """Resistor, Variable resistor, etc. - bom:ohms[,watts]""" - t1, t2 = terminals[0].pt, terminals[1].pt - vec = t1 - t2 - mid = (t1 + t2) / 2 - length = abs(vec) - angle = phase(vec) - quad_angle = angle + pi / 2 - points = [t1] - for i in range(1, 4 * int(length)): - points.append(t1 - rect(i / 4, angle) + pow(-1, i) - * rect(1, quad_angle) / 4) - points.append(t2) - return ( - polylinegon(points, **options) - + make_variable(mid, angle, "V" in box.type, **options) - + id_text( - box, - bom_data, - terminals, - (("Ω", False), ("W", False)), - make_text_point(t1, t2, **options), - **options, - ) - ) - - @component("C", "CV", "VC") @n_terminal(2) @no_ambiguous diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index c2b6223..6d00643 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -2,6 +2,7 @@ import abc import typing +import warnings from dataclasses import dataclass import schemascii.data as _data @@ -29,7 +30,7 @@ class DataConsumer(abc.ABC): to be rendered. This class registers the options that the class declares with Data so that they can be checked, automatically pulls the needed options when to_xml_string() is called, and passes the dict of - options to render(). + options to render() as keyword arguments. """ options: typing.ClassVar[list[Option @@ -40,14 +41,25 @@ class DataConsumer(abc.ABC): Option("linewidth", float, "Width of drawn lines", 2), Option("color", str, "black", "Default color for everything"), ] - namepaces: tuple[str, ...] css_class: typing.ClassVar[str] = "" - def __init_subclass__(cls, namespaces: tuple[str, ...] = ("*",)): + @property + def namespaces(self) -> tuple[str, ...]: + # base case to stop recursion + return () + + def __init_subclass__(cls, namespaces: tuple[str, ...] = None): + + if not namespaces: + # allow anonymous helper subclasses + return if not hasattr(cls, "namespaces"): - # don't clobber it if a subclass defines it as a @property! - cls.namespaces = namespaces + # don't clobber it if a subclass already overrides it! + @property + def __namespaces(self) -> tuple[str, ...]: + return super(type(self), self).namespaces + namespaces + cls.namepaces = __namespaces for b in cls.mro(): if (b is not cls @@ -97,14 +109,14 @@ def to_xml_string(self, data: _data.Data) -> str: if opt.name not in values: if opt.default is _NOT_SET: raise _errors.NoDataError( - f"value for {self.namespaces[0]}.{name} is required") + f"missing value for {self.namespaces[0]}.{name}") values[opt.name] = opt.default continue if isinstance(opt.type, list): if values[opt.name] not in opt.type: - raise _errors.DataTypeError( - f"option {self.namespaces[0]}.{opt.name}: " - f"invalid choice: {values[opt.name]} " + raise _errors.BOMError( + f"{self.namespaces[0]}.{opt.name}: " + f"invalid choice: {values[opt.name]!r} " f"(valid options are " f"{', '.join(map(repr, opt.type))})") continue @@ -114,7 +126,13 @@ def to_xml_string(self, data: _data.Data) -> str: raise _errors.DataTypeError( f"option {self.namespaces[0]}.{opt.name}: " f"invalid {opt.type.__name__} value: " - f"{values[opt.name]}") from err + f"{values[opt.name]!r}") from err + for key in values: + if any(opt.name == key for opt in self.options): + continue + warnings.warn( + f"unknown data key {key!r} for styling {self.namespaces[0]}", + stacklevel=2) # render result = self.render(**values, data=data) if self.css_class: diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 0f89134..79c629b 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -83,7 +83,7 @@ def to_xml_string( data |= fudge return super().to_xml_string(data) - def render(self, data, scale: float, padding: float) -> str: + def render(self, data, scale: float, padding: float, **options) -> str: # render everything content = _utils.XML.g( _utils.XML.g( diff --git a/schemascii/errors.py b/schemascii/errors.py index 7981622..ee68564 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -6,7 +6,7 @@ class DiagramSyntaxError(SyntaxError, Error): """Bad formatting in Schemascii diagram syntax.""" -class TerminalsError(TypeError, Error): +class TerminalsError(ValueError, Error): """Incorrect usage of terminals on this component.""" @@ -22,5 +22,5 @@ class NoDataError(NameError, Error): """Data item is required, but not present.""" -class DataTypeError(ValueError, Error): - """Invalid data value.""" +class DataTypeError(TypeError, Error): + """Invalid data type in data section.""" diff --git a/schemascii/metric.py b/schemascii/metric.py index d01b86a..82fcc15 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -94,7 +94,8 @@ def format_metric_unit( num: str, unit: str = "", six: bool = False, - unicode: bool = True) -> str: + unicode: bool = True, + allow_range: bool = True) -> str: """Normalizes the Metric multiplier on the number, then adds the unit. * If there is a suffix on num, moves it to after the unit. @@ -106,6 +107,8 @@ def format_metric_unit( num = num.strip() match = METRIC_RANGE.match(num) if match: + if not allow_range: + raise ValueError("range not allowed") # format the range by calling recursively num0, num1 = match.group(1), match.group(2) suffix = num[match.span()[1]:] diff --git a/schemascii/utils.py b/schemascii/utils.py index 4ddef32..188413f 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -313,6 +313,10 @@ def mk_tag(*contents: str, **attrs: str) -> str: del XMLClass +def _get_sss(options: dict) -> tuple[float, float, str]: + return options["scale"], options["stroke_width"], options["stroke"] + + def points2path(points: list[complex], close: bool = False) -> str: """Convert the list of points into SVG commands to draw the set of lines. @@ -346,13 +350,13 @@ def pad(number: float | int) -> str: def polylinegon( - points: list[complex], is_polygon: bool = False, - *, scale: float, stroke_width: float, stroke: str) -> str: + points: list[complex], is_polygon: bool = False, **options) -> str: """Turn the list of points into a line or filled area. If is_polygon is true, stroke color is used as fill color instead and stroke width is ignored. """ + scale, stroke_width, stroke = _get_sss(options) scaled_pts = [x * scale for x in points] if is_polygon: return XML.path(d=points2path(scaled_pts, True), @@ -380,12 +384,11 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: return [pt for pt, count in seen.items() if count > 3] -def bunch_o_lines( - pairs: list[tuple[complex, complex]], - *, scale: float, stroke_width: float, stroke: str) -> str: +def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: """Combine the pairs (p1, p2) into a set of SVG commands to draw all of the lines. """ + scale, stroke_width, stroke = _get_sss(options) lines = [] while pairs: group = take_next_group(pairs) @@ -402,37 +405,39 @@ def bunch_o_lines( def id_text( - box: Cbox, - bom_data: BOMData, + cname: str, terminals: list[Terminal], - unit: str | list[str] | None, + value: str | list[tuple[str, str] + | tuple[str, str, bool] + | tuple[str, str, bool, bool]], point: complex | None = None, - *, - label: typing.Literal["L", "V", "LV"], - nolabels: bool, - scale: float, - stroke: str) -> str: + **options) -> str: """Format the component ID and value around the point.""" + nolabels, label = options["nolabels"], options["label"] + scale, stroke = options["scale"], options["stroke"] + font = options["font"] if nolabels: return "" - if point is None: + if not point: point = sum(t.pt for t in terminals) / len(terminals) data = "" - if bom_data is not None: - text = bom_data.data - classy = "part-num" - if unit is None: - pass - elif isinstance(unit, str): - text = _metric.format_metric_unit(text, unit) - classy = "cmp-value" - else: - text = " ".join( - _metric.format_metric_unit(x, y, six) - for x, (y, six) in zip(text.split(","), unit) - ) - classy = "cmp-value" - data = XML.tspan(text, class_=classy) + if isinstance(value, str): + data = value + data_css_class = "part-num" + else: + for tp in value: + match tp: + case (n, unit) if n: + data += _metric.format_metric_unit(n, unit) + case (n, unit, six) if n: + data += _metric.format_metric_unit(n, unit, six) + case (n, unit, six, allow_range) if n: + data += _metric.format_metric_unit( + n, unit, six, allow_range=allow_range) + case _: + raise ValueError( + f"bad values tuple: {tp!r}") + data_css_class = "cmp-value" if len(terminals) > 1: textach = ( "start" @@ -446,21 +451,20 @@ def id_text( textach = "middle" if terminals[0].side in ( Side.TOP, Side.BOTTOM) else "start" return XML.text( - (XML.tspan(f"{box.type}{box.id}", class_="cmp-id") - * bool("L" in label)), - " " * (bool(data) and "L" in label), - data * bool("V" in label), + XML.tspan(cname, class_="cmp-id") if "L" in label else "", + " " if data and "L" in label else "", + XML.tspan(data, class_=data_css_class) if "V" in label else "", x=point.real, y=point.imag, text__anchor=textach, font__size=scale, fill=stroke, - ) + style=f"font-family:{font}") -def make_text_point(t1: complex, t2: complex, - *, scale: float, offset_scale: float) -> complex: +def make_text_point(t1: complex, t2: complex, **options) -> complex: """Compute the scaled coordinates of the text anchor point.""" + scale, offset_scale = options["scale"], options["offset_scale"] quad_angle = phase(t1 - t2) + pi / 2 text_pt = (t1 + t2) * scale / 2 offset = rect(scale / 2 * offset_scale, quad_angle) @@ -497,11 +501,8 @@ def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: ] -def make_variable(center: complex, theta: float, - is_variable: bool = True, **options) -> str: +def make_variable(center: complex, theta: float, **options) -> str: """Draw a 'variable' arrow across the component.""" - if not is_variable: - return "" return bunch_o_lines(deep_transform(arrow_points(-1, 1), center, (theta % pi) + pi / 4), diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index 8227a55..6ba65be 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -58,7 +58,7 @@ def find_all(cls, grid: _grid.Grid) -> list[WireTag]: point_dir, connect_pt)) return out - def render(self, data) -> str: + def render(self, **options) -> str: raise NotImplementedError From 363c723c7a4cdabd4cd4b94c6918a8ae9b1428f9 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 27 Aug 2024 18:16:44 -0400 Subject: [PATCH 057/101] dots --- schemascii/wire.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/schemascii/wire.py b/schemascii/wire.py index adc9584..8f94373 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -64,13 +64,24 @@ def get_from_grid(cls, grid: _grid.Grid, return cls(points, self_tag) def render(self, data, **options) -> str: + scale = options["scale"] + linewidth = options["linewidth"] # create lines for all of the neighbor pairs links = [] for p1, p2 in itertools.combinations(self.points, 2): if abs(p1 - p2) == 1: links.append((p1, p2)) + # find dots + dots = "" + for dot_pt in _utils.find_dots(links): + dots += _utils.XML.circle( + cx=scale * dot_pt.real, + cy=scale * dot_pt.real, + r=linewidth, + class_="dot") return (_utils.bunch_o_lines(links, **options) - + (self.tag.to_xml_string(data) if self.tag else "")) + + (self.tag.to_xml_string(data) if self.tag else "") + + dots) @classmethod def is_wire_character(cls, ch: str) -> bool: From c9ba88eb4bcfa0aa6d4e82827bd59e030b468e1a Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:39:04 -0400 Subject: [PATCH 058/101] import_all_components() --- schemascii/__init__.py | 70 ++++++++++--------------------- schemascii/components/__init__.py | 2 - 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 8387867..44181d1 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,56 +1,32 @@ +import importlib +import os +import schemascii.components as _comp +import schemascii.drawing as _drawing __version__ = "0.3.2" +def import_all_components(): + for f in os.scandir(os.path.dirname(_comp.__file__)): + if f.is_file(): + importlib.import_module( + f"{_comp.__package__}.{f.name.removesuffix('.py')}") + + +import_all_components() +del import_all_components + + def render(filename: str, text: str | None = None, **options) -> str: - "Render the Schemascii diagram to an SVG string." - raise NotImplementedError - if text is None: - with open(filename, encoding="ascii") as f: - text = f.read() - # get everything - grid = Grid(filename, text) - # Passed-in options override diagram inline options - options = apply_config_defaults( - options | get_inline_configs( - grid) | options.get("override_options", {}) - ) - components, bom_data = find_all(grid) - terminals = {c: find_edge_marks(grid, c) for c in components} - fixed_bom_data = { - c: [b for b in bom_data if b.id == c.id and b.type == c.type] - for c in components - } - # get some options - padding = options["padding"] - scale = options["scale"] - - wires = get_wires(grid, **options) - components_strs = ( - render_component(c, terminals[c], fixed_bom_data[c], **options) - for c in components - ) - return XML.svg( - wires, - *components_strs, - width=grid.width * scale + padding * 2, - height=grid.height * scale + padding * 2, - viewBox=f"{-padding} {-padding} " - f"{grid.width * scale + padding * 2} " - f"{grid.height * scale + padding * 2}", - xmlns="http://www.w3.org/2000/svg", - class_="schemascii", - ) + """Render the Schemascii diagram to an SVG string.""" + return _drawing.Drawing.from_file(filename, text).to_xml_string(options) if __name__ == "__main__": - print( - render( - "test_data/test_resistors.txt", - scale=20, - padding=20, - stroke_width=2, - stroke="black", - ) - ) + print(render( + "test_data/test_resistors.txt", + scale=20, + padding=20, + stroke_width=2, + stroke="black")) diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index bedf783..97e5333 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -4,8 +4,6 @@ import schemascii.component as _c import schemascii.errors as _errors -# TODO: import all of the component subclasses to register them - @dataclass class NTerminalComponent(_c.Component): From 9c858e0584d00fa65ebad7f215bb78368c3fe568 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:27:48 -0400 Subject: [PATCH 059/101] formatting --- schemascii/__init__.py | 14 +++++------- schemascii/__main__.py | 21 +++++++++--------- schemascii/component.py | 8 +++++-- schemascii/utils.py | 48 ++++++++++++++++------------------------- 4 files changed, 40 insertions(+), 51 deletions(-) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 44181d1..3099e4f 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,17 +1,17 @@ import importlib import os -import schemascii.components as _comp +import schemascii.components as _c import schemascii.drawing as _drawing __version__ = "0.3.2" def import_all_components(): - for f in os.scandir(os.path.dirname(_comp.__file__)): + for f in os.scandir(os.path.dirname(_c.__file__)): if f.is_file(): importlib.import_module( - f"{_comp.__package__}.{f.name.removesuffix('.py')}") + f"{_c.__package__}.{f.name.removesuffix('.py')}") import_all_components() @@ -24,9 +24,5 @@ def render(filename: str, text: str | None = None, **options) -> str: if __name__ == "__main__": - print(render( - "test_data/test_resistors.txt", - scale=20, - padding=20, - stroke_width=2, - stroke="black")) + import schemascii.component as _comp + print(_comp.Component.all_components) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index b987580..8dde9ec 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -1,26 +1,25 @@ import argparse import sys import warnings -from . import render, __version__ -from .errors import Error -from .configs import add_config_arguments + +import schemascii +import schemascii.errors as _errors def cli_main(): ap = argparse.ArgumentParser( - prog="schemascii", description="Render ASCII-art schematics into SVG." - ) + prog="schemascii", description="Render ASCII-art schematics into SVG.") ap.add_argument( - "-V", "--version", action="version", version="%(prog)s " + __version__ - ) + "-V", "--version", action="version", + version="%(prog)s " + schemascii.__version__) ap.add_argument("in_file", help="File to process.") ap.add_argument( "-o", "--out", default=None, dest="out_file", - help="Output SVG file. (default input file plus .svg)", - ) + help="Output SVG file. (default input file plus .svg)") + # TODO: implement this add_config_arguments(ap) args = ap.parse_args() if args.out_file is None: @@ -31,8 +30,8 @@ def cli_main(): args.in_file = "" try: with warnings.catch_warnings(record=True) as captured_warnings: - result_svg = render(args.in_file, text, **vars(args)) - except Error as err: + result_svg = schemascii.render(args.in_file, text, **vars(args)) + except _errors.Error as err: print(type(err).__name__ + ":", err, file=sys.stderr) sys.exit(1) if captured_warnings: diff --git a/schemascii/component.py b/schemascii/component.py index 8068a84..bab081b 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -16,6 +16,7 @@ @dataclass class Component(_dc.DataConsumer, namespaces=(":component",)): """An icon representing a single electronic component.""" + all_components: typing.ClassVar[dict[str, type[Component]]] = {} options = [ @@ -132,9 +133,12 @@ def __init_subclass__(cls, ids: tuple[str, ...] = None, if not (id_letters.isalpha() and id_letters.upper() == id_letters): raise ValueError( f"invalid reference designator letters: {id_letters!r}") - if id_letters in cls.all_components: + if (id_letters in cls.all_components + and cls.all_components[id_letters] is not cls): raise ValueError( - f"duplicate reference designator letters: {id_letters!r}") + f"duplicate reference designator letters: {id_letters!r} " + f"(trying to register {cls!r}, already " + f"occupied by {cls.all_components[id_letters]!r})") cls.all_components[id_letters] = cls @property diff --git a/schemascii/utils.py b/schemascii/utils.py index 188413f..04b7d02 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -419,7 +419,7 @@ def id_text( if nolabels: return "" if not point: - point = sum(t.pt for t in terminals) / len(terminals) + point = centroid(t.pt for t in terminals) data = "" if isinstance(value, str): data = value @@ -441,12 +441,9 @@ def id_text( if len(terminals) > 1: textach = ( "start" - if ( - any(Side.BOTTOM == t.side for t in terminals) - or any(Side.TOP == t.side for t in terminals) - ) - else "middle" - ) + if (any(Side.BOTTOM == t.side for t in terminals) + or any(Side.TOP == t.side for t in terminals)) + else "middle") else: textach = "middle" if terminals[0].side in ( Side.TOP, Side.BOTTOM) else "start" @@ -472,22 +469,15 @@ def make_text_point(t1: complex, t2: complex, **options) -> complex: return text_pt -def make_plus(terminals: list[Terminal], center: complex, - theta: float, **options) -> str: - """Make a + sign if the terminals indicate the component is polarized.""" - if all(t.flag != "+" for t in terminals): - return "" +def make_plus(center: complex, theta: float, **options) -> str: + """Make a '+' sign for indicating polarity.""" return XML.g( bunch_o_lines( deep_transform( deep_transform([(0.125, -0.125), (0.125j, -0.125j)], 0, theta), - center + deep_transform(0.33 + 0.75j, 0, theta), - 0, - ), - **options, - ), - class_="plus", - ) + center + deep_transform(0.33 + 0.75j, 0, theta), 0), + **options), + class_="plus") def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: @@ -497,16 +487,16 @@ def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: return [ (p2, p1), (p2, p2 - rect(tick_len, angle + pi / 5)), - (p2, p2 - rect(tick_len, angle - pi / 5)), - ] + (p2, p2 - rect(tick_len, angle - pi / 5))] def make_variable(center: complex, theta: float, **options) -> str: - """Draw a 'variable' arrow across the component.""" - return bunch_o_lines(deep_transform(arrow_points(-1, 1), - center, - (theta % pi) + pi / 4), - **options) + """Draw a "variable" arrow across the component.""" + return XML.g(bunch_o_lines(deep_transform(arrow_points(-1, 1), + center, + (theta % pi) + pi / 4), + **options), + class_="variable") def light_arrows(center: complex, theta: float, out: bool, **options): @@ -516,13 +506,13 @@ def light_arrows(center: complex, theta: float, out: bool, **options): a, b = 1j, 0.3 + 0.3j if out: a, b = b, a - return bunch_o_lines( + return XML.g(bunch_o_lines( deep_transform(arrow_points(a, b), center, theta - pi / 2) + deep_transform(arrow_points(a - 0.5, b - 0.5), center, theta - pi / 2), - **options - ) + **options), + class_="light-emitting" if out else "light-dependent") def sort_terminals_counterclockwise( From 6d70fb99f1a13e207be1fb58b1ed56d69d59244c Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:28:51 -0400 Subject: [PATCH 060/101] add capacitor and refactor units definitions --- schemascii/components/__init__.py | 36 +++++++++++++++++++++-- schemascii/components/capacitor.py | 47 ++++++++++++++++++++++++++++++ schemascii/components/resistor.py | 31 +++++++------------- schemascii/components_render.py | 45 ++-------------------------- 4 files changed, 95 insertions(+), 64 deletions(-) create mode 100644 schemascii/components/capacitor.py diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index 97e5333..d18b6bd 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -1,8 +1,34 @@ +from __future__ import annotations import typing from dataclasses import dataclass import schemascii.component as _c import schemascii.errors as _errors +import schemascii.utils as _utils + + +class SimpleComponent: + """Component mixin class that simplifies the formatting + of the various values and their units into the id_text. + """ + + value_format: typing.ClassVar[list[tuple[str, str] + | tuple[str, str, bool] + | tuple[str, str, bool, bool]]] + + def format_id_text(self: _c.Component | SimpleComponent, + textpoint: complex, **options): + val_fmt = [] + for valsch in self.value_format: + val_fmt.append((options[valsch[0]], *valsch[1:])) + try: + id_text = _utils.id_text(self.rd.name, self.terminals, + val_fmt, textpoint, **options) + except ValueError as e: + raise _errors.BOMError( + f"{self.rd.name}: Range of values not allowed " + "on fixed-value component") from e + return id_text @dataclass @@ -21,8 +47,10 @@ def __post_init__(self): class TwoTerminalComponent(NTerminalComponent): - """Shortcut to define a component with two terminals.""" - n_terminals: typing.Final = 2 + """Shortcut to define a component with two terminals, and one primary + value, that may or may not be variable.""" + n_terminals: typing.Final[int] = 2 + is_variable: typing.ClassVar[bool] = False @dataclass @@ -44,3 +72,7 @@ def __post_init__(self): if self.terminals[1].flag == "+": # swap first and last self.terminals.insert(0, self.terminals.pop(-1)) + + @property + def is_polarized(self) -> bool: + return any(t.flag == "+" for t in self.terminals) diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py new file mode 100644 index 0000000..1cd95f5 --- /dev/null +++ b/schemascii/components/capacitor.py @@ -0,0 +1,47 @@ +from cmath import phase, rect + +import schemascii.components as _c +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + + +class Capacitor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, + ids=("C",), namespaces=(":capacitor",)): + options = [ + "inherit", + _dc.Option("value", str, "Capacitance in farads"), + _dc.Option("voltage", str, "Maximum voltage tolerance in volts", None) + ] + + @property + def value_format(self): + return [("value", "F", True, self.is_variable), + ("voltage", "V", False)] + + def render(self, **options) -> str: + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + mid = (t1 + t2) / 2 + angle = phase(t1 - t2) + lines = [ + (t1, mid + rect(1/4, angle)), + (t2, mid + rect(-1/4, angle)), + *_utils.deep_transform([ + (complex(2/5, 1/4), complex(-2/5, 1/4)), + (complex(2/5, -1/4), complex(-2/5, -1/4)), + ], mid, angle) + ] + return (_utils.bunch_o_lines(lines, **options) + + (_utils.make_plus(self.terminals, mid, angle, **options) + if self.is_polarized else "") + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +class VariableCapacitor(Capacitor, ids=("VC", "CV")): + is_variable = True + + def render(self, **options): + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + return (super().render(**options) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py index 2c91b9d..0a10803 100644 --- a/schemascii/components/resistor.py +++ b/schemascii/components/resistor.py @@ -3,9 +3,8 @@ import schemascii.components as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils -import schemascii.errors as _errors -# TODO: IEC rectangular symbol +# TODO: IEC rectangular symbol, other variable markings? # see here: https://eepower.com/resistor-guide/resistor-standards-and-codes/resistor-symbols/ # noqa: E501 @@ -22,7 +21,8 @@ def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]: return points -class Resistor(_c.TwoTerminalComponent, ids=("R",), namespaces=(":resistor",)): +class Resistor(_c.TwoTerminalComponent, _c.SimpleComponent, + ids=("R",), namespaces=(":resistor",)): options = [ "inherit", _dc.Option("value", str, "Resistance in ohms"), @@ -30,22 +30,17 @@ class Resistor(_c.TwoTerminalComponent, ids=("R",), namespaces=(":resistor",)): "(i.e. size of the resistor)", None) ] - is_variable = False + @property + def value_format(self): + return [("value", "Ω", False, self.is_variable), + ("power", "W", False)] - def render(self, value: str, power: str, **options) -> str: + def render(self, **options) -> str: t1, t2 = self.terminals[0].pt, self.terminals[1].pt points = _ansi_resistor_squiggle(t1, t2) - try: - id_text = _utils.id_text(self.rd.name, self.terminals, - ((value, "Ω", False, self.is_variable), - (power, "W", False)), - _utils.make_text_point(t1, t2, **options), - **options) - except ValueError as e: - raise _errors.BOMError( - f"{self.rd.name}: Range of values not allowed " - "on fixed resistor") from e - return _utils.polylinegon(points, **options) + id_text + return (_utils.polylinegon(points, **options) + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) class VariableResistor(Resistor, ids=("VR", "RV")): @@ -58,7 +53,3 @@ def render(self, **options): (t1 + t2) / 2, phase(t1 - t2), **options)) # TODO: potentiometers - - -if __name__ == "__main__": - print(Resistor.all_components) diff --git a/schemascii/components_render.py b/schemascii/components_render.py index 243719f..a6fce0d 100644 --- a/schemascii/components_render.py +++ b/schemascii/components_render.py @@ -54,8 +54,7 @@ def n_check( if len(terminals) != n_terminals: raise TerminalsError( f"{box.type}{box.id} component can only " - f"have {n_terminals} terminals" - ) + f"have {n_terminals} terminals") return func(box, terminals, bom_data, **options) return n_check @@ -90,54 +89,16 @@ def sort_terminals( bom_data: list[BOMData], **options): if len(terminals) != 2: raise TerminalsError( - f"{box.type}{box.id} component can only " f"have 2 terminals" - ) + f"{box.type}{box.id} component can only have 2 terminals") if terminals[1].flag == "+": terminals[0], terminals[1] = terminals[1], terminals[0] return func(box, terminals, bom_data, **options) - sort_terminals.__doc__ = func.__doc__ return sort_terminals -@component("C", "CV", "VC") -@n_terminal(2) -@no_ambiguous -def capacitor(box: Cbox, terminals: list[Terminal], - bom_data: BOMData, **options): - """Draw a capacitor, variable capacitor, etc. - bom:farads[,volts] - flags:+=positive""" - t1, t2 = terminals[0].pt, terminals[1].pt - mid = (t1 + t2) / 2 - angle = phase(t1 - t2) - lines = [ - (t1, mid + rect(0.25, angle)), - (t2, mid + rect(-0.25, angle)), - ] + deep_transform( - [ - (complex(0.4, 0.25), complex(-0.4, 0.25)), - (complex(0.4, -0.25), complex(-0.4, -0.25)), - ], - mid, - angle, - ) - return ( - bunch_o_lines(lines, **options) - + make_plus(terminals, mid, angle, **options) - + make_variable(mid, angle, "V" in box.type, **options) - + id_text( - box, - bom_data, - terminals, - (("F", True), ("V", False)), - make_text_point(t1, t2, **options), - **options, - ) - ) - - @component("L", "VL", "LV") +@n_terminal(2) @no_ambiguous def inductor(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): From 0dfcbde6085e3734bc8c6f5a8f73220313396ca6 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:17:13 -0400 Subject: [PATCH 061/101] special case 0 can take any multiplier because it's 0 --- schemascii/metric.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/schemascii/metric.py b/schemascii/metric.py index 82fcc15..976a451 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -15,7 +15,7 @@ def exponent_to_multiplier(exponent: int) -> str | None: * 0 --> "" (no multiplier) * -6 --> "u" (micro) - If it is not a multiple of 3, returns None. + If it is not a multiple of 3, return None. """ if exponent % 3 != 0: return None @@ -25,11 +25,13 @@ def exponent_to_multiplier(exponent: int) -> str | None: def multiplier_to_exponent(multiplier: str) -> int: - """Turns the Metric multiplier into its exponent. + """Turn the Metric multiplier into its 10^exponent. * "k" --> 3 (kilo) * " " --> 0 (no multiplier) * "u" --> -6 (micro) + + If it is not a valid Metric multiplier, raises an error. """ if multiplier in (" ", ""): return 0 @@ -38,8 +40,11 @@ def multiplier_to_exponent(multiplier: str) -> int: if multiplier == "K": multiplier = multiplier.lower() # special case (preferred is lowercase) - i = "pnum kMGT".index(multiplier) - return (i - 4) * 3 + try: + return 3 * ("pnum kMGT".index(multiplier) - 4) + except IndexError as e: + raise ValueError( + f"unknown metric multiplier: {multiplier!r}") from e def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: @@ -60,16 +65,17 @@ def best_exponent(num: Decimal, six: bool) -> tuple[str, int]: # we're trying to avoid getting exponential notation here continue if "." in new_digits: + # rarely are significant figures important in component values new_digits = new_digits.rstrip("0").removesuffix(".") possibilities.append((new_digits, new_exp)) # heuristics: # * shorter is better - # * prefer no decimal point # * prefer no Metric multiplier if possible + # * prefer no decimal point return sorted( possibilities, key=lambda x: ((10 * len(x[0])) - + (2 * ("." in x[0])) - + (5 * (x[1] != 0))))[0] + + (5 * (x[1] != 0)) + + (2 * ("." in x[0]))))[0] def normalize_metric(num: str, six: bool, unicode: bool) -> tuple[str, str]: @@ -114,11 +120,11 @@ def format_metric_unit( suffix = num[match.span()[1]:] digits0, exp0 = normalize_metric(num0, six, unicode) digits1, exp1 = normalize_metric(num1, six, unicode) - if exp0 != exp1: + if exp0 != exp1 and digits0 != "0": # different multiplier so use multiplier and unit on both return (f"{digits0} {exp0}{unit} - " f"{digits1} {exp1}{unit} {suffix}").rstrip() - return f"{digits0}-{digits1} {exp0}{unit} {suffix}".rstrip() + return f"{digits0}-{digits1} {exp1}{unit} {suffix}".rstrip() match = METRIC_NUMBER.match(num) if not match: return num @@ -133,7 +139,8 @@ def test(*args): print(repr(format_metric_unit(*args))) test("2.5-3500", "V") test("50n", "F", True) - test("50M-1000000000000000000000p", "Hz") + test("50M-100000000000000000000p", "Hz") test(".1", "Ω") test("2200u", "F", True) + test("0-100k", "V") test("Gain", "Ω") From 41a990f698790dc0e7ec7397a1db57326e728860 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:27:53 -0400 Subject: [PATCH 062/101] port over inductor --- ...nts_render.py => OLD_components_render.py} | 41 +---------------- schemascii/components/inductor.py | 46 +++++++++++++++++++ scripts/docs.py | 8 +--- 3 files changed, 49 insertions(+), 46 deletions(-) rename schemascii/{components_render.py => OLD_components_render.py} (93%) create mode 100644 schemascii/components/inductor.py diff --git a/schemascii/components_render.py b/schemascii/OLD_components_render.py similarity index 93% rename from schemascii/components_render.py rename to schemascii/OLD_components_render.py index a6fce0d..b2f16ae 100644 --- a/schemascii/components_render.py +++ b/schemascii/OLD_components_render.py @@ -15,13 +15,10 @@ make_text_point, bunch_o_lines, deep_transform, - make_plus, - make_variable, sort_terminals_counterclockwise, light_arrows, sort_for_flags, - is_clockwise, -) + is_clockwise) from .errors import TerminalsError, BOMError, UnsupportedComponentError # pylint: disable=unbalanced-tuple-unpacking @@ -97,42 +94,6 @@ def sort_terminals( return sort_terminals -@component("L", "VL", "LV") -@n_terminal(2) -@no_ambiguous -def inductor(box: Cbox, terminals: list[Terminal], - bom_data: BOMData, **options): - """Draw an inductor (coil, choke, etc) - bom:henries""" - t1, t2 = terminals[0].pt, terminals[1].pt - vec = t1 - t2 - mid = (t1 + t2) / 2 - length = abs(vec) - angle = phase(vec) - scale = options["scale"] - data = f"M{t1.real * scale} {t1.imag * scale}" - dxdy = rect(scale, angle) - for _ in range(int(length)): - data += f"a1 1 0 01 {-dxdy.real} {dxdy.imag}" - return ( - XML.path( - d=data, - stroke=options["stroke"], - fill="transparent", - stroke__width=options["stroke_width"], - ) - + make_variable(mid, angle, "V" in box.type, **options) - + id_text( - box, - bom_data, - terminals, - (("H", False),), - make_text_point(t1, t2, **options), - **options, - ) - ) - - @component("B", "BT", "BAT") @polarized @no_ambiguous diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py new file mode 100644 index 0000000..2f1afe4 --- /dev/null +++ b/schemascii/components/inductor.py @@ -0,0 +1,46 @@ +from cmath import phase, rect + +import schemascii.components as _c +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + + +class Inductor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, + ids=("L",), namespaces=(":inductor",)): + options = [ + "inherit", + _dc.Option("value", str, "Inductance in henries"), + _dc.Option("current", str, "Maximum current rating in amps", None) + ] + + @property + def value_format(self): + return [("value", "H", False, self.is_variable), + ("current", "A", False)] + + def render(self, **options) -> str: + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + vec = t1 - t2 + length = abs(vec) + angle = phase(vec) + scale = options["scale"] + data = f"M{t1.real * scale} {t1.imag * scale}" + d = rect(scale, angle) + for _ in range(int(length)): + data += f"a1 1 0 01 {-d.real} {d.imag}" + return ( + _utils.XML.path(d=data, stroke=options["stroke"], + fill="transparent", + stroke__width=options["stroke_width"]) + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +class VariableInductor(Inductor, ids=("VL", "LV")): + is_variable = True + + def render(self, **options): + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + return (super().render(**options) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) diff --git a/scripts/docs.py b/scripts/docs.py index 631d7ca..ec82cc0 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -2,13 +2,9 @@ import re import os from itertools import groupby -from schemascii.components_render import RENDERERS -# pylint: disable=unspecified-encoding,missing-function-docstring,invalid-name -# pylint: disable=not-an-iterable -# cSpell:ignore siht etareneg redner iicsa stpircs nettirwrevo ylpmis segnahc -# cSpell:ignore mehcs daetsn detareneg yllacitamotua codc stnenopmoc lliw ruo -# cSpell:ignore sgnirtscod +raise NotImplementedError("TODO: re-write docs generator script " + "after I re-do the cmdline help text") TOP = ("# Supported Schemascii Components\n\n "330 µH" (integer values are preferred). +* `value = 0.33` on a resistor --> "0.33 Ω" (no Metric multiplier is preferred). +* `value = 1500mA` on a fuse --> "1.5 A" (using decimal point is shorter) +* `value = 2.2 nF` on a capacitor --> "2200 pF" (capacitances aren't typically given in nanofarads) +* `value = 10; power = 5` --> "10 Ω 5 W". Many components have "secondary" optional values; the name will be given in the [options][]. -**New in 0.2.0!** +Also, if the value is not even a number -- such as `L1 {value = "detector coil"}`, the Metric-normalization code will not activate, and the value written will be used verbatim -- "L1 detector coil". -You can specify configuration values for rendering the components inline in the document by writing `!name=value!` in your document. See the help output of the Schemascii CLI for the different options (in the README) or look at the config options at the top of [`configs.py`](https://github.com/dragoncoder047/schemascii/blob/main/schemascii/configs.py). The most common options I use are `scale` and `padding`. +[fnmatch]: https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch +[options]: options.md \ No newline at end of file From f18dba0b041d97ca597d5143ad95493929c46779 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:46:41 -0400 Subject: [PATCH 068/101] switched arguments oops --- schemascii/data_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 6d00643..c06c641 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -39,7 +39,7 @@ class DataConsumer(abc.ABC): Option("scale", float, "Scale by which to enlarge the " "entire diagram by", 15), Option("linewidth", float, "Width of drawn lines", 2), - Option("color", str, "black", "Default color for everything"), + Option("color", str, "Default color for everything", "black"), ] css_class: typing.ClassVar[str] = "" From 19b547e7d5a7f2c473134c9ac4ba274aaa166612 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:46:57 -0400 Subject: [PATCH 069/101] out of date stuff --- algorithm.md | 2 ++ designators.md | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/algorithm.md b/algorithm.md index 6592b57..381a788 100644 --- a/algorithm.md +++ b/algorithm.md @@ -1,5 +1,7 @@ # Schemascii algorithm +(THIS DOCUMENT IS HORRIBLY OUT-OF-DATE, I NEED TO RE-WRITE THE UPDATED ALGORITHM...) + Everything is based on a `Grid` that contains all the data for the document. This `Grid` can be masked to show something else instead of the original, and restored to the original. The algorithm first starts by finding all the "large component" boxes in the grid. After that, it finds all the "small component" reference designators. Then it picks out the BOM notes so component values, etc. can be included. diff --git a/designators.md b/designators.md index cc347da..ba99bae 100644 --- a/designators.md +++ b/designators.md @@ -2,7 +2,7 @@ (copied from and edited lightly) -This is a list of all components that Schemascii *might* support. For a complete list of all supported components, (generated from the implementation file), please see [supported-components.md](./supported-components.md). If a component you want is not supported, have a look at [#3](https://github.com/dragoncoder047/schemascii/issues/3) or fork and implement it yourself. +This is a list of all components that Schemascii *might* support. For a complete list of all supported components, (generated from the implementation file), please see [options.md][options]. If a component you want is not supported, post a request on [issue #3][todo_components] or fork and implement it yourself. | Designator | Component type | |:--:|:--| @@ -73,3 +73,6 @@ This is a list of all components that Schemascii *might* support. For a complete | VT | Voltage transformer | | W | Wire | | X, XTAL, Y | Crystal oscillator, ceramic resonator | + +[options]: options.md +[todo_components]: https://github.com/dragoncoder047/schemascii/issues/3 From 39566e11c3513376e5c1618d687d6022ca11d531 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:47:06 -0400 Subject: [PATCH 070/101] add a makefile yay --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d582a9 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: docs must_specify + +must_specify: + @echo "there is no default makefile rule" + @exit 1 + +docs: + python3 scripts/docs.py From 2b0ebb60e2c2a40e63c25b2a5ca64aed38abf083 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:47:20 -0400 Subject: [PATCH 071/101] add note about data separator --- format.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/format.md b/format.md index 56dd0b7..efc74d0 100644 --- a/format.md +++ b/format.md @@ -76,6 +76,8 @@ Lines are allowed to cross wires. They are crossed the same way that wires cross Every drawing is required to have a "data section" appended after it. The data section contains mappings of values to tell Schemascii how to render each component (e.g. how thick to make the lines, what the components' values are, etc.) +The data section is separated from the actual circuit by a line containing `---` and nothing else. This is REQUIRED and Schemascii will complain if it doesn't find any line containing `---` and nothing else, even if there is no data after it. + Here is some example data: ```txt From a13bd71ab6c97beb9611cc21832e6390589c64f8 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:47:56 -0400 Subject: [PATCH 072/101] rename to available_options --- schemascii/data.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/schemascii/data.py b/schemascii/data.py index 4d63654..8256436 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -51,18 +51,19 @@ class Data: sections: list[Section] - allowed_options: typing.ClassVar[dict[str, list[_dc.Option]]] = {} + # mapping of scope name to list of available options + available_options: typing.ClassVar[dict[str, list[_dc.Option]]] = {} @classmethod def define_option(cls, ns: str, opt: _dc.Option): - if ns in cls.allowed_options: + if ns in cls.available_options: if any(eo.name == opt.name and eo != opt - for eo in cls.allowed_options[ns]): + for eo in cls.available_options[ns]): raise ValueError(f"duplicate option name {opt.name!r}") - if opt not in cls.allowed_options[ns]: - cls.allowed_options[ns].append(opt) + if opt not in cls.available_options[ns]: + cls.available_options[ns].append(opt) else: - cls.allowed_options[ns] = [opt] + cls.available_options[ns] = [opt] @classmethod def parse_from_string(cls, text: str, startline=1, filename="") -> Data: From d8726cc5de7d8faf423fe7e7463d6a710e4d5134 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 13 Sep 2024 13:00:31 -0400 Subject: [PATCH 073/101] make docs --- options.md | 119 ++++++++++++++++++++++++++++++++++++++++ scripts/docs.py | 113 ++++++++++++++++++++++---------------- scripts/release.py | 21 +------ scripts/scriptutils.py | 30 ++++++++++ supported-components.md | 22 -------- 5 files changed, 217 insertions(+), 88 deletions(-) create mode 100644 options.md create mode 100644 scripts/scriptutils.py delete mode 100644 supported-components.md diff --git a/options.md b/options.md new file mode 100644 index 0000000..c1a3af8 --- /dev/null +++ b/options.md @@ -0,0 +1,119 @@ + +# Data Section Options + + + +## Scope `:WIRE-TAG` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | + +## Scope `:WIRE` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | + +## Scope `:NET` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | + +## Scope `:COMPONENT` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | + +## Scope `:ANNOTATION` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| font | str | Text font | 'sans-serif' | + +## Scope `:ANNOTATION-LINE` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | + +## Scope `:ROOT` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| padding | float | Margin around the border of the drawing | 10 | + +## Component `:BATTERY` + +Reference Designators: `B`, `BT`, `BAT` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| value | str | Battery voltage | (required) | +| capacity | str | Battery capacity in amp-hours | (no value) | + +## Component `:CAPACITOR` + +Reference Designators: `C`, `VC`, `CV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| value | str | Capacitance in farads | (required) | +| voltage | str | Maximum voltage tolerance in volts | (no value) | + +## Component `:INDUCTOR` + +Reference Designators: `L`, `VL`, `LV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| value | str | Inductance in henries | (required) | +| current | str | Maximum current rating in amps | (no value) | + +## Component `:RESISTOR` + +Reference Designators: `R`, `VR`, `RV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| value | str | Resistance in ohms | (required) | +| power | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | diff --git a/scripts/docs.py b/scripts/docs.py index ec82cc0..d3ed793 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -1,55 +1,74 @@ #! /usr/bin/env python3 -import re +import datetime import os -from itertools import groupby - -raise NotImplementedError("TODO: re-write docs generator script " - "after I re-do the cmdline help text") - -TOP = ("# Supported Schemascii Components\n\n\n\n| Reference Designators | Description | " - + "BOM Syntax | Supported Flags |" - + "\n|:--:|:--|:--:|:--|\n") - - -def group_components_by_func(): - items = groupby(list(RENDERERS.items()), lambda x: x[1]) - out = {} - for x, g in items: - out[x] = [p[0] for p in g] - return out - - -def parse_docstring(d): - out = [None, None, None] - if fs := re.search(r"flags:(.*?)$", d, re.M): - out[2] = [f.split("=") for f in fs.group(1).split(",")] - d = d.replace(fs.group(), "") - if b := re.search(r"bom:(.*?)$", d, re.M): - out[1] = b.group(1) - d = d.replace(b.group(), "") - out[0] = d.strip() - return out +import textwrap + +from scriptutils import spit, spy + +# flake8: noqa: E402 + +from schemascii.component import Component +from schemascii.data import Data +from schemascii.data_consumer import _NOT_SET as NO_DEFAULT +from schemascii.data_consumer import Option + + +def output_file(filename: os.PathLike, heading: str, content: str): + spit(filename, textwrap.dedent(f""" + # {heading} + + + """) + content) + + +def format_option(opt: Option) -> str: + typename = (opt.type.__name__ + if isinstance(opt.type, type) + else " or ".join(f"`{z!s}`" for z in opt.type)) + items: list[str] = [opt.name, typename, opt.help, "(required)"] + if opt.default is not NO_DEFAULT: + items[-1] = repr(opt.default) if opt.default is not None else "(no value)" + return f"| {' | '.join(items)} |\n" + + +def format_scope(scopename: str, options: list[Option], + interj: str, head: str) -> str: + scope_text = textwrap.dedent(f""" + ## {head} `{scopename.upper()}`%%%INTERJ%%% + + | Option | Value | Description | Default | + |:------:|:-----:|:------------|:-------:| + """) + for option in options: + scope_text += format_option(option) + scope_text = scope_text.replace("%%%INTERJ%%%", "\n\n" + interj if interj else "") + return scope_text + + +def get_RDs(scopename: str) -> list[str] | None: + out = [] + tgt_list = Data.available_options[scopename] + for component in Component.all_components: + if Component.all_components[component].options == tgt_list: + out.append(component) + if out: + return out + return None def main(): - content = TOP - for func, rds in group_components_by_func().items(): - data = parse_docstring(func.__doc__) - content += "| " + ", ".join(f"`{x}`" for x in rds) + " | " - content += data[0].replace("\n", "
") + " | " - content += "`" + data[1] + "` | " - content += "
".join(f"`{x[0]}` = {x[1]}" for x in (data[2] or [])) - content += " |\n" - with open("supported-components.md", "w") as f: - f.write(content) + content = "" + for s, d in Data.available_options.items(): + rds_line = "" + heading = "Scope" + if (rds := get_RDs(s)) is not None: + heading = "Component" + rds_line = "Reference Designators: " + rds_line += ", ".join(f"`{x}`" for x in rds) + content += format_scope(s, d, rds_line, heading) + + output_file("options.md", "Data Section Options", content) if __name__ == '__main__': diff --git a/scripts/release.py b/scripts/release.py index 2827333..144cba8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,28 +1,11 @@ #! /usr/bin/env python3 import argparse -import os import re import sys -# pylint: disable=unspecified-encoding,missing-function-docstring - - -def cmd(sh_line): - print(sh_line) - if code := os.system(sh_line): - print("*** Error", code, file=sys.stderr) - sys.exit(code) - - -def slurp(file): - with open(file) as f: - return f.read() - - -def spit(file, text): - with open(file, "w") as f: - f.write(text) +from scriptutils import cmd, slurp, spit +# pylint: disable=unspecified-encoding a = argparse.ArgumentParser() a.add_argument("version", help="release tag") diff --git a/scripts/scriptutils.py b/scripts/scriptutils.py new file mode 100644 index 0000000..bb392f2 --- /dev/null +++ b/scripts/scriptutils.py @@ -0,0 +1,30 @@ +import os +import sys +import typing + +# pylint: disable=missing-function-docstring + +T = typing.TypeVar("T") + + +def cmd(sh_line: str, say: bool = True): + if say: + print(sh_line) + if code := os.system(sh_line): + print("*** Error", code, file=sys.stderr) + sys.exit(code) + + +def slurp(file: os.PathLike) -> str: + with open(file) as f: + return f.read() + + +def spit(file: os.PathLike, text: str): + with open(file, "w") as f: + f.write(text) + + +def spy(value: T) -> T: + print(value) + return value diff --git a/supported-components.md b/supported-components.md deleted file mode 100644 index 7138a45..0000000 --- a/supported-components.md +++ /dev/null @@ -1,22 +0,0 @@ -# Supported Schemascii Components - - - -| Reference Designators | Description | BOM Syntax | Supported Flags | -|:--:|:--|:--:|:--| -| `R`, `RV`, `VR` | Resistor, Variable resistor, etc. | `ohms[,watts]` | | -| `C`, `CV`, `VC` | Draw a capacitor, variable capacitor, etc. | `farads[,volts]` | `+` = positive | -| `L`, `VL`, `LV` | Draw an inductor (coil, choke, etc) | `henries` | | -| `B`, `BT`, `BAT` | Draw a battery cell. | `volts[,amp-hours]` | `+` = positive | -| `D`, `LED`, `CR`, `IR` | Draw a diode or LED. | `part-number` | `+` = positive | -| `U`, `IC` | Draw an IC. | `part-number[,pin1-label[,pin2-label[,...]]]` | | -| `J`, `P` | Draw a jack connector or plug. | `label[,{circle/input/output}]` | | -| `Q`, `MOSFET`, `MOS`, `FET` | Draw a bipolar transistor (PNP/NPN) or FET (NFET/PFET). | `{npn/pnp/nfet/pfet}:part-number` | `s` = source
`d` = drain
`g` = gate
`e` = emitter
`c` = collector
`b` = base | -| `G`, `GND` | Draw a ground symbol. | `[{earth/chassis/signal/common}]` | | -| `S`, `SW`, `PB` | Draw a mechanical switch symbol. | `{nc/no}[m][:label]` | | From 398c9c562af6816dd504de3017ac30af32ea268c Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:08:43 -0500 Subject: [PATCH 074/101] docstring --- schemascii/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 9a318bb..f6fa415 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -99,7 +99,10 @@ def flood_walk( connections in the directions allowed by start_dirs and directions, and return the list of reached points. - Also updates the set seen for points that were walked into. + seen is the set of points that are already accounted for and should not be + walked into; the function updates the set seen for points that were + walked into. Thus, if this function is called twice with the same + arguments, the second call will always return nothing. """ points: list[complex] = [] stack: list[tuple[complex, list[complex]]] = [ From afe34bceb60a99f5d026de7b17b4df8e26e2807d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:09:11 -0500 Subject: [PATCH 075/101] squelch "security hole" warning, it isn't --- scripts/scriptutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scriptutils.py b/scripts/scriptutils.py index bb392f2..a0c419b 100644 --- a/scripts/scriptutils.py +++ b/scripts/scriptutils.py @@ -10,7 +10,7 @@ def cmd(sh_line: str, say: bool = True): if say: print(sh_line) - if code := os.system(sh_line): + if code := os.system(sh_line): # nosec start_process_with_a_shell print("*** Error", code, file=sys.stderr) sys.exit(code) From 4c0274bf42a024423107f14bab9641410a70d060 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:09:24 -0500 Subject: [PATCH 076/101] test test --- schemascii/drawing.py | 4 ++++ schemascii/refdes.py | 1 + schemascii/wire.py | 17 ++++++----------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 79c629b..9a0f44d 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -110,6 +110,10 @@ def render(self, data, scale: float, padding: float, **options) -> str: if __name__ == '__main__': import pprint + print("All components: ", end="") + pprint.pprint(_component.Component.all_components) + print("All namespaces: ", end="") + pprint.pprint(_data.Data.available_options) d = Drawing.from_file("test_data/stresstest.txt") pprint.pprint(d) print(d.to_xml_string()) diff --git a/schemascii/refdes.py b/schemascii/refdes.py index 1848f6d..633f44e 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -55,6 +55,7 @@ def short_name(self) -> str: BAT3V3 U3A Q1G1 + R.Heater GND """) rds = RefDes.find_all(gg) diff --git a/schemascii/wire.py b/schemascii/wire.py index 8f94373..de417f4 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -92,20 +92,15 @@ def is_wire_character(cls, ch: str) -> bool: x = _grid.Grid("", """ . - * -------------------------* - | | - *----------||||----* -------*----=foo> - | | - ----------- | - | | - -------*----------*---* - | | - *-----------------*---* - | + | [TODO: this loop-de-loop causes problems] + | [is it worth fixing?] +---------------------------------* + | | + | | + *-------------* . """.strip()) wire = Wire.get_from_grid(x, 2+4j, _wt.WireTag.find_all(x)) print(wire) x.spark(*wire.points) - print(wire.to_xml_string(scale=10, stroke_width=2, stroke="black")) From 36badb83f61872026013bb73fe8a92389c098f2a Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:10:10 -0500 Subject: [PATCH 077/101] add diode skeleton (need to finish) --- schemascii/components/diode.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 schemascii/components/diode.py diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py new file mode 100644 index 0000000..426b638 --- /dev/null +++ b/schemascii/components/diode.py @@ -0,0 +1,30 @@ +import schemascii.components as _c +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + + +class Diode(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, + ids=("D", "CR"), namespaces=(":diode",)): + options = [ + "inherit", + _dc.Option("voltage", str, "Maximum reverse voltage rating", None), + _dc.Option("current", str, "Maximum current rating", None) + ] + + @property + def value_format(self): + return [("voltage", "V", False), + ("current", "A", False)] + + def render(self, **options) -> str: + raise NotImplementedError + return (_utils.bunch_o_lines(lines, **options) + + (_utils.make_plus(self.terminals, mid, angle, **options) + if self.is_polarized else "") + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +class LED(Diode, ids=("LED", "IR"), namespaces=(":diode", ":led")): + def render(self, **options): + raise NotImplementedError From 44ddaad88e6032ebc1c4363794dbc41be2f460b7 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:12:31 -0500 Subject: [PATCH 078/101] add blob test file for SO question https://stackoverflow.com/q/79163581/23626926 --- test_data/blob_test.py | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test_data/blob_test.py diff --git a/test_data/blob_test.py b/test_data/blob_test.py new file mode 100644 index 0000000..0915a99 --- /dev/null +++ b/test_data/blob_test.py @@ -0,0 +1,57 @@ +import cmath + +s = """ +# +### +##### +####### +##### +### +# +""" +pts = [complex(c, r) + for (r, rt) in enumerate(s.splitlines()) + for (c, ch) in enumerate(rt) + if ch == "#"] + + +def centroid(pts: list[complex]) -> complex: + return sum(pts) / len(pts) + + +def sort_counterclockwise(pts: list[complex], + center: complex | None = None) -> list[complex]: + if center is None: + center = centroid(pts) + return sorted(pts, key=lambda p: cmath.phase(p - center)) + + +def perimeter(pts: list[complex]) -> list[complex]: + out = [] + for pt in pts: + for d in (-1, 1, -1j, 1j, -1+1j, 1+1j, -1-1j, 1-1j): + xp = pt + d + if xp not in pts: + out.append(pt) + break + return sort_counterclockwise(out, centroid(pts)) + + +def example(all_points: list[complex], scale: float = 20) -> str: + p = perimeter(all_points) + p.append(p[0]) + vbx = max(map(lambda x: x.real, p)) + 1 + vby = max(map(lambda x: x.imag, p)) + 1 + return f""" + + """ + + +print(example(pts)) From 02b9c3552c720a9247f1d3d9a44ba1980d7f8c46 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:48:28 -0500 Subject: [PATCH 079/101] simplify --- schemascii/utils.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index f6fa415..9de4268 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -310,8 +310,8 @@ def fix_number(n: float) -> str: class XMLClass: def __getattr__(self, tag: str) -> typing.Callable: - def mk_tag(*contents: str, **attrs: str) -> str: - out = f"<{tag} " + def mk_tag(*contents: str, **attrs: str | bool | float | int) -> str: + out = f"<{tag}" for k, v in attrs.items(): if v is False: continue @@ -323,8 +323,8 @@ def mk_tag(*contents: str, **attrs: str) -> str: # elif isinstance(v, str): # v = re.sub(r"\b\d+(\.\d+)\b", # lambda m: fix_number(float(m.group())), v) - out += f'{k.removesuffix("_").replace("__", "-")}="{v}" ' - out = out.rstrip() + ">" + "".join(contents) + out += f' {k.removesuffix("_").replace("__", "-")}="{v}"' + out = out + ">" + "".join(contents) return out + f"" return mk_tag @@ -393,15 +393,10 @@ def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: for p1, p2 in points: if p1 == p2: # Skip zero-length wires + # XXX: there shouldn't be any of these anymore? continue - if p1 not in seen: - seen[p1] = 1 - else: - seen[p1] += 1 - if p2 not in seen: - seen[p2] = 1 - else: - seen[p2] += 1 + seen[p1] = seen.get(p1, 0) + 1 + seen[p2] = seen.get(p2, 0) + 1 return [pt for pt, count in seen.items() if count > 3] From d492ce5938ea32432c90fef89ae200499a62ff1f Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:48:37 -0500 Subject: [PATCH 080/101] some more testing of blob algorithm --- test_data/blob_test.py | 106 +++++++++++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 31 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index 0915a99..b7c0e92 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -1,6 +1,7 @@ -import cmath +import collections as _collections -s = """ +strings = [ + """ # ### ##### @@ -8,44 +9,82 @@ ##### ### # -""" -pts = [complex(c, r) - for (r, rt) in enumerate(s.splitlines()) - for (c, ch) in enumerate(rt) - if ch == "#"] - +""", + """ +# +# +# +## +## +## +### +## +## +## +# +# +# +""", + """ +# +# +# +## +## +## +### +###### +######### +###### +### +## +## +## +# +# +# +""", + """ +# + # + # + # + # + # +#"""] -def centroid(pts: list[complex]) -> complex: - return sum(pts) / len(pts) +def sinker(pts: list[complex]) -> list[complex]: + last = None + out = [] + for p in pts: + if not last or last.real != p.real: + out.append(p) + last = p + out.append(p) + return out -def sort_counterclockwise(pts: list[complex], - center: complex | None = None) -> list[complex]: - if center is None: - center = centroid(pts) - return sorted(pts, key=lambda p: cmath.phase(p - center)) +def get_outline_points(pts: list[complex]) -> list[complex]: + by_y = _collections.defaultdict(list) + for p in pts: + by_y[p.imag].append(p.real) + left_side = sinker([complex(min(row), y) + for y, row in sorted(by_y.items())]) + right_side = sinker([complex(max(row), y) + for y, row in sorted(by_y.items(), reverse=True)]) -def perimeter(pts: list[complex]) -> list[complex]: - out = [] - for pt in pts: - for d in (-1, 1, -1j, 1j, -1+1j, 1+1j, -1-1j, 1-1j): - xp = pt + d - if xp not in pts: - out.append(pt) - break - return sort_counterclockwise(out, centroid(pts)) + return left_side + right_side def example(all_points: list[complex], scale: float = 20) -> str: - p = perimeter(all_points) - p.append(p[0]) - vbx = max(map(lambda x: x.real, p)) + 1 - vby = max(map(lambda x: x.imag, p)) + 1 + p = get_outline_points(all_points) + vbx = max(map(lambda x: x.real, p)) + vby = max(map(lambda x: x.imag, p)) return f""" + height="{vby * scale}"> str: """ -print(example(pts)) +for s in strings: + pts = [complex(c, r) + for (r, rt) in enumerate(s.splitlines()) + for (c, ch) in enumerate(rt) + if ch == "#"] + print(example(pts)) From 777a90360273eec31ccd30df2fd6c81828582a0e Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 6 Apr 2025 16:01:22 -0400 Subject: [PATCH 081/101] fix some typing and stuff --- schemascii/__main__.py | 19 +++++++++++++++++-- schemascii/annoline.py | 2 +- schemascii/annotation.py | 6 ++++-- schemascii/data.py | 29 ++++++++++++++--------------- schemascii/grid.py | 3 ++- schemascii/net.py | 2 +- schemascii/refdes.py | 2 ++ schemascii/utils.py | 2 ++ schemascii/wire_tag.py | 2 +- 9 files changed, 44 insertions(+), 23 deletions(-) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index 8dde9ec..b18b65f 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -19,6 +19,16 @@ def cli_main(): default=None, dest="out_file", help="Output SVG file. (default input file plus .svg)") + ap.add_argument( + "-w", + "--warnings-are-errors", + action="store_true", + help="Treat warnings as errors. (default: False)") + ap.add_argument( + "-v", + "--verbose", + action="store_true", + help="Print verbose logging output. (default: False)") # TODO: implement this add_config_arguments(ap) args = ap.parse_args() @@ -35,13 +45,18 @@ def cli_main(): print(type(err).__name__ + ":", err, file=sys.stderr) sys.exit(1) if captured_warnings: - for warn in captured_warnings: - print("warning:", warn.message, file=sys.stderr) + for warning in captured_warnings: + print("Warning:", warning.message, file=sys.stderr) + if args.warnings_are_errors: + print("Error: warnings were treated as errors", file=sys.stderr) + sys.exit(1) if args.out_file == "-": print(result_svg) else: with open(args.out_file, "w", encoding="utf-8") as out: out.write(result_svg) + if args.verbose: + print("Wrote SVG to", args.out_file) if __name__ == "__main__": diff --git a/schemascii/annoline.py b/schemascii/annoline.py index 6a1af95..076668b 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -73,7 +73,7 @@ def is_annoline_character(cls, ch: str) -> bool: def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]: """Return all of the annotation lines found in the grid.""" seen_points: set[complex] = set() - all_lines: list[cls] = [] + all_lines: list[AnnotationLine] = [] for y, line in enumerate(grid.lines): for x, ch in enumerate(line): diff --git a/schemascii/annotation.py b/schemascii/annotation.py index 0b22849..8c44152 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import html import re from dataclasses import dataclass @@ -24,9 +26,9 @@ class Annotation(_dc.DataConsumer, namespaces=(":annotation",)): css_class = "annotation" @classmethod - def find_all(cls, grid: _grid.Grid): + def find_all(cls, grid: _grid.Grid) -> list[Annotation]: """Return all of the text annotations present in the grid.""" - out: list[cls] = [] + out: list[Annotation] = [] for y, line in enumerate(grid.lines): for match in ANNOTATION_RE.finditer(line): x = match.span()[0] diff --git a/schemascii/data.py b/schemascii/data.py index 8256436..3da2c66 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -28,14 +28,14 @@ class Section(dict): """Section of data relevant to one portion of the drawing.""" header: str - data: dict + data: dict[str, typing.Any] - def __getitem__(self, key): + def __getitem__(self, key: str): return self.data[key] - def matches(self, name) -> bool: + def matches(self, name: str) -> bool: """True if self.header matches the name.""" - return fnmatch.fnmatch(name, self.header) + return fnmatch.fnmatch(name.lower(), self.header.lower()) @dataclass @@ -80,22 +80,22 @@ def parse_from_string(cls, text: str, startline=1, filename="") -> Data: def complain(msg): raise _errors.DiagramSyntaxError( f"{filename} line {line+startline}: {msg}\n" - f" {lines[line]}\n" - f" {' ' * col}{'^'*len(look())}".lstrip()) + f"{line} | {lines[line]}\n" + f"{' ' * len(str(line))} | {' ' * col}{'^'*len(look())}") def complain_eof(): restore(lastsig) skip_space(True) if index >= len(tokens): complain("unexpected EOF") - complain("cannot parse after this") + complain("unknown parse error") - def look(): + def look() -> str: if index >= len(tokens): return "\0" return tokens[index] - def eat(): + def eat() -> str: nonlocal line nonlocal col nonlocal index @@ -150,7 +150,7 @@ def skip_i(newlines: bool = True): if not skip_space(): return - def expect(expected: set[str]): + def expect_and_eat(expected: set[str]): got = look() if got in expected: eat() @@ -169,7 +169,7 @@ def parse_section() -> Section: # print("** starting section", repr(name)) mark_used() skip_i() - expect({"{"}) + expect_and_eat({"{"}) data = {} while look() != "}": data |= parse_kv_pair() @@ -177,7 +177,7 @@ def parse_section() -> Section: skip_i() return Section(name, data) - def parse_kv_pair() -> dict: + def parse_kv_pair() -> dict[str, int | float | str]: skip_i() if look() == "}": # handle case of ";}" @@ -187,7 +187,7 @@ def parse_kv_pair() -> dict: key = eat() mark_used() skip_i() - expect({"="}) + expect_and_eat({"="}) skip_space() expect_not(SPECIAL) value = "" @@ -214,7 +214,7 @@ def parse_kv_pair() -> dict: pass # don't eat the ending "}" if look() != "}": - expect({"\n", ";"}) + expect_and_eat({"\n", ";"}) # print("*** got KV", repr(key), repr(value)) return {key: value} @@ -263,4 +263,3 @@ def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data: my_data = Data.parse_from_string(text) pprint.pprint(my_data) pprint.pprint(my_data.get_values_for("R1")) - print(my_data.getopt("R1", "foo")) diff --git a/schemascii/grid.py b/schemascii/grid.py index 7b4f569..c1bb30f 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -75,7 +75,7 @@ def clip(self, p1: complex, p2: complex): def shrink(self): """Shrinks self so that there is not any space between the edges and - the next non-printing character. Takes masks into account. + the next non-whitespace character. Takes masks into account. """ # clip the top lines while all(self.get(complex(x, 0)).isspace() @@ -95,6 +95,7 @@ def shrink(self): this_indent = len(line) - len(line.lstrip()) min_indent = min(min_indent, this_indent) # chop the space + # TODO: for left and right, need to take into account the mask array if min_indent > 0: self.width -= min_indent for line in self.data: diff --git a/schemascii/net.py b/schemascii/net.py index 29429f0..953f183 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -21,7 +21,7 @@ def find_all(cls, grid: _grid.Grid) -> list[Net]: """Return a list of all the wire nets found on the grid. """ seen_points: set[complex] = set() - all_nets: list[cls] = [] + all_nets: list[Net] = [] all_tags = _wt.WireTag.find_all(grid) for y, line in enumerate(grid.lines): diff --git a/schemascii/refdes.py b/schemascii/refdes.py index 633f44e..86b5b40 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -56,6 +56,8 @@ def short_name(self) -> str: U3A Q1G1 R.Heater + ^ + this one is invalid; only the "R" and "H" are gotten GND """) rds = RefDes.find_all(gg) diff --git a/schemascii/utils.py b/schemascii/utils.py index 9de4268..2d1c45b 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -70,6 +70,8 @@ def from_phase(cls, pt: complex) -> Side: """Return the side that is closest to pt, if it is interpreted as a vector originating from the origin. """ + # TODO: fix this so it compares the components, + # instead of this distance mess ops = { -pi: Side.LEFT, pi: Side.LEFT, diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index 6ba65be..ef2eeaa 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -33,7 +33,7 @@ class WireTag(_dc.DataConsumer, namespaces=(":wire-tag",)): @classmethod def find_all(cls, grid: _grid.Grid) -> list[WireTag]: """Find all of the wire tags present in the grid.""" - out: list[cls] = [] + out: list[WireTag] = [] for y, line in enumerate(grid.lines): for match in WIRE_TAG_PAT.finditer(line): left_grp, right_grp = match.groups() From 7ac546d98e89ba2de45115f1a337324b1ecfa99c Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 6 Apr 2025 16:01:36 -0400 Subject: [PATCH 082/101] new algorithm idea for U --- test_data/blob_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index b7c0e92..adbf3b1 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -53,6 +53,20 @@ # #"""] +""" +idea for new algorithm +* find all of the edge points +* sort them by clockwise order around the perimeter +* handle the shapes that have holes in them by treating + each edge as a separate shape, then merging the svgs +* find all of the points that are concave +* find all of the straight line points +* assign each straight line point a distance from the nearest + concave point +* remove the concave points and straight line points that are closer + than a threshold +""" + def sinker(pts: list[complex]) -> list[complex]: last = None From 0df2c5b438cb951848d340b40ff4bd07f57038ba Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 00:09:26 -0400 Subject: [PATCH 083/101] some work on the blob algorithm --- test_data/blob_test.py | 221 +++++++++++++++++++++++++++++++++++------ 1 file changed, 193 insertions(+), 28 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index adbf3b1..d7b82ed 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -1,4 +1,4 @@ -import collections as _collections +import enum strings = [ """ @@ -51,12 +51,28 @@ # # # -#"""] +#""", + """ + ########### + ############# +############### +#### #### +#### #### +#### #### +#### #### +############### + ############# + ###########"""] """ idea for new algorithm * find all of the edge points * sort them by clockwise order around the perimeter + * this is done not by sorting by angle around the centroid + (that would only work for convex shapes) but by forming a list + of edges between adjacent points and then walking the graph + using a direction vector that is rotated only clockwise + and starting from the rightmost point and starting down * handle the shapes that have holes in them by treating each edge as a separate shape, then merging the svgs * find all of the points that are concave @@ -64,50 +80,199 @@ * assign each straight line point a distance from the nearest concave point * remove the concave points and straight line points that are closer - than a threshold + than a threshold and are not a local maximum of distance """ +VN_DIRECTIONS: list[complex] = [1, 1j, -1, -1j] +DIRECTIONS: list[complex] = [1, 1+1j, 1j, -1+1j, -1, -1-1j, -1j, 1-1j] -def sinker(pts: list[complex]) -> list[complex]: - last = None - out = [] - for p in pts: - if not last or last.real != p.real: - out.append(p) - last = p - out.append(p) + +class VertexType(enum.Enum): + STRAIGHT = 1 + CONCAVE = 2 + CONVEX = 3 + + +def rot(d: complex, n: int) -> complex: + # 1 step is 45 degrees + return DIRECTIONS[(DIRECTIONS.index(d) + n + len(DIRECTIONS)) + % len(DIRECTIONS)] + + +def points_to_edges( + edge_points: list[complex]) -> dict[complex, set[complex]]: + # find the edge points + edges: dict[complex, set[complex]] = {} + for p in edge_points: + edges[p] = set(p2 for p2 in edge_points + if p2 != p and abs(p2 - p) < 1.5) + return edges + + +def cir(list: list[complex], is_forward: bool) -> list[complex]: + if is_forward: + return list[1:] + [list[0]] + else: + return [list[-1]] + list[:-1] + + +def cull_disallowed_edges( + all_points: list[complex], + edges: dict[complex, set[complex]]) -> dict[complex, list[set]]: + # each point can only have 1 to 3 unique directions coming out of it + # if there are more, find the "outside" direction and remove the inner + # links + fixed_edges: dict[complex, set[complex]] = {} + for p1, conn in edges.items(): + if len(conn) <= 2: + fixed_edges[p1] = conn.copy() + continue + # if there are multiple directions out of here, find the gaps and + # only keep the ones on the sides of the gaps + gaps = [p1 + d in all_points for d in DIRECTIONS] + tran_5 = list(zip( + cir(cir(gaps, False), False), + cir(gaps, False), + gaps, + cir(gaps, True), + cir(cir(gaps, True), True))) + # im not quite sure what this is doing + fixed_edges[p1] = set(p1 + d for (d, (q, a, b, c, w)) + in zip(DIRECTIONS, tran_5) + if b and not ((a and c) or (q and w))) + return fixed_edges + + +def walk_graph_to_loop( + start: complex, + start_dir: complex, + edges: dict[complex, set[complex]]) -> list[complex]: + out: list[complex] = [] + current = start + current_dir = start_dir + swd_into: dict[complex, set[complex]] = {} + while not out or current != start: + out.append(current) + print(debug_singular_polyline_in_svg(out, current)) + # log the direction we came from + swd_into.setdefault(current, set()).add(current_dir) + # prefer counterclockwise (3 directions), + # then clockwise (3 directions), then forwards, then backwards + choices_directions = (rot(current_dir, i) + for i in (0, 1, -1, 2, -2, 3, -3, 4)) + bt_d = None + for d in choices_directions: + # if allowed to walk that direction + nxt = current + d + if nxt in edges[current]: + if nxt not in swd_into.keys(): + # if we haven't been there before, go there + print("go new place") + current = nxt + current_dir = d + break + # otherwise, if we've been there before, but haven't + # come from this direction, then save it for later + if d not in swd_into.get(nxt, set()) and bt_d is not None: + bt_d = d + print("saving", d) + else: + if bt_d is not None: + # if we have a saved direction to go, go that way + current = current + bt_d + current_dir = bt_d + else: + raise RuntimeError + print("finished normally") return out -def get_outline_points(pts: list[complex]) -> list[complex]: - by_y = _collections.defaultdict(list) +def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: + edges = cull_disallowed_edges(all_pts, points_to_edges(g)) + print(dots(g, edges)) + g = walk_graph_to_loop( + start=max(g, key=lambda x: x.real * 65536 - x.imag), + start_dir=1j, + edges=edges) + return g + + +def get_outline_points(pts: list[complex]) -> list[list[complex]]: + # find the edge points + edge_points: list[complex] = [] for p in pts: - by_y[p.imag].append(p.real) - left_side = sinker([complex(min(row), y) - for y, row in sorted(by_y.items())]) - right_side = sinker([complex(max(row), y) - for y, row in sorted(by_y.items(), reverse=True)]) + if not all(p + d in pts for d in DIRECTIONS) and not all( + p + d in pts for d in VN_DIRECTIONS): + edge_points.append(p) + # find all of the disconnected loop groups + loop_groups: list[list[complex]] = [] + while edge_points: + current_group = [edge_points.pop()] + while True: + for p in edge_points: + if any(ep + d == p + for ep in current_group + for d in DIRECTIONS): + current_group.append(p) + edge_points.remove(p) + break + else: + break + loop_groups.append(current_group) + # process each group + return [process_group(g, pts) for g in loop_groups] + + +def dots(points: list[complex], edges: dict[complex, list[complex]], + scale: float = 20) -> str: + vbx = max(x.real for x in points) + 1 + vby = max(x.imag for x in points) + 1 + return f"""{"".join(f"""""" for p in points)}{ + "".join(f"""""" + for p1 in edges for p2 in edges[p1])}""" + - return left_side + right_side +def debug_singular_polyline_in_svg( + points: list[complex], current: complex, scale: float = 20) -> str: + vbx = max(x.real for x in points) + 1 + vby = max(x.imag for x in points) + 1 + return f""" + """ def example(all_points: list[complex], scale: float = 20) -> str: - p = get_outline_points(all_points) - vbx = max(map(lambda x: x.real, p)) - vby = max(map(lambda x: x.imag, p)) - return f""" - - """ +
""" for p in ps) + return f""" + {polylines}""" for s in strings: + print(f"""
{s}
""") pts = [complex(c, r) for (r, rt) in enumerate(s.splitlines()) for (c, ch) in enumerate(rt) From 339f7b87898e1d06facd0e53dcd91e659bca76ab Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 08:24:06 -0400 Subject: [PATCH 084/101] one character fix --- schemascii/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemascii/utils.py b/schemascii/utils.py index 2d1c45b..6f109bf 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -350,7 +350,7 @@ def fix(number: float) -> float | int: def pad(number: float | int) -> str: if number < 0: return str(number) - return " " + str(number) + return " " + str(number).removeprefix("0") if not points: return "z" From 906accb3d16720ca0f9d613e632ec3b567739bb9 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 08:24:26 -0400 Subject: [PATCH 085/101] fix perimeter walking algorithm --- test_data/blob_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index d7b82ed..797b185 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -151,7 +151,7 @@ def walk_graph_to_loop( current = start current_dir = start_dir swd_into: dict[complex, set[complex]] = {} - while not out or current != start: + while not all(e in out for e in edges) or current != start: out.append(current) print(debug_singular_polyline_in_svg(out, current)) # log the direction we came from @@ -173,7 +173,7 @@ def walk_graph_to_loop( break # otherwise, if we've been there before, but haven't # come from this direction, then save it for later - if d not in swd_into.get(nxt, set()) and bt_d is not None: + if d not in swd_into.get(nxt, set()) and bt_d is None: bt_d = d print("saving", d) else: @@ -271,6 +271,7 @@ def example(all_points: list[complex], scale: float = 20) -> str: {polylines}""" +print("") for s in strings: print(f"""
{s}
""") pts = [complex(c, r) From 206e8c9f79a8f520f78feaa11980ce895f651957 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 08:28:36 -0400 Subject: [PATCH 086/101] use topleft for all --- test_data/blob_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index 797b185..a118605 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -191,7 +191,7 @@ def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: edges = cull_disallowed_edges(all_pts, points_to_edges(g)) print(dots(g, edges)) g = walk_graph_to_loop( - start=max(g, key=lambda x: x.real * 65536 - x.imag), + start=min(g, key=lambda x: x.real * 65536 + x.imag), start_dir=1j, edges=edges) return g From abbfd585b58d2493a33173a12ba0cd89b4dbb8be Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:04:47 -0400 Subject: [PATCH 087/101] enable type checking --- .vscode/settings.json | 4 +--- test_data/blob_test.py | 12 +++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 45c5ff3..893d7b4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,5 @@ "schemascii", "tspan" ], - "python.linting.pylintEnabled": false, - "python.linting.flake8Enabled": false, - "python.linting.enabled": true + "python.analysis.typeCheckingMode": "strict" } \ No newline at end of file diff --git a/test_data/blob_test.py b/test_data/blob_test.py index a118605..fb9045f 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -1,4 +1,5 @@ import enum +import typing strings = [ """ @@ -109,7 +110,10 @@ def points_to_edges( return edges -def cir(list: list[complex], is_forward: bool) -> list[complex]: +T = typing.TypeVar("T") + + +def cir(list: list[T], is_forward: bool) -> list[T]: if is_forward: return list[1:] + [list[0]] else: @@ -118,7 +122,7 @@ def cir(list: list[complex], is_forward: bool) -> list[complex]: def cull_disallowed_edges( all_points: list[complex], - edges: dict[complex, set[complex]]) -> dict[complex, list[set]]: + edges: dict[complex, set[complex]]) -> dict[complex, set[complex]]: # each point can only have 1 to 3 unique directions coming out of it # if there are more, find the "outside" direction and remove the inner # links @@ -156,8 +160,6 @@ def walk_graph_to_loop( print(debug_singular_polyline_in_svg(out, current)) # log the direction we came from swd_into.setdefault(current, set()).add(current_dir) - # prefer counterclockwise (3 directions), - # then clockwise (3 directions), then forwards, then backwards choices_directions = (rot(current_dir, i) for i in (0, 1, -1, 2, -2, 3, -3, 4)) bt_d = None @@ -223,7 +225,7 @@ def get_outline_points(pts: list[complex]) -> list[list[complex]]: return [process_group(g, pts) for g in loop_groups] -def dots(points: list[complex], edges: dict[complex, list[complex]], +def dots(points: list[complex], edges: dict[complex, set[complex]], scale: float = 20) -> str: vbx = max(x.real for x in points) + 1 vby = max(x.imag for x in points) + 1 From 04ebdce89688984873dd3974bb06237825c9fdad Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:47:42 -0400 Subject: [PATCH 088/101] it is working nice!! --- .vscode/settings.json | 3 +- test_data/blob_test.py | 88 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 893d7b4..a199b49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,5 @@ "rendec", "schemascii", "tspan" - ], - "python.analysis.typeCheckingMode": "strict" + ] } \ No newline at end of file diff --git a/test_data/blob_test.py b/test_data/blob_test.py index fb9045f..691d7e8 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -13,6 +13,15 @@ """, """ # +###### +########### +################ +########### +###### +# +""", + """ +# # # ## @@ -120,6 +129,10 @@ def cir(list: list[T], is_forward: bool) -> list[T]: return [list[-1]] + list[:-1] +def triples(v: list[T]) -> list[tuple[T, T, T]]: + return list(zip(cir(v, False), v, cir(v, True))) + + def cull_disallowed_edges( all_points: list[complex], edges: dict[complex, set[complex]]) -> dict[complex, set[complex]]: @@ -161,7 +174,7 @@ def walk_graph_to_loop( # log the direction we came from swd_into.setdefault(current, set()).add(current_dir) choices_directions = (rot(current_dir, i) - for i in (0, 1, -1, 2, -2, 3, -3, 4)) + for i in (-1, 1, -2, 2, -3, 3, 0, 4)) bt_d = None for d in choices_directions: # if allowed to walk that direction @@ -189,6 +202,74 @@ def walk_graph_to_loop( return out +def remove_unnecessary(pts: list[complex], + edges, maxslope=2) -> list[complex]: + triples_pts = list(triples(pts)) + dirs = [(b-a, c-b) for a, b, c in triples_pts] + signos = [a.real * b.imag - a.imag * b.real + + (abs(b - a) if a == b or a == -b else 0) for a, b in dirs] + # signos: 0 if straightline, negative if concave, positive if convex + distances = [None if s >= 0 else 0 for s in signos] + # distances: None if it's a convex or straight un-analyzed, + # number if it's a concave or counted straight + + # there ought to be a better way to do this + changing = True + while changing: + changing = False + for j in range(len(distances)): + i = j - 1 + k = (j + 1) % len(distances) + iNone = distances[i] is None + jNone = distances[j] is None + kNone = distances[k] is None + if jNone and signos[j] == 0: + if kNone and iNone: + continue + changing = True + if kNone: + distances[j] = distances[i] + 1 + elif iNone: + distances[j] = distances[k] + 1 + else: + distances[j] = min(distances[i], distances[k]) + 1 + # at this point, distances should contain: + # None for the convex points + # numbers for all others + points_to_keep = set(p for p, s, (a, b, c) in zip( + pts, signos, triples(distances)) + if (b is None and s != 0) # keep all convex points + or ((a is not None and a < b) # keep all the local maxima + and (c is not None and c < b)) + or (b is not None and b > maxslope)) # keep ones that are flat enough + assert len(points_to_keep) > 0 + # for debugging + a = dots([], edges) + i = a.replace("", "".join(f""" 0 + else "blue" if r < 0 + else "black" + }" opacity="50%">""" + for p, q, r in zip(pts, distances, signos)) + "") + z = a.replace("", "".join(f"""""" + for p in points_to_keep) + "") + print("begin conc", i, "end conc") + print("begin keep", z, "end keep") + # end debugging + return [p for p in pts if p in points_to_keep] + + def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: edges = cull_disallowed_edges(all_pts, points_to_edges(g)) print(dots(g, edges)) @@ -196,6 +277,7 @@ def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: start=min(g, key=lambda x: x.real * 65536 + x.imag), start_dir=1j, edges=edges) + g = remove_unnecessary(g, edges) return g @@ -227,8 +309,8 @@ def get_outline_points(pts: list[complex]) -> list[list[complex]]: def dots(points: list[complex], edges: dict[complex, set[complex]], scale: float = 20) -> str: - vbx = max(x.real for x in points) + 1 - vby = max(x.imag for x in points) + 1 + vbx = max(x.real for x in (*points, *edges)) + 1 + vby = max(x.imag for x in (*points, *edges)) + 1 return f"""{"".join(f""" list[tuple[T, T, T]]: return list(zip(cir(v, False), v, cir(v, True))) +def fiveles(v: list[T]) -> list[tuple[T, T, T]]: + x, y, z = cir(v, False), v, cir(v, True) + return list(zip(cir(x, False), x, y, z, cir(z, True))) + + def cull_disallowed_edges( all_points: list[complex], edges: dict[complex, set[complex]]) -> dict[complex, set[complex]]: @@ -147,16 +167,18 @@ def cull_disallowed_edges( # if there are multiple directions out of here, find the gaps and # only keep the ones on the sides of the gaps gaps = [p1 + d in all_points for d in DIRECTIONS] - tran_5 = list(zip( - cir(cir(gaps, False), False), - cir(gaps, False), - gaps, - cir(gaps, True), - cir(cir(gaps, True), True))) + tran_5 = fiveles(gaps) # im not quite sure what this is doing fixed_edges[p1] = set(p1 + d for (d, (q, a, b, c, w)) in zip(DIRECTIONS, tran_5) if b and not ((a and c) or (q and w))) + # ensure there are no one way "trap" edges + # (the above algorithm has some weird edge cases where it may produce + # one-way edges on accident) + # XXX This causes issues when it is enabled, why? + for p1 in all_points: + for p2 in fixed_edges.setdefault(p1, set()): + fixed_edges.setdefault(p2, set()).add(p1) return fixed_edges @@ -174,7 +196,7 @@ def walk_graph_to_loop( # log the direction we came from swd_into.setdefault(current, set()).add(current_dir) choices_directions = (rot(current_dir, i) - for i in (-1, 1, -2, 2, -3, 3, 0, 4)) + for i in (-1, -2, -3, 0, 1, 2, 3, 4)) bt_d = None for d in choices_directions: # if allowed to walk that direction @@ -202,13 +224,21 @@ def walk_graph_to_loop( return out +def is_mid_maxima(a: int | None, b: int | None, c: int | None) -> bool: + return all(x is not None for x in (a, b, c)) and a < b and c < b + + def remove_unnecessary(pts: list[complex], - edges, maxslope=2) -> list[complex]: + edges: dict[complex, set[complex]], + maxslope=2) -> list[complex]: triples_pts = list(triples(pts)) dirs = [(b-a, c-b) for a, b, c in triples_pts] signos = [a.real * b.imag - a.imag * b.real + (abs(b - a) if a == b or a == -b else 0) for a, b in dirs] # signos: 0 if straightline, negative if concave, positive if convex + dotnos = [a.real * b.real + a.imag * b.imag for a, b in dirs] + # dotnos: a measure of pointedness - 0 = right angle, positive = pointy, + # negative = blunt distances = [None if s >= 0 else 0 for s in signos] # distances: None if it's a convex or straight un-analyzed, # number if it's a concave or counted straight @@ -236,46 +266,71 @@ def remove_unnecessary(pts: list[complex], # at this point, distances should contain: # None for the convex points # numbers for all others - points_to_keep = set(p for p, s, (a, b, c) in zip( - pts, signos, triples(distances)) - if (b is None and s != 0) # keep all convex points - or ((a is not None and a < b) # keep all the local maxima - and (c is not None and c < b)) - or (b is not None and b > maxslope)) # keep ones that are flat enough - assert len(points_to_keep) > 0 + maxima = [is_mid_maxima(a, b, c) for (a, b, c) in triples(distances)] + points_to_maybe_discard = set( + pt for (pt, dist, maxima) in zip(pts, distances, maxima) + # keep all the local maxima + # keep ones that are flat enough + # --> remove the ones that are not sloped enough + # and are not local maxima + if ((dist is not None and dist < maxslope) + and not maxima)) + # the ones to definitely keep are the convex ones + # as well as concave ones that are adjacent to only straight ones that + # are being deleted + points_to_def_keep = set(p for p, s in zip(pts, signos) + if s > 0 + or (s < 0 and all( + signos[z := pts.index(q)] == 0 + and q in points_to_maybe_discard + for q in edges[p]))) + # special case: keep concave ones that are 2-near at + # least one convex pointy point (where pointy additionally means that + # it isn't a 180) + points_to_def_keep |= set( + p for ( + p, + (dot_2l, _, _, _, dot_2r), + (sig_2l, _, sig_m, _, sig_2r), + ((dd1_2l, dd2_2l), _, _, _, (dd1_2r, dd2_2r)) + ) in zip(pts, fiveles(dotnos), fiveles(signos), fiveles(dirs)) + if sig_m < 0 and ( + (sig_2l > 0 and dot_2l < 0 and dd1_2l != -dd2_2l) + or (sig_2r > 0 and dot_2r < 0 and dd1_2r != -dd2_2r))) # for debugging a = dots([], edges) i = a.replace("", "".join(f""" 0 else "blue" if r < 0 else "black" }" opacity="50%">""" - for p, q, r in zip(pts, distances, signos)) + "") + for p, q, r in zip(pts, distances, dotnos)) + "") z = a.replace("", "".join(f"""""" - for p in points_to_keep) + "") + for p in (points_to_maybe_discard - points_to_def_keep)) + "") print("begin conc", i, "end conc") - print("begin keep", z, "end keep") + print("begin discard", z, "end discard") # end debugging - return [p for p in pts if p in points_to_keep] + return [p for p in pts + if p not in points_to_maybe_discard + or p in points_to_def_keep] def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: edges = cull_disallowed_edges(all_pts, points_to_edges(g)) print(dots(g, edges)) + start = min(g, key=lambda x: x.real * 65536 + x.imag) + next = min(edges[start], key=lambda x: x.real * 65536 - x.imag) g = walk_graph_to_loop( - start=min(g, key=lambda x: x.real * 65536 + x.imag), - start_dir=1j, + start=start, + start_dir=next - start, edges=edges) g = remove_unnecessary(g, edges) return g From c7c4f5bb5544cf756c3554c2c90b2cc9c89077dd Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 19:22:13 -0400 Subject: [PATCH 090/101] not used --- test_data/blob_test.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index 7498c1b..1377d4e 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -112,12 +112,6 @@ DIRECTIONS: list[complex] = [1, 1+1j, 1j, -1+1j, -1, -1-1j, -1j, 1-1j] -class VertexType(enum.Enum): - STRAIGHT = 1 - CONCAVE = 2 - CONVEX = 3 - - def rot(d: complex, n: int) -> complex: # 1 step is 45 degrees return DIRECTIONS[(DIRECTIONS.index(d) + n + len(DIRECTIONS)) From df69da4329c2c5e1fac52b510ffe3b2e46186227 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 19:57:34 -0400 Subject: [PATCH 091/101] add many more test cases + tweaks --- test_data/blob_test.py | 138 ++++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 44 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index 1377d4e..3d4ef6c 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -1,8 +1,21 @@ -import enum import typing strings = [ """ +######################### +######################### +######################### +######################### +######################### +#########################""", + """ +######################### +######################### + ############# +######################### +######################### +""", + """ # ### ##### @@ -87,7 +100,42 @@ """ ############### ################### -###############"""] +###############""", + """ +################ + ################ +################ + ################ +################ + ################ +################ + ################""", + """ +# # # # # # # # +################ +################ +################ +################ +################ + # # # # # # # #""", + """ + # + #### + ####### + ########## + ############# + ################ + ################### + ###################### + ######################### + ###################### + ################### + ################ + ############# + ########## + ####### + #### + #"""] """ idea for new algorithm @@ -170,9 +218,9 @@ def cull_disallowed_edges( # (the above algorithm has some weird edge cases where it may produce # one-way edges on accident) # XXX This causes issues when it is enabled, why? - for p1 in all_points: - for p2 in fixed_edges.setdefault(p1, set()): - fixed_edges.setdefault(p2, set()).add(p1) + for p, c in fixed_edges.items(): + for q in c: + fixed_edges.setdefault(q, set()).add(p) return fixed_edges @@ -186,7 +234,6 @@ def walk_graph_to_loop( swd_into: dict[complex, set[complex]] = {} while not all(e in out for e in edges) or current != start: out.append(current) - print(debug_singular_polyline_in_svg(out, current)) # log the direction we came from swd_into.setdefault(current, set()).add(current_dir) choices_directions = (rot(current_dir, i) @@ -198,7 +245,6 @@ def walk_graph_to_loop( if nxt in edges[current]: if nxt not in swd_into.keys(): # if we haven't been there before, go there - print("go new place") current = nxt current_dir = d break @@ -206,7 +252,6 @@ def walk_graph_to_loop( # come from this direction, then save it for later if d not in swd_into.get(nxt, set()) and bt_d is None: bt_d = d - print("saving", d) else: if bt_d is not None: # if we have a saved direction to go, go that way @@ -214,7 +259,8 @@ def walk_graph_to_loop( current_dir = bt_d else: raise RuntimeError - print("finished normally") + print("path clockwise is") + print(debug_singular_polyline_in_svg(out, current)) return out @@ -262,13 +308,14 @@ def remove_unnecessary(pts: list[complex], # numbers for all others maxima = [is_mid_maxima(a, b, c) for (a, b, c) in triples(distances)] points_to_maybe_discard = set( - pt for (pt, dist, maxima) in zip(pts, distances, maxima) + pt for pt, dist, maxima, pointy in zip(pts, distances, maxima, dotnos) # keep all the local maxima # keep ones that are flat enough # --> remove the ones that are not sloped enough # and are not local maxima if ((dist is not None and dist < maxslope) - and not maxima)) + and not maxima + and pointy != 0)) # the ones to definitely keep are the convex ones # as well as concave ones that are adjacent to only straight ones that # are being deleted @@ -281,7 +328,7 @@ def remove_unnecessary(pts: list[complex], # special case: keep concave ones that are 2-near at # least one convex pointy point (where pointy additionally means that # it isn't a 180) - points_to_def_keep |= set( + points_to_def_keep.update(set( p for ( p, (dot_2l, _, _, _, dot_2r), @@ -290,27 +337,33 @@ def remove_unnecessary(pts: list[complex], ) in zip(pts, fiveles(dotnos), fiveles(signos), fiveles(dirs)) if sig_m < 0 and ( (sig_2l > 0 and dot_2l < 0 and dd1_2l != -dd2_2l) - or (sig_2r > 0 and dot_2r < 0 and dd1_2r != -dd2_2r))) + or (sig_2r > 0 and dot_2r < 0 and dd1_2r != -dd2_2r)))) # for debugging a = dots([], edges) i = a.replace("", "".join(f""" 0 - else "blue" if r < 0 + pt.imag + }" r="{ + 0.5 if conc == 0 + else 0.8 if conc > 0 + else 0.2 + }" fill="{ + "red" if sharp > 0 + else "blue" if sharp < 0 else "black" }" opacity="50%">""" - for p, q, r in zip(pts, distances, dotnos)) + "") + for pt, sharp, conc in zip(pts, dotnos, signos)) + "") z = a.replace("", "".join(f"""""" for p in (points_to_maybe_discard - points_to_def_keep)) + "") - print("begin conc", i, "end conc") - print("begin discard", z, "end discard") + print("pointyness / concavity") + print(i) + print("will be discarded") + print(z) # end debugging return [p for p in pts if p not in points_to_maybe_discard @@ -332,11 +385,12 @@ def process_group(g: list[complex], all_pts: list[complex]) -> list[complex]: def get_outline_points(pts: list[complex]) -> list[list[complex]]: # find the edge points - edge_points: list[complex] = [] + edge_points: set[complex] = set() for p in pts: - if not all(p + d in pts for d in DIRECTIONS) and not all( - p + d in pts for d in VN_DIRECTIONS): - edge_points.append(p) + if not all(p + d in pts for d in DIRECTIONS): + if not all( + p + d in pts for d in VN_DIRECTIONS): + edge_points.add(p) # find all of the disconnected loop groups loop_groups: list[list[complex]] = [] while edge_points: @@ -376,32 +430,28 @@ def debug_singular_polyline_in_svg( points: list[complex], current: complex, scale: float = 20) -> str: vbx = max(x.real for x in points) + 1 vby = max(x.imag for x in points) + 1 - return f""" - """ + }" r="0.3" fill="blue" />""") def example(all_points: list[complex], scale: float = 20) -> str: ps = get_outline_points(all_points) vbx = max(x.real for p in ps for x in p) + 1 vby = max(x.imag for p in ps for x in p) + 1 - polylines = "".join(f""" - """ for p in ps) - return f""" - {polylines}""" + polylines = "".join("""""" for p in ps) + print("final") + return f"""{polylines}""" print("") From 82baa421ff2a03d8b2bf660e895962760b359efc Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 8 Apr 2025 22:54:43 -0400 Subject: [PATCH 092/101] fix cli args options --- schemascii/__init__.py | 14 +++++---- schemascii/__main__.py | 48 ++++++++++++++++++++++--------- schemascii/components/resistor.py | 4 +-- schemascii/data.py | 40 ++++++++++++++------------ 4 files changed, 68 insertions(+), 38 deletions(-) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 3099e4f..a60c4f0 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,24 +1,28 @@ import importlib import os +from typing import Any import schemascii.components as _c +import schemascii.data as _d import schemascii.drawing as _drawing __version__ = "0.3.2" def import_all_components(): - for f in os.scandir(os.path.dirname(_c.__file__)): - if f.is_file(): - importlib.import_module( - f"{_c.__package__}.{f.name.removesuffix('.py')}") + for root, _, files in os.walk(os.path.dirname(_c.__file__)): + for f in files: + if f.endswith(".py"): + importlib.import_module("." + f.removesuffix(".py"), + _c.__package__) import_all_components() del import_all_components -def render(filename: str, text: str | None = None, **options) -> str: +def render(filename: str, text: str | None = None, + options: dict[str, Any] | _d.Data = {}) -> str: """Render the Schemascii diagram to an SVG string.""" return _drawing.Drawing.from_file(filename, text).to_xml_string(options) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index b18b65f..fff9e77 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -3,34 +3,56 @@ import warnings import schemascii +import schemascii.data as _d import schemascii.errors as _errors +class DataFudgeAction(argparse.Action): + # from https://stackoverflow.com/a/78890058/23626926 + def __call__(self, parser, namespace, values: str, option_string=None): + scope, sep, pair = values.partition(".") + if not sep: + parser.error("invalid -D format: missing .") + key, sep, val = pair.partition("=") + if not sep: + parser.error("invalid -D format: missing =") + items = getattr(namespace, self.dest) or _d.Data([]) + items |= _d.Data( + [_d.Section(scope, {key: _d.parse_simple_value(val)})]) + setattr(namespace, self.dest, items) + + def cli_main(): ap = argparse.ArgumentParser( prog="schemascii", description="Render ASCII-art schematics into SVG.") ap.add_argument( "-V", "--version", action="version", version="%(prog)s " + schemascii.__version__) - ap.add_argument("in_file", help="File to process.") + ap.add_argument("-i", "--in", help="File to process. (default: stdin)", + dest="in_file", default="-") ap.add_argument( "-o", "--out", default=None, dest="out_file", - help="Output SVG file. (default input file plus .svg)") - ap.add_argument( - "-w", - "--warnings-are-errors", - action="store_true", - help="Treat warnings as errors. (default: False)") + help="Output SVG file. (default input file plus .svg, or stdout " + "if input is stdin)") ap.add_argument( - "-v", - "--verbose", + "-s", + "--strict", action="store_true", - help="Print verbose logging output. (default: False)") - # TODO: implement this - add_config_arguments(ap) + dest="warnings_are_errors", + help="Treat warnings as errors. (default: lax mode)") + ap.add_argument("-D", + dest="fudge", + metavar="SCOPE.KEY=VALUE", + action=DataFudgeAction, + help="Add a definition for diagram data. Data passed " + "on the command line will override any value specified " + "on the drawing itself. For example, -DR.wattage=0.25 to " + "make all resistors 1/4 watt. The wildcard in scope is @ " + "so as to not conflict with your shell.\n\nThis option " + "can be repeated as many times as necessary.") args = ap.parse_args() if args.out_file is None: args.out_file = args.in_file + ".svg" @@ -40,7 +62,7 @@ def cli_main(): args.in_file = "" try: with warnings.catch_warnings(record=True) as captured_warnings: - result_svg = schemascii.render(args.in_file, text, **vars(args)) + result_svg = schemascii.render(args.in_file, text, args.fudge) except _errors.Error as err: print(type(err).__name__ + ":", err, file=sys.stderr) sys.exit(1) diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py index 0a10803..454834a 100644 --- a/schemascii/components/resistor.py +++ b/schemascii/components/resistor.py @@ -26,14 +26,14 @@ class Resistor(_c.TwoTerminalComponent, _c.SimpleComponent, options = [ "inherit", _dc.Option("value", str, "Resistance in ohms"), - _dc.Option("power", str, "Maximum power dissipation in watts " + _dc.Option("wattage", str, "Maximum power dissipation in watts " "(i.e. size of the resistor)", None) ] @property def value_format(self): return [("value", "Ω", False, self.is_variable), - ("power", "W", False)] + ("wattage", "W", False)] def render(self, **options) -> str: t1, t2 = self.terminals[0].pt, self.terminals[1].pt diff --git a/schemascii/data.py b/schemascii/data.py index 3da2c66..a67b6a1 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -1,6 +1,5 @@ from __future__ import annotations -import fnmatch import re import typing from dataclasses import dataclass @@ -23,6 +22,21 @@ def tokenize(stuff: str) -> list[str]: return TOKEN_PAT.findall(stuff) +def parse_simple_value(value: str) -> str | float | int: + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + value = bytes(value, "utf-8").decode("unicode-escape") + else: + # try to make a number if possible + try: + temp = value + value = float(temp) + value = int(temp) + except ValueError: + pass + return value + + @dataclass class Section(dict): """Section of data relevant to one portion of the drawing.""" @@ -35,7 +49,7 @@ def __getitem__(self, key: str): def matches(self, name: str) -> bool: """True if self.header matches the name.""" - return fnmatch.fnmatch(name.lower(), self.header.lower()) + return re.match(re.escape(self.header).replace("@", ".+?"), name, re.I) @dataclass @@ -80,8 +94,8 @@ def parse_from_string(cls, text: str, startline=1, filename="") -> Data: def complain(msg): raise _errors.DiagramSyntaxError( f"{filename} line {line+startline}: {msg}\n" - f"{line} | {lines[line]}\n" - f"{' ' * len(str(line))} | {' ' * col}{'^'*len(look())}") + f"{line + 1} | {lines[line]}\n" + f"{' ' * len(str(line + 1))} | {' ' * col}{'^'*len(look())}") def complain_eof(): restore(lastsig) @@ -201,17 +215,7 @@ def parse_kv_pair() -> dict[str, int | float | str]: restore(here) if ahead in SPECIAL: break - if value.startswith('"') and value.endswith('"'): - value = value[1:-1] - value = bytes(value, "utf-8").decode("unicode-escape") - else: - # try to make a number if possible - try: - temp = value - value = float(temp) - value = int(temp) - except ValueError: - pass + value = parse_simple_value(value) # don't eat the ending "}" if look() != "}": expect_and_eat({"\n", ";"}) @@ -233,7 +237,7 @@ def get_values_for(self, namespace: str) -> dict: def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data: if isinstance(other, dict): - other = Data([Section("*", other)]) + other = Data([Section("@", other)]) if not isinstance(other, Data): return NotImplemented return Data(self.sections + other.sections) @@ -243,7 +247,7 @@ def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data: import pprint text = "" text = r""" -* { +@ { %% these are global config options color = black width = 2; padding = 20; @@ -252,7 +256,7 @@ def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data: } -R* {tolerance = .05; wattage = 0.25} +R@ {tolerance = .05; wattage = 0.25} R1 { resistance = 0 - 10k; From 7b1fc28413e02e096be3523bb6565260b8699bca Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:52:58 -0400 Subject: [PATCH 093/101] minor refactoring: make things that don't need to be lists into sets, move XMLClass to svg_utils file --- schemascii/__main__.py | 4 +- schemascii/annoline.py | 26 +++---- schemascii/annotation.py | 4 +- schemascii/component.py | 8 +-- schemascii/components/inductor.py | 6 +- schemascii/data_consumer.py | 4 +- schemascii/drawing.py | 18 ++--- schemascii/svg_utils.py | 59 ++++++++++++++++ schemascii/utils.py | 110 +++++++++--------------------- schemascii/wire.py | 9 +-- setup.py | 4 +- test_data/blob_test.py | 8 ++- test_data/stresstest.txt | 6 +- 13 files changed, 140 insertions(+), 126 deletions(-) create mode 100644 schemascii/svg_utils.py diff --git a/schemascii/__main__.py b/schemascii/__main__.py index fff9e77..fc978f3 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -28,8 +28,8 @@ def cli_main(): ap.add_argument( "-V", "--version", action="version", version="%(prog)s " + schemascii.__version__) - ap.add_argument("-i", "--in", help="File to process. (default: stdin)", - dest="in_file", default="-") + ap.add_argument("in_file", help="File to process. (default: stdin)", + default="-") ap.add_argument( "-o", "--out", diff --git a/schemascii/annoline.py b/schemascii/annoline.py index 076668b..52ab4ee 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -21,7 +21,7 @@ class AnnotationLine(_dc.DataConsumer, css_class = "annotation annotation-line" directions: typing.ClassVar[ - defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( + defaultdict[str, defaultdict[complex, set[complex]]]] = defaultdict( lambda: None, { # allow jumps over actual wires "-": _utils.IDENTITY, @@ -31,25 +31,25 @@ class AnnotationLine(_dc.DataConsumer, ":": _utils.IDENTITY, "~": _utils.IDENTITY, ".": { - -1: [1j, 1], - 1j: [], - -1j: [-1, 1], - 1: [1j, -1] + -1: {1j, 1}, + 1j: set(), + -1j: {-1, 1}, + 1: {1j, -1} }, "'": { - -1: [-1j, 1], - -1j: [], - 1j: [-1, 1], - 1: [-1j, -1] + -1: {-1j, 1}, + -1j: set(), + 1j: {-1, 1}, + 1: {-1j, -1} } }) start_dirs: typing.ClassVar[ - defaultdict[str, list[complex]]] = defaultdict( + defaultdict[str, set[complex]]] = defaultdict( lambda: None, { "~": _utils.LEFT_RIGHT, ":": _utils.UP_DOWN, - ".": (-1, 1, -1j), - "'": (-1, 1, 1j), + ".": {-1, 1, -1j}, + "'": {-1, 1, 1j}, }) # the sole member @@ -59,7 +59,7 @@ class AnnotationLine(_dc.DataConsumer, def get_from_grid(cls, grid: _grid.Grid, start: complex) -> AnnotationLine: """Return an AnnotationLine that starts at the specified point.""" points = _utils.flood_walk( - grid, [start], cls.start_dirs, cls.directions, set()) + grid, {start}, cls.start_dirs, cls.directions, set()) return cls(points) @classmethod diff --git a/schemascii/annotation.py b/schemascii/annotation.py index 8c44152..8e45e9e 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -6,7 +6,7 @@ import schemascii.data_consumer as _dc import schemascii.grid as _grid -import schemascii.utils as _utils +import schemascii.svg_utils as _svg ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") @@ -37,7 +37,7 @@ def find_all(cls, grid: _grid.Grid) -> list[Annotation]: return out def render(self, scale, font, **options) -> str: - return _utils.XML.text( + return _svg.XML.text( html.escape(self.content), x=self.position.real * scale, y=self.position.imag * scale, diff --git a/schemascii/component.py b/schemascii/component.py index bab081b..de37e88 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -65,7 +65,7 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # add in the RD's bounds and find the main blob blobs.append(_utils.flood_walk( - grid, list(_utils.iterate_line(rd.left, rd.right)), + grid, set(_utils.iterate_line(rd.left, rd.right)), start_orth, cont_orth, seen)) # now find all of the auxillary blobs for perimeter_pt in _utils.perimeter(blobs[0]): @@ -75,7 +75,7 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: and grid.get(poss_aux_blob_pt) == "#"): # we found another blob blobs.append(_utils.flood_walk( - grid, [poss_aux_blob_pt], start_moore, + grid, {poss_aux_blob_pt}, start_moore, cont_moore, seen)) # find all of the terminals terminals: list[_utils.Terminal] = [] @@ -146,7 +146,7 @@ def css_class(self) -> str: return f"component {self.rd.letter}" @classmethod - def process_nets(self, nets: list[_net.Net]): + def process_nets(self, nets: list[_net.Net]) -> None: """Hook method called to do stuff with the nets that this component type connects to. By default it does nothing. @@ -165,7 +165,7 @@ def get_terminals( found, or there were multiple terminals with the requested flag (ambiguous). """ - out = [] + out: list[_utils.Terminal] = [] for flag in flags_names: matching_terminals = [t for t in self.terminals if t.flag == flag] if len(matching_terminals) > 1: diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py index 2f1afe4..ba3900e 100644 --- a/schemascii/components/inductor.py +++ b/schemascii/components/inductor.py @@ -3,6 +3,7 @@ import schemascii.components as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils +import schemascii.svg_utils as _svg class Inductor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, @@ -29,9 +30,8 @@ def render(self, **options) -> str: for _ in range(int(length)): data += f"a1 1 0 01 {-d.real} {d.imag}" return ( - _utils.XML.path(d=data, stroke=options["stroke"], - fill="transparent", - stroke__width=options["stroke_width"]) + _svg.path(data, "transparent", options["stroke_width"], + options["stroke"]) + self.format_id_text( _utils.make_text_point(t1, t2, **options), **options)) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index c06c641..4a1b136 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -7,7 +7,7 @@ import schemascii.data as _data import schemascii.errors as _errors -import schemascii.utils as _utils +import schemascii.svg_utils as _svg T = typing.TypeVar("T") _NOT_SET = object() @@ -136,7 +136,7 @@ def to_xml_string(self, data: _data.Data) -> str: # render result = self.render(**values, data=data) if self.css_class: - result = _utils.XML.g(result, class_=self.css_class) + result = _svg.group(result, class_=self.css_class) return result @abc.abstractmethod diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 9a0f44d..5248101 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -3,16 +3,16 @@ import typing from dataclasses import dataclass -import schemascii.data_consumer as _dc import schemascii.annoline as _annoline import schemascii.annotation as _a import schemascii.component as _component import schemascii.data as _data +import schemascii.data_consumer as _dc import schemascii.errors as _errors import schemascii.grid as _grid import schemascii.net as _net import schemascii.refdes as _rd -import schemascii.utils as _utils +import schemascii.svg_utils as _svg @dataclass @@ -54,8 +54,8 @@ def from_file(cls, marker_pos = lines.index(data_marker) except ValueError as e: raise _errors.DiagramSyntaxError( - "data-marker must be present in a drawing! " - f"(current data-marker is: {data_marker!r})") from e + "data_marker must be present in a drawing! " + f"(current data_marker is: {data_marker!r})") from e drawing_area = "\n".join(lines[:marker_pos]) data_area = "\n".join(lines[marker_pos+1:]) # find everything @@ -85,19 +85,19 @@ def to_xml_string( def render(self, data, scale: float, padding: float, **options) -> str: # render everything - content = _utils.XML.g( - _utils.XML.g( + content = _svg.group( + _svg.group( *(net.to_xml_string(data) for net in self.nets), class_="wires"), - _utils.XML.g( + _svg.group( *(comp.to_xml_string(data) for comp in self.components), class_="components"), class_="electrical") - content += _utils.XML.g( + content += _svg.group( *(line.to_xml_string(data) for line in self.annotation_lines), *(anno.to_xml_string(data) for anno in self.annotations), class_="annotations") - return _utils.XML.svg( + return _svg.group( content, width=self.grid.width * scale + padding * 2, height=self.grid.height * scale + padding * 2, diff --git a/schemascii/svg_utils.py b/schemascii/svg_utils.py new file mode 100644 index 0000000..9052638 --- /dev/null +++ b/schemascii/svg_utils.py @@ -0,0 +1,59 @@ +import typing + + +def fix_number(n: float) -> str: + """If n is an integer, remove the trailing ".0". + Otherwise round it to 2 digits, and return the stringified + number. + """ + if n.is_integer(): + return str(int(n)) + n = round(n, 2) + if n.is_integer(): + return str(int(n)) + return str(n) + + +class XMLClass: + def __getattr__(self, tag: str) -> typing.Callable[..., str]: + def mk_tag(*contents: str, **attrs: str | bool | float | int) -> str: + out = f"<{tag}" + for k, v in attrs.items(): + if v is False: + continue + if isinstance(v, float): + v = fix_number(v) + # XXX: this gets called on every XML level + # XXX: which means that it will be called multiple times + # XXX: unnecessarily + # elif isinstance(v, str): + # v = re.sub(r"\b\d+(\.\d+)\b", + # lambda m: fix_number(float(m.group())), v) + out += f' {k.removesuffix("_").replace("__", "-")}="{v}"' + out = out + ">" + "".join(contents) + return out + f"" + + return mk_tag + + +XML = XMLClass() +del XMLClass + + +def group(*items: str, class_: str | False = False) -> str: + return XML.g(*items, class_=class_) + + +def path(data: str, + fill: str | False = False, + stroke_width: float | False = False, + stroke: str | False = False, + class_: str | False = False) -> str: + return XML.path(d=data, fill=fill, stroke__width=stroke_width, + stroke=stroke, class_=class_) + + +def circle(center: complex, radius: float, stroke: str | False = False, + fill: str | False = False, class_: str | False = False) -> str: + return XML.circle(cx=center.real, cy=center.imag, r=radius, + stroke=stroke, fill=fill, class_=class_) diff --git a/schemascii/utils.py b/schemascii/utils.py index 6f109bf..f1327a1 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -9,21 +9,17 @@ import schemascii.errors as _errors import schemascii.grid as _grid import schemascii.metric as _metric +import schemascii.svg_utils as _svg -LEFT_RIGHT = (-1, 1) -UP_DOWN = (-1j, 1j) -ORTHAGONAL = LEFT_RIGHT + UP_DOWN -DIAGONAL = (-1+1j, 1+1j, -1-1j, 1-1j) -EVERYWHERE: defaultdict[complex, list[complex]] = defaultdict( +LEFT_RIGHT = {-1+0j, 1+0j} +UP_DOWN = {-1j, 1j} +ORTHAGONAL = LEFT_RIGHT | UP_DOWN +DIAGONAL = {-1+1j, 1+1j, -1-1j, 1-1j} +EVERYWHERE: defaultdict[complex, set[complex]] = defaultdict( lambda: ORTHAGONAL) -EVERYWHERE_MOORE: defaultdict[complex, list[complex]] = defaultdict( +EVERYWHERE_MOORE: defaultdict[complex, set[complex]] = defaultdict( lambda: ORTHAGONAL + DIAGONAL) -IDENTITY: dict[complex, list[complex]] = { - 1: [1], - 1j: [1j], - -1: [-1], - -1j: [-1j], -} +IDENTITY: dict[complex, set[complex]] = {x: set((x,)) for x in ORTHAGONAL} class Cbox(typing.NamedTuple): @@ -92,10 +88,10 @@ def from_phase(cls, pt: complex) -> Side: def flood_walk( grid: _grid.Grid, - seed: list[complex], - start_dirs: defaultdict[str, list[complex] | None], + seed: set[complex], + start_dirs: defaultdict[str, set[complex] | None], directions: defaultdict[str, defaultdict[ - complex, list[complex] | None]], + complex, set[complex] | None]], seen: set[complex]) -> list[complex]: """Flood-fill the area on the grid starting from seed, only following connections in the directions allowed by start_dirs and directions, and @@ -107,7 +103,7 @@ def flood_walk( arguments, the second call will always return nothing. """ points: list[complex] = [] - stack: list[tuple[complex, list[complex]]] = [ + stack: list[tuple[complex, set[complex]]] = [ (p, start_dirs[grid.get(p)]) for p in seed] while stack: @@ -130,26 +126,26 @@ def flood_walk( return points -def perimeter(pts: list[complex]) -> list[complex]: +def perimeter(pts: set[complex]) -> set[complex]: """Return the set of points that are on the boundary of the grid-aligned set pts. """ - out = [] + out = set() for pt in pts: for d in ORTHAGONAL + DIAGONAL: xp = pt + d if xp not in pts: - out.append(pt) + out.add(pt) break return out # sort_counterclockwise(out, centroid(pts)) -def centroid(pts: list[complex]) -> complex: +def centroid(pts: set[complex]) -> complex: """Return the centroid of the set of points pts.""" return sum(pts) / len(pts) -def sort_counterclockwise(pts: list[complex], +def sort_counterclockwise(pts: set[complex], center: complex | None = None) -> list[complex]: """Return pts sorted so that the points progress clockwise around the center, starting with the @@ -297,45 +293,6 @@ def deep_transform(data: _DT_Struct, origin: complex, theta: float): type(data).__name__) from err -def fix_number(n: float) -> str: - """If n is an integer, remove the trailing ".0". - Otherwise round it to 2 digits, and return the stringified - number. - """ - if n.is_integer(): - return str(int(n)) - n = round(n, 2) - if n.is_integer(): - return str(int(n)) - return str(n) - - -class XMLClass: - def __getattr__(self, tag: str) -> typing.Callable: - def mk_tag(*contents: str, **attrs: str | bool | float | int) -> str: - out = f"<{tag}" - for k, v in attrs.items(): - if v is False: - continue - if isinstance(v, float): - v = fix_number(v) - # XXX: this gets called on every XML level - # XXX: which means that it will be called multiple times - # XXX: unnecessarily - # elif isinstance(v, str): - # v = re.sub(r"\b\d+(\.\d+)\b", - # lambda m: fix_number(float(m.group())), v) - out += f' {k.removesuffix("_").replace("__", "-")}="{v}"' - out = out + ">" + "".join(contents) - return out + f"" - - return mk_tag - - -XML = XMLClass() -del XMLClass - - def _get_sss(options: dict) -> tuple[float, float, str]: return options["scale"], options["stroke_width"], options["stroke"] @@ -382,11 +339,10 @@ def polylinegon( scale, stroke_width, stroke = _get_sss(options) scaled_pts = [x * scale for x in points] if is_polygon: - return XML.path(d=points2path(scaled_pts, True), - fill=stroke, class_="filled") - return XML.path( - d=points2path(scaled_pts, False), fill="transparent", - stroke__width=stroke_width, stroke=stroke) + return _svg.path(points2path(scaled_pts, True), stroke, + class_="filled") + return _svg.path(points2path(scaled_pts, False), "transparent", + stroke_width, stroke) def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: @@ -417,9 +373,7 @@ def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: data = "" for line in lines: data += points2path([x * scale for x in line], False) - return XML.path( - d=data, fill="transparent", - stroke__width=stroke_width, stroke=stroke) + return _svg.path(data, "transparent", stroke_width, stroke) def id_text( @@ -465,10 +419,10 @@ def id_text( else: textach = "middle" if terminals[0].side in ( Side.TOP, Side.BOTTOM) else "start" - return XML.text( - XML.tspan(cname, class_="cmp-id") if "L" in label else "", + return _svg.XML.text( + _svg.XML.tspan(cname, class_="cmp-id") if "L" in label else "", " " if data and "L" in label else "", - XML.tspan(data, class_=data_css_class) if "V" in label else "", + _svg.XML.tspan(data, class_=data_css_class) if "V" in label else "", x=point.real, y=point.imag, text__anchor=textach, @@ -489,7 +443,7 @@ def make_text_point(t1: complex, t2: complex, **options) -> complex: def make_plus(center: complex, theta: float, **options) -> str: """Make a '+' sign for indicating polarity.""" - return XML.g( + return _svg.group( bunch_o_lines( deep_transform( deep_transform([(0.125, -0.125), (0.125j, -0.125j)], 0, theta), @@ -510,11 +464,11 @@ def arrow_points(p1: complex, p2: complex) -> list[tuple[complex, complex]]: def make_variable(center: complex, theta: float, **options) -> str: """Draw a "variable" arrow across the component.""" - return XML.g(bunch_o_lines(deep_transform(arrow_points(-1, 1), - center, - (theta % pi) + pi / 4), - **options), - class_="variable") + return _svg.group(bunch_o_lines(deep_transform(arrow_points(-1, 1), + center, + (theta % pi) + pi / 4), + **options), + class_="variable") def light_arrows(center: complex, theta: float, out: bool, **options): @@ -524,7 +478,7 @@ def light_arrows(center: complex, theta: float, out: bool, **options): a, b = 1j, 0.3 + 0.3j if out: a, b = b, a - return XML.g(bunch_o_lines( + return _svg.group(bunch_o_lines( deep_transform(arrow_points(a, b), center, theta - pi / 2) + deep_transform(arrow_points(a - 0.5, b - 0.5), diff --git a/schemascii/wire.py b/schemascii/wire.py index de417f4..3a6494e 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -7,6 +7,7 @@ import schemascii.data_consumer as _dc import schemascii.grid as _grid +import schemascii.svg_utils as _svg import schemascii.utils as _utils import schemascii.wire_tag as _wt @@ -72,13 +73,9 @@ def render(self, data, **options) -> str: if abs(p1 - p2) == 1: links.append((p1, p2)) # find dots - dots = "" + dots: str = "" for dot_pt in _utils.find_dots(links): - dots += _utils.XML.circle( - cx=scale * dot_pt.real, - cy=scale * dot_pt.real, - r=linewidth, - class_="dot") + dots += _svg.circle(scale * dot_pt, linewidth, class_="dot") return (_utils.bunch_o_lines(links, **options) + (self.tag.to_xml_string(data) if self.tag else "") + dots) diff --git a/setup.py b/setup.py index 8c6c642..56aea2f 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,2 @@ from setuptools import setup -setup( - packages=["schemascii"] -) +setup(packages=["schemascii"]) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index 3d4ef6c..ee0955d 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -135,7 +135,13 @@ ########## ####### #### - #"""] + #""", + """ +########### +########### + ########## +########### +###########"""] """ idea for new algorithm diff --git a/test_data/stresstest.txt b/test_data/stresstest.txt index 7af4107..8c74c69 100644 --- a/test_data/stresstest.txt +++ b/test_data/stresstest.txt @@ -27,14 +27,14 @@ J999999999999------ --- -* { +@ { stroke-width = 2 } C1 { value = 100u } -* { +@ { %% these are global config options color = black width = 2; padding = 20; @@ -43,7 +43,7 @@ C1 { } -R* {tolerance = .05; wattage = 0.25} +R@ {tolerance = .05; wattage = 0.25} R1 { resistance = 0 - 10k; From 793a07db57d73b36271c53c21567b1050c8d8a6c Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Wed, 9 Apr 2025 22:38:56 -0400 Subject: [PATCH 094/101] i could have actually been putting the test case name there the whole time cause only #'s are picked up not letters --- test_data/blob_test.py | 110 ++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/test_data/blob_test.py b/test_data/blob_test.py index ee0955d..675cc8a 100644 --- a/test_data/blob_test.py +++ b/test_data/blob_test.py @@ -1,7 +1,6 @@ -import typing - strings = [ """ +straight rectangle ######################### ######################### ######################### @@ -9,6 +8,7 @@ ######################### #########################""", """ +rectangle with slot in it ######################### ######################### ############# @@ -16,6 +16,14 @@ ######################### """, """ +rectangle with notch +########### +########### + ########## +########### +###########""", + """ +average triangle (op-amp) # ### ##### @@ -25,6 +33,7 @@ # """, """ +long triangle # ###### ########### @@ -34,6 +43,7 @@ # """, """ +stubby triangle # # # @@ -49,6 +59,7 @@ # """, """ +half star (concave) # # # @@ -68,6 +79,7 @@ # """, """ +back end of xor gate # # # @@ -76,6 +88,7 @@ # #""", """ +or gate ###### ######## ######### @@ -84,6 +97,27 @@ ######## ######""", """ +and gate +##### +####### +####### +######## +####### +####### +##### +""", + """ +not gate +# +### +##### ## +########### +##### ## +### +# +""", + """ +big O ########### ############# ############### @@ -95,13 +129,16 @@ ############# ###########""", """ +thin thing 1 ############### ###################""", """ +thin thing 2 ############### ################### ###############""", """ +zigzag rectangle 1 ################ ################ ################ @@ -111,6 +148,7 @@ ################ ################""", """ +zigzag rectangle 2 # # # # # # # # ################ ################ @@ -119,6 +157,7 @@ ################ # # # # # # # #""", """ +tilted rhombus # #### ####### @@ -137,30 +176,39 @@ #### #""", """ -########### -########### - ########## -########### -###########"""] - -""" -idea for new algorithm -* find all of the edge points -* sort them by clockwise order around the perimeter - * this is done not by sorting by angle around the centroid - (that would only work for convex shapes) but by forming a list - of edges between adjacent points and then walking the graph - using a direction vector that is rotated only clockwise - and starting from the rightmost point and starting down -* handle the shapes that have holes in them by treating - each edge as a separate shape, then merging the svgs -* find all of the points that are concave -* find all of the straight line points -* assign each straight line point a distance from the nearest - concave point -* remove the concave points and straight line points that are closer - than a threshold and are not a local maximum of distance +and just for the heck of it, a very big arrow + ## + #### + ###### + ######## + ########## + ############ + ############## + ################ + ################## + #################### + ###################### + ######################## + ########################## + ############################ + ############################## +################################ + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## + ########## """ +] VN_DIRECTIONS: list[complex] = [1, 1j, -1, -1j] DIRECTIONS: list[complex] = [1, 1+1j, 1j, -1+1j, -1, -1-1j, -1j, 1-1j] @@ -182,21 +230,18 @@ def points_to_edges( return edges -T = typing.TypeVar("T") - - -def cir(list: list[T], is_forward: bool) -> list[T]: +def cir[T](list: list[T], is_forward: bool) -> list[T]: if is_forward: return list[1:] + [list[0]] else: return [list[-1]] + list[:-1] -def triples(v: list[T]) -> list[tuple[T, T, T]]: +def triples[T](v: list[T]) -> list[tuple[T, T, T]]: return list(zip(cir(v, False), v, cir(v, True))) -def fiveles(v: list[T]) -> list[tuple[T, T, T]]: +def fiveles[T](v: list[T]) -> list[tuple[T, T, T, T, T]]: x, y, z = cir(v, False), v, cir(v, True) return list(zip(cir(x, False), x, y, z, cir(z, True))) @@ -214,8 +259,7 @@ def cull_disallowed_edges( continue # if there are multiple directions out of here, find the gaps and # only keep the ones on the sides of the gaps - gaps = [p1 + d in all_points for d in DIRECTIONS] - tran_5 = fiveles(gaps) + tran_5 = fiveles([p1 + d in all_points for d in DIRECTIONS]) # im not quite sure what this is doing fixed_edges[p1] = set(p1 + d for (d, (q, a, b, c, w)) in zip(DIRECTIONS, tran_5) From dff13202270df242e7ded3d57f709568528883eb Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Fri, 11 Apr 2025 23:50:23 -0400 Subject: [PATCH 095/101] i am ashamed for such a large commit --- options.md | 40 +++++++++++--- pyproject.toml | 2 +- schemascii/OLD_components_render.py | 21 ++++---- schemascii/__init__.py | 7 ++- schemascii/__main__.py | 71 +++++++++++++----------- schemascii/annotation.py | 14 ++--- schemascii/component.py | 52 +++++++++++++++++- schemascii/components/__init__.py | 40 +++----------- schemascii/components/capacitor.py | 2 +- schemascii/components/diode.py | 5 ++ schemascii/components/inductor.py | 4 +- schemascii/data_consumer.py | 12 ++--- schemascii/drawing.py | 4 +- schemascii/errors.py | 2 +- schemascii/refdes.py | 2 +- schemascii/svg.py | 60 +++++++++++++++++++++ schemascii/svg_utils.py | 59 -------------------- schemascii/utils.py | 83 +++++++---------------------- schemascii/wire.py | 2 +- scripts/docs.py | 48 +++++++++-------- 20 files changed, 283 insertions(+), 247 deletions(-) create mode 100644 schemascii/svg.py delete mode 100644 schemascii/svg_utils.py diff --git a/options.md b/options.md index c1a3af8..c959561 100644 --- a/options.md +++ b/options.md @@ -2,7 +2,7 @@ # Data Section Options +on Fri Apr 11 14:37:03 2025 --> ## Scope `:WIRE-TAG` @@ -73,7 +73,7 @@ Reference Designators: `B`, `BT`, `BAT` | color | str | Default color for everything | 'black' | | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | -| value | str | Battery voltage | (required) | +| value | str | Battery voltage | *required* | | capacity | str | Battery capacity in amp-hours | (no value) | ## Component `:CAPACITOR` @@ -87,9 +87,37 @@ Reference Designators: `C`, `VC`, `CV` | color | str | Default color for everything | 'black' | | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | -| value | str | Capacitance in farads | (required) | +| value | str | Capacitance in farads | *required* | | voltage | str | Maximum voltage tolerance in volts | (no value) | +## Component `:DIODE` + +Reference Designators: `D`, `CR`, `LED`, `IR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| voltage | str | Maximum reverse voltage rating | (no value) | +| current | str | Maximum current rating | (no value) | + +## Component `:LED` + +Reference Designators: `D`, `CR`, `LED`, `IR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | +| voltage | str | Maximum reverse voltage rating | (no value) | +| current | str | Maximum current rating | (no value) | + ## Component `:INDUCTOR` Reference Designators: `L`, `VL`, `LV` @@ -101,7 +129,7 @@ Reference Designators: `L`, `VL`, `LV` | color | str | Default color for everything | 'black' | | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | -| value | str | Inductance in henries | (required) | +| value | str | Inductance in henries | *required* | | current | str | Maximum current rating in amps | (no value) | ## Component `:RESISTOR` @@ -115,5 +143,5 @@ Reference Designators: `R`, `VR`, `RV` | color | str | Default color for everything | 'black' | | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | -| value | str | Resistance in ohms | (required) | -| power | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | +| value | str | Resistance in ohms | *required* | +| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | diff --git a/pyproject.toml b/pyproject.toml index 91486a9..8bc662e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Topic :: Scientific/Engineering", ] keywords = ["schematic", "electronics", "circuit", "diagram"] -requires-python = ">=3.10" +requires-python = ">=3.12" [project.urls] Homepage = "https://github.com/dragoncoder047/schemascii" diff --git a/schemascii/OLD_components_render.py b/schemascii/OLD_components_render.py index f401f47..07ae587 100644 --- a/schemascii/OLD_components_render.py +++ b/schemascii/OLD_components_render.py @@ -7,7 +7,6 @@ Cbox, Terminal, BOMData, - XML, Side, arrow_points, polylinegon, @@ -147,7 +146,7 @@ def integrated_circuit( sz = (box.p2 - box.p1) * scale mid = (box.p2 + box.p1) * scale / 2 part_num, *pin_labels = map(str.strip, bom_data.data.split(",")) - out = XML.rect( + out = xmltag("rect", x=box.p1.real * scale, y=box.p1.imag * scale, width=sz.real, @@ -162,8 +161,8 @@ def integrated_circuit( term.pt + rect(1, SIDE_TO_ANGLE_MAP[term.side]))], **options) if "V" in label_style and part_num: - out += XML.text( - XML.tspan(part_num, class_="part-num"), + out += xmltag("text", + xmltag("tspan", part_num, class_="part-num"), x=mid.real, y=mid.imag, text__anchor="middle", @@ -172,8 +171,8 @@ def integrated_circuit( ) mid -= 1j * scale if "L" in label_style and not options["nolabels"]: - out += XML.text( - XML.tspan(f"{box.type}{box.id}", class_="cmp-id"), + out += xmltag("text", + xmltag("tspan", f"{box.type}{box.id}", class_="cmp-id"), x=mid.real, y=mid.imag, text__anchor="middle", @@ -183,7 +182,7 @@ def integrated_circuit( s_terminals = sort_terminals_counterclockwise(terminals) for terminal, label in zip(s_terminals, pin_labels): sc_text_pt = terminal.pt * scale - out += XML.text( + out += xmltag("text", label, x=sc_text_pt.real, y=sc_text_pt.imag, @@ -225,7 +224,7 @@ def jack(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): if style == "circle": return ( bunch_o_lines([(t1, t2)], **options) - + XML.circle( + + xmltag("circle", cx=sc_t2.real, cy=sc_t2.imag, r=scale / 4, @@ -377,13 +376,13 @@ def switch(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): mid = (t1 + t2) / 2 angle = phase(t1 - t2) scale = options["scale"] - out = (XML.circle(cx=(rect(-scale, angle) + mid * scale).real, + out = (xmltag("circle", cx=(rect(-scale, angle) + mid * scale).real, cy=(rect(-scale, angle) + mid * scale).imag, r=scale / 4, stroke="transparent", fill=options["stroke"], class_="filled") - + XML.circle(cx=(rect(scale, angle) + mid * scale).real, + + xmltag("circle", cx=(rect(scale, angle) + mid * scale).real, cy=(rect(scale, angle) + mid * scale).imag, r=scale / 4, stroke="transparent", @@ -448,7 +447,7 @@ def render_component( "Render the component into an SVG string." if box.type not in RENDERERS: raise UnsupportedComponentError(box.type) - return XML.g( + return xmltag("g", RENDERERS[box.type](box, terminals, bom_data, **options), class_=f"component {box.type}", ) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index a60c4f0..5003d58 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -28,5 +28,8 @@ def render(filename: str, text: str | None = None, if __name__ == "__main__": - import schemascii.component as _comp - print(_comp.Component.all_components) + import schemascii.components.resistor as _r + import schemascii.refdes as _rd + import schemascii.utils as _u + print(_r.Resistor(_rd.RefDes("R", 0, "", 0, 0), [[]], [ + _u.Terminal(0, "w", 0), _u.Terminal(0, "a", 0)])) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index fc978f3..e712adf 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -12,10 +12,10 @@ class DataFudgeAction(argparse.Action): def __call__(self, parser, namespace, values: str, option_string=None): scope, sep, pair = values.partition(".") if not sep: - parser.error("invalid -D format: missing .") + parser.error("invalid -D argument: missing .") key, sep, val = pair.partition("=") if not sep: - parser.error("invalid -D format: missing =") + parser.error("invalid -D argument: missing =") items = getattr(namespace, self.dest) or _d.Data([]) items |= _d.Data( [_d.Section(scope, {key: _d.parse_simple_value(val)})]) @@ -24,15 +24,24 @@ def __call__(self, parser, namespace, values: str, option_string=None): def cli_main(): ap = argparse.ArgumentParser( - prog="schemascii", description="Render ASCII-art schematics into SVG.") + prog="schemascii", + description="Render ASCII-art schematics into SVG.") ap.add_argument( - "-V", "--version", action="version", + "-V", + "--version", + action="version", version="%(prog)s " + schemascii.__version__) - ap.add_argument("in_file", help="File to process. (default: stdin)", - default="-") + ap.add_argument( + "-i", + "--in", + type=argparse.FileType("r"), + default=None, + dest="in_file", + help="File to process. (default: stdin)") ap.add_argument( "-o", "--out", + type=argparse.FileType("w"), default=None, dest="out_file", help="Output SVG file. (default input file plus .svg, or stdout " @@ -54,31 +63,33 @@ def cli_main(): "so as to not conflict with your shell.\n\nThis option " "can be repeated as many times as necessary.") args = ap.parse_args() - if args.out_file is None: - args.out_file = args.in_file + ".svg" - text = None - if args.in_file == "-": - text = sys.stdin.read() - args.in_file = "" try: - with warnings.catch_warnings(record=True) as captured_warnings: - result_svg = schemascii.render(args.in_file, text, args.fudge) - except _errors.Error as err: - print(type(err).__name__ + ":", err, file=sys.stderr) - sys.exit(1) - if captured_warnings: - for warning in captured_warnings: - print("Warning:", warning.message, file=sys.stderr) - if args.warnings_are_errors: - print("Error: warnings were treated as errors", file=sys.stderr) - sys.exit(1) - if args.out_file == "-": - print(result_svg) - else: - with open(args.out_file, "w", encoding="utf-8") as out: - out.write(result_svg) - if args.verbose: - print("Wrote SVG to", args.out_file) + if args.in_file is None: + args.in_file = sys.stdin + if args.out_file is None: + args.out_file = sys.stdout + elif args.out_file is None: + args.out_file = open(args.in_file.name + ".svg") + try: + with warnings.catch_warnings(record=True) as captured_warnings: + result_svg = schemascii.render(args.in_file.name, + args.in_file.read(), args.fudge) + except _errors.Error as err: + ap.error(str(err)) + + if captured_warnings: + for warning in captured_warnings: + print("Warning:", warning.message, file=sys.stderr) + if args.warnings_are_errors: + print("Error: warnings were treated as errors", + file=sys.stderr) + sys.exit(1) + + args.out_file.write(result_svg) + + finally: + args.in_file.close() + args.out_file.close() if __name__ == "__main__": diff --git a/schemascii/annotation.py b/schemascii/annotation.py index 8e45e9e..feb2ff8 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -6,7 +6,7 @@ import schemascii.data_consumer as _dc import schemascii.grid as _grid -import schemascii.svg_utils as _svg +import schemascii.svg as _svg ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") @@ -37,9 +37,9 @@ def find_all(cls, grid: _grid.Grid) -> list[Annotation]: return out def render(self, scale, font, **options) -> str: - return _svg.XML.text( - html.escape(self.content), - x=self.position.real * scale, - y=self.position.imag * scale, - style=f"font-family:{font}", - alignment__baseline="middle") + return _svg.xmltag("text", + html.escape(self.content), + x=self.position.real * scale, + y=self.position.imag * scale, + style=f"font-family:{font}", + alignment__baseline="middle") diff --git a/schemascii/component.py b/schemascii/component.py index de37e88..d53456d 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -1,8 +1,9 @@ from __future__ import annotations +import types import typing from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import schemascii.data_consumer as _dc import schemascii.errors as _errors @@ -32,6 +33,55 @@ class Component(_dc.DataConsumer, namespaces=(":component",)): blobs: list[list[complex]] # to support multiple parts. terminals: list[_utils.Terminal] + # Ellipsis can only appear at the end. this means like a wildcard meaning + # that any other flag is suitable + terminal_flag_opts: typing.ClassVar[ + dict[str, tuple[str | None] | types.EllipsisType]] = (None,) + + term_option: str = field(init=False) + + def __post_init__(self): + has_any = False + # optimized check for number of terminals if they're all the same + available_lengths = sorted(set(map( + len, self.terminal_flag_opts.values()))) + for optlen in available_lengths: + if len(self.terminals) == optlen: + break + else: + raise _errors.TerminalsError( + f"Wrong number of terminals on {self.rd.name}. " + f"Got {len(self.terminals)} but " + f"expected {" or ".join(available_lengths)}") + for fo_name, fo_opt in self.terminal_flag_opts.items(): + if fo_opt is ...: + has_any = True + continue + t_copy = self.terminals.copy() + t_sorted: list[_utils.Terminal] = [] + match = True + ellipsis = False + for opt in fo_opt: + if opt is ...: + ellipsis = True + break + found = [t for t in t_copy if t.flag == opt] + if not found: + match = False + break + t_copy.remove(found[0]) + t_sorted.append(found[0]) + if not ellipsis and t_copy: + match = False + if not match: + continue + self.terminals = t_sorted + t_copy + self.term_option = fo_name + return + if not has_any: + raise _errors.TerminalsError( + f"Illegal terminal flags around {self.rd.name}") + @property def namespaces(self) -> tuple[str, ...]: return self.rd.name, self.rd.short_name, self.rd.letter, ":component" diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index d18b6bd..0acf253 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -32,25 +32,10 @@ def format_id_text(self: _c.Component | SimpleComponent, @dataclass -class NTerminalComponent(_c.Component): - """Represents a component that only ever has N terminals. - - The class must have an attribute n_terminals with the number - of terminals.""" - n_terminals: typing.ClassVar[int] - - def __post_init__(self): - if self.n_terminals != len(self.terminals): - raise _errors.TerminalsError( - f"{self.rd.name}: can only have {self.n_terminals} terminals " - f"(found {len(self.terminals)})") - - -class TwoTerminalComponent(NTerminalComponent): - """Shortcut to define a component with two terminals, and one primary - value, that may or may not be variable.""" - n_terminals: typing.Final[int] = 2 - is_variable: typing.ClassVar[bool] = False +class TwoTerminalComponent(_c.Component): + """Shortcut to define a component with two terminals.""" + terminal_flag_opts: typing.ClassVar = {"ok": (None, None)} + is_variable: typing.ClassVar = False @dataclass @@ -62,17 +47,8 @@ class PolarizedTwoTerminalComponent(TwoTerminalComponent): always_polarized: typing.ClassVar[bool] = False - def __post_init__(self): - super().__post_init__() # check count of terminals - num_plus = sum(t.flag == "+" for t in self.terminals) - if (self.always_polarized and num_plus != 1) or num_plus > 1: - raise _errors.TerminalsError( - f"{self.rd.name}: need '+' on only one terminal to indicate " - "polarization") - if self.terminals[1].flag == "+": - # swap first and last - self.terminals.insert(0, self.terminals.pop(-1)) - @property - def is_polarized(self) -> bool: - return any(t.flag == "+" for t in self.terminals) + def terminal_flags_opts(self): + if self.always_polarized: + return {"polarized": ("+", None)} + return {"polarized": ("+", None), "unpolarized": (None, None)} diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py index f232b6f..d240570 100644 --- a/schemascii/components/capacitor.py +++ b/schemascii/components/capacitor.py @@ -32,7 +32,7 @@ def render(self, **options) -> str: ] return (_utils.bunch_o_lines(lines, **options) + (_utils.make_plus(self.terminals, mid, angle, **options) - if self.is_polarized else "") + if self.term_option == "polarized" else "") + self.format_id_text( _utils.make_text_point(t1, t2, **options), **options)) diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py index 426b638..f52434c 100644 --- a/schemascii/components/diode.py +++ b/schemascii/components/diode.py @@ -1,3 +1,5 @@ +import typing + import schemascii.components as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils @@ -5,6 +7,9 @@ class Diode(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, ids=("D", "CR"), namespaces=(":diode",)): + + always_polarized: typing.Final = True + options = [ "inherit", _dc.Option("voltage", str, "Maximum reverse voltage rating", None), diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py index ba3900e..6f19f85 100644 --- a/schemascii/components/inductor.py +++ b/schemascii/components/inductor.py @@ -3,7 +3,9 @@ import schemascii.components as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils -import schemascii.svg_utils as _svg +import schemascii.svg as _svg + +# TODO: add dot on + end if inductor is polarized class Inductor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 4a1b136..08ea81f 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -7,14 +7,13 @@ import schemascii.data as _data import schemascii.errors as _errors -import schemascii.svg_utils as _svg +import schemascii.svg as _svg -T = typing.TypeVar("T") -_NOT_SET = object() +_OPT_IS_REQUIRED = object() @dataclass -class Option(typing.Generic[T]): +class Option[T]: """Represents an allowed name used in Schemascii's internals somewhere. Normal users have no need for this class. """ @@ -22,9 +21,10 @@ class Option(typing.Generic[T]): name: str type: type[T] | list[T] help: str - default: T = _NOT_SET + default: T = _OPT_IS_REQUIRED +@dataclass class DataConsumer(abc.ABC): """Base class for any Schemascii AST node that needs data to be rendered. This class registers the options that the class @@ -107,7 +107,7 @@ def to_xml_string(self, data: _data.Data) -> str: # validate the options for opt in self.options: if opt.name not in values: - if opt.default is _NOT_SET: + if opt.default is _OPT_IS_REQUIRED: raise _errors.NoDataError( f"missing value for {self.namespaces[0]}.{name}") values[opt.name] = opt.default diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 5248101..0564822 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -12,7 +12,7 @@ import schemascii.grid as _grid import schemascii.net as _net import schemascii.refdes as _rd -import schemascii.svg_utils as _svg +import schemascii.svg as _svg @dataclass @@ -97,7 +97,7 @@ def render(self, data, scale: float, padding: float, **options) -> str: *(line.to_xml_string(data) for line in self.annotation_lines), *(anno.to_xml_string(data) for anno in self.annotations), class_="annotations") - return _svg.group( + return _svg.whole_thing( content, width=self.grid.width * scale + padding * 2, height=self.grid.height * scale + padding * 2, diff --git a/schemascii/errors.py b/schemascii/errors.py index ee68564..f818f99 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -18,7 +18,7 @@ class UnsupportedComponentError(NameError, Error): """Component type is not supported.""" -class NoDataError(NameError, Error): +class NoDataError(KeyError, Error): """Data item is required, but not present.""" diff --git a/schemascii/refdes.py b/schemascii/refdes.py index 86b5b40..7051dae 100644 --- a/schemascii/refdes.py +++ b/schemascii/refdes.py @@ -45,7 +45,7 @@ def name(self) -> str: @property def short_name(self) -> str: - return f"{self.letter}{self.number}{self.suffix}" + return f"{self.letter}{self.number}" if __name__ == '__main__': diff --git a/schemascii/svg.py b/schemascii/svg.py new file mode 100644 index 0000000..2ed9ae8 --- /dev/null +++ b/schemascii/svg.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import typing + +type OrFalse[T] = T | typing.Literal[False] + + +def fix_number(n: float) -> str: + """If n is an integer, remove the trailing ".0". + Otherwise round it to 2 digits, and return the stringified + number. + """ + if n.is_integer(): + return str(int(n)) + n = round(n, 2) + if n.is_integer(): + return str(int(n)) + return str(n) + + +def xmltag(tag: str, *contents: str, **attrs: str | bool | float | int) -> str: + out = f"<{tag}" + for k, v in attrs.items(): + if v is False: + continue + if isinstance(v, float): + v = fix_number(v) + # XXX: this gets called on every XML level + # XXX: which means that it will be called multiple times + # XXX: unnecessarily + # elif isinstance(v, str): + # v = re.sub(r"\b\d+(\.\d+)\b", + # lambda m: fix_number(float(m.group())), v) + out += f' {k.removesuffix("_").replace("__", "-")}="{v}"' + out = out + ">" + "".join(contents) + return out + f"" + + +def group(*items: str, class_: OrFalse[str] = False) -> str: + return xmltag("g", *items, class_=class_) + + +def path(data: str, fill: OrFalse[str] = False, + stroke_width: OrFalse[float] = False, stroke: OrFalse[str] = False, + class_: OrFalse[str] = False) -> str: + return xmltag("path", d=data, fill=fill, stroke__width=stroke_width, + stroke=stroke, class_=class_) + + +def circle(center: complex, radius: float, stroke: OrFalse[str] = False, + fill: OrFalse[str] = False, class_: OrFalse[str] = False) -> str: + return xmltag("circle", cx=center.real, cy=center.imag, r=radius, + stroke=stroke, fill=fill, class_=class_) + + +def whole_thing(contents: str, width: float, height: float, viewBox: str, + xmlns="http://www.w3.org/2000/svg", + class_="schemascii") -> str: + return xmltag("svg", contents, width=width, height=height, + viewBox=viewBox, xmlns=xmlns, class_=class_) diff --git a/schemascii/svg_utils.py b/schemascii/svg_utils.py deleted file mode 100644 index 9052638..0000000 --- a/schemascii/svg_utils.py +++ /dev/null @@ -1,59 +0,0 @@ -import typing - - -def fix_number(n: float) -> str: - """If n is an integer, remove the trailing ".0". - Otherwise round it to 2 digits, and return the stringified - number. - """ - if n.is_integer(): - return str(int(n)) - n = round(n, 2) - if n.is_integer(): - return str(int(n)) - return str(n) - - -class XMLClass: - def __getattr__(self, tag: str) -> typing.Callable[..., str]: - def mk_tag(*contents: str, **attrs: str | bool | float | int) -> str: - out = f"<{tag}" - for k, v in attrs.items(): - if v is False: - continue - if isinstance(v, float): - v = fix_number(v) - # XXX: this gets called on every XML level - # XXX: which means that it will be called multiple times - # XXX: unnecessarily - # elif isinstance(v, str): - # v = re.sub(r"\b\d+(\.\d+)\b", - # lambda m: fix_number(float(m.group())), v) - out += f' {k.removesuffix("_").replace("__", "-")}="{v}"' - out = out + ">" + "".join(contents) - return out + f"" - - return mk_tag - - -XML = XMLClass() -del XMLClass - - -def group(*items: str, class_: str | False = False) -> str: - return XML.g(*items, class_=class_) - - -def path(data: str, - fill: str | False = False, - stroke_width: float | False = False, - stroke: str | False = False, - class_: str | False = False) -> str: - return XML.path(d=data, fill=fill, stroke__width=stroke_width, - stroke=stroke, class_=class_) - - -def circle(center: complex, radius: float, stroke: str | False = False, - fill: str | False = False, class_: str | False = False) -> str: - return XML.circle(cx=center.real, cy=center.imag, r=radius, - stroke=stroke, fill=fill, class_=class_) diff --git a/schemascii/utils.py b/schemascii/utils.py index f1327a1..3766d8f 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -6,10 +6,9 @@ from cmath import phase, pi, rect from collections import defaultdict -import schemascii.errors as _errors import schemascii.grid as _grid import schemascii.metric as _metric -import schemascii.svg_utils as _svg +import schemascii.svg as _svg LEFT_RIGHT = {-1+0j, 1+0j} UP_DOWN = {-1j, 1j} @@ -22,24 +21,6 @@ IDENTITY: dict[complex, set[complex]] = {x: set((x,)) for x in ORTHAGONAL} -class Cbox(typing.NamedTuple): - """Component bounding box. Also holds the letter - and number of the reference designator. - """ - # XXX is this still used? - p1: complex - p2: complex - type: str - id: str - - -class BOMData(typing.NamedTuple): - """Data to link the BOM data entry with the reference designator.""" - type: str - id: str - data: str - - class Flag(typing.NamedTuple): """Data indicating the non-wire character next to a component.""" pt: complex @@ -255,19 +236,17 @@ def iterate_line(p1: complex, p2: complex, step: float = 1.0): yield point -# __future__ annotations does no good here if we -# don't have 3.12's type statement!! -_DT_Struct = list["_DT_Struct"] | tuple["_DT_Struct"] | complex +type DT_Struct = list[DT_Struct] | tuple[DT_Struct] | complex @typing.overload -def deep_transform(data: list[_DT_Struct], origin: complex, - theta: float) -> list[_DT_Struct]: ... +def deep_transform(data: list[DT_Struct], origin: complex, + theta: float) -> list[DT_Struct]: ... @typing.overload -def deep_transform(data: tuple[_DT_Struct], origin: complex, - theta: float) -> tuple[_DT_Struct]: ... +def deep_transform(data: tuple[DT_Struct], origin: complex, + theta: float) -> tuple[DT_Struct]: ... @typing.overload @@ -275,7 +254,7 @@ def deep_transform(data: complex, origin: complex, theta: float) -> complex: ... -def deep_transform(data: _DT_Struct, origin: complex, theta: float): +def deep_transform(data: DT_Struct, origin: complex, theta: float): """Transform the point or points first by translating by origin, then rotating by theta. Return an identical data structure, but with the transformed points substituted. @@ -419,16 +398,18 @@ def id_text( else: textach = "middle" if terminals[0].side in ( Side.TOP, Side.BOTTOM) else "start" - return _svg.XML.text( - _svg.XML.tspan(cname, class_="cmp-id") if "L" in label else "", - " " if data and "L" in label else "", - _svg.XML.tspan(data, class_=data_css_class) if "V" in label else "", - x=point.real, - y=point.imag, - text__anchor=textach, - font__size=scale, - fill=stroke, - style=f"font-family:{font}") + return _svg.xmltag("text", + (_svg.xmltag("tspan", cname, class_="cmp-id") + if "L" in label else ""), + " " if data and "L" in label else "", + (_svg.xmltag("tspan", data, class_=data_css_class) + if "V" in label else ""), + x=point.real, + y=point.imag, + text__anchor=textach, + font__size=scale, + fill=stroke, + style=f"font-family:{font}") def make_text_point(t1: complex, t2: complex, **options) -> complex: @@ -515,30 +496,6 @@ def is_clockwise(terminals: list[Terminal]) -> bool: return False -def sort_for_flags(terminals: list[Terminal], - box: Cbox, *flags: list[str]) -> list[Terminal]: - """Sorts out the terminals in the specified order using the flags. - Raises an error if the flags are absent. - """ - out = [] - for flag in flags: - matching_terminals = list(filter(lambda t: t.flag == flag, terminals)) - if len(matching_terminals) > 1: - raise _errors.TerminalsError( - f"Multiple terminals with the same flag {flag} " - f"on component {box.type}{box.id}" - ) - if len(matching_terminals) == 0: - raise _errors.TerminalsError( - f"Need a terminal with the flag {flag} " - f"on component {box.type}{box.id}" - ) - out.append(matching_terminals[0]) - # terminals.remove(matching_terminals[0]) - # is this necessary with the checks above? - return out - - if __name__ == '__main__': import pprint pts = [] @@ -546,4 +503,4 @@ def sort_for_flags(terminals: list[Terminal], for x in range(n): pts.append(force_int(rect(n, 2 * pi * x / n))) pprint.pprint(sort_counterclockwise(pts)) - print(_DT_Struct) + print(DT_Struct) diff --git a/schemascii/wire.py b/schemascii/wire.py index 3a6494e..ca00953 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -7,7 +7,7 @@ import schemascii.data_consumer as _dc import schemascii.grid as _grid -import schemascii.svg_utils as _svg +import schemascii.svg as _svg import schemascii.utils as _utils import schemascii.wire_tag as _wt diff --git a/scripts/docs.py b/scripts/docs.py index d3ed793..95b64be 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -1,48 +1,52 @@ #! /usr/bin/env python3 +from __future__ import annotations + import datetime import os -import textwrap - -from scriptutils import spit, spy +import sys -# flake8: noqa: E402 +from scriptutils import spit -from schemascii.component import Component -from schemascii.data import Data -from schemascii.data_consumer import _NOT_SET as NO_DEFAULT -from schemascii.data_consumer import Option +try: + sys.path.append(os.path.pardir) + from schemascii.component import Component + from schemascii.data import Data + from schemascii.data_consumer import _OPT_IS_REQUIRED, Option +except ModuleNotFoundError: + raise def output_file(filename: os.PathLike, heading: str, content: str): - spit(filename, textwrap.dedent(f""" - # {heading} + spit(filename, f""" +# {heading} - - """) + content) + +""" + content) def format_option(opt: Option) -> str: typename = (opt.type.__name__ if isinstance(opt.type, type) else " or ".join(f"`{z!s}`" for z in opt.type)) - items: list[str] = [opt.name, typename, opt.help, "(required)"] - if opt.default is not NO_DEFAULT: - items[-1] = repr(opt.default) if opt.default is not None else "(no value)" + items: list[str] = [opt.name, typename, opt.help, "*required*"] + if opt.default is not _OPT_IS_REQUIRED: + items[-1] = (repr(opt.default) + if opt.default is not None + else "(no value)") return f"| {' | '.join(items)} |\n" def format_scope(scopename: str, options: list[Option], interj: str, head: str) -> str: - scope_text = textwrap.dedent(f""" - ## {head} `{scopename.upper()}`%%%INTERJ%%% + scope_text = f""" +## {head} `{scopename.upper()}`{"\n\n" + interj if interj else ""} - | Option | Value | Description | Default | - |:------:|:-----:|:------------|:-------:| - """) +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +""" for option in options: scope_text += format_option(option) - scope_text = scope_text.replace("%%%INTERJ%%%", "\n\n" + interj if interj else "") return scope_text From 975b49ccf9063f6e1da2158b2405ed8fc0f67620 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sun, 13 Apr 2025 20:48:01 -0400 Subject: [PATCH 096/101] metaclass madness breaks stuff, multiple inheritance causes problems, need to use decorators --- options.md | 86 +----------------------------- schemascii/component.py | 7 ++- schemascii/components/__init__.py | 14 +++++ schemascii/components/battery.py | 4 +- schemascii/components/capacitor.py | 4 +- schemascii/components/diode.py | 20 ++----- schemascii/components/inductor.py | 4 +- schemascii/components/resistor.py | 3 +- schemascii/data_consumer.py | 11 ++-- 9 files changed, 36 insertions(+), 117 deletions(-) diff --git a/options.md b/options.md index c959561..eca7e45 100644 --- a/options.md +++ b/options.md @@ -2,7 +2,7 @@ # Data Section Options +on Sun Apr 13 20:18:36 2025 --> ## Scope `:WIRE-TAG` @@ -61,87 +61,3 @@ on Fri Apr 11 14:37:03 2025 --> |:------:|:-----:|:------------|:-------:| | scale | float | Scale by which to enlarge the entire diagram by | 15 | | padding | float | Margin around the border of the drawing | 10 | - -## Component `:BATTERY` - -Reference Designators: `B`, `BT`, `BAT` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| value | str | Battery voltage | *required* | -| capacity | str | Battery capacity in amp-hours | (no value) | - -## Component `:CAPACITOR` - -Reference Designators: `C`, `VC`, `CV` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| value | str | Capacitance in farads | *required* | -| voltage | str | Maximum voltage tolerance in volts | (no value) | - -## Component `:DIODE` - -Reference Designators: `D`, `CR`, `LED`, `IR` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| voltage | str | Maximum reverse voltage rating | (no value) | -| current | str | Maximum current rating | (no value) | - -## Component `:LED` - -Reference Designators: `D`, `CR`, `LED`, `IR` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| voltage | str | Maximum reverse voltage rating | (no value) | -| current | str | Maximum current rating | (no value) | - -## Component `:INDUCTOR` - -Reference Designators: `L`, `VL`, `LV` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| value | str | Inductance in henries | *required* | -| current | str | Maximum current rating in amps | (no value) | - -## Component `:RESISTOR` - -Reference Designators: `R`, `VR`, `RV` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | -| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | -| font | str | Text font for labels | 'monospace' | -| value | str | Resistance in ohms | *required* | -| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | diff --git a/schemascii/component.py b/schemascii/component.py index d53456d..c5731cc 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -21,7 +21,7 @@ class Component(_dc.DataConsumer, namespaces=(":component",)): all_components: typing.ClassVar[dict[str, type[Component]]] = {} options = [ - "inherit", + ..., _dc.Option("offset_scale", float, "How far to offset the label from the center of the " "component. Relative to the global scale option.", 1), @@ -172,10 +172,9 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # done return cls(rd, blobs, terminals) - def __init_subclass__(cls, ids: tuple[str, ...] = None, - namespaces: tuple[str, ...] = None, **kwargs): + def __init_subclass__(cls, ids: tuple[str, ...] = None): """Register the component subclass in the component registry.""" - super().__init_subclass__(namespaces=(namespaces or ()), **kwargs) + super().__init_subclass__(ids) if not ids: # allow anonymous helper classes return diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index 0acf253..e1af173 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -5,6 +5,7 @@ import schemascii.component as _c import schemascii.errors as _errors import schemascii.utils as _utils +import schemascii.data_consumer as _dc class SimpleComponent: @@ -52,3 +53,16 @@ def terminal_flags_opts(self): if self.always_polarized: return {"polarized": ("+", None)} return {"polarized": ("+", None), "unpolarized": (None, None)} + + +@dataclass +class SiliconComponent(_c.Component): + """Class for a part that doesn't have a traditional Metric value that + defines its behavior, but only a specific part number. + """ + + options = [ + ..., + _dc.Option("part-number", str, "The manufacturer-specified part " + "number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.)") + ] diff --git a/schemascii/components/battery.py b/schemascii/components/battery.py index e32e813..427b7bc 100644 --- a/schemascii/components/battery.py +++ b/schemascii/components/battery.py @@ -6,9 +6,9 @@ class Battery(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("B", "BT", "BAT"), namespaces=(":battery",)): + ids=("B", "BT", "BAT")): options = [ - "inherit", + ..., _dc.Option("value", str, "Battery voltage"), _dc.Option("capacity", str, "Battery capacity in amp-hours", None) ] diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py index d240570..3840357 100644 --- a/schemascii/components/capacitor.py +++ b/schemascii/components/capacitor.py @@ -6,9 +6,9 @@ class Capacitor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("C",), namespaces=(":capacitor",)): + ids=("C",)): options = [ - "inherit", + ..., _dc.Option("value", str, "Capacitance in farads"), _dc.Option("voltage", str, "Maximum voltage tolerance in volts", None) ] diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py index f52434c..3731bc0 100644 --- a/schemascii/components/diode.py +++ b/schemascii/components/diode.py @@ -1,26 +1,14 @@ import typing import schemascii.components as _c -import schemascii.data_consumer as _dc import schemascii.utils as _utils -class Diode(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("D", "CR"), namespaces=(":diode",)): +class Diode(_c.PolarizedTwoTerminalComponent, _c.SiliconComponent, + ids=("D", "CR")): always_polarized: typing.Final = True - options = [ - "inherit", - _dc.Option("voltage", str, "Maximum reverse voltage rating", None), - _dc.Option("current", str, "Maximum current rating", None) - ] - - @property - def value_format(self): - return [("voltage", "V", False), - ("current", "A", False)] - def render(self, **options) -> str: raise NotImplementedError return (_utils.bunch_o_lines(lines, **options) @@ -30,6 +18,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -class LED(Diode, ids=("LED", "IR"), namespaces=(":diode", ":led")): +class LED(Diode, ids=("LED", "IR")): def render(self, **options): raise NotImplementedError + +# TODO: zener diode, Schottky diode, DIAC, varactor, photodiode diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py index 6f19f85..2541564 100644 --- a/schemascii/components/inductor.py +++ b/schemascii/components/inductor.py @@ -9,9 +9,9 @@ class Inductor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("L",), namespaces=(":inductor",)): + ids=("L",)): options = [ - "inherit", + ..., _dc.Option("value", str, "Inductance in henries"), _dc.Option("current", str, "Maximum current rating in amps", None) ] diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py index 454834a..8ec8c74 100644 --- a/schemascii/components/resistor.py +++ b/schemascii/components/resistor.py @@ -21,8 +21,7 @@ def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]: return points -class Resistor(_c.TwoTerminalComponent, _c.SimpleComponent, - ids=("R",), namespaces=(":resistor",)): +class Resistor(_c.TwoTerminalComponent, _c.SimpleComponent, ids=("R",)): options = [ "inherit", _dc.Option("value", str, "Resistance in ohms"), diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 08ea81f..598052f 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -1,9 +1,10 @@ from __future__ import annotations import abc +import types import typing import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field import schemascii.data as _data import schemascii.errors as _errors @@ -20,7 +21,7 @@ class Option[T]: name: str type: type[T] | list[T] - help: str + help: str = field(repr=False) default: T = _OPT_IS_REQUIRED @@ -34,7 +35,7 @@ class DataConsumer(abc.ABC): """ options: typing.ClassVar[list[Option - | typing.Literal["inherit"] + | types.EllipsisType | tuple[str, ...]]] = [ Option("scale", float, "Scale by which to enlarge the " "entire diagram by", 15), @@ -74,9 +75,9 @@ def coalesce_options(cls: type[DataConsumer]) -> list[Option]: if DataConsumer not in cls.mro(): return [] seen_inherit = False - opts = [] + opts: list[Option] = [] for opt in cls.options: - if opt == "inherit": + if opt is ...: if seen_inherit: raise ValueError("can't use 'inherit' twice") for base in cls.__bases__: From 56cab42a2e67a6a56071e7c23b79ac2a5c2738bc Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:19:28 -0400 Subject: [PATCH 097/101] fixed decorator registering --- schemascii/__init__.py | 13 ++--- schemascii/annoline.py | 5 +- schemascii/annotation.py | 8 +-- schemascii/component.py | 76 ++++++++++-------------- schemascii/components/__init__.py | 9 ++- schemascii/components/battery.py | 12 ++-- schemascii/components/capacitor.py | 15 ++--- schemascii/components/diode.py | 10 ++-- schemascii/components/inductor.py | 15 ++--- schemascii/components/resistor.py | 14 +++-- schemascii/data.py | 15 ----- schemascii/data_consumer.py | 93 ++++++++++-------------------- schemascii/drawing.py | 8 +-- schemascii/net.py | 3 +- schemascii/wire.py | 3 +- schemascii/wire_tag.py | 3 +- 16 files changed, 124 insertions(+), 178 deletions(-) diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 5003d58..95fcda0 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -2,7 +2,7 @@ import os from typing import Any -import schemascii.components as _c +import schemascii.components as _cs import schemascii.data as _d import schemascii.drawing as _drawing @@ -10,11 +10,11 @@ def import_all_components(): - for root, _, files in os.walk(os.path.dirname(_c.__file__)): + for root, _, files in os.walk(os.path.dirname(_cs.__file__)): for f in files: if f.endswith(".py"): importlib.import_module("." + f.removesuffix(".py"), - _c.__package__) + _cs.__package__) import_all_components() @@ -28,8 +28,5 @@ def render(filename: str, text: str | None = None, if __name__ == "__main__": - import schemascii.components.resistor as _r - import schemascii.refdes as _rd - import schemascii.utils as _u - print(_r.Resistor(_rd.RefDes("R", 0, "", 0, 0), [[]], [ - _u.Terminal(0, "w", 0), _u.Terminal(0, "a", 0)])) + import schemascii.data_consumer as _d + print(_d.DataConsumer.registry) diff --git a/schemascii/annoline.py b/schemascii/annoline.py index 52ab4ee..b360fbb 100644 --- a/schemascii/annoline.py +++ b/schemascii/annoline.py @@ -8,11 +8,12 @@ import schemascii.data_consumer as _dc import schemascii.grid as _grid import schemascii.utils as _utils +import schemascii.annotation as _at +@_dc.DataConsumer.register(":annotation-line") @dataclass -class AnnotationLine(_dc.DataConsumer, - namespaces=(":annotation", ":annotation-line")): +class AnnotationLine(_at.Annotation): """Class that implements the ability to draw annotation lines on the drawing without having to use a disconnected wire. diff --git a/schemascii/annotation.py b/schemascii/annotation.py index feb2ff8..bb5e164 100644 --- a/schemascii/annotation.py +++ b/schemascii/annotation.py @@ -11,14 +11,14 @@ ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") +@_dc.DataConsumer.register(":annotation") @dataclass -class Annotation(_dc.DataConsumer, namespaces=(":annotation",)): +class Annotation(_dc.DataConsumer): """A chunk of text that will be rendered verbatim in the output SVG.""" - options = [ - ("scale",), + options = _dc.OptionsSet([ _dc.Option("font", str, "Text font", "sans-serif"), - ] + ], {"scale"}) position: complex content: str diff --git a/schemascii/component.py b/schemascii/component.py index c5731cc..561e0a6 100644 --- a/schemascii/component.py +++ b/schemascii/component.py @@ -14,20 +14,20 @@ import schemascii.wire as _wire +@_dc.DataConsumer.register(":component") @dataclass -class Component(_dc.DataConsumer, namespaces=(":component",)): +class Component(_dc.DataConsumer): """An icon representing a single electronic component.""" all_components: typing.ClassVar[dict[str, type[Component]]] = {} - options = [ + options = _dc.OptionsSet([ ..., _dc.Option("offset_scale", float, "How far to offset the label from the center of the " "component. Relative to the global scale option.", 1), _dc.Option("font", str, "Text font for labels", "monospace"), - - ] + ]) rd: _rd.RefDes blobs: list[list[complex]] # to support multiple parts. @@ -36,11 +36,16 @@ class Component(_dc.DataConsumer, namespaces=(":component",)): # Ellipsis can only appear at the end. this means like a wildcard meaning # that any other flag is suitable terminal_flag_opts: typing.ClassVar[ - dict[str, tuple[str | None] | types.EllipsisType]] = (None,) + dict[str, tuple[str | None] | types.EllipsisType]] = {} term_option: str = field(init=False) def __post_init__(self): + if len(self.terminal_flag_opts) == 0: + raise RuntimeError( + f"no terminal flag configuration options defined for { + self.__class__.__qualname__ + }") has_any = False # optimized check for number of terminals if they're all the same available_lengths = sorted(set(map( @@ -172,23 +177,27 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: # done return cls(rd, blobs, terminals) - def __init_subclass__(cls, ids: tuple[str, ...] = None): + @classmethod + def define[T: type[Component]]( + cls, ids: tuple[str, ...] = None) -> typing.Callable[[T], T]: """Register the component subclass in the component registry.""" - super().__init_subclass__(ids) - if not ids: - # allow anonymous helper classes - return - for id_letters in ids: - if not (id_letters.isalpha() and id_letters.upper() == id_letters): - raise ValueError( - f"invalid reference designator letters: {id_letters!r}") - if (id_letters in cls.all_components - and cls.all_components[id_letters] is not cls): - raise ValueError( - f"duplicate reference designator letters: {id_letters!r} " - f"(trying to register {cls!r}, already " - f"occupied by {cls.all_components[id_letters]!r})") - cls.all_components[id_letters] = cls + def doit(cls2: type[Component]): + if any(_dc.DataConsumer.registry.get(r, None) is cls2 + for r in ids): + raise RuntimeError("use either Component.define() or " + "DataConsumer.register(), not both") + for id in ids: + _dc.DataConsumer.register(id)(cls2) + for id_letters in ids: + if not (id_letters.isalpha() + and id_letters.upper() == id_letters): + raise ValueError( + f"invalid reference designator letters: { + id_letters!r + }") + cls.all_components[id_letters] = cls + return cls2 + return doit @property def css_class(self) -> str: @@ -203,28 +212,3 @@ def process_nets(self, nets: list[_net.Net]) -> None: mutate the list in-place (the return value is ignored). """ pass - - def get_terminals( - self, *flags_names: str) -> list[_utils.Terminal]: - """Return the component's terminals sorted so that the terminals with - the specified flags appear first in the order specified and the - remaining terminals come after. - - Raises an error if a terminal with the specified flag could not be - found, or there were multiple terminals with the requested flag - (ambiguous). - """ - out: list[_utils.Terminal] = [] - for flag in flags_names: - matching_terminals = [t for t in self.terminals if t.flag == flag] - if len(matching_terminals) > 1: - raise _errors.TerminalsError( - f"{self.rd.name}: multiple terminals with the " - f"same flag {flag!r}") - if len(matching_terminals) == 0: - raise _errors.TerminalsError( - f"{self.rd.name}: need a terminal with flag {flag!r}") - out.append(matching_terminals[0]) - out.extend(t for t in self.terminals if t.flag not in flags_names) - assert set(self.terminals) == set(out) - return out diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index e1af173..16f7d1e 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -8,7 +8,7 @@ import schemascii.data_consumer as _dc -class SimpleComponent: +class SimpleComponent(_c.Component): """Component mixin class that simplifies the formatting of the various values and their units into the id_text. """ @@ -33,7 +33,7 @@ def format_id_text(self: _c.Component | SimpleComponent, @dataclass -class TwoTerminalComponent(_c.Component): +class TwoTerminalComponent(SimpleComponent): """Shortcut to define a component with two terminals.""" terminal_flag_opts: typing.ClassVar = {"ok": (None, None)} is_variable: typing.ClassVar = False @@ -61,8 +61,7 @@ class SiliconComponent(_c.Component): defines its behavior, but only a specific part number. """ - options = [ - ..., + options = _dc.OptionsSet([ _dc.Option("part-number", str, "The manufacturer-specified part " "number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.)") - ] + ]) diff --git a/schemascii/components/battery.py b/schemascii/components/battery.py index 427b7bc..5fba3f3 100644 --- a/schemascii/components/battery.py +++ b/schemascii/components/battery.py @@ -1,17 +1,17 @@ from cmath import phase, rect -import schemascii.components as _c +import schemascii.component as _c +import schemascii.components as _cs import schemascii.data_consumer as _dc import schemascii.utils as _utils -class Battery(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("B", "BT", "BAT")): - options = [ - ..., +@_c.Component.define(("B", "BT", "BAT")) +class Battery(_cs.PolarizedTwoTerminalComponent): + options = _dc.OptionsSet([ _dc.Option("value", str, "Battery voltage"), _dc.Option("capacity", str, "Battery capacity in amp-hours", None) - ] + ]) @property def value_format(self): diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py index 3840357..f81d688 100644 --- a/schemascii/components/capacitor.py +++ b/schemascii/components/capacitor.py @@ -1,17 +1,17 @@ from cmath import phase, rect -import schemascii.components as _c +import schemascii.component as _c +import schemascii.components as _cs import schemascii.data_consumer as _dc import schemascii.utils as _utils -class Capacitor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("C",)): - options = [ - ..., +@_c.Component.define(("C")) +class Capacitor(_cs.PolarizedTwoTerminalComponent): + options = _dc.OptionsSet([ _dc.Option("value", str, "Capacitance in farads"), _dc.Option("voltage", str, "Maximum voltage tolerance in volts", None) - ] + ]) @property def value_format(self): @@ -37,7 +37,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -class VariableCapacitor(Capacitor, ids=("VC", "CV")): +@_c.Component.define(("VC", "CV")) +class VariableCapacitor(Capacitor): is_variable = True def render(self, **options): diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py index 3731bc0..857b43d 100644 --- a/schemascii/components/diode.py +++ b/schemascii/components/diode.py @@ -1,11 +1,12 @@ import typing -import schemascii.components as _c +import schemascii.component as _c +import schemascii.components as _cs import schemascii.utils as _utils -class Diode(_c.PolarizedTwoTerminalComponent, _c.SiliconComponent, - ids=("D", "CR")): +@_c.Component.define(("D", "CR")) +class Diode(_cs.PolarizedTwoTerminalComponent, _cs.SiliconComponent): always_polarized: typing.Final = True @@ -18,7 +19,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -class LED(Diode, ids=("LED", "IR")): +@_c.Component.define(("LED", "IR")) +class LED(Diode): def render(self, **options): raise NotImplementedError diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py index 2541564..6789cdd 100644 --- a/schemascii/components/inductor.py +++ b/schemascii/components/inductor.py @@ -1,6 +1,7 @@ from cmath import phase, rect -import schemascii.components as _c +import schemascii.components as _cs +import schemascii.component as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils import schemascii.svg as _svg @@ -8,13 +9,12 @@ # TODO: add dot on + end if inductor is polarized -class Inductor(_c.PolarizedTwoTerminalComponent, _c.SimpleComponent, - ids=("L",)): - options = [ - ..., +@_c.Component.define(("L",)) +class Inductor(_cs.PolarizedTwoTerminalComponent): + options = _dc.OptionsSet([ _dc.Option("value", str, "Inductance in henries"), _dc.Option("current", str, "Maximum current rating in amps", None) - ] + ]) @property def value_format(self): @@ -38,7 +38,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -class VariableInductor(Inductor, ids=("VL", "LV")): +@_c.Component.define(("VL", "LV")) +class VariableInductor(Inductor): is_variable = True def render(self, **options): diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py index 8ec8c74..f1a7324 100644 --- a/schemascii/components/resistor.py +++ b/schemascii/components/resistor.py @@ -1,6 +1,7 @@ from cmath import phase, pi, rect -import schemascii.components as _c +import schemascii.components as _cs +import schemascii.component as _c import schemascii.data_consumer as _dc import schemascii.utils as _utils @@ -21,13 +22,13 @@ def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]: return points -class Resistor(_c.TwoTerminalComponent, _c.SimpleComponent, ids=("R",)): - options = [ - "inherit", +@_c.Component.define(("R",)) +class Resistor(_cs.TwoTerminalComponent): + options = _dc.OptionsSet([ _dc.Option("value", str, "Resistance in ohms"), _dc.Option("wattage", str, "Maximum power dissipation in watts " "(i.e. size of the resistor)", None) - ] + ]) @property def value_format(self): @@ -42,7 +43,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -class VariableResistor(Resistor, ids=("VR", "RV")): +@_c.Component.define(("VR", "RV")) +class VariableResistor(Resistor): is_variable = True def render(self, **options): diff --git a/schemascii/data.py b/schemascii/data.py index a67b6a1..424d465 100644 --- a/schemascii/data.py +++ b/schemascii/data.py @@ -4,7 +4,6 @@ import typing from dataclasses import dataclass -import schemascii.data_consumer as _dc import schemascii.errors as _errors T = typing.TypeVar("T") @@ -65,20 +64,6 @@ class Data: sections: list[Section] - # mapping of scope name to list of available options - available_options: typing.ClassVar[dict[str, list[_dc.Option]]] = {} - - @classmethod - def define_option(cls, ns: str, opt: _dc.Option): - if ns in cls.available_options: - if any(eo.name == opt.name and eo != opt - for eo in cls.available_options[ns]): - raise ValueError(f"duplicate option name {opt.name!r}") - if opt not in cls.available_options[ns]: - cls.available_options[ns].append(opt) - else: - cls.available_options[ns] = [opt] - @classmethod def parse_from_string(cls, text: str, startline=1, filename="") -> Data: """Parses the data from the text. diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 598052f..1353c61 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import types import typing import warnings from dataclasses import dataclass, field @@ -10,6 +9,14 @@ import schemascii.errors as _errors import schemascii.svg as _svg + +@dataclass +class OptionsSet[T]: + self_opts: set[Option[T]] + inherit: set[str] | bool = True + inherit_from: list[type[DataConsumer]] | None = None + + _OPT_IS_REQUIRED = object() @@ -34,74 +41,24 @@ class DataConsumer(abc.ABC): options to render() as keyword arguments. """ - options: typing.ClassVar[list[Option - | types.EllipsisType - | tuple[str, ...]]] = [ + options: typing.ClassVar[OptionsSet] = OptionsSet([ Option("scale", float, "Scale by which to enlarge the " "entire diagram by", 15), Option("linewidth", float, "Width of drawn lines", 2), Option("color", str, "Default color for everything", "black"), - ] + ], False) css_class: typing.ClassVar[str] = "" - @property - def namespaces(self) -> tuple[str, ...]: - # base case to stop recursion - return () - - def __init_subclass__(cls, namespaces: tuple[str, ...] = None): - - if not namespaces: - # allow anonymous helper subclasses - return - - if not hasattr(cls, "namespaces"): - # don't clobber it if a subclass already overrides it! - @property - def __namespaces(self) -> tuple[str, ...]: - return super(type(self), self).namespaces + namespaces - cls.namepaces = __namespaces - - for b in cls.mro(): - if (b is not cls - and issubclass(b, DataConsumer) - and b.options is cls.options): - # if we literally just inherit the attribute, - # don't bother reprocessing it - just assign it in the - # namespaces - break - else: - def coalesce_options(cls: type[DataConsumer]) -> list[Option]: - if DataConsumer not in cls.mro(): - return [] - seen_inherit = False - opts: list[Option] = [] - for opt in cls.options: - if opt is ...: - if seen_inherit: - raise ValueError("can't use 'inherit' twice") - for base in cls.__bases__: - opts.extend(coalesce_options(base)) - seen_inherit = True - elif isinstance(opt, tuple): - for base in cls.__bases__: - opts.extend(o for o in coalesce_options(base) - if o.name in opt) - elif isinstance(opt, Option): - opts.append(opt) - else: - raise TypeError(f"unknown option definition: {opt!r}") - return opts - - cls.options = coalesce_options(cls) - - for ns in namespaces: - for option in cls.options: - _data.Data.define_option(ns, option) + registry: typing.ClassVar[dict[str, type[DataConsumer]]] = {} def to_xml_string(self, data: _data.Data) -> str: """Pull options relevant to this node from data, calls self.render(), and wraps the output in a .""" + # TODO: fix this with the new OptionsSet inheritance mode + # recurse to get all of the namespaces + # recurse to get all of the pulled values + # then the below + raise NotImplementedError values = {} for name in self.namespaces: values |= data.get_values_for(name) @@ -132,8 +89,8 @@ def to_xml_string(self, data: _data.Data) -> str: if any(opt.name == key for opt in self.options): continue warnings.warn( - f"unknown data key {key!r} for styling {self.namespaces[0]}", - stacklevel=2) + f"unknown data key {key!r} for {self.namespaces[0]}", + stacklevel=3) # render result = self.render(**values, data=data) if self.css_class: @@ -149,3 +106,17 @@ def render(self, data: _data.Data, **options) -> str: Subclasses must implement this method. """ raise NotImplementedError + + @classmethod + def register[T: type[DataConsumer]]( + cls, namespace: str | None = None) -> typing.Callable[[T], T]: + def register(cls2: type[DataConsumer]): + if namespace: + if namespace in cls.registry: + raise ValueError(f"{namespace} already registered") + cls.registry[namespace] = cls2 + if (cls2.options.inherit_from is None + and DataConsumer in cls2.mro()): + cls2.options.inherit_from = cls2.__bases__ + return cls2 + return register diff --git a/schemascii/drawing.py b/schemascii/drawing.py index 0564822..f505378 100644 --- a/schemascii/drawing.py +++ b/schemascii/drawing.py @@ -15,15 +15,15 @@ import schemascii.svg as _svg +@_dc.DataConsumer.register(":root") @dataclass -class Drawing(_dc.DataConsumer, namespaces=(":root",)): +class Drawing(_dc.DataConsumer): """A Schemascii drawing document.""" - options = [ - ("scale",), + options = _dc.OptionsSet([ _dc.Option("padding", float, "Margin around the border of the drawing", 10), - ] + ], {"scale"}) nets: list[_net.Net] components: list[_component.Component] diff --git a/schemascii/net.py b/schemascii/net.py index 953f183..9950362 100644 --- a/schemascii/net.py +++ b/schemascii/net.py @@ -8,8 +8,9 @@ import schemascii.wire_tag as _wt +@_dc.DataConsumer.register(":net") @dataclass -class Net(_dc.DataConsumer, namespaces=(":net",)): +class Net(_dc.DataConsumer): """Grouping of wires that are electrically connected. """ diff --git a/schemascii/wire.py b/schemascii/wire.py index ca00953..a60d0e4 100644 --- a/schemascii/wire.py +++ b/schemascii/wire.py @@ -12,8 +12,9 @@ import schemascii.wire_tag as _wt +@_dc.DataConsumer.register(":wire") @dataclass -class Wire(_dc.DataConsumer, namespaces=(":wire",)): +class Wire(_dc.DataConsumer): """List of grid points along a wire that are electrically connected. """ diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py index ef2eeaa..d33fad7 100644 --- a/schemascii/wire_tag.py +++ b/schemascii/wire_tag.py @@ -12,8 +12,9 @@ WIRE_TAG_PAT = re.compile(r"<([^\s=]+)=|=([^\s>]+)>") +@_dc.DataConsumer.register(":wire-tag") @dataclass -class WireTag(_dc.DataConsumer, namespaces=(":wire-tag",)): +class WireTag(_dc.DataConsumer): """A wire tag is a named flag on the end of the wire, that gives it a name and also indicates what direction information flows. From 228db389f9facacd2dd750de1e8fbed95005e1c5 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 15 Apr 2025 08:29:31 -0400 Subject: [PATCH 098/101] docs script is all messed up because of inheritance --- options.md | 141 +++++++++++++++++++++++----- schemascii/OLD_components_render.py | 20 ++-- schemascii/__init__.py | 16 +++- schemascii/component.py | 19 ++-- schemascii/components/__init__.py | 10 ++ schemascii/components/battery.py | 2 +- schemascii/components/capacitor.py | 8 +- schemascii/components/diode.py | 9 +- schemascii/components/inductor.py | 12 +-- schemascii/components/resistor.py | 8 +- schemascii/data_consumer.py | 61 +++++++++--- schemascii/errors.py | 2 +- schemascii/svg.py | 4 +- schemascii/utils.py | 14 +-- scripts/docs.py | 59 +++++++----- 15 files changed, 271 insertions(+), 114 deletions(-) diff --git a/options.md b/options.md index eca7e45..d1e993e 100644 --- a/options.md +++ b/options.md @@ -1,53 +1,147 @@ - # Data Section Options - + +*This file was automatically generated by scripts/docs.py on Tue Apr 15 08:28:35 2025* -## Scope `:WIRE-TAG` +## Scope `:ANNOTATION` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | +| font | str | Text font | 'sans-serif' | -## Scope `:WIRE` +Inherits `scale` from: DataConsumer + +## Scope `:ANNOTATION` OR `:ANNOTATION-LINE` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | +| font | str | Text font | 'sans-serif' | -## Scope `:NET` +Inherits `scale` from: DataConsumer + +## Component `:COMPONENT` OR `:BATTERY` + +Reference Designators: `B`, `BT`, `BAT` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | +| value | str | Battery voltage | *required* | +| capacity | str | Battery capacity in amp-hours | (no value) | + +Inherits values from: PolarizedTwoTerminalComponent + +## Component `:COMPONENT` OR `:CAPACITOR` + +Reference Designators: `C` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Capacitance in farads | *required* | +| voltage | str | Maximum voltage tolerance in volts | (no value) | + +Inherits values from: PolarizedTwoTerminalComponent ## Scope `:COMPONENT` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | -## Scope `:ANNOTATION` +Inherits values from: DataConsumer + +## Component `:COMPONENT` OR `:DIODE` + +Reference Designators: `D`, `CR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | + +Inherits values from: PolarizedTwoTerminalComponent, SiliconComponent + +## Scope `:ROOT` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| padding | float | Margin around the border of the drawing | 10 | + +Inherits `scale` from: DataConsumer + +## Component `:COMPONENT` OR `:INDUCTOR` + +Reference Designators: `L` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Inductance in henries | *required* | +| current | str | Maximum current rating in amps | (no value) | + +Inherits values from: PolarizedTwoTerminalComponent + +## Component `:COMPONENT` OR `:DIODE` OR `D` OR `CR` + +Reference Designators: `LED`, `IR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | + +Inherits values from: PolarizedTwoTerminalComponent, SiliconComponent + +## Scope `:NET` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | scale | float | Scale by which to enlarge the entire diagram by | 15 | | linewidth | float | Width of drawn lines | 2 | | color | str | Default color for everything | 'black' | -| font | str | Text font | 'sans-serif' | -## Scope `:ANNOTATION-LINE` +## Component `:COMPONENT` OR `:RESISTOR` + +Reference Designators: `R` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Resistance in ohms | *required* | +| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | + +Inherits values from: TwoTerminalComponent + +## Component `:COMPONENT` OR `:VARIABLE` OR `:CAPACITOR` OR `C` + +Reference Designators: `VC`, `CV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Capacitance in farads | *required* | +| voltage | str | Maximum voltage tolerance in volts | (no value) | + +Inherits values from: PolarizedTwoTerminalComponent + +## Component `:COMPONENT` OR `:VARIABLE` OR `:INDUCTOR` OR `L` + +Reference Designators: `VL`, `LV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Inductance in henries | *required* | +| current | str | Maximum current rating in amps | (no value) | + +Inherits values from: PolarizedTwoTerminalComponent + +## Component `:COMPONENT` OR `:VARIABLE` OR `:RESISTOR` OR `R` + +Reference Designators: `VR`, `RV` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Resistance in ohms | *required* | +| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | + +Inherits values from: TwoTerminalComponent + +## Scope `:WIRE` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| @@ -55,9 +149,10 @@ on Sun Apr 13 20:18:36 2025 --> | linewidth | float | Width of drawn lines | 2 | | color | str | Default color for everything | 'black' | -## Scope `:ROOT` +## Scope `:WIRE-TAG` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | scale | float | Scale by which to enlarge the entire diagram by | 15 | -| padding | float | Margin around the border of the drawing | 10 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | diff --git a/schemascii/OLD_components_render.py b/schemascii/OLD_components_render.py index 07ae587..51f70e6 100644 --- a/schemascii/OLD_components_render.py +++ b/schemascii/OLD_components_render.py @@ -110,7 +110,7 @@ def diode(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): ] triangle = deep_transform((-0.3j, 0.3 + 0.3j, -0.3 + 0.3j), mid, angle) light_emitting = box.type in ("LED", "IR") - fill_override = {"stroke": bom_data.data} if box.type == "LED" else {} + fill_override = {"color": bom_data.data} if box.type == "LED" else {} return ( (light_arrows(mid, angle, True, **options) if light_emitting else "") + id_text( @@ -151,8 +151,8 @@ def integrated_circuit( y=box.p1.imag * scale, width=sz.real, height=sz.imag, - stroke__width=options["stroke_width"], - stroke=options["stroke"], + stroke__width=options["linewidth"], + stroke=options["color"], fill="transparent", ) for term in terminals: @@ -167,7 +167,7 @@ def integrated_circuit( y=mid.imag, text__anchor="middle", font__size=options["scale"], - fill=options["stroke"], + fill=options["color"], ) mid -= 1j * scale if "L" in label_style and not options["nolabels"]: @@ -177,7 +177,7 @@ def integrated_circuit( y=mid.imag, text__anchor="middle", font__size=options["scale"], - fill=options["stroke"], + fill=options["color"], ) s_terminals = sort_terminals_counterclockwise(terminals) for terminal, label in zip(s_terminals, pin_labels): @@ -191,7 +191,7 @@ def integrated_circuit( Side.TOP, Side.BOTTOM)) else "middle" ), font__size=options["scale"], - fill=options["stroke"], + fill=options["color"], class_="pin-label", ) warn( @@ -228,8 +228,8 @@ def jack(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): cx=sc_t2.real, cy=sc_t2.imag, r=scale / 4, - stroke__width=options["stroke_width"], - stroke=options["stroke"], + stroke__width=options["linewidth"], + stroke=options["color"], fill="transparent", ) + id_text(box, bom_data, terminals, None, sc_text_pt, **options) @@ -380,13 +380,13 @@ def switch(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): cy=(rect(-scale, angle) + mid * scale).imag, r=scale / 4, stroke="transparent", - fill=options["stroke"], + fill=options["color"], class_="filled") + xmltag("circle", cx=(rect(scale, angle) + mid * scale).real, cy=(rect(scale, angle) + mid * scale).imag, r=scale / 4, stroke="transparent", - fill=options["stroke"], + fill=options["color"], class_="filled") + bunch_o_lines([ (t1, mid + rect(1, angle)), diff --git a/schemascii/__init__.py b/schemascii/__init__.py index 95fcda0..a104fab 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,5 +1,6 @@ import importlib import os +import re from typing import Any import schemascii.components as _cs @@ -12,7 +13,10 @@ def import_all_components(): for root, _, files in os.walk(os.path.dirname(_cs.__file__)): for f in files: - if f.endswith(".py"): + # ignore dunder __init__ file + # if we try to import that it gets run twice + # which tries to double-register stuff and causes problems + if re.match(r"(?!__)\w+(? tuple[str, ...]: - return self.rd.name, self.rd.short_name, self.rd.letter, ":component" + def dynamic_namespaces(self): + return self.rd.letter, self.rd.short_name, self.rd.name @classmethod def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: @@ -179,13 +177,12 @@ def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component: @classmethod def define[T: type[Component]]( - cls, ids: tuple[str, ...] = None) -> typing.Callable[[T], T]: + cls, scope: str | None, + ids: tuple[str, ...]) -> typing.Callable[[T], T]: """Register the component subclass in the component registry.""" def doit(cls2: type[Component]): - if any(_dc.DataConsumer.registry.get(r, None) is cls2 - for r in ids): - raise RuntimeError("use either Component.define() or " - "DataConsumer.register(), not both") + if scope: + _dc.DataConsumer.register(scope)(cls2) for id in ids: _dc.DataConsumer.register(id)(cls2) for id_letters in ids: @@ -195,7 +192,7 @@ def doit(cls2: type[Component]): f"invalid reference designator letters: { id_letters!r }") - cls.all_components[id_letters] = cls + cls.all_components[id_letters] = cls2 return cls2 return doit diff --git a/schemascii/components/__init__.py b/schemascii/components/__init__.py index 16f7d1e..d1cd57a 100644 --- a/schemascii/components/__init__.py +++ b/schemascii/components/__init__.py @@ -39,6 +39,16 @@ class TwoTerminalComponent(SimpleComponent): is_variable: typing.ClassVar = False +@_dc.DataConsumer.register(":variable") +class VariableComponent(TwoTerminalComponent): + """Inherit from this class to get the variable scope + and is_variable: true. + """ + + is_variable: typing.Final = True + not_for_docs: typing.Final = True + + @dataclass class PolarizedTwoTerminalComponent(TwoTerminalComponent): """Helper class that ensures that a component has only two terminals, diff --git a/schemascii/components/battery.py b/schemascii/components/battery.py index 5fba3f3..77a8b0a 100644 --- a/schemascii/components/battery.py +++ b/schemascii/components/battery.py @@ -6,7 +6,7 @@ import schemascii.utils as _utils -@_c.Component.define(("B", "BT", "BAT")) +@_c.Component.define(":battery", ("B", "BT", "BAT")) class Battery(_cs.PolarizedTwoTerminalComponent): options = _dc.OptionsSet([ _dc.Option("value", str, "Battery voltage"), diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py index f81d688..2d89800 100644 --- a/schemascii/components/capacitor.py +++ b/schemascii/components/capacitor.py @@ -6,7 +6,7 @@ import schemascii.utils as _utils -@_c.Component.define(("C")) +@_c.Component.define(":capacitor", ("C")) class Capacitor(_cs.PolarizedTwoTerminalComponent): options = _dc.OptionsSet([ _dc.Option("value", str, "Capacitance in farads"), @@ -37,10 +37,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -@_c.Component.define(("VC", "CV")) -class VariableCapacitor(Capacitor): - is_variable = True - +@_c.Component.define(None, ("VC", "CV")) +class VariableCapacitor(Capacitor, _cs.VariableComponent): def render(self, **options): t1, t2 = self.terminals[0].pt, self.terminals[1].pt return (super().render(**options) diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py index 857b43d..e0df109 100644 --- a/schemascii/components/diode.py +++ b/schemascii/components/diode.py @@ -1,14 +1,11 @@ -import typing - import schemascii.component as _c import schemascii.components as _cs import schemascii.utils as _utils -@_c.Component.define(("D", "CR")) +@_c.Component.define(":diode", ("D", "CR")) class Diode(_cs.PolarizedTwoTerminalComponent, _cs.SiliconComponent): - - always_polarized: typing.Final = True + always_polarized = True def render(self, **options) -> str: raise NotImplementedError @@ -19,7 +16,7 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -@_c.Component.define(("LED", "IR")) +@_c.Component.define(None, ("LED", "IR")) class LED(Diode): def render(self, **options): raise NotImplementedError diff --git a/schemascii/components/inductor.py b/schemascii/components/inductor.py index 6789cdd..f5a897b 100644 --- a/schemascii/components/inductor.py +++ b/schemascii/components/inductor.py @@ -9,7 +9,7 @@ # TODO: add dot on + end if inductor is polarized -@_c.Component.define(("L",)) +@_c.Component.define(":inductor", ("L",)) class Inductor(_cs.PolarizedTwoTerminalComponent): options = _dc.OptionsSet([ _dc.Option("value", str, "Inductance in henries"), @@ -32,16 +32,14 @@ def render(self, **options) -> str: for _ in range(int(length)): data += f"a1 1 0 01 {-d.real} {d.imag}" return ( - _svg.path(data, "transparent", options["stroke_width"], - options["stroke"]) + _svg.path(data, "transparent", options["linewidth"], + options["color"]) + self.format_id_text( _utils.make_text_point(t1, t2, **options), **options)) -@_c.Component.define(("VL", "LV")) -class VariableInductor(Inductor): - is_variable = True - +@_c.Component.define(None, ("VL", "LV")) +class VariableInductor(Inductor, _cs.VariableComponent): def render(self, **options): t1, t2 = self.terminals[0].pt, self.terminals[1].pt return (super().render(**options) diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py index f1a7324..8a15a91 100644 --- a/schemascii/components/resistor.py +++ b/schemascii/components/resistor.py @@ -22,7 +22,7 @@ def _ansi_resistor_squiggle(t1: complex, t2: complex) -> list[complex]: return points -@_c.Component.define(("R",)) +@_c.Component.define(":resistor", ("R",)) class Resistor(_cs.TwoTerminalComponent): options = _dc.OptionsSet([ _dc.Option("value", str, "Resistance in ohms"), @@ -43,10 +43,8 @@ def render(self, **options) -> str: _utils.make_text_point(t1, t2, **options), **options)) -@_c.Component.define(("VR", "RV")) -class VariableResistor(Resistor): - is_variable = True - +@_c.Component.define(None, ("VR", "RV")) +class VariableResistor(Resistor, _cs.VariableComponent): def render(self, **options): t1, t2 = self.terminals[0].pt, self.terminals[1].pt return (super().render(**options) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py index 1353c61..adc8567 100644 --- a/schemascii/data_consumer.py +++ b/schemascii/data_consumer.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import itertools import typing import warnings from dataclasses import dataclass, field @@ -12,7 +13,7 @@ @dataclass class OptionsSet[T]: - self_opts: set[Option[T]] + self_opts: list[Option[T]] inherit: set[str] | bool = True inherit_from: list[type[DataConsumer]] | None = None @@ -46,34 +47,66 @@ class DataConsumer(abc.ABC): "entire diagram by", 15), Option("linewidth", float, "Width of drawn lines", 2), Option("color", str, "Default color for everything", "black"), - ], False) + ], False) # don't inherit, this is the base case + css_class: typing.ClassVar[str] = "" registry: typing.ClassVar[dict[str, type[DataConsumer]]] = {} + def dynamic_namespaces() -> tuple[str, ...]: + return () + + @classmethod + @typing.final + def get_namespaces(cls) -> list[str]: + copt = cls.options + nss = [ + k for k, v in cls.registry.items() if v is cls] + if copt.inherit: + nss.extend(itertools.chain.from_iterable( + p.get_namespaces() for p in copt.inherit_from)) + return nss + + @classmethod + @typing.final + def get_options(cls) -> list[Option]: + copt = cls.options + out = copt.self_opts.copy() + if copt.inherit: + paropts: itertools.chain[Option] = itertools.chain.from_iterable( + p.get_options() for p in copt.inherit_from) + if isinstance(copt.inherit, bool): + out.extend(paropts) + else: + out.extend(opt for opt in paropts + if opt.name in copt.inherit) + return out + def to_xml_string(self, data: _data.Data) -> str: """Pull options relevant to this node from data, calls self.render(), and wraps the output in a .""" - # TODO: fix this with the new OptionsSet inheritance mode # recurse to get all of the namespaces + namespaces = list(itertools.chain( + self.get_namespaces(), self.dynamic_namespaces())) # recurse to get all of the pulled values + options = self.get_options() # then the below - raise NotImplementedError values = {} - for name in self.namespaces: + for name in namespaces: values |= data.get_values_for(name) # validate the options - for opt in self.options: + print("***", options) + for opt in options: if opt.name not in values: if opt.default is _OPT_IS_REQUIRED: raise _errors.NoDataError( - f"missing value for {self.namespaces[0]}.{name}") + f"missing value for {namespaces[-1]}.{opt.name}") values[opt.name] = opt.default continue if isinstance(opt.type, list): if values[opt.name] not in opt.type: raise _errors.BOMError( - f"{self.namespaces[0]}.{opt.name}: " + f"{namespaces[-1]}.{opt.name}: " f"invalid choice: {values[opt.name]!r} " f"(valid options are " f"{', '.join(map(repr, opt.type))})") @@ -82,14 +115,14 @@ def to_xml_string(self, data: _data.Data) -> str: values[opt.name] = opt.type(values[opt.name]) except ValueError as err: raise _errors.DataTypeError( - f"option {self.namespaces[0]}.{opt.name}: " + f"option {namespaces[-1]}.{opt.name}: " f"invalid {opt.type.__name__} value: " f"{values[opt.name]!r}") from err for key in values: - if any(opt.name == key for opt in self.options): + if any(opt.name == key for opt in options): continue warnings.warn( - f"unknown data key {key!r} for {self.namespaces[0]}", + f"unknown data key {key!r} for {namespaces[-1]}", stacklevel=3) # render result = self.render(**values, data=data) @@ -105,15 +138,17 @@ def render(self, data: _data.Data, **options) -> str: Subclasses must implement this method. """ - raise NotImplementedError @classmethod + @typing.final def register[T: type[DataConsumer]]( cls, namespace: str | None = None) -> typing.Callable[[T], T]: def register(cls2: type[DataConsumer]): if namespace: if namespace in cls.registry: - raise ValueError(f"{namespace} already registered") + raise ValueError(f"{namespace} already registered as { + cls.registry[namespace] + }") cls.registry[namespace] = cls2 if (cls2.options.inherit_from is None and DataConsumer in cls2.mro()): diff --git a/schemascii/errors.py b/schemascii/errors.py index f818f99..2ecf38d 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -18,7 +18,7 @@ class UnsupportedComponentError(NameError, Error): """Component type is not supported.""" -class NoDataError(KeyError, Error): +class NoDataError(ValueError, Error): """Data item is required, but not present.""" diff --git a/schemascii/svg.py b/schemascii/svg.py index 2ed9ae8..26bc621 100644 --- a/schemascii/svg.py +++ b/schemascii/svg.py @@ -41,9 +41,9 @@ def group(*items: str, class_: OrFalse[str] = False) -> str: def path(data: str, fill: OrFalse[str] = False, - stroke_width: OrFalse[float] = False, stroke: OrFalse[str] = False, + linewidth: OrFalse[float] = False, stroke: OrFalse[str] = False, class_: OrFalse[str] = False) -> str: - return xmltag("path", d=data, fill=fill, stroke__width=stroke_width, + return xmltag("path", d=data, fill=fill, stroke__width=linewidth, stroke=stroke, class_=class_) diff --git a/schemascii/utils.py b/schemascii/utils.py index 3766d8f..08b4122 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -272,8 +272,8 @@ def deep_transform(data: DT_Struct, origin: complex, theta: float): type(data).__name__) from err -def _get_sss(options: dict) -> tuple[float, float, str]: - return options["scale"], options["stroke_width"], options["stroke"] +def _get_slc(options: dict) -> tuple[float, float, str]: + return options["scale"], options["linewidth"], options["color"] def points2path(points: list[complex], close: bool = False) -> str: @@ -315,13 +315,13 @@ def polylinegon( If is_polygon is true, stroke color is used as fill color instead and stroke width is ignored. """ - scale, stroke_width, stroke = _get_sss(options) + scale, linewidth, stroke = _get_slc(options) scaled_pts = [x * scale for x in points] if is_polygon: return _svg.path(points2path(scaled_pts, True), stroke, class_="filled") return _svg.path(points2path(scaled_pts, False), "transparent", - stroke_width, stroke) + linewidth, stroke) def find_dots(points: list[tuple[complex, complex]]) -> list[complex]: @@ -341,7 +341,7 @@ def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: """Combine the pairs (p1, p2) into a set of SVG commands to draw all of the lines. """ - scale, stroke_width, stroke = _get_sss(options) + scale, linewidth, stroke = _get_slc(options) lines = [] while pairs: group = take_next_group(pairs) @@ -352,7 +352,7 @@ def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> str: data = "" for line in lines: data += points2path([x * scale for x in line], False) - return _svg.path(data, "transparent", stroke_width, stroke) + return _svg.path(data, "transparent", linewidth, stroke) def id_text( @@ -365,7 +365,7 @@ def id_text( **options) -> str: """Format the component ID and value around the point.""" nolabels, label = options["nolabels"], options["label"] - scale, stroke = options["scale"], options["stroke"] + scale, stroke = options["scale"], options["color"] font = options["font"] if nolabels: return "" diff --git a/scripts/docs.py b/scripts/docs.py index 95b64be..d74cf40 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -9,22 +9,27 @@ try: sys.path.append(os.path.pardir) + import schemascii # noqa: F401 from schemascii.component import Component - from schemascii.data import Data - from schemascii.data_consumer import _OPT_IS_REQUIRED, Option + from schemascii.data_consumer import ( + _OPT_IS_REQUIRED, DataConsumer, Option, OptionsSet) except ModuleNotFoundError: raise def output_file(filename: os.PathLike, heading: str, content: str): - spit(filename, f""" -# {heading} + spit(filename, f"""# {heading} - + +*This file was automatically generated by scripts/docs.py on { + datetime.datetime.now().ctime()}* """ + content) +def get_scopes_for_cls(cls: type[DataConsumer]): + return [k for k, v in DataConsumer.registry.items() if v in cls.mro()] + + def format_option(opt: Option) -> str: typename = (opt.type.__name__ if isinstance(opt.type, type) @@ -37,7 +42,7 @@ def format_option(opt: Option) -> str: return f"| {' | '.join(items)} |\n" -def format_scope(scopename: str, options: list[Option], +def format_scope(scopename: str, options: OptionsSet, interj: str, head: str) -> str: scope_text = f""" ## {head} `{scopename.upper()}`{"\n\n" + interj if interj else ""} @@ -45,32 +50,44 @@ def format_scope(scopename: str, options: list[Option], | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| """ - for option in options: + for option in options.self_opts: scope_text += format_option(option) + if options.inherit: + classes = [p.__name__ for p in options.inherit_from] + if isinstance(options.inherit, bool): + vt = "values" + else: + vt = ", ".join(f"`{x}`" for x in sorted(options.inherit)) + scope_text += f""" +Inherits {vt} from: {", ".join(classes)} +""" return scope_text -def get_RDs(scopename: str) -> list[str] | None: - out = [] - tgt_list = Data.available_options[scopename] - for component in Component.all_components: - if Component.all_components[component].options == tgt_list: - out.append(component) - if out: - return out - return None +def get_RDs_for_cls(cls: type[Component]) -> list[str]: + return [k for k, v in Component.all_components.items() if v is cls] def main(): - content = "" - for s, d in Data.available_options.items(): + content: str = "" + classes: list[type[DataConsumer]] = sorted( + set(DataConsumer.registry.values()), + key=lambda cls: cls.__name__) + print("all classes: ", classes) + for d in classes: + if "not_for_docs" in d.__dict__ and d.not_for_docs: + continue + scopes = get_scopes_for_cls(d) + s = "` or `".join(scopes) rds_line = "" heading = "Scope" - if (rds := get_RDs(s)) is not None: + if issubclass(d, Component) and d is not Component: + rds = get_RDs_for_cls(d) + s = "` or `".join(sc for sc in scopes if sc not in rds) heading = "Component" rds_line = "Reference Designators: " rds_line += ", ".join(f"`{x}`" for x in rds) - content += format_scope(s, d, rds_line, heading) + content += format_scope(s, d.options, rds_line, heading) output_file("options.md", "Data Section Options", content) From 52c1516cad396243da6cd6d0ab7e7b2792a76c61 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:45:39 -0400 Subject: [PATCH 099/101] a mess --- options.md | 2 +- scripts/docs.py | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/options.md b/options.md index d1e993e..efba865 100644 --- a/options.md +++ b/options.md @@ -1,7 +1,7 @@ # Data Section Options -*This file was automatically generated by scripts/docs.py on Tue Apr 15 08:28:35 2025* +*This file was automatically generated by scripts/docs.py on Tue Apr 15 09:57:38 2025* ## Scope `:ANNOTATION` diff --git a/scripts/docs.py b/scripts/docs.py index d74cf40..27c8c32 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -4,19 +4,29 @@ import datetime import os import sys +from pprint import pprint + +# TODO: this is an awful mess +# need to clean it up so it actually produces useful documentation from scriptutils import spit try: - sys.path.append(os.path.pardir) + sys.path.append(os.path.realpath(os.path.pardir)) import schemascii # noqa: F401 from schemascii.component import Component - from schemascii.data_consumer import ( - _OPT_IS_REQUIRED, DataConsumer, Option, OptionsSet) + from schemascii.data_consumer import (_OPT_IS_REQUIRED, DataConsumer, + Option, OptionsSet) except ModuleNotFoundError: + # dummy to prevent isort from putting the + # imports above the sys.path.append raise +def uniq_sameorder[T](xs: list[T]) -> list[T]: + return sorted(set(xs), key=lambda x: xs.index(x)) + + def output_file(filename: os.PathLike, heading: str, content: str): spit(filename, f"""# {heading} @@ -68,7 +78,13 @@ def get_RDs_for_cls(cls: type[Component]) -> list[str]: return [k for k, v in Component.all_components.items() if v is cls] +def classes_inorder(): + return uniq_sameorder(sorted(DataConsumer.registry.values(), + key=lambda cls: len(cls.mro()))) + + def main(): + pprint(("in order", classes_inorder())) content: str = "" classes: list[type[DataConsumer]] = sorted( set(DataConsumer.registry.values()), From 96ee5e6959953593b764ddbcd48a1725edcfac1d Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 21 Apr 2025 11:12:43 -0400 Subject: [PATCH 100/101] better error messages at command line --- schemascii/__main__.py | 8 +++++--- schemascii/errors.py | 13 +++++++++++++ scripts/scriptutils.py | 5 +---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index e712adf..e1b49e1 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -1,4 +1,5 @@ import argparse +import os import sys import warnings @@ -69,13 +70,15 @@ def cli_main(): if args.out_file is None: args.out_file = sys.stdout elif args.out_file is None: - args.out_file = open(args.in_file.name + ".svg") + args.out_file = open(args.in_file.name + ".svg", "w") try: with warnings.catch_warnings(record=True) as captured_warnings: result_svg = schemascii.render(args.in_file.name, args.in_file.read(), args.fudge) except _errors.Error as err: - ap.error(str(err)) + if args.out_file is not sys.stdout: + os.unlink(args.out_file.name) + ap.error(err.nice_message()) if captured_warnings: for warning in captured_warnings: @@ -86,7 +89,6 @@ def cli_main(): sys.exit(1) args.out_file.write(result_svg) - finally: args.in_file.close() args.out_file.close() diff --git a/schemascii/errors.py b/schemascii/errors.py index 2ecf38d..aadbb59 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -1,26 +1,39 @@ class Error(Exception): """A generic Schemascii error encountered when rendering a drawing.""" + message_prefix: str | None = None + + def nice_message(self) -> str: + return f"{self.message_prefix}{ + ": " if self.message_prefix else "" + }{self!s}" + class DiagramSyntaxError(SyntaxError, Error): """Bad formatting in Schemascii diagram syntax.""" + message_prefix = "syntax error" class TerminalsError(ValueError, Error): """Incorrect usage of terminals on this component.""" + message_prefix = "terminals error" class BOMError(ValueError, Error): """Problem with BOM data for a component.""" + message_prefix = "BOM error" class UnsupportedComponentError(NameError, Error): """Component type is not supported.""" + message_prefix = "unsupported component" class NoDataError(ValueError, Error): """Data item is required, but not present.""" + message_prefix = "missing data item" class DataTypeError(TypeError, Error): """Invalid data type in data section.""" + message_prefix = "invalid data type" diff --git a/scripts/scriptutils.py b/scripts/scriptutils.py index a0c419b..a73d7f2 100644 --- a/scripts/scriptutils.py +++ b/scripts/scriptutils.py @@ -1,11 +1,8 @@ import os import sys -import typing # pylint: disable=missing-function-docstring -T = typing.TypeVar("T") - def cmd(sh_line: str, say: bool = True): if say: @@ -25,6 +22,6 @@ def spit(file: os.PathLike, text: str): f.write(text) -def spy(value: T) -> T: +def spy[T](value: T) -> T: print(value) return value From 3a1071de90aea5888f61c35d4d300974aa292ef0 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:07:05 -0400 Subject: [PATCH 101/101] github copilot made an even worse mess of the docs script --- options.md | 139 +++++++++++++++--------------------------------- scripts/docs.py | 103 ++++++++++++++++++++++------------- 2 files changed, 110 insertions(+), 132 deletions(-) diff --git a/options.md b/options.md index efba865..8a2881f 100644 --- a/options.md +++ b/options.md @@ -1,158 +1,105 @@ # Data Section Options -*This file was automatically generated by scripts/docs.py on Tue Apr 15 09:57:38 2025* +*This file was automatically generated by scripts/docs.py on Mon Apr 21 14:06:17 2025* -## Scope `:ANNOTATION` +## Scope `:wire-tag` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| font | str | Text font | 'sans-serif' | - -Inherits `scale` from: DataConsumer - -## Scope `:ANNOTATION` OR `:ANNOTATION-LINE` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| font | str | Text font | 'sans-serif' | - -Inherits `scale` from: DataConsumer - -## Component `:COMPONENT` OR `:BATTERY` +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | -Reference Designators: `B`, `BT`, `BAT` +## Scope `:wire` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| value | str | Battery voltage | *required* | -| capacity | str | Battery capacity in amp-hours | (no value) | - -Inherits values from: PolarizedTwoTerminalComponent - -## Component `:COMPONENT` OR `:CAPACITOR` +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | -Reference Designators: `C` +## Scope `:net` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| value | str | Capacitance in farads | *required* | -| voltage | str | Maximum voltage tolerance in volts | (no value) | - -Inherits values from: PolarizedTwoTerminalComponent +| scale | float | Scale by which to enlarge the entire diagram by | 15 | +| linewidth | float | Width of drawn lines | 2 | +| color | str | Default color for everything | 'black' | -## Scope `:COMPONENT` +## Scope `:component` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | | font | str | Text font for labels | 'monospace' | +| offset_scale | float | How far to offset the label from the center of the component. Relative to the global scale option. | 1 | +| font | str | Text font for labels | 'monospace' | -Inherits values from: DataConsumer - -## Component `:COMPONENT` OR `:DIODE` - -Reference Designators: `D`, `CR` +## Scope `:annotation` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | - -Inherits values from: PolarizedTwoTerminalComponent, SiliconComponent +| font | str | Text font | 'sans-serif' | -## Scope `:ROOT` +## Scope `:root` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | padding | float | Margin around the border of the drawing | 10 | +| padding | float | Margin around the border of the drawing | 10 | -Inherits `scale` from: DataConsumer - -## Component `:COMPONENT` OR `:INDUCTOR` - -Reference Designators: `L` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| value | str | Inductance in henries | *required* | -| current | str | Maximum current rating in amps | (no value) | - -Inherits values from: PolarizedTwoTerminalComponent - -## Component `:COMPONENT` OR `:DIODE` OR `D` OR `CR` - -Reference Designators: `LED`, `IR` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | - -Inherits values from: PolarizedTwoTerminalComponent, SiliconComponent - -## Scope `:NET` +## Scope `:annotation` or `:annotation-line` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | - -## Component `:COMPONENT` OR `:RESISTOR` +| font | str | Text font | 'sans-serif' | -Reference Designators: `R` +## Component `:component` or `:resistor` or `R` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | value | str | Resistance in ohms | *required* | | wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | +| value | str | Resistance in ohms | *required* | +| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | -Inherits values from: TwoTerminalComponent +## Component `:component` or `:battery` or `B` or `BT` or `BAT` -## Component `:COMPONENT` OR `:VARIABLE` OR `:CAPACITOR` OR `C` +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| value | str | Battery voltage | *required* | +| capacity | str | Battery capacity in amp-hours | (no value) | +| capacity | str | Battery capacity in amp-hours | (no value) | -Reference Designators: `VC`, `CV` +## Component `:component` or `:capacitor` or `C` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | value | str | Capacitance in farads | *required* | | voltage | str | Maximum voltage tolerance in volts | (no value) | +| voltage | str | Maximum voltage tolerance in volts | (no value) | -Inherits values from: PolarizedTwoTerminalComponent - -## Component `:COMPONENT` OR `:VARIABLE` OR `:INDUCTOR` OR `L` - -Reference Designators: `VL`, `LV` +## Component `:component` or `:inductor` or `L` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| | value | str | Inductance in henries | *required* | | current | str | Maximum current rating in amps | (no value) | +| current | str | Maximum current rating in amps | (no value) | -Inherits values from: PolarizedTwoTerminalComponent - -## Component `:COMPONENT` OR `:VARIABLE` OR `:RESISTOR` OR `R` - -Reference Designators: `VR`, `RV` - -| Option | Value | Description | Default | -|:------:|:-----:|:------------|:-------:| -| value | str | Resistance in ohms | *required* | -| wattage | str | Maximum power dissipation in watts (i.e. size of the resistor) | (no value) | - -Inherits values from: TwoTerminalComponent - -## Scope `:WIRE` +## Component `:component` or `:diode` or `D` or `CR` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | -## Scope `:WIRE-TAG` +## Component `:component` or `:diode` or `D` or `CR` or `LED` or `IR` | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| -| scale | float | Scale by which to enlarge the entire diagram by | 15 | -| linewidth | float | Width of drawn lines | 2 | -| color | str | Default color for everything | 'black' | +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | diff --git a/scripts/docs.py b/scripts/docs.py index 27c8c32..e91c7ea 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -4,10 +4,6 @@ import datetime import os import sys -from pprint import pprint - -# TODO: this is an awful mess -# need to clean it up so it actually produces useful documentation from scriptutils import spit @@ -27,6 +23,10 @@ def uniq_sameorder[T](xs: list[T]) -> list[T]: return sorted(set(xs), key=lambda x: xs.index(x)) +def reverse_dict[K, V](d: dict[K, V]) -> dict[V, K]: + return {v: k for k, v in d.items()} + + def output_file(filename: os.PathLike, heading: str, content: str): spit(filename, f"""# {heading} @@ -55,22 +55,13 @@ def format_option(opt: Option) -> str: def format_scope(scopename: str, options: OptionsSet, interj: str, head: str) -> str: scope_text = f""" -## {head} `{scopename.upper()}`{"\n\n" + interj if interj else ""} +## {head} `{scopename}`{"\n\n" + interj if interj else ""} | Option | Value | Description | Default | |:------:|:-----:|:------------|:-------:| """ for option in options.self_opts: scope_text += format_option(option) - if options.inherit: - classes = [p.__name__ for p in options.inherit_from] - if isinstance(options.inherit, bool): - vt = "values" - else: - vt = ", ".join(f"`{x}`" for x in sorted(options.inherit)) - scope_text += f""" -Inherits {vt} from: {", ".join(classes)} -""" return scope_text @@ -83,29 +74,69 @@ def classes_inorder(): key=lambda cls: len(cls.mro()))) -def main(): - pprint(("in order", classes_inorder())) - content: str = "" - classes: list[type[DataConsumer]] = sorted( - set(DataConsumer.registry.values()), - key=lambda cls: cls.__name__) - print("all classes: ", classes) - for d in classes: - if "not_for_docs" in d.__dict__ and d.not_for_docs: +def format_inherited_options( + inherited_options: dict[str, list[Option]]) -> str: + out: list[str] = [] + for namespace, options in inherited_options.items(): + option_names = ", ".join(f"`{opt.name}`" for opt in options) + out.append(f"* copies {option_names} from `{namespace}`") + return "\n".join(out) + + +def generate_class_docs() -> str: + """ + Generate documentation for all classes in DataConsumer.registry. + """ + content = "" + class_to_namespace = reverse_dict(DataConsumer.registry) + defined_options = {} # Track defined options to avoid redefinition + + for cls in classes_inorder(): + if getattr(cls, "not_for_docs", False): continue - scopes = get_scopes_for_cls(d) - s = "` or `".join(scopes) - rds_line = "" - heading = "Scope" - if issubclass(d, Component) and d is not Component: - rds = get_RDs_for_cls(d) - s = "` or `".join(sc for sc in scopes if sc not in rds) - heading = "Component" - rds_line = "Reference Designators: " - rds_line += ", ".join(f"`{x}`" for x in rds) - content += format_scope(s, d.options, rds_line, heading) - - output_file("options.md", "Data Section Options", content) + + # Collect unique options + unique_options = [ + opt for opt in cls.options.self_opts + if opt.name not in defined_options] + for opt in unique_options: + defined_options[opt.name] = class_to_namespace.get( + cls, cls.__name__) + + # Collect inherited options + inherited_options = {} + base_cls: type[DataConsumer] + for base_cls in cls.mro()[1:]: + if base_cls in DataConsumer.registry.values(): + namespace = class_to_namespace.get(base_cls, base_cls.__name__) + inherited = cls.options.inherit if isinstance( + cls.options.inherit, set) else None + base_options = [ + opt for opt in base_cls.options.self_opts + if opt.name not in defined_options and ( + inherited is None or opt.name in inherited)] + if base_options: + inherited_options[namespace] = base_options + for opt in base_options: + defined_options[opt.name] = namespace + + # Format the scope for the class + scopes = "` or `".join(get_scopes_for_cls(cls)) + heading = "Component" if issubclass( + cls, Component) and cls is not Component else "Scope" + content += format_scope( + scopes, + cls.options, + format_inherited_options(inherited_options), + heading, + ) + content += "".join(format_option(opt) for opt in unique_options) + + return content + + +def main(): + output_file("options.md", "Data Section Options", generate_class_docs()) if __name__ == '__main__':