Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

component-render

A standalone library that records component diagrams in a small JSON format and renders them to DXF and SVG with ezdxf.

Install

pip install ezdxf networkx

ezdxf writes the DXF and SVG. networkx is needed only by OrthogonalRenderer, which uses its planarity test and its minimum-cost-flow solver; every other part of the library depends on ezdxf alone. Run from this directory, or put it on PYTHONPATH.

DotRenderer calls the graphviz dot binary (brew install graphviz or apt install graphviz). When dot is not on the path it falls back to BarycentreRenderer and records a note in renderer.notes.

Run the demo

python3 examples/build_demo_document.py

It writes examples/out/demo_document.json, .dxf and .svg for a dual-source data-centre one-line of 16 components. examples/build_symbol_sheet.py renders every symbol in the library on one review sheet.

JSON format

A document is a JSON list of component records. A record holds four fields and nothing else.

[
  {
    "name": "circuit_breaker",
    "instance_id": "CB-001",
    "label": ["XT4N 250 EKIP LSI IN=250A", "OFF"],
    "connections": [["in", "UTIL-001", "out"], ["out", "XFMR-001", "primary"]]
  }
]
  • name selects the component class.
  • instance_id is the display name plus a three-digit number.
  • label is a list of free-text lines lettered under the instance id, one per line, such as ["S203P C1,6 NA", "Fire detection"]. Every component carries a Label object holding them; it is empty by default, and a record that leaves the field out loads as empty. An open device is marked with an OFF or N.O. line rather than a separate symbol, as IEEE 315 note 9.4.4A allows.
  • each connection is [local_terminal, external_instance_id, external_terminal].

Every connection appears twice, once in each component it affects. Position and scale are never stored; the renderer decides them.

Classes

Class Role
GeneralComponent Abstract base. Class-level draw, terminals, name, display_name, description, symbol_source, input_terminals, output_terminals; instance-level instance_id, xy_position, scale, connections.
73 concrete components All direct children of GeneralComponent, listed below.
InstanceIdMinter Issues BUS-001, BUS-002, and so on.
ComponentRegistry Maps a record name to its class.
ConnectionChecker, ConnectionFinding Reports bad terminal names, missing mirrors, and unknown components.
JSONStorer, JSONLoader Convert between component objects and JSON.
DXFLineSet Strictly orthogonal connection segments; rejects a sloped segment.
GeneralRenderer Abstract renderer: abstract arrange_components, concrete render.
AlgorithmDescription, DescriptionWriter The plain-language account of one layout algorithm, and the writer that prints it wrapped to the console.
TextStyleBuilder Registers the MINIWATT and MINIWATT-BOLD text styles, so labels and sheet text are lettered the way Mini-Watt letters a sheet.
SheetPagePlanner Plans the exported page so the sheet plots at true size rather than being stretched to fill it.
BarycentreRenderer JSON to DXF and SVG; depth rows ordered in one upstream barycentre pass. This is the default.
LayeredRenderer Full four-phase layered Sugiyama. A rotatable one-in one-out device whose downstream neighbour is already fed by something else is lifted out of the ranking; where its two neighbours then land in the same band, the device is turned a quarter turn and seated between them as a level tie.
DotRenderer Seventh alternative. Hands every component to the graphviz dot binary as one sized node, pins sources to the top rank and loads to the bottom, keeps tie-joined peers level and adjacent through rank=same groups and invisible chain edges, turns each tie in such a group a quarter turn so the cross-tie is drawn as a horizontal run between its two buses, then renumbers bus taps by neighbour x and wires each row gap through planned channels.

Component library

Symbols follow United States one-line drafting practice, using the IEEE 315 vocabulary: geometry only, with the instance id lettered beside the glyph rather than inside it. Render the review sheet to see all of them at once:

python3 examples/build_symbol_sheet.py
Tag Class JSON name Terminals
AHF ActivePowerConditioner active_power_conditioner in
ATS TransferSwitch transfer_switch emergency_in, normal_in, out
ATSI InlineTransferSwitch transfer_switch_inline emergency_in, normal_in, out
BATT BatterySource battery_source out
BUS Bus bus in_1.. / out_1.. (as needed)
BUSWAY Busway busway in, out, tap_1, tap_2, tap_3
BWYBUS BuswayBus busway_bus in_1.. / out_1.. (as needed)
CAP Capacitor capacitor in, out
CBLV LowVoltageDrawoutBreaker breaker_lv_drawout in, out
CBMV MediumVoltageBreaker breaker_mv in, out
CBMVDO MediumVoltageDrawoutBreaker breaker_mv_drawout in, out
CONT Contactor contactor in, out
CPT ControlPowerTransformer cpt in
CT CurrentTransformer ct in, out
CTM MultiRatioCt ct_multi in, out
DCDC DcConverter dc_dc_converter in, out
DISC DisconnectSwitch disconnect_switch in, out
EQP EquipmentLoad equipment_load in
EVSE VehicleSupplyEquipment evse in
FC FuelCellSource fuel_cell_source out
FLY FlywheelStorage flywheel in
FSW FusedSwitch fused_switch in, out
FU Fuse fuse in, out
GEN EngineGenerator generator_source out
GFD GroundFaultDetector gfd in
GND GroundingElectrode ground in
GXFMR GroundingTransformer grounding_transformer in
HF HarmonicFilter harmonic_filter in
HTR HeaterLoad heater_load in
ILK InterlockedSwitches interlocked_switches in_1, in_2, out_1, out_2
INV Inverter inverter in, out
IT InformationTechnologyLoad it_load in
LB LoadBank load_bank in
LBE LoadBreakElbow load_break_elbow in
LBS LoadBreakSwitch load_break_switch in, out
LTG LightingLoad lighting_load in
MCCB MoldedCaseBreaker breaker_mccb in, out
MOT Motor motor in
MTR Meter meter in
NGR NeutralGroundingResistor ngr in
NOPT NormallyOpenPoint normally_open in, out
PDU PowerDistributionUnit pdu in, out
PDUBUS PduBus pdu_bus in_1.. / out_1.. (as needed)
PNL Panelboard panel in, out_1, out_2, out_3, out_4, out_5, out_6
PNLBUS PanelboardBus panelboard_bus in_1.. / out_1.. (as needed)
PT PotentialTransformer pt in
PV PhotovoltaicArray pv_array out
RCL Recloser recloser in, out
RCPT ReceptacleLoad receptacle_load in
RCT Reactor reactor in, out
RECT Rectifier rectifier in, out
RLY ProtectiveRelay relay in
RPP RemotePowerPanel rpp in, out_1, out_2, out_3
RPPBUS RemotePanelBus remote_panel_bus in_1.. / out_1.. (as needed)
RVSS SoftStarter soft_starter in, out
SEC Sectionalizer sectionalizer in, out
SEPDN SeparatedDownstreamComponent separated_downstream in
SEPUP SeparatedUpstreamComponent separated_upstream out
SPD SurgeArrester surge_arrester in
SPL CableSplice splice in, out
SRC GenericSource generic_source out
STS StaticTransferSwitch static_transfer_switch emergency_in, normal_in, out
SWGR Switchgear switchgear in_1, in_2, out_1, out_2, out_3, out_4, out_5, out_6
SWGRBUS SwitchgearBus switchgear_bus in_1.. / out_1.. (as needed)
TERM CableTermination cable_termination out
TIE TieBreaker tie_breaker in, out
TIENO NormallyOpenTieBreaker tie_breaker_normally_open in, out
UPS UninterruptiblePowerSupply ups in, out
UPSD DualFeedUps ups_dual_input bypass_in, in, out
UPSR RotaryUps ups_rotary in, out
UTIL UtilitySource utility_source out
VFD VariableFrequencyDrive variable_frequency_drive in, out
WTG WindTurbineGenerator wind_generator_source out
XFMR PowerTransformer transformer_2w primary, secondary
XFMRDY DeltaGroundedWyeTransformer transformer_delta_grounded_wye primary, secondary

Where these symbols came from

The glyphs were first converted from the Mini-Watt one-line symbol library, then every one was checked against IEEE Std 315-1975 (R1993) and its 1986 supplement. Every class records the answer in a symbol_source string of at most 30 characters, which the base class requires the same way it requires name and description:

symbol_source Meaning Count
IEEE 315 <clause> p<page> The 1975 standard names a symbol for the device. 49
IEEE 315A <clause> p<page> The 1986 supplement does. (included above)
Mini-Watt symbol library Neither standard has a symbol; the glyph is Mini-Watt's. 25
unverified Neither. Nothing has been checked. 1

