diff --git a/freecad/kiconnect/__init__.py b/freecad/kiconnect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freecad/kiconnect/api.py b/freecad/kiconnect/api.py new file mode 100644 index 0000000..00ba5cf --- /dev/null +++ b/freecad/kiconnect/api.py @@ -0,0 +1,44 @@ +import FreeCAD as App +import FreeCADGui as Gui + +from kipy import KiCad + +from . import settings +from .bases import BaseObject, BaseViewProvider + +class APIObject(BaseObject): + def __init__(self, parent, feature): + super(APIObject, self).__init__(parent, feature) + + feature.addProperty('App::PropertyFile', 'Socket', 'KiConnect', 'Path to the KiCAD Socket File').Socket = '/tmp/kicad/api.lock' + + def execute(self, feature): + self.parent.ping_connection() + + +class APIViewProvider(BaseViewProvider): + pass + +class API(): + ICON = 'icon_footprint_browser.svg' + TYPE = 'KiConnect::API' + + def __init__(self): + self.feature = App.ActiveDocument.addObject('App::FeaturePython', 'API') + + APIObject(self, self.feature) + APIViewProvider(self, self.feature.ViewObject) + + self.kicad = KiCad() + self.ping_connection() + + @property + def is_connected(self): + return self._connected + + def ping_connection(self): + try: + self.kicad.ping() + self._connected = True + except: + self._connected = False diff --git a/freecad/kiconnect/bases/BaseObject.py b/freecad/kiconnect/bases/BaseObject.py new file mode 100644 index 0000000..1cfc532 --- /dev/null +++ b/freecad/kiconnect/bases/BaseObject.py @@ -0,0 +1,35 @@ +class BaseObject: + def __init__(self, parent, feature): + print(self) + self.parent = parent + self.feature = feature + + feature.Proxy = self + + self.Type = '' + + if hasattr(parent.__class__, 'TYPE'): + self.Type = parent.__class__.TYPE + + self.setup_properties() + self.setup_extensions() + + def execute(self, feature): + print('execute', feature.Label, self.Type) + + def setup_properties(self): + pass + + def setup_extensions(self): + if hasattr(self.parent.__class__, 'EXTENSIONS'): + for ext in self.parent.__class__.EXTENSIONS: + self.feature.addExtension(ext) + + def onBeforeChange(self, feature, prop): + pass + + def onChanged(self, feature, prop): + pass + + def __getstate__(self): + return None diff --git a/freecad/kiconnect/bases/BaseViewProvider.py b/freecad/kiconnect/bases/BaseViewProvider.py new file mode 100644 index 0000000..c851818 --- /dev/null +++ b/freecad/kiconnect/bases/BaseViewProvider.py @@ -0,0 +1,46 @@ +import os +import FreeCADGui as Gui +from pivy import coin + +from .. import settings + +class BaseViewProvider: + def __init__(self, parent, viewprovider): + self.parent = parent + self.viewprovider = viewprovider + + viewprovider.Proxy = self + + self.Type = '' + + if hasattr(parent.__class__, 'TYPE'): + self.Type = parent.__class__.TYPE + + self.setup_extensions() + + def setup_extensions(self): + if hasattr(self.parent.__class__, 'VIEWPROVIDER_EXTENSIONS'): + for ext in self.parent.__getstate__.VIEWPROVIDER_EXTENSIONS: + self.feature.addExtension(ext) + + def attach(self, vobj): + self.standard = coin.SoGroup() + vobj.addDisplayMode(self.standard, "Standard") + + def doubleClicked(self, vobj): + Gui.activateWorkbench("KiConnect") + Gui.Selection.clearSelection() + + def getIcon(self): + return os.path.join(settings.ICONPATH, self.parent.__class__.ICON) + + def getDisplayModes(self,obj): + '''Return a list of display modes.''' + return [ 'Standard' ] + + def getDefaultDisplayMode(self): + '''Return the name of the default display mode. It must be defined in getDisplayModes.''' + return 'Standard' + + def __getstate__(self): + return None diff --git a/freecad/kiconnect/bases/__init__.py b/freecad/kiconnect/bases/__init__.py new file mode 100644 index 0000000..5e7fa73 --- /dev/null +++ b/freecad/kiconnect/bases/__init__.py @@ -0,0 +1,2 @@ +from .BaseObject import * +from .BaseViewProvider import * diff --git a/freecad/kiconnect/commands/__init__.py b/freecad/kiconnect/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freecad/kiconnect/commands/cmd_new_pcb.py b/freecad/kiconnect/commands/cmd_new_pcb.py new file mode 100644 index 0000000..253b677 --- /dev/null +++ b/freecad/kiconnect/commands/cmd_new_pcb.py @@ -0,0 +1,32 @@ +import os +import sys +import kipy + +import FreeCADGui as Gui +import FreeCAD as App + +from .. import settings +from ..project import Project + +class New: + def GetResources(self): + tooltip = '
Create new KiCAD Project
' + iconFile = os.path.join(settings.ICONPATH, 'add_board.svg') + + return {'MenuText': 'New KiCAD Project', 'ToolTip': tooltip, 'Pixmap' : iconFile } + + def Activated(self): + if App.ActiveDocument is None: + App.newDocument() + + App.ActiveDocument.openTransaction('kiconnect_new') + + kiconnect = Project() + + App.ActiveDocument.recompute() + Gui.SendMsgToActiveView("ViewFit") + + App.ActiveDocument.commitTransaction() + + +Gui.addCommand('kiconn_new', New()) diff --git a/freecad/kiconnect/commands/cmd_reload.py b/freecad/kiconnect/commands/cmd_reload.py new file mode 100644 index 0000000..e5cf484 --- /dev/null +++ b/freecad/kiconnect/commands/cmd_reload.py @@ -0,0 +1,40 @@ +import importlib +import FreeCADGui as Gui +import os +import sys + +from .. import settings +from ..project import Project + +class Reload: + def GetResources(self): + tooltip = 'Reload KiConnect Workbench for development.
' + iconFile = os.path.join(settings.ICONPATH, 'kiconnect.svg') + + return { + 'MenuText': 'Reload KiConnect', + 'ToolTip': tooltip, + 'Pixmap' : iconFile + } + + def Activated(self): + try: + Gui.activateWorkbench('Part') + print('failed to switch to Part WB') + except: pass + + try: + Gui.removeWorkbench('KiConnect') + except: + print('failed to remove KiConnect') + pass + + for mod in [mod for mod in sys.modules if 'kicon' in mod]: + print(f'Reloading {mod}') + + importlib.reload(sys.modules[mod]) + + Gui.activateWorkbench('KiConnect') + + +Gui.addCommand('kiconn_reload', Reload()) diff --git a/freecad/kiconnect/commands/cmd_sync_from.py b/freecad/kiconnect/commands/cmd_sync_from.py new file mode 100644 index 0000000..01ad8af --- /dev/null +++ b/freecad/kiconnect/commands/cmd_sync_from.py @@ -0,0 +1,29 @@ +import importlib +import FreeCAD as App +import FreeCADGui as Gui +import os +import sys + +from .. import settings +from ..project import Project + +class Sync: + def GetResources(self): + tooltip = 'Reload Board from KiCAD.
' + iconFile = os.path.join(settings.ICONPATH, 'import_brd_file.svg') + + return { + 'MenuText': 'Sync from KiCAD', + 'ToolTip': tooltip, + 'Pixmap' : iconFile + } + + def Activated(self): + boards = [ sel for sel in Gui.Selection.getSelection() if sel.Type == 'KiConnect::Board' ] + + for board in boards: + board.KiConnBoard.sketch_outline() + + App.ActiveDocument.recompute() + +Gui.addCommand('kiconn_sync_from', Sync()) diff --git a/freecad/kiconnect/commands/cmd_sync_to.py b/freecad/kiconnect/commands/cmd_sync_to.py new file mode 100644 index 0000000..f519c40 --- /dev/null +++ b/freecad/kiconnect/commands/cmd_sync_to.py @@ -0,0 +1,29 @@ +import importlib +import FreeCADGui as Gui +import os +import sys + +from .. import settings +from ..project import Project + +class Sync: + def GetResources(self): + tooltip = 'Update Board in KiCAD.
' + iconFile = os.path.join(settings.ICONPATH, 'export_to_pcbnew.svg') + + return { + 'MenuText': 'Sync to KiCAD', + 'ToolTip': tooltip, + 'Pixmap' : iconFile + } + + def Activated(self): + boards = [ sel for sel in Gui.Selection.getSelection() if sel.Type == 'KiConnect::Board' ] + + for board in boards: + board.KiConnBoard.sync() + + + + +Gui.addCommand('kiconn_sync_to', Sync()) diff --git a/freecad/kiconnect/copper.py b/freecad/kiconnect/copper.py new file mode 100644 index 0000000..e05b611 --- /dev/null +++ b/freecad/kiconnect/copper.py @@ -0,0 +1,98 @@ +import os +import FreeCADGui as Gui +import FreeCAD as App +import Materials +import Part + +from kipy import KiCad +from kipy.board_types import Footprint3DModel, BoardPolygon, BoardSegment, PadStackShape +from kipy.util.board_layer import BoardLayer + +from . import settings +from .bases import BaseObject, BaseViewProvider + +gold_mat_uuid = '85257e2c-be3f-40a1-b03f-0bd4ba58ca08' +materials_manager = Materials.MaterialManager() +gold = materials_manager.Materials[gold_mat_uuid] + +class CopperObject(BaseObject): + pass + +class CopperViewProvider(BaseViewProvider): + pass + +class Copper(): + ICON = 'show_all_copper_layers.svg' + TYPE = 'KiConnect::Copper' + + def __init__(self, kicad_board, kiconn_board): + self.nets = {} + + self.kicad_board = kicad_board + self.kiconn_board = kiconn_board + + feature = App.ActiveDocument.addObject('App::DocumentObjectGroupPython', 'Copper') + + CopperObject(self, feature) + CopperViewProvider(self, feature.ViewObject) + + self.feature = feature + + self.create_net_sketches() + self.draw_nets() + + feature.recompute() + + def create_net_sketches(self): + for net in self.kicad_board.get_nets(): + # this needs to be handled better, if there is an empty net, it will result + # in an empty sketch and an error + if net.name == '': continue + + face = App.ActiveDocument.addObject('Part::Face', net.name) + face.ShapeMaterial = gold + ''' + if self.feature.HideUnconnected and net.name.startswith('unconnected_'): + face.Hidden = True + ''' + + sketch = App.ActiveDocument.addObject('Sketcher::SketchObject') + sketch.Placement.Base.z = settings.BOARD_THICKNESS + 0.01 + sketch.Visibility = False + + face.Sources = sketch + + self.nets[net.name] = face + self.feature.addObject(face) + + def draw_nets(self): + # setup or clear net arrays/geometry + for net_name in self.nets: + self.nets[net_name].Sources[0].Geometry = [] + + for pad in self.kicad_board.get_pads(): + if BoardLayer.BL_F_Cu not in pad.padstack.layers: + continue + + f_cu = pad.padstack.copper_layer(BoardLayer.BL_F_Cu) + + center = (App.Vector(pad.position.x, -pad.position.y) / 1000000.0) - self.kiconn_board.offset + + sketch = self.nets[pad.net.name].Sources[0] + + if f_cu.shape in [ PadStackShape.PSS_ROUNDRECT, PadStackShape.PSS_RECTANGLE ]: + size = App.Vector(f_cu.size.x, f_cu.size.y) / 1000000.0 / 2 + + sketch.addGeometry([ + Part.LineSegment(App.Vector(center.x + size.x, center.y + size.y), App.Vector(center.x + size.x, center.y - size.y)), + Part.LineSegment(App.Vector(center.x + size.x, center.y - size.y), App.Vector(center.x - size.x, center.y - size.y)), + Part.LineSegment(App.Vector(center.x - size.x, center.y - size.y), App.Vector(center.x - size.x, center.y + size.y)), + Part.LineSegment(App.Vector(center.x - size.x, center.y + size.y), App.Vector(center.x + size.x, center.y + size.y)), + ]) + elif f_cu.shape == PadStackShape.PSS_CIRCLE: + drill = pad.padstack.drill + + if drill.diameter.x == drill.diameter.y: + rad = drill.diameter.x / 1000000.0 / 2 + sketch.addGeometry(Part.Circle(App.Vector(center.x, center.y), App.Vector(0, 0, 1), rad)) + diff --git a/freecad/kiconnect/init.py b/freecad/kiconnect/init.py new file mode 100644 index 0000000..a1a609a --- /dev/null +++ b/freecad/kiconnect/init.py @@ -0,0 +1 @@ +print('init.py') diff --git a/freecad/kiconnect/init_gui.py b/freecad/kiconnect/init_gui.py new file mode 100644 index 0000000..493dc6f --- /dev/null +++ b/freecad/kiconnect/init_gui.py @@ -0,0 +1,58 @@ +import os +import sys + +# temp hack to run kipy from source until 0.1.0 is looking ~ +sys.path.insert(1, os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', '..', '.venv', 'lib', 'python3.13', 'site-packages')) + +import FreeCADGui as Gui +import FreeCAD as App + +from .commands import cmd_new_pcb, cmd_reload, cmd_sync_from, cmd_sync_to +from . import settings + +translate=App.Qt.translate +QT_TRANSLATE_NOOP=App.Qt.QT_TRANSLATE_NOOP + +TRANSLATIONSPATH = os.path.join(os.path.dirname(__file__), "resources", "translations") + +# Add translations path +Gui.addLanguagePath(TRANSLATIONSPATH) +Gui.updateLocale() + +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_reload', 'kiconn_sync_to', 'kiconn_sync_from' ] + + def GetClassName(self): + return "Gui::PythonWorkbench" + + def Initialize(self): + """ + This function is called at the first activation of the workbench. + here is the place to import all the commands + """ + + 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) + + def Activated(self): + App.Console.PrintMessage(translate("Log", "Workbench KiConnect activated.") + "\n") + + def Deactivated(self): + App.Console.PrintMessage(translate("Log", "Workbench KiConnect de-activated.") + "\n") + + def ContextMenu(self, recipient): + boards = [sel for sel in Gui.Selection.getSelection() if sel.Type == 'KiConnect::Board'] + + if boards: + self.appendContextMenu("", "Separator") + self.appendContextMenu("", "kiconn_sync_from") + self.appendContextMenu("", "Separator") + + +Gui.addWorkbench(KiConnect()) diff --git a/freecad/kiconnect/parts.py b/freecad/kiconnect/parts.py new file mode 100644 index 0000000..3724545 --- /dev/null +++ b/freecad/kiconnect/parts.py @@ -0,0 +1,60 @@ +import os +import ImportGui +import FreeCADGui as Gui +import FreeCAD as App +import Materials +import Part + +from kipy import KiCad +from kipy.board_types import Footprint3DModel, BoardPolygon, BoardSegment, PadStackShape +from kipy.util.board_layer import BoardLayer + +from . import settings +from .bases import BaseObject, BaseViewProvider + +class PartsObject(BaseObject): + pass + +class PartsViewProvider(BaseViewProvider): + pass + +class Parts(): + ICON = 'icon_footprint_browser.svg' + TYPE = 'KiConnect::Parts' + + def __init__(self, kicad_board, kiconn_board): + self.kicad_board = kicad_board + self.kiconn_board = kiconn_board + + feature = App.ActiveDocument.addObject('App::DocumentObjectGroupPython', 'Parts') + + PartsObject(self, feature) + PartsViewProvider(self, feature.ViewObject) + + self.feature = feature + + self.import_footprints() + + def import_footprints(self): + for footprint in self.kicad_board.get_footprints(): + # NOTE this doesn't handle footprints that have been removed + if App.ActiveDocument.getObjectsByLabel(footprint.reference_field.text.value): continue + + for item in [item for item in footprint.definition.items if isinstance(item, Footprint3DModel)]: + filename = item.filename.replace('${KICAD9_3DMODEL_DIR}', settings.KICAD9_3DMODEL_DIR).replace('wrl', 'step') + ImportGui.insert(filename, App.ActiveDocument.Name) + + # simply grabs the last object in the document, probably need to figure out a safer way to handle + model = App.ActiveDocument.findObjects()[-1] + model.Label = footprint.reference_field.text.value + + model.addProperty('App::PropertyPlacement', 'BoardOffset', 'Base', 'Internal offset for zeroing out Footprint offset', hidden=True, read_only=True) + + self.feature.addObject(model) + + model.Placement.Base.x = (footprint.position.x / 1000000.0) - self.kiconn_board.offset.x + model.Placement.Base.y = (-footprint.position.y / 1000000.0) - self.kiconn_board.offset.y + model.Placement.Base.z = 0.8 + model.Placement.Rotation.Angle = footprint.orientation.to_radians() + model.BoardOffset = model.Placement + diff --git a/freecad/kiconnect/project.py b/freecad/kiconnect/project.py new file mode 100644 index 0000000..7ef81fa --- /dev/null +++ b/freecad/kiconnect/project.py @@ -0,0 +1,43 @@ +import FreeCADGui as Gui +import FreeCAD as App +import os +import time + +from kipy import KiCad + +from . import settings + +from .api import API +from .copper import Copper +from .board import Board +from .parts import Parts + +class Project: + def __init__(self): + start_time = time.time() + self.board = None + self.kicad_api = None + self.kicad_project = None + self.viewprovider = None + + feature = App.ActiveDocument.addObject('App::Part', 'KiConnect') + self.feature = feature + + feature.addProperty('App::PropertyTime', 'ProcessTime', 'KiConnect', 'Time to process Project', hidden=True, read_only=True) + + self.API = API() + self.feature.addObject(self.API.feature) + + if self.API.is_connected: + kicad_board = self.API.kicad.get_board() + + self.board = Board(kicad_board) + self.feature.addObject(self.board.feature) + + self.parts = Parts(kicad_board, self.board) + self.board.feature.addObject(self.parts.feature) + + self.copper = Copper(kicad_board, self.board) + self.board.feature.addObject(self.copper.feature) + + feature.ProcessTime = time.time() - start_time diff --git a/freecad/kiconnect/resources/icons/.gitkeep b/freecad/kiconnect/resources/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/freecad/kiconnect/resources/icons/add_board.svg b/freecad/kiconnect/resources/icons/add_board.svg new file mode 100644 index 0000000..47eda1c --- /dev/null +++ b/freecad/kiconnect/resources/icons/add_board.svg @@ -0,0 +1,254 @@ + + diff --git a/freecad/kiconnect/resources/icons/export_to_pcbnew.svg b/freecad/kiconnect/resources/icons/export_to_pcbnew.svg new file mode 100644 index 0000000..0b233e8 --- /dev/null +++ b/freecad/kiconnect/resources/icons/export_to_pcbnew.svg @@ -0,0 +1,1506 @@ + + diff --git a/freecad/kiconnect/resources/icons/icon_footprint_browser.svg b/freecad/kiconnect/resources/icons/icon_footprint_browser.svg new file mode 100644 index 0000000..b78cdf1 --- /dev/null +++ b/freecad/kiconnect/resources/icons/icon_footprint_browser.svg @@ -0,0 +1,187 @@ + + diff --git a/freecad/kiconnect/resources/icons/import_brd_file.svg b/freecad/kiconnect/resources/icons/import_brd_file.svg new file mode 100644 index 0000000..79c7670 --- /dev/null +++ b/freecad/kiconnect/resources/icons/import_brd_file.svg @@ -0,0 +1,235 @@ + + diff --git a/freecad/kiconnect/resources/icons/kiconnect.svg b/freecad/kiconnect/resources/icons/kiconnect.svg new file mode 100644 index 0000000..26666d2 --- /dev/null +++ b/freecad/kiconnect/resources/icons/kiconnect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/freecad/kiconnect/resources/icons/load_module_board.svg b/freecad/kiconnect/resources/icons/load_module_board.svg new file mode 100644 index 0000000..3c5b4ba --- /dev/null +++ b/freecad/kiconnect/resources/icons/load_module_board.svg @@ -0,0 +1,248 @@ + + diff --git a/freecad/kiconnect/resources/icons/options_board.svg b/freecad/kiconnect/resources/icons/options_board.svg new file mode 100644 index 0000000..6a3996b --- /dev/null +++ b/freecad/kiconnect/resources/icons/options_board.svg @@ -0,0 +1,204 @@ + + diff --git a/freecad/kiconnect/resources/icons/show_all_copper_layers.svg b/freecad/kiconnect/resources/icons/show_all_copper_layers.svg new file mode 100644 index 0000000..9014500 --- /dev/null +++ b/freecad/kiconnect/resources/icons/show_all_copper_layers.svg @@ -0,0 +1,38 @@ + + diff --git a/freecad/kiconnect/resources/icons/update.svg b/freecad/kiconnect/resources/icons/update.svg new file mode 100644 index 0000000..9f584b7 --- /dev/null +++ b/freecad/kiconnect/resources/icons/update.svg @@ -0,0 +1,1052 @@ + + diff --git a/freecad/kiconnect/resources/translations/README.md b/freecad/kiconnect/resources/translations/README.md new file mode 100644 index 0000000..81997e3 --- /dev/null +++ b/freecad/kiconnect/resources/translations/README.md @@ -0,0 +1,104 @@ +# About translating kiconnect Workbench + +> [!NOTE] +> All commands **must** be run in `./freecad/kiconnect/resources/translations/` directory. + +> [!IMPORTANT] +> If you want to update/release the files you need to have installed +> `lupdate` and `lrelease` from **Qt6** version. Using the versions from +> Qt5 is not advised because they're buggy. + +## Updating translations template file + +To update the template file from source files you should use this command: + +```shell +./update_translation.sh -U +``` + +Once done you can commit the changes and upload the new file to CrowdIn platform +at