From cff22b9e085894a282ec15c9b3bb888034a28d8d Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 26 Sep 2023 22:42:57 +0200 Subject: WIP --- gerbonara/cad/kicad/pcb.py | 133 +++++++++++++-------------------------- twisted_coil_gen_twolayer.py | 147 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 102 deletions(-) diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py index 0c018cf..9249a9d 100644 --- a/gerbonara/cad/kicad/pcb.py +++ b/gerbonara/cad/kicad/pcb.py @@ -2,12 +2,10 @@ Library for handling KiCad's PCB files (`*.kicad_mod`). """ -import sys import math from pathlib import Path from dataclasses import field, KW_ONLY, fields from itertools import chain -from collections import defaultdict import re import fnmatch import functools @@ -352,6 +350,7 @@ class Board: b.__after_parse__(None) return b + def init_default_layers(self, inner_layers=0): inner = [(i, f'In{i}.Cu', 'signal', None) for i in range(1, inner_layers+1)] self.layers = [LayerSettings(idx, name, Atom(ltype)) for idx, name, ltype, cname in [ @@ -386,12 +385,13 @@ class Board: (57, 'User.8', 'user', None), (58, 'User.9', 'user', None)]] + def rebuild_trace_index(self): idx = self._trace_index = rtree.index.Index() id_map = self._trace_index_map = {} for obj in chain(self.track_segments, self.track_arcs): - for field in ('start', 'end'): - obj_id = id(obj) + for i, field in enumerate(('start', 'end')): + obj_id = id(obj) + i coord = getattr(obj, field) id_map[obj_id] = obj, field, obj.width, obj.layer_mask idx.insert(obj_id, (coord.x, coord.y, coord.x, coord.y)) @@ -407,6 +407,7 @@ class Board: id_map[obj_id] = via, 'at', via.size, via.layer_mask idx.insert(obj_id, (via.at.x, via.at.y, via.at.x, via.at.y)) + @staticmethod def _require_trace_index(fun): @functools.wraps(fun) @@ -417,6 +418,7 @@ class Board: return fun(self, *args, **kwargs) return wrapper + @_require_trace_index def query_trace_index_nearest(self, point, layers='*.Cu', n=1): layers = layer_mask(layers) @@ -427,10 +429,10 @@ class Board: if layers & mask: yield entry + @_require_trace_index def query_trace_index_tolerance(self, point, layers='*.Cu', tol=10e-6): layers = layer_mask(layers) - print(f'query {layers:08x}', file=sys.stderr) x, y = point for obj_id in self._trace_index.intersection((x-tol, y-tol, x+tol, y+tol)): @@ -439,6 +441,7 @@ class Board: if layers & mask and math.dist((attr.x, attr.y), (x, y)) <= tol: yield entry + def find_connected_traces(self, obj, layers='*.Cu', tol=10e-6): search_frontier = [] visited = set() @@ -463,100 +466,30 @@ class Board: raise TypeError(f'Finding connected traces for {type(obj)} objects is not (yet) supported.') enqueue(obj) + yield obj filter_layers = layer_mask(layers) while search_frontier: coord, size, layers = search_frontier.pop() x, y = coord.x, coord.y - for cand in self.find_conductors_at(x, y, layers & filter_layers, size): - if id(cand) not in visited: - enqueue(cand) - yield cand - - def track_skeleton(self, start, tol=10e-6): - search_frontier = [] - def enqueue(obj, arr): - if isinstance(obj, (TrackSegment, TrackArc)): - search_frontier.append((id(obj), arr, False, obj.start, obj.width, obj.layer_mask)) - search_frontier.append((id(obj), arr, False, obj.end, obj.width, obj.layer_mask)) - - elif isinstance(obj, Via): - search_frontier.append((id(obj), arr, True, obj.at, obj.size, obj.layer_mask)) - - elif isinstance(obj, Pad): - search_frontier.append((id(obj), arr, True, obj.at, max(obj.size.x, obj.size.y), obj.layer_mask)) - - else: - raise TypeError(f'Track skeleton starting at {type(obj)} objects is not (yet) supported.') + # First, find all bounding box intersections + found = [] + for cand, attr, cand_size, cand_mask in self.query_trace_index_tolerance((x, y), layers&filter_layers, size): + cand_coord = getattr(cand, attr) + dist = math.dist((x, y), (cand_coord.x, cand_coord.y)) + if dist <= size/2 + cand_size/2 and layers&cand_mask: + found.append((dist, cand)) - first_edge = [] - enqueue(start, first_edge) - nodes = {id(start): 1} - edges = {1: [first_edge]} - - i = 0 - while search_frontier: - obj_id, edge, force_node, coord, size, layers = search_frontier.pop() - print(f'current entry {obj_id} {force_node} {coord} {size} {layers:08x}', file=sys.stderr) - x, y = coord.x, coord.y - - candidates = [cand for cand in self.find_conductors_at(x, y, layers, size) if id(cand) != obj_id] - - if force_node or len(candidates) > 1: - for cand in candidates: - if node_id := nodes.get(id(cand)): - edge.append(node_id) - break - - else: - node_id = nodes[obj_id] = len(nodes) + 1 - edge.append(node_id) - edges[node_id] = arrs = [] - for cand in candidates: - a = [cand] - arrs.append(a) - enqueue(cand, a) - - elif len(candidates) == 1: - next_obj, = candidates - edge.append(next_obj) - if id(next_obj) not in nodes: - enqueue(next_obj, edge) - - i += 1 - print(f'~ Step {i}', file=sys.stderr) - print(f'~ Candidates:', file=sys.stderr) - for e in candidates: - print(f'~ {e}', file=sys.stderr) - - print(f'~ Nodes:', file=sys.stderr) - for k, v in nodes.items(): - print(f'~ {k} = {v}', file=sys.stderr) - - print(f'~ Current edge:', file=sys.stderr) - for e in edge: - print(f'~ {e}', file=sys.stderr) - - return nodes, edges - - def find_conductors_at(self, x, y, layers, size, tol=1e-6): - # First, find all bounding box intersections - found = {} - for cand, attr, cand_size, cand_mask in self.query_trace_index_tolerance((x, y), layers, size): - cand_coord = getattr(cand, attr) - dist = math.dist((x, y), (cand_coord.x, cand_coord.y)) - if dist <= size/2 + cand_size/2 and layers&cand_mask: - found[id(cand)] = dist, cand - - if not found: - return + if not found: + continue - # Second, filter to match only objects that are within tolerance of closest - min_dist = min(e[0] for e in found.values()) - for dist, cand in found.values(): - if dist < min_dist+tol: - yield cand + # Second, filter to match only objects that are within tolerance of closest + min_dist = min(e[0] for e in found) + for dist, cand in found: + if dist < min_dist+tol and id(cand) not in visited: + enqueue(cand) + yield cand def __after_parse__(self, parent): @@ -567,10 +500,12 @@ class Board: self.nets = {net.index: net.name for net in self.nets} + def __before_sexp__(self): self.properties = [Property(key, value) for key, value in self.properties.items()] self.nets = [Net(index, name) for index, name in self.nets.items()] + def remove(self, obj): match obj: case gr.Text(): @@ -608,12 +543,14 @@ class Board: case _: raise TypeError('Can only remove KiCad objects, cannot map generic gerbonara.cad objects for removal') + def remove_many(self, iterable): iterable = {id(obj) for obj in iterable} for field in fields(self): if field.default_factory is list and field.name not in ('nets', 'properties'): setattr(self, field.name, [obj for obj in getattr(self, field.name) if id(obj) not in iterable]) + def add(self, obj): match obj: case gr.Text(): @@ -652,6 +589,7 @@ class Board: for elem in self.map_gn_cad(obj): self.add(elem) + def map_gn_cad(self, obj, locked=False, net_name=None): match obj: case cad_pr.Trace(): @@ -703,10 +641,12 @@ class Board: v=Atom(v_align) if v_align != 'middle' else None, mirror=flip))) + def unfill_zones(self): for zone in self.zones: zone.unfill() + def find_pads(self, net=None): for fp in self.footprints: for pad in fp.pads: @@ -714,6 +654,7 @@ class Board: continue yield pad + def find_footprints(self, value=None, reference=None, name=None, net=None, sheetname=None, sheetfile=None): for fp in self.footprints: if name and not match_filter(name, fp.name): @@ -730,6 +671,7 @@ class Board: continue yield fp + def find_traces(self, net=None, include_vias=True): net_id = self.net_id(net, create=False) match = lambda obj: obj.net == net_id @@ -737,34 +679,42 @@ class Board: if obj.net == net_id: yield obj + @property def version(self): return self._version + @version.setter def version(self, value): if value not in SUPPORTED_FILE_FORMAT_VERSIONS: raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + def write(self, filename=None): with open(filename or self.original_filename, 'w') as f: f.write(self.serialize()) + def serialize(self): return build_sexp(sexp(type(self), self)[0]) + @classmethod def open(kls, pcb_file, *args, **kwargs): return kls.load(Path(pcb_file).read_text(), *args, **kwargs, original_filename=pcb_file) + @classmethod def load(kls, data, *args, **kwargs): return kls.parse(data, *args, **kwargs) + @property def single_sided(self): raise NotImplementedError() + def net_id(self, name, create=True): if name is None: return None @@ -781,6 +731,7 @@ class Board: else: raise IndexError(f'No such net: "{name}"') + # FIXME vvv def graphic_objects(self, text=False, images=False): return chain( diff --git a/twisted_coil_gen_twolayer.py b/twisted_coil_gen_twolayer.py index 8103fb6..1bf2bb1 100644 --- a/twisted_coil_gen_twolayer.py +++ b/twisted_coil_gen_twolayer.py @@ -14,6 +14,7 @@ from gerbonara.cad.kicad import footprints as kicad_fp from gerbonara.cad.kicad import graphical_primitives as kicad_gr from gerbonara.cad.kicad import primitives as kicad_pr from gerbonara.utils import Tag +from gerbonara import graphic_primitives as gp import click @@ -46,6 +47,95 @@ def angle_between_vectors(va, vb): angle += 2*pi return angle + +def traces_to_gmsh(traces, mesh_out, bbox, model_name='gerbonara_board', log=True, copper_thickness=35e-6, board_thickness=0.8, air_box_margin=5.0): + import gmsh + occ = gmsh.model.occ + eps = 1e-6 + + board_thickness -= 2*copper_thickness + + gmsh.initialize() + gmsh.model.add('gerbonara_board') + if log: + gmsh.logger.start() + + trace_tags = {} + trace_ends = set() + render_cache = {} + for i, tr in enumerate(traces): + layer = tr[1].layer + z0 = 0 if layer == 'F.Cu' else -(board_thickness+copper_thickness) + + prims = [prim + for elem in tr + for obj in elem.render(cache=render_cache) + for prim in obj.to_primitives()] + + tags = [] + for prim in prims: + if isinstance(prim, gp.Line): + length = dist((prim.x1, prim.y1), (prim.x2, prim.y2)) + box_tag = occ.addBox(0, -prim.width/2, 0, length, prim.width, copper_thickness) + angle = atan2(prim.y2 - prim.y1, prim.x2 - prim.x1) + occ.rotate([(3, box_tag)], 0, 0, 0, 0, 0, 1, angle) + occ.translate([(3, box_tag)], prim.x1, prim.y1, z0) + tags.append(box_tag) + + for x, y in ((prim.x1, prim.y1), (prim.x2, prim.y2)): + disc_id = (round(x, 3), round(y, 3), round(z0, 3), round(prim.width, 3)) + if disc_id in trace_ends: + continue + + trace_ends.add(disc_id) + cylinder_tag = occ.addCylinder(x, y, z0, 0, 0, copper_thickness, prim.width/2) + tags.append(cylinder_tag) + print('fusing', tags) + tags, tag_map = occ.fuse([(3, tags[0])], [(3, tag) for tag in tags[1:]]) + print(tags) + assert len(tags) == 1 + (_dim, tag), = tags + trace_tags[i] = tag + + (x1, y1), (x2, y2) = bbox + substrate = occ.addBox(x1, y1, -board_thickness, x2-x1, y2-y1, board_thickness) + + x1, y1 = x1-air_box_margin, y1-air_box_margin + x2, y2 = x2+air_box_margin, y2+air_box_margin + w, d = x2-x1, y2-y1 + z0 = -board_thickness-air_box_margin + ab_h = board_thickness + 2*air_box_margin + airbox = occ.addBox(x1, y1, z0, w, d, ab_h) + + print('Cutting airbox') + occ.cut([(3, airbox)], [(3, tag) for tag in trace_tags.values()], removeObject=True, removeTool=False) + print('Fragmenting') + fragment_tags, fragment_hierarchy = occ.fragment([(3, airbox)], [(3, substrate)] + [(3, tag) for tag in trace_tags.values()]) + + print('Synchronizing') + occ.synchronize() + substrate_physical = gmsh.model.add_physical_group(3, [substrate], name='substrate') + airbox_physical = gmsh.model.add_physical_group(3, [airbox], name='airbox') + trace_physical_surfaces = [ + gmsh.model.add_physical_group(2, list(gmsh.model.getAdjacencies(3, tag)[1]), name=f'trace{i}') + for i, tag in trace_tags.items()] + + airbox_adjacent = set(gmsh.model.getAdjacencies(3, airbox)[1]) + in_bbox = {tag for _dim, tag in gmsh.model.getEntitiesInBoundingBox(x1+eps, y1+eps, z0+eps, x1+w-eps, y1+d-eps, z0+ab_h-eps, dim=22)} + airbox_physical_surface = gmsh.model.add_physical_group(2, list(airbox_adjacent - in_bbox), name='airbox_surface') + + gmsh.option.setNumber('Mesh.MeshSizeFromCurvature', 90) + gmsh.option.setNumber('Mesh.Smoothing', 10) + gmsh.option.setNumber('Mesh.Algorithm3D', 10) + gmsh.option.setNumber('Mesh.MeshSizeMax', 0.2e-3) + gmsh.option.setNumber('General.NumThreads', 12) + + print('Meshing') + gmsh.model.mesh.generate(dim=3) + print('Writing') + gmsh.write(str(mesh_out)) + + class SVGPath: def __init__(self, **attrs): self.d = '' @@ -143,12 +233,13 @@ def print_valid_twists(ctx, param, value): @click.option('--show-twists', callback=print_valid_twists, expose_value=False, type=int, is_eager=True, help='Calculate and show valid --twists counts for the given number of turns. Takes the number of turns as a value.') @click.option('--clearance', type=float, default=None) @click.option('--arc-tolerance', type=float, default=0.02) +@click.option('--mesh-out', type=click.Path(writable=True, dir_okay=False, path_type=Path)) @click.option('--clipboard/--no-clipboard', help='Use clipboard integration (requires wl-clipboard)') @click.option('--counter-clockwise/--clockwise', help='Direction of generated spiral. Default: clockwise when wound from the inside.') @click.version_option() def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_drill, via_offset, trace_width, clearance, footprint_name, layer_pair, twists, clipboard, counter_clockwise, keepout_zone, keepout_margin, - arc_tolerance, pcb): + arc_tolerance, pcb, mesh_out): if 'WAYLAND_DISPLAY' in os.environ: copy, paste, cliputil = ['wl-copy'], ['wl-paste'], 'xclip' else: @@ -157,6 +248,9 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d if gcd(twists, turns) != 1: raise click.ClickException('For the geometry to work out, the --twists parameter must be co-prime to --turns, i.e. the two must have 1 as their greatest common divisor. You can print valid values for --twists by running this command with --show-twists [turns number].') + if mesh_out and not pcb: + raise click.ClickException('--pcb is required when --mesh-out is used.') + outer_radius = outer_diameter/2 inner_radius = inner_diameter/2 turns_per_layer = turns/2 @@ -326,13 +420,13 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d xn, yn = x0, y0 points = [(x0, y0)] dists = [] - for i in range(fn+1): + for i in range(fn): r, g, b, _a = mpl.cm.plasma(start_frac + (end_frac - start_frac)/fn * (i + 0.5)) path = SVGPath(fill='none', stroke=f'#{round(r*255):02x}{round(g*255):02x}{round(b*255):02x}', stroke_width=trace_width, stroke_linejoin='round', stroke_linecap='round') svg_stuff.append(path) xp, yp = xn, yn - r = r1 + i*(r2-r1)/fn - a = a1 + i*(a2-a1)/fn + r = r1 + (i+1)*(r2-r1)/fn + a = a1 + (i+1)*(a2-a1)/fn xn, yn = cos(a)*r, sin(a)*r path.move(xp, yp) path.line(xn, yn) @@ -358,7 +452,6 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d inverse = {} for i in range(twists): - #print(i, i*turns % twists, file=sys.stderr) inverse[i*turns%twists] = i svg_vias = [] @@ -373,7 +466,7 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d xv, yv = inner_via_ring_radius*cos(fold_angle), inner_via_ring_radius*sin(fold_angle) pads.append(make_via(xv, yv, layer_pair)) - if via_offset > 0: + if not isclose(via_offset, 0, abs_tol=1e-6): lines.append(make_line(xn, yn, xv, yv, layer_pair[0])) lines.append(make_line(xn, yn, xv, yv, layer_pair[1])) svg_vias.append(Tag('circle', cx=xv, cy=yv, r=via_diameter/2, stroke='none', fill='white')) @@ -382,7 +475,7 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d if i > 0: xv, yv = outer_via_ring_radius*cos(start_angle), outer_via_ring_radius*sin(start_angle) pads.append(make_via(xv, yv, layer_pair)) - if via_offset > 0: + if not isclose(via_offset, 0, abs_tol=1e-6): lines.append(make_line(x0, y0, xv, yv, layer_pair[0])) lines.append(make_line(x0, y0, xv, yv, layer_pair[1])) svg_vias.append(Tag('circle', cx=xv, cy=yv, r=via_diameter/2, stroke='none', fill='white')) @@ -390,8 +483,10 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d print(f'Approximate track length: {clen*twists*2:.2f} mm', file=sys.stderr) - pads.append(make_pad(1, [layer_pair[0]], outer_radius, 0)) - pads.append(make_pad(2, [layer_pair[1]], outer_radius, 0)) + top_pad = make_pad(1, [layer_pair[0]], outer_radius, 0) + pads.append(top_pad) + bottom_pad = make_pad(2, [layer_pair[1]], outer_radius, 0) + pads.append(bottom_pad) svg_stuff += svg_vias @@ -461,8 +556,38 @@ def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_d vias=[kicad_pcb.Via.from_pad(pad) for pad in pads if pad.type == kicad_pcb.Atom.thru_hole]) obj.rebuild_trace_index() seg = obj.track_segments[-1] - for e in obj.find_connected_traces(seg, layers=seg.layer_mask): - print(getattr(e, 'layer', ''), str(e)[:80], file=sys.stderr) + traces = [] + end = top_pad + layer = 'F.Cu' + while True: + tr = list(obj.find_connected_traces(end, layers=[layer])) + traces.append(tr) + if not isinstance(tr[-1], kicad_pcb.Via): + break + layer = 'B.Cu' if layer == 'F.Cu' else 'F.Cu' + end = tr[-1] + # remove start pad + traces[0] = traces[0][1:] + + r = outer_diameter/2 + 20 + traces_to_gmsh(traces, mesh_out, ((-r, -r), (r, r))) + +# for trace in traces: +# print(f'Trace {i}', file=sys.stderr) +# print(f' Length: {len(trace)}', file=sys.stderr) +# print(f' Start: {trace[0]}', file=sys.stderr) +# print(f' End: {trace[-1]}', file=sys.stderr) +# print(f' Layer: {trace[1].layer}', file=sys.stderr) + + #for e in obj.find_connected_traces(seg, layers=seg.layer_mask): + # print(getattr(e, 'layer', ''), str(e)[:80], file=sys.stderr) + #nodes, edges = obj.track_skeleton(pads[-1]) + #for node, node_edges in edges.items(): + # print(f'Node {node} with {len(node_edges)} edges', file=sys.stderr) + # for i, e in enumerate(node_edges): + # print(f' Edge {i}', file=sys.stderr) + # for elem in e: + # print(' ', elem, file=sys.stderr) else: obj = kicad_fp.Footprint( -- cgit