The page is the page printed on the sheet, not the PDF page. ieee315_review.md gives the rule used, how the page numbers and the Mini-Watt derivations were established, and one row per component. The one unverified glyph is Switchgear.

examples/build_symbol_sheet.py letters symbol_source under every symbol on sheet.svg in green for a standard, blue for Mini-Watt and red for unverified, so an unchecked glyph is visible at a glance.

Choices made where the standard leaves room:

  • The medium-voltage breakers are open squares (9.4.4), not solid ones. A filled box reads as closed-status on a utility one-line, and this library carries no device state to assert.
  • The meter and the relay carry placeholder text inside the circle, WH (12.1) and 51 (9.5.1), so the two cannot be confused; a project legend should replace them with the real function letter or device number.
  • The panelboard, the remote power panel, and the busway gained the branch and tap terminals their glyphs already draw. Without them a distribution device could not feed anything downstream.
  • Winding connections are carried by a distinct class rather than by a mark you attach to a transformer: DeltaGroundedWyeTransformer draws the delta and grounded-wye marks inside the two winding circles (6.4.15.1, 13.3.5, 13.3.4). PowerTransformer stays available where the connection is not stated. The bare wye, grounded-wye and delta marks are not components of their own, since they have no terminals.
  • Equipment that source data often models as a bus has a bus-form twin, so the drawing can show either: Switchgear / SwitchgearBus, Panelboard / PanelboardBus, RemotePowerPanel / RemotePanelBus, PowerDistributionUnit / PduBus, Busway / BuswayBus. Each bus form is its own class holding a copy of the bus geometry, not a subclass of Bus. Switchgear and SwitchgearBus share the in_N / out_N naming, so one can replace the other without touching any connection, up to the eight terminals the enclosure draws.
  • A bus has as many terminals as it needs, named in_1 upward and out_1 upward, created on demand. A terminal also takes any number of connections, so fan-out is never limited.

Converting a Mini-Watt document

JSONConverter reads a Mini-Watt engineering document and writes this library's format.

python3 examples/convert_miniwatt_document.py path/to/miniwatt-02.json

It writes the converted JSON, a DXF, an SVG, and a map file pairing every source id with the instance id it became (MSWGR-A to SWGRBUS-001), so the drawing can be traced back to the document it came from.

What it does with the source document:

  • Topology comes only from electrical_connections. The connected_bus, input_bus and primary_bus fields repeat the same links, so they are ignored rather than wired twice.
  • A circuit is an edge in the source document, not a device, so a closed one collapses into a direct connection between the two things it joins.
  • A normally-open circuit becomes a NormallyOpenTieBreaker between them, because on a one-line an open tie has to be visible.
  • A transformer's stated primary_switch becomes a component in the primary path, so the bus feeds the switch and the switch feeds the winding.
  • A bus is drawn as a bus-form variant when its own name says what it is: Main Switchgear A becomes a SwitchgearBus, PDU A a PduBus.
  • line and load terminals become in and out; output becomes out; input becomes in; primary and secondary keep their names.
  • A source document's single bus terminal has to become a real terminal on our bus, so the converter reserves one per connection: a feed takes the next input, an outgoing circuit the next output. Nothing is ever reused, and the bus grows to fit.
  • protective_devices are drawn by symbol_variant (lv_drawout, mccb, mv, fuse), falling back to device_type. Switch types switch_disconnector and contactor map to the disconnect and the contactor; automatic_transfer_switch, transfer_switch and changeover_switch_disconnector all map to the transfer switch, since the IR authors every changeover as one record with line, alternate_line and load. alternate_line lands on emergency_in.
  • An interlocks record is lettered INTERLOCK - <name> on each member and not drawn, as IEEE 315 4.29 directs.
  • A UPS with a static_bypass_input or bypass connection becomes a DualFeedUps; both terminals land on bypass_in.
  • An equipment row is drawn by equipment_type: panelboard, switchboard or pad_mounted_switchgear, ups, ground_fault_detector; anything else is a general equipment load with a note.
  • A transfer switch whose alternate_line has no feed in the document gets a SeparatedUpstreamComponent labelled ALTERNATE SOURCE, plus the not_stated.alternate_line token when the row names one, so the second inlet is visible and traceable to the document's own unknown.
  • Labels are composed from the row: product id and name for devices, name and P = … for loads, name and line_voltage for buses, and name, rating and product id for sources, transformers and UPSs. A normally-open device or connection adds an OFF line; a collapsed circuit's cable text is lettered on the device it feeds.

Every decision that is not a direct mapping is recorded as a ConversionNote, readable through describe_notes(), so nothing is inferred silently. The converter does not carry voltages, ratings or fault currents, since this format holds only names, instance ids and connections; the map file is what ties the drawing back to that data.

Using it directly

from component_render import InstanceIdMinter, JSONStorer, MoldedCaseBreaker, Renderer, UtilitySource

minter = InstanceIdMinter()
utility = minter.create_component(UtilitySource)
breaker = minter.create_component(MoldedCaseBreaker)
utility.connect_to("out", breaker, "in")

components = [utility, breaker]
JSONStorer().write_json_file(components, "document.json")
renderer = Renderer()
renderer.render_json_file("document.json", dxf_path="document.dxf", svg_path="document.svg")
print(renderer.describe_findings())

Splitting a drawing across sheets

A large one-line is often drawn in parts: side A on one sheet, side B on another, or two drawings side by side on one sheet. The part not drawn is stood in for by one of two single-terminal components:

Class Terminal Arrow Stands in for
SeparatedUpstreamComponent out points toward the device it feeds something upstream, drawn elsewhere
SeparatedDownstreamComponent in points away from the device that feeds it something downstream, drawn elsewhere

Both draw the IEEE 315 one-way flow arrow (1.7.1, filled head) and both are rotatable, so they can meet a rotated device from the side. Say where the other end is with the label:

from_side_b = minter.create_component(SeparatedUpstreamComponent)
from_side_b.apply_label_lines(["FROM SIDE B"])
from_side_b.connect_to("out", transfer_switch, "emergency_in")

Because the upstream stand-in has only an output and the downstream one only an input, every layout algorithm already ranks the first as a source and the second as a load; no renderer needed to change. examples/build_separated_drawing.py builds the A side of a two-sided transfer scheme this way and writes examples/out/separated/side-a.svg.

Adding a component

Subclass GeneralComponent directly, set name, display_name, description, symbol_source, symbol_width, symbol_height, input_terminals, output_terminals, then implement draw and terminals. The base class checks the lengths and checks that every declared terminal is drawn as soon as the class is defined. symbol_source is the one text field you may leave out: it defaults to unverified, so a new component shows up red on the review sheet until someone traces its glyph. Never guess a clause to turn it green.

Bus terminals and length

A bus is the component whose terminal set and size are not fixed. It starts with no terminals at all and grows as connections are made:

bus.connect_to(bus.reserve_output_terminal(), panel, "in")

reserve_input_terminal() and reserve_output_terminal() hand out in_1, in_2, ... and out_1, out_2, ..., and ensure_terminal(name) grows the counts when a stored document refers to a terminal that is not there yet, so loading a document rebuilds the same bus it came from.

Positions follow the counts:

  • A single input, or a single output, sits at the middle of the bus.
  • Two or more are evenly spaced end to end.
  • Inputs sit on one side of the drawn line and outputs on the other, half a line width off the centre, so the two sets never fight over the same point.

Length follows the busier side:

length = length_scale_factor * max(input_count, output_count) * distance_per_connection

capped at upper_length_limit and floored at minimum_length.

VisibilityRenderer is the one sanctioned exception to that formula: its whole algorithm is stretching a bus to span the children hanging under it, so it sizes a bus from its feeder geometry instead. It still honours the same cap and floor. Every other renderer leaves the formula alone. The defaults are a scale factor of 1.0, 12 units per terminal, a 20-unit floor and a 1200-unit cap. The floor is declared once, on GeneralComponent, so every bus-like class inherits the same value; the rest are class attributes you can tune per class. arrange_components sets the length of every bus before it places anything.

Because both the length and the counts are per instance, draw on a bus-like class takes a fourth parameter and terminals takes three more: length, input_count and output_count. Every other component keeps the plain form, and the instance supplies its own values. Use component.measure_width() and component.measure_height() rather than symbol_width and symbol_height when you need the space a particular instance occupies, and component.list_input_terminals() / list_output_terminals() rather than the class tuples when you need roles.

