--fanout: commit a way out for every fine-pitch pin before routing (work in progress)
This commit is contained in:
@@ -119,6 +119,8 @@ class Via(Item):
|
||||
shape: object = None
|
||||
#: drill diameter in board units, when the padstack name gives it
|
||||
drill: int = 0
|
||||
#: placed by the router's fan-out, not on the board yet: written out
|
||||
generated: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -132,6 +134,8 @@ class Trace(Item):
|
||||
layer: int = 0
|
||||
half_width: int = 0
|
||||
corners: list[IntPoint] = field(default_factory=list)
|
||||
#: placed by the router's fan-out, not on the board yet: written out
|
||||
generated: bool = False
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return len(self.corners) < 2
|
||||
|
||||
+16
-2
@@ -30,8 +30,11 @@ from freeroute.ses import write_ses
|
||||
#: which optional flags each engine actually implements. Anything not listed for
|
||||
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
|
||||
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
|
||||
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
|
||||
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
|
||||
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback", "fanout"}),
|
||||
"exact": frozenset(
|
||||
{"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access",
|
||||
"rollback", "fanout"}
|
||||
),
|
||||
"room": frozenset({"shove", "pack"}),
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ _FLAG_NAMES = {
|
||||
"promote": "--promote-failures",
|
||||
"access": "--access-cost",
|
||||
"rollback": "--no-rollback",
|
||||
"fanout": "--fanout",
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +187,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="grid and exact engines: least distance between the edges of any two via "
|
||||
"holes, of one net or two (KiCad's min_hole_to_hole; default 0.25)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fanout",
|
||||
action="store_true",
|
||||
help="grid and exact engines: before routing, give every fine-pitch pin a stub "
|
||||
"outward past its pad row (and a via to its plane, for a plane net), committed "
|
||||
"as copper so later routes cannot wall it in",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
type=int,
|
||||
@@ -213,6 +224,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
("promote", args.promote_failures is not None),
|
||||
("access", args.access_cost is not None),
|
||||
("rollback", not args.rollback),
|
||||
("fanout", args.fanout),
|
||||
)
|
||||
if asked
|
||||
]
|
||||
@@ -248,6 +260,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"heuristic_weight": args.heuristic_weight,
|
||||
"access_cost": args.access_cost or 0.0,
|
||||
"hole_to_hole_mm": args.hole_to_hole,
|
||||
"fanout": args.fanout,
|
||||
"rollback": args.rollback,
|
||||
}
|
||||
elif args.engine == "grid":
|
||||
@@ -259,6 +272,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"resolution": 1 if args.grid_resolution is None else args.grid_resolution,
|
||||
"access_cost": args.access_cost or 0.0,
|
||||
"hole_to_hole_mm": args.hole_to_hole,
|
||||
"fanout": args.fanout,
|
||||
"rollback": args.rollback,
|
||||
}
|
||||
if args.engine == "room":
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Fan out fine-pitch pins before routing: commit each pin's way out as copper.
|
||||
|
||||
On a dense board the pins of a fine-pitch package get walled in: their own
|
||||
neighbours' pads on either side, and other nets' copper that got there first
|
||||
across the only way out. Reserving the way out did not help (the reserved
|
||||
cells still had to be routed, and lost to congestion there); committing it
|
||||
does, which is what a person does by hand.
|
||||
|
||||
Each fine-pitch pin of a perimeter package (QFP, QFN, LGA: pads on the edge,
|
||||
none inside) gets a straight stub outward, perpendicular to its side of the
|
||||
package, just past its pad row. A pin whose net has an inner plane also gets
|
||||
a via at the stub's end, staggered between neighbouring pins so their holes
|
||||
keep their spacing, and so reaches the plane with nothing left to route.
|
||||
Every stub and via is checked exactly against pads, keepouts, the board edge,
|
||||
copper already on the board and earlier fan-out, and left out where it would
|
||||
not fit. The result goes on the board as fixed copper of its net (marked
|
||||
``generated``), so the router starts from the stub's end or the via, and the
|
||||
session writes it out with the routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
from freeroute.board.board import BasicBoard
|
||||
from freeroute.board.items import Pin, Trace, Via
|
||||
from freeroute.geometry import IntBox, IntPoint
|
||||
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
|
||||
|
||||
# a package is fanned out when it has this many pins and they are this close
|
||||
_MIN_PINS = 8
|
||||
_FINE_PITCH_FACTOR = 1.2 # neighbour centres within this many (trace + 2 clearance)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FanoutRules:
|
||||
trace_width: int
|
||||
clearance: int
|
||||
via_half: int
|
||||
via_drill: int
|
||||
hole_to_hole: int
|
||||
edge_clearance: int
|
||||
signal_layers: list[int]
|
||||
via_padstack: str
|
||||
|
||||
|
||||
def plan_fanout(board: BasicBoard, rules: FanoutRules) -> list:
|
||||
"""The stubs and vias to put on the board before routing."""
|
||||
half = max(rules.trace_width // 2, 1)
|
||||
# a net may have several plane zones (a main plane and an island)
|
||||
planes: dict[int, list] = {}
|
||||
for z in board.get_conduction_areas():
|
||||
if z.net_nos and not board.layer_structure.arr[z.layer].is_signal:
|
||||
planes.setdefault(z.net_nos[0], []).append(z)
|
||||
placed = _Placed(board, rules)
|
||||
items: list = []
|
||||
for pins in _packages(board, rules).values():
|
||||
centre = IntPoint(
|
||||
sum(p.location.x for p in pins) // len(pins), sum(p.location.y for p in pins) // len(pins)
|
||||
)
|
||||
for k, pin in enumerate(sorted(pins, key=lambda p: (p.location.x, p.location.y))):
|
||||
net = pin.net_nos[0] if pin.net_nos else None
|
||||
if net is None:
|
||||
continue
|
||||
layer = next(layer for layer in pin.layers if layer in rules.signal_layers)
|
||||
direction = _outward(pin.location, centre)
|
||||
reach = _pad_reach(pin, direction) + rules.clearance + half + rules.trace_width
|
||||
end = _step(pin.location, direction, reach)
|
||||
plane = planes.get(net)
|
||||
if plane is not None:
|
||||
# stagger the via of every other pin along the side outward
|
||||
pitch = 2 * rules.via_half + rules.hole_to_hole
|
||||
options = [reach + rules.via_half + rules.clearance + (k % 2) * pitch,
|
||||
reach + rules.via_half + rules.clearance + ((k + 1) % 2) * pitch]
|
||||
for d in options:
|
||||
at = _step(pin.location, direction, d)
|
||||
if not any(z.polygon is not None and z.polygon.contains(at) for z in plane):
|
||||
continue
|
||||
if placed.stub_fits(pin.location, at, layer, net, half) and placed.via_fits(at, net):
|
||||
items.append(placed.add_stub(pin.location, at, layer, net, half))
|
||||
items.append(placed.add_via(at, net))
|
||||
break
|
||||
continue
|
||||
if placed.stub_fits(pin.location, end, layer, net, half):
|
||||
items.append(placed.add_stub(pin.location, end, layer, net, half))
|
||||
return items
|
||||
|
||||
|
||||
def _packages(board: BasicBoard, rules: FanoutRules) -> dict[str, list[Pin]]:
|
||||
"""Fine-pitch perimeter packages: many single-layer pins, close together."""
|
||||
by_ref: dict[str, list[Pin]] = {}
|
||||
for pin in board.get_pins():
|
||||
if pin.shape is None or not pin.component_ref:
|
||||
continue
|
||||
on = [layer for layer in pin.layers if layer in rules.signal_layers]
|
||||
if len(on) != 1:
|
||||
continue # through-hole pins change layer for free
|
||||
by_ref.setdefault(pin.component_ref, []).append(pin)
|
||||
fine = _FINE_PITCH_FACTOR * (rules.trace_width + 2 * rules.clearance)
|
||||
out = {}
|
||||
for ref, pins in by_ref.items():
|
||||
if len(pins) < _MIN_PINS:
|
||||
continue
|
||||
close = sum(
|
||||
1 for p in pins
|
||||
if min(math.hypot(p.location.x - q.location.x, p.location.y - q.location.y)
|
||||
for q in pins if q is not p) < fine * 2
|
||||
)
|
||||
if close >= len(pins) // 2:
|
||||
out[ref] = pins
|
||||
return out
|
||||
|
||||
|
||||
def _outward(point: IntPoint, centre: IntPoint) -> tuple[int, int]:
|
||||
"""Away from the package centre, along the nearer axis (its side's normal)."""
|
||||
dx, dy = point.x - centre.x, point.y - centre.y
|
||||
if abs(dx) >= abs(dy):
|
||||
return (1 if dx >= 0 else -1, 0)
|
||||
return (0, 1 if dy >= 0 else -1)
|
||||
|
||||
|
||||
def _pad_reach(pin: Pin, direction: tuple[int, int]) -> int:
|
||||
"""How far the pad reaches from its centre in ``direction``."""
|
||||
box = pin.shape.bounding_box()
|
||||
if direction[0] > 0:
|
||||
return box.ur.x - pin.location.x
|
||||
if direction[0] < 0:
|
||||
return pin.location.x - box.ll.x
|
||||
if direction[1] > 0:
|
||||
return box.ur.y - pin.location.y
|
||||
return pin.location.y - box.ll.y
|
||||
|
||||
|
||||
def _step(point: IntPoint, direction: tuple[int, int], distance: float) -> IntPoint:
|
||||
return IntPoint(point.x + round(direction[0] * distance), point.y + round(direction[1] * distance))
|
||||
|
||||
|
||||
class _Placed:
|
||||
"""Copper a fan-out stub or via has to clear, growing as fan-out is placed."""
|
||||
|
||||
def __init__(self, board: BasicBoard, rules: FanoutRules) -> None:
|
||||
self.board, self.rules = board, rules
|
||||
self.copper: list[tuple[IntBox, frozenset[int], int]] = [] # box, layers, net
|
||||
for pin in board.get_pins():
|
||||
if pin.shape is not None:
|
||||
net = pin.net_nos[0] if pin.net_nos else -1
|
||||
self.copper.append((pin.shape.bounding_box(), frozenset(pin.layers), net))
|
||||
for net, layers, box in board.get_fixed_copper():
|
||||
self.copper.append((box, layers, net))
|
||||
self.keepouts = [
|
||||
(tile.bounding_box(), o.layer, o.blocks_wires, o.blocks_vias, o.clearance)
|
||||
for o in board.get_obstacle_areas() for tile in o.tiles
|
||||
]
|
||||
self.holes: list[tuple[IntPoint, int]] = [] # centre, radius
|
||||
for item in board.get_items():
|
||||
if isinstance(item, Via) and item.fixed:
|
||||
self.holes.append((item.location, (item.drill or rules.via_drill) // 2))
|
||||
for pin in board.get_pins():
|
||||
if pin.shape is not None and sum(1 for layer in pin.layers if layer in rules.signal_layers) > 1:
|
||||
b = pin.shape.bounding_box()
|
||||
self.holes.append((pin.location, min(b.ur.x - b.ll.x, b.ur.y - b.ll.y) // 2))
|
||||
|
||||
def _clear(self, box: IntBox, layers: frozenset[int], net: int, kind: str) -> bool:
|
||||
grown = box.offset(self.rules.clearance)
|
||||
for other, other_layers, other_net in self.copper:
|
||||
if other_net != net and layers & other_layers and other.overlaps(grown):
|
||||
return False
|
||||
for kbox, klayer, wires, vias, needs in self.keepouts:
|
||||
if klayer not in layers or not (wires if kind == "wire" else vias):
|
||||
continue
|
||||
if kbox.overlaps(grown if needs else box):
|
||||
return False
|
||||
outline = self.board.outline
|
||||
return outline is None or outline.box_clear(box, self.rules.edge_clearance)
|
||||
|
||||
def stub_fits(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> bool:
|
||||
boxes = list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]
|
||||
return all(self._clear(box, frozenset({layer}), net, "wire") for box in boxes)
|
||||
|
||||
def via_fits(self, at: IntPoint, net: int) -> bool:
|
||||
h = self.rules.via_half
|
||||
box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h)
|
||||
if not self._clear(box, frozenset(self.rules.signal_layers), net, "via"):
|
||||
return False
|
||||
mine = self.rules.via_drill // 2
|
||||
return all(
|
||||
math.hypot(c.x - at.x, c.y - at.y) >= mine + r + self.rules.hole_to_hole
|
||||
for c, r in self.holes
|
||||
)
|
||||
|
||||
def add_stub(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> Trace:
|
||||
for box in list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]:
|
||||
self.copper.append((box, frozenset({layer}), net))
|
||||
return Trace(net_nos=[net], fixed=True, layer=layer, half_width=half, corners=[a, b],
|
||||
generated=True)
|
||||
|
||||
def add_via(self, at: IntPoint, net: int) -> Via:
|
||||
h = self.rules.via_half
|
||||
box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h)
|
||||
layers = list(self.rules.signal_layers)
|
||||
self.copper.append((box, frozenset(layers), net))
|
||||
self.holes.append((at, self.rules.via_drill // 2))
|
||||
return Via(net_nos=[net], fixed=True, padstack_name=self.rules.via_padstack,
|
||||
location=at, layers=layers, shape=box, drill=self.rules.via_drill,
|
||||
generated=True)
|
||||
@@ -1360,7 +1360,7 @@ class GridRouter:
|
||||
elif isinstance(item, Trace) and not item.is_empty():
|
||||
boxes = list(PolylineShape(Polyline(item.corners), item.half_width).tiles())
|
||||
boxes += [corner_box(e, item.half_width) for e in (item.corners[0], item.corners[-1])]
|
||||
piece = _FixedPiece([item.layer], boxes)
|
||||
piece = _FixedPiece([item.layer], boxes, trace=item)
|
||||
else:
|
||||
continue
|
||||
cache.setdefault(item.net_nos[0], []).append(piece)
|
||||
@@ -1482,12 +1482,13 @@ def _fixed_joins_zone(piece, zone, plane: bool) -> bool:
|
||||
class _FixedPiece:
|
||||
"""One track or via already on the board, as connectivity."""
|
||||
|
||||
__slots__ = ("layers", "boxes", "via")
|
||||
__slots__ = ("layers", "boxes", "via", "trace")
|
||||
|
||||
def __init__(self, layers, boxes, via=None) -> None:
|
||||
def __init__(self, layers, boxes, via=None, trace=None) -> None:
|
||||
self.layers = frozenset(layers)
|
||||
self.boxes = boxes
|
||||
self.via = via # the Via item, for a via
|
||||
self.trace = trace # the Trace item, for a track
|
||||
|
||||
|
||||
def _connected_groups(
|
||||
@@ -1548,11 +1549,28 @@ def _connected_groups(
|
||||
groups[root][1].append(zone)
|
||||
for k, piece in enumerate(fixed):
|
||||
root = find(base + k)
|
||||
if piece.via is not None and root in groups:
|
||||
if root not in groups:
|
||||
continue
|
||||
if piece.via is not None:
|
||||
groups[root][0].append(_via_end(piece.via))
|
||||
elif piece.trace is not None:
|
||||
# a track's ends are places a route may start: a fan-out stub's
|
||||
# far end is outside the pad row the pin sat walled in by
|
||||
for end in (piece.trace.corners[0], piece.trace.corners[-1]):
|
||||
groups[root][0].append(_trace_end(piece.trace, end))
|
||||
return [groups[r] for r in order]
|
||||
|
||||
|
||||
def _trace_end(trace, end: IntPoint) -> Pin:
|
||||
"""An end of a fixed track as a place a route may start or end."""
|
||||
from freeroute.geometry.polyline import corner_box
|
||||
|
||||
return Pin(
|
||||
net_nos=list(trace.net_nos), fixed=True, name="track", component_ref="",
|
||||
location=end, layers=[trace.layer], shape=corner_box(end, trace.half_width),
|
||||
)
|
||||
|
||||
|
||||
def _via_end(via) -> Pin:
|
||||
"""A fixed via as a place a route may start or end (not a pad to route)."""
|
||||
return Pin(
|
||||
|
||||
@@ -88,6 +88,38 @@ def _dsn_units_per_um(dsn: DsnBoard) -> float:
|
||||
return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0)
|
||||
|
||||
|
||||
def _place_fanout(board, dsn, scale, layers, width, clearance, edge, hole_to_hole_mm) -> None:
|
||||
"""Put the fan-out's stubs and vias on the board, before routing."""
|
||||
from .fanout import FanoutRules, plan_fanout
|
||||
|
||||
via_half = _via_half_board(dsn, scale) or max(width // 2, 1)
|
||||
rules = FanoutRules(
|
||||
trace_width=width,
|
||||
clearance=clearance,
|
||||
via_half=via_half,
|
||||
via_drill=_via_drill_board(dsn, scale) or 2 * via_half,
|
||||
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
|
||||
edge_clearance=edge or 0,
|
||||
signal_layers=layers or [i for i, layer in enumerate(dsn.layers) if layer.is_signal],
|
||||
via_padstack=_via_padstack(dsn),
|
||||
)
|
||||
for item in plan_fanout(board, rules):
|
||||
board.insert(item)
|
||||
|
||||
|
||||
def _add_generated(result, board) -> None:
|
||||
"""The fan-out's copper goes into the session with the routes."""
|
||||
from freeroute.board.items import Trace, Via
|
||||
|
||||
for item in board.get_items():
|
||||
if not getattr(item, "generated", False) or not item.net_nos:
|
||||
continue
|
||||
if isinstance(item, Trace):
|
||||
result.add_wire(item.net_nos[0], item.layer, list(item.corners))
|
||||
elif isinstance(item, Via):
|
||||
result.add_via(item.net_nos[0], item.location)
|
||||
|
||||
|
||||
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
|
||||
"""Half the via pad's size in board units, from the DSN's via padstack.
|
||||
|
||||
@@ -119,6 +151,7 @@ def route_dsn_board(
|
||||
route_first: frozenset | None = None,
|
||||
access_cost: float = 0.0,
|
||||
hole_to_hole_mm: float = 0.25,
|
||||
fanout: bool = False,
|
||||
rollback: bool = True,
|
||||
) -> tuple[RouteResult, int, list[str]]:
|
||||
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
|
||||
@@ -130,6 +163,9 @@ def route_dsn_board(
|
||||
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
|
||||
width_board = round(_rule_width_dsn(dsn) * scale)
|
||||
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
|
||||
if fanout:
|
||||
_place_fanout(board, dsn, scale, layers, width_board, clearance_board,
|
||||
edge_clearance_board, hole_to_hole_mm)
|
||||
result = route_board(
|
||||
board,
|
||||
resolution=resolution,
|
||||
@@ -148,6 +184,7 @@ def route_dsn_board(
|
||||
via_drill=_via_drill_board(dsn, scale),
|
||||
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
|
||||
)
|
||||
_add_generated(result, board)
|
||||
return result, scale, [layer.name for layer in dsn.layers]
|
||||
|
||||
|
||||
@@ -197,6 +234,7 @@ def build_routing_result(
|
||||
route_first: frozenset | None = None,
|
||||
access_cost: float = 0.0,
|
||||
hole_to_hole_mm: float = 0.25,
|
||||
fanout: bool = False,
|
||||
rollback: bool = True,
|
||||
) -> RoutingResult:
|
||||
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
|
||||
@@ -206,6 +244,7 @@ def build_routing_result(
|
||||
route_first=route_first,
|
||||
access_cost=access_cost,
|
||||
hole_to_hole_mm=hole_to_hole_mm,
|
||||
fanout=fanout,
|
||||
rollback=rollback,
|
||||
)
|
||||
return _to_routing_result(route, dsn, scale, layer_names)
|
||||
@@ -226,6 +265,7 @@ def route_dsn_board_exact(
|
||||
route_first: frozenset | None = None,
|
||||
access_cost: float = 0.0,
|
||||
hole_to_hole_mm: float = 0.25,
|
||||
fanout: bool = False,
|
||||
rollback: bool = True,
|
||||
) -> tuple[ExactRouteResult, int, list[str]]:
|
||||
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
|
||||
@@ -238,6 +278,9 @@ def route_dsn_board_exact(
|
||||
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
|
||||
width_board = round(_rule_width_dsn(dsn) * scale)
|
||||
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
|
||||
if fanout:
|
||||
_place_fanout(board, dsn, scale, layers, width_board, clearance_board,
|
||||
edge_clearance_board, hole_to_hole_mm)
|
||||
exact = route_board_exact(
|
||||
board,
|
||||
resolution=resolution,
|
||||
@@ -259,6 +302,7 @@ def route_dsn_board_exact(
|
||||
via_drill=_via_drill_board(dsn, scale),
|
||||
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
|
||||
)
|
||||
_add_generated(exact.result, board)
|
||||
return exact, scale, [layer.name for layer in dsn.layers]
|
||||
|
||||
|
||||
@@ -277,6 +321,7 @@ def build_exact_routing_result(
|
||||
route_first: frozenset | None = None,
|
||||
access_cost: float = 0.0,
|
||||
hole_to_hole_mm: float = 0.25,
|
||||
fanout: bool = False,
|
||||
rollback: bool = True,
|
||||
) -> RoutingResult:
|
||||
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
|
||||
@@ -294,6 +339,7 @@ def build_exact_routing_result(
|
||||
route_first=route_first,
|
||||
access_cost=access_cost,
|
||||
hole_to_hole_mm=hole_to_hole_mm,
|
||||
fanout=fanout,
|
||||
rollback=rollback,
|
||||
)
|
||||
return _to_routing_result(exact.result, dsn, scale, layer_names)
|
||||
|
||||
@@ -159,6 +159,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
|
||||
"promote": ["--promote-failures", "2"],
|
||||
"access": ["--access-cost", "5"],
|
||||
"rollback": ["--no-rollback"],
|
||||
"fanout": ["--fanout"],
|
||||
}
|
||||
for engine, options in ENGINE_OPTIONS.items():
|
||||
for option in options:
|
||||
|
||||
Reference in New Issue
Block a user