Compare commits

..

1 commit

Author SHA1 Message Date
Morgan 'ARR\!' Allen
796206f116 helper method to find the Board Feature of a Sketch 2025-05-16 23:15:55 -07:00
18 changed files with 84 additions and 357 deletions

View file

@ -6,7 +6,6 @@ KiCAD 9 API Workbench
## What works
* Create board
* Bidirectional syncing
* Add Vias
* Add footprint pads
* Import footprint 3d models
@ -20,11 +19,14 @@ KiCAD 9 API Workbench
## In Progress / To be ported
### Tracks
Tracks can be loaded and drawn as simple `LineSegment` but more work is needed to draw properly 'widthed', and particularly while trying to eliminate overlapping lines.
Tracks can be loaded and drawn as simple `LineSegment` but more work is needed to draw properly 'widthed', and particularly whilte trying to eliminate overlapping lines.
### More Pad Shapes
Only rectangle get drawn right now, would like to figure out how to get Sketcher to do the heavy lifting
### Sync to KiCAD
In theory this code is mostly working but there seems to be an [issue in kicad-python](https://gitlab.com/kicad/code/kicad-python/-/issues/34)
## Plans
### Add more board features

View file

@ -5,15 +5,12 @@ from kipy import KiCad
from kipy.proto.common.types import DocumentType
from . import settings
from . import board as Board
from .bases import BaseObject, BaseViewProvider
class APIObject(BaseObject):
TYPE = 'KiConnect::API'
def __init__(self, feature):
self.boards = {}
self.polygons = {}
self.kicad = KiCad()
super(APIObject, self).__init__(feature)
@ -24,49 +21,21 @@ class APIObject(BaseObject):
self.onDocumentRestored(feature)
self.ping_connection()
self.kicad_board = self.kicad.get_board()
polygons = Board.extract_polygons(self.kicad_board)
for polygon in polygons:
board, polygon_id = Board.makeBoard(self.feature.getParent(), self.kicad_board, polygon)
self.polygons[polygon_id] = polygon
self.boards[polygon_id] = board
self.ping_connection(feature)
def onDocumentRestored(self, feature):
super(APIObject, self).onDocumentRestored(feature)
if not hasattr(self, 'polygons'):
setattr(self, 'polygons', {})
if not hasattr(self, 'boards'):
setattr(self, 'boards', {})
self.kicad = KiCad()
self.refresh_polygons()
self.ping_connection(feature)
parent = feature.getParent()
if not parent: return
# XXX This gets all of the KiConnect::Board features but then does nothing with them
# future multi-board support?
boards = [ board for board in parent.Group if hasattr(board, 'Type') and board.Type == 'KiConnect::Board' ]
for board in boards:
print(board)
self.boards[board.PolygonId] = board
def refresh_polygons(self):
self.ping_connection()
if self.is_connected:
self.kicad_board = self.kicad.get_board()
polygons = Board.extract_polygons(self.kicad_board)
print('>', polygons)
for poly in polygons:
self.polygons[poly.id.value] = poly
else:
print('*****NOT CONNECTED')
@property
def is_connected(self):
@ -76,12 +45,7 @@ class APIObject(BaseObject):
return self.feature.Connected
def get_polygon(self, polygon_id):
print(polygon_id, self.polygons)
self.refresh_polygons()
return self.polygons.get(polygon_id)
def ping_connection(self):
def ping_connection(self, feature):
'''
Ping the KiCAD API to determine if it's connected
'''
@ -92,29 +56,29 @@ class APIObject(BaseObject):
try:
self.kicad.ping()
self.feature.Connected = True
feature.Connected = True
connection_status = 'Connected'
except Exception as e:
self.feature.Connected = False
feature.Connected = False
connection_status = 'Disconnected'
if self.feature.Connected:
if feature.Connected:
try:
docs = self.kicad.get_open_documents(DocumentType.DOCTYPE_PCB)
document_status = f'{len(docs)} Documents'
self.feature.DocumentCount = len(docs)
feature.DocumentCount = len(docs)
except Exception as e:
print(e)
self.feature.DocumentCount = 0
feature.DocumentCount = 0
self.feature.Label2 = f'{connection_status} ({document_status})'
feature.Label2 = f'{connection_status} ({document_status})'
return self.feature.Connected
return feature.Connected
class APIViewProvider(BaseViewProvider):
ICON = 'kicad/icon_footprint_browser.svg'
ICON = 'icon_footprint_browser.svg'
TYPE = 'KiConnect::API'
def __init__(self, viewprovider):

View file

@ -2,8 +2,6 @@ class BaseObject:
EXTENSIONS = []
TYPE = None
save_keys = []
def __init__(self, feature):
self.feature = feature
@ -17,7 +15,9 @@ class BaseObject:
self.sync_from()
def execute(self, feature):
pass
# TODO this might not be the right move
print(self, 'BaseObject.execute')
#self.onDocumentRestored(feature)
def get_api(self):
p = self.feature
@ -54,7 +54,7 @@ class BaseObject:
self.feature.addExtension(ext)
def setup_properties(self, feature):
feature.addProperty('App::PropertyString', 'Type', 'KiConnect', 'Internal KiConnect Type', hidden=True)
feature.addProperty('App::PropertyString', 'Type', 'KiConnect', 'Internal KiConnect Type', read_only=True, hidden=True)
def sync_from(self):
pass
@ -62,21 +62,5 @@ class BaseObject:
def sync_to(self):
pass
def dumps(self):
data = [ getattr(self, 'TYPE') ]
if len(self.save_keys) > 0:
for key in self.save_keys:
try:
data.append(getattr(self, key))
except Exception as e:
#XXX logging
print(e)
return tuple(data)
def loads(self, state):
self.Type = state[0]
for idx, key in enumerate(self.save_keys):
setattr(self, key, state[idx + 1])
def __getstate__(self):
return None

View file

@ -9,8 +9,6 @@ class BaseViewProvider:
TYPE = None
EXTENSIONS = []
save_keys = []
def __init__(self, viewprovider):
self.viewprovider = viewprovider
self.feature = viewprovider.Object.Proxy.feature
@ -48,21 +46,11 @@ class BaseViewProvider:
'''Return the name of the default display mode. It must be defined in getDisplayModes.'''
return 'Standard'
def dumps(self):
data = [ getattr(self, 'icon') ]
def __getstate__(self):
return {
'icon': self.icon
}
if len(self.save_keys) > 0:
for key in self.save_keys:
try:
data.append(getattr(self, key))
except Exception as e:
#XXX logging
print(e)
return tuple(data)
def loads(self, state):
self.icon = state[0]
for idx, key in enumerate(self.save_keys):
setattr(self, key, state[idx + 1])
def __setstate__(self, props):
for prop in props:
setattr(self, prop, props[prop])

View file

@ -16,15 +16,11 @@ from . import board_sketch as BoardSketch
class BoardObject(BaseObject):
TYPE = 'KiConnect::Board'
save_keys = [ 'polygon_id' ]
def __init__(self, feature, kicad_board, board_polygon):
self.feature = feature
self.substrate_body = None
self.board_sketch = None
self.kicad_board = kicad_board
# TODO add this to FreeCAD Preferences and Property?
self.do_offset = True
# TODO needs to be resotred in onDocumentRestored
#self.board_polygon = board_polygon
self.polygon_id = board_polygon.id.value
@ -35,7 +31,6 @@ class BoardObject(BaseObject):
self.feature.PolygonId = board_polygon.id.value
self.substrate_body = self.create_substrate_body()
self.board_sketch = BoardSketch.makeBoardSketch(self.substrate_body, kicad_board, board_polygon)
self.create_substrate_pad()
@ -46,8 +41,17 @@ class BoardObject(BaseObject):
def onDocumentRestored(self, feature):
super(BoardObject, self).onDocumentRestored(feature)
self.board_sketch = self.feature.getObject('BoardSketch')
self.kicad_board = self.get_api().kicad.get_board()
self.board_sketch = self.feature.getObject('Substrate').getObject('BoardSketch')
@property
def substrate_body(self):
body = self.feature.getObject('Substrate')
if not body:
body = self.create_substrate_body()
return body
def create_substrate_body(self):
substrate_body = App.ActiveDocument.addObject('PartDesign::Body', 'Substrate')
@ -68,10 +72,7 @@ class BoardObject(BaseObject):
return self.board_sketch
def get_polygon(self, kiid):
return [ s for s in self.kicad_board.get_shapes() if s.id.value == kiid ][0]
def get_boardpoly(self):
def get_boardpoly(self, polygon_id=None):
# TODO remove in favor of extract_polygons class method
board = self.kicad_board
@ -79,8 +80,10 @@ class BoardObject(BaseObject):
edge_cuts = [ edge for edge in board.get_shapes() if edge.layer == BoardLayer.BL_Edge_Cuts ]
polygons = [ edge for edge in edge_cuts if isinstance(edge, BoardPolygon) ]
# XXX only single board supported at the moment
return polygons[0]
if polygon_id:
return [ p for p in polygons if p.id.value == polygon_id ][0]
else:
return polygons
def pocket_vias(self):
board = self.kicad_board
@ -127,8 +130,17 @@ class BoardObject(BaseObject):
Pulls outline from KiCAD PolygonBoard and saves points as Vectors
'''
if self.board_sketch:
self.board_sketch.Proxy.sync_from()
# bit of a quick hack to keep the board in one place, needs more testing
if self.do_offset and not self.feature.BoardOffset:
bb = self.board_polygon.bounding_box()
self.feature.BoardOffset.Base = (App.Vector(bb.pos.x, -bb.pos.y) + App.Vector(bb.size.x, -bb.size.y) / 2) / 1000000.0
vectors = []
for node in self.board_polygon.polygons[0].outline:
vectors.append(self.point_to_vector(node.point))
self.feature.Vectors = vectors
def sync_to(self):
board = self.kicad_board
@ -143,7 +155,7 @@ class BoardObject(BaseObject):
poly = boardpoly.polygons[0]
poly.outline.clear()
geom = self.feature.getObject('Substrate').getObject('BoardSketch').Geometry
geom = self.feature.getObject('Substrate').getObject('Sketch').Geometry
segments = [l for l in geom if isinstance(l, Part.LineSegment)]
for line in segments:
@ -171,7 +183,6 @@ class BoardObject(BaseObject):
feature.addProperty('App::PropertyVectorList', 'Vectors', 'KiConnect', 'Internal offset for zeroing out Footprint offset', hidden=True)
feature.Doc = kicad_board.name
feature.Label2 = kicad_board.name
if not feature.Thickness:
feature.Thickness = 1.6
@ -181,7 +192,7 @@ class BoardObject(BaseObject):
class BoardViewProvider(BaseViewProvider):
TYPE = 'KiConnect::Board'
ICON = 'kicad/board.svg'
ICON = 'board.svg'
def makeBoard(parent, kicad_board, polygon):
feature = App.ActiveDocument.addObject('App::DocumentObjectGroupPython', 'Board')
@ -192,7 +203,7 @@ def makeBoard(parent, kicad_board, polygon):
Parts.makeParts(feature)
return feature, feature.Proxy.polygon_id
return feature
def extract_polygons(board):
# find polygons of Edge Cuts

View file

@ -11,18 +11,11 @@ from kipy.util.board_layer import BoardLayer
from .bases import BaseObject, BaseViewProvider
class BoardPolyNotFoundException(Exception):
pass
class BoardSketchObject(BaseObject):
TYPE = 'KiConnect::BoardSketch'
save_keys = [ 'polygon_id' ]
def __init__(self, feature, kicad_board, board_polygon):
self.board_polygon = board_polygon
self.polygon_id = board_polygon.id.value
super(BoardSketchObject, self).__init__(feature)
#feature.Visibility = False
@ -46,13 +39,7 @@ class BoardSketchObject(BaseObject):
feature = self.feature
vectors = []
# board.get_shapes needs to be called to ensure polygons are actually up to date
board_polygon = self.get_api().get_polygon(self.polygon_id)
if not board_polygon:
raise BoardPolyNotFoundException('Board Polygon not found: ' + self.polygon_id)
for node in board_polygon.polygons[0].outline:
for node in self.board_polygon.polygons[0].outline:
vectors.append(self.point_to_vector(node.point))
self.feature.Vectors = vectors
@ -78,9 +65,12 @@ class BoardSketchObject(BaseObject):
feature.recompute()
def __getstate__(self):
return None
class BoardSketchViewProvider(BaseViewProvider):
TYPE = 'KiConnect::BoardSketch'
#ICON = 'kicad/board.svg'
#ICON = 'board.svg'
def makeBoardSketch(parent, kicad_board, polygon):
feature = App.ActiveDocument.addObject('Sketcher::SketchObjectPython', 'BoardSketch')

View file

@ -1,8 +1,7 @@
import FreeCAD as App
import FreeCADGui as Gui
class Syncable:
SYNCABLES = [ 'KiConnect::Project', 'KiConnect::Board', 'KiConnect::Parts', 'KiConnect::BoardBody', 'KiConnect::BoardSketch' ]
SYNCABLES = [ 'KiConnect::Project', 'KiConnect::Board', 'KiConnect::Parts', 'KiConnect::BoardBody', ]
def IsActive(self):
sel = Gui.Selection.getSelection()
@ -42,5 +41,3 @@ class Syncable:
getattr(feature, self.method)()
s.recompute()
App.ActiveDocument.recompute()

View file

@ -1,24 +0,0 @@
import importlib
import FreeCADGui as Gui
import os
import sys
from .. import settings
from ..project import Project
class EditPrefs:
def GetResources(self):
tooltip = '<p>EditPrefs KiConnect Workbench for development.\nNOTE: Does not reload toolbars.</p>'
iconFile = 'preferences-system.svg'
return {
'MenuText': 'Edit Preferences',
'ToolTip': tooltip,
'Pixmap' : iconFile
}
def Activated(self):
Gui.showPreferences("KiConnect")
Gui.addCommand('cmd_edit_prefs', EditPrefs())

View file

@ -19,7 +19,7 @@ class CopperObject(BaseObject):
TYPE = 'KiConnect::Copper'
class CopperViewProvider(BaseViewProvider):
ICON = 'kicad/show_all_copper_layers.svg'
ICON = 'show_all_copper_layers.svg'
TYPE = 'KiConnect::Copper'
class Copper():

View file

@ -7,7 +7,7 @@ sys.path.insert(1, os.path.join(os.path.dirname(os.path.realpath(__file__)), '..
import FreeCADGui as Gui
import FreeCAD as App
from .commands import cmd_edit_prefs, cmd_new_pcb, cmd_reload, cmd_sync_from, cmd_sync_to
from .commands import cmd_new_pcb, cmd_reload, cmd_sync_from, cmd_sync_to
from . import settings
translate=App.Qt.translate
@ -19,18 +19,12 @@ TRANSLATIONSPATH = os.path.join(os.path.dirname(__file__), "resources", "transla
Gui.addLanguagePath(TRANSLATIONSPATH)
Gui.updateLocale()
default_preferences = [
('Bool', 'debug_reload', True),
('Bool', 'prefs_toolbar', True),
('Float', 'default_thickness', 1.6),
]
class KiConnect(Gui.Workbench):
MenuText = translate("Workbench", "KiConnect")
ToolTip = translate("Workbench", "KiConnect PCB Workbench")
Icon = os.path.join(settings.ICONPATH, "kiconnect.svg")
toolbox = [ 'kiconn_new', 'kiconn_sync_to', 'kiconn_sync_from' ]
toolbox = [ 'kiconn_new', 'kiconn_reload', 'kiconn_sync_to', 'kiconn_sync_from' ]
def GetClassName(self):
return "Gui::PythonWorkbench"
@ -41,27 +35,11 @@ class KiConnect(Gui.Workbench):
here is the place to import all the commands
"""
# setup default preferences on first load
if settings.preferences.IsEmpty():
for pref in default_preferences:
print('setting pref: ', f'Set{pref[0]}', pref[1], pref[2])
getattr(settings.preferences, f'Set{pref[0]}')(pref[1], pref[2])
# add debug reload button if enabled
if settings.preferences.GetBool('debug_reload'):
self.toolbox.append('kiconn_reload')
if settings.preferences.GetBool('prefs_toolbar'):
self.toolbox.append('cmd_edit_prefs')
print('setting up toolbar')
# NOTE: Context for this commands must be "Workbench"
self.appendToolbar(QT_TRANSLATE_NOOP("Workbench", "KiConnect"), self.toolbox)
self.appendMenu(QT_TRANSLATE_NOOP("Workbench", "KiConnect"), self.toolbox)
Gui.addPreferencePage(os.path.join(settings.UIPATH, 'preferences.ui'), 'KiConnect')
def Activated(self):
App.Console.PrintMessage(translate("Log", "Workbench KiConnect activated.") + "\n")

View file

@ -15,13 +15,12 @@ from .bases import BaseObject, BaseViewProvider
class PartsObject(BaseObject):
TYPE = 'KiConnect::Parts'
def __init__(self, feature):
super(PartsObject, self).__init__(feature)
def execute(self, feature):
super(PartsObject, self).execute(feature)
def sync_from(self):
self.import_footprints()
def import_footprints(self):
kiconn_board = self.feature.getParentGroup()
kicad_board = self.get_api().kicad.get_board()
@ -73,7 +72,7 @@ class PartsObject(BaseObject):
print(e)
class PartsViewProvider(BaseViewProvider):
ICON = 'kicad/icon_footprint_browser.svg'
ICON = 'icon_footprint_browser.svg'
TYPE = 'KiConnect::Parts'
def makeParts(parent):

View file

@ -28,6 +28,16 @@ class Project:
self.API = api.makeAPI(self.feature)
if self.API.Proxy.is_connected and self.API.DocumentCount > 0:
kicad_board = self.API.Proxy.kicad.get_board()
polygons = Board.extract_polygons(kicad_board)
for polygon in polygons:
self.board = Board.makeBoard(self.feature, kicad_board, polygon)
#self.copper = Copper(kicad_board, self.board)
#self.board.feature.addObject(self.copper.feature)
feature.ProcessTime = time.time() - start_time
App.ActiveDocument.recompute()

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1,9 +0,0 @@
ICONS:
Original KiCad Icon work by Inigo Zuluaga and Fabrizio Tappero among others
KiCad icons were redesigned in 2020 by Aleksandr Zyrianov
KiCad nightly icon reworked by Rafael Silva based on the 2020 redesign
License: CC-BY-SA 4.0

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -1,158 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>KiConnect::DlgSettingsKiConnect</class>
<widget class="QWidget" name="KiConnect::DlgSettingsKiConnect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1151</width>
<height>663</height>
</rect>
</property>
<property name="maximumSize">
<size>
<width>1151</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>General</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="defaults_group">
<property name="title">
<string>Defaults</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Default Board Thickness</string>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefUnitSpinBox" name="spin_thickness">
<property name="unit" stdset="0">
<string>mm</string>
</property>
<property name="value">
<double>1.600000000000000</double>
</property>
<property name="prefEntry" stdset="0">
<cstring>default_thickness</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Mod/KiConnect</cstring>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="check_load_parts">
<property name="text">
<string>Load Parts</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>parts_load</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Mod/KiConnect</cstring>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="debug_group">
<property name="title">
<string>Debug and Development</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
<widget class="Gui::PrefCheckBox" name="check_debug_reload">
<property name="font">
<font>
<pointsize>10</pointsize>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Enable Reload</string>
</property>
<property name="tristate">
<bool>false</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>debug_reload</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Mod/KiConnect</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="checkBox">
<property name="text">
<string> Preferences from toolbar</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>Mod/KiConnect</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>prefs_toolbar</cstring>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>Gui::QuantitySpinBox</class>
<extends>QAbstractSpinBox</extends>
<header>Gui/QuantitySpinBox.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefCheckBox</class>
<extends>QCheckBox</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefUnitSpinBox</class>
<extends>Gui::QuantitySpinBox</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View file

@ -1,10 +1,5 @@
import os
import FreeCAD as App
BOARD_THICKNESS = 0.80
ICONPATH = os.path.join(os.path.dirname(__file__), "resources", 'icons')
UIPATH = os.path.join(os.path.dirname(__file__), "resources", 'ui')
KICAD9_3DMODEL_DIR = '/usr/share/kicad/3dmodels/'
PREF_PATH = 'User parameter:BaseApp/Preferences/Mod/KiConnect'
preferences = App.ParamGet(PREF_PATH)