Switchgear is deliberately not variable: its rectangle is an enclosure outline, and stretching an enclosure to encode a connection count would misstate the equipment.

InterlockedSwitches is variable in length but not in terminal count. It is one symbol holding two switches, and the two switches sit at the ends of the length, which is what lets a mechanical interlock span the sheet the way IEEE 315A 14.5.5 and the drawings that follow it draw it: two switches on two separate feeders, joined by a line with a triangle at its midpoint. Because it is length-variable it is bus-like to every renderer, so each of its two poles gets its own upstream chain and its own drop to the bus below - a utility branch on one side and a generator branch on the other, rather than both incomers collapsed into one column. Its counts are pinned at two inputs and two outputs, so the formula above gives a fixed span of 120 units, and in_1 / out_1 are the left pole while in_2 / out_2 are the right one.

A component may also declare display_length, the length used when it is drawn for show rather than for a diagram - on the symbol review sheet and in the sheet legend, where a 120-unit span would set the width of every cell in the grid. It defaults to default_length; only InterlockedSwitches overrides it.

Route coincidence

Two connection routes that touch anywhere except a shared terminal are ambiguous: a reader cannot tell a junction from a coincidence. DXFLineSet therefore refuses to let that happen silently. Every route enters through one of add_segment, add_path, add_route_points or add_elbow_route, each of which is one connection between two terminals, and each is checked by CoincidenceGuard against every route already placed before it is kept as a RouteRecord. The guard names eight ways two routes can coincide:

Kind Meaning
overlap two collinear segments share more than a point
terminal_on_bend, bend_on_terminal, bend_on_bend a vertex of one route sits on a vertex of the other, and they are not both terminals
terminal_on_route, bend_on_route a vertex of the new route sits on the interior of a placed segment
route_on_terminal, route_on_bend the new route passes through a vertex of a placed route
collinear_at_terminal two routes share a terminal and leave it along the same line, so they read as one wire passing through

Two routes may share a terminal and leave it in different directions - a bus tap accepts any number of connections - and two routes may cross at right angles; neither is a conflict.

find_route_conflict(points) asks without placing, so a router can try again; add_route places regardless and records any conflict, so a route is never dropped from the drawing, and the renderer exposes what it found through count_route_conflicts() and describe_route_conflicts(components), which names the terminals at each end of both routes. The sheet transforms a line set by copying its routes and conflicts through the same scale and shift, not by re-adding segments, so the check runs once per route.

Every engine acts on the answer before it draws. The line set offers two guarded entry points: add_route_if_clear(points) places a route only if it is clear and otherwise places nothing, records a RouteRefusal and returns it; and add_first_clear_route(candidates) tries an ordered list of point lists and places the first clear one, recording exactly one refusal only when none is. No engine calls the unconditional add_segment, add_path, add_route_points or add_elbow_route any more. Each engine builds its own candidate families - channel heights, side steps, terminal approaches, corridors - because each engine routes differently; the per-engine notes below say what each one tries, in what order, and why. Measured on the ABB side A document (Orthogonal on miniwatt-02, since it does not finish side A):

Engine Conflicts before After Refusals Connections unrouted
TemplateRenderer 74 0 0 0
BarycentreRenderer 34 0 0 0
VisibilityRenderer 17 0 0 0
OrthogonalRenderer 11 0 0 0
DotRenderer 4 0 0 0
SlotRenderer 2 on miniwatt-02 0 0 15, pre-existing
LayeredRenderer 0 0 0 0
AnnealingRenderer not measured 0 0 0

Slot's fifteen unrouted connections are the nine From Side B arrows and three UPS-bypass chains it strands in its leftover row; they never had a route, before or after. Two lessons recurred across engines. Two of them had built their own coincidence check on channel heights alone, blind to vertices and to x extent; deleting it in favour of the guard fixed the bend conflicts and, in Visibility's case, made the arrange nearly four times faster. And the hardest conflicts in three engines were the same geometry: a bus input and output sit 0.7 units apart on opposite sides of the bus line and so share a column, which no channel height can separate - the route has to approach the terminal from the side.

Where taps and feeders line up, none of this is needed. VisibilityRenderer sizes a bus to its feeders, and with the cap raised to 1200 units it now puts 15 of the 16 taps on BUS-004 directly above their feeder; with the old 240-unit cap the same bus was 180 units long under 900 units of feeders.

Template

TemplateRenderer used to place every conductor and count the damage afterwards: on the ABB side A document it recorded 74 coincidences, the worst of the eight engines. All 74 were the same fault. A bus fanning twenty feeders sent every one of them down into the same horizontal channel halfway between the bus and the row of breakers below, and the channel's own retry was capped so tightly - a seven-unit margin off each end of a twenty-three-unit gap - that only three heights existed for twenty-five runs. Where the fan overlapped in x, the runs were drawn one on top of another and read as a single wire.

The engine now asks before it draws. Its six placement sites - the plumb drop, the drop that has to pass a symbol box, the level run, the level run that has to pass over a box, the elbow through a horizontal channel, and the side lane for a run that goes backwards - each build a family of genuine alternatives and hand the whole ordered list to add_first_clear_route, which places the first one the CoincidenceGuard accepts. None of them calls add_segment, add_path, add_route_points or add_elbow_route any more, so nothing is placed without being checked first.

Each family is a StepLadder, which counts alternating outward steps away from the position the draftsman would have picked:

Ladder First choice Alternatives
ChannelLadder the channel halfway down channels stepped up and down about it, then the two L shapes that turn at one end's own height
PlumbDropLadder the plain plumb line a jog to one side and back, then the same jog arriving at the lower terminal sideways
LevelRunLadder straight across the run lifted or dropped by one step
OverBoxLadder just above the box higher, a step at a time
BoxBypassLadder down the nearer side of the box further out on that side, then the far side
SideLaneLadder the nearer sheet edge further out, the far edge, then a deeper drop into each

A ladder returns its candidates as ordered families rather than one flat list, because the fallback shapes have to stay behind every ordinary one. Mixing them cost six connections on side A: an L that happened to miss every label box was tried ahead of a stepped channel that only just grazed one, and the L then stood in the way of the six runs behind it.

ClearanceSorter keeps the obstacle preference the engine already had - a candidate clear of every symbol and label box first, one clear of only the symbols next, one that hits something last - and applies it inside each family, so a channel is still chosen for the same reasons as before and only reordered among equals.

Order matters as much as the alternatives. RouteOrderer places the plumb drops first, since a drop between two terminals on one vertical line cannot move at all; then the level runs, then the elbows, then the side lanes, which have the whole width of the sheet to play with. Inside the elbow group it walks each channel band from the top of the sheet down and each band from left to right, which is the order that colours an interval graph with the fewest colours - so a fan of twenty feeders uses as few channel heights as the fan actually needs.

The channel margin came down from seven units to one and a half and the step from three to two, which is what makes room for the eight or nine heights the densest fans on side A want inside a twenty-three-unit gap. The midpoint is still the first candidate, so a diagram that never conflicts is drawn exactly where it was drawn before.

Measured on the two documents, counting a connection as lost when no route ends on both of its terminals:

Document Pairs Routes Conflicts Refusals Unrouted
ABB side A, before 176 176 74 0 0
ABB side A, after 176 176 0 0 0
miniwatt-02, before 33 33 0 0 0
miniwatt-02, after 33 33 0 0 0

Ninety-two of side A's 176 runs still take their first choice; the deepest any run has to reach is the twelfth candidate. The old global list of used channel heights is gone, because the guard answers the question the list was guessing at: two taps that fan opposite ways off one bus may now share a height, and only the ones whose spans actually overlap are pushed apart.

One shape earns its place twice. Two feeders into a single load terminal - a dual-corded PDU, say - coincide when both arrive plumb, because they share the last vertical leg as well as the terminal, and no channel height separates them: the two routes meet before the channel matters. The fallback families are the answer. ChannelLadder's L turns at the destination's own height, so the second feeder arrives along a horizontal leg, and PlumbDropLadder's side approach does the same for a drop. The guard allows two routes to share a terminal when they leave it in different directions, and a reader can tell them apart. That L is what clears the two refusals converted-layout-02 was left with before the fallback families existed.

Barycentre

