WireViz/src/wireviz/wv_graphviz.py
Daniel Rojas c33a19708c 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
2025-03-01 19:41:27 +01:00

619 lines
21 KiB
Python

# -*- 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("&nbsp;"))) # 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("&nbsp;"))) # 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("&nbsp;"))) # 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)