From 6d7502d3b0742c809111c961a7b4baa116eb015e Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 16:06:33 +0200 Subject: [PATCH 01/10] Fixed exceptions unter FreeCAD v1.1 / PySide6 --- lasercut/helper.py | 4 ++++ lasercut/material.py | 6 +++++- panel/treepanel.py | 10 +++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lasercut/helper.py b/lasercut/helper.py index 5a58f21..778e2b9 100644 --- a/lasercut/helper.py +++ b/lasercut/helper.py @@ -258,6 +258,8 @@ def sort_quad_vertex(list_edges, reverse): def biggest_area_faces(freecad_shape): sorted_list = sort_area_shape_faces(freecad_shape) + if not sorted_list: + raise ValueError("Shape has no usable faces (check the shape is valid and not empty)") biggest_area_face = sorted_list[-1] # contains : 0:normal, 1:area mm2, 2; list of faces return biggest_area_face @@ -265,6 +267,8 @@ def biggest_area_faces(freecad_shape): def smallest_area_faces(freecad_shape): sorted_list = sort_area_shape_faces(freecad_shape) + if not sorted_list: + raise ValueError("Shape has no usable faces (check the shape is valid and not empty)") smallest_area_face = sorted_list[0] # contains : 0:normal, 1:area mm2, 2; list of faces return smallest_area_face diff --git a/lasercut/material.py b/lasercut/material.py index 5c76142..87db3e1 100644 --- a/lasercut/material.py +++ b/lasercut/material.py @@ -75,7 +75,11 @@ def __init__(self, **kwargs): def recomputeInit(self, freecad_obj): self.freecad_object = freecad_obj - thickness = retrieve_thickness_from_biggest_face(freecad_obj) + try: + thickness = retrieve_thickness_from_biggest_face(freecad_obj) + except ValueError as e: + FreeCAD.Console.PrintError(e) + return if compare_value(thickness, self.thickness) is False: FreeCAD.Console.PrintError("Recomputed thickness for %s is different (%f != %f)\n" % (self.name, thickness, self.thickness)) diff --git a/panel/treepanel.py b/panel/treepanel.py index c9c0121..2ad66d4 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -97,7 +97,7 @@ def __init__(self, title, obj_join = None): #none to be removed self.model.append_tab(item.freecad_obj_name, item.tab_name, item.face_name, bool(item.link_name)) def getStandardButtons(self): - return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel) + return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel def accept(self): raise ValueError("Must overloaded") @@ -282,6 +282,8 @@ def add_same_tabs(self): return def force_selection(self, index): + if index is None or not index.isValid(): + return self.selection_model.clearSelection() self.selection_model.select(index, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) @@ -314,9 +316,11 @@ def selection_changed(self, selected, deselected): item = index.internalPointer() tab, widget = self.tabsList.get(item.get_name()) if tab is None: - raise ValueError("No tab named %s", item.get_name()) + FreeCAD.Console.PrintWarning("No tab named %s\n" % item.get_name()) + continue if widget is None: - raise ValueError("No widget named %s", item.get_name()) + FreeCAD.Console.PrintWarning("No widget named %s\n" % item.get_name()) + continue fobj = self.active_document.getObject(tab.freecad_obj_name) FreeCADGui.Selection.addSelection(fobj, tab.face_name) From f47143d5edc54a4efbbb9d0f257c028bc426e3fb Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 17:19:39 +0200 Subject: [PATCH 02/10] Fixed handling of PartDesign objects --- panel/crosspiece.py | 16 +--------------- panel/multiplejoins.py | 16 +--------------- panel/selection.py | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/panel/crosspiece.py b/panel/crosspiece.py index 20ad1c8..5bcc5b4 100644 --- a/panel/crosspiece.py +++ b/panel/crosspiece.py @@ -110,25 +110,14 @@ def execute(self, fp): fp.need_recompute = False document = fp.Document - if len(fp.fromParts) > 0: - groupObj = fp.fromParts[0] - else: - groupObj = document.addObject("App::DocumentObjectGroup", str(fp.Name) + "_origin_parts") - - subObjectList = groupObj.Group - for subObj in subObjectList: - groupObj.removeObject(subObj) - fp.fromParts = [] parts = [] freedac_origin_obj = [] - freedac_origin_obj.append(groupObj) for part in fp.parts.lst: cp_part = copy.deepcopy(part) freecad_obj = document.getObject(cp_part.name) freedac_origin_obj.append(freecad_obj) cp_part.recomputeInit(freecad_obj) - groupObj.addObject(freecad_obj) parts.append(cp_part) fp.fromParts = freedac_origin_obj @@ -198,10 +187,7 @@ def attach(self, vobj): self.Object = vobj.Object def claimChildren(self): - if len(self.Object.fromParts) > 0: - return [self.Object.fromParts[0]] + self.Object.generatedParts - else: - return [] + return list(self.Object.fromParts) + list(self.Object.generatedParts) class CrossPiece(TreePanel): diff --git a/panel/multiplejoins.py b/panel/multiplejoins.py index 287f2ff..d2c3b0b 100644 --- a/panel/multiplejoins.py +++ b/panel/multiplejoins.py @@ -120,25 +120,14 @@ def execute(self, fp): fp.need_recompute = False document = fp.Document - if len(fp.fromParts) > 0: - groupObj = fp.fromParts[0] - else: - groupObj = document.addObject("App::DocumentObjectGroup", str(fp.Name) + "_origin_parts") - - subObjectList = groupObj.Group - for subObj in subObjectList: - groupObj.removeObject(subObj) - fp.fromParts = [] parts = [] freedac_origin_obj = [] - freedac_origin_obj.append(groupObj) for part in fp.parts.lst: cp_part = copy.deepcopy(part) freecad_obj = document.getObject(cp_part.name) freedac_origin_obj.append(freecad_obj) cp_part.recomputeInit(freecad_obj) - groupObj.addObject(freecad_obj) parts.append(cp_part) fp.fromParts = freedac_origin_obj @@ -217,10 +206,7 @@ def attach(self, vobj): self.Object = vobj.Object def claimChildren(self): - if len(self.Object.fromParts) > 0: - return [self.Object.fromParts[0]] + self.Object.generatedParts - else: - return [] + return list(self.Object.fromParts) + list(self.Object.generatedParts) class MultipleJoins(TreePanel): diff --git a/panel/selection.py b/panel/selection.py index a73dc7a..63123aa 100644 --- a/panel/selection.py +++ b/panel/selection.py @@ -25,19 +25,30 @@ import FreeCADGui +def resolve_canonical_object(obj): + """Resolve a PartDesign Tip/sub-feature to its owning Body.""" + if obj is None or obj.TypeId == "PartDesign::Body": + return obj + for parent in obj.InList: + if parent.TypeId == "PartDesign::Body" and obj in parent.Group: + return parent + return obj + + def get_freecad_objects_list(): objs_sel = [] for selection in FreeCADGui.Selection.getSelectionEx(): - objs_sel.append(selection.Object) + objs_sel.append(resolve_canonical_object(selection.Object)) return objs_sel def get_freecad_faces_objects_list(): face_obj_list = [] for selection_obj in FreeCADGui.Selection.getSelectionEx(): + canonical_obj = resolve_canonical_object(selection_obj.Object) index = 0 for face in selection_obj.SubObjects: - face_obj_list.append({'freecad_object': selection_obj.Object, 'face': face, + face_obj_list.append({'freecad_object': canonical_obj, 'face': face, 'name': selection_obj.SubElementNames[index]}) index += 1 return face_obj_list From 4e2048a36009f5a0483fa81c0ced021ed06f0524 Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 17:42:17 +0200 Subject: [PATCH 03/10] Reintroduced "Origin Parts" group --- panel/crosspiece.py | 18 ++++++++++++++++-- panel/multiplejoins.py | 17 +++++++++++++++-- panel/treepanel.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/panel/crosspiece.py b/panel/crosspiece.py index 5bcc5b4..3eb69c6 100644 --- a/panel/crosspiece.py +++ b/panel/crosspiece.py @@ -27,7 +27,7 @@ from FreeCAD import Gui, Matrix import os from lasercut.crosspart import make_cross_parts -from panel.treepanel import TreePanel, PREVIEW_NONE, PREVIEW_NORMAL, PREVIEW_FAST +from panel.treepanel import TreePanel, PREVIEW_NONE, PREVIEW_NORMAL, PREVIEW_FAST, OriginalPartsGroup, OriginalPartsGroupViewProvider from panel.propertieslist import PropertiesList import json import copy @@ -44,6 +44,7 @@ def __init__(self, obj): obj.addProperty('App::PropertyPythonObject', 'preview').preview = PREVIEW_NONE obj.addProperty('App::PropertyLinkList', 'generatedParts').generatedParts = [] obj.addProperty('App::PropertyLinkList', 'fromParts').fromParts = [] + obj.addProperty('App::PropertyLink', 'originFolder').originFolder = None obj.addProperty('App::PropertyPythonObject', 'edit').edit = False obj.addProperty('App::PropertyPythonObject', 'namesMapping').namesMapping = {} obj.Proxy = self @@ -121,6 +122,16 @@ def execute(self, fp): parts.append(cp_part) fp.fromParts = freedac_origin_obj + + if not hasattr(fp, "originFolder"): + fp.addProperty('App::PropertyLink', 'originFolder').originFolder = None + if fp.originFolder is None: + origin_folder = document.addObject("App::FeaturePython", str(fp.Name) + "_origin_parts") + OriginalPartsGroup(origin_folder) + OriginalPartsGroupViewProvider(origin_folder.ViewObject) + fp.originFolder = origin_folder + fp.originFolder.parts = freedac_origin_obj + computed_parts = make_cross_parts(parts) previous_nameMapping = copy.copy(fp.namesMapping) @@ -187,7 +198,10 @@ def attach(self, vobj): self.Object = vobj.Object def claimChildren(self): - return list(self.Object.fromParts) + list(self.Object.generatedParts) + children = [] + if hasattr(self.Object, "originFolder") and self.Object.originFolder is not None: + children.append(self.Object.originFolder) + return children + list(self.Object.generatedParts) class CrossPiece(TreePanel): diff --git a/panel/multiplejoins.py b/panel/multiplejoins.py index d2c3b0b..140d48a 100644 --- a/panel/multiplejoins.py +++ b/panel/multiplejoins.py @@ -27,7 +27,7 @@ from FreeCAD import Gui, Matrix import os from lasercut.join import make_tabs_joins -from panel.treepanel import TreePanel, PREVIEW_NONE, PREVIEW_NORMAL, PREVIEW_FAST +from panel.treepanel import TreePanel, PREVIEW_NONE, PREVIEW_NORMAL, PREVIEW_FAST, OriginalPartsGroup, OriginalPartsGroupViewProvider from panel.propertieslist import PropertiesList import json import copy @@ -44,6 +44,7 @@ def __init__(self, obj): obj.addProperty('App::PropertyPythonObject', 'preview').preview = PREVIEW_NONE obj.addProperty('App::PropertyLinkList', 'generatedParts').generatedParts = [] obj.addProperty('App::PropertyLinkList', 'fromParts').fromParts = [] + obj.addProperty('App::PropertyLink', 'originFolder').originFolder = None obj.addProperty('App::PropertyPythonObject', 'edit').edit = False obj.addProperty('App::PropertyPythonObject', 'namesMapping').namesMapping = {} obj.Proxy = self @@ -132,6 +133,15 @@ def execute(self, fp): fp.fromParts = freedac_origin_obj + if not hasattr(fp, "originFolder"): + fp.addProperty('App::PropertyLink', 'originFolder').originFolder = None + if fp.originFolder is None: + origin_folder = document.addObject("App::FeaturePython", str(fp.Name) + "_origin_parts") + OriginalPartsGroup(origin_folder) + OriginalPartsGroupViewProvider(origin_folder.ViewObject) + fp.originFolder = origin_folder + fp.originFolder.parts = freedac_origin_obj + tabs = [] for tab in fp.faces.lst: cp_tab = copy.deepcopy(tab) @@ -206,7 +216,10 @@ def attach(self, vobj): self.Object = vobj.Object def claimChildren(self): - return list(self.Object.fromParts) + list(self.Object.generatedParts) + children = [] + if hasattr(self.Object, "originFolder") and self.Object.originFolder is not None: + children.append(self.Object.originFolder) + return children + list(self.Object.generatedParts) class MultipleJoins(TreePanel): diff --git a/panel/treepanel.py b/panel/treepanel.py index 2ad66d4..c351779 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -424,3 +424,38 @@ def check_is_in_active_view(self): def save_link_properties(self): self.partsList.get_parts_properties() self.tabsList.get_tabs_properties() + + +class OriginalPartsGroup: + """Presentational-only folder: claims parts via its own link list, never App::DocumentObjectGroup's Group, so it never reparents them.""" + + def __init__(self, obj): + obj.addProperty('App::PropertyLinkList', 'parts').parts = [] + obj.Proxy = self + + def execute(self, fp): + pass + + +class OriginalPartsGroupViewProvider: + def __init__(self, vobj): + vobj.Proxy = self + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + def claimChildren(self): + return list(self.Object.parts) + + def getIcon(self): + return ":/icons/Group.svg" + + def onChanged(self, vp, prop): + pass + + def __getstate__(self): + return None + + def __setstate__(self, state): + return None From 5c3192284b67d1aee863d8f26bb9754150047e9c Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 17:54:16 +0200 Subject: [PATCH 04/10] Fixed errors during removing faces/parts --- panel/treepanel.py | 81 ++++++++++++++++++++++++++++++---------------- panel/treeview.py | 5 +++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/panel/treepanel.py b/panel/treepanel.py index c351779..a8b8fa4 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -90,9 +90,12 @@ def __init__(self, title, obj_join = None): #none to be removed self.other_object_list = [] self.save_initial_objects() + self.rebuild_tree() + + def rebuild_tree(self): + self.model.clear() for item in self.parts: self.model.append_part(item.name, item.label, bool(item.link_name)) - for item in self.faces: self.model.append_tab(item.freecad_obj_name, item.tab_name, item.face_name, bool(item.link_name)) @@ -204,35 +207,57 @@ def remove_items(self): if len(indexes) == 0: FreeCAD.Console.PrintWarning("Nothing to remove\n") return - parent_test_name = indexes[0].internalPointer().parent().get_name() - for index in indexes:#[1:]: - if index.internalPointer().parent().get_name() != parent_test_name: - FreeCAD.Console.PrintError("No same level delete") - return False - elif index.internalPointer().child_count() > 0: - FreeCAD.Console.PrintError("%s has children" % index.internalPointer().get_name()) - return False - for index in indexes: - item = index.internalPointer() - if item.type == TreeItem.PART and len(self.partsList.get_linked_parts(item.get_name())) > 0: - FreeCAD.Console.PrintError('Some parts are linked to this part %s\n' % item.get_name()) - return False - elif item.type == TreeItem.TAB and len(self.tabsList.get_linked_tabs(item.get_name())) > 0: - FreeCAD.Console.PrintError('Some tabs are linked to this tab %s\n' % item.get_name()) - return False - for index in indexes: - item = index.internalPointer() - if item.type == TreeItem.PART or item.type == TreeItem.PART_LINK: - self.partsList.remove(item.get_name()) - elif item.type == TreeItem.TAB or item.type == TreeItem.TAB_LINK: - self.tabsList.remove(item.get_name()) - else: - FreeCAD.Console.PrintError("Unknown deleter item") - rows = sorted(set(index.row() for index in indexes)) - for row in reversed(rows): - self.model.removeRow(row, indexes[0].parent()) + # Cascading delete: removing a part also removes its child faces. + items_to_remove = [] + seen_ids = set() + def collect(tree_item): + if id(tree_item) in seen_ids: + return + seen_ids.add(id(tree_item)) + items_to_remove.append(tree_item) + for child in list(tree_item.childItems): + collect(child) + + for index in indexes: + collect(index.internalPointer()) + + names_to_remove = set(item.get_name() for item in items_to_remove) + + for item in items_to_remove: + if item.type == TreeItem.PART: + linked = [n for n in self.partsList.get_linked_parts(item.get_name()) if n not in names_to_remove] + if len(linked) > 0: + FreeCAD.Console.PrintError('Some parts are linked to this part %s\n' % item.get_name()) + return False + elif item.type == TreeItem.TAB: + linked = [tab.name for tab in self.tabsList.get_linked_tabs(item.get_name()) if tab.name not in names_to_remove] + if len(linked) > 0: + FreeCAD.Console.PrintError('Some tabs are linked to this tab %s\n' % item.get_name()) + return False + + # Retry in passes so a link removed in the same batch as its origin resolves either order. + remaining = items_to_remove + while remaining: + still_remaining = [] + for item in remaining: + try: + if item.type == TreeItem.PART or item.type == TreeItem.PART_LINK: + self.partsList.remove(item.get_name()) + elif item.type == TreeItem.TAB or item.type == TreeItem.TAB_LINK: + self.tabsList.remove(item.get_name()) + else: + FreeCAD.Console.PrintError("Unknown deleter item") + except ValueError: + still_remaining.append(item) + if len(still_remaining) == len(remaining): + for item in still_remaining: + FreeCAD.Console.PrintError("Could not remove %s\n" % item.get_name()) + break + remaining = still_remaining + + self.rebuild_tree() return def check_faces(self, faces): diff --git a/panel/treeview.py b/panel/treeview.py index 1c75094..b0c31c2 100644 --- a/panel/treeview.py +++ b/panel/treeview.py @@ -86,6 +86,11 @@ def __init__(self, parent=None): super(TreeModel, self).__init__(parent) self.rootItem = TreeItem(TreeItem.ROOT, ["Name", "Label"]) + def clear(self): + self.beginResetModel() + self.rootItem = TreeItem(TreeItem.ROOT, ["Name", "Label"]) + self.endResetModel() + def append_part(self, name, label, is_link=False): row = self.rootItem.child_count() - 1 self.beginInsertRows(QtCore.QModelIndex(), row, row) From 9d806fda7e300d8c351b0303ecd3f6fed63e52b7 Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 19:23:23 +0200 Subject: [PATCH 05/10] Fix for removing linked parts/faces --- panel/partmat.py | 42 +++++++++++++++++++++++++----------- panel/tab.py | 54 ++++++++++++++++++++++++++++++---------------- panel/treepanel.py | 19 ++++------------ 3 files changed, 70 insertions(+), 45 deletions(-) diff --git a/panel/partmat.py b/panel/partmat.py index 1ae7b15..5735b00 100644 --- a/panel/partmat.py +++ b/panel/partmat.py @@ -124,13 +124,9 @@ def append_link(self, freecad_object, freecad_object_src): return self.part_list[-1] - def remove(self, name): + def remove(self, name, batch_names=frozenset()): + self._promote_link(name, batch_names) found_index = None - linked_parts = self.get_linked_parts(name) - if len(linked_parts) > 0: - FreeCAD.Console.PrintError('Some parts are linked to this part %s\n' % name) - raise ValueError('Some parts are linked to this part %s' % name) - for index in range(len(self.part_list)): if self.part_list[index].name == name: found_index = index @@ -138,6 +134,33 @@ def remove(self, name): if found_index is not None: self.part_list.pop(found_index) + def _merged_from_origin(self, origin, link_part): + new_part = copy.deepcopy(origin) + new_part.name = link_part.name + new_part.label = link_part.label + new_part.new_name = link_part.new_name + new_part.link_name = link_part.link_name + return new_part + + def _promote_link(self, name, batch_names): + linked_names = [n for n in self.get_linked_parts(name) if n not in batch_names] + if len(linked_names) == 0: + return + origin, widget = self.get(name) + promoted_name = linked_names[0] + for index in range(len(self.part_list.lst)): + if self.part_list.lst[index].name == promoted_name: + promoted = self._merged_from_origin(origin, self.part_list.lst[index]) + promoted.link_name = "" + self.part_list.lst[index] = promoted + break + for other_name in linked_names[1:]: + for index in range(len(self.part_list.lst)): + if self.part_list.lst[index].name == other_name: + self.part_list.lst[index].link_name = promoted_name + break + FreeCAD.Console.PrintMessage("%s promoted to origin of the linked parts group\n" % promoted_name) + def get_linked_parts(self, name): el_list = [] part_lst = self.part_list.lst @@ -172,12 +195,7 @@ def get_parts_properties(self): for part in self.part_list.lst: if part.link_name: part_link, widget = self.get(part.link_name) - new_part = copy.deepcopy(part_link) - new_part.new_name = part.new_name - new_part.name = part.name - new_part.link_name = part.link_name - - part_properties.append(new_part) + part_properties.append(self._merged_from_origin(part_link, part)) else: part_properties.append(copy.deepcopy(part)) diff --git a/panel/tab.py b/panel/tab.py index 723b063..198debb 100644 --- a/panel/tab.py +++ b/panel/tab.py @@ -192,12 +192,9 @@ def append_link(self, face, src_tab_name): #self.faces_widget_list.append(TabLink(tab_properties)) return self.faces[-1]#, self.faces_widget_list[-1] - def remove(self, name): + def remove(self, name, batch_names=frozenset()): + self._promote_link(name, batch_names) found_index = None - linked_tabs = self.get_linked_tabs(name) - if len(linked_tabs) > 0: - raise ValueError('Some tabs are linked to this part %s' % name) - for index in range(len(self.faces)): if self.faces[index].tab_name == name: found_index = index @@ -206,6 +203,38 @@ def remove(self, name): if found_index is not None: self.faces.pop(found_index) + def _merged_from_origin(self, origin, link_tab): + new_tab = copy.deepcopy(origin) + new_tab.link_name = link_tab.link_name + new_tab.tab_name = link_tab.tab_name + new_tab.face_name = link_tab.face_name + new_tab.description = link_tab.description + new_tab.freecad_obj_name = link_tab.freecad_obj_name + new_tab.y_invert = link_tab.y_invert + new_tab.transform_matrix = link_tab.transform_matrix + new_tab.thickness = link_tab.thickness + new_tab.y_length = link_tab.y_length + return new_tab + + def _promote_link(self, name, batch_names): + linked = [tab for tab in self.get_linked_tabs(name) if tab.tab_name not in batch_names] + if len(linked) == 0: + return + origin, widget = self.get(name) + promoted_tab_name = linked[0].tab_name + for index in range(len(self.faces.lst)): + if self.faces.lst[index].tab_name == promoted_tab_name: + promoted = self._merged_from_origin(origin, self.faces.lst[index]) + promoted.link_name = "" + self.faces.lst[index] = promoted + break + for other in linked[1:]: + for index in range(len(self.faces.lst)): + if self.faces.lst[index].tab_name == other.tab_name: + self.faces.lst[index].link_name = promoted_tab_name + break + FreeCAD.Console.PrintMessage("%s promoted to origin of the linked faces group\n" % promoted_tab_name) + def exist(self, name): for part in self.faces: if part.tab_name == name: @@ -230,7 +259,7 @@ def get(self, name): def get_linked_tabs(self, name): el_list = [] for tab in self.faces: - if isinstance(tab, TabLink) and tab.link_name == name: + if tab.link_name == name: el_list.append(tab) return el_list @@ -242,18 +271,7 @@ def get_tabs_properties(self): for tab in self.faces.lst: if tab.link_name: tab_link, widget = self.get(tab.link_name) - new_tab = copy.deepcopy(tab_link) - new_tab.link_name = tab.link_name - new_tab.tab_name = tab.tab_name - new_tab.face_name = tab.face_name - new_tab.description = tab.description - new_tab.freecad_obj_name = tab.freecad_obj_name - new_tab.y_invert = tab.y_invert - new_tab.transform_matrix = tab.transform_matrix - new_tab.thickness = tab.thickness - new_tab.y_length = tab.y_length - - tabs_properties.append(new_tab) + tabs_properties.append(self._merged_from_origin(tab_link, tab)) else: tabs_properties.append(copy.deepcopy(tab)) diff --git a/panel/treepanel.py b/panel/treepanel.py index a8b8fa4..f34b7de 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -223,30 +223,19 @@ def collect(tree_item): for index in indexes: collect(index.internalPointer()) + # An origin still linked from outside this batch is promoted to a new + # origin (PartsList/TabsList.remove) rather than blocking the removal. names_to_remove = set(item.get_name() for item in items_to_remove) - for item in items_to_remove: - if item.type == TreeItem.PART: - linked = [n for n in self.partsList.get_linked_parts(item.get_name()) if n not in names_to_remove] - if len(linked) > 0: - FreeCAD.Console.PrintError('Some parts are linked to this part %s\n' % item.get_name()) - return False - elif item.type == TreeItem.TAB: - linked = [tab.name for tab in self.tabsList.get_linked_tabs(item.get_name()) if tab.name not in names_to_remove] - if len(linked) > 0: - FreeCAD.Console.PrintError('Some tabs are linked to this tab %s\n' % item.get_name()) - return False - - # Retry in passes so a link removed in the same batch as its origin resolves either order. remaining = items_to_remove while remaining: still_remaining = [] for item in remaining: try: if item.type == TreeItem.PART or item.type == TreeItem.PART_LINK: - self.partsList.remove(item.get_name()) + self.partsList.remove(item.get_name(), names_to_remove) elif item.type == TreeItem.TAB or item.type == TreeItem.TAB_LINK: - self.tabsList.remove(item.get_name()) + self.tabsList.remove(item.get_name(), names_to_remove) else: FreeCAD.Console.PrintError("Unknown deleter item") except ValueError: From c5efc57ad9b6892e282234431462f5f2a8cd6153 Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 22:24:18 +0200 Subject: [PATCH 06/10] Added auto-detect faces feature --- lasercut/autodetect.py | 116 +++++++++++++++++++++++++++++++++++++++++ panel/treepanel.py | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 lasercut/autodetect.py diff --git a/lasercut/autodetect.py b/lasercut/autodetect.py new file mode 100644 index 0000000..9ed6608 --- /dev/null +++ b/lasercut/autodetect.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python + +# *************************************************************************** +# * * +# * Copyright (c) 2016 execuc * +# * * +# * This file is part of LCInterlocking module. * +# * LCInterlocking module is free software; you can redistribute it and/or* +# * modify it under the terms of the GNU Lesser General Public * +# * License as published by the Free Software Foundation; either * +# * version 2.1 of the License, or (at your option) any later version. * +# * * +# * This module is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with this library; if not, write to the Free Software * +# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * +# * MA 02110-1301 USA * +# * * +# *************************************************************************** + +from lasercut import helper +from lasercut.tabproperties import TabProperties + +# Extra depth added past the neighboring part's own thickness when probing for +# a physical intersection, to absorb tiny numerical/zero-gap edge cases. +PROBE_MARGIN = 0.2 +MIN_INTERSECT_VOLUME = 0.001 + + +class CandidateFace: + def __init__(self, freecad_obj, face_index, face, y_length, thickness): + self.freecad_obj = freecad_obj + self.face_index = face_index + self.face = face + self.face_name = "Face%d" % (face_index + 1) + self.y_length = y_length + self.thickness = thickness + + +def collect_candidate_faces(freecad_objects): + candidates = [] + for obj in freecad_objects: + for index, face in enumerate(obj.Shape.Faces): + try: + normal, y_local, z_local = helper.get_local_axis(face) + except Exception: + # get_local_axis assumes a planar quad face; skip anything else + # (curved faces, fillets, holes, ...) rather than crashing the scan. + continue + if normal is None: + continue + candidates.append(CandidateFace(obj, index, face, y_local.Length, z_local.Length)) + return candidates + + +def _intersecting_parts(candidate, freecad_objects, thickness_by_name): + normal = candidate.face.normalAt(0, 0).normalize() + matches = [] + for obj in freecad_objects: + if obj is candidate.freecad_obj: + continue + depth = thickness_by_name.get(obj.Name, candidate.thickness) + PROBE_MARGIN + try: + probe = candidate.face.extrude(normal * depth) + volume = obj.Shape.common(probe).Volume + except Exception: + continue + if volume > MIN_INTERSECT_VOLUME: + matches.append(obj) + return matches + + +def find_connections(freecad_objects, thickness_by_name): + """For every candidate tab face, test (via the same boolean-intersection + test the real cut algorithm uses) which other part it would actually cut + into. A face connecting to exactly one other part is a confident match; + each physical connection is only reported once (from whichever side is + found first), since only one side needs a tab entry.""" + candidates = collect_candidate_faces(freecad_objects) + + connections = [] + ambiguous = [] + unmatched = [] + seen_pairs = set() + for candidate in candidates: + matches = _intersecting_parts(candidate, freecad_objects, thickness_by_name) + if len(matches) == 0: + unmatched.append(candidate) + elif len(matches) > 1: + ambiguous.append(candidate) + else: + target = matches[0] + pair_key = frozenset([candidate.freecad_obj.Name, target.Name]) + if pair_key in seen_pairs: + continue + seen_pairs.add(pair_key) + connections.append((candidate, target)) + return connections, ambiguous, unmatched + + +def compute_tab_sizing(y_length, desired_tab_width, tab_type): + if tab_type == TabProperties.TYPE_CONTINUOUS: + # tabs_number counts every alternating segment here, tab and gap alike. + tabs_number = max(2, int(round(y_length / desired_tab_width))) + return tabs_number, desired_tab_width + + # tabs_number counts only the solid tabs, so double the period to leave a gap of roughly the same size. + tabs_number = max(1, int(round(y_length / (2. * desired_tab_width)))) + tabs_width = desired_tab_width + while tabs_number > 1 and tabs_number * tabs_width > y_length: + tabs_number -= 1 + return tabs_number, tabs_width diff --git a/panel/treepanel.py b/panel/treepanel.py index f34b7de..a759f16 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -38,6 +38,7 @@ from lasercut.tabproperties import TabProperties from panel.treeview import TreeModel, TreeItem from panel.propertieslist import PropertiesList +from lasercut import autodetect PREVIEW_NONE = 0 @@ -147,6 +148,18 @@ def init_tree_widget(self): h_box.addWidget(add_faces_button) h_box.addWidget(add_same_faces_button) self.tree_vbox.addLayout(h_box) + # Auto button + h_box = QtGui.QHBoxLayout() + h_box.addWidget(QtGui.QLabel('Tab width:', self.tree_widget)) + self.auto_tab_width_box = QtGui.QDoubleSpinBox(self.tree_widget) + self.auto_tab_width_box.setRange(1., 300.) + self.auto_tab_width_box.setDecimals(2) + self.auto_tab_width_box.setValue(10.) + h_box.addWidget(self.auto_tab_width_box) + auto_button = QtGui.QPushButton('Auto-add faces', self.tree_widget) + auto_button.clicked.connect(self.auto_configure) + h_box.addWidget(auto_button) + self.tree_vbox.addLayout(h_box) # tree self.selection_model = self.tree_view_widget.selectionModel() self.selection_model.selectionChanged.connect(self.selection_changed) @@ -295,6 +308,84 @@ def add_same_tabs(self): self.force_selection(index) return + def auto_configure(self): + self.check_is_in_active_view() + freecad_objects = [] + thickness_by_name = {} + for material in self.partsList: + freecad_obj = self.active_document.getObject(material.name) + if freecad_obj is None: + FreeCAD.Console.PrintWarning("Part %s no longer exists in the document\n" % material.name) + continue + freecad_objects.append(freecad_obj) + thickness_by_name[freecad_obj.Name] = material.thickness + if len(freecad_objects) == 0: + FreeCAD.Console.PrintWarning("No parts added yet\n") + return + + desired_width = self.auto_tab_width_box.value() + tab_type = self.tab_type_box.currentText() + connections, ambiguous, unmatched = autodetect.find_connections(freecad_objects, thickness_by_name) + + # Skip connections where the face is already configured, so a repeated + # Auto run never disturbs faces added or edited manually before it. + filtered = [] + for candidate, target in connections: + tab_name = "%s.%s" % (candidate.freecad_obj.Name, candidate.face_name) + if self.tabsList.exist(tab_name): + continue + tabs_number, tabs_width = autodetect.compute_tab_sizing(candidate.y_length, desired_width, tab_type) + filtered.append((candidate, target, tabs_number, tabs_width)) + + # Group by size so same-size connections are linked (edit one, edit all). + groups = {} + for candidate, target, tabs_number, tabs_width in filtered: + key = (round(candidate.y_length, 1), round(candidate.thickness, 1)) + groups.setdefault(key, []).append((candidate, target, tabs_number, tabs_width)) + + added_count = 0 + last_index = None + for key, entries in groups.items(): + origin_candidate, origin_target, tabs_number, tabs_width = entries[0] + face_dict = {'freecad_object': origin_candidate.freecad_obj, + 'face': origin_candidate.face, + 'name': origin_candidate.face_name} + try: + item = self.tabsList.append(face_dict, tab_type) + except ValueError as e: + FreeCAD.Console.PrintError(e) + continue + item.tabs_number = tabs_number + item.tabs_width = tabs_width + last_index = self.model.append_tab(item.freecad_obj_name, item.tab_name, item.face_name) + added_count += 1 + FreeCAD.Console.PrintMessage("Auto: added %s.%s -> %s (%.1fmm, %d tabs)\n" % ( + origin_candidate.freecad_obj.Name, origin_candidate.face_name, + origin_target.Name, origin_candidate.y_length, tabs_number)) + + if len(entries) > 1: + for link_candidate, _, _, _ in entries[1:]: + link_dict = {'freecad_object': link_candidate.freecad_obj, + 'face': link_candidate.face, + 'name': link_candidate.face_name} + try: + sub_item = self.tabsList.append_link(link_dict, item.tab_name) + except ValueError as e: + FreeCAD.Console.PrintError(e) + continue + self.model.append_tab(sub_item.freecad_obj_name, sub_item.tab_name, sub_item.face_name, True) + added_count += 1 + FreeCAD.Console.PrintMessage( + "Auto: linked %d connections of size %.1fx%.1fmm - verify these are meant to share settings\n" + % (len(entries), key[0], key[1])) + + FreeCAD.Console.PrintMessage( + "Auto: added %d connection(s) in %d group(s), %d unmatched, %d ambiguous face(s) (review the tree " + "and use Remove item for anything wrong)\n" % (added_count, len(groups), len(unmatched), len(ambiguous))) + if last_index is not None: + self.force_selection(last_index) + return + def force_selection(self, index): if index is None or not index.isValid(): return From 1a8c307f0a0a62aae9a50b23b58138c2edd2b78a Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 22:33:50 +0200 Subject: [PATCH 07/10] Fixed thickness of tabs not updating --- lasercut/material.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lasercut/material.py b/lasercut/material.py index 87db3e1..1b9379c 100644 --- a/lasercut/material.py +++ b/lasercut/material.py @@ -81,7 +81,8 @@ def recomputeInit(self, freecad_obj): FreeCAD.Console.PrintError(e) return if compare_value(thickness, self.thickness) is False: - FreeCAD.Console.PrintError("Recomputed thickness for %s is different (%f != %f)\n" % (self.name, thickness, self.thickness)) + FreeCAD.Console.PrintMessage("Thickness for %s updated (%f -> %f)\n" % (self.name, self.thickness, thickness)) + self.thickness = thickness # Prendre la normal la plus présente en terme de surface (biggest_area_faces) From 7ece6f273b6b2f48613e98b15992a3ff8a81fa95 Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 22:41:58 +0200 Subject: [PATCH 08/10] Fixed ambiguous cases in auto-detect --- lasercut/autodetect.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lasercut/autodetect.py b/lasercut/autodetect.py index 9ed6608..3463144 100644 --- a/lasercut/autodetect.py +++ b/lasercut/autodetect.py @@ -75,17 +75,11 @@ def _intersecting_parts(candidate, freecad_objects, thickness_by_name): def find_connections(freecad_objects, thickness_by_name): - """For every candidate tab face, test (via the same boolean-intersection - test the real cut algorithm uses) which other part it would actually cut - into. A face connecting to exactly one other part is a confident match; - each physical connection is only reported once (from whichever side is - found first), since only one side needs a tab entry.""" candidates = collect_candidate_faces(freecad_objects) - connections = [] ambiguous = [] unmatched = [] - seen_pairs = set() + best_by_pair = {} for candidate in candidates: matches = _intersecting_parts(candidate, freecad_objects, thickness_by_name) if len(matches) == 0: @@ -95,10 +89,11 @@ def find_connections(freecad_objects, thickness_by_name): else: target = matches[0] pair_key = frozenset([candidate.freecad_obj.Name, target.Name]) - if pair_key in seen_pairs: - continue - seen_pairs.add(pair_key) - connections.append((candidate, target)) + existing = best_by_pair.get(pair_key) + # Both sides of a T-joint pass the test; keep the smaller face as the tab face. + if existing is None or candidate.face.Area < existing[0].face.Area: + best_by_pair[pair_key] = (candidate, target) + connections = list(best_by_pair.values()) return connections, ambiguous, unmatched From 631c834754398b069a3ace0f2afc3535f9d1f679 Mon Sep 17 00:00:00 2001 From: buergi Date: Sun, 6 Sep 2026 22:47:53 +0200 Subject: [PATCH 09/10] Cleanup on MultiJoin delete --- panel/crosspiece.py | 10 ++++++++++ panel/multiplejoins.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/panel/crosspiece.py b/panel/crosspiece.py index 3eb69c6..04e4967 100644 --- a/panel/crosspiece.py +++ b/panel/crosspiece.py @@ -203,6 +203,16 @@ def claimChildren(self): children.append(self.Object.originFolder) return children + list(self.Object.generatedParts) + def onDelete(self, *args): + document = self.Object.Document + for obj in self.Object.fromParts: + obj.ViewObject.show() + for obj in list(self.Object.generatedParts): + document.removeObject(obj.Name) + if hasattr(self.Object, "originFolder") and self.Object.originFolder is not None: + document.removeObject(self.Object.originFolder.Name) + return True + class CrossPiece(TreePanel): def __init__(self, obj_join): diff --git a/panel/multiplejoins.py b/panel/multiplejoins.py index 140d48a..3a82c2e 100644 --- a/panel/multiplejoins.py +++ b/panel/multiplejoins.py @@ -221,6 +221,16 @@ def claimChildren(self): children.append(self.Object.originFolder) return children + list(self.Object.generatedParts) + def onDelete(self, *args): + document = self.Object.Document + for obj in self.Object.fromParts: + obj.ViewObject.show() + for obj in list(self.Object.generatedParts): + document.removeObject(obj.Name) + if hasattr(self.Object, "originFolder") and self.Object.originFolder is not None: + document.removeObject(self.Object.originFolder.Name) + return True + class MultipleJoins(TreePanel): def __init__(self, obj_join): From 4ab728c369b28bda17ef29eedd2beb94e8eec699 Mon Sep 17 00:00:00 2001 From: buergi Date: Mon, 7 Sep 2026 00:39:38 +0200 Subject: [PATCH 10/10] Reduced verboseness --- panel/crosspiece.py | 1 + panel/multiplejoins.py | 1 + panel/treepanel.py | 12 ++++-------- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/panel/crosspiece.py b/panel/crosspiece.py index 04e4967..eb2ed42 100644 --- a/panel/crosspiece.py +++ b/panel/crosspiece.py @@ -119,6 +119,7 @@ def execute(self, fp): freecad_obj = document.getObject(cp_part.name) freedac_origin_obj.append(freecad_obj) cp_part.recomputeInit(freecad_obj) + part.thickness = cp_part.thickness parts.append(cp_part) fp.fromParts = freedac_origin_obj diff --git a/panel/multiplejoins.py b/panel/multiplejoins.py index 3a82c2e..9763896 100644 --- a/panel/multiplejoins.py +++ b/panel/multiplejoins.py @@ -129,6 +129,7 @@ def execute(self, fp): freecad_obj = document.getObject(cp_part.name) freedac_origin_obj.append(freecad_obj) cp_part.recomputeInit(freecad_obj) + part.thickness = cp_part.thickness parts.append(cp_part) fp.fromParts = freedac_origin_obj diff --git a/panel/treepanel.py b/panel/treepanel.py index a759f16..3b3cee5 100644 --- a/panel/treepanel.py +++ b/panel/treepanel.py @@ -344,6 +344,7 @@ def auto_configure(self): groups.setdefault(key, []).append((candidate, target, tabs_number, tabs_width)) added_count = 0 + linked_group_count = 0 last_index = None for key, entries in groups.items(): origin_candidate, origin_target, tabs_number, tabs_width = entries[0] @@ -359,11 +360,9 @@ def auto_configure(self): item.tabs_width = tabs_width last_index = self.model.append_tab(item.freecad_obj_name, item.tab_name, item.face_name) added_count += 1 - FreeCAD.Console.PrintMessage("Auto: added %s.%s -> %s (%.1fmm, %d tabs)\n" % ( - origin_candidate.freecad_obj.Name, origin_candidate.face_name, - origin_target.Name, origin_candidate.y_length, tabs_number)) if len(entries) > 1: + linked_group_count += 1 for link_candidate, _, _, _ in entries[1:]: link_dict = {'freecad_object': link_candidate.freecad_obj, 'face': link_candidate.face, @@ -375,13 +374,10 @@ def auto_configure(self): continue self.model.append_tab(sub_item.freecad_obj_name, sub_item.tab_name, sub_item.face_name, True) added_count += 1 - FreeCAD.Console.PrintMessage( - "Auto: linked %d connections of size %.1fx%.1fmm - verify these are meant to share settings\n" - % (len(entries), key[0], key[1])) FreeCAD.Console.PrintMessage( - "Auto: added %d connection(s) in %d group(s), %d unmatched, %d ambiguous face(s) (review the tree " - "and use Remove item for anything wrong)\n" % (added_count, len(groups), len(unmatched), len(ambiguous))) + "Auto: added %d connection(s) in %d group(s) (%d linked), %d unmatched, %d ambiguous face(s)\n" + % (added_count, len(groups), linked_group_count, len(unmatched), len(ambiguous))) if last_index is not None: self.force_selection(last_index) return