BarycentreRenderer no longer places a route and reports the damage afterwards. Every connection is now offered to the line set as an ordered list of candidate paths through add_first_clear_route, which places the first one that coincides with nothing already drawn and withholds the connection only when the whole list fails. The engine calls none of add_segment, add_path, add_route_points or add_route, so no coincident line can reach the drawing by accident.

The candidates come from four families, tried in that order:

Family Shape What it frees
straight two points the plumb pair, tried first when the terminals share an x
elbow four points, one horizontal channel the channel height
jog six points, two channels and a side leg a second channel and a sideways offset
side departure five points, leaving one terminal horizontally the direction a route leaves a shared terminal

ElbowChannelBand holds the range a channel may move inside - strictly between the two terminals' y values, or one row gap below a pair that sits level - and its first choice, the midpoint. ChannelHeightLister walks that band twice: fine steps of channel_step either side of the midpoint, then a sweep of the whole band in channel_sweep_divisions parts, ordered by nearness to the midpoint and de-duplicated against the steps. The fine steps answer the crowded case; the sweep answers the tall one. BUS-003 on ABB side A fans 20 taps through a 71-unit gap, which needs more than 17 channels, so channel_step dropped from 4.0 to 2.0; and the feed into SEPUP-005 needs a channel 800 units from its own midpoint, which only the sweep reaches.

Two of the eight coincidence kinds cannot be answered by moving a channel. Two routes leaving the same terminal both start with a vertical stub at that terminal's x, so they overlap however high the channel sits - the side departure family exists for that, and the guard explicitly allows two routes to share a terminal and leave in different directions. And a terminal that sits on the interior of a route already placed is unreachable from any direction: no candidate can help, because the conflict is at the endpoint itself. TerminalObstacleIndex heads that off by indexing every component terminal by rounded x and y and sorting the candidates so that any path running through a terminal which is not one of its own two ends is tried last. That is what stops the feeder into BUS-002 from being drawn straight over the BUS-005 terminals, which would have stranded both of BUS-005's own connections.

On the six documents in the repository the engine now records no conflicts and no refusals, and every connection pair reaches the paper:

Document Components Conflicts before Conflicts after Refusals Unrouted
ABB side A 171 34 0 0 0
miniwatt-02 31 0 0 0 0
converted-layout-01 14 2 0 0 0
converted-layout-02 22 2 0 0 0
demo document 16 1 0 0 0
separated drawing 6 1 0 0 0

ABB side A carries 176 unique connection pairs and 176 placed routes, so the unrouted count is measured, not assumed.

Arranging ABB side A, 171 components and 176 connections, takes about one second.

Avoiding coincidence also improved the drawing on measures it was not aiming at. Scored the way examples/compare_layouts.py scores a layout, ABB side A went from 26784 to 21189 units of conductor, from 741 to 511 crossings and from 175 to 36 symbol pierces, on the same 522 segments and the same 176 routes. Most of that is the terminal preference: a candidate that ran through a foreign terminal was usually running through the symbol that terminal belongs to.

Visibility

VisibilityRenderer no longer places a connection and then reports what it hit. Every route is offered to the line set first, through is_route_clear, and only a route that coincides with nothing already placed is kept, through add_route_if_clear. Nothing in the engine calls add_route_points, add_segment, add_path or add_elbow_route any more.

Refusing a route would make the drawing wrong in a way a reader cannot see - a connection that exists in the data and not on the paper - so the engine offers alternatives before it gives up. RouteCandidateSet builds them in five tiers and hands each tier to RoutePlacer, which takes the first clear candidate in the tier and only moves on when the whole tier is coincident:

Tier Candidates
0 the straight drop, and the level run under the labels of a tie
1 the upright jog, its horizontal channel swept across the gap between the two terminals
2 the sideways jog, its vertical channel swept across the columns between them
3 the detour, corridor by corridor around the symbol boxes
4 the same jogs again with the symbol-clearance test dropped, ending in a plain elbow

ChannelSweeper is what makes tiers one and two a family rather than a single line. It takes the two end values and the natural channel, insets the interval so no candidate can land on a terminal, and returns the natural channel first and then steps alternately above and below it. So the second connection into a crowded gap is drawn one step off the first, the third one step the other way, and a bus fanning out to ten breakers ends up with ten distinct channels rather than ten copies of one.

Sweeping the vertical channel in tier two matters for a reason of its own: every tier-one candidate leaves the start terminal vertically, so two connections sharing one terminal can never both be drawn as upright jogs without reading as a single wire passing through - the collinear_at_terminal case. The sideways jog leaves that terminal horizontally instead. Neither the ABB side A document nor miniwatt-02 reaches tier two, because a bus reserves a terminal per connection, but the demo document and the separated drawing both hang two wires on one breaker terminal and both take a sideways jog for the second.

Where the two terminals sit in one column - the ordinary case for that second wire off a breaker - the sweep between them has nowhere to go, and the same holds for the crossing height when they sit in one row. In that case the sweep is replaced by the obstacle list: RouteFinder.list_corridors gives the columns just clear of every symbol box and list_rows gives the heights, both ordered outward from the midpoint. So the wire steps out to the nearest free column, runs down it and comes back, instead of being refused.

What the old engine did instead was keep its own list of horizontal channel heights it had already used, used_channels, and reject any elbow whose run sat at a height already in the list. That list was the source of the route_on_bend and bend_on_route conflicts unique to this engine. It was too coarse in one direction - a height was blocked across the whole sheet, however far away, which pushed fifteen connections into detours they did not need - and too narrow in another: it recorded only horizontal runs, by height alone, and a detour whose horizontal leg collapsed to zero length recorded nothing at all while still leaving a corner behind. A later elbow then ran straight through that corner. The list is gone; the guard does the work.

On the ABB side A document the engine now records no conflict, no refusal and no unrouted connection, against seventeen conflicts before - ten overlap, four route_on_bend and three bend_on_route. Every one of the 176 connections is on the paper. The route mix moved with it: 113 straight drops and one level run are unchanged, the fifteen detours fell to one, and the seventeen routes that were previously drawn blocked - placed on top of another route because nothing else was tried - fell to none, since a swept channel was available for all of them. miniwatt-02 records no conflict and no refusal too.

Orthogonal

OrthogonalRenderer no longer places a connection and reports the damage afterwards. Its one DXFLineSet placement site, WireRouter.place_route, hands a family of candidate routes to add_first_clear_route and keeps the first that CoincidenceGuard finds clear; add_route_points is not called from this engine any more. A connection is withheld only when every candidate coincides, and the RouteRefusal is counted in the engine report as route_refusals.

Why an engine that computes an orthogonal drawing coincided at all

Almost none of it came from the shape stage. The router emitted one route per graph edge, and a graph edge is not a connection.

  • A bus is modelled as a chain of tap vertices joined by kind="chain" edges. Those chain edges were drawn as connection routes, so every bus spine was re-drawn as a row of separate routes lying end to end on one line. Each neighbouring pair shared a tap and left it along the same line, which is collinear_at_terminal, and any feeder landing on a middle tap sat on one of those routes' terminals or interiors. The bus symbol draws that line itself, so the chain routes were duplicates of symbol geometry. On miniwatt-02 they accounted for nine of the eleven recorded conflicts.
  • Planarisation splits a connection at every crossing dummy and every port jog, and each half was drawn as its own route. At a degree-four crossing that put four route ends on one point, which reads as a junction rather than a crossing and which the guard calls collinear_at_terminal.

So the repair is upstream of any nudging. ConnectionPathFinder walks the planarised graph and chains the split edges back into one path per connection - pairing the two half-edges at a crossing dummy by their opposite headings, and following the only other neighbour at a degree-two jog - and skips the bus chain edges entirely. One connection is then one route, a crossing is drawn as a crossing, and the bus line is left to the bus symbol. That step alone took miniwatt-02 from eleven recorded conflicts to none, with one connection still refused, and from 54 drawn segments to 36 with nothing lost from the sheet.

What was left, and the alternatives offered for it

One coincidence on miniwatt-02 was real: the compaction stage gave two independent vertical wires the same column, so a long tie ran down the column a short link already occupied. That is the metrics stage collapsing two edges onto one line, and no amount of chaining fixes it.

