Refactor code (squashed commit)
Refactor connector node generation Further refactor connector node generation Rebuild demos Generate gauge string inside Cable object WIP: refactor cable node generation Implement HTML indentation WIP More WIP Remove old stuff, slightly simplify code Outsource `gv_pin_table()`, simplify padding Add TODOs Outsource `set_dot_basics()` and `apply_dot_tweaks()` Make setting HTML tag attributes easier through `kwargs` Fix and simplify bgcolor logic Reactivate cable edge generation Outsource `gv_edge_wire()` Make connecting things more object-oriented Alphabetize HTML tags, improve bgcolor rendering Make mates object-oriented Run `autoflake -i` Run `autoflake -i --remove-all-unused-imports` Streamline assignment of ports to simple connectors Implement color objects Use color objects in WireViz Re-sort `wv_colors.py` Make green color darker Break longer lines not caught by `black` because they were unbroken strings or comments Make variable name more expressive Apply dot tweaks last Remove unused line Improve subclassing of components, prepare for BOM refactoring Clean up Include nested additional components in BOM do not add autogenerated designators to BOM Improve BOM generation (TODO: wires from a bundle) Prepare `harness.populate_bom()` Change `description` to `type` in additional BOM item YAML Define CLI epilog str in single statement Rename modules, adjust imports, move `build_examples.py` Restructure and update `.gitignore` Clarify `wireviz.parse()` input types Implement BOM population (missing: qty multipliers) Make `pin_objects` and `wire_objects` dictionaries Compute qty's of additional components (WIP) Add qty test file Adapt `tutorial08.yml` (remove `unit` field) Add `tabulate` to dependency list (might remove later if not needed) Sort BOM by category, assign BOM IDs Rename `Options.color_mode` to `.color_output_mod` for consistency Change BOM output file extension from `.bom.tsv` to `.tsv` Implement BOM bubbles Stop recursive nesting of additional components Add BOM bubble to additional component list (WIP) Fix gauge conversion Fix line breaks in code Optimize BOM bubble geometry Implement pin color output Small issue: GraphViz warning ``` Warning: table size too small for content ``` Add some test files to `tests/` directory Update test files Allow multiple colors for components Implement multiple colors for components, improve multicolor table rendering Fix color cell implementation Fix node background color rendering Add test file for node and title bgcolors WIP: BOM modes Add TODO for empty connector pin tables Comment out BOM modes (WIP) and BOM bubbles Resume work on BOM Include part number info in BOM table Fix BOM output in TSV and HTML Add bundles' wires' part number info to BOM Add TODOs Implement bundle part number rendering Improve conductor table rendering Fix additional component BOM table layout Disable CLI BOM output Add suggestions from #246 Add suggestions from #186 Add .vscode/ to .gitignore Fix PyLance problems Update interim version number Fix zero-size cell for simple connectors without type Implement additional parameters dict for components Implement note for additional components Thicken additional component table Add placeholder for add.comp. PN info Apply black
4
.github/workflows/main.yml
vendored
@ -22,11 +22,11 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install .
|
pip install .
|
||||||
- name: Create Examples
|
- name: Create Examples
|
||||||
run: PYTHONPATH=$(pwd)/src:$PYTHONPATH cd src/wireviz/ && python build_examples.py
|
run: PYTHONPATH=$(pwd)/src:$PYTHONPATH && python src/wireviz/tools/build_examples.py
|
||||||
- name: Upload examples, demos, and tutorials
|
- name: Upload examples, demos, and tutorials
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: examples-and-tutorials
|
name: examples-and-tutorials
|
||||||
path: |
|
path: |
|
||||||
examples/
|
examples/
|
||||||
tutorial/
|
tutorial/
|
||||||
|
|||||||
28
.gitignore
vendored
@ -1,15 +1,21 @@
|
|||||||
|
# OS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
desktop.ini
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Development aids
|
||||||
.idea/
|
.idea/
|
||||||
.eggs
|
.vscode/
|
||||||
__pycache__
|
temp/
|
||||||
.*.swp
|
|
||||||
*.egg-info
|
|
||||||
*.pyc
|
|
||||||
build
|
|
||||||
data
|
|
||||||
dist
|
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
desktop.ini
|
|
||||||
thumbs.db
|
# Build/compile/release artifacts
|
||||||
temp/
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Other temporary files
|
||||||
|
__pycache__
|
||||||
|
.*.swp
|
||||||
|
|||||||
2
examples/demo01.html
generated
@ -41,6 +41,7 @@
|
|||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
<title>X1</title>
|
<title>X1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="139,-253 0,-253 0,0 139,0 139,-253"/>
|
<polygon fill="#ffffff" stroke="black" points="139,-253 0,-253 0,0 139,0 139,-253"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,0 0,-253 139,-253 139,0 0,0"/>
|
||||||
<polygon fill="none" stroke="black" points="0.5,-229.5 0.5,-252.5 139.5,-252.5 139.5,-229.5 0.5,-229.5"/>
|
<polygon fill="none" stroke="black" points="0.5,-229.5 0.5,-252.5 139.5,-252.5 139.5,-229.5 0.5,-229.5"/>
|
||||||
<text text-anchor="start" x="61" y="-237.3" font-family="arial" font-size="14.00">X1</text>
|
<text text-anchor="start" x="61" y="-237.3" font-family="arial" font-size="14.00">X1</text>
|
||||||
<polygon fill="none" stroke="black" points="0.5,-206.5 0.5,-229.5 48.5,-229.5 48.5,-206.5 0.5,-206.5"/>
|
<polygon fill="none" stroke="black" points="0.5,-206.5 0.5,-229.5 48.5,-229.5 48.5,-206.5 0.5,-206.5"/>
|
||||||
@ -162,6 +163,7 @@
|
|||||||
<g id="node2" class="node">
|
<g id="node2" class="node">
|
||||||
<title>X2</title>
|
<title>X2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="825,-254 638,-254 638,-139 825,-139 825,-254"/>
|
<polygon fill="#ffffff" stroke="black" points="825,-254 638,-254 638,-139 825,-139 825,-254"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="638,-139 638,-254 825,-254 825,-139 638,-139"/>
|
||||||
<polygon fill="none" stroke="black" points="638.5,-230.5 638.5,-253.5 825.5,-253.5 825.5,-230.5 638.5,-230.5"/>
|
<polygon fill="none" stroke="black" points="638.5,-230.5 638.5,-253.5 825.5,-253.5 825.5,-230.5 638.5,-230.5"/>
|
||||||
<text text-anchor="start" x="723" y="-238.3" font-family="arial" font-size="14.00">X2</text>
|
<text text-anchor="start" x="723" y="-238.3" font-family="arial" font-size="14.00">X2</text>
|
||||||
<polygon fill="none" stroke="black" points="638.5,-207.5 638.5,-230.5 734.5,-230.5 734.5,-207.5 638.5,-207.5"/>
|
<polygon fill="none" stroke="black" points="638.5,-207.5 638.5,-230.5 734.5,-230.5 734.5,-207.5 638.5,-207.5"/>
|
||||||
|
|||||||
BIN
examples/demo01.png
generated
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
2
examples/demo01.svg
generated
@ -12,6 +12,7 @@
|
|||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
<title>X1</title>
|
<title>X1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="139,-253 0,-253 0,0 139,0 139,-253"/>
|
<polygon fill="#ffffff" stroke="black" points="139,-253 0,-253 0,0 139,0 139,-253"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,0 0,-253 139,-253 139,0 0,0"/>
|
||||||
<polygon fill="none" stroke="black" points="0.5,-229.5 0.5,-252.5 139.5,-252.5 139.5,-229.5 0.5,-229.5"/>
|
<polygon fill="none" stroke="black" points="0.5,-229.5 0.5,-252.5 139.5,-252.5 139.5,-229.5 0.5,-229.5"/>
|
||||||
<text text-anchor="start" x="61" y="-237.3" font-family="arial" font-size="14.00">X1</text>
|
<text text-anchor="start" x="61" y="-237.3" font-family="arial" font-size="14.00">X1</text>
|
||||||
<polygon fill="none" stroke="black" points="0.5,-206.5 0.5,-229.5 48.5,-229.5 48.5,-206.5 0.5,-206.5"/>
|
<polygon fill="none" stroke="black" points="0.5,-206.5 0.5,-229.5 48.5,-229.5 48.5,-206.5 0.5,-206.5"/>
|
||||||
@ -133,6 +134,7 @@
|
|||||||
<g id="node2" class="node">
|
<g id="node2" class="node">
|
||||||
<title>X2</title>
|
<title>X2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="825,-254 638,-254 638,-139 825,-139 825,-254"/>
|
<polygon fill="#ffffff" stroke="black" points="825,-254 638,-254 638,-139 825,-139 825,-254"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="638,-139 638,-254 825,-254 825,-139 638,-139"/>
|
||||||
<polygon fill="none" stroke="black" points="638.5,-230.5 638.5,-253.5 825.5,-253.5 825.5,-230.5 638.5,-230.5"/>
|
<polygon fill="none" stroke="black" points="638.5,-230.5 638.5,-253.5 825.5,-253.5 825.5,-230.5 638.5,-230.5"/>
|
||||||
<text text-anchor="start" x="723" y="-238.3" font-family="arial" font-size="14.00">X2</text>
|
<text text-anchor="start" x="723" y="-238.3" font-family="arial" font-size="14.00">X2</text>
|
||||||
<polygon fill="none" stroke="black" points="638.5,-207.5 638.5,-230.5 734.5,-230.5 734.5,-207.5 638.5,-207.5"/>
|
<polygon fill="none" stroke="black" points="638.5,-207.5 638.5,-230.5 734.5,-230.5 734.5,-207.5 638.5,-207.5"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
6
examples/demo02.html
generated
@ -199,6 +199,7 @@
|
|||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
<title>X1</title>
|
<title>X1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="189.5,-517.5 2.5,-517.5 2.5,-287.5 189.5,-287.5 189.5,-517.5"/>
|
<polygon fill="#ffffff" stroke="black" points="189.5,-517.5 2.5,-517.5 2.5,-287.5 189.5,-287.5 189.5,-517.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="2.5,-287.5 2.5,-517.5 189.5,-517.5 189.5,-287.5 2.5,-287.5"/>
|
||||||
<polygon fill="none" stroke="black" points="3,-494.5 3,-517.5 190,-517.5 190,-494.5 3,-494.5"/>
|
<polygon fill="none" stroke="black" points="3,-494.5 3,-517.5 190,-517.5 190,-494.5 3,-494.5"/>
|
||||||
<text text-anchor="start" x="87.5" y="-502.3" font-family="arial" font-size="14.00">X1</text>
|
<text text-anchor="start" x="87.5" y="-502.3" font-family="arial" font-size="14.00">X1</text>
|
||||||
<polygon fill="none" stroke="black" points="3,-471.5 3,-494.5 99,-494.5 99,-471.5 3,-471.5"/>
|
<polygon fill="none" stroke="black" points="3,-471.5 3,-494.5 99,-494.5 99,-471.5 3,-471.5"/>
|
||||||
@ -445,6 +446,7 @@
|
|||||||
<g id="node2" class="node">
|
<g id="node2" class="node">
|
||||||
<title>X2</title>
|
<title>X2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-726.5 687,-726.5 687,-588.5 874,-588.5 874,-726.5"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-726.5 687,-726.5 687,-588.5 874,-588.5 874,-726.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-588.5 687,-726.5 874,-726.5 874,-588.5 687,-588.5"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-703.5 687.5,-726.5 874.5,-726.5 874.5,-703.5 687.5,-703.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-703.5 687.5,-726.5 874.5,-726.5 874.5,-703.5 687.5,-703.5"/>
|
||||||
<text text-anchor="start" x="772" y="-711.3" font-family="arial" font-size="14.00">X2</text>
|
<text text-anchor="start" x="772" y="-711.3" font-family="arial" font-size="14.00">X2</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-680.5 687.5,-703.5 783.5,-703.5 783.5,-680.5 687.5,-680.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-680.5 687.5,-703.5 783.5,-703.5 783.5,-680.5 687.5,-680.5"/>
|
||||||
@ -474,6 +476,7 @@
|
|||||||
<g id="node3" class="node">
|
<g id="node3" class="node">
|
||||||
<title>X3</title>
|
<title>X3</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-518.5 687,-518.5 687,-380.5 874,-380.5 874,-518.5"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-518.5 687,-518.5 687,-380.5 874,-380.5 874,-518.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-380.5 687,-518.5 874,-518.5 874,-380.5 687,-380.5"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-495.5 687.5,-518.5 874.5,-518.5 874.5,-495.5 687.5,-495.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-495.5 687.5,-518.5 874.5,-518.5 874.5,-495.5 687.5,-495.5"/>
|
||||||
<text text-anchor="start" x="772" y="-503.3" font-family="arial" font-size="14.00">X3</text>
|
<text text-anchor="start" x="772" y="-503.3" font-family="arial" font-size="14.00">X3</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-472.5 687.5,-495.5 783.5,-495.5 783.5,-472.5 687.5,-472.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-472.5 687.5,-495.5 783.5,-495.5 783.5,-472.5 687.5,-472.5"/>
|
||||||
@ -503,6 +506,7 @@
|
|||||||
<g id="node4" class="node">
|
<g id="node4" class="node">
|
||||||
<title>X4</title>
|
<title>X4</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-322 687,-322 687,-161 874,-161 874,-322"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-322 687,-322 687,-161 874,-161 874,-322"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-161 687,-322 874,-322 874,-161 687,-161"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-298.5 687.5,-321.5 874.5,-321.5 874.5,-298.5 687.5,-298.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-298.5 687.5,-321.5 874.5,-321.5 874.5,-298.5 687.5,-298.5"/>
|
||||||
<text text-anchor="start" x="772" y="-306.3" font-family="arial" font-size="14.00">X4</text>
|
<text text-anchor="start" x="772" y="-306.3" font-family="arial" font-size="14.00">X4</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-275.5 687.5,-298.5 783.5,-298.5 783.5,-275.5 687.5,-275.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-275.5 687.5,-298.5 783.5,-298.5 783.5,-275.5 687.5,-275.5"/>
|
||||||
@ -536,6 +540,7 @@
|
|||||||
<g id="node5" class="node">
|
<g id="node5" class="node">
|
||||||
<title>AUTOGENERATED_F_1</title>
|
<title>AUTOGENERATED_F_1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="192,-70 0,-70 0,-47 192,-47 192,-70"/>
|
<polygon fill="#ffffff" stroke="black" points="192,-70 0,-70 0,-47 192,-47 192,-70"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,-47 0,-70 192,-70 192,-47 0,-47"/>
|
||||||
<polygon fill="none" stroke="black" points="0,-46.5 0,-69.5 89,-69.5 89,-46.5 0,-46.5"/>
|
<polygon fill="none" stroke="black" points="0,-46.5 0,-69.5 89,-69.5 89,-46.5 0,-46.5"/>
|
||||||
<text text-anchor="start" x="4" y="-54.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
<text text-anchor="start" x="4" y="-54.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
||||||
<polygon fill="none" stroke="black" points="89,-46.5 89,-69.5 157,-69.5 157,-46.5 89,-46.5"/>
|
<polygon fill="none" stroke="black" points="89,-46.5 89,-69.5 157,-69.5 157,-46.5 89,-46.5"/>
|
||||||
@ -581,6 +586,7 @@
|
|||||||
<g id="node6" class="node">
|
<g id="node6" class="node">
|
||||||
<title>AUTOGENERATED_F_2</title>
|
<title>AUTOGENERATED_F_2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="192,-23 0,-23 0,0 192,0 192,-23"/>
|
<polygon fill="#ffffff" stroke="black" points="192,-23 0,-23 0,0 192,0 192,-23"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,0 0,-23 192,-23 192,0 0,0"/>
|
||||||
<polygon fill="none" stroke="black" points="0,0.5 0,-22.5 89,-22.5 89,0.5 0,0.5"/>
|
<polygon fill="none" stroke="black" points="0,0.5 0,-22.5 89,-22.5 89,0.5 0,0.5"/>
|
||||||
<text text-anchor="start" x="4" y="-7.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
<text text-anchor="start" x="4" y="-7.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
||||||
<polygon fill="none" stroke="black" points="89,0.5 89,-22.5 157,-22.5 157,0.5 89,0.5"/>
|
<polygon fill="none" stroke="black" points="89,0.5 89,-22.5 157,-22.5 157,0.5 89,0.5"/>
|
||||||
|
|||||||
BIN
examples/demo02.png
generated
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 185 KiB |
6
examples/demo02.svg
generated
@ -12,6 +12,7 @@
|
|||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
<title>X1</title>
|
<title>X1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="189.5,-517.5 2.5,-517.5 2.5,-287.5 189.5,-287.5 189.5,-517.5"/>
|
<polygon fill="#ffffff" stroke="black" points="189.5,-517.5 2.5,-517.5 2.5,-287.5 189.5,-287.5 189.5,-517.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="2.5,-287.5 2.5,-517.5 189.5,-517.5 189.5,-287.5 2.5,-287.5"/>
|
||||||
<polygon fill="none" stroke="black" points="3,-494.5 3,-517.5 190,-517.5 190,-494.5 3,-494.5"/>
|
<polygon fill="none" stroke="black" points="3,-494.5 3,-517.5 190,-517.5 190,-494.5 3,-494.5"/>
|
||||||
<text text-anchor="start" x="87.5" y="-502.3" font-family="arial" font-size="14.00">X1</text>
|
<text text-anchor="start" x="87.5" y="-502.3" font-family="arial" font-size="14.00">X1</text>
|
||||||
<polygon fill="none" stroke="black" points="3,-471.5 3,-494.5 99,-494.5 99,-471.5 3,-471.5"/>
|
<polygon fill="none" stroke="black" points="3,-471.5 3,-494.5 99,-494.5 99,-471.5 3,-471.5"/>
|
||||||
@ -258,6 +259,7 @@
|
|||||||
<g id="node2" class="node">
|
<g id="node2" class="node">
|
||||||
<title>X2</title>
|
<title>X2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-726.5 687,-726.5 687,-588.5 874,-588.5 874,-726.5"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-726.5 687,-726.5 687,-588.5 874,-588.5 874,-726.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-588.5 687,-726.5 874,-726.5 874,-588.5 687,-588.5"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-703.5 687.5,-726.5 874.5,-726.5 874.5,-703.5 687.5,-703.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-703.5 687.5,-726.5 874.5,-726.5 874.5,-703.5 687.5,-703.5"/>
|
||||||
<text text-anchor="start" x="772" y="-711.3" font-family="arial" font-size="14.00">X2</text>
|
<text text-anchor="start" x="772" y="-711.3" font-family="arial" font-size="14.00">X2</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-680.5 687.5,-703.5 783.5,-703.5 783.5,-680.5 687.5,-680.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-680.5 687.5,-703.5 783.5,-703.5 783.5,-680.5 687.5,-680.5"/>
|
||||||
@ -287,6 +289,7 @@
|
|||||||
<g id="node3" class="node">
|
<g id="node3" class="node">
|
||||||
<title>X3</title>
|
<title>X3</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-518.5 687,-518.5 687,-380.5 874,-380.5 874,-518.5"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-518.5 687,-518.5 687,-380.5 874,-380.5 874,-518.5"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-380.5 687,-518.5 874,-518.5 874,-380.5 687,-380.5"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-495.5 687.5,-518.5 874.5,-518.5 874.5,-495.5 687.5,-495.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-495.5 687.5,-518.5 874.5,-518.5 874.5,-495.5 687.5,-495.5"/>
|
||||||
<text text-anchor="start" x="772" y="-503.3" font-family="arial" font-size="14.00">X3</text>
|
<text text-anchor="start" x="772" y="-503.3" font-family="arial" font-size="14.00">X3</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-472.5 687.5,-495.5 783.5,-495.5 783.5,-472.5 687.5,-472.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-472.5 687.5,-495.5 783.5,-495.5 783.5,-472.5 687.5,-472.5"/>
|
||||||
@ -316,6 +319,7 @@
|
|||||||
<g id="node4" class="node">
|
<g id="node4" class="node">
|
||||||
<title>X4</title>
|
<title>X4</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="874,-322 687,-322 687,-161 874,-161 874,-322"/>
|
<polygon fill="#ffffff" stroke="black" points="874,-322 687,-322 687,-161 874,-161 874,-322"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="687,-161 687,-322 874,-322 874,-161 687,-161"/>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-298.5 687.5,-321.5 874.5,-321.5 874.5,-298.5 687.5,-298.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-298.5 687.5,-321.5 874.5,-321.5 874.5,-298.5 687.5,-298.5"/>
|
||||||
<text text-anchor="start" x="772" y="-306.3" font-family="arial" font-size="14.00">X4</text>
|
<text text-anchor="start" x="772" y="-306.3" font-family="arial" font-size="14.00">X4</text>
|
||||||
<polygon fill="none" stroke="black" points="687.5,-275.5 687.5,-298.5 783.5,-298.5 783.5,-275.5 687.5,-275.5"/>
|
<polygon fill="none" stroke="black" points="687.5,-275.5 687.5,-298.5 783.5,-298.5 783.5,-275.5 687.5,-275.5"/>
|
||||||
@ -349,6 +353,7 @@
|
|||||||
<g id="node5" class="node">
|
<g id="node5" class="node">
|
||||||
<title>AUTOGENERATED_F_1</title>
|
<title>AUTOGENERATED_F_1</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="192,-70 0,-70 0,-47 192,-47 192,-70"/>
|
<polygon fill="#ffffff" stroke="black" points="192,-70 0,-70 0,-47 192,-47 192,-70"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,-47 0,-70 192,-70 192,-47 0,-47"/>
|
||||||
<polygon fill="none" stroke="black" points="0,-46.5 0,-69.5 89,-69.5 89,-46.5 0,-46.5"/>
|
<polygon fill="none" stroke="black" points="0,-46.5 0,-69.5 89,-69.5 89,-46.5 0,-46.5"/>
|
||||||
<text text-anchor="start" x="4" y="-54.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
<text text-anchor="start" x="4" y="-54.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
||||||
<polygon fill="none" stroke="black" points="89,-46.5 89,-69.5 157,-69.5 157,-46.5 89,-46.5"/>
|
<polygon fill="none" stroke="black" points="89,-46.5 89,-69.5 157,-69.5 157,-46.5 89,-46.5"/>
|
||||||
@ -394,6 +399,7 @@
|
|||||||
<g id="node6" class="node">
|
<g id="node6" class="node">
|
||||||
<title>AUTOGENERATED_F_2</title>
|
<title>AUTOGENERATED_F_2</title>
|
||||||
<polygon fill="#ffffff" stroke="black" points="192,-23 0,-23 0,0 192,0 192,-23"/>
|
<polygon fill="#ffffff" stroke="black" points="192,-23 0,-23 0,0 192,0 192,-23"/>
|
||||||
|
<polygon fill="#ffffff" stroke="transparent" points="0,0 0,-23 192,-23 192,0 0,0"/>
|
||||||
<polygon fill="none" stroke="black" points="0,0.5 0,-22.5 89,-22.5 89,0.5 0,0.5"/>
|
<polygon fill="none" stroke="black" points="0,0.5 0,-22.5 89,-22.5 89,0.5 0,0.5"/>
|
||||||
<text text-anchor="start" x="4" y="-7.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
<text text-anchor="start" x="4" y="-7.3" font-family="arial" font-size="14.00">Crimp ferrule</text>
|
||||||
<polygon fill="none" stroke="black" points="89,0.5 89,-22.5 157,-22.5 157,0.5 89,0.5"/>
|
<polygon fill="none" stroke="black" points="89,0.5 89,-22.5 157,-22.5 157,0.5 89,0.5"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
@ -3,3 +3,4 @@ graphviz
|
|||||||
pillow
|
pillow
|
||||||
pyyaml
|
pyyaml
|
||||||
setuptools
|
setuptools
|
||||||
|
tabulate
|
||||||
|
|||||||
7
setup.py
@ -15,13 +15,14 @@ setup(
|
|||||||
author="Daniel Rojas",
|
author="Daniel Rojas",
|
||||||
# author_email='',
|
# author_email='',
|
||||||
description="Easily document cables and wiring harnesses",
|
description="Easily document cables and wiring harnesses",
|
||||||
long_description=open(README_PATH).read(),
|
long_description=README_PATH.read_text(),
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"click",
|
"click",
|
||||||
"pyyaml",
|
|
||||||
"pillow",
|
|
||||||
"graphviz",
|
"graphviz",
|
||||||
|
"pillow",
|
||||||
|
"pyyaml",
|
||||||
|
"tabulate",
|
||||||
],
|
],
|
||||||
license="GPLv3",
|
license="GPLv3",
|
||||||
keywords="cable connector hardware harness wiring wiring-diagram wiring-harness",
|
keywords="cable connector hardware harness wiring wiring-diagram wiring-harness",
|
||||||
|
|||||||
@ -1,441 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from dataclasses import InitVar, dataclass, field
|
|
||||||
from enum import Enum, auto
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from wireviz.wv_colors import COLOR_CODES, Color, ColorMode, Colors, ColorScheme
|
|
||||||
from wireviz.wv_helper import aspect_ratio, int2tuple
|
|
||||||
|
|
||||||
# Each type alias have their legal values described in comments - validation might be implemented in the future
|
|
||||||
PlainText = str # Text not containing HTML tags nor newlines
|
|
||||||
Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output
|
|
||||||
MultilineHypertext = (
|
|
||||||
str # Hypertext possibly also including newlines to break lines in diagram output
|
|
||||||
)
|
|
||||||
|
|
||||||
Designator = PlainText # Case insensitive unique name of connector or cable
|
|
||||||
|
|
||||||
# Literal type aliases below are commented to avoid requiring python 3.8
|
|
||||||
ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']
|
|
||||||
CableMultiplier = (
|
|
||||||
PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']
|
|
||||||
)
|
|
||||||
ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both']
|
|
||||||
|
|
||||||
# Type combinations
|
|
||||||
Pin = Union[int, PlainText] # Pin identifier
|
|
||||||
PinIndex = int # Zero-based pin index
|
|
||||||
Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield
|
|
||||||
NoneOrMorePins = Union[
|
|
||||||
Pin, Tuple[Pin, ...], None
|
|
||||||
] # None, one, or a tuple of pin identifiers
|
|
||||||
NoneOrMorePinIndices = Union[
|
|
||||||
PinIndex, Tuple[PinIndex, ...], None
|
|
||||||
] # None, one, or a tuple of zero-based pin indices
|
|
||||||
OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires
|
|
||||||
|
|
||||||
# Metadata can contain whatever is needed by the HTML generation/template.
|
|
||||||
MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...]
|
|
||||||
|
|
||||||
|
|
||||||
Side = Enum("Side", "LEFT RIGHT")
|
|
||||||
|
|
||||||
AUTOGENERATED_PREFIX = "AUTOGENERATED_"
|
|
||||||
|
|
||||||
|
|
||||||
class Metadata(dict):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Options:
|
|
||||||
fontname: PlainText = "arial"
|
|
||||||
bgcolor: Color = "WH"
|
|
||||||
bgcolor_node: Optional[Color] = "WH"
|
|
||||||
bgcolor_connector: Optional[Color] = None
|
|
||||||
bgcolor_cable: Optional[Color] = None
|
|
||||||
bgcolor_bundle: Optional[Color] = None
|
|
||||||
color_mode: ColorMode = "SHORT"
|
|
||||||
mini_bom_mode: bool = True
|
|
||||||
template_separator: str = "."
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if not self.bgcolor_node:
|
|
||||||
self.bgcolor_node = self.bgcolor
|
|
||||||
if not self.bgcolor_connector:
|
|
||||||
self.bgcolor_connector = self.bgcolor_node
|
|
||||||
if not self.bgcolor_cable:
|
|
||||||
self.bgcolor_cable = self.bgcolor_node
|
|
||||||
if not self.bgcolor_bundle:
|
|
||||||
self.bgcolor_bundle = self.bgcolor_cable
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Tweak:
|
|
||||||
override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None
|
|
||||||
append: Union[str, List[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Image:
|
|
||||||
# Attributes of the image object <img>:
|
|
||||||
src: str
|
|
||||||
scale: Optional[ImageScale] = None
|
|
||||||
# Attributes of the image cell <td> containing the image:
|
|
||||||
width: Optional[int] = None
|
|
||||||
height: Optional[int] = None
|
|
||||||
fixedsize: Optional[bool] = None
|
|
||||||
bgcolor: Optional[Color] = None
|
|
||||||
# Contents of the text cell <td> just below the image cell:
|
|
||||||
caption: Optional[MultilineHypertext] = None
|
|
||||||
# See also HTML doc at https://graphviz.org/doc/info/shapes.html#html
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
|
|
||||||
if self.fixedsize is None:
|
|
||||||
# Default True if any dimension specified unless self.scale also is specified.
|
|
||||||
self.fixedsize = (self.width or self.height) and self.scale is None
|
|
||||||
|
|
||||||
if self.scale is None:
|
|
||||||
if not self.width and not self.height:
|
|
||||||
self.scale = "false"
|
|
||||||
elif self.width and self.height:
|
|
||||||
self.scale = "both"
|
|
||||||
else:
|
|
||||||
self.scale = "true" # When only one dimension is specified.
|
|
||||||
|
|
||||||
if self.fixedsize:
|
|
||||||
# If only one dimension is specified, compute the other
|
|
||||||
# because Graphviz requires both when fixedsize=True.
|
|
||||||
if self.height:
|
|
||||||
if not self.width:
|
|
||||||
self.width = self.height * aspect_ratio(self.src)
|
|
||||||
else:
|
|
||||||
if self.width:
|
|
||||||
self.height = self.width / aspect_ratio(self.src)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdditionalComponent:
|
|
||||||
type: MultilineHypertext
|
|
||||||
subtype: Optional[MultilineHypertext] = None
|
|
||||||
manufacturer: Optional[MultilineHypertext] = None
|
|
||||||
mpn: Optional[MultilineHypertext] = None
|
|
||||||
supplier: Optional[MultilineHypertext] = None
|
|
||||||
spn: Optional[MultilineHypertext] = None
|
|
||||||
pn: Optional[Hypertext] = None
|
|
||||||
qty: float = 1
|
|
||||||
unit: Optional[str] = None
|
|
||||||
qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None
|
|
||||||
bgcolor: Optional[Color] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
s = self.type.rstrip() + f", {self.subtype.rstrip()}" if self.subtype else ""
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Connector:
|
|
||||||
name: Designator
|
|
||||||
bgcolor: Optional[Color] = None
|
|
||||||
bgcolor_title: Optional[Color] = None
|
|
||||||
manufacturer: Optional[MultilineHypertext] = None
|
|
||||||
mpn: Optional[MultilineHypertext] = None
|
|
||||||
supplier: Optional[MultilineHypertext] = None
|
|
||||||
spn: Optional[MultilineHypertext] = None
|
|
||||||
pn: Optional[Hypertext] = None
|
|
||||||
style: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
type: Optional[MultilineHypertext] = None
|
|
||||||
subtype: Optional[MultilineHypertext] = None
|
|
||||||
pincount: Optional[int] = None
|
|
||||||
image: Optional[Image] = None
|
|
||||||
notes: Optional[MultilineHypertext] = None
|
|
||||||
pins: List[Pin] = field(default_factory=list)
|
|
||||||
pinlabels: List[Pin] = field(default_factory=list)
|
|
||||||
pincolors: List[Color] = field(default_factory=list)
|
|
||||||
color: Optional[Color] = None
|
|
||||||
show_name: Optional[bool] = None
|
|
||||||
show_pincount: Optional[bool] = None
|
|
||||||
hide_disconnected_pins: bool = False
|
|
||||||
loops: List[List[Pin]] = field(default_factory=list)
|
|
||||||
ignore_in_bom: bool = False
|
|
||||||
additional_components: List[AdditionalComponent] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_autogenerated(self):
|
|
||||||
return self.name.startswith(AUTOGENERATED_PREFIX)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
|
|
||||||
if isinstance(self.image, dict):
|
|
||||||
self.image = Image(**self.image)
|
|
||||||
|
|
||||||
self.ports_left = False
|
|
||||||
self.ports_right = False
|
|
||||||
self.visible_pins = {}
|
|
||||||
|
|
||||||
if self.style == "simple":
|
|
||||||
if self.pincount and self.pincount > 1:
|
|
||||||
raise Exception(
|
|
||||||
"Connectors with style set to simple may only have one pin"
|
|
||||||
)
|
|
||||||
self.pincount = 1
|
|
||||||
|
|
||||||
if not self.pincount:
|
|
||||||
self.pincount = max(
|
|
||||||
len(self.pins), len(self.pinlabels), len(self.pincolors)
|
|
||||||
)
|
|
||||||
if not self.pincount:
|
|
||||||
raise Exception(
|
|
||||||
"You need to specify at least one, pincount, pins, pinlabels, or pincolors"
|
|
||||||
)
|
|
||||||
|
|
||||||
# create default list for pins (sequential) if not specified
|
|
||||||
if not self.pins:
|
|
||||||
self.pins = list(range(1, self.pincount + 1))
|
|
||||||
|
|
||||||
if len(self.pins) != len(set(self.pins)):
|
|
||||||
raise Exception("Pins are not unique")
|
|
||||||
|
|
||||||
if self.show_name is None:
|
|
||||||
self.show_name = self.style != "simple" and not self.is_autogenerated
|
|
||||||
|
|
||||||
if self.show_pincount is None:
|
|
||||||
# hide pincount for simple (1 pin) connectors by default
|
|
||||||
self.show_pincount = self.style != "simple"
|
|
||||||
|
|
||||||
for loop in self.loops:
|
|
||||||
# TODO: allow using pin labels in addition to pin numbers, just like when defining regular connections
|
|
||||||
# TODO: include properties of wire used to create the loop
|
|
||||||
if len(loop) != 2:
|
|
||||||
raise Exception("Loops must be between exactly two pins!")
|
|
||||||
for pin in loop:
|
|
||||||
if pin not in self.pins:
|
|
||||||
raise Exception(f'Unknown loop pin "{pin}" for connector "{self.name}"!')
|
|
||||||
# Make sure loop connected pins are not hidden.
|
|
||||||
self.activate_pin(pin)
|
|
||||||
|
|
||||||
for i, item in enumerate(self.additional_components):
|
|
||||||
if isinstance(item, dict):
|
|
||||||
self.additional_components[i] = AdditionalComponent(**item)
|
|
||||||
|
|
||||||
def activate_pin(self, pin: Pin, side: Side) -> None:
|
|
||||||
self.visible_pins[pin] = True
|
|
||||||
if side == Side.LEFT:
|
|
||||||
self.ports_left = True
|
|
||||||
elif side == Side.RIGHT:
|
|
||||||
self.ports_right = True
|
|
||||||
|
|
||||||
def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int:
|
|
||||||
if not qty_multiplier:
|
|
||||||
return 1
|
|
||||||
elif qty_multiplier == "pincount":
|
|
||||||
return self.pincount
|
|
||||||
elif qty_multiplier == "populated":
|
|
||||||
return sum(self.visible_pins.values())
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid qty multiplier parameter for connector {qty_multiplier}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Cable:
|
|
||||||
name: Designator
|
|
||||||
bgcolor: Optional[Color] = None
|
|
||||||
bgcolor_title: Optional[Color] = None
|
|
||||||
manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None
|
|
||||||
mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None
|
|
||||||
supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None
|
|
||||||
spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None
|
|
||||||
pn: Union[Hypertext, List[Hypertext], None] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
type: Optional[MultilineHypertext] = None
|
|
||||||
gauge: Optional[float] = None
|
|
||||||
gauge_unit: Optional[str] = None
|
|
||||||
show_equiv: bool = False
|
|
||||||
length: float = 0
|
|
||||||
length_unit: Optional[str] = None
|
|
||||||
color: Optional[Color] = None
|
|
||||||
wirecount: Optional[int] = None
|
|
||||||
shield: Union[bool, Color] = False
|
|
||||||
image: Optional[Image] = None
|
|
||||||
notes: Optional[MultilineHypertext] = None
|
|
||||||
colors: List[Colors] = field(default_factory=list)
|
|
||||||
wirelabels: List[Wire] = field(default_factory=list)
|
|
||||||
color_code: Optional[ColorScheme] = None
|
|
||||||
show_name: Optional[bool] = None
|
|
||||||
show_wirecount: bool = True
|
|
||||||
show_wirenumbers: Optional[bool] = None
|
|
||||||
ignore_in_bom: bool = False
|
|
||||||
additional_components: List[AdditionalComponent] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_autogenerated(self):
|
|
||||||
return self.name.startswith(AUTOGENERATED_PREFIX)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
|
|
||||||
if isinstance(self.image, dict):
|
|
||||||
self.image = Image(**self.image)
|
|
||||||
|
|
||||||
if isinstance(self.gauge, str): # gauge and unit specified
|
|
||||||
try:
|
|
||||||
g, u = self.gauge.split(" ")
|
|
||||||
except Exception:
|
|
||||||
raise Exception(
|
|
||||||
f"Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space"
|
|
||||||
)
|
|
||||||
self.gauge = g
|
|
||||||
|
|
||||||
if self.gauge_unit is not None:
|
|
||||||
print(
|
|
||||||
f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}"
|
|
||||||
)
|
|
||||||
if u.upper() == "AWG":
|
|
||||||
self.gauge_unit = u.upper()
|
|
||||||
else:
|
|
||||||
self.gauge_unit = u.replace("mm2", "mm\u00B2")
|
|
||||||
|
|
||||||
elif self.gauge is not None: # gauge specified, assume mm2
|
|
||||||
if self.gauge_unit is None:
|
|
||||||
self.gauge_unit = "mm\u00B2"
|
|
||||||
else:
|
|
||||||
pass # gauge not specified
|
|
||||||
|
|
||||||
if isinstance(self.length, str): # length and unit specified
|
|
||||||
try:
|
|
||||||
L, u = self.length.split(" ")
|
|
||||||
L = float(L)
|
|
||||||
except Exception:
|
|
||||||
raise Exception(
|
|
||||||
f"Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space"
|
|
||||||
)
|
|
||||||
self.length = L
|
|
||||||
if self.length_unit is not None:
|
|
||||||
print(
|
|
||||||
f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}"
|
|
||||||
)
|
|
||||||
self.length_unit = u
|
|
||||||
elif not any(isinstance(self.length, t) for t in [int, float]):
|
|
||||||
raise Exception(f"Cable {self.name} length has a non-numeric value")
|
|
||||||
elif self.length_unit is None:
|
|
||||||
self.length_unit = "m"
|
|
||||||
|
|
||||||
self.connections = []
|
|
||||||
|
|
||||||
if self.wirecount: # number of wires explicitly defined
|
|
||||||
if self.colors: # use custom color palette (partly or looped if needed)
|
|
||||||
pass
|
|
||||||
elif self.color_code:
|
|
||||||
# use standard color palette (partly or looped if needed)
|
|
||||||
if self.color_code not in COLOR_CODES:
|
|
||||||
raise Exception("Unknown color code")
|
|
||||||
self.colors = COLOR_CODES[self.color_code]
|
|
||||||
else: # no colors defined, add dummy colors
|
|
||||||
self.colors = [""] * self.wirecount
|
|
||||||
|
|
||||||
# make color code loop around if more wires than colors
|
|
||||||
if self.wirecount > len(self.colors):
|
|
||||||
m = self.wirecount // len(self.colors) + 1
|
|
||||||
self.colors = self.colors * int(m)
|
|
||||||
# cut off excess after looping
|
|
||||||
self.colors = self.colors[: self.wirecount]
|
|
||||||
else: # wirecount implicit in length of color list
|
|
||||||
if not self.colors:
|
|
||||||
raise Exception(
|
|
||||||
"Unknown number of wires. Must specify wirecount or colors (implicit length)"
|
|
||||||
)
|
|
||||||
self.wirecount = len(self.colors)
|
|
||||||
|
|
||||||
if self.wirelabels:
|
|
||||||
if self.shield and "s" in self.wirelabels:
|
|
||||||
raise Exception(
|
|
||||||
'"s" may not be used as a wire label for a shielded cable.'
|
|
||||||
)
|
|
||||||
|
|
||||||
# if lists of part numbers are provided check this is a bundle and that it matches the wirecount.
|
|
||||||
for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]:
|
|
||||||
if isinstance(idfield, list):
|
|
||||||
if self.category == "bundle":
|
|
||||||
# check the length
|
|
||||||
if len(idfield) != self.wirecount:
|
|
||||||
raise Exception("lists of part data must match wirecount")
|
|
||||||
else:
|
|
||||||
raise Exception("lists of part data are only supported for bundles")
|
|
||||||
|
|
||||||
if self.show_name is None:
|
|
||||||
self.show_name = not self.is_autogenerated
|
|
||||||
|
|
||||||
if self.show_wirenumbers is None:
|
|
||||||
# by default, show wire numbers for cables, hide for bundles
|
|
||||||
self.show_wirenumbers = self.category != "bundle"
|
|
||||||
|
|
||||||
for i, item in enumerate(self.additional_components):
|
|
||||||
if isinstance(item, dict):
|
|
||||||
self.additional_components[i] = AdditionalComponent(**item)
|
|
||||||
|
|
||||||
# The *_pin arguments accept a tuple, but it seems not in use with the current code.
|
|
||||||
def connect(
|
|
||||||
self,
|
|
||||||
from_name: Optional[Designator],
|
|
||||||
from_pin: NoneOrMorePinIndices,
|
|
||||||
via_wire: OneOrMoreWires,
|
|
||||||
to_name: Optional[Designator],
|
|
||||||
to_pin: NoneOrMorePinIndices,
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
from_pin = int2tuple(from_pin)
|
|
||||||
via_wire = int2tuple(via_wire)
|
|
||||||
to_pin = int2tuple(to_pin)
|
|
||||||
if len(from_pin) != len(to_pin):
|
|
||||||
raise Exception("from_pin must have the same number of elements as to_pin")
|
|
||||||
for i, _ in enumerate(from_pin):
|
|
||||||
self.connections.append(
|
|
||||||
Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i])
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float:
|
|
||||||
if not qty_multiplier:
|
|
||||||
return 1
|
|
||||||
elif qty_multiplier == "wirecount":
|
|
||||||
return self.wirecount
|
|
||||||
elif qty_multiplier == "terminations":
|
|
||||||
return len(self.connections)
|
|
||||||
elif qty_multiplier == "length":
|
|
||||||
return self.length
|
|
||||||
elif qty_multiplier == "total_length":
|
|
||||||
return self.length * self.wirecount
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid qty multiplier parameter for cable {qty_multiplier}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Connection:
|
|
||||||
from_name: Optional[Designator]
|
|
||||||
from_pin: Optional[Pin]
|
|
||||||
via_port: Wire
|
|
||||||
to_name: Optional[Designator]
|
|
||||||
to_pin: Optional[Pin]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MatePin:
|
|
||||||
from_name: Designator
|
|
||||||
from_pin: Pin
|
|
||||||
to_name: Designator
|
|
||||||
to_pin: Pin
|
|
||||||
shape: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MateComponent:
|
|
||||||
from_name: Designator
|
|
||||||
to_name: Designator
|
|
||||||
shape: str
|
|
||||||
@ -1,705 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from itertools import zip_longest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List, Union
|
|
||||||
|
|
||||||
from graphviz import Graph
|
|
||||||
|
|
||||||
from wireviz import APP_NAME, APP_URL, __version__, wv_colors
|
|
||||||
from wireviz.DataClasses import (
|
|
||||||
Cable,
|
|
||||||
Connector,
|
|
||||||
MateComponent,
|
|
||||||
MatePin,
|
|
||||||
Metadata,
|
|
||||||
Options,
|
|
||||||
Side,
|
|
||||||
Tweak,
|
|
||||||
)
|
|
||||||
from wireviz.svgembed import embed_svg_images_file
|
|
||||||
from wireviz.wv_bom import (
|
|
||||||
HEADER_MPN,
|
|
||||||
HEADER_PN,
|
|
||||||
HEADER_SPN,
|
|
||||||
bom_list,
|
|
||||||
component_table_entry,
|
|
||||||
generate_bom,
|
|
||||||
get_additional_component_table,
|
|
||||||
pn_info_string,
|
|
||||||
)
|
|
||||||
from wireviz.wv_colors import get_color_hex, translate_color
|
|
||||||
from wireviz.wv_gv_html import (
|
|
||||||
html_bgcolor,
|
|
||||||
html_bgcolor_attr,
|
|
||||||
html_caption,
|
|
||||||
html_colorbar,
|
|
||||||
html_image,
|
|
||||||
html_line_breaks,
|
|
||||||
nested_html_table,
|
|
||||||
remove_links,
|
|
||||||
)
|
|
||||||
from wireviz.wv_helper import (
|
|
||||||
awg_equiv,
|
|
||||||
flatten2d,
|
|
||||||
is_arrow,
|
|
||||||
mm2_equiv,
|
|
||||||
open_file_read,
|
|
||||||
open_file_write,
|
|
||||||
tuplelist2tsv,
|
|
||||||
)
|
|
||||||
from wireviz.wv_html import generate_html_output
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Harness:
|
|
||||||
metadata: Metadata
|
|
||||||
options: Options
|
|
||||||
tweak: Tweak
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
self.connectors = {}
|
|
||||||
self.cables = {}
|
|
||||||
self.mates = []
|
|
||||||
self._bom = [] # Internal Cache for generated bom
|
|
||||||
self.additional_bom_items = []
|
|
||||||
|
|
||||||
def add_connector(self, name: str, *args, **kwargs) -> None:
|
|
||||||
self.connectors[name] = Connector(name, *args, **kwargs)
|
|
||||||
|
|
||||||
def add_cable(self, name: str, *args, **kwargs) -> None:
|
|
||||||
self.cables[name] = Cable(name, *args, **kwargs)
|
|
||||||
|
|
||||||
def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_type) -> None:
|
|
||||||
self.mates.append(MatePin(from_name, from_pin, to_name, to_pin, arrow_type))
|
|
||||||
self.connectors[from_name].activate_pin(from_pin, Side.RIGHT)
|
|
||||||
self.connectors[to_name].activate_pin(to_pin, Side.LEFT)
|
|
||||||
|
|
||||||
def add_mate_component(self, from_name, to_name, arrow_type) -> None:
|
|
||||||
self.mates.append(MateComponent(from_name, to_name, arrow_type))
|
|
||||||
|
|
||||||
def add_bom_item(self, item: dict) -> None:
|
|
||||||
self.additional_bom_items.append(item)
|
|
||||||
|
|
||||||
def connect(
|
|
||||||
self,
|
|
||||||
from_name: str,
|
|
||||||
from_pin: (int, str),
|
|
||||||
via_name: str,
|
|
||||||
via_wire: (int, str),
|
|
||||||
to_name: str,
|
|
||||||
to_pin: (int, str),
|
|
||||||
) -> None:
|
|
||||||
# check from and to connectors
|
|
||||||
for (name, pin) in zip([from_name, to_name], [from_pin, to_pin]):
|
|
||||||
if name is not None and name in self.connectors:
|
|
||||||
connector = self.connectors[name]
|
|
||||||
# check if provided name is ambiguous
|
|
||||||
if pin in connector.pins and pin in connector.pinlabels:
|
|
||||||
if connector.pins.index(pin) != connector.pinlabels.index(pin):
|
|
||||||
raise Exception(
|
|
||||||
f"{name}:{pin} is defined both in pinlabels and pins, for different pins."
|
|
||||||
)
|
|
||||||
# TODO: Maybe issue a warning if present in both lists but referencing the same pin?
|
|
||||||
if pin in connector.pinlabels:
|
|
||||||
if connector.pinlabels.count(pin) > 1:
|
|
||||||
raise Exception(f"{name}:{pin} is defined more than once.")
|
|
||||||
index = connector.pinlabels.index(pin)
|
|
||||||
pin = connector.pins[index] # map pin name to pin number
|
|
||||||
if name == from_name:
|
|
||||||
from_pin = pin
|
|
||||||
if name == to_name:
|
|
||||||
to_pin = pin
|
|
||||||
if not pin in connector.pins:
|
|
||||||
raise Exception(f"{name}:{pin} not found.")
|
|
||||||
|
|
||||||
# check via cable
|
|
||||||
if via_name in self.cables:
|
|
||||||
cable = self.cables[via_name]
|
|
||||||
# check if provided name is ambiguous
|
|
||||||
if via_wire in cable.colors and via_wire in cable.wirelabels:
|
|
||||||
if cable.colors.index(via_wire) != cable.wirelabels.index(via_wire):
|
|
||||||
raise Exception(
|
|
||||||
f"{via_name}:{via_wire} is defined both in colors and wirelabels, for different wires."
|
|
||||||
)
|
|
||||||
# TODO: Maybe issue a warning if present in both lists but referencing the same wire?
|
|
||||||
if via_wire in cable.colors:
|
|
||||||
if cable.colors.count(via_wire) > 1:
|
|
||||||
raise Exception(
|
|
||||||
f"{via_name}:{via_wire} is used for more than one wire."
|
|
||||||
)
|
|
||||||
# list index starts at 0, wire IDs start at 1
|
|
||||||
via_wire = cable.colors.index(via_wire) + 1
|
|
||||||
elif via_wire in cable.wirelabels:
|
|
||||||
if cable.wirelabels.count(via_wire) > 1:
|
|
||||||
raise Exception(
|
|
||||||
f"{via_name}:{via_wire} is used for more than one wire."
|
|
||||||
)
|
|
||||||
via_wire = (
|
|
||||||
cable.wirelabels.index(via_wire) + 1
|
|
||||||
) # list index starts at 0, wire IDs start at 1
|
|
||||||
|
|
||||||
# perform the actual connection
|
|
||||||
self.cables[via_name].connect(from_name, from_pin, via_wire, to_name, to_pin)
|
|
||||||
if from_name in self.connectors:
|
|
||||||
self.connectors[from_name].activate_pin(from_pin, Side.RIGHT)
|
|
||||||
if to_name in self.connectors:
|
|
||||||
self.connectors[to_name].activate_pin(to_pin, Side.LEFT)
|
|
||||||
|
|
||||||
def create_graph(self) -> Graph:
|
|
||||||
dot = Graph()
|
|
||||||
dot.body.append(f"// Graph generated by {APP_NAME} {__version__}\n")
|
|
||||||
dot.body.append(f"// {APP_URL}\n")
|
|
||||||
dot.attr(
|
|
||||||
"graph",
|
|
||||||
rankdir="LR",
|
|
||||||
ranksep="2",
|
|
||||||
bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"),
|
|
||||||
nodesep="0.33",
|
|
||||||
fontname=self.options.fontname,
|
|
||||||
)
|
|
||||||
dot.attr(
|
|
||||||
"node",
|
|
||||||
shape="none",
|
|
||||||
width="0",
|
|
||||||
height="0",
|
|
||||||
margin="0", # Actual size of the node is entirely determined by the label.
|
|
||||||
style="filled",
|
|
||||||
fillcolor=wv_colors.translate_color(self.options.bgcolor_node, "HEX"),
|
|
||||||
fontname=self.options.fontname,
|
|
||||||
)
|
|
||||||
dot.attr("edge", style="bold", fontname=self.options.fontname)
|
|
||||||
|
|
||||||
for connector in self.connectors.values():
|
|
||||||
|
|
||||||
# If no wires connected (except maybe loop wires)?
|
|
||||||
if not (connector.ports_left or connector.ports_right):
|
|
||||||
connector.ports_left = True # Use left side pins.
|
|
||||||
|
|
||||||
html = []
|
|
||||||
# fmt: off
|
|
||||||
rows = [[f'{html_bgcolor(connector.bgcolor_title)}{remove_links(connector.name)}'
|
|
||||||
if connector.show_name else None],
|
|
||||||
[pn_info_string(HEADER_PN, None, remove_links(connector.pn)),
|
|
||||||
html_line_breaks(pn_info_string(HEADER_MPN, connector.manufacturer, connector.mpn)),
|
|
||||||
html_line_breaks(pn_info_string(HEADER_SPN, connector.supplier, connector.spn))],
|
|
||||||
[html_line_breaks(connector.type),
|
|
||||||
html_line_breaks(connector.subtype),
|
|
||||||
f'{connector.pincount}-pin' if connector.show_pincount else None,
|
|
||||||
translate_color(connector.color, self.options.color_mode) if connector.color else None,
|
|
||||||
html_colorbar(connector.color)],
|
|
||||||
'<!-- connector table -->' if connector.style != 'simple' else None,
|
|
||||||
[html_image(connector.image)],
|
|
||||||
[html_caption(connector.image)]]
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
rows.extend(get_additional_component_table(self, connector))
|
|
||||||
rows.append([html_line_breaks(connector.notes)])
|
|
||||||
html.extend(nested_html_table(rows, html_bgcolor_attr(connector.bgcolor)))
|
|
||||||
|
|
||||||
if connector.style != "simple":
|
|
||||||
pinhtml = []
|
|
||||||
pinhtml.append(
|
|
||||||
'<table border="0" cellspacing="0" cellpadding="3" cellborder="1">'
|
|
||||||
)
|
|
||||||
|
|
||||||
for pinindex, (pinname, pinlabel, pincolor) in enumerate(
|
|
||||||
zip_longest(
|
|
||||||
connector.pins, connector.pinlabels, connector.pincolors
|
|
||||||
)
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
connector.hide_disconnected_pins
|
|
||||||
and not connector.visible_pins.get(pinname, False)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
pinhtml.append(" <tr>")
|
|
||||||
if connector.ports_left:
|
|
||||||
pinhtml.append(f' <td port="p{pinindex+1}l">{pinname}</td>')
|
|
||||||
if pinlabel:
|
|
||||||
pinhtml.append(f" <td>{pinlabel}</td>")
|
|
||||||
if connector.pincolors:
|
|
||||||
if pincolor in wv_colors._color_hex.keys():
|
|
||||||
# fmt: off
|
|
||||||
pinhtml.append(f' <td sides="tbl">{translate_color(pincolor, self.options.color_mode)}</td>')
|
|
||||||
pinhtml.append( ' <td sides="tbr">')
|
|
||||||
pinhtml.append( ' <table border="0" cellborder="1"><tr>')
|
|
||||||
pinhtml.append(f' <td bgcolor="{wv_colors.translate_color(pincolor, "HEX")}" width="8" height="8" fixedsize="true"></td>')
|
|
||||||
pinhtml.append( ' </tr></table>')
|
|
||||||
pinhtml.append( ' </td>')
|
|
||||||
# fmt: on
|
|
||||||
else:
|
|
||||||
pinhtml.append(' <td colspan="2"></td>')
|
|
||||||
|
|
||||||
if connector.ports_right:
|
|
||||||
pinhtml.append(f' <td port="p{pinindex+1}r">{pinname}</td>')
|
|
||||||
pinhtml.append(" </tr>")
|
|
||||||
|
|
||||||
pinhtml.append(" </table>")
|
|
||||||
|
|
||||||
html = [
|
|
||||||
row.replace("<!-- connector table -->", "\n".join(pinhtml))
|
|
||||||
for row in html
|
|
||||||
]
|
|
||||||
|
|
||||||
html = "\n".join(html)
|
|
||||||
dot.node(
|
|
||||||
connector.name,
|
|
||||||
label=f"<\n{html}\n>",
|
|
||||||
shape="box",
|
|
||||||
style="filled",
|
|
||||||
fillcolor=translate_color(self.options.bgcolor_connector, "HEX"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(connector.loops) > 0:
|
|
||||||
dot.attr("edge", color="#000000:#ffffff:#000000")
|
|
||||||
if connector.ports_left:
|
|
||||||
loop_side = "l"
|
|
||||||
loop_dir = "w"
|
|
||||||
elif connector.ports_right:
|
|
||||||
loop_side = "r"
|
|
||||||
loop_dir = "e"
|
|
||||||
else:
|
|
||||||
raise Exception("No side for loops")
|
|
||||||
for loop in connector.loops:
|
|
||||||
dot.edge(
|
|
||||||
f"{connector.name}:p{loop[0]}{loop_side}:{loop_dir}",
|
|
||||||
f"{connector.name}:p{loop[1]}{loop_side}:{loop_dir}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# determine if there are double- or triple-colored wires in the harness;
|
|
||||||
# if so, pad single-color wires to make all wires of equal thickness
|
|
||||||
pad = any(
|
|
||||||
len(colorstr) > 2
|
|
||||||
for cable in self.cables.values()
|
|
||||||
for colorstr in cable.colors
|
|
||||||
)
|
|
||||||
|
|
||||||
for cable in self.cables.values():
|
|
||||||
|
|
||||||
html = []
|
|
||||||
|
|
||||||
awg_fmt = ""
|
|
||||||
if cable.show_equiv:
|
|
||||||
# Only convert units we actually know about, i.e. currently
|
|
||||||
# mm2 and awg --- other units _are_ technically allowed,
|
|
||||||
# and passed through as-is.
|
|
||||||
if cable.gauge_unit == "mm\u00B2":
|
|
||||||
awg_fmt = f" ({awg_equiv(cable.gauge)} AWG)"
|
|
||||||
elif cable.gauge_unit.upper() == "AWG":
|
|
||||||
awg_fmt = f" ({mm2_equiv(cable.gauge)} mm\u00B2)"
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
rows = [[f'{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}'
|
|
||||||
if cable.show_name else None],
|
|
||||||
[pn_info_string(HEADER_PN, None,
|
|
||||||
remove_links(cable.pn)) if not isinstance(cable.pn, list) else None,
|
|
||||||
html_line_breaks(pn_info_string(HEADER_MPN,
|
|
||||||
cable.manufacturer if not isinstance(cable.manufacturer, list) else None,
|
|
||||||
cable.mpn if not isinstance(cable.mpn, list) else None)),
|
|
||||||
html_line_breaks(pn_info_string(HEADER_SPN,
|
|
||||||
cable.supplier if not isinstance(cable.supplier, list) else None,
|
|
||||||
cable.spn if not isinstance(cable.spn, list) else None))],
|
|
||||||
[html_line_breaks(cable.type),
|
|
||||||
f'{cable.wirecount}x' if cable.show_wirecount else None,
|
|
||||||
f'{cable.gauge} {cable.gauge_unit}{awg_fmt}' if cable.gauge else None,
|
|
||||||
'+ S' if cable.shield else None,
|
|
||||||
f'{cable.length} {cable.length_unit}' if cable.length > 0 else None,
|
|
||||||
translate_color(cable.color, self.options.color_mode) if cable.color else None,
|
|
||||||
html_colorbar(cable.color)],
|
|
||||||
'<!-- wire table -->',
|
|
||||||
[html_image(cable.image)],
|
|
||||||
[html_caption(cable.image)]]
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
rows.extend(get_additional_component_table(self, cable))
|
|
||||||
rows.append([html_line_breaks(cable.notes)])
|
|
||||||
html.extend(nested_html_table(rows, html_bgcolor_attr(cable.bgcolor)))
|
|
||||||
|
|
||||||
wirehtml = []
|
|
||||||
# conductor table
|
|
||||||
wirehtml.append('<table border="0" cellspacing="0" cellborder="0">')
|
|
||||||
wirehtml.append(" <tr><td> </td></tr>")
|
|
||||||
|
|
||||||
for i, (connection_color, wirelabel) in enumerate(
|
|
||||||
zip_longest(cable.colors, cable.wirelabels), 1
|
|
||||||
):
|
|
||||||
wirehtml.append(" <tr>")
|
|
||||||
wirehtml.append(f" <td><!-- {i}_in --></td>")
|
|
||||||
wirehtml.append(f" <td>")
|
|
||||||
|
|
||||||
wireinfo = []
|
|
||||||
if cable.show_wirenumbers:
|
|
||||||
wireinfo.append(str(i))
|
|
||||||
colorstr = wv_colors.translate_color(
|
|
||||||
connection_color, self.options.color_mode
|
|
||||||
)
|
|
||||||
if colorstr:
|
|
||||||
wireinfo.append(colorstr)
|
|
||||||
if cable.wirelabels:
|
|
||||||
wireinfo.append(wirelabel if wirelabel is not None else "")
|
|
||||||
wirehtml.append(f' {":".join(wireinfo)}')
|
|
||||||
|
|
||||||
wirehtml.append(f" </td>")
|
|
||||||
wirehtml.append(f" <td><!-- {i}_out --></td>")
|
|
||||||
wirehtml.append(" </tr>")
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
bgcolors = ['#000000'] + get_color_hex(connection_color, pad=pad) + ['#000000']
|
|
||||||
wirehtml.append(f" <tr>")
|
|
||||||
wirehtml.append(f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">')
|
|
||||||
wirehtml.append(' <table cellspacing="0" cellborder="0" border="0">')
|
|
||||||
for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors
|
|
||||||
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>')
|
|
||||||
wirehtml.append(" </table>")
|
|
||||||
wirehtml.append(" </td>")
|
|
||||||
wirehtml.append(" </tr>")
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
# for bundles, individual wires can have part information
|
|
||||||
if cable.category == "bundle":
|
|
||||||
# create a list of wire parameters
|
|
||||||
wireidentification = []
|
|
||||||
if isinstance(cable.pn, list):
|
|
||||||
wireidentification.append(
|
|
||||||
pn_info_string(
|
|
||||||
HEADER_PN, None, remove_links(cable.pn[i - 1])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
manufacturer_info = pn_info_string(
|
|
||||||
HEADER_MPN,
|
|
||||||
cable.manufacturer[i - 1]
|
|
||||||
if isinstance(cable.manufacturer, list)
|
|
||||||
else None,
|
|
||||||
cable.mpn[i - 1] if isinstance(cable.mpn, list) else None,
|
|
||||||
)
|
|
||||||
supplier_info = pn_info_string(
|
|
||||||
HEADER_SPN,
|
|
||||||
cable.supplier[i - 1]
|
|
||||||
if isinstance(cable.supplier, list)
|
|
||||||
else None,
|
|
||||||
cable.spn[i - 1] if isinstance(cable.spn, list) else None,
|
|
||||||
)
|
|
||||||
if manufacturer_info:
|
|
||||||
wireidentification.append(html_line_breaks(manufacturer_info))
|
|
||||||
if supplier_info:
|
|
||||||
wireidentification.append(html_line_breaks(supplier_info))
|
|
||||||
# print parameters into a table row under the wire
|
|
||||||
if len(wireidentification) > 0:
|
|
||||||
# fmt: off
|
|
||||||
wirehtml.append(' <tr><td colspan="3">')
|
|
||||||
wirehtml.append(' <table border="0" cellspacing="0" cellborder="0"><tr>')
|
|
||||||
for attrib in wireidentification:
|
|
||||||
wirehtml.append(f" <td>{attrib}</td>")
|
|
||||||
wirehtml.append(" </tr></table>")
|
|
||||||
wirehtml.append(" </td></tr>")
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
if cable.shield:
|
|
||||||
wirehtml.append(" <tr><td> </td></tr>") # spacer
|
|
||||||
wirehtml.append(" <tr>")
|
|
||||||
wirehtml.append(" <td><!-- s_in --></td>")
|
|
||||||
wirehtml.append(" <td>Shield</td>")
|
|
||||||
wirehtml.append(" <td><!-- s_out --></td>")
|
|
||||||
wirehtml.append(" </tr>")
|
|
||||||
if isinstance(cable.shield, str):
|
|
||||||
# shield is shown with specified color and black borders
|
|
||||||
shield_color_hex = wv_colors.get_color_hex(cable.shield)[0]
|
|
||||||
attributes = (
|
|
||||||
f'height="6" bgcolor="{shield_color_hex}" border="2" sides="tb"'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# shield is shown as a thin black wire
|
|
||||||
attributes = f'height="2" bgcolor="#000000" border="0"'
|
|
||||||
# fmt: off
|
|
||||||
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" {attributes} port="ws"></td></tr>')
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
wirehtml.append(" <tr><td> </td></tr>")
|
|
||||||
wirehtml.append(" </table>")
|
|
||||||
|
|
||||||
html = [
|
|
||||||
row.replace("<!-- wire table -->", "\n".join(wirehtml)) for row in html
|
|
||||||
]
|
|
||||||
|
|
||||||
# connections
|
|
||||||
for connection in cable.connections:
|
|
||||||
if isinstance(connection.via_port, int):
|
|
||||||
# check if it's an actual wire and not a shield
|
|
||||||
dot.attr(
|
|
||||||
"edge",
|
|
||||||
color=":".join(
|
|
||||||
["#000000"]
|
|
||||||
+ wv_colors.get_color_hex(
|
|
||||||
cable.colors[connection.via_port - 1], pad=pad
|
|
||||||
)
|
|
||||||
+ ["#000000"]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else: # it's a shield connection
|
|
||||||
# shield is shown with specified color and black borders, or as a thin black wire otherwise
|
|
||||||
dot.attr(
|
|
||||||
"edge",
|
|
||||||
color=":".join(["#000000", shield_color_hex, "#000000"])
|
|
||||||
if isinstance(cable.shield, str)
|
|
||||||
else "#000000",
|
|
||||||
)
|
|
||||||
if connection.from_pin is not None: # connect to left
|
|
||||||
from_connector = self.connectors[connection.from_name]
|
|
||||||
from_pin_index = from_connector.pins.index(connection.from_pin)
|
|
||||||
from_port_str = (
|
|
||||||
f":p{from_pin_index+1}r"
|
|
||||||
if from_connector.style != "simple"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
code_left_1 = f"{connection.from_name}{from_port_str}:e"
|
|
||||||
code_left_2 = f"{cable.name}:w{connection.via_port}:w"
|
|
||||||
dot.edge(code_left_1, code_left_2)
|
|
||||||
if from_connector.show_name:
|
|
||||||
from_info = [
|
|
||||||
str(connection.from_name),
|
|
||||||
str(connection.from_pin),
|
|
||||||
]
|
|
||||||
if from_connector.pinlabels:
|
|
||||||
pinlabel = from_connector.pinlabels[from_pin_index]
|
|
||||||
if pinlabel != "":
|
|
||||||
from_info.append(pinlabel)
|
|
||||||
from_string = ":".join(from_info)
|
|
||||||
else:
|
|
||||||
from_string = ""
|
|
||||||
html = [
|
|
||||||
row.replace(f"<!-- {connection.via_port}_in -->", from_string)
|
|
||||||
for row in html
|
|
||||||
]
|
|
||||||
if connection.to_pin is not None: # connect to right
|
|
||||||
to_connector = self.connectors[connection.to_name]
|
|
||||||
to_pin_index = to_connector.pins.index(connection.to_pin)
|
|
||||||
to_port_str = (
|
|
||||||
f":p{to_pin_index+1}l" if to_connector.style != "simple" else ""
|
|
||||||
)
|
|
||||||
code_right_1 = f"{cable.name}:w{connection.via_port}:e"
|
|
||||||
code_right_2 = f"{connection.to_name}{to_port_str}:w"
|
|
||||||
dot.edge(code_right_1, code_right_2)
|
|
||||||
if to_connector.show_name:
|
|
||||||
to_info = [str(connection.to_name), str(connection.to_pin)]
|
|
||||||
if to_connector.pinlabels:
|
|
||||||
pinlabel = to_connector.pinlabels[to_pin_index]
|
|
||||||
if pinlabel != "":
|
|
||||||
to_info.append(pinlabel)
|
|
||||||
to_string = ":".join(to_info)
|
|
||||||
else:
|
|
||||||
to_string = ""
|
|
||||||
html = [
|
|
||||||
row.replace(f"<!-- {connection.via_port}_out -->", to_string)
|
|
||||||
for row in html
|
|
||||||
]
|
|
||||||
|
|
||||||
style, bgcolor = (
|
|
||||||
("filled,dashed", self.options.bgcolor_bundle)
|
|
||||||
if cable.category == "bundle"
|
|
||||||
else ("filled", self.options.bgcolor_cable)
|
|
||||||
)
|
|
||||||
html = "\n".join(html)
|
|
||||||
dot.node(
|
|
||||||
cable.name,
|
|
||||||
label=f"<\n{html}\n>",
|
|
||||||
shape="box",
|
|
||||||
style=style,
|
|
||||||
fillcolor=translate_color(bgcolor, "HEX"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def typecheck(name: str, value: Any, expect: type) -> None:
|
|
||||||
if not isinstance(value, expect):
|
|
||||||
raise Exception(
|
|
||||||
f"Unexpected value type of {name}: Expected {expect}, got {type(value)}\n{value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO?: Differ between override attributes and HTML?
|
|
||||||
if self.tweak.override is not None:
|
|
||||||
typecheck("tweak.override", self.tweak.override, dict)
|
|
||||||
for k, d in self.tweak.override.items():
|
|
||||||
typecheck(f"tweak.override.{k} key", k, str)
|
|
||||||
typecheck(f"tweak.override.{k} value", d, dict)
|
|
||||||
for a, v in d.items():
|
|
||||||
typecheck(f"tweak.override.{k}.{a} key", a, str)
|
|
||||||
typecheck(f"tweak.override.{k}.{a} value", v, (str, type(None)))
|
|
||||||
|
|
||||||
# Override generated attributes of selected entries matching tweak.override.
|
|
||||||
for i, entry in enumerate(dot.body):
|
|
||||||
if isinstance(entry, str):
|
|
||||||
# Find a possibly quoted keyword after leading TAB(s) and followed by [ ].
|
|
||||||
match = re.match(
|
|
||||||
r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S
|
|
||||||
)
|
|
||||||
keyword = match and match[2]
|
|
||||||
if keyword in self.tweak.override.keys():
|
|
||||||
for attr, value in self.tweak.override[keyword].items():
|
|
||||||
if value is None:
|
|
||||||
entry, n_subs = re.subn(
|
|
||||||
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
|
|
||||||
)
|
|
||||||
if n_subs < 1:
|
|
||||||
print(
|
|
||||||
f"Harness.create_graph() warning: {attr} not found in {keyword}!"
|
|
||||||
)
|
|
||||||
elif n_subs > 1:
|
|
||||||
print(
|
|
||||||
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if len(value) == 0 or " " in value:
|
|
||||||
value = value.replace('"', r"\"")
|
|
||||||
value = f'"{value}"'
|
|
||||||
entry, n_subs = re.subn(
|
|
||||||
f'{attr}=("[^"]*"|[^] ]*)', f"{attr}={value}", entry
|
|
||||||
)
|
|
||||||
if n_subs < 1:
|
|
||||||
# If attr not found, then append it
|
|
||||||
entry = re.sub(r"\]$", f" {attr}={value}]", entry)
|
|
||||||
elif n_subs > 1:
|
|
||||||
print(
|
|
||||||
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!"
|
|
||||||
)
|
|
||||||
|
|
||||||
dot.body[i] = entry
|
|
||||||
|
|
||||||
if self.tweak.append is not None:
|
|
||||||
if isinstance(self.tweak.append, list):
|
|
||||||
for i, element in enumerate(self.tweak.append, 1):
|
|
||||||
typecheck(f"tweak.append[{i}]", element, str)
|
|
||||||
dot.body.extend(self.tweak.append)
|
|
||||||
else:
|
|
||||||
typecheck("tweak.append", self.tweak.append, str)
|
|
||||||
dot.body.append(self.tweak.append)
|
|
||||||
|
|
||||||
for mate in self.mates:
|
|
||||||
if mate.shape[0] == "<" and mate.shape[-1] == ">":
|
|
||||||
dir = "both"
|
|
||||||
elif mate.shape[0] == "<":
|
|
||||||
dir = "back"
|
|
||||||
elif mate.shape[-1] == ">":
|
|
||||||
dir = "forward"
|
|
||||||
else:
|
|
||||||
dir = "none"
|
|
||||||
|
|
||||||
if isinstance(mate, MatePin):
|
|
||||||
color = "#000000"
|
|
||||||
elif isinstance(mate, MateComponent):
|
|
||||||
color = "#000000:#000000"
|
|
||||||
else:
|
|
||||||
raise Exception(f"{mate} is an unknown mate")
|
|
||||||
|
|
||||||
from_connector = self.connectors[mate.from_name]
|
|
||||||
if (
|
|
||||||
isinstance(mate, MatePin)
|
|
||||||
and self.connectors[mate.from_name].style != "simple"
|
|
||||||
):
|
|
||||||
from_pin_index = from_connector.pins.index(mate.from_pin)
|
|
||||||
from_port_str = f":p{from_pin_index+1}r"
|
|
||||||
else: # MateComponent or style == 'simple'
|
|
||||||
from_port_str = ""
|
|
||||||
if (
|
|
||||||
isinstance(mate, MatePin)
|
|
||||||
and self.connectors[mate.to_name].style != "simple"
|
|
||||||
):
|
|
||||||
to_pin_index = to_connector.pins.index(mate.to_pin)
|
|
||||||
to_port_str = (
|
|
||||||
f":p{to_pin_index+1}l"
|
|
||||||
if isinstance(mate, MatePin)
|
|
||||||
and self.connectors[mate.to_name].style != "simple"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
else: # MateComponent or style == 'simple'
|
|
||||||
to_port_str = ""
|
|
||||||
code_from = f"{mate.from_name}{from_port_str}:e"
|
|
||||||
to_connector = self.connectors[mate.to_name]
|
|
||||||
code_to = f"{mate.to_name}{to_port_str}:w"
|
|
||||||
|
|
||||||
dot.attr("edge", color=color, style="dashed", dir=dir)
|
|
||||||
dot.edge(code_from, code_to)
|
|
||||||
|
|
||||||
return dot
|
|
||||||
|
|
||||||
# cache for the GraphViz Graph object
|
|
||||||
# do not access directly, use self.graph instead
|
|
||||||
_graph = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def graph(self):
|
|
||||||
if not self._graph: # no cached graph exists, generate one
|
|
||||||
self._graph = self.create_graph()
|
|
||||||
return self._graph # return cached graph
|
|
||||||
|
|
||||||
@property
|
|
||||||
def png(self):
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
graph = self.graph
|
|
||||||
data = BytesIO()
|
|
||||||
data.write(graph.pipe(format="png"))
|
|
||||||
data.seek(0)
|
|
||||||
return data.read()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def svg(self):
|
|
||||||
graph = self.graph
|
|
||||||
return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd())
|
|
||||||
|
|
||||||
def output(
|
|
||||||
self,
|
|
||||||
filename: (str, Path),
|
|
||||||
view: bool = False,
|
|
||||||
cleanup: bool = True,
|
|
||||||
fmt: tuple = ("html", "png", "svg", "tsv"),
|
|
||||||
) -> None:
|
|
||||||
# graphical output
|
|
||||||
graph = self.graph
|
|
||||||
svg_already_exists = Path(
|
|
||||||
f"{filename}.svg"
|
|
||||||
).exists() # if SVG already exists, do not delete later
|
|
||||||
# graphical output
|
|
||||||
for f in fmt:
|
|
||||||
if f in ("png", "svg", "html"):
|
|
||||||
if f == "html": # if HTML format is specified,
|
|
||||||
f = "svg" # generate SVG for embedding into HTML
|
|
||||||
# SVG file will be renamed/deleted later
|
|
||||||
_filename = f"{filename}.tmp" if f == "svg" else filename
|
|
||||||
# TODO: prevent rendering SVG twice when both SVG and HTML are specified
|
|
||||||
graph.format = f
|
|
||||||
graph.render(filename=_filename, view=view, cleanup=cleanup)
|
|
||||||
# embed images into SVG output
|
|
||||||
if "svg" in fmt or "html" in fmt:
|
|
||||||
embed_svg_images_file(f"{filename}.tmp.svg")
|
|
||||||
# GraphViz output
|
|
||||||
if "gv" in fmt:
|
|
||||||
graph.save(filename=f"{filename}.gv")
|
|
||||||
# BOM output
|
|
||||||
bomlist = bom_list(self.bom())
|
|
||||||
if "tsv" in fmt:
|
|
||||||
open_file_write(f"{filename}.bom.tsv").write(tuplelist2tsv(bomlist))
|
|
||||||
if "csv" in fmt:
|
|
||||||
# TODO: implement CSV output (preferrably using CSV library)
|
|
||||||
print("CSV output is not yet supported")
|
|
||||||
# HTML output
|
|
||||||
if "html" in fmt:
|
|
||||||
generate_html_output(filename, bomlist, self.metadata, self.options)
|
|
||||||
# PDF output
|
|
||||||
if "pdf" in fmt:
|
|
||||||
# TODO: implement PDF output
|
|
||||||
print("PDF output is not yet supported")
|
|
||||||
# delete SVG if not needed
|
|
||||||
if "html" in fmt and not "svg" in fmt:
|
|
||||||
# SVG file was just needed to generate HTML
|
|
||||||
Path(f"{filename}.tmp.svg").unlink()
|
|
||||||
elif "svg" in fmt:
|
|
||||||
Path(f"{filename}.tmp.svg").replace(f"{filename}.svg")
|
|
||||||
|
|
||||||
def bom(self):
|
|
||||||
if not self._bom:
|
|
||||||
self._bom = generate_bom(self)
|
|
||||||
return self._bom
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Please don't import anything in this file to avoid issues when it is imported in setup.py
|
# Please don't import anything in this file to avoid issues when it is imported in setup.py
|
||||||
|
|
||||||
__version__ = "0.4-dev"
|
__version__ = "0.4-dev-refactored"
|
||||||
|
|
||||||
CMD_NAME = "wireviz" # Lower case command and module name
|
CMD_NAME = "wireviz" # Lower case command and module name
|
||||||
APP_NAME = "WireViz" # Application name in texts meant to be human readable
|
APP_NAME = "WireViz" # Application name in texts meant to be human readable
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
mime_subtype_replacements = {"jpg": "jpeg", "tif": "tiff"}
|
|
||||||
|
|
||||||
|
|
||||||
def embed_svg_images(svg_in: str, base_path: Union[str, Path] = Path.cwd()) -> str:
|
|
||||||
images_b64 = {} # cache of base64-encoded images
|
|
||||||
|
|
||||||
def image_tag(pre: str, url: str, post: str) -> str:
|
|
||||||
return f'<image{pre} xlink:href="{url}"{post}>'
|
|
||||||
|
|
||||||
def replace(match: re.Match) -> str:
|
|
||||||
imgurl = match["URL"]
|
|
||||||
if not imgurl in images_b64: # only encode/cache every unique URL once
|
|
||||||
imgurl_abs = (Path(base_path) / imgurl).resolve()
|
|
||||||
image = imgurl_abs.read_bytes()
|
|
||||||
images_b64[imgurl] = base64.b64encode(image).decode("utf-8")
|
|
||||||
return image_tag(
|
|
||||||
match["PRE"] or "",
|
|
||||||
f"data:image/{get_mime_subtype(imgurl)};base64, {images_b64[imgurl]}",
|
|
||||||
match["POST"] or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
pattern = re.compile(
|
|
||||||
image_tag(r"(?P<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return pattern.sub(replace, svg_in)
|
|
||||||
|
|
||||||
|
|
||||||
def get_mime_subtype(filename: Union[str, Path]) -> str:
|
|
||||||
mime_subtype = Path(filename).suffix.lstrip(".").lower()
|
|
||||||
if mime_subtype in mime_subtype_replacements:
|
|
||||||
mime_subtype = mime_subtype_replacements[mime_subtype]
|
|
||||||
return mime_subtype
|
|
||||||
|
|
||||||
|
|
||||||
def embed_svg_images_file(
|
|
||||||
filename_in: Union[str, Path], overwrite: bool = True
|
|
||||||
) -> None:
|
|
||||||
filename_in = Path(filename_in).resolve()
|
|
||||||
filename_out = filename_in.with_suffix(".b64.svg")
|
|
||||||
filename_out.write_text(
|
|
||||||
embed_svg_images(filename_in.read_text(), filename_in.parent)
|
|
||||||
)
|
|
||||||
if overwrite:
|
|
||||||
filename_out.replace(filename_in)
|
|
||||||
@ -7,13 +7,12 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
script_path = Path(__file__).absolute()
|
script_path = Path(__file__).absolute()
|
||||||
|
sys.path.insert(0, str(script_path.parent.parent.parent)) # to find wireviz module
|
||||||
sys.path.insert(0, str(script_path.parent.parent)) # to find wireviz module
|
|
||||||
from wv_helper import open_file_append, open_file_read, open_file_write
|
|
||||||
|
|
||||||
from wireviz import APP_NAME, __version__, wireviz
|
from wireviz import APP_NAME, __version__, wireviz
|
||||||
|
from wireviz.wv_utils import open_file_append, open_file_read, open_file_write
|
||||||
|
|
||||||
dir = script_path.parent.parent.parent
|
dir = script_path.parent.parent.parent.parent
|
||||||
readme = "readme.md"
|
readme = "readme.md"
|
||||||
groups = {
|
groups = {
|
||||||
"examples": {
|
"examples": {
|
||||||
@ -98,7 +97,7 @@ def clean_generated(groupkeys):
|
|||||||
for filename in collect_filenames("Cleaning", key, generated_extensions):
|
for filename in collect_filenames("Cleaning", key, generated_extensions):
|
||||||
if filename.is_file():
|
if filename.is_file():
|
||||||
print(f' rm "{filename}"')
|
print(f' rm "{filename}"')
|
||||||
Path(filename).unlink()
|
filename.unlink()
|
||||||
|
|
||||||
|
|
||||||
def compare_generated(groupkeys, branch="", include_graphviz_output=False):
|
def compare_generated(groupkeys, branch="", include_graphviz_output=False):
|
||||||
@ -10,9 +10,9 @@ import yaml
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
|
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
|
||||||
|
|
||||||
from wireviz.DataClasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
|
from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
|
||||||
from wireviz.Harness import Harness
|
from wireviz.wv_harness import Harness
|
||||||
from wireviz.wv_helper import (
|
from wireviz.wv_utils import (
|
||||||
expand,
|
expand,
|
||||||
get_single_key_and_value,
|
get_single_key_and_value,
|
||||||
is_arrow,
|
is_arrow,
|
||||||
@ -34,7 +34,7 @@ def parse(
|
|||||||
and outputs the result as one or more files and/or as a function return value
|
and outputs the result as one or more files and/or as a function return value
|
||||||
|
|
||||||
Accepted inputs:
|
Accepted inputs:
|
||||||
* A path to a YAML source file to parse
|
* A Path object or a path-like string pointing to a YAML source file to parse
|
||||||
* A string containing the YAML data to parse
|
* A string containing the YAML data to parse
|
||||||
* A Python Dict containing the pre-parsed YAML data
|
* A Python Dict containing the pre-parsed YAML data
|
||||||
|
|
||||||
@ -93,7 +93,8 @@ def parse(
|
|||||||
output_file = output_dir / output_name
|
output_file = output_dir / output_name
|
||||||
|
|
||||||
if yaml_file:
|
if yaml_file:
|
||||||
# if reading from file, ensure that input file's parent directory is included in image_paths
|
# if reading from file, ensure that input file's parent directory
|
||||||
|
# is included in image_paths
|
||||||
default_image_path = yaml_file.parent.resolve()
|
default_image_path = yaml_file.parent.resolve()
|
||||||
if not default_image_path in [Path(x).resolve() for x in image_paths]:
|
if not default_image_path in [Path(x).resolve() for x in image_paths]:
|
||||||
image_paths.append(default_image_path)
|
image_paths.append(default_image_path)
|
||||||
@ -128,7 +129,8 @@ def parse(
|
|||||||
if len(yaml_data[sec]) > 0: # section has contents
|
if len(yaml_data[sec]) > 0: # section has contents
|
||||||
if ty == dict:
|
if ty == dict:
|
||||||
for key, attribs in yaml_data[sec].items():
|
for key, attribs in yaml_data[sec].items():
|
||||||
# The Image dataclass might need to open an image file with a relative path.
|
# The Image dataclass might need to open
|
||||||
|
# an image file with a relative path.
|
||||||
image = attribs.get("image")
|
image = attribs.get("image")
|
||||||
if isinstance(image, dict):
|
if isinstance(image, dict):
|
||||||
image_path = image["src"]
|
image_path = image["src"]
|
||||||
@ -164,12 +166,16 @@ def parse(
|
|||||||
autogenerated_designators[template] = (
|
autogenerated_designators[template] = (
|
||||||
autogenerated_designators.get(template, 0) + 1
|
autogenerated_designators.get(template, 0) + 1
|
||||||
)
|
)
|
||||||
designator = f"{AUTOGENERATED_PREFIX}{template}_{autogenerated_designators[template]}"
|
designator = (
|
||||||
|
f"{AUTOGENERATED_PREFIX}"
|
||||||
|
f"{template}_{autogenerated_designators[template]}"
|
||||||
|
)
|
||||||
# check if redefining existing component to different template
|
# check if redefining existing component to different template
|
||||||
if designator in designators_and_templates:
|
if designator in designators_and_templates:
|
||||||
if designators_and_templates[designator] != template:
|
if designators_and_templates[designator] != template:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Trying to redefine {designator} from {designators_and_templates[designator]} to {template}"
|
f"Trying to redefine {designator}"
|
||||||
|
f" from {designators_and_templates[designator]} to {template}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
designators_and_templates[designator] = template
|
designators_and_templates[designator] = template
|
||||||
@ -201,7 +207,6 @@ def parse(
|
|||||||
expected_type = alternating_types[1 - alternating_types.index(expected_type)]
|
expected_type = alternating_types[1 - alternating_types.index(expected_type)]
|
||||||
|
|
||||||
for connection_set in connection_sets:
|
for connection_set in connection_sets:
|
||||||
|
|
||||||
# figure out number of parallel connections within this set
|
# figure out number of parallel connections within this set
|
||||||
connectioncount = []
|
connectioncount = []
|
||||||
for entry in connection_set:
|
for entry in connection_set:
|
||||||
@ -282,7 +287,7 @@ def parse(
|
|||||||
# generate new connector instance from template
|
# generate new connector instance from template
|
||||||
check_type(designator, template, "connector")
|
check_type(designator, template, "connector")
|
||||||
harness.add_connector(
|
harness.add_connector(
|
||||||
name=designator, **template_connectors[template]
|
designator=designator, **template_connectors[template]
|
||||||
)
|
)
|
||||||
|
|
||||||
elif designator in harness.cables: # existing cable instance
|
elif designator in harness.cables: # existing cable instance
|
||||||
@ -290,7 +295,9 @@ def parse(
|
|||||||
elif template in template_cables.keys():
|
elif template in template_cables.keys():
|
||||||
# generate new cable instance from template
|
# generate new cable instance from template
|
||||||
check_type(designator, template, "cable/arrow")
|
check_type(designator, template, "cable/arrow")
|
||||||
harness.add_cable(name=designator, **template_cables[template])
|
harness.add_cable(
|
||||||
|
designator=designator, **template_cables[template]
|
||||||
|
)
|
||||||
|
|
||||||
elif is_arrow(designator):
|
elif is_arrow(designator):
|
||||||
check_type(designator, template, "cable/arrow")
|
check_type(designator, template, "cable/arrow")
|
||||||
@ -300,7 +307,8 @@ def parse(
|
|||||||
f"{template} is an unknown template/designator/arrow."
|
f"{template} is an unknown template/designator/arrow."
|
||||||
)
|
)
|
||||||
|
|
||||||
alternate_type() # entries in connection set must alternate between connectors and cables/arrows
|
# entries in connection set must alternate between connectors and cables/arrows
|
||||||
|
alternate_type()
|
||||||
|
|
||||||
# transpose connection set list
|
# transpose connection set list
|
||||||
# before: one item per component, one subitem per connection in set
|
# before: one item per component, one subitem per connection in set
|
||||||
@ -355,11 +363,13 @@ def parse(
|
|||||||
# mate two connectors as a whole
|
# mate two connectors as a whole
|
||||||
harness.add_mate_component(from_name, to_name, designator)
|
harness.add_mate_component(from_name, to_name, designator)
|
||||||
|
|
||||||
# harness population completed =============================================
|
|
||||||
|
|
||||||
if "additional_bom_items" in yaml_data:
|
if "additional_bom_items" in yaml_data:
|
||||||
for line in yaml_data["additional_bom_items"]:
|
for line in yaml_data["additional_bom_items"]:
|
||||||
harness.add_bom_item(line)
|
harness.add_additional_bom_item(line)
|
||||||
|
|
||||||
|
# harness population completed =============================================
|
||||||
|
|
||||||
|
harness.populate_bom()
|
||||||
|
|
||||||
if output_formats:
|
if output_formats:
|
||||||
harness.output(filename=output_file, fmt=output_formats, view=False)
|
harness.output(filename=output_file, fmt=output_formats, view=False)
|
||||||
@ -382,23 +392,15 @@ def parse(
|
|||||||
return tuple(returns) if len(returns) != 1 else returns[0]
|
return tuple(returns) if len(returns) != 1 else returns[0]
|
||||||
|
|
||||||
|
|
||||||
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
|
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> Tuple[Dict, Path]:
|
||||||
# determine whether inp is a file path, a YAML string, or a Dict
|
# determine whether inp is a file path, a YAML string, or a Dict
|
||||||
if not isinstance(inp, Dict): # received a str or a Path
|
if not isinstance(inp, Dict): # received a str or a Path
|
||||||
try:
|
if isinstance(inp, Path) or (isinstance(inp, str) and not "\n" in inp):
|
||||||
yaml_path = Path(inp).expanduser().resolve(strict=True)
|
yaml_path = Path(inp).expanduser().resolve(strict=True)
|
||||||
# if no FileNotFoundError exception happens, get file contents
|
|
||||||
yaml_str = open_file_read(yaml_path).read()
|
yaml_str = open_file_read(yaml_path).read()
|
||||||
except (FileNotFoundError, OSError) as e:
|
else:
|
||||||
# if inp is a long YAML string, Pathlib will raise OSError: [errno.ENAMETOOLONG]
|
|
||||||
# when trying to expand and resolve it as a path.
|
|
||||||
# Catch this error, but raise any others
|
|
||||||
from errno import ENAMETOOLONG
|
|
||||||
if type(e) is OSError and e.errno != ENAMETOOLONG:
|
|
||||||
raise e
|
|
||||||
# file does not exist; assume inp is a YAML string
|
|
||||||
yaml_str = inp
|
|
||||||
yaml_path = None
|
yaml_path = None
|
||||||
|
yaml_str = inp
|
||||||
yaml_data = yaml.safe_load(yaml_str)
|
yaml_data = yaml.safe_load(yaml_str)
|
||||||
else:
|
else:
|
||||||
# received a Dict, use as-is
|
# received a Dict, use as-is
|
||||||
|
|||||||
@ -1,266 +1,87 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from dataclasses import asdict
|
from collections import namedtuple
|
||||||
from itertools import groupby
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from enum import Enum, IntEnum
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from wireviz.DataClasses import AdditionalComponent, Cable, Color, Connector
|
import tabulate as tabulate_module
|
||||||
from wireviz.wv_colors import translate_color
|
|
||||||
from wireviz.wv_gv_html import html_bgcolor_attr, html_line_breaks
|
|
||||||
from wireviz.wv_helper import clean_whitespace
|
|
||||||
|
|
||||||
BOM_COLUMNS_ALWAYS = ("id", "description", "qty", "unit", "designators")
|
from wireviz.wv_utils import html_line_breaks
|
||||||
BOM_COLUMNS_OPTIONAL = ("pn", "manufacturer", "mpn", "supplier", "spn")
|
|
||||||
BOM_COLUMNS_IN_KEY = ("description", "unit") + BOM_COLUMNS_OPTIONAL
|
|
||||||
|
|
||||||
HEADER_PN = "P/N"
|
BOM_HASH_FIELDS = "description qty_unit amount partnumbers"
|
||||||
HEADER_MPN = "MPN"
|
|
||||||
HEADER_SPN = "SPN"
|
|
||||||
|
|
||||||
BOMKey = Tuple[str, ...]
|
|
||||||
BOMColumn = str # = Literal[*BOM_COLUMNS_ALWAYS, *BOM_COLUMNS_OPTIONAL]
|
|
||||||
BOMEntry = Dict[BOMColumn, Union[str, int, float, List[str], None]]
|
|
||||||
|
|
||||||
|
|
||||||
def optional_fields(part: Union[Connector, Cable, AdditionalComponent]) -> BOMEntry:
|
BomEntry = namedtuple("BomEntry", "category qty designators")
|
||||||
"""Return part field values for the optional BOM columns as a dict."""
|
BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)
|
||||||
part = asdict(part)
|
BomHashList = namedtuple("BomHashList", BOM_HASH_FIELDS)
|
||||||
return {field: part.get(field) for field in BOM_COLUMNS_OPTIONAL}
|
PartNumberInfo = namedtuple("PartNumberInfo", "pn manufacturer mpn supplier spn")
|
||||||
|
|
||||||
|
# TODO: different BOM modes
|
||||||
|
# BomMode
|
||||||
|
# "normal" # no bubbles, full PN info in GV node
|
||||||
|
# "bubbles" # = "full" -> maximum info in GV node
|
||||||
|
# "hide PN info"
|
||||||
|
# "PN crossref" = "PN bubbles" + "hide PN info"
|
||||||
|
# "additionally: BOM table in GV graph label (#227)"
|
||||||
|
# "title block in GV graph label"
|
||||||
|
|
||||||
|
|
||||||
def get_additional_component_table(
|
BomCategory = IntEnum( # to enforce ordering in BOM
|
||||||
harness: "Harness", component: Union[Connector, Cable]
|
"BomEntry", "CONNECTOR CABLE WIRE ADDITIONAL_INSIDE ADDITIONAL_OUTSIDE"
|
||||||
|
)
|
||||||
|
QtyMultiplierConnector = Enum(
|
||||||
|
"QtyMultiplierConnector", "PINCOUNT POPULATED CONNECTIONS"
|
||||||
|
)
|
||||||
|
QtyMultiplierCable = Enum(
|
||||||
|
"QtyMultiplierCable", "WIRECOUNT TERMINATION LENGTH TOTAL_LENGTH"
|
||||||
|
)
|
||||||
|
|
||||||
|
PART_NUMBER_HEADERS = PartNumberInfo(
|
||||||
|
pn="P/N", manufacturer=None, mpn="MPN", supplier=None, spn="SPN"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def partnumbers2list(
|
||||||
|
partnumbers: PartNumberInfo, parent_partnumbers: PartNumberInfo = None
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Return a list of diagram node table row strings with additional components."""
|
if parent_partnumbers is None:
|
||||||
rows = []
|
_is_toplevel = True
|
||||||
if component.additional_components:
|
parent_partnumbers = partnumbers
|
||||||
rows.append(["Additional components"])
|
else:
|
||||||
for part in component.additional_components:
|
_is_toplevel = False
|
||||||
common_args = {
|
|
||||||
"qty": part.qty * component.get_qty_multiplier(part.qty_multiplier),
|
|
||||||
"unit": part.unit,
|
|
||||||
"bgcolor": part.bgcolor,
|
|
||||||
}
|
|
||||||
if harness.options.mini_bom_mode:
|
|
||||||
id = get_bom_index(
|
|
||||||
harness.bom(),
|
|
||||||
bom_entry_key({**asdict(part), "description": part.description}),
|
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
component_table_entry(
|
|
||||||
f"#{id} ({part.type.rstrip()})", **common_args
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rows.append(
|
|
||||||
component_table_entry(
|
|
||||||
part.description, **common_args, **optional_fields(part)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
# Note: != operator used as XOR in the following section (https://stackoverflow.com/a/433161)
|
||||||
|
|
||||||
def get_additional_component_bom(component: Union[Connector, Cable]) -> List[BOMEntry]:
|
if _is_toplevel != isinstance(parent_partnumbers.pn, List):
|
||||||
"""Return a list of BOM entries with additional components."""
|
# top level and not a list, or wire level and list
|
||||||
bom_entries = []
|
cell_pn = pn_info_string(PART_NUMBER_HEADERS.pn, None, partnumbers.pn)
|
||||||
for part in component.additional_components:
|
else:
|
||||||
bom_entries.append(
|
# top level and list -> do per wire later
|
||||||
{
|
# wire level and not list -> already done at top level
|
||||||
"description": part.description,
|
cell_pn = None
|
||||||
"qty": part.qty * component.get_qty_multiplier(part.qty_multiplier),
|
|
||||||
"unit": part.unit,
|
if _is_toplevel != isinstance(parent_partnumbers.mpn, List):
|
||||||
"designators": component.name if component.show_name else None,
|
# TODO: edge case: different manufacturers, but same MPN?
|
||||||
**optional_fields(part),
|
cell_mpn = pn_info_string(
|
||||||
}
|
PART_NUMBER_HEADERS.mpn, partnumbers.manufacturer, partnumbers.mpn
|
||||||
)
|
)
|
||||||
return bom_entries
|
else:
|
||||||
|
cell_mpn = None
|
||||||
|
|
||||||
|
if _is_toplevel != isinstance(parent_partnumbers.spn, List):
|
||||||
def bom_entry_key(entry: BOMEntry) -> BOMKey:
|
# TODO: edge case: different suppliers, but same SPN?
|
||||||
"""Return a tuple of string values from the dict that must be equal to join BOM entries."""
|
cell_spn = pn_info_string(
|
||||||
if "key" not in entry:
|
PART_NUMBER_HEADERS.spn, partnumbers.supplier, partnumbers.spn
|
||||||
entry["key"] = tuple(
|
|
||||||
clean_whitespace(make_str(entry.get(c))) for c in BOM_COLUMNS_IN_KEY
|
|
||||||
)
|
)
|
||||||
return entry["key"]
|
else:
|
||||||
|
cell_spn = None
|
||||||
|
|
||||||
|
cell_contents = [cell_pn, cell_mpn, cell_spn]
|
||||||
def generate_bom(harness: "Harness") -> List[BOMEntry]:
|
if any(cell_contents):
|
||||||
"""Return a list of BOM entries generated from the harness."""
|
return [html_line_breaks(cell) for cell in cell_contents]
|
||||||
from wireviz.Harness import Harness # Local import to avoid circular imports
|
else:
|
||||||
|
return None
|
||||||
bom_entries = []
|
|
||||||
# connectors
|
|
||||||
for connector in harness.connectors.values():
|
|
||||||
if not connector.ignore_in_bom:
|
|
||||||
description = (
|
|
||||||
"Connector"
|
|
||||||
+ (f", {connector.type}" if connector.type else "")
|
|
||||||
+ (f", {connector.subtype}" if connector.subtype else "")
|
|
||||||
+ (f", {connector.pincount} pins" if connector.show_pincount else "")
|
|
||||||
+ (
|
|
||||||
f", {translate_color(connector.color, harness.options.color_mode)}"
|
|
||||||
if connector.color
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bom_entries.append(
|
|
||||||
{
|
|
||||||
"description": description,
|
|
||||||
"designators": connector.name if connector.show_name else None,
|
|
||||||
**optional_fields(connector),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# add connectors aditional components to bom
|
|
||||||
bom_entries.extend(get_additional_component_bom(connector))
|
|
||||||
|
|
||||||
# cables
|
|
||||||
# TODO: If category can have other non-empty values than 'bundle', maybe it should be part of description?
|
|
||||||
for cable in harness.cables.values():
|
|
||||||
if not cable.ignore_in_bom:
|
|
||||||
if cable.category != "bundle":
|
|
||||||
# process cable as a single entity
|
|
||||||
description = (
|
|
||||||
"Cable"
|
|
||||||
+ (f", {cable.type}" if cable.type else "")
|
|
||||||
+ (f", {cable.wirecount}")
|
|
||||||
+ (
|
|
||||||
f" x {cable.gauge} {cable.gauge_unit}"
|
|
||||||
if cable.gauge
|
|
||||||
else " wires"
|
|
||||||
)
|
|
||||||
+ (" shielded" if cable.shield else "")
|
|
||||||
+ (
|
|
||||||
f", {translate_color(cable.color, harness.options.color_mode)}"
|
|
||||||
if cable.color
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bom_entries.append(
|
|
||||||
{
|
|
||||||
"description": description,
|
|
||||||
"qty": cable.length,
|
|
||||||
"unit": cable.length_unit,
|
|
||||||
"designators": cable.name if cable.show_name else None,
|
|
||||||
**optional_fields(cable),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# add each wire from the bundle to the bom
|
|
||||||
for index, color in enumerate(cable.colors):
|
|
||||||
description = (
|
|
||||||
"Wire"
|
|
||||||
+ (f", {cable.type}" if cable.type else "")
|
|
||||||
+ (f", {cable.gauge} {cable.gauge_unit}" if cable.gauge else "")
|
|
||||||
+ (
|
|
||||||
f", {translate_color(color, harness.options.color_mode)}"
|
|
||||||
if color
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bom_entries.append(
|
|
||||||
{
|
|
||||||
"description": description,
|
|
||||||
"qty": cable.length,
|
|
||||||
"unit": cable.length_unit,
|
|
||||||
"designators": cable.name if cable.show_name else None,
|
|
||||||
**{
|
|
||||||
k: index_if_list(v, index)
|
|
||||||
for k, v in optional_fields(cable).items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# add cable/bundles aditional components to bom
|
|
||||||
bom_entries.extend(get_additional_component_bom(cable))
|
|
||||||
|
|
||||||
# add harness aditional components to bom directly, as they both are List[BOMEntry]
|
|
||||||
bom_entries.extend(harness.additional_bom_items)
|
|
||||||
|
|
||||||
# remove line breaks if present and cleanup any resulting whitespace issues
|
|
||||||
bom_entries = [
|
|
||||||
{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries
|
|
||||||
]
|
|
||||||
|
|
||||||
# deduplicate bom
|
|
||||||
bom = []
|
|
||||||
for _, group in groupby(sorted(bom_entries, key=bom_entry_key), key=bom_entry_key):
|
|
||||||
group_entries = list(group)
|
|
||||||
designators = sum(
|
|
||||||
(make_list(entry.get("designators")) for entry in group_entries), []
|
|
||||||
)
|
|
||||||
total_qty = sum(entry.get("qty", 1) for entry in group_entries)
|
|
||||||
bom.append(
|
|
||||||
{
|
|
||||||
**group_entries[0],
|
|
||||||
"qty": round(total_qty, 3),
|
|
||||||
"designators": sorted(set(designators)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# add an incrementing id to each bom entry
|
|
||||||
return [{**entry, "id": index} for index, entry in enumerate(bom, 1)]
|
|
||||||
|
|
||||||
|
|
||||||
def get_bom_index(bom: List[BOMEntry], target: BOMKey) -> int:
|
|
||||||
"""Return id of BOM entry or raise exception if not found."""
|
|
||||||
for entry in bom:
|
|
||||||
if bom_entry_key(entry) == target:
|
|
||||||
return entry["id"]
|
|
||||||
raise Exception("Internal error: No BOM entry found matching: " + "|".join(target))
|
|
||||||
|
|
||||||
|
|
||||||
def bom_list(bom: List[BOMEntry]) -> List[List[str]]:
|
|
||||||
"""Return list of BOM rows as lists of column strings with headings in top row."""
|
|
||||||
keys = list(BOM_COLUMNS_ALWAYS) # Always include this fixed set of BOM columns.
|
|
||||||
for fieldname in BOM_COLUMNS_OPTIONAL:
|
|
||||||
# Include only those optional BOM columns that are in use.
|
|
||||||
if any(entry.get(fieldname) for entry in bom):
|
|
||||||
keys.append(fieldname)
|
|
||||||
# Custom mapping from internal name to BOM column headers.
|
|
||||||
# Headers not specified here are generated by capitilising the internal name.
|
|
||||||
bom_headings = {
|
|
||||||
"pn": HEADER_PN,
|
|
||||||
"mpn": HEADER_MPN,
|
|
||||||
"spn": HEADER_SPN,
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
[bom_headings.get(k, k.capitalize()) for k in keys]
|
|
||||||
] + [ # Create header row with key names
|
|
||||||
[make_str(entry.get(k)) for k in keys] for entry in bom
|
|
||||||
] # Create string list for each entry row
|
|
||||||
|
|
||||||
|
|
||||||
def component_table_entry(
|
|
||||||
type: str,
|
|
||||||
qty: Union[int, float],
|
|
||||||
unit: Optional[str] = None,
|
|
||||||
bgcolor: Optional[Color] = None,
|
|
||||||
pn: Optional[str] = None,
|
|
||||||
manufacturer: Optional[str] = None,
|
|
||||||
mpn: Optional[str] = None,
|
|
||||||
supplier: Optional[str] = None,
|
|
||||||
spn: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Return a diagram node table row string with an additional component."""
|
|
||||||
part_number_list = [
|
|
||||||
pn_info_string(HEADER_PN, None, pn),
|
|
||||||
pn_info_string(HEADER_MPN, manufacturer, mpn),
|
|
||||||
pn_info_string(HEADER_SPN, supplier, spn),
|
|
||||||
]
|
|
||||||
output = (
|
|
||||||
f"{qty}"
|
|
||||||
+ (f" {unit}" if unit else "")
|
|
||||||
+ f" x {type}"
|
|
||||||
+ ("<br/>" if any(part_number_list) else "")
|
|
||||||
+ (", ".join([pn for pn in part_number_list if pn]))
|
|
||||||
)
|
|
||||||
# format the above output as left aligned text in a single visible cell
|
|
||||||
# indent is set to two to match the indent in the generated html table
|
|
||||||
return f"""<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{html_bgcolor_attr(bgcolor)}><tr>
|
|
||||||
<td align="left" balign="left">{html_line_breaks(output)}</td>
|
|
||||||
</tr></table>"""
|
|
||||||
|
|
||||||
|
|
||||||
def pn_info_string(
|
def pn_info_string(
|
||||||
@ -274,16 +95,51 @@ def pn_info_string(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def index_if_list(value: Any, index: int) -> Any:
|
def bom_list(bom):
|
||||||
"""Return the value indexed if it is a list, or simply the value otherwise."""
|
headers = (
|
||||||
return value[index] if isinstance(value, list) else value
|
"# Qty Unit Description Amount Unit Designators "
|
||||||
|
"P/N Manufacturer MPN Supplier SPN Category".split(" ")
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
rows.append(headers)
|
||||||
|
# fill rows
|
||||||
|
for hash, entry in bom.items():
|
||||||
|
cells = [
|
||||||
|
entry["id"],
|
||||||
|
entry["qty"],
|
||||||
|
hash.qty_unit,
|
||||||
|
hash.description,
|
||||||
|
hash.amount.number if hash.amount else None,
|
||||||
|
hash.amount.unit if hash.amount else None,
|
||||||
|
", ".join(sorted(entry["designators"])),
|
||||||
|
]
|
||||||
|
if hash.partnumbers:
|
||||||
|
cells.extend(
|
||||||
|
[
|
||||||
|
hash.partnumbers.pn,
|
||||||
|
hash.partnumbers.manufacturer,
|
||||||
|
hash.partnumbers.mpn,
|
||||||
|
hash.partnumbers.supplier,
|
||||||
|
hash.partnumbers.spn,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cells.extend([None, None, None, None, None])
|
||||||
|
# cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging
|
||||||
|
rows.append(cells)
|
||||||
|
# remove empty columns
|
||||||
|
transposed = list(map(list, zip(*rows)))
|
||||||
|
transposed = [
|
||||||
|
column
|
||||||
|
for column in transposed
|
||||||
|
if any([cell is not None for cell in column[1:]])
|
||||||
|
# ^ ignore header cell in check
|
||||||
|
]
|
||||||
|
rows = list(map(list, zip(*transposed)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def make_list(value: Any) -> list:
|
def print_bom_table(bom):
|
||||||
"""Return value if a list, empty list if None, or single element list otherwise."""
|
print()
|
||||||
return value if isinstance(value, list) else [] if value is None else [value]
|
print(tabulate_module.tabulate(bom_list(bom), headers="firstrow"))
|
||||||
|
print()
|
||||||
|
|
||||||
def make_str(value: Any) -> str:
|
|
||||||
"""Return comma separated elements if a list, empty string if None, or value as a string otherwise."""
|
|
||||||
return ", ".join(str(element) for element in make_list(value))
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import wireviz.wireviz as wv
|
import wireviz.wireviz as wv
|
||||||
from wireviz import APP_NAME, __version__
|
from wireviz import APP_NAME, __version__
|
||||||
from wireviz.wv_helper import open_file_read
|
from wireviz.wv_utils import open_file_read
|
||||||
|
|
||||||
format_codes = {
|
format_codes = {
|
||||||
"c": "csv",
|
"c": "csv",
|
||||||
@ -23,9 +23,12 @@ format_codes = {
|
|||||||
"t": "tsv",
|
"t": "tsv",
|
||||||
}
|
}
|
||||||
|
|
||||||
epilog = "The -f or --format option accepts a string containing one or more of the "
|
|
||||||
epilog += "following characters to specify which file types to output:\n"
|
epilog = (
|
||||||
epilog += ", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
|
"The -f or --format option accepts a string containing one or more of the "
|
||||||
|
"following characters to specify which file types to output:\n"
|
||||||
|
+ f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.command(epilog=epilog, no_args_is_help=True)
|
@click.command(epilog=epilog, no_args_is_help=True)
|
||||||
@ -58,7 +61,10 @@ epilog += ", ".join([f"{key} ({value.upper()})" for key, value in format_codes.i
|
|||||||
"--output-name",
|
"--output-name",
|
||||||
default=None,
|
default=None,
|
||||||
type=str,
|
type=str,
|
||||||
help="File name (without extension) to use for output files, if different from input file name.",
|
help=(
|
||||||
|
"File name (without extension) to use for output files, "
|
||||||
|
"if different from input file name."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-V",
|
"-V",
|
||||||
@ -71,7 +77,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
|
|||||||
"""
|
"""
|
||||||
Parses the provided FILE and generates the specified outputs.
|
Parses the provided FILE and generates the specified outputs.
|
||||||
"""
|
"""
|
||||||
print()
|
print() # blank line before execution
|
||||||
print(f"{APP_NAME} {__version__}")
|
print(f"{APP_NAME} {__version__}")
|
||||||
if version:
|
if version:
|
||||||
return # print version number only and exit
|
return # print version number only and exit
|
||||||
@ -105,6 +111,8 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
|
|||||||
prepend_file = Path(prepend_file)
|
prepend_file = Path(prepend_file)
|
||||||
if not prepend_file.exists():
|
if not prepend_file.exists():
|
||||||
raise Exception(f"File does not exist:\n{prepend_file}")
|
raise Exception(f"File does not exist:\n{prepend_file}")
|
||||||
|
if not prepend_file.is_file():
|
||||||
|
raise Exception(f"Path is not a file:\n{prepend_file}")
|
||||||
print("Prepend file:", prepend_file)
|
print("Prepend file:", prepend_file)
|
||||||
|
|
||||||
prepend_input += open_file_read(prepend_file).read() + "\n"
|
prepend_input += open_file_read(prepend_file).read() + "\n"
|
||||||
@ -116,6 +124,8 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
|
|||||||
file = Path(file)
|
file = Path(file)
|
||||||
if not file.exists():
|
if not file.exists():
|
||||||
raise Exception(f"File does not exist:\n{file}")
|
raise Exception(f"File does not exist:\n{file}")
|
||||||
|
if not file.is_file():
|
||||||
|
raise Exception(f"Path is not a file:\n{file}")
|
||||||
|
|
||||||
# file_out = file.with_suffix("") if not output_file else output_file
|
# file_out = file.with_suffix("") if not output_file else output_file
|
||||||
_output_dir = file.parent if not output_dir else output_dir
|
_output_dir = file.parent if not output_dir else output_dir
|
||||||
@ -142,7 +152,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
|
|||||||
image_paths=list(image_paths),
|
image_paths=list(image_paths),
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print() # blank line after execution
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,6 +1,198 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from typing import Dict, List
|
from collections import namedtuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
padding_amount = 1
|
||||||
|
|
||||||
|
ColorOutputMode = Enum(
|
||||||
|
"ColorOutputMode", "EN_LOWER EN_UPPER DE_LOWER DE_UPPER HTML_LOWER HTML_UPPER"
|
||||||
|
)
|
||||||
|
|
||||||
|
color_output_mode = ColorOutputMode.EN_UPPER
|
||||||
|
|
||||||
|
KnownColor = namedtuple("KnownColor", "html code_de full_en full_de")
|
||||||
|
|
||||||
|
known_colors = { # v--------v--------- for future use
|
||||||
|
"BK": KnownColor("#000000", "sw", "black", "schwarz"),
|
||||||
|
"WH": KnownColor("#ffffff", "ws", "white", "weiß"),
|
||||||
|
"GY": KnownColor("#999999", "gr", "grey", "grau"),
|
||||||
|
"PK": KnownColor("#ff66cc", "rs", "pink", "rosa"),
|
||||||
|
"RD": KnownColor("#ff0000", "rt", "red", "rot"),
|
||||||
|
"OG": KnownColor("#ff8000", "or", "orange", "orange"),
|
||||||
|
"YE": KnownColor("#ffff00", "ge", "yellow", "gelb"),
|
||||||
|
"OL": KnownColor("#708000", "ol", "olive green", "olivgrün"),
|
||||||
|
"GN": KnownColor("#00aa00", "gn", "green", "grün"),
|
||||||
|
"TQ": KnownColor("#00ffff", "tk", "turquoise", "türkis"),
|
||||||
|
"LB": KnownColor("#a0dfff", "hb", "light blue", "hellblau"),
|
||||||
|
"BU": KnownColor("#0066ff", "bl", "blue", "blau"),
|
||||||
|
"VT": KnownColor("#8000ff", "vi", "violet", "violett"),
|
||||||
|
"BN": KnownColor("#895956", "br", "brown", "braun"),
|
||||||
|
"BG": KnownColor("#ceb673", "bg", "beige", "beige"),
|
||||||
|
"IV": KnownColor("#f5f0d0", "eb", "ivory", "elfenbein"),
|
||||||
|
"SL": KnownColor("#708090", "si", "slate", "schiefer"),
|
||||||
|
"CU": KnownColor("#d6775e", "ku", "copper", "Kupfer"),
|
||||||
|
"SN": KnownColor("#aaaaaa", "vz", "tin", "verzinkt"),
|
||||||
|
"SR": KnownColor("#84878c", "ag", "silver", "Silber"),
|
||||||
|
"GD": KnownColor("#ffcf80", "au", "gold", "Gold"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_case(inp):
|
||||||
|
if "_LOWER" in color_output_mode.name:
|
||||||
|
return inp.lower()
|
||||||
|
elif "_UPPER" in color_output_mode.name:
|
||||||
|
return inp.upper()
|
||||||
|
else: # currently not used
|
||||||
|
return inp
|
||||||
|
|
||||||
|
|
||||||
|
def get_color_by_colorcode_index(color_code: str, index: int) -> str:
|
||||||
|
num_colors_in_code = len(COLOR_CODES[color_code])
|
||||||
|
actual_index = index % num_colors_in_code # wrap around if index is out of bounds
|
||||||
|
return COLOR_CODES[color_code][actual_index]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SingleColor:
|
||||||
|
_code_en: str
|
||||||
|
_html: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code_en(self):
|
||||||
|
return convert_case(self._code_en) if self._code_en else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code_de(self):
|
||||||
|
return (
|
||||||
|
convert_case(known_colors[self._code_en.upper()].code_de)
|
||||||
|
if self._code_en
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html(self):
|
||||||
|
return convert_case(self._html) if self._code_en else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def known(self):
|
||||||
|
# treat None as a known color
|
||||||
|
return self.code_en.upper() in known_colors.keys() if self._code_en else True
|
||||||
|
|
||||||
|
def __init__(self, inp):
|
||||||
|
if inp is None:
|
||||||
|
self._html = None
|
||||||
|
self._code_en = None
|
||||||
|
elif isinstance(inp, int):
|
||||||
|
hex_str = f"#{inp:06x}"
|
||||||
|
self._html = hex_str
|
||||||
|
self._code_en = hex_str # do not perform reverse lookup
|
||||||
|
elif inp.upper() in known_colors.keys():
|
||||||
|
inp_upper = inp.upper()
|
||||||
|
self._code_en = inp_upper
|
||||||
|
self._html = known_colors[inp_upper].html
|
||||||
|
else: # assume it's a valid HTML color name
|
||||||
|
self._html = inp
|
||||||
|
self._code_en = inp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html_padded(self):
|
||||||
|
return ":".join([self.html] * padding_amount)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return self._code_en is not None
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self._html is None:
|
||||||
|
return ""
|
||||||
|
elif self.known and "EN_" in color_output_mode.name:
|
||||||
|
return self.code_en
|
||||||
|
elif self.known and "DE_" in color_output_mode.name:
|
||||||
|
return self.code_de
|
||||||
|
else:
|
||||||
|
return self.html
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MultiColor:
|
||||||
|
colors: List[SingleColor] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __init__(self, inp):
|
||||||
|
self.colors = []
|
||||||
|
if inp is None:
|
||||||
|
pass
|
||||||
|
elif isinstance(inp, List): # input is already a list
|
||||||
|
for item in inp:
|
||||||
|
if item is None:
|
||||||
|
pass
|
||||||
|
elif isinstance(item, SingleColor):
|
||||||
|
self.colors.append(item)
|
||||||
|
else: # string
|
||||||
|
self.colors.append(SingleColor(item))
|
||||||
|
elif isinstance(inp, SingleColor): # single color
|
||||||
|
self.colors = [inp]
|
||||||
|
else: # split input into list
|
||||||
|
if ":" in str(inp):
|
||||||
|
self.colors = [SingleColor(item) for item in inp.split(":")]
|
||||||
|
else:
|
||||||
|
if isinstance(inp, int):
|
||||||
|
self.colors = [SingleColor(inp)]
|
||||||
|
elif len(inp) % 2 == 0:
|
||||||
|
items = [inp[i : i + 2] for i in range(0, len(inp), 2)]
|
||||||
|
known = [item.upper() in known_colors.keys() for item in items]
|
||||||
|
if all(known):
|
||||||
|
self.colors = [SingleColor(item) for item in items]
|
||||||
|
else: # assume it's a valud HTML color name
|
||||||
|
self.colors = [SingleColor(inp)]
|
||||||
|
else: # assume it's a valid HTML color name
|
||||||
|
self.colors = [SingleColor(inp)]
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.colors)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return len(self.colors) >= 1
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if "EN_" in color_output_mode.name or "DE_" in color_output_mode.name:
|
||||||
|
joiner = "" if self.all_known else ":"
|
||||||
|
elif "HTML_" in color_output_mode.name:
|
||||||
|
joiner = ":"
|
||||||
|
else:
|
||||||
|
joiner = "???"
|
||||||
|
return joiner.join([str(color) for color in self.colors])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_known(self):
|
||||||
|
return all([color.known for color in self.colors])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html(self):
|
||||||
|
return ":".join([color.html for color in self.colors])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html_padded_list(self):
|
||||||
|
# padding only properly works for padding_amount 1 or 3
|
||||||
|
if padding_amount == 1:
|
||||||
|
out = [color.html for color in self.colors]
|
||||||
|
elif len(self) == 0:
|
||||||
|
out = []
|
||||||
|
elif len(self) == 1:
|
||||||
|
out = [self.colors[0].html for i in range(3)]
|
||||||
|
elif len(self) == 2:
|
||||||
|
out = [self.colors[0].html, self.colors[1].html, self.colors[0].html]
|
||||||
|
elif len(self) == 3:
|
||||||
|
out = [color.html for color in self.colors]
|
||||||
|
else:
|
||||||
|
raise Exception(f"Padding not supported for len {len(self)}")
|
||||||
|
return [str(color) for color in out]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html_padded(self):
|
||||||
|
return ":".join(self.html_padded_list)
|
||||||
|
|
||||||
|
|
||||||
COLOR_CODES = {
|
COLOR_CODES = {
|
||||||
# fmt: off
|
# fmt: off
|
||||||
@ -38,164 +230,3 @@ COLOR_CODES = {
|
|||||||
"T568A": ["WHGN", "GN", "WHOG", "BU", "WHBU", "OG", "WHBN", "BN"],
|
"T568A": ["WHGN", "GN", "WHOG", "BU", "WHBU", "OG", "WHBN", "BN"],
|
||||||
"T568B": ["WHOG", "OG", "WHGN", "BU", "WHBU", "GN", "WHBN", "BN"],
|
"T568B": ["WHOG", "OG", "WHGN", "BU", "WHBU", "GN", "WHBN", "BN"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convention: Color names should be 2 letters long, to allow for multicolored wires
|
|
||||||
|
|
||||||
_color_hex = {
|
|
||||||
"BK": "#000000",
|
|
||||||
"WH": "#ffffff",
|
|
||||||
"GY": "#999999",
|
|
||||||
"PK": "#ff66cc",
|
|
||||||
"RD": "#ff0000",
|
|
||||||
"OG": "#ff8000",
|
|
||||||
"YE": "#ffff00",
|
|
||||||
"OL": "#708000", # olive green
|
|
||||||
"GN": "#00ff00",
|
|
||||||
"TQ": "#00ffff",
|
|
||||||
"LB": "#a0dfff", # light blue
|
|
||||||
"BU": "#0066ff",
|
|
||||||
"VT": "#8000ff",
|
|
||||||
"BN": "#895956",
|
|
||||||
"BG": "#ceb673", # beige
|
|
||||||
"IV": "#f5f0d0", # ivory
|
|
||||||
"SL": "#708090",
|
|
||||||
"CU": "#d6775e", # Faux-copper look, for bare CU wire
|
|
||||||
"SN": "#aaaaaa", # Silvery look for tinned bare wire
|
|
||||||
"SR": "#84878c", # Darker silver for silvered wire
|
|
||||||
"GD": "#ffcf80", # Golden color for gold
|
|
||||||
}
|
|
||||||
|
|
||||||
_color_full = {
|
|
||||||
"BK": "black",
|
|
||||||
"WH": "white",
|
|
||||||
"GY": "grey",
|
|
||||||
"PK": "pink",
|
|
||||||
"RD": "red",
|
|
||||||
"OG": "orange",
|
|
||||||
"YE": "yellow",
|
|
||||||
"OL": "olive green",
|
|
||||||
"GN": "green",
|
|
||||||
"TQ": "turquoise",
|
|
||||||
"LB": "light blue",
|
|
||||||
"BU": "blue",
|
|
||||||
"VT": "violet",
|
|
||||||
"BN": "brown",
|
|
||||||
"BG": "beige",
|
|
||||||
"IV": "ivory",
|
|
||||||
"SL": "slate",
|
|
||||||
"CU": "copper",
|
|
||||||
"SN": "tin",
|
|
||||||
"SR": "silver",
|
|
||||||
"GD": "gold",
|
|
||||||
}
|
|
||||||
|
|
||||||
_color_ger = {
|
|
||||||
"BK": "sw",
|
|
||||||
"WH": "ws",
|
|
||||||
"GY": "gr",
|
|
||||||
"PK": "rs",
|
|
||||||
"RD": "rt",
|
|
||||||
"OG": "or",
|
|
||||||
"YE": "ge",
|
|
||||||
"OL": "ol", # olivgrün
|
|
||||||
"GN": "gn",
|
|
||||||
"TQ": "tk",
|
|
||||||
"LB": "hb", # hellblau
|
|
||||||
"BU": "bl",
|
|
||||||
"VT": "vi",
|
|
||||||
"BN": "br",
|
|
||||||
"BG": "bg", # beige
|
|
||||||
"IV": "eb", # elfenbeinfarben
|
|
||||||
"SL": "si", # Schiefer
|
|
||||||
"CU": "ku", # Kupfer
|
|
||||||
"SN": "vz", # verzinkt
|
|
||||||
"SR": "ag", # Silber
|
|
||||||
"GD": "au", # Gold
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
color_default = "#ffffff"
|
|
||||||
|
|
||||||
_hex_digits = set("0123456789abcdefABCDEF")
|
|
||||||
|
|
||||||
|
|
||||||
# Literal type aliases below are commented to avoid requiring python 3.8
|
|
||||||
Color = str # Two-letter color name = Literal[_color_hex.keys()]
|
|
||||||
Colors = str # One or more two-letter color names (Color) concatenated into one string
|
|
||||||
ColorMode = (
|
|
||||||
str # = Literal['full', 'FULL', 'hex', 'HEX', 'short', 'SHORT', 'ger', 'GER']
|
|
||||||
)
|
|
||||||
ColorScheme = str # Color scheme name = Literal[COLOR_CODES.keys()]
|
|
||||||
|
|
||||||
|
|
||||||
def get_color_hex(input: Colors, pad: bool = False) -> List[str]:
|
|
||||||
"""Return list of hex colors from either a string of color names or :-separated hex colors."""
|
|
||||||
if input is None or input == "":
|
|
||||||
return [color_default]
|
|
||||||
elif input[0] == "#": # Hex color(s)
|
|
||||||
output = input.split(":")
|
|
||||||
for i, c in enumerate(output):
|
|
||||||
if c[0] != "#" or not all(d in _hex_digits for d in c[1:]):
|
|
||||||
if c != input:
|
|
||||||
c += f" in input: {input}"
|
|
||||||
print(f"Invalid hex color: {c}")
|
|
||||||
output[i] = color_default
|
|
||||||
else: # Color name(s)
|
|
||||||
|
|
||||||
def lookup(c: str) -> str:
|
|
||||||
try:
|
|
||||||
return _color_hex[c]
|
|
||||||
except KeyError:
|
|
||||||
if c != input:
|
|
||||||
c += f" in input: {input}"
|
|
||||||
print(f"Unknown color name: {c}")
|
|
||||||
return color_default
|
|
||||||
|
|
||||||
output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)]
|
|
||||||
|
|
||||||
if len(output) == 2: # Give wires with EXACTLY 2 colors that striped look.
|
|
||||||
output += output[:1]
|
|
||||||
elif pad and len(output) == 1: # Hacky style fix: Give single color wires
|
|
||||||
output *= 3 # a triple-up so that wires are the same size.
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def get_color_translation(translate: Dict[Color, str], input: Colors) -> List[str]:
|
|
||||||
"""Return list of colors translations from either a string of color names or :-separated hex colors."""
|
|
||||||
|
|
||||||
def from_hex(hex_input: str) -> str:
|
|
||||||
for color, hex in _color_hex.items():
|
|
||||||
if hex == hex_input:
|
|
||||||
return translate[color]
|
|
||||||
return f'({",".join(str(int(hex_input[i:i+2], 16)) for i in range(1, 6, 2))})'
|
|
||||||
|
|
||||||
return (
|
|
||||||
[from_hex(h) for h in input.lower().split(":")]
|
|
||||||
if input[0] == "#"
|
|
||||||
else [translate.get(input[i : i + 2], "??") for i in range(0, len(input), 2)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def translate_color(input: Colors, color_mode: ColorMode) -> str:
|
|
||||||
if input == "" or input is None:
|
|
||||||
return ""
|
|
||||||
upper = color_mode.isupper()
|
|
||||||
if not (color_mode.isupper() or color_mode.islower()):
|
|
||||||
raise Exception("Unknown color mode capitalization")
|
|
||||||
|
|
||||||
color_mode = color_mode.lower()
|
|
||||||
if color_mode == "full":
|
|
||||||
output = "/".join(get_color_translation(_color_full, input))
|
|
||||||
elif color_mode == "hex":
|
|
||||||
output = ":".join(get_color_hex(input, pad=False))
|
|
||||||
elif color_mode == "ger":
|
|
||||||
output = "".join(get_color_translation(_color_ger, input))
|
|
||||||
elif color_mode == "short":
|
|
||||||
output = input
|
|
||||||
else:
|
|
||||||
raise Exception("Unknown color mode")
|
|
||||||
if upper:
|
|
||||||
return output.upper()
|
|
||||||
else:
|
|
||||||
return output.lower()
|
|
||||||
|
|||||||
815
src/wireviz/wv_dataclasses.py
Normal file
@ -0,0 +1,815 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from itertools import zip_longest
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from wireviz.wv_bom import (
|
||||||
|
BomHash,
|
||||||
|
BomHashList,
|
||||||
|
PartNumberInfo,
|
||||||
|
QtyMultiplierCable,
|
||||||
|
QtyMultiplierConnector,
|
||||||
|
)
|
||||||
|
from wireviz.wv_colors import (
|
||||||
|
COLOR_CODES,
|
||||||
|
ColorOutputMode,
|
||||||
|
MultiColor,
|
||||||
|
SingleColor,
|
||||||
|
get_color_by_colorcode_index,
|
||||||
|
)
|
||||||
|
from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links
|
||||||
|
|
||||||
|
# Each type alias have their legal values described in comments
|
||||||
|
# - validation might be implemented in the future
|
||||||
|
PlainText = str # Text not containing HTML tags nor newlines
|
||||||
|
Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output
|
||||||
|
MultilineHypertext = (
|
||||||
|
str # Hypertext possibly also including newlines to break lines in diagram output
|
||||||
|
)
|
||||||
|
|
||||||
|
Designator = PlainText # Case insensitive unique name of connector or cable
|
||||||
|
|
||||||
|
# Literal type aliases below are commented to avoid requiring python 3.8
|
||||||
|
ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both']
|
||||||
|
|
||||||
|
# Type combinations
|
||||||
|
Pin = Union[int, PlainText] # Pin identifier
|
||||||
|
PinIndex = int # Zero-based pin index
|
||||||
|
Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield
|
||||||
|
NoneOrMorePins = Union[
|
||||||
|
Pin, Tuple[Pin, ...], None
|
||||||
|
] # None, one, or a tuple of pin identifiers
|
||||||
|
NoneOrMorePinIndices = Union[
|
||||||
|
PinIndex, Tuple[PinIndex, ...], None
|
||||||
|
] # None, one, or a tuple of zero-based pin indices
|
||||||
|
OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires
|
||||||
|
|
||||||
|
# Metadata can contain whatever is needed by the HTML generation/template.
|
||||||
|
MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...]
|
||||||
|
|
||||||
|
|
||||||
|
Side = Enum("Side", "LEFT RIGHT")
|
||||||
|
ArrowDirection = Enum("ArrowDirection", "NONE BACK FORWARD BOTH")
|
||||||
|
ArrowWeight = Enum("ArrowWeight", "SINGLE DOUBLE")
|
||||||
|
NumberAndUnit = namedtuple("NumberAndUnit", "number unit")
|
||||||
|
|
||||||
|
AUTOGENERATED_PREFIX = "AUTOGENERATED_"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Arrow:
|
||||||
|
direction: ArrowDirection
|
||||||
|
weight: ArrowWeight
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata(dict):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Options:
|
||||||
|
fontname: PlainText = "arial"
|
||||||
|
bgcolor: SingleColor = "WH" # will be converted to SingleColor in __post_init__
|
||||||
|
bgcolor_node: SingleColor = "WH"
|
||||||
|
bgcolor_connector: SingleColor = None
|
||||||
|
bgcolor_cable: SingleColor = None
|
||||||
|
bgcolor_bundle: SingleColor = None
|
||||||
|
color_output_mode: ColorOutputMode = ColorOutputMode.EN_UPPER
|
||||||
|
mini_bom_mode: bool = True
|
||||||
|
template_separator: str = "."
|
||||||
|
_pad: int = 0
|
||||||
|
# TODO: resolve template and image paths during rendering, not during YAML parsing
|
||||||
|
_template_paths: List = field(default_factory=list)
|
||||||
|
_image_paths: List = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.bgcolor = SingleColor(self.bgcolor)
|
||||||
|
self.bgcolor_node = SingleColor(self.bgcolor_node)
|
||||||
|
self.bgcolor_connector = SingleColor(self.bgcolor_connector)
|
||||||
|
self.bgcolor_cable = SingleColor(self.bgcolor_cable)
|
||||||
|
self.bgcolor_bundle = SingleColor(self.bgcolor_bundle)
|
||||||
|
|
||||||
|
if not self.bgcolor_node:
|
||||||
|
self.bgcolor_node = self.bgcolor
|
||||||
|
if not self.bgcolor_connector:
|
||||||
|
self.bgcolor_connector = self.bgcolor_node
|
||||||
|
if not self.bgcolor_cable:
|
||||||
|
self.bgcolor_cable = self.bgcolor_node
|
||||||
|
if not self.bgcolor_bundle:
|
||||||
|
self.bgcolor_bundle = self.bgcolor_cable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tweak:
|
||||||
|
override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None
|
||||||
|
append: Union[str, List[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Image:
|
||||||
|
# Attributes of the image object <img>:
|
||||||
|
src: str
|
||||||
|
scale: Optional[ImageScale] = None
|
||||||
|
# Attributes of the image cell <td> containing the image:
|
||||||
|
width: Optional[int] = None
|
||||||
|
height: Optional[int] = None
|
||||||
|
fixedsize: Optional[bool] = None
|
||||||
|
bgcolor: SingleColor = None
|
||||||
|
# Contents of the text cell <td> just below the image cell:
|
||||||
|
caption: Optional[MultilineHypertext] = None
|
||||||
|
# See also HTML doc at https://graphviz.org/doc/info/shapes.html#html
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.bgcolor = SingleColor(self.bgcolor)
|
||||||
|
|
||||||
|
if self.fixedsize is None:
|
||||||
|
# Default True if any dimension specified unless self.scale also is specified.
|
||||||
|
self.fixedsize = (self.width or self.height) and self.scale is None
|
||||||
|
|
||||||
|
if self.scale is None:
|
||||||
|
if not self.width and not self.height:
|
||||||
|
self.scale = "false"
|
||||||
|
elif self.width and self.height:
|
||||||
|
self.scale = "both"
|
||||||
|
else:
|
||||||
|
self.scale = "true" # When only one dimension is specified.
|
||||||
|
|
||||||
|
if self.fixedsize:
|
||||||
|
# If only one dimension is specified, compute the other
|
||||||
|
# because Graphviz requires both when fixedsize=True.
|
||||||
|
if self.height:
|
||||||
|
if not self.width:
|
||||||
|
self.width = self.height * aspect_ratio(self.src)
|
||||||
|
else:
|
||||||
|
if self.width:
|
||||||
|
self.height = self.width / aspect_ratio(self.src)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PinClass:
|
||||||
|
index: int
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
color: MultiColor
|
||||||
|
parent: str # designator of parent connector
|
||||||
|
_num_connections = 0 # incremented in Connector.connect()
|
||||||
|
_anonymous: bool = False # true for pins on autogenerated connectors
|
||||||
|
_simple: bool = False # true for simple connector
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
snippets = [ # use str() for each in case they are int or other non-str
|
||||||
|
str(self.parent) if not self._anonymous else "",
|
||||||
|
str(self.id) if not self._anonymous and not self._simple else "",
|
||||||
|
str(self.label) if self.label else "",
|
||||||
|
]
|
||||||
|
return ":".join([snip for snip in snippets if snip != ""])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Component:
|
||||||
|
category: Optional[str] = None # currently only used by cables, to define bundles
|
||||||
|
type: Union[MultilineHypertext, List[MultilineHypertext]] = None
|
||||||
|
subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None
|
||||||
|
|
||||||
|
# part number
|
||||||
|
partnumbers: PartNumberInfo = None # filled by fill_partnumbers()
|
||||||
|
# the following are provided for user convenience and should not be accessed later.
|
||||||
|
# their contents are loaded into partnumbers during the child class __post_init__()
|
||||||
|
pn: str = None
|
||||||
|
manufacturer: str = None
|
||||||
|
mpn: str = None
|
||||||
|
supplier: str = None
|
||||||
|
spn: str = None
|
||||||
|
# BOM info
|
||||||
|
qty: NumberAndUnit = NumberAndUnit(1, None)
|
||||||
|
amount: Optional[NumberAndUnit] = None
|
||||||
|
sum_amounts_in_bom: bool = True
|
||||||
|
ignore_in_bom: bool = False
|
||||||
|
bom_id: Optional[str] = None # to be filled after harness is built
|
||||||
|
|
||||||
|
def fill_partnumbers(self):
|
||||||
|
partnos = [self.pn, self.manufacturer, self.mpn, self.supplier, self.spn]
|
||||||
|
partnos = [remove_links(entry) for entry in partnos]
|
||||||
|
partnos = tuple(partnos)
|
||||||
|
self.partnumbers = PartNumberInfo(*partnos)
|
||||||
|
|
||||||
|
def parse_number_and_unit(
|
||||||
|
self,
|
||||||
|
inp: Optional[Union[NumberAndUnit, float, int, str]],
|
||||||
|
default_unit: Optional[str] = None,
|
||||||
|
) -> Optional[NumberAndUnit]:
|
||||||
|
if inp is None:
|
||||||
|
return None
|
||||||
|
elif isinstance(inp, NumberAndUnit):
|
||||||
|
return inp
|
||||||
|
elif isinstance(inp, float) or isinstance(inp, int):
|
||||||
|
return NumberAndUnit(float(inp), default_unit)
|
||||||
|
elif isinstance(inp, str):
|
||||||
|
if " " in inp:
|
||||||
|
number, unit = inp.split(" ", 1)
|
||||||
|
else:
|
||||||
|
number, unit = inp, default_unit
|
||||||
|
try:
|
||||||
|
number = float(number)
|
||||||
|
except ValueError:
|
||||||
|
raise Exception(
|
||||||
|
f"{inp} is not a valid number and unit.\n"
|
||||||
|
"It must be a number, or a number and unit separated by a space."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return NumberAndUnit(number, unit)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_hash(self) -> BomHash:
|
||||||
|
if self.sum_amounts_in_bom:
|
||||||
|
_hash = BomHash(
|
||||||
|
description=self.description,
|
||||||
|
qty_unit=self.amount.unit if self.amount else None,
|
||||||
|
amount=None,
|
||||||
|
partnumbers=self.partnumbers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_hash = BomHash(
|
||||||
|
description=self.description,
|
||||||
|
qty_unit=self.qty.unit,
|
||||||
|
amount=self.amount,
|
||||||
|
partnumbers=self.partnumbers,
|
||||||
|
)
|
||||||
|
return _hash
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_qty(self) -> float:
|
||||||
|
if self.sum_amounts_in_bom:
|
||||||
|
if self.amount:
|
||||||
|
return self.qty.number * self.amount.number
|
||||||
|
else:
|
||||||
|
return self.qty.number
|
||||||
|
else:
|
||||||
|
return self.qty.number
|
||||||
|
|
||||||
|
def bom_amount(self) -> NumberAndUnit:
|
||||||
|
if self.sum_amounts_in_bom:
|
||||||
|
return NumberAndUnit(None, None)
|
||||||
|
else:
|
||||||
|
return self.amount
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pn_info(self) -> bool:
|
||||||
|
return any([self.pn, self.manufacturer, self.mpn, self.supplier, self.spn])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdditionalComponent(Component):
|
||||||
|
qty_multiplier: Union[QtyMultiplierConnector, QtyMultiplierCable, int] = 1
|
||||||
|
_qty_multiplier_computed: Union[int, float] = 1
|
||||||
|
designators: Optional[str] = None # used for components definedi in the
|
||||||
|
# additional_bom_items section within another component
|
||||||
|
bgcolor: SingleColor = None # ^ same here
|
||||||
|
note: str = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
super().fill_partnumbers()
|
||||||
|
self.bgcolor = SingleColor(self.bgcolor)
|
||||||
|
self.qty = self.parse_number_and_unit(self.qty, None)
|
||||||
|
self.amount = self.parse_number_and_unit(self.amount, None)
|
||||||
|
|
||||||
|
if isinstance(self.qty_multiplier, float) or isinstance(
|
||||||
|
self.qty_multiplier, int
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.qty_multiplier = self.qty_multiplier.upper()
|
||||||
|
if self.qty_multiplier in QtyMultiplierConnector.__members__.keys():
|
||||||
|
self.qty_multiplier = QtyMultiplierConnector[self.qty_multiplier]
|
||||||
|
elif self.qty_multiplier in QtyMultiplierCable.__members__.keys():
|
||||||
|
self.qty_multiplier = QtyMultiplierCable[self.qty_multiplier]
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown qty multiplier: {self.qty_multiplier}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_components(self):
|
||||||
|
# an additional component may not have further nested additional comonents
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_qty(self):
|
||||||
|
return self.qty.number * self._qty_multiplier_computed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return f"{self.type}{', ' + self.subtype if self.subtype else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphicalComponent(Component): # abstract class, for future use
|
||||||
|
bgcolor: Optional[SingleColor] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TopLevelGraphicalComponent(GraphicalComponent): # abstract class
|
||||||
|
# component properties
|
||||||
|
designator: Designator = None
|
||||||
|
color: Optional[MultiColor] = None
|
||||||
|
image: Optional[Image] = None
|
||||||
|
additional_parameters: Optional[Dict] = None
|
||||||
|
additional_components: List[AdditionalComponent] = field(default_factory=list)
|
||||||
|
notes: Optional[MultilineHypertext] = None
|
||||||
|
# BOM options
|
||||||
|
add_up_in_bom: Optional[bool] = None
|
||||||
|
# rendering options
|
||||||
|
bgcolor_title: Optional[SingleColor] = None
|
||||||
|
show_name: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Connector(TopLevelGraphicalComponent):
|
||||||
|
# connector-specific properties
|
||||||
|
style: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
loops: List[List[Pin]] = field(default_factory=list)
|
||||||
|
# pin information in particular
|
||||||
|
pincount: Optional[int] = None
|
||||||
|
pins: List[Pin] = field(default_factory=list) # legacy
|
||||||
|
pinlabels: List[Pin] = field(default_factory=list) # legacy
|
||||||
|
pincolors: List[str] = field(default_factory=list) # legacy
|
||||||
|
pin_objects: Dict[Any, PinClass] = field(default_factory=dict) # new
|
||||||
|
# rendering option
|
||||||
|
show_pincount: Optional[bool] = None
|
||||||
|
hide_disconnected_pins: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_autogenerated(self):
|
||||||
|
return self.designator.startswith(AUTOGENERATED_PREFIX)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
substrs = [
|
||||||
|
"Connector",
|
||||||
|
self.type,
|
||||||
|
self.subtype,
|
||||||
|
f"{self.pincount} pins" if self.show_pincount else None,
|
||||||
|
str(self.color) if self.color else None,
|
||||||
|
]
|
||||||
|
return ", ".join([str(s) for s in substrs if s is not None and s != ""])
|
||||||
|
|
||||||
|
def should_show_pin(self, pin_id):
|
||||||
|
return (
|
||||||
|
not self.hide_disconnected_pins
|
||||||
|
or self.pin_objects[pin_id]._num_connections > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit(self): # for compatibility with BOM hashing
|
||||||
|
return None # connectors do not support units.
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().fill_partnumbers()
|
||||||
|
|
||||||
|
self.bgcolor = SingleColor(self.bgcolor)
|
||||||
|
self.bgcolor_title = SingleColor(self.bgcolor_title)
|
||||||
|
self.color = MultiColor(self.color)
|
||||||
|
|
||||||
|
# connectors do not support custom qty or amount
|
||||||
|
self.qty = NumberAndUnit(1, None)
|
||||||
|
self.amount = None
|
||||||
|
|
||||||
|
if isinstance(self.image, dict):
|
||||||
|
self.image = Image(**self.image)
|
||||||
|
|
||||||
|
self.ports_left = False
|
||||||
|
self.ports_right = False
|
||||||
|
self.visible_pins = {}
|
||||||
|
|
||||||
|
if self.style == "simple":
|
||||||
|
if self.pincount and self.pincount > 1:
|
||||||
|
raise Exception(
|
||||||
|
"Connectors with style set to simple may only have one pin"
|
||||||
|
)
|
||||||
|
self.pincount = 1
|
||||||
|
|
||||||
|
if not self.pincount:
|
||||||
|
self.pincount = max(
|
||||||
|
len(self.pins), len(self.pinlabels), len(self.pincolors)
|
||||||
|
)
|
||||||
|
if not self.pincount:
|
||||||
|
raise Exception(
|
||||||
|
"You need to specify at least one: "
|
||||||
|
"pincount, pins, pinlabels, or pincolors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# create default list for pins (sequential) if not specified
|
||||||
|
if not self.pins:
|
||||||
|
self.pins = list(range(1, self.pincount + 1))
|
||||||
|
|
||||||
|
if len(self.pins) != len(set(self.pins)):
|
||||||
|
raise Exception("Pins are not unique")
|
||||||
|
|
||||||
|
# all checks have passed
|
||||||
|
pin_tuples = zip_longest(
|
||||||
|
self.pins,
|
||||||
|
self.pinlabels,
|
||||||
|
self.pincolors,
|
||||||
|
)
|
||||||
|
for pin_index, (pin_id, pin_label, pin_color) in enumerate(pin_tuples):
|
||||||
|
self.pin_objects[pin_id] = PinClass(
|
||||||
|
index=pin_index,
|
||||||
|
id=pin_id,
|
||||||
|
label=pin_label,
|
||||||
|
color=MultiColor(pin_color),
|
||||||
|
parent=self.designator,
|
||||||
|
_anonymous=self.is_autogenerated,
|
||||||
|
_simple=self.style == "simple",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.show_name is None:
|
||||||
|
self.show_name = self.style != "simple" and not self.is_autogenerated
|
||||||
|
|
||||||
|
if self.show_pincount is None:
|
||||||
|
# hide pincount for simple (1 pin) connectors by default
|
||||||
|
self.show_pincount = self.style != "simple"
|
||||||
|
|
||||||
|
for loop in self.loops:
|
||||||
|
# TODO: allow using pin labels in addition to pin numbers,
|
||||||
|
# just like when defining regular connections
|
||||||
|
# TODO: include properties of wire used to create the loop
|
||||||
|
if len(loop) != 2:
|
||||||
|
raise Exception("Loops must be between exactly two pins!")
|
||||||
|
for pin in loop:
|
||||||
|
if pin not in self.pins:
|
||||||
|
raise Exception(f'Unknown loop pin "{pin}" for connector "{self.name}"!')
|
||||||
|
# Make sure loop connected pins are not hidden.
|
||||||
|
# side=None, determine side to show loops during rendering
|
||||||
|
self.activate_pin(pin, side=None, is_connection=True)
|
||||||
|
|
||||||
|
for i, item in enumerate(self.additional_components):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
self.additional_components[i] = AdditionalComponent(**item)
|
||||||
|
|
||||||
|
def activate_pin(self, pin_id, side: Side = None, is_connection=True) -> None:
|
||||||
|
if is_connection:
|
||||||
|
self.pin_objects[pin_id]._num_connections += 1
|
||||||
|
if side == Side.LEFT:
|
||||||
|
self.ports_left = True
|
||||||
|
elif side == Side.RIGHT:
|
||||||
|
self.ports_right = True
|
||||||
|
|
||||||
|
def compute_qty_multipliers(self):
|
||||||
|
# do not run before all connections in harness have been made!
|
||||||
|
num_populated_pins = len(
|
||||||
|
[pin for pin in self.pin_objects.values() if pin._num_connections > 0]
|
||||||
|
)
|
||||||
|
num_connections = sum(
|
||||||
|
[pin._num_connections for pin in self.pin_objects.values()]
|
||||||
|
)
|
||||||
|
qty_multipliers_computed = {
|
||||||
|
"PINCOUNT": self.pincount,
|
||||||
|
"POPULATED": num_populated_pins,
|
||||||
|
"CONNECTIONS": num_connections,
|
||||||
|
}
|
||||||
|
for subitem in self.additional_components:
|
||||||
|
if isinstance(subitem.qty_multiplier, QtyMultiplierConnector):
|
||||||
|
computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name]
|
||||||
|
elif isinstance(subitem.qty_multiplier, QtyMultiplierCable):
|
||||||
|
raise Exception("Used a cable multiplier in a connector!")
|
||||||
|
else: # int or float
|
||||||
|
computed_factor = subitem.qty_multiplier
|
||||||
|
subitem._qty_multiplier_computed = computed_factor
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WireClass:
|
||||||
|
parent: str # designator of parent cable/bundle
|
||||||
|
# wire-specific properties
|
||||||
|
index: int
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
color: MultiColor
|
||||||
|
# ...
|
||||||
|
bom_id: Optional[str] = None # to be filled after harness is built
|
||||||
|
# inheritable from parent cable
|
||||||
|
type: Union[MultilineHypertext, List[MultilineHypertext]] = None
|
||||||
|
subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None
|
||||||
|
gauge: Optional[NumberAndUnit] = None
|
||||||
|
length: Optional[NumberAndUnit] = None
|
||||||
|
ignore_in_bom: Optional[bool] = False
|
||||||
|
sum_amounts_in_bom: bool = True
|
||||||
|
partnumbers: PartNumberInfo = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_hash(self) -> BomHash:
|
||||||
|
if self.sum_amounts_in_bom:
|
||||||
|
_hash = BomHash(
|
||||||
|
description=self.description,
|
||||||
|
qty_unit=self.length.unit if self.length else None,
|
||||||
|
amount=None,
|
||||||
|
partnumbers=self.partnumbers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_hash = BomHash(
|
||||||
|
description=self.description,
|
||||||
|
qty_unit=None,
|
||||||
|
amount=self.length,
|
||||||
|
partnumbers=self.partnumbers,
|
||||||
|
)
|
||||||
|
return _hash
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gauge_str(self):
|
||||||
|
if not self.gauge:
|
||||||
|
return None
|
||||||
|
actual_gauge = f"{self.gauge.number} {self.gauge.unit}"
|
||||||
|
actual_gauge = actual_gauge.replace("mm2", "mm\u00B2")
|
||||||
|
return actual_gauge
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
substrs = [
|
||||||
|
"Wire",
|
||||||
|
self.type,
|
||||||
|
self.subtype,
|
||||||
|
self.gauge_str,
|
||||||
|
str(self.color) if self.color else None,
|
||||||
|
]
|
||||||
|
desc = ", ".join([s for s in substrs if s is not None and s != ""])
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShieldClass(WireClass):
|
||||||
|
pass # TODO, for wires with multiple shields more shield details, ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Connection:
|
||||||
|
from_: PinClass = None
|
||||||
|
via: Union[WireClass, ShieldClass] = None
|
||||||
|
to: PinClass = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cable(TopLevelGraphicalComponent):
|
||||||
|
# cable-specific properties
|
||||||
|
gauge: Optional[NumberAndUnit] = None
|
||||||
|
length: Optional[NumberAndUnit] = None
|
||||||
|
color_code: Optional[str] = None
|
||||||
|
# wire information in particular
|
||||||
|
wirecount: Optional[int] = None
|
||||||
|
shield: Union[bool, MultiColor] = False
|
||||||
|
colors: List[str] = field(default_factory=list) # legacy
|
||||||
|
wirelabels: List[Wire] = field(default_factory=list) # legacy
|
||||||
|
wire_objects: Dict[Any, WireClass] = field(default_factory=dict) # new
|
||||||
|
# internal
|
||||||
|
_connections: List[Connection] = field(default_factory=list)
|
||||||
|
# rendering options
|
||||||
|
show_name: Optional[bool] = None
|
||||||
|
show_equiv: bool = False
|
||||||
|
show_wirecount: bool = True
|
||||||
|
show_wirenumbers: Optional[bool] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_autogenerated(self):
|
||||||
|
return self.designator.startswith(AUTOGENERATED_PREFIX)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit(self): # for compatibility with parent class
|
||||||
|
return self.length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gauge_str(self):
|
||||||
|
if not self.gauge:
|
||||||
|
return None
|
||||||
|
actual_gauge = f"{self.gauge.number} {self.gauge.unit}"
|
||||||
|
actual_gauge = actual_gauge.replace("mm2", "mm\u00B2")
|
||||||
|
return actual_gauge
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gauge_str_with_equiv(self):
|
||||||
|
if not self.gauge:
|
||||||
|
return None
|
||||||
|
actual_gauge = self.gauge_str
|
||||||
|
equivalent_gauge = ""
|
||||||
|
if self.show_equiv:
|
||||||
|
# convert unit if known
|
||||||
|
if self.gauge.unit == "mm2":
|
||||||
|
equivalent_gauge = f" ({awg_equiv(self.gauge.number)} AWG)"
|
||||||
|
elif self.gauge.unit.upper() == "AWG":
|
||||||
|
equivalent_gauge = f" ({mm2_equiv(self.gauge.number)} mm2)"
|
||||||
|
out = f"{actual_gauge}{equivalent_gauge}"
|
||||||
|
out = out.replace("mm2", "mm\u00B2")
|
||||||
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length_str(self):
|
||||||
|
if not self.length:
|
||||||
|
return None
|
||||||
|
out = f"{self.length.number} {self.length.unit}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bom_hash(self):
|
||||||
|
if self.category == "bundle":
|
||||||
|
raise Exception("Do this at the wire level!") # TODO
|
||||||
|
else:
|
||||||
|
return super().bom_hash
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
if self.category == "bundle":
|
||||||
|
raise Exception("Do this at the wire level!") # TODO
|
||||||
|
else:
|
||||||
|
substrs = [
|
||||||
|
("", "Cable"),
|
||||||
|
(", ", self.type),
|
||||||
|
(", ", self.subtype),
|
||||||
|
(", ", self.wirecount),
|
||||||
|
(" ", f"x {self.gauge_str}" if self.gauge else "wires"),
|
||||||
|
(" ", "shielded" if self.shield else None),
|
||||||
|
(", ", str(self.color) if self.color else None),
|
||||||
|
]
|
||||||
|
desc = "".join(
|
||||||
|
[f"{s[0]}{s[1]}" for s in substrs if s[1] is not None and s[1] != ""]
|
||||||
|
)
|
||||||
|
return desc
|
||||||
|
|
||||||
|
def _get_wire_partnumber(self, idx) -> PartNumberInfo:
|
||||||
|
def _get_correct_element(inp, idx):
|
||||||
|
return inp[idx] if isinstance(inp, List) else inp
|
||||||
|
|
||||||
|
# TODO: possibly make more robust/elegant
|
||||||
|
if self.category == "bundle":
|
||||||
|
return PartNumberInfo(
|
||||||
|
_get_correct_element(self.partnumbers.pn, idx),
|
||||||
|
_get_correct_element(self.partnumbers.manufacturer, idx),
|
||||||
|
_get_correct_element(self.partnumbers.mpn, idx),
|
||||||
|
_get_correct_element(self.partnumbers.supplier, idx),
|
||||||
|
_get_correct_element(self.partnumbers.spn, idx),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None # non-bundles do not support lists of part data
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().fill_partnumbers()
|
||||||
|
|
||||||
|
self.bgcolor = SingleColor(self.bgcolor)
|
||||||
|
self.bgcolor_title = SingleColor(self.bgcolor_title)
|
||||||
|
self.color = MultiColor(self.color)
|
||||||
|
|
||||||
|
if isinstance(self.image, dict):
|
||||||
|
self.image = Image(**self.image)
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# allow gauge, length, and other fields to be lists too (like part numbers),
|
||||||
|
# and assign them the same way to bundles.
|
||||||
|
|
||||||
|
self.gauge = self.parse_number_and_unit(self.gauge, "mm2")
|
||||||
|
self.length = self.parse_number_and_unit(self.length, "m")
|
||||||
|
self.amount = self.length # for BOM
|
||||||
|
|
||||||
|
if self.wirecount: # number of wires explicitly defined
|
||||||
|
if self.colors: # use custom color palette (partly or looped if needed)
|
||||||
|
self.colors = [
|
||||||
|
self.colors[i % len(self.colors)] for i in range(self.wirecount)
|
||||||
|
]
|
||||||
|
elif self.color_code:
|
||||||
|
# use standard color palette (partly or looped if needed)
|
||||||
|
if self.color_code not in COLOR_CODES:
|
||||||
|
raise Exception("Unknown color code")
|
||||||
|
self.colors = [
|
||||||
|
get_color_by_colorcode_index(self.color_code, i)
|
||||||
|
for i in range(self.wirecount)
|
||||||
|
]
|
||||||
|
else: # no colors defined, add dummy colors
|
||||||
|
self.colors = [""] * self.wirecount
|
||||||
|
|
||||||
|
else: # wirecount implicit in length of color list
|
||||||
|
if not self.colors:
|
||||||
|
raise Exception(
|
||||||
|
"Unknown number of wires. "
|
||||||
|
"Must specify wirecount or colors (implicit length)"
|
||||||
|
)
|
||||||
|
self.wirecount = len(self.colors)
|
||||||
|
|
||||||
|
if self.wirelabels:
|
||||||
|
if self.shield and "s" in self.wirelabels:
|
||||||
|
raise Exception(
|
||||||
|
'"s" may not be used as a wire label for a shielded cable.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# if lists of part numbers are provided,
|
||||||
|
# check this is a bundle and that it matches the wirecount.
|
||||||
|
for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]:
|
||||||
|
if isinstance(idfield, list):
|
||||||
|
if self.category == "bundle":
|
||||||
|
# check the length
|
||||||
|
if len(idfield) != self.wirecount:
|
||||||
|
raise Exception("lists of part data must match wirecount")
|
||||||
|
else:
|
||||||
|
raise Exception("lists of part data are only supported for bundles")
|
||||||
|
|
||||||
|
# all checks have passed
|
||||||
|
wire_tuples = zip_longest(
|
||||||
|
# TODO: self.wire_ids
|
||||||
|
self.colors,
|
||||||
|
self.wirelabels,
|
||||||
|
)
|
||||||
|
for wire_index, (wire_color, wire_label) in enumerate(wire_tuples):
|
||||||
|
id = wire_index + 1
|
||||||
|
self.wire_objects[id] = WireClass(
|
||||||
|
parent=self.designator,
|
||||||
|
# wire-specific properties
|
||||||
|
index=wire_index, # TODO: wire_id
|
||||||
|
id=id, # TODO: wire_id
|
||||||
|
label=wire_label,
|
||||||
|
color=MultiColor(wire_color),
|
||||||
|
# inheritable from parent cable
|
||||||
|
type=self.type,
|
||||||
|
subtype=self.subtype,
|
||||||
|
gauge=self.gauge,
|
||||||
|
length=self.length,
|
||||||
|
sum_amounts_in_bom=self.sum_amounts_in_bom,
|
||||||
|
ignore_in_bom=self.ignore_in_bom,
|
||||||
|
partnumbers=self._get_wire_partnumber(wire_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.shield:
|
||||||
|
index_offset = len(self.wire_objects)
|
||||||
|
# TODO: add support for multiple shields
|
||||||
|
id = "s"
|
||||||
|
self.wire_objects[id] = ShieldClass(
|
||||||
|
index=index_offset,
|
||||||
|
id=id,
|
||||||
|
label="Shield",
|
||||||
|
color=MultiColor(self.shield)
|
||||||
|
if isinstance(self.shield, str)
|
||||||
|
else MultiColor(None),
|
||||||
|
parent=self.designator,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.show_name is None:
|
||||||
|
self.show_name = not self.is_autogenerated
|
||||||
|
|
||||||
|
if self.show_wirenumbers is None:
|
||||||
|
# by default, show wire numbers for cables, hide for bundles
|
||||||
|
self.show_wirenumbers = self.category != "bundle"
|
||||||
|
|
||||||
|
for i, item in enumerate(self.additional_components):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
self.additional_components[i] = AdditionalComponent(**item)
|
||||||
|
|
||||||
|
def _connect(
|
||||||
|
self,
|
||||||
|
from_pin_obj: List[PinClass],
|
||||||
|
via_wire_id: str,
|
||||||
|
to_pin_obj: List[PinClass],
|
||||||
|
) -> None:
|
||||||
|
via_wire_obj = self.wire_objects[via_wire_id]
|
||||||
|
self._connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj))
|
||||||
|
|
||||||
|
def compute_qty_multipliers(self):
|
||||||
|
# do not run before all connections in harness have been made!
|
||||||
|
total_length = sum(
|
||||||
|
[
|
||||||
|
wire.length.number if wire.length else 0
|
||||||
|
for wire in self.wire_objects.values()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
qty_multipliers_computed = {
|
||||||
|
"WIRECOUNT": len(self.wire_objects),
|
||||||
|
"TERMINATIONS": 999, # TODO
|
||||||
|
"LENGTH": self.length.number if self.length else 0,
|
||||||
|
"TOTAL_LENGTH": total_length,
|
||||||
|
}
|
||||||
|
for subitem in self.additional_components:
|
||||||
|
if isinstance(subitem.qty_multiplier, QtyMultiplierCable):
|
||||||
|
computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name]
|
||||||
|
# inherit component's length unit if appropriate
|
||||||
|
if subitem.qty_multiplier.name in ["LENGTH", "TOTAL_LENGTH"]:
|
||||||
|
if subitem.qty.unit is not None:
|
||||||
|
raise Exception(
|
||||||
|
f"No unit may be specified when using"
|
||||||
|
f"{subitem.qty_multiplier} as a multiplier"
|
||||||
|
)
|
||||||
|
subitem.qty = NumberAndUnit(subitem.qty.number, self.length.unit)
|
||||||
|
|
||||||
|
elif isinstance(subitem.qty_multiplier, QtyMultiplierConnector):
|
||||||
|
raise Exception("Used a connector multiplier in a cable!")
|
||||||
|
else: # int or float
|
||||||
|
computed_factor = subitem.qty_multiplier
|
||||||
|
subitem._qty_multiplier_computed = computed_factor
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatePin:
|
||||||
|
from_: PinClass
|
||||||
|
to: PinClass
|
||||||
|
arrow: Arrow
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MateComponent:
|
||||||
|
from_: str # Designator
|
||||||
|
to: str # Designator
|
||||||
|
arrow: Arrow
|
||||||
618
src/wireviz/wv_graphviz.py
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import re
|
||||||
|
from itertools import zip_longest
|
||||||
|
from typing import Any, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from wireviz import APP_NAME, APP_URL, __version__
|
||||||
|
from wireviz.wv_bom import partnumbers2list
|
||||||
|
from wireviz.wv_colors import MultiColor
|
||||||
|
from wireviz.wv_dataclasses import (
|
||||||
|
ArrowDirection,
|
||||||
|
ArrowWeight,
|
||||||
|
Cable,
|
||||||
|
Component,
|
||||||
|
Connector,
|
||||||
|
MateComponent,
|
||||||
|
MatePin,
|
||||||
|
Options,
|
||||||
|
PartNumberInfo,
|
||||||
|
ShieldClass,
|
||||||
|
WireClass,
|
||||||
|
)
|
||||||
|
from wireviz.wv_html import Img, Table, Td, Tr
|
||||||
|
from wireviz.wv_utils import html_line_breaks, remove_links
|
||||||
|
|
||||||
|
|
||||||
|
def gv_node_component(component: Component) -> Table:
|
||||||
|
# If no wires connected (except maybe loop wires)?
|
||||||
|
if isinstance(component, Connector):
|
||||||
|
if not (component.ports_left or component.ports_right):
|
||||||
|
component.ports_left = True # Use left side pins by default
|
||||||
|
|
||||||
|
# generate all rows to be shown in the node
|
||||||
|
if component.show_name:
|
||||||
|
str_name = f"{remove_links(component.designator)}"
|
||||||
|
line_name = Td(str_name, bgcolor=component.bgcolor_title.html)
|
||||||
|
else:
|
||||||
|
line_name = None
|
||||||
|
|
||||||
|
line_pn = partnumbers2list(component.partnumbers)
|
||||||
|
|
||||||
|
is_simple_connector = (
|
||||||
|
isinstance(component, Connector) and component.style == "simple"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(component, Connector):
|
||||||
|
line_info = [
|
||||||
|
bom_bubble(component.bom_id),
|
||||||
|
html_line_breaks(component.type),
|
||||||
|
html_line_breaks(component.subtype),
|
||||||
|
f"{component.pincount}-pin" if component.show_pincount else None,
|
||||||
|
str(component.color) if component.color else None,
|
||||||
|
]
|
||||||
|
elif isinstance(component, Cable):
|
||||||
|
line_info = [
|
||||||
|
bom_bubble(component.bom_id) if component.category != "bundle" else None,
|
||||||
|
html_line_breaks(component.type),
|
||||||
|
f"{component.wirecount}x" if component.show_wirecount else None,
|
||||||
|
component.gauge_str_with_equiv,
|
||||||
|
"+ S" if component.shield else None,
|
||||||
|
component.length_str,
|
||||||
|
str(component.color) if component.color else None,
|
||||||
|
]
|
||||||
|
|
||||||
|
if component.additional_parameters:
|
||||||
|
line_additional_parameters = nested_table_dict(component.additional_parameters)
|
||||||
|
else:
|
||||||
|
line_additional_parameters = []
|
||||||
|
|
||||||
|
if component.color:
|
||||||
|
line_info.extend(colorbar_cells(component.color))
|
||||||
|
|
||||||
|
line_image, line_image_caption = image_and_caption_cells(component)
|
||||||
|
line_additional_component_table = gv_additional_component_table(component)
|
||||||
|
line_notes = [Td(html_line_breaks(component.notes), balign="left")]
|
||||||
|
|
||||||
|
if isinstance(component, Connector):
|
||||||
|
if component.style != "simple":
|
||||||
|
line_ports = gv_pin_table(component)
|
||||||
|
else:
|
||||||
|
line_ports = None
|
||||||
|
elif isinstance(component, Cable):
|
||||||
|
line_ports = gv_conductor_table(component)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
line_name,
|
||||||
|
line_pn,
|
||||||
|
line_info,
|
||||||
|
line_additional_parameters,
|
||||||
|
line_ports,
|
||||||
|
line_image,
|
||||||
|
line_image_caption,
|
||||||
|
line_additional_component_table,
|
||||||
|
line_notes,
|
||||||
|
]
|
||||||
|
|
||||||
|
tbl = nested_table(lines)
|
||||||
|
if is_simple_connector:
|
||||||
|
# Simple connectors have no pin table, and therefore, no ports to attach wires to.
|
||||||
|
# Manually assign left and right ports here if required.
|
||||||
|
# Use table itself for right port, and the first cell for left port.
|
||||||
|
# Even if the table only has one cell, two separate ports can still be assigned.
|
||||||
|
tbl.update_attribs(port="p1r")
|
||||||
|
first_cell_in_tbl = tbl.contents[0].contents
|
||||||
|
first_cell_in_tbl.update_attribs(port="p1l")
|
||||||
|
|
||||||
|
return tbl
|
||||||
|
|
||||||
|
|
||||||
|
def gv_additional_component_table(component):
|
||||||
|
if not component.additional_components:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for subitem in component.additional_components:
|
||||||
|
firstline = [
|
||||||
|
Td(bom_bubble(subitem.bom_id)),
|
||||||
|
Td(f"{subitem.bom_qty}", align="right"),
|
||||||
|
Td(f"{subitem.qty.unit if subitem.qty.unit else 'x'}", align="left"),
|
||||||
|
Td(f"{subitem.description}", align="left"),
|
||||||
|
Td(f"{subitem.note if subitem.note else ''}", align="left"),
|
||||||
|
]
|
||||||
|
rows.append(Tr(firstline))
|
||||||
|
|
||||||
|
if subitem.has_pn_info:
|
||||||
|
secondline = [
|
||||||
|
Td("", colspan=3),
|
||||||
|
Td(f"# TODO PN string", align="left"), # TODO
|
||||||
|
Td(""),
|
||||||
|
]
|
||||||
|
rows.append(Tr(secondline))
|
||||||
|
|
||||||
|
return Table(rows, border=1, cellborder=0, cellpadding=3, cellspacing=0)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_node_bgcolor(component, harness_options):
|
||||||
|
# assign component node bgcolor at the GraphViz node level
|
||||||
|
# instead of at the HTML table level for better rendering of node outline
|
||||||
|
if component.bgcolor:
|
||||||
|
return component.bgcolor.html
|
||||||
|
elif isinstance(component, Connector) and harness_options.bgcolor_connector:
|
||||||
|
return harness_options.bgcolor_connector.html
|
||||||
|
elif (
|
||||||
|
isinstance(component, Cable)
|
||||||
|
and component.category == "bundle"
|
||||||
|
and harness_options.bgcolor_bundle
|
||||||
|
):
|
||||||
|
return harness_options.bgcolor_bundle.html
|
||||||
|
elif isinstance(component, Cable) and harness_options.bgcolor_cable:
|
||||||
|
return harness_options.bgcolor_cable.html
|
||||||
|
|
||||||
|
|
||||||
|
def bom_bubble(id) -> Table:
|
||||||
|
if id is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# TODO: activate BOM bubbles
|
||||||
|
return None
|
||||||
|
# size and style of BOM bubble is optimized to be a rounded square,
|
||||||
|
# big enough to hold any two-digit ID without GraphViz warnings
|
||||||
|
text = id
|
||||||
|
# text = f'<FONT COLOR="#FFFFFF">{id}</FONT>'
|
||||||
|
return Table(
|
||||||
|
Tr(
|
||||||
|
Td(
|
||||||
|
text,
|
||||||
|
border=1,
|
||||||
|
cellpadding=0,
|
||||||
|
fixedsize="true",
|
||||||
|
style="rounded",
|
||||||
|
height=20,
|
||||||
|
width=20,
|
||||||
|
# bgcolor="#000000",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
border=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_list_of_cells(inp) -> List[Td]:
|
||||||
|
# inp may be List,
|
||||||
|
if isinstance(inp, List):
|
||||||
|
# ensure all list items are Td
|
||||||
|
list_out = [item if isinstance(item, Td) else Td(item) for item in inp]
|
||||||
|
return list_out
|
||||||
|
else:
|
||||||
|
if inp is None:
|
||||||
|
return []
|
||||||
|
if isinstance(inp, Td):
|
||||||
|
return [inp]
|
||||||
|
else:
|
||||||
|
return [Td(inp)]
|
||||||
|
|
||||||
|
|
||||||
|
def nested_table(lines: List[Td]) -> Table:
|
||||||
|
cell_lists = [make_list_of_cells(line) for line in lines]
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for lst in cell_lists:
|
||||||
|
if len(lst) == 0:
|
||||||
|
continue # no cells in list
|
||||||
|
cells = [item for item in lst if item.contents is not None]
|
||||||
|
if len(cells) == 0:
|
||||||
|
continue # no cells in list, or all cells are None
|
||||||
|
if (
|
||||||
|
len(cells) == 1
|
||||||
|
and isinstance(cells[0].contents, Table)
|
||||||
|
and not "!" in cells[0].contents.attribs.get("id", "")
|
||||||
|
):
|
||||||
|
# cell content is already a table, no need to re-wrap it;
|
||||||
|
# unless explicitly asked to by a "!" in the ID field
|
||||||
|
# as used by image_and_caption_cells()
|
||||||
|
inner_table = cells[0].contents
|
||||||
|
else:
|
||||||
|
# nest cell content inside a table
|
||||||
|
inner_table = Table(
|
||||||
|
Tr(cells), border=0, cellborder=1, cellpadding=3, cellspacing=0
|
||||||
|
)
|
||||||
|
rows.append(Tr(Td(inner_table)))
|
||||||
|
|
||||||
|
if len(rows) == 0: # create dummy row to avoid GraphViz errors due to empty <table>
|
||||||
|
inner_table = Table(
|
||||||
|
Tr(Td("")), border=0, cellborder=1, cellpadding=3, cellspacing=0
|
||||||
|
)
|
||||||
|
rows = [Tr(Td(inner_table))]
|
||||||
|
tbl = Table(rows, border=0, cellspacing=0, cellpadding=0)
|
||||||
|
return tbl
|
||||||
|
|
||||||
|
|
||||||
|
def nested_table_dict(d: dict) -> Table:
|
||||||
|
rows = []
|
||||||
|
for k, v in d.items():
|
||||||
|
rows.append(
|
||||||
|
Tr(
|
||||||
|
[
|
||||||
|
Td(k, align="left", balign="left", valign="top"),
|
||||||
|
Td(html_line_breaks(v), align="left", balign="left"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Table(rows, border=0, cellborder=1, cellpadding=3, cellspacing=0)
|
||||||
|
|
||||||
|
|
||||||
|
def gv_pin_table(component) -> Table:
|
||||||
|
pin_rows = []
|
||||||
|
for pin in component.pin_objects.values():
|
||||||
|
if component.should_show_pin(pin.id):
|
||||||
|
pin_rows.append(gv_pin_row(pin, component))
|
||||||
|
if len(pin_rows) == 0:
|
||||||
|
# TODO: write test for empty pin tables, and for unconnected connectors that hide disconnected pins
|
||||||
|
pass
|
||||||
|
tbl = Table(pin_rows, border=0, cellborder=1, cellpadding=3, cellspacing=0)
|
||||||
|
return tbl
|
||||||
|
|
||||||
|
|
||||||
|
def gv_pin_row(pin, connector) -> Tr:
|
||||||
|
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
|
||||||
|
has_pincolors = any([_pin.color for _pin in connector.pin_objects.values()])
|
||||||
|
cells = [
|
||||||
|
Td(pin.id, port=f"p{pin.index+1}l") if connector.ports_left else None,
|
||||||
|
Td(pin.label, delete_if_empty=True),
|
||||||
|
Td(str(pin.color) if pin.color else "", sides="TBL") if has_pincolors else None,
|
||||||
|
Td(color_minitable(pin.color), sides="TBR") if has_pincolors else None,
|
||||||
|
Td(pin.id, port=f"p{pin.index+1}r") if connector.ports_right else None,
|
||||||
|
]
|
||||||
|
return Tr(cells)
|
||||||
|
|
||||||
|
|
||||||
|
def gv_connector_loops(connector: Connector) -> List:
|
||||||
|
loop_edges = []
|
||||||
|
if connector.ports_left:
|
||||||
|
loop_side = "l"
|
||||||
|
loop_dir = "w"
|
||||||
|
elif connector.ports_right:
|
||||||
|
loop_side = "r"
|
||||||
|
loop_dir = "e"
|
||||||
|
else:
|
||||||
|
raise Exception("No side for loops")
|
||||||
|
for loop in connector.loops:
|
||||||
|
head = f"{connector.designator}:p{loop[0]}{loop_side}:{loop_dir}"
|
||||||
|
tail = f"{connector.designator}:p{loop[1]}{loop_side}:{loop_dir}"
|
||||||
|
loop_edges.append((head, tail))
|
||||||
|
return loop_edges
|
||||||
|
|
||||||
|
|
||||||
|
def gv_conductor_table(cable) -> Table:
|
||||||
|
rows = []
|
||||||
|
rows.append(Tr(Td(" "))) # spacer row on top
|
||||||
|
|
||||||
|
inserted_break_inbetween = False
|
||||||
|
for wire in cable.wire_objects.values():
|
||||||
|
# insert blank space between wires and shields
|
||||||
|
if isinstance(wire, ShieldClass) and not inserted_break_inbetween:
|
||||||
|
rows.append(Tr(Td(" "))) # spacer row between wires and shields
|
||||||
|
inserted_break_inbetween = True
|
||||||
|
|
||||||
|
# row above the wire
|
||||||
|
wireinfo = []
|
||||||
|
if cable.show_wirenumbers and not isinstance(wire, ShieldClass):
|
||||||
|
wireinfo.append(str(wire.id))
|
||||||
|
wireinfo.append(str(wire.color))
|
||||||
|
wireinfo.append(wire.label)
|
||||||
|
|
||||||
|
ins, outs = [], []
|
||||||
|
for conn in cable._connections:
|
||||||
|
if conn.via.id == wire.id:
|
||||||
|
if conn.from_ is not None:
|
||||||
|
ins.append(str(conn.from_))
|
||||||
|
if conn.to is not None:
|
||||||
|
outs.append(str(conn.to))
|
||||||
|
|
||||||
|
cells_above = [
|
||||||
|
Td(" " + ", ".join(ins), align="left"),
|
||||||
|
Td(" "), # increase cell spacing here
|
||||||
|
Td(bom_bubble(wire.bom_id)) if cable.category == "bundle" else None,
|
||||||
|
Td(":".join([wi for wi in wireinfo if wi is not None and wi != ""])),
|
||||||
|
Td(" "), # increase cell spacing here
|
||||||
|
Td(", ".join(outs) + " ", align="right"),
|
||||||
|
]
|
||||||
|
cells_above = [cell for cell in cells_above if cell is not None]
|
||||||
|
rows.append(Tr(cells_above))
|
||||||
|
|
||||||
|
# the wire itself
|
||||||
|
rows.append(Tr(gv_wire_cell(wire, len(cells_above))))
|
||||||
|
|
||||||
|
# row below the wire
|
||||||
|
if wire.partnumbers:
|
||||||
|
cells_below = partnumbers2list(
|
||||||
|
wire.partnumbers, parent_partnumbers=cable.partnumbers
|
||||||
|
)
|
||||||
|
if cells_below is not None and len(cells_below) > 0:
|
||||||
|
table_below = (
|
||||||
|
Table(
|
||||||
|
Tr([Td(cell) for cell in cells_below]),
|
||||||
|
border=0,
|
||||||
|
cellborder=0,
|
||||||
|
cellspacing=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rows.append(Tr(Td(table_below, colspan=len(cells_above))))
|
||||||
|
|
||||||
|
rows.append(Tr(Td(" "))) # spacer row on bottom
|
||||||
|
tbl = Table(rows, border=0, cellborder=0, cellspacing=0)
|
||||||
|
return tbl
|
||||||
|
|
||||||
|
|
||||||
|
def gv_wire_cell(wire: Union[WireClass, ShieldClass], colspan: int) -> Td:
|
||||||
|
if wire.color:
|
||||||
|
color_list = ["#000000"] + wire.color.html_padded_list + ["#000000"]
|
||||||
|
else:
|
||||||
|
color_list = ["#000000"]
|
||||||
|
|
||||||
|
wire_inner_rows = []
|
||||||
|
for j, bgcolor in enumerate(color_list[::-1]):
|
||||||
|
wire_inner_cell_attribs = {
|
||||||
|
"bgcolor": bgcolor if bgcolor != "" else "#000000",
|
||||||
|
"border": 0,
|
||||||
|
"cellpadding": 0,
|
||||||
|
"colspan": colspan,
|
||||||
|
"height": 2,
|
||||||
|
}
|
||||||
|
wire_inner_rows.append(Tr(Td("", **wire_inner_cell_attribs)))
|
||||||
|
wire_inner_table = Table(wire_inner_rows, border=0, cellborder=0, cellspacing=0)
|
||||||
|
wire_outer_cell_attribs = {
|
||||||
|
"border": 0,
|
||||||
|
"cellspacing": 0,
|
||||||
|
"cellpadding": 0,
|
||||||
|
"colspan": colspan,
|
||||||
|
"height": 2 * len(color_list),
|
||||||
|
"port": f"w{wire.index+1}",
|
||||||
|
}
|
||||||
|
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
|
||||||
|
wire_outer_cell = Td(wire_inner_table, **wire_outer_cell_attribs)
|
||||||
|
|
||||||
|
return wire_outer_cell
|
||||||
|
|
||||||
|
|
||||||
|
def gv_edge_wire(harness, cable, connection) -> Tuple[str, str, str, str, str]:
|
||||||
|
if connection.via.color:
|
||||||
|
# check if it's an actual wire and not a shield
|
||||||
|
color = f"#000000:{connection.via.color.html_padded}:#000000"
|
||||||
|
else: # it's a shield connection
|
||||||
|
color = "#000000"
|
||||||
|
|
||||||
|
if connection.from_ is not None: # connect to left
|
||||||
|
from_port_str = (
|
||||||
|
f":p{connection.from_.index+1}r"
|
||||||
|
if harness.connectors[connection.from_.parent].style != "simple"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
code_left_1 = f"{connection.from_.parent}{from_port_str}:e"
|
||||||
|
code_left_2 = f"{connection.via.parent}:w{connection.via.index+1}:w"
|
||||||
|
# ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers
|
||||||
|
else:
|
||||||
|
code_left_1, code_left_2 = None, None
|
||||||
|
|
||||||
|
if connection.to is not None: # connect to right
|
||||||
|
to_port_str = (
|
||||||
|
f":p{connection.to.index+1}l"
|
||||||
|
if harness.connectors[connection.to.parent].style != "simple"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
code_right_1 = f"{connection.via.parent}:w{connection.via.index+1}:e"
|
||||||
|
code_right_2 = f"{connection.to.parent}{to_port_str}:w"
|
||||||
|
else:
|
||||||
|
code_right_1, code_right_2 = None, None
|
||||||
|
|
||||||
|
return color, code_left_1, code_left_2, code_right_1, code_right_2
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arrow_str(inp: str) -> ArrowDirection:
|
||||||
|
if inp[0] == "<" and inp[-1] == ">":
|
||||||
|
return ArrowDirection.BOTH
|
||||||
|
elif inp[0] == "<":
|
||||||
|
return ArrowDirection.BACK
|
||||||
|
elif inp[-1] == ">":
|
||||||
|
return ArrowDirection.FORWARD
|
||||||
|
else:
|
||||||
|
return ArrowDirection.NONE
|
||||||
|
|
||||||
|
|
||||||
|
def gv_edge_mate(mate) -> Tuple[str, str, str, str]:
|
||||||
|
if mate.arrow.weight == ArrowWeight.SINGLE:
|
||||||
|
color = "#000000"
|
||||||
|
elif mate.arrow.weight == ArrowWeight.DOUBLE:
|
||||||
|
color = "#000000:#000000"
|
||||||
|
|
||||||
|
dir = mate.arrow.direction.name.lower()
|
||||||
|
|
||||||
|
if isinstance(mate, MatePin):
|
||||||
|
from_pin_index = mate.from_.index
|
||||||
|
from_port_str = f":p{from_pin_index+1}r"
|
||||||
|
from_designator = mate.from_.parent
|
||||||
|
to_pin_index = mate.to.index
|
||||||
|
to_port_str = f":p{to_pin_index+1}l"
|
||||||
|
to_designator = mate.to.parent
|
||||||
|
elif isinstance(mate, MateComponent):
|
||||||
|
from_designator = mate.from_
|
||||||
|
from_port_str = ""
|
||||||
|
to_designator = mate.to
|
||||||
|
to_port_str = ""
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown type of mate:\n{mate}")
|
||||||
|
|
||||||
|
code_from = f"{from_designator}{from_port_str}:e"
|
||||||
|
code_to = f"{to_designator}{to_port_str}:w"
|
||||||
|
|
||||||
|
return color, dir, code_from, code_to
|
||||||
|
|
||||||
|
|
||||||
|
def colorbar_cells(color, mini=False) -> List[Td]:
|
||||||
|
cells = []
|
||||||
|
mini = {"height": 8, "width": 8, "fixedsize": "true"} if mini else {}
|
||||||
|
for index, subcolor in enumerate(color.colors):
|
||||||
|
sides_l = "L" if index == 0 else ""
|
||||||
|
sides_r = "R" if index == len(color.colors) - 1 else ""
|
||||||
|
sides = "TB" + sides_l + sides_r
|
||||||
|
cells.append(Td("", bgcolor=subcolor.html, sides=sides, **mini))
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def color_minitable(color: Optional[MultiColor]) -> Union[Table, str]:
|
||||||
|
if color is None or len(color) == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
cells = colorbar_cells(color, mini=True)
|
||||||
|
|
||||||
|
return Table(
|
||||||
|
Tr(cells),
|
||||||
|
border=0,
|
||||||
|
cellborder=1,
|
||||||
|
cellspacing=0,
|
||||||
|
height=8,
|
||||||
|
width=8 * len(cells),
|
||||||
|
fixedsize="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def image_and_caption_cells(component: Component) -> Tuple[Td, Td]:
|
||||||
|
if not component.image:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
image_tag = Img(scale=component.image.scale, src=component.image.src)
|
||||||
|
image_cell_inner = Td(image_tag, flat=True)
|
||||||
|
if component.image.fixedsize:
|
||||||
|
# further nest the image in a table with width/height/fixedsize parameters,
|
||||||
|
# and place that table in a cell
|
||||||
|
image_cell_inner.update_attribs(**html_size_attr_dict(component.image))
|
||||||
|
image_cell = Td(
|
||||||
|
Table(Tr(image_cell_inner), border=0, cellborder=0, cellspacing=0, id="!")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_cell = image_cell_inner
|
||||||
|
|
||||||
|
image_cell.update_attribs(
|
||||||
|
balign="left",
|
||||||
|
bgcolor=component.image.bgcolor.html,
|
||||||
|
sides="TLR" if component.image.caption else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if component.image.caption:
|
||||||
|
caption_cell = Td(
|
||||||
|
f"{html_line_breaks(component.image.caption)}", balign="left", sides="BLR"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
caption_cell = None
|
||||||
|
return (image_cell, caption_cell)
|
||||||
|
|
||||||
|
|
||||||
|
def html_size_attr_dict(image):
|
||||||
|
# Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object
|
||||||
|
pass
|
||||||
|
|
||||||
|
attr_dict = {}
|
||||||
|
if image:
|
||||||
|
if image.width:
|
||||||
|
attr_dict["width"] = image.width
|
||||||
|
if image.height:
|
||||||
|
attr_dict["height"] = image.height
|
||||||
|
if image.fixedsize:
|
||||||
|
attr_dict["fixedsize"] = "true"
|
||||||
|
return attr_dict
|
||||||
|
|
||||||
|
|
||||||
|
def set_dot_basics(dot, options):
|
||||||
|
dot.body.append(f"// Graph generated by {APP_NAME} {__version__}\n")
|
||||||
|
dot.body.append(f"// {APP_URL}\n")
|
||||||
|
dot.attr(
|
||||||
|
"graph",
|
||||||
|
rankdir="LR",
|
||||||
|
ranksep="2",
|
||||||
|
bgcolor=options.bgcolor.html,
|
||||||
|
nodesep="0.33",
|
||||||
|
fontname=options.fontname,
|
||||||
|
)
|
||||||
|
dot.attr(
|
||||||
|
"node",
|
||||||
|
shape="none",
|
||||||
|
width="0",
|
||||||
|
height="0",
|
||||||
|
margin="0", # Actual size of the node is entirely determined by the label.
|
||||||
|
style="filled",
|
||||||
|
fillcolor=options.bgcolor_node.html,
|
||||||
|
fontname=options.fontname,
|
||||||
|
)
|
||||||
|
dot.attr("edge", style="bold", fontname=options.fontname)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_dot_tweaks(dot, tweak):
|
||||||
|
def typecheck(name: str, value: Any, expect: type) -> None:
|
||||||
|
if not isinstance(value, expect):
|
||||||
|
raise Exception(
|
||||||
|
f"Unexpected value type of {name}: "
|
||||||
|
f"Expected {expect}, got {type(value)}\n{value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO?: Differ between override attributes and HTML?
|
||||||
|
if tweak.override is not None:
|
||||||
|
typecheck("tweak.override", tweak.override, dict)
|
||||||
|
for k, d in tweak.override.items():
|
||||||
|
typecheck(f"tweak.override.{k} key", k, str)
|
||||||
|
typecheck(f"tweak.override.{k} value", d, dict)
|
||||||
|
for a, v in d.items():
|
||||||
|
typecheck(f"tweak.override.{k}.{a} key", a, str)
|
||||||
|
typecheck(f"tweak.override.{k}.{a} value", v, (str, type(None)))
|
||||||
|
|
||||||
|
# Override generated attributes of selected entries matching tweak.override.
|
||||||
|
for i, entry in enumerate(dot.body):
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
continue
|
||||||
|
# Find a possibly quoted keyword after leading TAB(s) and followed by [ ].
|
||||||
|
match = re.match(r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S)
|
||||||
|
keyword = match and match[2]
|
||||||
|
if not keyword in tweak.override.keys():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for attr, value in tweak.override[keyword].items():
|
||||||
|
if value is None:
|
||||||
|
entry, n_subs = re.subn(
|
||||||
|
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
|
||||||
|
)
|
||||||
|
if n_subs < 1:
|
||||||
|
print(
|
||||||
|
"Harness.create_graph() warning: "
|
||||||
|
f"{attr} not found in {keyword}!"
|
||||||
|
)
|
||||||
|
elif n_subs > 1:
|
||||||
|
print(
|
||||||
|
"Harness.create_graph() warning: "
|
||||||
|
f"{attr} removed {n_subs} times in {keyword}!"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(value) == 0 or " " in value:
|
||||||
|
value = value.replace('"', r"\"")
|
||||||
|
value = f'"{value}"'
|
||||||
|
entry, n_subs = re.subn(
|
||||||
|
f'{attr}=("[^"]*"|[^] ]*)', f"{attr}={value}", entry
|
||||||
|
)
|
||||||
|
if n_subs < 1:
|
||||||
|
# If attr not found, then append it
|
||||||
|
entry = re.sub(r"\]$", f" {attr}={value}]", entry)
|
||||||
|
elif n_subs > 1:
|
||||||
|
print(
|
||||||
|
"Harness.create_graph() warning: "
|
||||||
|
f"{attr} overridden {n_subs} times in {keyword}!"
|
||||||
|
)
|
||||||
|
|
||||||
|
dot.body[i] = entry
|
||||||
|
|
||||||
|
if tweak.append is not None:
|
||||||
|
if isinstance(tweak.append, list):
|
||||||
|
for i, element in enumerate(tweak.append, 1):
|
||||||
|
typecheck(f"tweak.append[{i}]", element, str)
|
||||||
|
dot.body.extend(tweak.append)
|
||||||
|
else:
|
||||||
|
typecheck("tweak.append", tweak.append, str)
|
||||||
|
dot.body.append(tweak.append)
|
||||||
@ -1,111 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import List, Optional, Union
|
|
||||||
|
|
||||||
from wireviz.DataClasses import Color
|
|
||||||
from wireviz.wv_colors import translate_color
|
|
||||||
from wireviz.wv_helper import remove_links
|
|
||||||
|
|
||||||
|
|
||||||
def nested_html_table(
|
|
||||||
rows: List[Union[str, List[Optional[str]], None]], table_attrs: str = ""
|
|
||||||
) -> str:
|
|
||||||
# input: list, each item may be scalar or list
|
|
||||||
# output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar
|
|
||||||
# purpose: create the appearance of one table, where cell widths are independent between rows
|
|
||||||
# attributes in any leading <tdX> inside a list are injected into to the preceeding <td> tag
|
|
||||||
html = []
|
|
||||||
html.append(
|
|
||||||
f'<table border="0" cellspacing="0" cellpadding="0"{table_attrs or ""}>'
|
|
||||||
)
|
|
||||||
|
|
||||||
num_rows = 0
|
|
||||||
for row in rows:
|
|
||||||
if isinstance(row, List):
|
|
||||||
if len(row) > 0 and any(row):
|
|
||||||
html.append(" <tr><td>")
|
|
||||||
# fmt: off
|
|
||||||
html.append(' <table border="0" cellspacing="0" cellpadding="3" cellborder="1"><tr>')
|
|
||||||
# fmt: on
|
|
||||||
for cell in row:
|
|
||||||
if cell is not None:
|
|
||||||
# Inject attributes to the preceeding <td> tag where needed
|
|
||||||
# fmt: off
|
|
||||||
html.append(f' <td balign="left">{cell}</td>'.replace("><tdX", ""))
|
|
||||||
# fmt: on
|
|
||||||
html.append(" </tr></table>")
|
|
||||||
html.append(" </td></tr>")
|
|
||||||
num_rows = num_rows + 1
|
|
||||||
elif row is not None:
|
|
||||||
html.append(" <tr><td>")
|
|
||||||
html.append(f" {row}")
|
|
||||||
html.append(" </td></tr>")
|
|
||||||
num_rows = num_rows + 1
|
|
||||||
if num_rows == 0: # empty table
|
|
||||||
# generate empty cell to avoid GraphViz errors
|
|
||||||
html.append("<tr><td></td></tr>")
|
|
||||||
html.append("</table>")
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def html_bgcolor_attr(color: Color) -> str:
|
|
||||||
"""Return attributes for bgcolor or '' if no color."""
|
|
||||||
return f' bgcolor="{translate_color(color, "HEX")}"' if color else ""
|
|
||||||
|
|
||||||
|
|
||||||
def html_bgcolor(color: Color, _extra_attr: str = "") -> str:
|
|
||||||
"""Return <td> attributes prefix for bgcolor or '' if no color."""
|
|
||||||
return f"<tdX{html_bgcolor_attr(color)}{_extra_attr}>" if color else ""
|
|
||||||
|
|
||||||
|
|
||||||
def html_colorbar(color: Color) -> str:
|
|
||||||
"""Return <tdX> attributes prefix for bgcolor and minimum width or None if no color."""
|
|
||||||
return html_bgcolor(color, ' width="4"') if color else None
|
|
||||||
|
|
||||||
|
|
||||||
def html_image(image):
|
|
||||||
from wireviz.DataClasses import Image
|
|
||||||
|
|
||||||
if not image:
|
|
||||||
return None
|
|
||||||
# The leading attributes belong to the preceeding tag. See where used below.
|
|
||||||
html = f'{html_size_attr(image)}><img scale="{image.scale}" src="{image.src}"/>'
|
|
||||||
if image.fixedsize:
|
|
||||||
# Close the preceeding tag and enclose the image cell in a table without
|
|
||||||
# borders to avoid narrow borders when the fixed width < the node width.
|
|
||||||
html = f""">
|
|
||||||
<table border="0" cellspacing="0" cellborder="0"><tr>
|
|
||||||
<td{html}</td>
|
|
||||||
</tr></table>
|
|
||||||
"""
|
|
||||||
return f"""<tdX{' sides="TLR"' if image.caption else ''}{html_bgcolor_attr(image.bgcolor)}{html}"""
|
|
||||||
|
|
||||||
|
|
||||||
def html_caption(image):
|
|
||||||
from wireviz.DataClasses import Image
|
|
||||||
|
|
||||||
return (
|
|
||||||
f'<tdX sides="BLR"{html_bgcolor_attr(image.bgcolor)}>{html_line_breaks(image.caption)}'
|
|
||||||
if image and image.caption
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def html_size_attr(image):
|
|
||||||
from wireviz.DataClasses import Image
|
|
||||||
|
|
||||||
# Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
(f' width="{image.width}"' if image.width else "")
|
|
||||||
+ (f' height="{image.height}"' if image.height else "")
|
|
||||||
+ (' fixedsize="true"' if image.fixedsize else "")
|
|
||||||
)
|
|
||||||
if image
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def html_line_breaks(inp):
|
|
||||||
return remove_links(inp).replace("\n", "<br />") if isinstance(inp, str) else inp
|
|
||||||
431
src/wireviz/wv_harness.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
from graphviz import Graph
|
||||||
|
|
||||||
|
import wireviz.wv_colors
|
||||||
|
from wireviz.wv_bom import BomCategory, BomEntry, bom_list, print_bom_table
|
||||||
|
from wireviz.wv_dataclasses import (
|
||||||
|
AUTOGENERATED_PREFIX,
|
||||||
|
AdditionalComponent,
|
||||||
|
Arrow,
|
||||||
|
ArrowWeight,
|
||||||
|
Cable,
|
||||||
|
Component,
|
||||||
|
Connector,
|
||||||
|
MateComponent,
|
||||||
|
MatePin,
|
||||||
|
Metadata,
|
||||||
|
Options,
|
||||||
|
Side,
|
||||||
|
TopLevelGraphicalComponent,
|
||||||
|
Tweak,
|
||||||
|
)
|
||||||
|
from wireviz.wv_graphviz import (
|
||||||
|
apply_dot_tweaks,
|
||||||
|
calculate_node_bgcolor,
|
||||||
|
gv_connector_loops,
|
||||||
|
gv_edge_mate,
|
||||||
|
gv_edge_wire,
|
||||||
|
gv_node_component,
|
||||||
|
parse_arrow_str,
|
||||||
|
set_dot_basics,
|
||||||
|
)
|
||||||
|
from wireviz.wv_output import (
|
||||||
|
embed_svg_images,
|
||||||
|
embed_svg_images_file,
|
||||||
|
generate_html_output,
|
||||||
|
)
|
||||||
|
from wireviz.wv_utils import bom2tsv, open_file_write
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Harness:
|
||||||
|
metadata: Metadata
|
||||||
|
options: Options
|
||||||
|
tweak: Tweak
|
||||||
|
additional_bom_items: List[AdditionalComponent] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.connectors = {}
|
||||||
|
self.cables = {}
|
||||||
|
self.mates = []
|
||||||
|
self.bom = defaultdict(dict)
|
||||||
|
self.additional_bom_items = []
|
||||||
|
|
||||||
|
def add_connector(self, designator: str, *args, **kwargs) -> None:
|
||||||
|
conn = Connector(designator=designator, *args, **kwargs)
|
||||||
|
self.connectors[designator] = conn
|
||||||
|
|
||||||
|
def add_cable(self, designator: str, *args, **kwargs) -> None:
|
||||||
|
cbl = Cable(designator=designator, *args, **kwargs)
|
||||||
|
self.cables[designator] = cbl
|
||||||
|
|
||||||
|
def add_additional_bom_item(self, item: dict) -> None:
|
||||||
|
new_item = AdditionalComponent(**item)
|
||||||
|
self.additional_bom_items.append(new_item)
|
||||||
|
|
||||||
|
def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_str) -> None:
|
||||||
|
from_con = self.connectors[from_name]
|
||||||
|
from_pin_obj = from_con.pin_objects[from_pin]
|
||||||
|
to_con = self.connectors[to_name]
|
||||||
|
to_pin_obj = to_con.pin_objects[to_pin]
|
||||||
|
arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE)
|
||||||
|
|
||||||
|
self.mates.append(MatePin(from_pin_obj, to_pin_obj, arrow))
|
||||||
|
self.connectors[from_name].activate_pin(
|
||||||
|
from_pin, Side.RIGHT, is_connection=False
|
||||||
|
)
|
||||||
|
self.connectors[to_name].activate_pin(to_pin, Side.LEFT, is_connection=False)
|
||||||
|
|
||||||
|
def add_mate_component(self, from_name, to_name, arrow_str) -> None:
|
||||||
|
arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE)
|
||||||
|
self.mates.append(MateComponent(from_name, to_name, arrow))
|
||||||
|
|
||||||
|
def populate_bom(self):
|
||||||
|
# helper lists
|
||||||
|
all_toplevel_items = (
|
||||||
|
list(self.connectors.values())
|
||||||
|
+ list(self.cables.values())
|
||||||
|
+ self.additional_bom_items
|
||||||
|
)
|
||||||
|
all_subitems = [
|
||||||
|
subitem
|
||||||
|
for item in all_toplevel_items
|
||||||
|
for subitem in item.additional_components
|
||||||
|
]
|
||||||
|
all_bom_relevant_items = (
|
||||||
|
list(self.connectors.values())
|
||||||
|
+ [cable for cable in self.cables.values() if cable.category != "bundle"]
|
||||||
|
+ [
|
||||||
|
wire
|
||||||
|
for cable in self.cables.values()
|
||||||
|
if cable.category == "bundle"
|
||||||
|
for wire in cable.wire_objects.values()
|
||||||
|
]
|
||||||
|
+ all_subitems
|
||||||
|
)
|
||||||
|
|
||||||
|
# add items to BOM
|
||||||
|
for item in all_toplevel_items:
|
||||||
|
self._add_to_internal_bom(item) # nested subitems are also handled
|
||||||
|
# sort BOM by category first, then alphabetically by description within category
|
||||||
|
self.bom = dict(
|
||||||
|
sorted(
|
||||||
|
self.bom.items(),
|
||||||
|
key=lambda x: (
|
||||||
|
x[1]["category"],
|
||||||
|
x[0].description,
|
||||||
|
), # x[0] = key, x[1] = value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# assign BOM IDs
|
||||||
|
for id, key in enumerate(self.bom.keys(), 1):
|
||||||
|
self.bom[key]["id"] = id
|
||||||
|
# set BOM IDs within components (for BOM bubbles)
|
||||||
|
for item in all_bom_relevant_items:
|
||||||
|
if item.ignore_in_bom:
|
||||||
|
continue
|
||||||
|
if not item.bom_hash in self.bom:
|
||||||
|
print(f"{item}'s hash' not found in BOM dict.")
|
||||||
|
continue
|
||||||
|
item.bom_id = self.bom[item.bom_hash]["id"]
|
||||||
|
|
||||||
|
# print_bom_table(self.bom) # for debugging
|
||||||
|
|
||||||
|
def _add_to_internal_bom(self, item: Component):
|
||||||
|
if item.ignore_in_bom:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _add(hash, qty, designator=None, category=None):
|
||||||
|
bom_entry = self.bom[hash]
|
||||||
|
# initialize missing fields
|
||||||
|
if not "qty" in bom_entry:
|
||||||
|
bom_entry["qty"] = 0
|
||||||
|
if not "designators" in bom_entry:
|
||||||
|
bom_entry["designators"] = set()
|
||||||
|
# update fields
|
||||||
|
bom_entry["qty"] += qty
|
||||||
|
if designator is None:
|
||||||
|
designator_list = []
|
||||||
|
elif isinstance(designator, list):
|
||||||
|
designator_list = designator
|
||||||
|
else:
|
||||||
|
designator_list = [designator]
|
||||||
|
for des in designator_list:
|
||||||
|
if des and not des.startswith(AUTOGENERATED_PREFIX):
|
||||||
|
bom_entry["designators"].add(des)
|
||||||
|
bom_entry["category"] = category
|
||||||
|
|
||||||
|
if isinstance(item, TopLevelGraphicalComponent):
|
||||||
|
if isinstance(item, Connector):
|
||||||
|
cat = BomCategory.CONNECTOR
|
||||||
|
elif isinstance(item, Cable):
|
||||||
|
if item.category == "bundle":
|
||||||
|
cat = BomCategory.WIRE
|
||||||
|
else:
|
||||||
|
cat = BomCategory.CABLE
|
||||||
|
else:
|
||||||
|
cat = ""
|
||||||
|
|
||||||
|
if item.category == "bundle":
|
||||||
|
for subitem in item.wire_objects.values():
|
||||||
|
_add(
|
||||||
|
hash=subitem.bom_hash,
|
||||||
|
qty=item.bom_qty, # should be 1
|
||||||
|
designator=item.designator, # inherit from parent item
|
||||||
|
category=cat,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_add(
|
||||||
|
hash=item.bom_hash,
|
||||||
|
qty=item.bom_qty,
|
||||||
|
designator=item.designator,
|
||||||
|
category=cat,
|
||||||
|
)
|
||||||
|
if item.additional_components:
|
||||||
|
if item.category == "bundle":
|
||||||
|
pass # TODO
|
||||||
|
item.compute_qty_multipliers()
|
||||||
|
for comp in item.additional_components:
|
||||||
|
if comp.ignore_in_bom:
|
||||||
|
continue
|
||||||
|
_add(
|
||||||
|
hash=comp.bom_hash,
|
||||||
|
designator=item.designator,
|
||||||
|
qty=comp.bom_qty,
|
||||||
|
category=BomCategory.ADDITIONAL_INSIDE,
|
||||||
|
)
|
||||||
|
elif isinstance(item, AdditionalComponent):
|
||||||
|
cat = BomCategory.ADDITIONAL_OUTSIDE
|
||||||
|
_add(
|
||||||
|
hash=item.bom_hash,
|
||||||
|
qty=item.bom_qty,
|
||||||
|
designator=None,
|
||||||
|
category=cat,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unknown type of item:\n{item}")
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
from_name: str,
|
||||||
|
from_pin: Union[int, str],
|
||||||
|
via_name: str,
|
||||||
|
via_wire: Union[int, str],
|
||||||
|
to_name: str,
|
||||||
|
to_pin: Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
# check from and to connectors
|
||||||
|
for name, pin in zip([from_name, to_name], [from_pin, to_pin]):
|
||||||
|
if name is not None and name in self.connectors:
|
||||||
|
connector = self.connectors[name]
|
||||||
|
# check if provided name is ambiguous
|
||||||
|
if pin in connector.pins and pin in connector.pinlabels:
|
||||||
|
if connector.pins.index(pin) != connector.pinlabels.index(pin):
|
||||||
|
raise Exception(
|
||||||
|
f"{name}:{pin} is defined both in pinlabels and pins, "
|
||||||
|
"for different pins."
|
||||||
|
)
|
||||||
|
# TODO: Maybe issue a warning if present in both lists
|
||||||
|
# but referencing the same pin?
|
||||||
|
if pin in connector.pinlabels:
|
||||||
|
if connector.pinlabels.count(pin) > 1:
|
||||||
|
raise Exception(f"{name}:{pin} is defined more than once.")
|
||||||
|
index = connector.pinlabels.index(pin)
|
||||||
|
pin = connector.pins[index] # map pin name to pin number
|
||||||
|
if name == from_name:
|
||||||
|
from_pin = pin
|
||||||
|
if name == to_name:
|
||||||
|
to_pin = pin
|
||||||
|
if not pin in connector.pins:
|
||||||
|
raise Exception(f"{name}:{pin} not found.")
|
||||||
|
|
||||||
|
# check via cable
|
||||||
|
if via_name in self.cables:
|
||||||
|
cable = self.cables[via_name]
|
||||||
|
# check if provided name is ambiguous
|
||||||
|
if via_wire in cable.colors and via_wire in cable.wirelabels:
|
||||||
|
if cable.colors.index(via_wire) != cable.wirelabels.index(via_wire):
|
||||||
|
raise Exception(
|
||||||
|
f"{via_name}:{via_wire} is defined both in colors and wirelabels, "
|
||||||
|
"for different wires."
|
||||||
|
)
|
||||||
|
# TODO: Maybe issue a warning if present in both lists
|
||||||
|
# but referencing the same wire?
|
||||||
|
if via_wire in cable.colors:
|
||||||
|
if cable.colors.count(via_wire) > 1:
|
||||||
|
raise Exception(
|
||||||
|
f"{via_name}:{via_wire} is used for more than one wire."
|
||||||
|
)
|
||||||
|
# list index starts at 0, wire IDs start at 1
|
||||||
|
via_wire = cable.colors.index(via_wire) + 1
|
||||||
|
elif via_wire in cable.wirelabels:
|
||||||
|
if cable.wirelabels.count(via_wire) > 1:
|
||||||
|
raise Exception(
|
||||||
|
f"{via_name}:{via_wire} is used for more than one wire."
|
||||||
|
)
|
||||||
|
via_wire = (
|
||||||
|
cable.wirelabels.index(via_wire) + 1
|
||||||
|
) # list index starts at 0, wire IDs start at 1
|
||||||
|
|
||||||
|
# perform the actual connection
|
||||||
|
if from_name is not None:
|
||||||
|
from_con = self.connectors[from_name]
|
||||||
|
from_pin_obj = from_con.pin_objects[from_pin]
|
||||||
|
else:
|
||||||
|
from_pin_obj = None
|
||||||
|
if to_name is not None:
|
||||||
|
to_con = self.connectors[to_name]
|
||||||
|
to_pin_obj = to_con.pin_objects[to_pin]
|
||||||
|
else:
|
||||||
|
to_pin_obj = None
|
||||||
|
|
||||||
|
self.cables[via_name]._connect(from_pin_obj, via_wire, to_pin_obj)
|
||||||
|
if from_name in self.connectors:
|
||||||
|
self.connectors[from_name].activate_pin(from_pin, Side.RIGHT)
|
||||||
|
if to_name in self.connectors:
|
||||||
|
self.connectors[to_name].activate_pin(to_pin, Side.LEFT)
|
||||||
|
|
||||||
|
def create_graph(self) -> Graph:
|
||||||
|
dot = Graph()
|
||||||
|
set_dot_basics(dot, self.options)
|
||||||
|
|
||||||
|
for connector in self.connectors.values():
|
||||||
|
# generate connector node
|
||||||
|
gv_html = gv_node_component(connector)
|
||||||
|
gv_html.update_attribs(
|
||||||
|
bgcolor=calculate_node_bgcolor(connector, self.options)
|
||||||
|
)
|
||||||
|
dot.node(
|
||||||
|
connector.designator,
|
||||||
|
label=f"<\n{gv_html}\n>",
|
||||||
|
shape="box",
|
||||||
|
style="filled",
|
||||||
|
)
|
||||||
|
# generate edges for connector loops
|
||||||
|
if len(connector.loops) > 0:
|
||||||
|
dot.attr("edge", color="#000000")
|
||||||
|
loops = gv_connector_loops(connector)
|
||||||
|
for head, tail in loops:
|
||||||
|
dot.edge(head, tail)
|
||||||
|
|
||||||
|
# determine if there are double- or triple-colored wires in the harness;
|
||||||
|
# if so, pad single-color wires to make all wires of equal thickness
|
||||||
|
wire_is_multicolor = [
|
||||||
|
len(wire.color) > 1
|
||||||
|
for cable in self.cables.values()
|
||||||
|
for wire in cable.wire_objects.values()
|
||||||
|
]
|
||||||
|
if any(wire_is_multicolor):
|
||||||
|
wireviz.wv_colors.padding_amount = 3
|
||||||
|
else:
|
||||||
|
wireviz.wv_colors.padding_amount = 1
|
||||||
|
|
||||||
|
for cable in self.cables.values():
|
||||||
|
# generate cable node
|
||||||
|
# TODO: PN info for bundles (per wire)
|
||||||
|
gv_html = gv_node_component(cable)
|
||||||
|
gv_html.update_attribs(bgcolor=calculate_node_bgcolor(cable, self.options))
|
||||||
|
style = "filled,dashed" if cable.category == "bundle" else "filled"
|
||||||
|
dot.node(
|
||||||
|
cable.designator,
|
||||||
|
label=f"<\n{gv_html}\n>",
|
||||||
|
shape="box",
|
||||||
|
style=style,
|
||||||
|
)
|
||||||
|
|
||||||
|
# generate wire edges between component nodes and cable nodes
|
||||||
|
for connection in cable._connections:
|
||||||
|
color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection)
|
||||||
|
dot.attr("edge", color=color)
|
||||||
|
if not (l1, l2) == (None, None):
|
||||||
|
dot.edge(l1, l2)
|
||||||
|
if not (r1, r2) == (None, None):
|
||||||
|
dot.edge(r1, r2)
|
||||||
|
|
||||||
|
for mate in self.mates:
|
||||||
|
color, dir, code_from, code_to = gv_edge_mate(mate)
|
||||||
|
|
||||||
|
dot.attr("edge", color=color, style="dashed", dir=dir)
|
||||||
|
dot.edge(code_from, code_to)
|
||||||
|
|
||||||
|
apply_dot_tweaks(dot, self.tweak)
|
||||||
|
|
||||||
|
return dot
|
||||||
|
|
||||||
|
# cache for the GraphViz Graph object
|
||||||
|
# do not access directly, use self.graph instead
|
||||||
|
_graph = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def graph(self):
|
||||||
|
if not self._graph: # no cached graph exists, generate one
|
||||||
|
self._graph = self.create_graph()
|
||||||
|
return self._graph # return cached graph
|
||||||
|
|
||||||
|
@property
|
||||||
|
def png(self):
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
graph = self.graph
|
||||||
|
data = BytesIO()
|
||||||
|
data.write(graph.pipe(format="png"))
|
||||||
|
data.seek(0)
|
||||||
|
return data.read()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def svg(self):
|
||||||
|
graph = self.graph
|
||||||
|
return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd())
|
||||||
|
|
||||||
|
def output(
|
||||||
|
self,
|
||||||
|
filename: Union[str, Path],
|
||||||
|
view: bool = False,
|
||||||
|
cleanup: bool = True,
|
||||||
|
fmt: tuple = ("html", "png", "svg", "tsv"),
|
||||||
|
) -> None:
|
||||||
|
# graphical output
|
||||||
|
graph = self.graph
|
||||||
|
for f in fmt:
|
||||||
|
if f in ("png", "svg", "html"):
|
||||||
|
if f == "html": # if HTML format is specified,
|
||||||
|
f = "svg" # generate SVG for embedding into HTML
|
||||||
|
# SVG file will be renamed/deleted later
|
||||||
|
_filename = f"{filename}.tmp" if f == "svg" else filename
|
||||||
|
# TODO: prevent rendering SVG twice when both SVG and HTML are specified
|
||||||
|
graph.format = f
|
||||||
|
graph.render(filename=_filename, view=view, cleanup=cleanup)
|
||||||
|
# embed images into SVG output
|
||||||
|
if "svg" in fmt or "html" in fmt:
|
||||||
|
embed_svg_images_file(f"{filename}.tmp.svg")
|
||||||
|
# GraphViz output
|
||||||
|
if "gv" in fmt:
|
||||||
|
graph.save(filename=f"{filename}.gv")
|
||||||
|
# BOM output
|
||||||
|
bomlist = bom_list(self.bom)
|
||||||
|
# bomlist = [[]]
|
||||||
|
if "tsv" in fmt:
|
||||||
|
tsv = bom2tsv(bomlist)
|
||||||
|
open_file_write(f"{filename}.tsv").write(tsv)
|
||||||
|
if "csv" in fmt:
|
||||||
|
# TODO: implement CSV output (preferrably using CSV library)
|
||||||
|
print("CSV output is not yet supported")
|
||||||
|
# HTML output
|
||||||
|
if "html" in fmt:
|
||||||
|
generate_html_output(filename, bomlist, self.metadata, self.options)
|
||||||
|
# PDF output
|
||||||
|
if "pdf" in fmt:
|
||||||
|
# TODO: implement PDF output
|
||||||
|
print("PDF output is not yet supported")
|
||||||
|
# delete SVG if not needed
|
||||||
|
if "html" in fmt and not "svg" in fmt:
|
||||||
|
# SVG file was just needed to generate HTML
|
||||||
|
Path(f"{filename}.tmp.svg").unlink()
|
||||||
|
elif "svg" in fmt:
|
||||||
|
Path(f"{filename}.tmp.svg").replace(f"{filename}.svg")
|
||||||
@ -1,119 +1,125 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import re
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Union
|
from typing import Dict
|
||||||
|
|
||||||
from wireviz import APP_NAME, APP_URL, __version__, wv_colors
|
indent_count = 1
|
||||||
from wireviz.DataClasses import Metadata, Options
|
|
||||||
from wireviz.wv_gv_html import html_line_breaks
|
|
||||||
from wireviz.wv_helper import (
|
|
||||||
flatten2d,
|
|
||||||
open_file_read,
|
|
||||||
open_file_write,
|
|
||||||
smart_file_resolve,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_html_output(
|
class Attribs(Dict):
|
||||||
filename: Union[str, Path],
|
def __repr__(self):
|
||||||
bom_list: List[List[str]],
|
if len(self) == 0:
|
||||||
metadata: Metadata,
|
return ""
|
||||||
options: Options,
|
|
||||||
):
|
|
||||||
|
|
||||||
# load HTML template
|
html = []
|
||||||
templatename = metadata.get("template", {}).get("name")
|
for k, v in self.items():
|
||||||
if templatename:
|
if v is not None:
|
||||||
# if relative path to template was provided, check directory of YAML file first, fall back to built-in template directory
|
html.append(f' {k}="{v}"')
|
||||||
templatefile = smart_file_resolve(
|
# else:
|
||||||
f"{templatename}.html",
|
# html.append(f" {k}")
|
||||||
[Path(filename).parent, Path(__file__).parent / "templates"],
|
return "".join(html)
|
||||||
)
|
|
||||||
else:
|
|
||||||
# fall back to built-in simple template if no template was provided
|
|
||||||
templatefile = Path(__file__).parent / "templates/simple.html"
|
|
||||||
|
|
||||||
html = open_file_read(templatefile).read()
|
|
||||||
|
|
||||||
# embed SVG diagram
|
@dataclass
|
||||||
with open_file_read(f"{filename}.tmp.svg") as file:
|
class Tag:
|
||||||
svgdata = re.sub(
|
contents = None
|
||||||
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
|
attribs: Attribs = field(default_factory=Attribs)
|
||||||
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
|
flat: bool = None
|
||||||
file.read(),
|
delete_if_empty: bool = False
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# generate BOM table
|
def __init__(self, contents, flat=None, delete_if_empty=False, **kwargs):
|
||||||
bom = flatten2d(bom_list)
|
self.contents = contents
|
||||||
|
self.flat = flat
|
||||||
|
self.delete_if_empty = delete_if_empty
|
||||||
|
self.attribs = Attribs({**kwargs})
|
||||||
|
|
||||||
# generate BOM header (may be at the top or bottom of the table)
|
def update_attribs(self, **kwargs):
|
||||||
bom_header_html = " <tr>\n"
|
for k, v in kwargs.items():
|
||||||
for item in bom[0]:
|
self.attribs[k] = v
|
||||||
th_class = f"bom_col_{item.lower()}"
|
|
||||||
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
|
|
||||||
bom_header_html = f"{bom_header_html} </tr>\n"
|
|
||||||
|
|
||||||
# generate BOM contents
|
@property
|
||||||
bom_contents = []
|
def tagname(self):
|
||||||
for row in bom[1:]:
|
return type(self).__name__.lower()
|
||||||
row_html = " <tr>\n"
|
|
||||||
for i, item in enumerate(row):
|
|
||||||
td_class = f"bom_col_{bom[0][i].lower()}"
|
|
||||||
row_html = f'{row_html} <td class="{td_class}">{item}</td>\n'
|
|
||||||
row_html = f"{row_html} </tr>\n"
|
|
||||||
bom_contents.append(row_html)
|
|
||||||
|
|
||||||
bom_html = (
|
@property
|
||||||
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
|
def auto_flat(self):
|
||||||
)
|
if self.flat is not None: # user specified
|
||||||
bom_html_reversed = (
|
return self.flat
|
||||||
'<table class="bom">\n'
|
if not _is_iterable_not_str(self.contents): # catch str, int, float, ...
|
||||||
+ "".join(list(reversed(bom_contents)))
|
if not isinstance(self.contents, Tag): # avoid recursion
|
||||||
+ bom_header_html
|
return not "\n" in str(self.contents) # flatten if single line
|
||||||
+ "</table>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# prepare simple replacements
|
@property
|
||||||
replacements = {
|
def is_empty(self):
|
||||||
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
|
return self.get_contents(force_flat=True) == ""
|
||||||
"<!-- %fontname% -->": options.fontname,
|
|
||||||
"<!-- %bgcolor% -->": wv_colors.translate_color(options.bgcolor, "hex"),
|
|
||||||
"<!-- %diagram% -->": svgdata,
|
|
||||||
"<!-- %bom% -->": bom_html,
|
|
||||||
"<!-- %bom_reversed% -->": bom_html_reversed,
|
|
||||||
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
|
|
||||||
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
|
|
||||||
}
|
|
||||||
|
|
||||||
# prepare metadata replacements
|
def indent_lines(self, lines, force_flat=False):
|
||||||
if metadata:
|
if self.auto_flat or force_flat:
|
||||||
for item, contents in metadata.items():
|
return lines
|
||||||
if isinstance(contents, (str, int, float)):
|
else:
|
||||||
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
|
indenter = " " * indent_count
|
||||||
elif isinstance(contents, Dict): # useful for authors, revisions
|
return "\n".join(f"{indenter}{line}" for line in lines.split("\n"))
|
||||||
for index, (category, entry) in enumerate(contents.items()):
|
|
||||||
if isinstance(entry, Dict):
|
|
||||||
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
|
|
||||||
for entry_key, entry_value in entry.items():
|
|
||||||
replacements[
|
|
||||||
f"<!-- %{item}_{index+1}_{entry_key}% -->"
|
|
||||||
] = html_line_breaks(str(entry_value))
|
|
||||||
|
|
||||||
replacements['"sheetsize_default"'] = '"{}"'.format(
|
def get_contents(self, force_flat=False):
|
||||||
metadata.get("template", {}).get("sheetsize", "")
|
separator = "" if self.auto_flat or force_flat else "\n"
|
||||||
)
|
if _is_iterable_not_str(self.contents):
|
||||||
# include quotes so no replacement happens within <style> definition
|
return separator.join(
|
||||||
|
[
|
||||||
|
self.indent_lines(str(c), force_flat)
|
||||||
|
for c in self.contents
|
||||||
|
if c is not None
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif self.contents is None:
|
||||||
|
return ""
|
||||||
|
else: # str, int, float, etc.
|
||||||
|
return self.indent_lines(str(self.contents), force_flat)
|
||||||
|
|
||||||
# perform replacements
|
def __repr__(self):
|
||||||
# regex replacement adapted from:
|
separator = "" if self.auto_flat else "\n"
|
||||||
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
|
if self.delete_if_empty and self.is_empty:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
html = [
|
||||||
|
f"<{self.tagname}{str(self.attribs)}>",
|
||||||
|
f"{self.get_contents()}",
|
||||||
|
f"</{self.tagname}>",
|
||||||
|
]
|
||||||
|
html_joined = separator.join(html)
|
||||||
|
return html_joined
|
||||||
|
|
||||||
# longer replacements first, just in case
|
|
||||||
replacements_sorted = sorted(replacements, key=len, reverse=True)
|
|
||||||
replacements_escaped = map(re.escape, replacements_sorted)
|
|
||||||
pattern = re.compile("|".join(replacements_escaped))
|
|
||||||
html = pattern.sub(lambda match: replacements[match.group(0)], html)
|
|
||||||
|
|
||||||
open_file_write(f"{filename}.html").write(html)
|
@dataclass
|
||||||
|
class TagSingleton(Tag):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.attribs = Attribs({**kwargs})
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.tagname}{str(self.attribs)} />"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iterable_not_str(inp):
|
||||||
|
# str is iterable, but should be treated as not iterable
|
||||||
|
return isinstance(inp, Iterable) and not isinstance(inp, str)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Br(TagSingleton):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Img(TagSingleton):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Td(Tag):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Tr(Tag):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Table(Tag):
|
||||||
|
pass
|
||||||
|
|||||||
164
src/wireviz/wv_output.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
|
import wireviz # for doing wireviz.__file__
|
||||||
|
from wireviz import APP_NAME, APP_URL, __version__
|
||||||
|
from wireviz.wv_dataclasses import Metadata, Options
|
||||||
|
from wireviz.wv_utils import (
|
||||||
|
html_line_breaks,
|
||||||
|
open_file_read,
|
||||||
|
open_file_write,
|
||||||
|
smart_file_resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
mime_subtype_replacements = {"jpg": "jpeg", "tif": "tiff"}
|
||||||
|
|
||||||
|
|
||||||
|
def embed_svg_images(svg_in: str, base_path: Union[str, Path] = Path.cwd()) -> str:
|
||||||
|
images_b64 = {} # cache of base64-encoded images
|
||||||
|
|
||||||
|
def image_tag(pre: str, url: str, post: str) -> str:
|
||||||
|
return f'<image{pre} xlink:href="{url}"{post}>'
|
||||||
|
|
||||||
|
def replace(match: re.Match) -> str:
|
||||||
|
imgurl = match["URL"]
|
||||||
|
if not imgurl in images_b64: # only encode/cache every unique URL once
|
||||||
|
imgurl_abs = (Path(base_path) / imgurl).resolve()
|
||||||
|
image = imgurl_abs.read_bytes()
|
||||||
|
images_b64[imgurl] = base64.b64encode(image).decode("utf-8")
|
||||||
|
return image_tag(
|
||||||
|
match["PRE"] or "",
|
||||||
|
f"data:image/{get_mime_subtype(imgurl)};base64, {images_b64[imgurl]}",
|
||||||
|
match["POST"] or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
pattern = re.compile(
|
||||||
|
image_tag(r"(?P<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return pattern.sub(replace, svg_in)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mime_subtype(filename: Union[str, Path]) -> str:
|
||||||
|
mime_subtype = Path(filename).suffix.lstrip(".").lower()
|
||||||
|
if mime_subtype in mime_subtype_replacements:
|
||||||
|
mime_subtype = mime_subtype_replacements[mime_subtype]
|
||||||
|
return mime_subtype
|
||||||
|
|
||||||
|
|
||||||
|
def embed_svg_images_file(
|
||||||
|
filename_in: Union[str, Path], overwrite: bool = True
|
||||||
|
) -> None:
|
||||||
|
filename_in = Path(filename_in).resolve()
|
||||||
|
filename_out = filename_in.with_suffix(".b64.svg")
|
||||||
|
filename_out.write_text(
|
||||||
|
embed_svg_images(filename_in.read_text(), filename_in.parent)
|
||||||
|
)
|
||||||
|
if overwrite:
|
||||||
|
filename_out.replace(filename_in)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html_output(
|
||||||
|
filename: Union[str, Path],
|
||||||
|
bom: List[List[str]],
|
||||||
|
metadata: Metadata,
|
||||||
|
options: Options,
|
||||||
|
):
|
||||||
|
# load HTML template
|
||||||
|
templatename = metadata.get("template", {}).get("name")
|
||||||
|
if templatename:
|
||||||
|
# if relative path to template was provided,
|
||||||
|
# check directory of YAML file first, fall back to built-in template directory
|
||||||
|
templatefile = smart_file_resolve(
|
||||||
|
f"{templatename}.html",
|
||||||
|
[Path(filename).parent, Path(__file__).parent / "templates"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# fall back to built-in simple template if no template was provided
|
||||||
|
templatefile = Path(wireviz.__file__).parent / "templates/simple.html"
|
||||||
|
|
||||||
|
html = open_file_read(templatefile).read()
|
||||||
|
|
||||||
|
# embed SVG diagram
|
||||||
|
with open_file_read(f"{filename}.tmp.svg") as file:
|
||||||
|
svgdata = re.sub(
|
||||||
|
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
|
||||||
|
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
|
||||||
|
file.read(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# generate BOM table
|
||||||
|
# generate BOM header (may be at the top or bottom of the table)
|
||||||
|
bom_header_html = " <tr>\n"
|
||||||
|
for item in bom[0]:
|
||||||
|
th_class = f"bom_col_{item.lower()}"
|
||||||
|
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
|
||||||
|
bom_header_html = f"{bom_header_html} </tr>\n"
|
||||||
|
|
||||||
|
# generate BOM contents
|
||||||
|
bom_contents = []
|
||||||
|
for row in bom[1:]:
|
||||||
|
row_html = " <tr>\n"
|
||||||
|
for i, item in enumerate(row):
|
||||||
|
td_class = f"bom_col_{bom[0][i].lower()}"
|
||||||
|
row_html = f'{row_html} <td class="{td_class}">{item if item is not None else ""}</td>\n'
|
||||||
|
row_html = f"{row_html} </tr>\n"
|
||||||
|
bom_contents.append(row_html)
|
||||||
|
|
||||||
|
bom_html = (
|
||||||
|
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
|
||||||
|
)
|
||||||
|
bom_html_reversed = (
|
||||||
|
'<table class="bom">\n'
|
||||||
|
+ "".join(list(reversed(bom_contents)))
|
||||||
|
+ bom_header_html
|
||||||
|
+ "</table>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# prepare simple replacements
|
||||||
|
replacements = {
|
||||||
|
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
|
||||||
|
"<!-- %fontname% -->": options.fontname,
|
||||||
|
"<!-- %bgcolor% -->": options.bgcolor.html,
|
||||||
|
"<!-- %diagram% -->": svgdata,
|
||||||
|
"<!-- %bom% -->": bom_html,
|
||||||
|
"<!-- %bom_reversed% -->": bom_html_reversed,
|
||||||
|
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
|
||||||
|
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare metadata replacements
|
||||||
|
if metadata:
|
||||||
|
for item, contents in metadata.items():
|
||||||
|
if isinstance(contents, (str, int, float)):
|
||||||
|
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
|
||||||
|
elif isinstance(contents, Dict): # useful for authors, revisions
|
||||||
|
for index, (category, entry) in enumerate(contents.items()):
|
||||||
|
if isinstance(entry, Dict):
|
||||||
|
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
|
||||||
|
for entry_key, entry_value in entry.items():
|
||||||
|
replacements[
|
||||||
|
f"<!-- %{item}_{index+1}_{entry_key}% -->"
|
||||||
|
] = html_line_breaks(str(entry_value))
|
||||||
|
|
||||||
|
replacements['"sheetsize_default"'] = '"{}"'.format(
|
||||||
|
metadata.get("template", {}).get("sheetsize", "")
|
||||||
|
)
|
||||||
|
# include quotes so no replacement happens within <style> definition
|
||||||
|
|
||||||
|
# perform replacements
|
||||||
|
# regex replacement adapted from:
|
||||||
|
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
|
||||||
|
|
||||||
|
# longer replacements first, just in case
|
||||||
|
replacements_sorted = sorted(replacements, key=len, reverse=True)
|
||||||
|
replacements_escaped = map(re.escape, replacements_sorted)
|
||||||
|
pattern = re.compile("|".join(replacements_escaped))
|
||||||
|
html = pattern.sub(lambda match: replacements[match.group(0)], html)
|
||||||
|
|
||||||
|
open_file_write(f"{filename}.html").write(html)
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
awg_equiv_table = {
|
awg_equiv_table = {
|
||||||
"0.09": "28",
|
"0.09": "28",
|
||||||
@ -70,9 +70,10 @@ def expand(yaml_data):
|
|||||||
|
|
||||||
|
|
||||||
def get_single_key_and_value(d: dict):
|
def get_single_key_and_value(d: dict):
|
||||||
k = list(d.keys())[0]
|
# used for defining a line in a harness' connection set
|
||||||
v = d[k]
|
# E.g. for the YAML input `- X1: 1`
|
||||||
return (k, v)
|
# this function returns a tuple in the form ("X1", "1")
|
||||||
|
return next(iter(d.items()))
|
||||||
|
|
||||||
|
|
||||||
def int2tuple(inp):
|
def int2tuple(inp):
|
||||||
@ -90,16 +91,20 @@ def flatten2d(inp):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def tuplelist2tsv(inp, header=None):
|
def bom2tsv(inp, header=None):
|
||||||
output = ""
|
output = ""
|
||||||
if header is not None:
|
if header is not None:
|
||||||
inp.insert(0, header)
|
inp.insert(0, header)
|
||||||
inp = flatten2d(inp)
|
|
||||||
for row in inp:
|
for row in inp:
|
||||||
|
row = [item if item is not None else "" for item in row]
|
||||||
output = output + "\t".join(str(remove_links(item)) for item in row) + "\n"
|
output = output + "\t".join(str(remove_links(item)) for item in row) + "\n"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def html_line_breaks(inp):
|
||||||
|
return remove_links(inp).replace("\n", "<br />") if isinstance(inp, str) else inp
|
||||||
|
|
||||||
|
|
||||||
def remove_links(inp):
|
def remove_links(inp):
|
||||||
return (
|
return (
|
||||||
re.sub(r"<[aA] [^>]*>([^<]*)</[aA]>", r"\1", inp)
|
re.sub(r"<[aA] [^>]*>([^<]*)</[aA]>", r"\1", inp)
|
||||||
@ -154,7 +159,7 @@ def aspect_ratio(image_src):
|
|||||||
return 1 # Assume 1:1 when unable to read actual image size
|
return 1 # Assume 1:1 when unable to read actual image size
|
||||||
|
|
||||||
|
|
||||||
def smart_file_resolve(filename: str, possible_paths: (str, List[str])) -> Path:
|
def smart_file_resolve(filename: str, possible_paths: Union[str, List[str]]) -> Path:
|
||||||
if not isinstance(possible_paths, List):
|
if not isinstance(possible_paths, List):
|
||||||
possible_paths = [possible_paths]
|
possible_paths = [possible_paths]
|
||||||
filename = Path(filename)
|
filename = Path(filename)
|
||||||
8
tests/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
*.gv
|
||||||
|
*.html
|
||||||
|
*.png
|
||||||
|
*.svg
|
||||||
|
*.tsv
|
||||||
|
*.csv
|
||||||
|
*.html
|
||||||
|
*.pdf
|
||||||
65
tests/bom/bomqty.yml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
connectors:
|
||||||
|
X1:
|
||||||
|
type: No additional components
|
||||||
|
pincount: 6
|
||||||
|
|
||||||
|
X2:
|
||||||
|
type: Contains additional components
|
||||||
|
pincount: 6
|
||||||
|
additional_components:
|
||||||
|
-
|
||||||
|
type: One, no unit
|
||||||
|
-
|
||||||
|
type: Two kilometers
|
||||||
|
qty: 2 km
|
||||||
|
-
|
||||||
|
type: Takes pincount times seven
|
||||||
|
qty: 7
|
||||||
|
qty_multiplier: pincount
|
||||||
|
-
|
||||||
|
type: Takes 10 mm per populated pin
|
||||||
|
qty: 10 mm
|
||||||
|
qty_multiplier: populated
|
||||||
|
-
|
||||||
|
type: Takes number of connections
|
||||||
|
qty_multiplier: connections
|
||||||
|
|
||||||
|
cables:
|
||||||
|
C1:
|
||||||
|
type: Containts additional components
|
||||||
|
wirecount: 4
|
||||||
|
length: 1.5
|
||||||
|
color_code: DIN
|
||||||
|
additional_components:
|
||||||
|
-
|
||||||
|
type: One
|
||||||
|
-
|
||||||
|
type: Three centimeters
|
||||||
|
qty: 3 cm
|
||||||
|
-
|
||||||
|
type: Takes wirecount times two
|
||||||
|
qty: 2
|
||||||
|
qty_multiplier: wirecount
|
||||||
|
-
|
||||||
|
type: Takes length times three
|
||||||
|
qty: 3 # adding unit here should cause error because the length already has a unit
|
||||||
|
qty_multiplier: length
|
||||||
|
-
|
||||||
|
type: Takes total length times three
|
||||||
|
qty: 2 # adding unit here should cause error because the length already has a unit
|
||||||
|
qty_multiplier: total_length
|
||||||
|
|
||||||
|
W2:
|
||||||
|
category: bundle
|
||||||
|
wirecount: 2
|
||||||
|
colors: [tomato, skyblue]
|
||||||
|
|
||||||
|
connections:
|
||||||
|
-
|
||||||
|
- X1: [1-3]
|
||||||
|
- C1: [1-3]
|
||||||
|
- X2: [1-3]
|
||||||
|
-
|
||||||
|
- X1: [3,4]
|
||||||
|
- W2: [1,2]
|
||||||
|
- X2: [3,4]
|
||||||
26
tests/rendering/00_minimal.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
connectors:
|
||||||
|
X1:
|
||||||
|
pincount: 4
|
||||||
|
X2:
|
||||||
|
pincount: 4
|
||||||
|
X3:
|
||||||
|
pincount: 2
|
||||||
|
F:
|
||||||
|
style: simple
|
||||||
|
|
||||||
|
cables:
|
||||||
|
C1:
|
||||||
|
wirecount: 4
|
||||||
|
W2:
|
||||||
|
wirecount: 2
|
||||||
|
category: bundle
|
||||||
|
|
||||||
|
connections:
|
||||||
|
-
|
||||||
|
- X1: [1-4]
|
||||||
|
- C1: [1-4]
|
||||||
|
- X2: [1-4]
|
||||||
|
-
|
||||||
|
- X3: [1,2]
|
||||||
|
- W2: [1,2]
|
||||||
|
- F.
|
||||||
32
tests/rendering/01_color_single.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
connectors:
|
||||||
|
X1: # shorthand color codes
|
||||||
|
color: BK
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [RD, YE, GN, BU]
|
||||||
|
X2: # HTML color codes
|
||||||
|
color: 0xFFFFFF
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [0xFF8000, 0x00FF80, 0x8000FF] # no color for last pin
|
||||||
|
X3: # HTML color names
|
||||||
|
color: red
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [deeppink, tomato, salmon, indianred]
|
||||||
|
F:
|
||||||
|
style: simple
|
||||||
|
color: BN
|
||||||
|
|
||||||
|
cables:
|
||||||
|
C1:
|
||||||
|
wirecount: 4
|
||||||
|
color: GY
|
||||||
|
colors: [OG, OL, LB, PK]
|
||||||
|
|
||||||
|
connections:
|
||||||
|
-
|
||||||
|
- X1: [1-4]
|
||||||
|
- C1: [1-4]
|
||||||
|
- X2: [1-4]
|
||||||
|
- <--
|
||||||
|
- X3: [1-4]
|
||||||
|
- --
|
||||||
|
- F.
|
||||||
35
tests/rendering/02_color_multi.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
connectors:
|
||||||
|
X1:
|
||||||
|
color: YEGN
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [WHBK, BKWH, GNYE, GNWHRD]
|
||||||
|
X2:
|
||||||
|
color: 0xCCCCCC:0x333333
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [0xFF8000:0x80FF00, 0x00FF80, 0x8000FF]
|
||||||
|
X3:
|
||||||
|
color: red:yellow
|
||||||
|
pincount: 4
|
||||||
|
pincolors: [deeppink, tomato, salmon, indianred]
|
||||||
|
F:
|
||||||
|
style: simple
|
||||||
|
color: IVTQ
|
||||||
|
|
||||||
|
cables:
|
||||||
|
C1:
|
||||||
|
wirecount: 4
|
||||||
|
color: GDSR
|
||||||
|
colors: [RDYE, YEGN, GNBU, BURD]
|
||||||
|
C2:
|
||||||
|
wirecount: 4
|
||||||
|
colors: [0xFF8000:0x80FF00, YEGN, GNBU, BURD]
|
||||||
|
|
||||||
|
connections:
|
||||||
|
-
|
||||||
|
- X1: [1-4]
|
||||||
|
- C1: [1-4]
|
||||||
|
- X2: [1-4]
|
||||||
|
- C2: [1-4]
|
||||||
|
- X3: [1-4]
|
||||||
|
- --
|
||||||
|
- F.
|
||||||
51
tests/rendering/03_bgcolors.yml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
options:
|
||||||
|
bgcolor_connector: 0xFFCCCC
|
||||||
|
bgcolor_cable: 0xCCFFCC
|
||||||
|
bgcolor_bundle: 0xCCCCFF
|
||||||
|
|
||||||
|
|
||||||
|
connectors:
|
||||||
|
X1:
|
||||||
|
pincount: 4
|
||||||
|
bgcolor: 0xFF9999
|
||||||
|
bgcolor_title: 0xFF6666
|
||||||
|
X2:
|
||||||
|
pincount: 4
|
||||||
|
X3:
|
||||||
|
pincount: 4
|
||||||
|
color: GN
|
||||||
|
X:
|
||||||
|
pincount: 2
|
||||||
|
F:
|
||||||
|
style: simple
|
||||||
|
|
||||||
|
cables:
|
||||||
|
C1:
|
||||||
|
wirecount: 4
|
||||||
|
bgcolor: 0x99FF99
|
||||||
|
bgcolor_title: 0x66FF66
|
||||||
|
C2:
|
||||||
|
wirecount: 4
|
||||||
|
color: PK
|
||||||
|
W1:
|
||||||
|
wirecount: 2
|
||||||
|
category: bundle
|
||||||
|
W2:
|
||||||
|
wirecount: 2
|
||||||
|
category: bundle
|
||||||
|
bgcolor: 0x9999FF
|
||||||
|
bgcolor_title: 0x6666FF
|
||||||
|
|
||||||
|
connections:
|
||||||
|
-
|
||||||
|
- X1: [1-4]
|
||||||
|
- C1: [1-4]
|
||||||
|
- X2: [1-4]
|
||||||
|
- C2: [1-4]
|
||||||
|
- X3: [1-4]
|
||||||
|
-
|
||||||
|
- X.X4: [1,2]
|
||||||
|
- W1: [1,2]
|
||||||
|
- X.X5: [1,2]
|
||||||
|
- W2: [1,2]
|
||||||
|
- F.
|
||||||
@ -56,10 +56,9 @@ cables:
|
|||||||
# add a list of additional components to a part (shown in graph)
|
# add a list of additional components to a part (shown in graph)
|
||||||
additional_components:
|
additional_components:
|
||||||
-
|
-
|
||||||
type: Sleve # short identifier used in graph
|
type: Sleeve # short identifier used in graph
|
||||||
subtype: Braided nylon, black, 3mm # extra information added to type in bom
|
subtype: Braided nylon, black, 3mm # extra information added to type in bom
|
||||||
qty_multiplier: length # multipier for quantity (length of cable)
|
qty_multiplier: length # multipier for quantity (length of cable)
|
||||||
unit: m
|
|
||||||
pn: SLV-1
|
pn: SLV-1
|
||||||
|
|
||||||
|
|
||||||
@ -75,7 +74,7 @@ connections:
|
|||||||
|
|
||||||
additional_bom_items:
|
additional_bom_items:
|
||||||
- # define an additional item to add to the bill of materials (does not appear in graph)
|
- # define an additional item to add to the bill of materials (does not appear in graph)
|
||||||
description: Label, pinout information
|
type: Label, pinout information
|
||||||
qty: 2
|
qty: 2
|
||||||
designators:
|
designators:
|
||||||
- X2
|
- X2
|
||||||
|
|||||||