diff --git a/.vscode/settings.json b/.vscode/settings.json index 2157f82..a199b49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,15 @@ { "cSpell.words": [ "Cbox", + "clrall", + "clrmask", "MOSFET", + "nanofarads", "NFET", "PFET", "polylinegon", "rendec", "schemascii", "tspan" - ], - "python.linting.pylintEnabled": false, - "python.linting.flake8Enabled": false, - "python.linting.enabled": true + ] } \ No newline at end of file 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 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/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 diff --git a/format.md b/format.md index ca749b0..efc74d0 100644 --- a/format.md +++ b/format.md @@ -15,56 +15,122 @@ Crossed: Joined: Corner: Crossed: Crossed: Dangling en | | | | ``` -## Components - -### Small components +### Wire Tags -Small components are notated with their reference designator: one or more uppercase letters followed by an ID, which can either be a number, or a period followed by some text. +Wire tags look like `` and are attached to specific wires. All wires tagged with the same `name` belong to the same net. Currently this only affects rendering. -They are always written horizontally even if the terminals are on the top and bottom. +## Components -IDs are allowed to be duplicated and result in the same component values. +Components are notated with their reference designator: one or more uppercase letters followed by an ID (a nonnegative integer) optionally followed by a suffix (which can be composed of uppercase letters and underscores) -Components can be padded on either side with `#`'s to make them bigger. +* They are always written horizontally even if the terminals are on the top and bottom. +* IDs are allowed to be duplicated and result in the same component values. +* Components can be padded (horizontally and vertically) `#`'s to make them bigger and control the shape (for components that support shapes). Examples: * `C33` * `Q1001` -* `L.Coil` +* `L5` * `F3#` -* `D7#####` -* `U1#####` -* `####R.Heater####` +* `####D7#####` +* `R333A` +* ```txt + # ###### + # ######## + ----# ######### + # #U1G1#####---- + ----# ######### + # ######## + # ###### + ``` -Components are able to accept "flags", which are other punctuation characters and lowercase letters touching them. +Components' terminals are annotated with "flags", which are other punctuation characters and lowercase letters touching them. -* Polarized components (caps, diodes, etc.) accept a `+` to indicate the polarity, -* Transistors use lowercase letters to indicate collector/emitter/base (for BJTs) or source/gate/drain (for FETs). +* Polarized components (caps, diodes, etc.) use a `+` for one terminal to indicate the polarity, +* Transistors use 3 lowercase letters to indicate collector/emitter/base (for bipolar transistors) or source/gate/drain (for field-effect transistors). -### Big components +## Annotations -* Big components are indicated with a box made of `~` (top and bottom), `:` (sides), and `.` (corners). -* The inside of the box can contain anything you want, but it must include exactly one reference designator as to what component it is. +### Text Annotations -## Reference designators +You can include arbitrary text in the diagram by `[enclosing it in brackets like this]`. -To include component values, pin numbers, etc, somewhere in the drawing but not touching any wires or other components, you can write component values - these are formatted as the reference designator (same as in circuit) followed by a `:` and the value parameter (which stops at the first whitespace). *I usually put these in a "BOM section" below the circuit itself but you could also put them right next to the component.* +### Line Annotations -For simple components, this is usually just a value rating, but *without* the units (only the Metric prefix). For more specific components (mostly semiconductor devices) this is usually the part number and/or some string to determine how to draw the component. +To outline certain areas with lines (that are not wires), you can draw lines with colons/tildes (for straight lines), and periods/single quotes (for corners) like so: -Examples: +```txt + .~~~~~~~~~~. + : .~~~ : + : : : + : : : + : : .~~'~~~~. + : '~~~~' : + '~~~~~~~~~~~~~~~' +``` + +Lines are allowed to cross wires. They are crossed the same way that wires cross each other. + +## Data + +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 +* { + %% these are global config options + color = black + width = 2; padding = 20; + format = symbol + mystring = "hello\nworld" +} + +R* %% matches any resistor +{tolerance = .05; wattage = 0.25} + +VR1 { + resistance = 0 - 10k; %% ranges on variable components + %% trailing comments are allowed +} +``` + +In each RULE, the SELECTOR defines what components and/or drawing objects match; for the ones that match, the MAPPING (which is stored as a Python `dict`) is or'ed into the computed properties. Rules on the bottom take precedence (there is no such thing as specificity like there is in CSS). + +Selectors can match multiple scopes by using glob matching. Schemascii currently uses [`fnmatch`][fnmatch] to determine matching selectors but I'll probably change that. + +See [options.md][options] for what options are available in which scopes. + +Here is a rough grammar (space ignored): + +```ebnf +DATA = RULE (eol+ RULE)* ; +RULE = SELECTOR eol* MAPPING ; +SELECTOR = symbol ; +MAPPING = "{" ((NAME "=" VALUE)? eol)* "}" ; +NAME = string | symbol ; +VALUE = (number | string | symbol)* ; +number = /(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?/ ; +string = /"(?:\\"|[^"])+"|\s+/ ; +symbol = +eol = ";" | comment? "\n"+ ; +comment = "%%" ; +``` + +### Metric values -* `C33:2.2u` -- "2.2 µF" -* `Q1001:pnp:TIP102` -- "pnp", "npn", "pfet", or "nfet" to determine what kind of transistor, plus the part number; printed verbatim -* `L51:0.33` -- this is rewritten to "330 mH" so that it has no decimal point. -* `F3:1500m` -- rewritten: "1.5 A" -* `D7:1N4001` -- again, part number -* `U1:SN74LS08N,14,1,2,7,3` -- some components let you label pins with whatever you want (in this case just numbers). They start at the top-leftmost and follow **counterclockwise** to follow with the pin-numbering of most IC's. -* `R2:10,5` -- this is formatted as "10 Ω 5 W". The second value is the rating; for resistors, this is a wattage, for most else, this is a maximum voltage. +For many small components, the value of the component is just a number and some unit -- e.g. for resistors it's ohms (Ω). Schemascii includes built-in Metric normalization and units, so if the "value" is a Metric number it will be re-written to be as short as possible while still observing the conventions of typical component values. If the unit symbol is not included, it will be added. -## Inline configuration values +* `value = 0.33m` on an inductor --> "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 diff --git a/options.md b/options.md new file mode 100644 index 0000000..8a2881f --- /dev/null +++ b/options.md @@ -0,0 +1,105 @@ +# Data Section Options + + +*This file was automatically generated by scripts/docs.py on Mon Apr 21 14:06:17 2025* + +## 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' | +| 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 | +|:------:|:-----:|:------------|:-------:| +| 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' | + +## Scope `:annotation` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| font | str | Text font | 'sans-serif' | + +## 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 | + +## Scope `:annotation` or `:annotation-line` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| font | str | Text font | 'sans-serif' | + +## 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) | + +## Component `:component` or `:battery` or `B` or `BT` or `BAT` + +| 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) | + +## 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) | + +## 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) | + +## Component `:component` or `:diode` or `D` or `CR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| 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* | + +## Component `:component` or `:diode` or `D` or `CR` or `LED` or `IR` + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +| part-number | str | The manufacturer-specified part number (e.g. NE555P, 2N7000, L293D, ATtiny85, etc.) | *required* | 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/components_render.py b/schemascii/OLD_components_render.py similarity index 64% rename from schemascii/components_render.py rename to schemascii/OLD_components_render.py index 46fd3bf..51f70e6 100644 --- a/schemascii/components_render.py +++ b/schemascii/OLD_components_render.py @@ -2,11 +2,11 @@ from cmath import phase, rect from math import pi from warnings import warn +from functools import wraps from .utils import ( Cbox, Terminal, BOMData, - XML, Side, arrow_points, polylinegon, @@ -14,13 +14,10 @@ make_text_point, bunch_o_lines, deep_transform, - make_plus, - make_variable, - sort_counterclockwise, + sort_terminals_counterclockwise, light_arrows, sort_for_flags, - is_clockwise, -) + is_clockwise) from .errors import TerminalsError, BOMError, UnsupportedComponentError # pylint: disable=unbalanced-tuple-unpacking @@ -46,17 +43,16 @@ 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 " - f"have {n_terminals} terminals" - ) + f"have {n_terminals} terminals") return func(box, terminals, bom_data, **options) - n_check.__doc__ = func.__doc__ return n_check return n_inner @@ -65,9 +61,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 +72,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,155 +79,20 @@ 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" - ) + 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("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 -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") -@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 -def battery(box: Cbox, terminals: list[Terminal], bom_data: BOMData, **options): - """Draw a battery cell. - bom:volts[,amp-hours] - flags:+=positive""" - t1, t2 = terminals[0].pt, terminals[1].pt - mid = (t1 + t2) / 2 - angle = phase(t1 - t2) - lines = [ - (t1, mid + rect(0.5, angle)), - (t2, mid + rect(-0.5, angle)), - ] + deep_transform( - [ - (complex(0.5, 0.5), complex(-0.5, 0.5)), - (complex(0.25, 0.16), complex(-0.25, 0.16)), - (complex(0.5, -0.16), complex(-0.5, -0.16)), - (complex(0.25, -0.5), complex(-0.25, -0.5)), - ], - mid, - angle, - ) - return id_text( - box, - bom_data, - terminals, - (("V", False), ("Ah", False)), - make_text_point(t1, t2, **options), - **options, - ) + bunch_o_lines(lines, **options) - - @component("D", "LED", "CR", "IR") @polarized @no_ambiguous @@ -249,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( @@ -285,42 +146,43 @@ 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, height=sz.imag, - stroke__width=options["stroke_width"], - stroke=options["stroke"], + stroke__width=options["linewidth"], + stroke=options["color"], fill="transparent", ) 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"), + out += xmltag("text", + xmltag("tspan", part_num, class_="part-num"), x=mid.real, 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"]: - 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", font__size=options["scale"], - fill=options["stroke"], + fill=options["color"], ) - 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( + out += xmltag("text", label, x=sc_text_pt.real, y=sc_text_pt.imag, @@ -329,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( @@ -351,7 +213,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, @@ -361,12 +224,12 @@ 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, - 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) @@ -381,13 +244,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 +271,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 +324,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 +375,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 = (xmltag("circle", cx=(rect(-scale, angle) + mid * scale).real, + cy=(rect(-scale, angle) + mid * scale).imag, + r=scale / 4, + stroke="transparent", + 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["color"], + 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 +400,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}") @@ -559,7 +430,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 @@ -575,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 96d7308..a104fab 100644 --- a/schemascii/__init__.py +++ b/schemascii/__init__.py @@ -1,62 +1,44 @@ -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 * +import importlib +import os +import re +from typing import Any + +import schemascii.components as _cs +import schemascii.data as _d +import schemascii.drawing as _drawing __version__ = "0.3.2" -def render(filename: str, text: str = None, **options) -> str: - "Render the Schemascii diagram to an SVG string." - 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", - ) +def import_all_components(): + for root, _, files in os.walk(os.path.dirname(_cs.__file__)): + for f in files: + # 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+(? str: + """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", - ) - ) + import schemascii.data as _da + import schemascii.data_consumer as _d + import schemascii.refdes as _rd + import schemascii.utils as _u + print(_d.DataConsumer.registry["BAT"]( + _rd.RefDes("BAT", 0, "", 0, 0), [], [ + _u.Terminal(0, None, None), + _u.Terminal(0, None, None) + ]).to_xml_string(_da.Data([_da.Section( + "BAT", {"foo": "bar", "value": 10})]))) diff --git a/schemascii/__main__.py b/schemascii/__main__.py index b987580..e1b49e1 100644 --- a/schemascii/__main__.py +++ b/schemascii/__main__.py @@ -1,48 +1,97 @@ import argparse +import os import sys import warnings -from . import render, __version__ -from .errors import Error -from .configs import add_config_arguments + +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 argument: missing .") + key, sep, val = pair.partition("=") + if not sep: + 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)})]) + setattr(namespace, self.dest, items) 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 " + schemascii.__version__) ap.add_argument( - "-V", "--version", action="version", version="%(prog)s " + __version__ - ) - ap.add_argument("in_file", help="File to process.") + "-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)", - ) - add_config_arguments(ap) + help="Output SVG file. (default input file plus .svg, or stdout " + "if input is stdin)") + ap.add_argument( + "-s", + "--strict", + action="store_true", + 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" - 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 = render(args.in_file, text, **vars(args)) - except Error as err: - 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) - 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.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", "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: + 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: + 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/annoline.py b/schemascii/annoline.py new file mode 100644 index 0000000..b360fbb --- /dev/null +++ b/schemascii/annoline.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import itertools +import typing +from collections import defaultdict +from dataclasses import dataclass + +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(_at.Annotation): + """Class that implements the ability to + draw annotation lines on the drawing + without having to use a disconnected wire. + """ + + css_class = "annotation annotation-line" + + directions: typing.ClassVar[ + defaultdict[str, defaultdict[complex, set[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: set(), + -1j: {-1, 1}, + 1: {1j, -1} + }, + "'": { + -1: {-1j, 1}, + -1j: set(), + 1j: {-1, 1}, + 1: {-1j, -1} + } + }) + start_dirs: typing.ClassVar[ + defaultdict[str, set[complex]]] = defaultdict( + lambda: None, { + "~": _utils.LEFT_RIGHT, + ":": _utils.UP_DOWN, + ".": {-1, 1, -1j}, + "'": {-1, 1, 1j}, + }) + + # the sole member + points: list[complex] + + @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[AnnotationLine] = [] + + 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 + + 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("", """ + | | + ----------- .~~~|~~~~~~. + | : | : + -------*------:---*---* : + ~~~~~~~~~~~~~~~:~~~|~~~~~~~~~~~~~ + *-------------:---*---* '~~~~. + | : : + '~~~~~~~~~~~~~~~' + +""") + line, = AnnotationLine.find_all(x) + print(line) + x.spark(*line.points) diff --git a/schemascii/annotation.py b/schemascii/annotation.py new file mode 100644 index 0000000..bb5e164 --- /dev/null +++ b/schemascii/annotation.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import html +import re +from dataclasses import dataclass + +import schemascii.data_consumer as _dc +import schemascii.grid as _grid +import schemascii.svg as _svg + +ANNOTATION_RE = re.compile(r"\[([^\]]+)\]") + + +@_dc.DataConsumer.register(":annotation") +@dataclass +class Annotation(_dc.DataConsumer): + """A chunk of text that will be rendered verbatim in the output SVG.""" + + options = _dc.OptionsSet([ + _dc.Option("font", str, "Text font", "sans-serif"), + ], {"scale"}) + + position: complex + content: str + + css_class = "annotation" + + @classmethod + def find_all(cls, grid: _grid.Grid) -> list[Annotation]: + """Return all of the text annotations present in the grid.""" + out: list[Annotation] = [] + 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 + + def render(self, scale, font, **options) -> str: + 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 new file mode 100644 index 0000000..f1e134e --- /dev/null +++ b/schemascii/component.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import types +import typing +from collections import defaultdict +from dataclasses import dataclass, field + +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.wire as _wire + + +@_dc.DataConsumer.register(":component") +@dataclass +class Component(_dc.DataConsumer): + """An icon representing a single electronic component.""" + + all_components: typing.ClassVar[dict[str, type[Component]]] = {} + + 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. + 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]] = {} + + 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( + 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(map(str, 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}") + + 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: + """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]] = [] + seen: set[complex] = set() + + 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(_utils.flood_walk( + 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]): + 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(_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): + # 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 + # 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(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 + 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 + 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) + + @classmethod + def define[T: type[Component]]( + 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 scope: + _dc.DataConsumer.register(scope)(cls2) + 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] = cls2 + return cls2 + return doit + + @property + def css_class(self) -> str: + return f"component {self.rd.letter}" + + @classmethod + 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. + + If a subclass implements this method to do something, it should + mutate the list in-place (the return value is ignored). + """ + pass 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/components/__init__.py b/schemascii/components/__init__.py new file mode 100644 index 0000000..d1cd57a --- /dev/null +++ b/schemascii/components/__init__.py @@ -0,0 +1,77 @@ +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 +import schemascii.data_consumer as _dc + + +class SimpleComponent(_c.Component): + """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 +class TwoTerminalComponent(SimpleComponent): + """Shortcut to define a component with two terminals.""" + terminal_flag_opts: typing.ClassVar = {"ok": (None, None)} + 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, + and if provided, sorts the terminals so that the "+" terminal comes + first in the list. + """ + + always_polarized: typing.ClassVar[bool] = False + + @property + 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.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 new file mode 100644 index 0000000..77a8b0a --- /dev/null +++ b/schemascii/components/battery.py @@ -0,0 +1,40 @@ +from cmath import phase, rect + +import schemascii.component as _c +import schemascii.components as _cs +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + + +@_c.Component.define(":battery", ("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): + return [("value", "V", True, self.is_variable), + ("capacity", "Ah", 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(0.5, angle)), + (t2, mid + rect(-0.5, angle)), + *_utils.deep_transform( + [ + (.5+.5j, -.5+.5j), + (.25+.16j, -.25+.16j), + (.5-.16j, -.5-.16j), + (.25-.5j, -.25-.5j), + ], + mid, + angle) + ] + return (_utils.bunch_o_lines(lines, **options) + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) diff --git a/schemascii/components/capacitor.py b/schemascii/components/capacitor.py new file mode 100644 index 0000000..2d89800 --- /dev/null +++ b/schemascii/components/capacitor.py @@ -0,0 +1,46 @@ +from cmath import phase, rect + +import schemascii.component as _c +import schemascii.components as _cs +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + + +@_c.Component.define(":capacitor", ("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): + 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([ + (.4+.25j, -.4+.25j), + (.4-.25j, -.4-.25j) + ], mid, angle) + ] + return (_utils.bunch_o_lines(lines, **options) + + (_utils.make_plus(self.terminals, mid, angle, **options) + if self.term_option == "polarized" else "") + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +@_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) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) diff --git a/schemascii/components/diode.py b/schemascii/components/diode.py new file mode 100644 index 0000000..e0df109 --- /dev/null +++ b/schemascii/components/diode.py @@ -0,0 +1,24 @@ +import schemascii.component as _c +import schemascii.components as _cs +import schemascii.utils as _utils + + +@_c.Component.define(":diode", ("D", "CR")) +class Diode(_cs.PolarizedTwoTerminalComponent, _cs.SiliconComponent): + always_polarized = True + + 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)) + + +@_c.Component.define(None, ("LED", "IR")) +class LED(Diode): + 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 new file mode 100644 index 0000000..f5a897b --- /dev/null +++ b/schemascii/components/inductor.py @@ -0,0 +1,47 @@ +from cmath import phase, rect + +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 + +# TODO: add dot on + end if inductor is polarized + + +@_c.Component.define(":inductor", ("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): + 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 ( + _svg.path(data, "transparent", options["linewidth"], + options["color"]) + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +@_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) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) diff --git a/schemascii/components/resistor.py b/schemascii/components/resistor.py new file mode 100644 index 0000000..8a15a91 --- /dev/null +++ b/schemascii/components/resistor.py @@ -0,0 +1,54 @@ +from cmath import phase, pi, rect + +import schemascii.components as _cs +import schemascii.component as _c +import schemascii.data_consumer as _dc +import schemascii.utils as _utils + +# TODO: IEC rectangular symbol, other variable markings? +# 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 + + +@_c.Component.define(":resistor", ("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): + return [("value", "Ω", False, self.is_variable), + ("wattage", "W", False)] + + def render(self, **options) -> str: + t1, t2 = self.terminals[0].pt, self.terminals[1].pt + points = _ansi_resistor_squiggle(t1, t2) + return (_utils.polylinegon(points, **options) + + self.format_id_text( + _utils.make_text_point(t1, t2, **options), **options)) + + +@_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) + + _utils.make_variable( + (t1 + t2) / 2, phase(t1 - t2), **options)) + +# TODO: potentiometers diff --git a/schemascii/configs.py b/schemascii/configs.py deleted file mode 100644 index d2376d7..0000000 --- a/schemascii/configs.py +++ /dev/null @@ -1,83 +0,0 @@ -import argparse -from dataclasses import dataclass -from .errors import ArgumentError - - -@dataclass -class ConfigConfig: - name: str - clazz: type | list - default: object - description: 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( - "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.description, - choices=opt.clazz, - default=opt.default, - ) - elif opt.clazz is bool: - a.add_argument( - "--" + opt.name, - help=opt.description, - action="store_false" if opt.default else "store_true", - ) - else: - a.add_argument( - "--" + opt.name, - help=opt.description, - 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 ArgumentError( - 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 ArgumentError( - f"config option {opt.name}: " - f"invalid {opt.clazz.__name__} value: " - f"{options[opt.name]}" - ) from err - return options diff --git a/schemascii/data.py b/schemascii/data.py new file mode 100644 index 0000000..424d465 --- /dev/null +++ b/schemascii/data.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import re +import typing +from dataclasses import dataclass + +import schemascii.errors as _errors + +T = typing.TypeVar("T") +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) + + +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.""" + + header: str + data: dict[str, typing.Any] + + def __getitem__(self, key: str): + return self.data[key] + + def matches(self, name: str) -> bool: + """True if self.header matches the name.""" + return re.match(re.escape(self.header).replace("@", ".+?"), name, re.I) + + +@dataclass +class Data: + """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] + + @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 + lastsig: tuple[int, int, int] = (0, 0, 0) + + def complain(msg): + raise _errors.DiagramSyntaxError( + f"{filename} line {line+startline}: {msg}\n" + f"{line + 1} | {lines[line]}\n" + f"{' ' * len(str(line + 1))} | {' ' * col}{'^'*len(look())}") + + def complain_eof(): + restore(lastsig) + skip_space(True) + if index >= len(tokens): + complain("unexpected EOF") + complain("unknown parse error") + + def look() -> str: + if index >= len(tokens): + return "\0" + return tokens[index] + + def eat() -> str: + 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: tuple[int, int, int]): + 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_and_eat(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 in disallowed: + complain(f"unexpected {got!r}") + + def parse_section() -> Section: + expect_not(SPECIAL) + name = eat() + # print("** starting section", repr(name)) + mark_used() + skip_i() + expect_and_eat({"{"}) + data = {} + while look() != "}": + data |= parse_kv_pair() + eat() # the "}" + skip_i() + return Section(name, data) + + def parse_kv_pair() -> dict[str, int | float | str]: + skip_i() + if look() == "}": + # handle case of ";}" + # print("**** got a ';}'") + return {} + expect_not(SPECIAL) + key = eat() + mark_used() + skip_i() + expect_and_eat({"="}) + 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 + value = parse_simple_value(value) + # don't eat the ending "}" + if look() != "}": + expect_and_eat({"\n", ";"}) + # print("*** got KV", repr(key), repr(value)) + return {key: value} + + skip_i() + sections = [] + while index < len(tokens): + sections.append(parse_section()) + return cls(sections) + + def get_values_for(self, namespace: str) -> dict: + out = {} + for section in self.sections: + if section.matches(namespace): + out |= section.data + return out + + 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): + return NotImplemented + return Data(self.sections + other.sections) + + +if __name__ == '__main__': + import pprint + text = "" + text = r""" +@ { + %% 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 + %% foo = "bar\n\tnop" +} +""" + my_data = Data.parse_from_string(text) + pprint.pprint(my_data) + pprint.pprint(my_data.get_values_for("R1")) diff --git a/schemascii/data_consumer.py b/schemascii/data_consumer.py new file mode 100644 index 0000000..adc8567 --- /dev/null +++ b/schemascii/data_consumer.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import abc +import itertools +import typing +import warnings +from dataclasses import dataclass, field + +import schemascii.data as _data +import schemascii.errors as _errors +import schemascii.svg as _svg + + +@dataclass +class OptionsSet[T]: + self_opts: list[Option[T]] + inherit: set[str] | bool = True + inherit_from: list[type[DataConsumer]] | None = None + + +_OPT_IS_REQUIRED = object() + + +@dataclass +class Option[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 = field(repr=False) + 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 + 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() as keyword arguments. + """ + + 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) # 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 .""" + # 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 + values = {} + for name in namespaces: + values |= data.get_values_for(name) + # validate the 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 {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"{namespaces[-1]}.{opt.name}: " + f"invalid choice: {values[opt.name]!r} " + 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 {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 options): + continue + warnings.warn( + f"unknown data key {key!r} for {namespaces[-1]}", + stacklevel=3) + # render + result = self.render(**values, data=data) + if self.css_class: + result = _svg.group(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. + """ + + @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 as { + cls.registry[namespace] + }") + 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 new file mode 100644 index 0000000..f505378 --- /dev/null +++ b/schemascii/drawing.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import typing +from dataclasses import dataclass + +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.svg as _svg + + +@_dc.DataConsumer.register(":root") +@dataclass +class Drawing(_dc.DataConsumer): + """A Schemascii drawing document.""" + + options = _dc.OptionsSet([ + _dc.Option("padding", float, + "Margin around the border of the drawing", 10), + ], {"scale"}) + + 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 from_file(cls, + filename: str, + data: str | None = None, + *, + 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() + try: + 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 + 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) + 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) + # 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 | 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 + return super().to_xml_string(data) + + def render(self, data, scale: float, padding: float, **options) -> str: + # render everything + content = _svg.group( + _svg.group( + *(net.to_xml_string(data) for net in self.nets), + class_="wires"), + _svg.group( + *(comp.to_xml_string(data) for comp in self.components), + class_="components"), + class_="electrical") + 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 _svg.whole_thing( + 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 + 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/edgemarks.py b/schemascii/edgemarks.py deleted file mode 100644 index d9bd19c..0000000 --- a/schemascii/edgemarks.py +++ /dev/null @@ -1,73 +0,0 @@ -from itertools import chain -from types import FunctionType -from .utils import Cbox, Flag, Side, Terminal -from .grid import Grid - - -def over_edges(box: Cbox) -> list: - "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 - - -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: - c = grid.get(p) - if c in " -|()*": - return None - grid.setmask(p, "*") - return Flag(p, c, s) - - return 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: - 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 terminals diff --git a/schemascii/errors.py b/schemascii/errors.py index 6a33ed1..aadbb59 100644 --- a/schemascii/errors.py +++ b/schemascii/errors.py @@ -1,22 +1,39 @@ class Error(Exception): - "A generic Schemascii error." + """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." + """Bad formatting in Schemascii diagram syntax.""" + message_prefix = "syntax error" -class TerminalsError(TypeError, Error): - "Incorrect usage of terminals on this component." +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." + """Problem with BOM data for a component.""" + message_prefix = "BOM error" class UnsupportedComponentError(NameError, Error): - "Component type is not supported." + """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 ArgumentError(ValueError, Error): - "Invalid config argument value." +class DataTypeError(TypeError, Error): + """Invalid data type in data section.""" + message_prefix = "invalid data type" diff --git a/schemascii/grid.py b/schemascii/grid.py index 85e39d4..c1bb30f 100644 --- a/schemascii/grid.py +++ b/schemascii/grid.py @@ -1,8 +1,9 @@ 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): + def __init__(self, filename: str, data: str | None = None): if data is None: with open(filename, encoding="ascii") as f: data = f.read() @@ -10,37 +11,40 @@ 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) 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): - "The current contents, with masks applied." - return [ + def lines(self) -> tuple[str]: + """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)] @@ -52,28 +56,78 @@ 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." - self.masks = [[False for x in range(self.width)] for y in range(self.height)] + """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]) return Grid(self.filename, d) + def shrink(self): + """Shrinks self so that there is not any space between the edges and + the next non-whitespace 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 + # 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: + 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}, \"\"\"" + f"\n{chr(10).join(self.lines)}\"\"\")") __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) @@ -86,5 +140,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 + +""") + 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)) 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/metric.py b/schemascii/metric.py index 031b86c..7dab36a 100644 --- a/schemascii/metric.py +++ b/schemascii/metric.py @@ -1,73 +1,150 @@ import re from decimal import Decimal -METRIC_NUMBER = re.compile(r"^(\d*\.?\d+)([pnumKkMG]?)$") # cSpell:ignore pnum +METRIC_NUMBER = re.compile( + r"(\d*\.?\d+)\s*([pnumKkMGT]?)") # cSpell:ignore pnum +METRIC_RANGE = re.compile( + r"(\d*\.?\d+\s*[pnumKkMGT]?)-(\d*\.?\d+\s*[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. - E.g. 3 --> "k" (kilo) - E.g. 0 --> "" (no prefix) - E.g. -6 --> "u" (micro) - If it is not a multiple of 3, returns None.""" +def exponent_to_multiplier(exponent: int) -> str | None: + """Turns the 10-power into a Metric multiplier. + + * 3 --> "k" (kilo) + * 0 --> "" (no multiplier) + * -6 --> "u" (micro) + + If it is not a multiple of 3, return 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 multiplier_to_exponent(multiplier: str) -> int: + """Turn the Metric multiplier into its 10^exponent. -def prefix_to_exponent(prefix: int) -> str: - """Turns the Metric prefix into its exponent. - E.g. "k" --> 3 (kilo) - E.g. " " --> 0 (no prefix) - E.g. "u" --> -6 (micro)""" - if prefix in (" ", ""): + * "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 - if prefix == "µ": - prefix = "u" # allow unicode - if prefix == "K": - prefix = prefix.lower() # special case (preferred is lowercase) - i = "pnum kMG".index(prefix) - return (i - 4) * 3 + if multiplier == "µ": + multiplier = "u" # allow unicode + if multiplier == "K": + multiplier = multiplier.lower() + # special case (preferred is lowercase) + 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]: + """Finds the best exponent for the number. + Returns a tuple (digits, best_exponent) + """ + res = ENG_NUMBER.match(num.to_eng_string()) + assert res + digits, exp = Decimal(res.group(1)), int(res.group(2) or "0") + assert exp % 3 == 0, "failed to make engineering notation" + possibilities = [] + 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: + # 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 Metric multiplier if possible + # * prefer no decimal point + return sorted( + possibilities, key=lambda x: ((10 * len(x[0])) + + (5 * (x[1] != 0)) + + (2 * ("." in x[0]))))[0] -def format_metric_unit(num: str, unit: str = "", six: bool = False) -> str: - "Normalizes the Metric unit on the number." + +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, None + 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, + allow_range: bool = True) -> str: + """Normalizes the Metric multiplier on the number, then adds the unit + if the unit was not used. + + * 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: + 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]:].lstrip().removeprefix(unit) + digits0, exp0 = normalize_metric(num0, six, unicode) + digits1, exp1 = normalize_metric(num1, six, unicode) + 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} {exp1}{unit} {suffix}".rstrip() 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()) - if not res: - raise RuntimeError - 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))) - if "e" in new_digits.lower(): - 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])) - )[0] - out = digits + " " + exponent_to_prefix(exp) + unit - return out.replace(" u", " µ") + suffix = num[match.span(0)[1]:].lstrip().removeprefix(unit) + digits, exp = normalize_metric(match.group(), 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, sep="") + print(repr(format_metric_unit(*args))) + test("2.5-3500", "V") + test("0.33m", "H", True) + test("50M-100000000000000000000p", "Hz") + test(".1", "Ω") + test("2200u", "F", True) + test("2200uF", "F", True) + test("2200u F", "F", True) + test("2200 uF", "F", True) + test("0-100k", "V") + test("Gain", "Ω") diff --git a/schemascii/net.py b/schemascii/net.py new file mode 100644 index 0000000..9950362 --- /dev/null +++ b/schemascii/net.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +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 + + +@_dc.DataConsumer.register(":net") +@dataclass +class Net(_dc.DataConsumer): + """Grouping of wires that are + 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[Net] = [] + 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), all_tags) + if all(p in seen_points for p in wire.points): + continue + # 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 + + def render(self, data) -> str: + return "".join(w.to_xml_string(data) for w in self.wires) + + +if __name__ == '__main__': + g = _grid.Grid("", """ +=wrap1>------C1------=wrap1> + 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): + left_col, right_col = match.span() + letter, number, suffix = match.groups() + number = int(number) if number else 0 + out.append(cls( + letter, + number, + suffix, + complex(left_col, row), + complex(right_col - 1, row))) + return out + + @property + def name(self) -> str: + return f"{self.short_name}{self.suffix}" + + @property + def short_name(self) -> str: + return f"{self.letter}{self.number}" + + +if __name__ == '__main__': + import pprint + gg = _grid.Grid("", """ +C1 + BAT3V3 + U3A + Q1G1 + R.Heater + ^ + this one is invalid; only the "R" and "H" are gotten + GND +""") + rds = RefDes.find_all(gg) + pts = [p for r in rds for p in [r.left, r.right]] + gg.spark(*pts) + pprint.pprint(rds) diff --git a/schemascii/svg.py b/schemascii/svg.py new file mode 100644 index 0000000..26bc621 --- /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, + linewidth: OrFalse[float] = False, stroke: OrFalse[str] = False, + class_: OrFalse[str] = False) -> str: + return xmltag("path", d=data, fill=fill, stroke__width=linewidth, + 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/utils.py b/schemascii/utils.py index 4b07dcd..08b4122 100644 --- a/schemascii/utils.py +++ b/schemascii/utils.py @@ -1,43 +1,162 @@ -from collections import namedtuple -from itertools import groupby, chain -from enum import IntEnum -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 - -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 Side(IntEnum): - "Which edge the flag was found on." +from __future__ import annotations + +import enum +import itertools +import typing +from cmath import phase, pi, rect +from collections import defaultdict + +import schemascii.grid as _grid +import schemascii.metric as _metric +import schemascii.svg as _svg + +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, set[complex]] = defaultdict( + lambda: ORTHAGONAL + DIAGONAL) +IDENTITY: dict[complex, set[complex]] = {x: set((x,)) for x in ORTHAGONAL} + + +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): + """One of the four cardinal directions.""" RIGHT = 0 - TOP = 1 - LEFT = 2 - BOTTOM = 3 + TOP = -pi / 2 + LEFT = pi + BOTTOM = pi / 2 + + @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. + """ + # TODO: fix this so it compares the components, + # instead of this distance mess + 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 flood_walk( + grid: _grid.Grid, + seed: set[complex], + start_dirs: defaultdict[str, set[complex] | None], + directions: defaultdict[str, defaultdict[ + 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 + return the list of reached points. + + 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, set[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: + # shortcut + next_dirs = defaultdict(lambda: None) + stack.append((next_pt, next_dirs[dir])) + return points + + +def perimeter(pts: set[complex]) -> set[complex]: + """Return the set of points that are on the boundary of + the grid-aligned set pts. + """ + out = set() + for pt in pts: + for d in ORTHAGONAL + DIAGONAL: + xp = pt + d + if xp not in pts: + out.add(pt) + break + return out # sort_counterclockwise(out, centroid(pts)) + + +def centroid(pts: set[complex]) -> complex: + """Return the centroid of the set of points pts.""" + return sum(pts) / len(pts) + + +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 + 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 0 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) @@ -46,34 +165,37 @@ def sharpness_score(points: list[complex]) -> float: return score -def intersecting(a, b, p, q): - """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) 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, - mutating the input list.""" +def take_next_group(links: list[tuple[complex, complex]]) -> list[ + tuple[complex, complex]]: + """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: 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 @@ -81,14 +203,17 @@ def take_next_group(links: list[tuple[complex, complex]]) -> list[tuple[complex, 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: - if i == len(links): + while 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: @@ -96,7 +221,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." + """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): @@ -105,14 +236,35 @@ def iterate_line(p1: complex, p2: complex, step: float = 1.0): yield point -def deep_transform(data, origin: complex, theta: float): +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]: ... + + +@typing.overload +def deep_transform(data: tuple[DT_Struct], origin: complex, + theta: float) -> tuple[DT_Struct]: ... + + +@typing.overload +def deep_transform(data: complex, origin: complex, + theta: float) -> complex: ... + + +def deep_transform(data: DT_Struct, 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.""" - if isinstance(data, list | tuple): + then rotating by theta. Return an identical data structure, + but with the transformed points substituted. + """ + 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: @@ -120,71 +272,76 @@ def deep_transform(data, 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.""" - 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) -> Callable: - def mk_tag(*contents: str, **attrs: str) -> str: - out = f"<{tag} " - for k, v in attrs.items(): - if v is False: - 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) - out += f'{k.removesuffix("_").replace("__", "-")}="{v}" ' - out = out.rstrip() + ">" + "".join(contents) - return out + f"" - - return mk_tag - - -XML = XMLClass() -del XMLClass - - -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"] - c = options["stroke"] - pts = " ".join(f"{x.real * scale},{x.imag * scale}" for x in points) +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: + """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 + + def pad(number: float | int) -> str: + if number < 0: + return str(number) + return " " + str(number).removeprefix("0") + + 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 line or filled area. + + If is_polygon is true, stroke color is used as fill color instead + and stroke width is ignored. + """ + scale, linewidth, stroke = _get_slc(options) + 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", stroke__width=w, stroke=c) + return _svg.path(points2path(scaled_pts, True), stroke, + class_="filled") + return _svg.path(points2path(scaled_pts, False), "transparent", + linewidth, stroke) 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: # 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] def bunch_o_lines(pairs: list[tuple[complex, complex]], **options) -> 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. + """ + scale, linewidth, stroke = _get_slc(options) lines = [] while pairs: group = take_next_group(pairs) @@ -192,133 +349,135 @@ 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) + data = "" + for line in lines: + data += points2path([x * scale for x in line], False) + return _svg.path(data, "transparent", linewidth, stroke) def id_text( - box: Cbox, - bom_data: BOMData, - terminals: list[Terminal], - unit: str | list[str] | None, - point: complex | None = None, - **options, -) -> str: - "Format the component ID and value around the point." - if options["nolabels"]: + cname: str, + terminals: list[Terminal], + value: str | list[tuple[str, str] + | tuple[str, str, bool] + | tuple[str, str, bool, bool]], + point: complex | None = None, + **options) -> str: + """Format the component ID and value around the point.""" + nolabels, label = options["nolabels"], options["label"] + scale, stroke = options["scale"], options["color"] + font = options["font"] + if nolabels: return "" - label_style = options["label"] - if point is None: - point = sum(t.pt for t in terminals) / len(terminals) + if not point: + point = centroid(t.pt for t in 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 = format_metric_unit(text, unit) - classy = "cmp-value" - else: - text = " ".join( - 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" - 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" - 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), - x=point.real, - y=point.imag, - text__anchor=textach, - font__size=options["scale"], - fill=options["stroke"], - ) + 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: - "Compute the scaled coordinates of the text anchor point." + """Compute the scaled coordinates of the text anchor point.""" + scale, offset_scale = options["scale"], options["offset_scale"] 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 -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 "" - return XML.g( +def make_plus(center: complex, theta: float, **options) -> str: + """Make a '+' sign for indicating polarity.""" + return _svg.group( 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]]: - "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 [ (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, 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 - ) +def make_variable(center: complex, theta: float, **options) -> str: + """Draw a "variable" arrow across the component.""" + 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): """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 - 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, - ) - - -def sort_counterclockwise(terminals: list[Terminal]) -> list[Terminal]: - "Sort the terminals in counterclockwise order." + 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), + center, theta - pi / 2), + **options), + class_="light-emitting" if out else "light-dependent") + + +def sort_terminals_counterclockwise( + terminals: list[Terminal]) -> list[Terminal]: + """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), @@ -328,8 +487,8 @@ 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) + """Return true if the terminals are clockwise order.""" + sort = sort_terminals_counterclockwise(terminals) for _ in range(len(sort)): if sort == terminals: return True @@ -337,23 +496,11 @@ 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 and 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 TerminalsError( - f"Multiple terminals with the same flag {flag} " - f"on component {box.type}{box.id}" - ) - if len(matching_terminals) == 0: - raise TerminalsError( - f"Need a terminal with the flag {flag} " - f"on component {box.type}{box.id}" - ) - (terminal,) = matching_terminals - out = *out, terminal - terminals.remove(terminal) - return out +if __name__ == '__main__': + 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)) + print(DT_Struct) diff --git a/schemascii/wire.py b/schemascii/wire.py new file mode 100644 index 0000000..a60d0e4 --- /dev/null +++ b/schemascii/wire.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import itertools +from collections import defaultdict +from dataclasses import dataclass +from typing import ClassVar + +import schemascii.data_consumer as _dc +import schemascii.grid as _grid +import schemascii.svg as _svg +import schemascii.utils as _utils +import schemascii.wire_tag as _wt + + +@_dc.DataConsumer.register(":wire") +@dataclass +class Wire(_dc.DataConsumer): + """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[ + defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict( + lambda: None, { + "-": _utils.IDENTITY, + "|": _utils.IDENTITY, + "(": _utils.IDENTITY, + ")": _utils.IDENTITY, + "*": _utils.EVERYWHERE, + # allow jumps through annotation lines + ":": _utils.IDENTITY, + "~": _utils.IDENTITY, + }) + start_dirs: ClassVar[ + defaultdict[str, list[complex]]] = defaultdict( + lambda: None, { + "-": _utils.LEFT_RIGHT, + "|": _utils.UP_DOWN, + "(": _utils.UP_DOWN, + ")": _utils.UP_DOWN, + "*": _utils.ORTHAGONAL, + }) + + points: list[complex] + tag: _wt.WireTag | None + + @classmethod + def get_from_grid(cls, grid: _grid.Grid, + start: complex, tags: list[_wt.WireTag]) -> Wire: + """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 + 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 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: str = "" + for dot_pt in _utils.find_dots(links): + 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) + + @classmethod + def is_wire_character(cls, ch: str) -> bool: + return ch in cls.start_dirs + + +if __name__ == '__main__': + x = _grid.Grid("", """ +. + + | [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) diff --git a/schemascii/wire_tag.py b/schemascii/wire_tag.py new file mode 100644 index 0000000..d33fad7 --- /dev/null +++ b/schemascii/wire_tag.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re +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 + +WIRE_TAG_PAT = re.compile(r"<([^\s=]+)=|=([^\s>]+)>") + + +@_dc.DataConsumer.register(":wire-tag") +@dataclass +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. + + Wire tags currently only support horizontal connections + as of right now. + """ + + css_class = "wire-tag" + + name: str + 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]: + """Find all of the wire tags present in the grid.""" + 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 + connect_pt = position - 1 + else: + attach_side = _utils.Side.RIGHT + position = right_pos + connect_pt = position + 1 + out.append(cls(name, position, attach_side, + point_dir, connect_pt)) + return out + + def render(self, **options) -> str: + raise NotImplementedError + + +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.connect_pt for x in tags)) diff --git a/schemascii/wires.py b/schemascii/wires.py deleted file mode 100644 index ef5cb94..0000000 --- a/schemascii/wires.py +++ /dev/null @@ -1,142 +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) diff --git a/scripts/docs.py b/scripts/docs.py index 631d7ca..e91c7ea 100755 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -1,59 +1,142 @@ #! /usr/bin/env python3 -import re +from __future__ import annotations + +import datetime 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 - -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 sys + +from scriptutils import spit + +try: + 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) +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 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} + + +*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) + 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 _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: OptionsSet, + interj: str, head: str) -> str: + scope_text = f""" +## {head} `{scopename}`{"\n\n" + interj if interj else ""} + +| Option | Value | Description | Default | +|:------:|:-----:|:------------|:-------:| +""" + for option in options.self_opts: + scope_text += format_option(option) + return scope_text + + +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 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 + + # 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(): - 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) + output_file("options.md", "Data Section Options", generate_class_docs()) if __name__ == '__main__': 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..144cba8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,60 +1,47 @@ #! /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 readfile(file): - with open(file) as f: - return f.read() - - -def writefile(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") 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") 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 = 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'')) -cmd("convert test_data/test_charge_pump_css.txt.svg test_data/test_charge_pump_css.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("git add -A") # cmd("git commit -m 'blah'") diff --git a/scripts/scriptutils.py b/scripts/scriptutils.py new file mode 100644 index 0000000..a73d7f2 --- /dev/null +++ b/scripts/scriptutils.py @@ -0,0 +1,27 @@ +import os +import sys + +# pylint: disable=missing-function-docstring + + +def cmd(sh_line: str, say: bool = True): + if say: + print(sh_line) + if code := os.system(sh_line): # nosec start_process_with_a_shell + 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[T](value: T) -> T: + print(value) + return value 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/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]` | | diff --git a/test_data/blob_test.py b/test_data/blob_test.py new file mode 100644 index 0000000..675cc8a --- /dev/null +++ b/test_data/blob_test.py @@ -0,0 +1,514 @@ +strings = [ + """ +straight rectangle +######################### +######################### +######################### +######################### +######################### +#########################""", + """ +rectangle with slot in it +######################### +######################### + ############# +######################### +######################### +""", + """ +rectangle with notch +########### +########### + ########## +########### +###########""", + """ +average triangle (op-amp) +# +### +##### +####### +##### +### +# +""", + """ +long triangle +# +###### +########### +################ +########### +###### +# +""", + """ +stubby triangle +# +# +# +## +## +## +### +## +## +## +# +# +# +""", + """ +half star (concave) +# +# +# +## +## +## +### +###### +######### +###### +### +## +## +## +# +# +# +""", + """ +back end of xor gate +# + # + # + # + # + # +#""", + """ +or gate +###### + ######## + ######### + ########## + ######### + ######## +######""", + """ +and gate +##### +####### +####### +######## +####### +####### +##### +""", + """ +not gate +# +### +##### ## +########### +##### ## +### +# +""", + """ +big O + ########### + ############# +############### +#### #### +#### #### +#### #### +#### #### +############### + ############# + ###########""", + """ +thin thing 1 +############### + ###################""", + """ +thin thing 2 +############### + ################### +###############""", + """ +zigzag rectangle 1 +################ + ################ +################ + ################ +################ + ################ +################ + ################""", + """ +zigzag rectangle 2 +# # # # # # # # +################ +################ +################ +################ +################ + # # # # # # # #""", + """ +tilted rhombus + # + #### + ####### + ########## + ############# + ################ + ################### + ###################### + ######################### + ###################### + ################### + ################ + ############# + ########## + ####### + #### + #""", + """ +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] + + +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[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[T](v: list[T]) -> list[tuple[T, T, T]]: + return list(zip(cir(v, False), v, cir(v, True))) + + +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))) + + +def cull_disallowed_edges( + all_points: list[complex], + 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 + 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 + 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) + 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 p, c in fixed_edges.items(): + for q in c: + fixed_edges.setdefault(q, set()).add(p) + 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 all(e in out for e in edges) or current != start: + out.append(current) + # log the direction we came from + swd_into.setdefault(current, set()).add(current_dir) + choices_directions = (rot(current_dir, i) + 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 + nxt = current + d + if nxt in edges[current]: + if nxt not in swd_into.keys(): + # if we haven't been there before, go there + 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 None: + bt_d = 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("path clockwise is") + print(debug_singular_polyline_in_svg(out, current)) + 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: 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 + + # 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 + maxima = [is_mid_maxima(a, b, c) for (a, b, c) in triples(distances)] + points_to_maybe_discard = set( + 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 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 + 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.update(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 sharp < 0 + else "black" + }" opacity="50%">""" + 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("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 + 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=start, + start_dir=next - start, + edges=edges) + g = remove_unnecessary(g, edges) + return g + + +def get_outline_points(pts: list[complex]) -> list[list[complex]]: + # find the edge points + edge_points: set[complex] = set() + for p in pts: + 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: + 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, set[complex]], + scale: float = 20) -> str: + vbx = max(x.real for x in (*points, *edges)) + 1 + vby = max(x.imag for x in (*points, *edges)) + 1 + return f"""{"".join(f"""""" for p in points)}{ + "".join(f"""""" + for p1 in edges for p2 in edges[p1])}""" + + +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: + 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("""""" for p in ps) + print("final") + return f"""{polylines}""" + + +print("") +for s in strings: + print(f"""
{s}
""") + pts = [complex(c, r) + for (r, rt) in enumerate(s.splitlines()) + for (c, ch) in enumerate(rt) + if ch == "#"] + print(example(pts)) diff --git a/test_data/stresstest.txt b/test_data/stresstest.txt new file mode 100644 index 0000000..8c74c69 --- /dev/null +++ b/test_data/stresstest.txt @@ -0,0 +1,51 @@ +J2-------C1--------* +J1--|||---C2+------* +J999999999999------ +[this is a comment] + + [xor gate] [op amp] + + # ###### # + # ######## ### + ----# ######### ----+##### + # #U1G1#####---- #U2A###----- + ----# ######### -----##### + # ######## ### + # ###### # + + + [wires and lines test] + + | | + ----------- .~~~|~~~~~~. + | : | : + -------*------:---*---* : + : | | : + *-------------:---*---* '~~~~. + | : : + '~~~~~~~~~~~~~~~' + + +--- +@ { + stroke-width = 2 +} +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 +}