RouteCandidateFamily answers it with alternatives that keep both terminals and both departure axes fixed:

  • the base route the shape and metrics stages produced, which stands unless another candidate is measurably clearer of symbols and terminals;
  • the base with one interior segment - one that touches neither terminal - moved perpendicular to itself by one to six channel_steps either way, longest segment first, which changes the corridor without moving a bend onto a terminal;
  • the same for a detour frame built where the base has no interior segment: a two-point route gains a sidestep and a single-corner route gains a staircase, each with a free middle run for the shift to act on;
  • a lead jog at either end, which steps the terminal-adjacent run sideways just past the terminal, so a route can leave a contested column even where the only overlap is on its first or last segment.

RouteClearanceRanker orders that family before it is offered: candidates are sorted by how many other components' terminals they run over, then by how many symbol boxes they cut through, then by their position in the family, so the base still wins every tie. This matters twice over - it keeps the detours out of symbols, and it stops a route from being drawn through a terminal that a later connection will need, which is what turned the one refusal on the stacked-tie case into a clean drawing.

LayoutRanker now refuses any rotated retry that withholds or coincides more than the upright sheet, and read_rank sorts on refusals and conflicts before crossings, so the turn stage can never trade a connection for a tidier page.

Measured

Document Before After
miniwatt-02 (31 components, 33 connections) 50 routes, 11 conflicts, 2 unrouted 33 routes, 0 conflicts, 0 refusals, 0 unrouted
miniwatt-01 (16 connections) 23 routes, 3 conflicts 16 routes, 0 conflicts, 0 refusals, 0 unrouted
shared-column synthetic (9 connections) 13 routes, 3 conflicts 9 routes, 0 conflicts, 0 refusals
crossed-feeds synthetic (7 connections) 10 routes, 1 conflict 7 routes, 0 conflicts, 0 refusals
stacked-tie synthetic (12 connections) 20 routes, 5 conflicts 12 routes, 0 conflicts, 0 refusals

The three synthetic cases were built to exercise one situation each - four feeders off one bus, two feeds that must cross, and a tie running past two intervening buses - and are not committed to this repository.

Route count falls because the bus spines are no longer counted as connections. symbol_intrusions stays at zero on miniwatt-02 and non_orthogonal at zero everywhere. The engine report gains route_conflicts, route_refusals, detoured_routes - connections whose base route was not clear - and broken_chains, which is the number of connections whose walk through the dummies could not be completed and should stay at zero.

This engine still does not finish the ABB side A document, so none of these numbers come from it.

Dot

DotRenderer no longer places a connection and then reports the coincidence. Every one of its four route shapes is now offered to DXFLineSet as an ordered list of candidates through add_first_clear_route, which places the first candidate that CoincidenceGuard finds clear and places nothing at all if none is. The unconditional add_segment, add_path, add_route_points and add_elbow_route are no longer called by this engine.

The first candidate in every list is the shape the engine drew before, so a drawing with no coincidence to resolve is unchanged. The alternatives behind it are built by one small class each:

Class What it offers
ChannelStepper channel heights, planned height first, then a coarse sweep by channel_step and a fine sweep at a quarter of it, every height kept strictly between the two ends; the midpoint if the gap is too tight for any of them
StraightCandidateBuilder the direct run between two aligned terminals, then doglegs that step out to one side and back, alternating sides by growing multiples of the side gap
LevelCandidateBuilder the elbow the rotation calls for, then the other elbow, then bows through corridors above and below the level
ElbowCandidateBuilder one elbow per offered channel height, first rising into the end terminal in its own column and then rising beside it and stepping in sideways
TerminalApproachLister the x positions a route may rise along before that sideways step, the terminal's own column first
ReturnCandidateBuilder the stub, side step, channel and drop that reaches a terminal above its own source, over every channel height and terminal approach, then with the side leg further out and the stub further down

The sideways approach is what the ABB side A document needed. Its four interlocks sit below the bus they feed, so a return route rose into BUS.in_1 along the same column that the feeder leaving BUS.out_1 drops through, and the two overlapped for the whole length of the feeder. Rising four units beside the terminal and stepping in along the bus input line clears it. That took DotRenderer from 4 recorded conflicts to none, with no refusal and no connection missing: 176 pairs, 176 routes, 0 conflicts, 0 refusals, 0 unrouted. The cost is four extra segments and 44 more drawing units out of 17,742, because only the four blocked routes changed shape. miniwatt-02 was already clear and stays clear.

A refused route would still be a connection that exists in the data and not on the paper, so the candidate lists are deliberately long and the refusal is the last resort. Every route this engine places, and every refusal it records, now carries a route_id reading START.terminal to END.terminal, so a withheld connection can be named from the record itself rather than looked up by coordinate.

Slot

SlotRenderer now asks before every line it draws. Each of its four placement sites - the feeder elbow, the straight drop between two terminals on one column, the run between two devices in a tie lane, and the tie leg between a lane and a bus - builds a list of candidate point lists and hands the whole list to add_first_clear_route. The line set places the first candidate that is clear and, if none is, places nothing and records one RouteRefusal. The engine no longer calls add_segment, add_path, add_route_points or add_elbow_route, so it can no longer put a knowingly coincident line on the paper.

Three candidate families feed that list.

  • Channel heights. For an elbow, ChannelRetrier still steps the horizontal channel by channel_step, away from the bus on an approach and alternating about the midpoint between two devices, staying strictly between the two ends. This is the family the engine already had; it is now expressed as candidates rather than as its own retry loop.
  • Sidesteps. For a run that is straight - two terminals sharing an x, or two tie devices sharing a lane - JogPlanner offers the plain line first and then detours whose middle third is offset to one side and then the other, by one column_clearance for a drop and one channel_step for a horizontal run.
  • Bus approaches. A tie leg that lands on a bus terminal on the far side of the bus from its lane - the usual case, since a tie lane hangs below the band and a bus input sits above the bus line - can rise clear of the bus end and come in from the terminal's own side. BusApproachPlanner builds those: outboard of the bus edge by approach_gap plus multiples of channel_step, over the terminal by the same ladder, then down onto it. A leg whose terminal faces its own lane is offered no such candidate, because its riser never crosses the bus.

That third family is what the two remaining conflicts needed. On miniwatt-02 the tie into SWGRBUS-001.in_2 rose from the lane at the input's own x, which is also the x of the out_3 feeder dropping out of the same bus, so the riser and the feeder ran along each other for sixteen units. The tie now rises eight units outboard of the bus end, crosses above the bus, and drops onto the input. TIENO-002 into SWGRBUS-002.in_2 is the same case mirrored, and takes the second height because the first was itself occupied. miniwatt-02 is now 33 routes, no conflicts and no refusals, with every one of its 33 connections drawn.

Side A is untouched by the change: the direct route is always the first candidate, so a leg that was already clear is placed exactly where it was. It still arranges as 161 routes with no conflicts and no refusals. Its 15 connections without a route are the nine From Side B arrows and the three UPS-bypass chains that never get a cell and sit in the leftover row; that is a placement gap, not a refusal, and this change neither fixes nor worsens it.

Layered

LayeredRenderer now asks before it draws. Every connection it places - each feeder edge and each of the two level leads of a lateral tie - goes in through DXFLineSet.add_first_clear_route, which takes a list of candidate point lists, places the first one CoincidenceGuard calls clear, and if none is clear places nothing and records a single RouteRefusal. The engine no longer calls add_segment, add_path, add_route_points or add_elbow_route, so it can no longer put a coincident line on the sheet.

The freedom it trades on is the height of the horizontal run. A feeder hop leaves its upper terminal straight down, runs sideways at one height, and drops into its lower terminal, so moving that height sideways-shifts nothing and changes only where the two stems end. RunHeightStepper lists heights starting with the first choice and then stepping away from it, alternately above and below, discarding any step that leaves the span it is given. EdgeRouteLister uses it twice for each hop: first inside the band, between the two component rows, where the drawing stays tidy, and then - only once those are exhausted - across the hop's own span, ending with the two terminal heights themselves, which draw the feeder as a plain L that leaves its terminal sideways at once. LeadRouteLister does the same for a tie lead between the neighbour terminal and the device terminal. Candidates that simplify to the same point list are listed once.

TerminalCoverChecker then reorders the list. A candidate whose stem runs over a terminal belonging to some other component - the classic case is a vertical busway where the upper tap's stem runs down the bus face across the lower tap - is pushed behind the candidates that cover no foreign terminal. It never drops a candidate, only demotes it.

The first candidate is still exactly the point list the channel planner chose, so a document that never collides draws exactly as it did before: on A-Side-direct.json the 176 routes are geometrically identical to the ones the unguarded engine produced, with zero conflicts, zero refusals and no connection missing from the sheet, and miniwatt-02 is likewise unchanged at zero and zero. The change shows up only where the old engine would have overlapped. On a corpus of 600 generated documents (6443 connections, one connection per named terminal) the planned channel alone loses 126 routes to coincidence; the alternative family recovers all 126 and refuses nothing.

The smallest case that exercises the retry is four components: a utility_source feeding a busway, and the busway's tap_1 and tap_2 each feeding a breaker_mv. The busway is drawn vertically, so both taps sit at one x. The first tap's stem descends the bus face; the second tap's planned channel would run down the same line, and every in-band height still overlaps it. The hop-span tier supplies a run at the tap's own height, which leaves the bus sideways and clears the first stem, so all three connections are drawn. With the candidate list cut back to the planned channel, that third connection is refused.

What no run height can fix is a coincidence at a shared terminal: two feeders ending on one terminal both approach it along the same line whatever their run height, so the guard reports collinear_at_terminal and the second is withheld and named. That is a topology the terminal reservation in GeneralComponent is meant to prevent, not something the router can route around.

Annealing

AnnealingRenderer places every route through add_first_clear_route, at the one site RouteCommitter.commit_plans, with the engine's own route_index as the route id. RouteCandidateFamily builds the ordered alternatives for one planned connection, the planned route always first: elbows whose channel is shifted outward from the plan by half channel_step at a time and kept inside the plan's band, then terminal steps that move the first vertical stub sideways before dropping to the channel, then six-point side steps that offset the middle third of the run. A connection routed along its own row gets the direct L and the same side steps. channel_retry_limit bounds every family.

The annealer also sees coincidence while it searches. CoincidenceCounter counts, with numpy, every meeting of two routes' vertices that is not two terminals leaving in different directions, and every vertex that lands on another route's segment; LayoutScorer charges weight_coincidence (20.0) per count, alongside the pierce, crossing, overlap, bend, length, extent and balance terms. So a collision can be relieved by moving a component, not only by rerouting a wire once the components are fixed.

Measured on the ABB side A document: 176 of 176 connections routed, 0 conflicts, 0 refusals, in 147.9 s for the arrange alone. On miniwatt-02: 0 conflicts, 0 refusals. This section was written from the agent's committed code after its session stalled; the numbers were re-measured independently.

Keeping parallel routes apart

Touching is not the only way two wires become one. CoincidenceGuard also refuses a route whose segment runs parallel to a placed segment of another route closer than parallel_clearance (2.0 drawing units) while the two overlap along their length; the conflict kind is too_close. Perpendicular passes and end-to-end runs are untouched, and a gap of exactly the clearance is allowed, so an engine whose channel step is 2.0 or more clears it without change. Engines that sweep channels in finer steps have to widen the step or skip the candidates inside the clearance.

Separated stand-ins hang beside their partner

A SeparatedUpstreamComponent or SeparatedDownstreamComponent stands in for something drawn elsewhere; it has one terminal and one connection, and its only job is to mark where that connection leaves the sheet. Every engine used to lay it out as a source or a load in its own right - nine From Side B arrows in a row across the top of the sheet, each with a route across the whole drawing to the transfer switch it belongs to.

SatellitePlacer takes that decision away from the engines. Before arrange_components runs, GeneralRenderer.arrange_with_satellites detaches every such component - it is left out of the list the engine sees and its mirror connection is lifted off the partner - and once the engine has placed everything else, the placer restores the connection, gives the stand-in its partner's scale, and hangs it a short lead beyond the partner's terminal on the axis that terminal faces, rotated so its own terminal points back at the partner. The stub between them is placed through the guard, trying leads of 6, 9, 12, 16 and 20 units before recording a refusal. is_satellite_placement_applied turns the pass off for an engine that wants to place them itself. Invariant 25 still holds: arrange_components receives a list and returns a DXFLineSet; the list is just shorter.

On side A the nine arrows now sit directly above their transfer switches as the source drawing draws them, every engine's satellite reach is the 6-unit lead, and Slot's stranded leftover row fell from twelve components to three.

Measuring a layout

examples/evaluate_layouts.py <document.json> [Engine ...] arranges the document on the plot sheet with each engine, label placement off, and tabulates:

Column Meaning
routes, confl, refus, unrtd routes placed, conflicts recorded, refusals recorded, connection pairs left without a route
cross right-angle crossings between segments of different routes
bends segments minus routes
length total wire length in drawing units
tight parallel segment pairs of different routes closer than 4 units
jog/taps bus taps whose route does not drop straight to its feeder, over all bus taps
span mean of drawn bus length over the spread of its feeders; 1.0 means the bus reaches its feeders
sat_mean, sat_max distance from a stand-in's terminal to its partner's, in units at scale 1
fill, aspect bounding box of the drawing over the diagram region, and its width over height

Measured on the label-free side A document before the per-engine work (Annealing took 132 s; Orthogonal does not finish the document):

Engine cross bends length tight jog/taps span fill aspect
Slot 90 164 18517 0 80/86 0.48 0.86 2.28
Barycentre 359 324 41882 0 85/86 0.55 0.72 1.92
Layered 96 172 24983 3 75/86 0.49 0.69 3.23
Template 290 320 28401 89 84/86 0.37 0.52 4.32
Visibility 28 75 16611 19 28/86 0.60 0.37 6.01
Dot 99 161 18605 1 37/86 0.55 0.47 4.73
Annealing 109 298 28963 0 86/86 0.66 0.69 1.84

Two things the table says at once. Almost every bus tap jogs, because every engine but Visibility sizes a bus by its terminal count while its feeders spread five times wider - invariant 47 now lets any engine lengthen a bus to span the feeders it places. And the drawings are two to six times wider than tall while the diagram region is about 1.8, so the fit is limited by width and Visibility, the one engine with straight taps, fills only 37 percent of the region because its wide buses sit side by side instead of on separate rows.

Rotation

IEEE 315 does not tie a symbol's meaning to its orientation on the page, so a series device may be turned to align with the conductor it sits in. US one-lines draw power top to bottom, so most runs are vertical, but bus ties, cross-ties and taps off a horizontal bus run sideways, and a device in one of those is drawn sideways too.

Every component carries rotation, in degrees counter-clockwise, defaulting to 0.0. Only a class that sets is_rotatable = True accepts a value, and only one of three quarter turns: 0.0, 90.0 or -90.0, held in allowed_rotations. Anything else is refused, so a renderer cannot drift onto a diagonal, and 180 degrees in particular cannot silently reverse the power flow of a symmetric two-terminal device:

breaker.apply_rotation(90.0)     # a molded-case breaker in a horizontal run
breaker.apply_rotation(45.0)     # raises: 45.0 is not one of (0.0, 90.0, -90.0)
transformer.apply_rotation(90.0) # raises: XFMR-001 may not be rotated

The test for whether a class may rotate is whether its glyph holds a mark that has to be read the right way up, or is only a shape in the current path. Nineteen classes pass it:

CAP, CBLV, CBMV, CBMVDO, CONT, CT, CTM, DISC, FSW, FU, LBS, MCCB, NOPT, RCL, RCT, SEC, SPL, TIE, TIENO.

Four groups are held upright, each for its own reason:

  • A readable mark. The delta and grounded-wye winding marks, the AC and DC waveforms inside a converter box, and the letters in MTR, RLY, GFD and UPS are read to know what the device is, so the glyph cannot turn.
  • One terminal only. Every source and every load is its own pictograph and ends a run rather than sitting in one, so there is no conductor to align to.
  • Sides that must stay apart. ATS, ATSI, STS and UPSD have to keep the normal inlet, the emergency inlet and the bypass distinguishable, and the enclosures SWGR, PNL, RPP and BUSWAY draw an internal arrangement.
  • Bus-like classes. A bus puts its inputs on one side of the drawn line and its outputs on the other, along a length that runs across the page, so turning one is a larger change than turning a symbol.

Text never turns. rotation moves where a label or an internal legend such as TIENO's N.O. is anchored, but the letters stay horizontal. Rotation reaches the geometry through GeneralComponent.place_point, which every drawing helper already calls, and draw_body_arc also adds the angle to its start and end bearings.

Extents follow the rotation. measure_local_half_width, measure_local_half_height, measure_width and measure_height return the axis-aligned box of the turned symbol, so a device at ninety degrees reports its height as its width and the instance label clears it instead of landing on top of it:

CBLV symbol box 5.6 wide by 16 tall
   0 deg   label anchored at local x =  4.800
  90 deg   label anchored at local x = 10.000

A quarter turn can leave two terminals neither level nor plumb, which DXFLineSet.add_segment refuses. add_route_points is the way to draw such a run: it plans the whole route first, inserts a corner wherever a hop is sloped, turning after the direction the route was already travelling, checks the plan, and only then commits. Nothing is written if the plan is bad, unlike add_path, which commits every hop before the one that fails.

measure_base_half_width and measure_base_half_height keep the upright numbers for anything that needs them. Every renderer and the sheet reach the extents through these methods rather than through symbol_height, so a rotated component is measured correctly wherever bounds, obstacle boxes or node sizes are computed. At a rotation of 0.0 every one of them returns exactly what it returned before, so no existing drawing moves.

Six of the eight layout algorithms now set a rotation, and every one of them uses it for the same thing: a bus tie between two buses that belong on one level. DotRenderer, SlotRenderer, VisibilityRenderer, TemplateRenderer and AnnealingRenderer turn all three ties of the miniwatt-02 switchgear lineup; LayeredRenderer turns the one whose buses it can level. OrthogonalRenderer deliberately turns nothing: it prescribes a north or south heading for every device port, so its flow solver already reaches zero bends and a quarter turn has nothing to remove. BarycentreRenderer, the default, is not part of this work.

What each of them found is that the gain is in the ranking, not the glyph. Turning a tie on its own only adds bends; the drawing improves once the tie's two buses sit on one level, and the quarter turn is what makes that level run drawable.

A level run sits at the height where an upright component's label is lettered, so a renderer that lays one names the components it passes in raised_label_ids and GeneralRenderer lifts those labels clear. A turned component's own label is lifted without being named.

DotRenderer carries one further tidy-up, on by default and switched off with is_alignment_pass_applied = False. Dot balances a parent over its children, so a run whose parent and child sit in different columns is wired through a channel and gains two bends it does not need. The pass groups every parent-child run into one column class through OffsetClasses, keeping the offset between a bus terminal and the device below it, then solves one x per class by longest path with ConstraintGraphSolver. Dot's own column is a lower bound in that solve, so a component in no run stays where dot put it rather than being repacked. A join is refused where it would drive two components in one row onto the same x, and every component named in raised_label_ids is pinned so a level tie run is never pulled out of shape.

The pass keeps its result only if it pays. LayoutAuditor measures both layouts; a rise in symbol pierces or sloped segments rejects the aligned one outright, and otherwise the two are compared on `crossings * crossing_penalty

  • segments, with conductor length as the tiebreak. On miniwatt-02that trade is 69 segments down to 59 for one extra crossing, which the default penalty of 4.0 accepts; onminiwatt-01the bend count ties and the aligned layout wins on length alone. Raisecrossing_penalty` to make the pass more conservative.

python3 examples/build_symbol_sheet.py marks each rotatable class with a small pink circular arrow under its instance id.

Label offsets

Every component carries label_xy_offset, a page-space nudge applied to its instance label, defaulting to (0.0, 0.0). It is stored per instance and scales with the component, so an offset means the same thing at every drawing scale:

breaker.apply_label_offset(-8.0, 5.0)   # eight left, five up
breaker.clear_label_offset()            # back to the class default
breaker.is_label_offset_set()

The offset is in page space, not symbol-local space, because a label never rotates: a local offset would have to be turned back on every read. It is added on top of the anchor the renderer already chose, so it composes with the lift that raised_label_ids applies rather than replacing it.

LabelPlacer sets the offsets. It runs once inside render, after the layout has settled and the conductors are routed, because where a label can go depends only on the finished drawing and not on the algorithm that produced it. For each component it tries eight positions — right, above, below, left, the two left diagonals and two further-out variants — scores each against the symbol boxes, the routed segments and the labels already placed, and writes the winner into label_xy_offset. Components are placed top row first so the busiest part of the sheet is settled before the rest.

A renderer contributes what it knows through list_label_preferences, not through its own placement code. The base class prefers above for a turned component and for anything a renderer names in level_run_ids, which is how the renderers that draw a level bus tie say that a label must clear the run. A preference only decides between positions that collide equally, so it never puts a label on top of a conductor.

Measured on miniwatt-02, counting a label as colliding when it overlaps a conductor within 0.3 label heights, a symbol, or another label:

renderer on a wire on a symbol on a label
barycentre 6 → 0 6 → 0 0 → 0
layered 2 → 0 0 → 0 0 → 0
slot 4 → 0 0 → 0 0 → 0
visibility 7 → 0 0 → 0 0 → 0
orthogonal 12 → 6 0 → 1 0 → 0
annealing 7 → 0 0 → 0 0 → 0
template 6 → 0 0 → 0 0 → 0
dot 8 → 0 1 → 0 0 → 0

Seven of the eight clear every collision. OrthogonalRenderer halves them and gains one symbol overlap: at 84 units wide and 285 tall it simply has nowhere to put a label, which is its aspect ratio rather than a placement failure.

Set is_label_placement_applied = False to letter every label to the right and leave the offsets alone. Placement runs after the fit loop, so a label offset does not feed back into the scale the drawing was fitted at; the fit uses the default reach, which is the conservative one.

Rendering onto the plot sheet

SLDRenderer puts a diagram on the same bordered sheet Mini-Watt's one-lines use, so the output is a drawing rather than a bare diagram:

python3 examples/build_sld_sheet.py path/to/miniwatt-02.json

The sheet is ANSI D, 34 by 22 inches at 100 units to the inch, divided the same way: a 0.75 inch margin, a 250-unit title-block strip down the right edge, and the remaining content split into a six by five module grid. SheetLayout computes each region, and its numbers match Mini-Watt's exactly - border (75, 75, 3325, 2125), diagram (95, 505, 3055, 1715), notes in the top-right module, legend across the bottom band.

What lands on the sheet:

  • The border and the corner sheet number.
  • The title block: project, client and location; project and drawing number, date, scale, design revision and generation date; designed, drawn, checked and approved initials; a signature zone; the sheet title; and the sheet number set large at the foot of the strip. An undeclared field prints ---- rather than repeating its own label.
  • A PRELIMINARY / NOT FOR CONSTRUCTION stamp until SheetConfig.stage is set to final.
  • General notes and keynotes in the top-right module.
  • SCALE: NONE, since a one-line has no measurable scale.
  • The legend across the bottom band: one glyph per component class actually used, its name spelled out beside it, then the nomenclature note and the abbreviation list on the right.

SLDRenderer does not lay the diagram out itself. It composes a Renderer, measures the diagram region, and asks that renderer to fit inside it:

line_set = self.renderer.arrange_components(components, max_width, max_height)

Renderer.arrange_components now takes optional max_width and max_height. When either is given it measures the arranged bounds, including the reach of every instance label, and if the result overflows it reduces the scale by the overflow ratio and arranges again, up to fit_attempt_limit times. Gaps, channels and row pitch all scale with it, so the whole drawing shrinks uniformly rather than just the symbols. SLDRenderer also sets renderer.base_scale first, so a small diagram grows to fill the region instead of sitting tiny in one corner.

Line weights

Widths are carried as DXF lineweights, which are plot properties in hundredths of a millimetre rather than geometry. Each role declares the width it should plot at when the diagram is drawn at 1:1, one drawing unit to one millimetre:

Role Declared lineweight
Bus lines, sheet border 0.70 mm
Component symbols, connection routes 0.35 mm
Instance labels, sheet text 0.25 mm

A lineweight is not geometry, so on its own it would not follow the drawing. That is wrong on a plot sheet, where the diagram is shrunk to fit: one drawing unit is 1/100 inch, so 0.254 mm, and a dense sheet is fitted at a drawn scale near 1, which put a 0.35 mm stroke on a symbol only 2.8 mm tall. The same symbol on the review sheet is 10 mm tall, so the identical stroke read as four times heavier on the sheet than in the symbol library.

LineweightScaler closes that gap. After the diagram is drawn, every entity on the SYMBOLS, CONNECTIONS and LABELS layers has its lineweight multiplied by the drawing's plot factor and snapped to the nearest lineweight AutoCAD accepts, with a floor of 0.09 mm so nothing disappears. The factor is GeneralRenderer.lineweight_scaling, which is 1.0, so a bare diagram is untouched; SLDRenderer overrides it with millimetres per drawing unit times the drawn diagram scale, which is the ratio between what the symbol was designed at and what it plots at. Two worked sheets:

Sheet Components Drawn scale Symbols Buses Border
miniwatt-02 31 3.31 0.30 mm 0.60 mm 0.70 mm
ABB side A 171 1.11 0.09 mm 0.20 mm 0.70 mm

The sheet's own furniture - border, title block, notes, keynotes, sheet text - is drawn at sheet scale and never scaled, so the frame stays at its full 0.70 mm however dense the diagram inside it is. The legend is the one place where the two meet: its symbols sit on the SYMBOLS layer at a fixed scale of 3.0, so on a dense sheet they letter at the diagram's thinner width.

The SVG export asks the ezdxf frontend for LineweightPolicy.ABSOLUTE, so the exported strokes honour the scaled weights; the DXF carries the same numbers for AutoCAD to plot.

Sheet colours and lettering

SLDRenderer plots the same way Mini-Watt's own one-line does: black ink on a white ground, lettered in the same face.

Property Value Where Mini-Watt sets the same thing
Sheet background white a full-sheet fill="#ffffff" rect in its SVG writer
Lines and text black ACI 7 by layer, which resolves to black over white
Text style MINIWATT, arial.ttf, width factor 0.8 doc.styles.add("MINIWATT", font="arial.ttf") in its DXF writer
Text heights 9.375 / 12.5 / 25 units TEXT_MIN / TEXT_TYPICAL / TEXT_TITLE

The white ground comes from background_policy = BackgroundPolicy.WHITE on SLDRenderer. Because entities are ACI 7, ezdxf resolves them against that background and draws them black; over the default dark ground the same entities come out white. Only SLDRenderer sets the policy, so a bare diagram from any other renderer keeps the ezdxf default.

TextStyleBuilder registers the two styles on every document, and GeneralRenderer.draw_one_label and SLDRenderer.draw_sheet_text ask for MINIWATT by name. So every instance label and every piece of sheet text is lettered in Arial at a 0.8 width factor, in the DXF and in the SVG alike. MINIWATT-BOLD is registered for parity with Mini-Watt; nothing in this library draws bold yet.

Text drawn inside a symbol is deliberately left on Standard: the WH of a meter, the 51 of a relay, the UPS of a supply and the N.O. of an open tie are symbol geometry from IEEE 315, not sheet lettering, and they belong to the glyph rather than to the drawing's type.

The exported page

The sheet plots at true size: one drawing unit is 1/100 inch, so 0.254 mm, and SLDRenderer exports with fit_page=False at that scale onto a 34 by 22 inch page. The sheet's own 0.75 inch margin then shows as an equal offset on all four sides, and the drawing measures what it says it measures.

The default is different, and deliberately so: GeneralRenderer exports with ezdxf's own settings, which scale the content to fill the page. That suits a bare diagram, which has no sheet and no margin of its own, but on a sheet it does two visible things wrong. It stretches the border to whichever page edge the content aspect hits first, so the offset from the page is uneven - on an ANSI D sheet the border touched the left and right edges while a gap of about a third of the true margin was left above and below. And a border stroke lying exactly on the page edge loses the outer half of its width to the viewBox, so the left and right borders plotted at half the thickness of the top and bottom ones. Plotting at true size fixes both at once, because nothing then sits on the page edge.

Two honest differences from Mini-Watt remain. Its SVG keeps live, selectable <text> carrying a font-family stack, while ezdxf's SVG backend converts text to filled outlines - the shapes match, the selectability does not. And its stack prefers osifont, a single-stroke drafting face, which falls back to Arial when it is not installed; this library asks for Arial directly.

Choosing a layout algorithm

arrange_components is the only layout decision point, so an algorithm is just a GeneralRenderer that implements it. Eight ship with the library:

Class Algorithm
BarycentreRenderer depth rows, one upstream barycentre pass (the default)
LayeredRenderer full four-phase layered Sugiyama, with a rotatable cross-tie lifted out of the ranking so its two neighbours share one band and the tie is turned sideways into the level run between them
SlotRenderer slot-based, semi-automatic, hand-overridable; a same-band bus tie leaves the slots and is drawn as a horizontal run of quarter-turned devices
VisibilityRenderer visibility representation, buses stretched to span their children, bus ties turned sideways into a horizontal run between two bus bars on one row
OrthogonalRenderer Tamassia topology-shape-metrics; every face is tried as the outside and bends are minimised exactly by minimum-cost flow, then each rotatable device is turned to face the way its own wires left and the sheet is re-solved
AnnealingRenderer simulated-annealing post-pass over a snapped grid, with the quarter turn of a device as a search variable
TemplateRenderer hand-drafted block templates placed as rigid rectangles, a slot carrying the quarter turn it is drafted at, so a tied bus row lays its ties down flat along the run
DotRenderer graphviz dot placement, with every same-level tie turned a quarter turn and drawn as a horizontal cross-tie between its two buses, then an optional alignment pass that pulls each parent-child run onto one column

SLDRenderer takes whichever one you pass and defaults to default_renderer_class:

SLDRenderer()                                     # BarycentreRenderer
SLDRenderer(renderer=LayeredRenderer())           # any of the eight

It only needs arrange_components(components, max_width, max_height) from its engine, so it measures, grows and shifts the result itself. Compare them all on one document with:

python3 examples/compare_layouts.py path/to/miniwatt-02.json

Asking an algorithm to explain itself

Every renderer can print an account of its own algorithm, so you can read what a layout does without reading its code:

python3 examples/describe_layouts.py                 # all of them
python3 examples/describe_layouts.py DotRenderer     # one of them
LayeredRenderer().describe_algorithm()

describe_algorithm is declared empty on GeneralRenderer, so it is optional: a new renderer that does not implement it prints nothing and returns None. A renderer that does implement it builds an AlgorithmDescription - a title, a plain-language summary, numbered steps, honest notes on the limits, and references to the papers or standards the algorithm comes from - and calls print_to_console(). The text is module-level data at the top of each renderer file, so the method itself stays short.

SLDRenderer is the one special case. It runs no layout of its own, so it describes what it does to the sheet and then calls describe_algorithm on whichever engine it was given.

Swapping the arrangement

arrange_components is the only layout decision point. Subclass GeneralRenderer (or Renderer) and return your own DXFLineSet to try a different approach; everything else stays the same. Renderer currently places components in rows by longest path from a source, orders each row by the average position of its upstream neighbours, and routes each connection through a horizontal channel between the two rows.

Known limit of that arrangement: a connection may cross a bus or another component, since routing has no obstacle awareness yet.

See invariants.txt for the rules the code holds to and TESTS.md for the tests that would cover it.

Slot layout quality

Two SlotRenderer gaps are closed:

A bus's length used to come only from its tap count (invariant 47's count-times-distance formula), which is usually far narrower than the spread its feeder columns actually need, so most taps jogged sideways to reach their column. measure_cell_columns now stretches each bus, once its feeder columns are known, to the span its widest feeder group needs (stretch_bus_to_feeder_spread, still inside the class's cap and floor), so a tap's own terminal position lines up with its column and the jog disappears.

A dual-feed device's second input (a UPS's bypass_in, fed by its own breaker off the same bus as the main feed) used to have nowhere to go: CellTracer walked straight through any input terminal to the device's output, so the second feed's chain re-entered the same shared downstream run as the main feed and got discarded as already claimed. CellTracer.walk_chain now stops at a component's destination as soon as it arrives through any input terminal after the first one declared on that class (is_secondary_input), so the second feed becomes its own feeder cell that ends at the device rather than passing through it, and its breaker is placed and routed like any other tap.

Layered layout quality

LayeredRenderer sizes a length-variable bus twice. resize_for_connections gives it a starting length from its tap count alone, before any feeder is placed. Once CoordinateAssigner and ChainStraightener have settled every node's x position, BusSpanStretcher runs a second pass: for each real bus node it reads the x position of the column each attached feeder already straightened onto, and if the spread between the outermost columns is wider than the bus's current length, it regrows the bus to that spread with apply_length, still clamped inside the bus class's own minimum_length and upper_length_limit (invariant 47). A bus already as wide as its feeders is left alone. This shortens the diagonal a tap's lead has to run to reach its feeder without moving the bus's own position, so it never has to renegotiate clearance against a sibling on the same layer.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages