From 87b7c86197b0f704229d92ebfdd718795d22fd4a Mon Sep 17 00:00:00 2001 From: DeepSOIC Date: Fri, 29 Jul 2016 14:16:54 +0300 Subject: [PATCH] Part: add BOPTools package Containing implementation of new JoinConnect, as well as new splitting tools: BooleanFragments, Slice and XOR. --- src/Mod/Part/App/CMakeLists.txt | 8 + src/Mod/Part/BOPTools/GeneralFuseResult.py | 420 ++++++++++++++++++++ src/Mod/Part/BOPTools/JoinAPI.py | 175 ++++++++ src/Mod/Part/BOPTools/JoinFeatures.py | 386 ++++++++++++++++++ src/Mod/Part/BOPTools/ShapeMerge.py | 227 +++++++++++ src/Mod/Part/BOPTools/SplitAPI.py | 103 +++++ src/Mod/Part/BOPTools/SplitFeatures.py | 441 +++++++++++++++++++++ src/Mod/Part/BOPTools/Utils.py | 130 ++++++ src/Mod/Part/BOPTools/__init__.py | 75 ++++ src/Mod/Part/CMakeLists.txt | 14 + src/Mod/Part/InitGui.py | 6 +- 11 files changed, 1982 insertions(+), 3 deletions(-) create mode 100644 src/Mod/Part/BOPTools/GeneralFuseResult.py create mode 100644 src/Mod/Part/BOPTools/JoinAPI.py create mode 100644 src/Mod/Part/BOPTools/JoinFeatures.py create mode 100644 src/Mod/Part/BOPTools/ShapeMerge.py create mode 100644 src/Mod/Part/BOPTools/SplitAPI.py create mode 100644 src/Mod/Part/BOPTools/SplitFeatures.py create mode 100644 src/Mod/Part/BOPTools/Utils.py create mode 100644 src/Mod/Part/BOPTools/__init__.py diff --git a/src/Mod/Part/App/CMakeLists.txt b/src/Mod/Part/App/CMakeLists.txt index 29af1b8ca15b..a788d1484c22 100644 --- a/src/Mod/Part/App/CMakeLists.txt +++ b/src/Mod/Part/App/CMakeLists.txt @@ -292,6 +292,14 @@ SET(Part_Scripts AttachmentEditor/FrozenClass.py AttachmentEditor/TaskAttachmentEditor.py AttachmentEditor/TaskAttachmentEditor.ui + BOPTools/__init__.py + BOPTools/GeneralFuseResult.py + BOPTools/JoinAPI.py + BOPTools/JoinFeatures.py + BOPTools/ShapeMerge.py + BOPTools/SplitAPI.py + BOPTools/SplitFeatures.py + BOPTools/Utils.py ) add_library(Part SHARED ${Part_SRCS}) diff --git a/src/Mod/Part/BOPTools/GeneralFuseResult.py b/src/Mod/Part/BOPTools/GeneralFuseResult.py new file mode 100644 index 000000000000..b5b34dcd5e05 --- /dev/null +++ b/src/Mod/Part/BOPTools/GeneralFuseResult.py @@ -0,0 +1,420 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.GeneralFuseResult module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Implementation of GeneralFuseResult class, which parses return of generalFuse." + +import Part +from .Utils import HashableShape, HashableShape_Deep, FrozenClass + + +class GeneralFuseResult(FrozenClass): + """class GeneralFuseResult: helper object for obtaining info from results of + Part.Shape.generalFuse() method. + +Usage: +def myCustomFusionRoutine(list_of_shapes): + generalFuse_return = list_of_shapes[0].generalFuse(list_of_shapes[1:]) + ao = GeneralFuseResult(list_of_shapes, generalFuse_return) + ... (use attributes and methods of ao) ...""" + + def __define_attributes(self): + self.gfa_return = None #stores the data returned by generalFuse, supplied to class constructor + + self.pieces = None #pieces that resulted from intersetion routine. List of shapes (non-decorated). + self._piece_to_index = {} # key = decorated shape. Value = index (int) into self.pieces + + self.source_shapes = [] # list of shapes that was supplied to generalFuse (plus the self-shape). List of shapes (non-decorated) + self._source_to_index = {} # key = decorated shape. Value = index (int) into self.source_shapes + + self._pieces_of_source = [] #list of pieces (indexes) generated from a source shape, by index of source shape. List of lists of ints. + self._sources_of_piece = [] #list of source shapes (indexes) the piece came from, by index of piece. List of lists of ints. + + self._element_to_source = {} #dictionary for finding, which source shapes did an element of pieces come from. key = HashableShape (element). Value = set of ints + + self._freeze() + + def __init__(self, source_shapes, generalFuse_return): + self.__define_attributes() + + self.gfa_return = generalFuse_return + self.source_shapes = source_shapes + self.parse() + + def parse(self): + """Parses the result of generalFuse recorded into self.gfa_return. Recovers missing + information. Fills in data structures. + + It is called automatically by class constructor.""" + + #save things to be parsed and wipe out all other data + gfa_return = self.gfa_return + source_shapes = self.source_shapes + self.__define_attributes() + self.gfa_return = gfa_return + self.source_shapes = source_shapes + # and start filling in data structures... + + compound, map = self.gfa_return + self.pieces = compound.childShapes() + + # create piece shape index + for iPiece in range(len(self.pieces)): + ha_piece = HashableShape(self.pieces[iPiece]) + if not ha_piece in self._piece_to_index: + self._piece_to_index[ha_piece] = iPiece + else: + raise ValueError("GeneralFuseAnalyzer.parse: duplicate piece shape detected.") + # create source shape index + for iSource in range(len(self.source_shapes)): + ha_source = HashableShape(self.source_shapes[iSource]) + if not ha_source in self._source_to_index: + self._source_to_index[ha_source] = iSource + else: + raise ValueError("GeneralFuseAnalyzer.parse: duplicate source shape detected.") + + #test if map has missing entries + map_needs_repairing = False + for iSource in range(len(map)): + if len(map[iSource]) == 0: + map_needs_repairing = True + + if map_needs_repairing: + aggregate_types = set(["Wire","Shell","CompSolid","Compound"]) + nonaggregate_types = set(["Vertex","Edge","Face","Solid"]) + + types = set() + for piece in self.pieces: + types.add(piece.ShapeType) + + types_to_extract = types.intersection(nonaggregate_types) + extractor = lambda(sh):( + (sh.Vertexes if "Vertex" in types_to_extract else []) + + (sh.Edges if "Edge" in types_to_extract else []) + + (sh.Faces if "Face" in types_to_extract else []) + + (sh.Solids if "Solid" in types_to_extract else []) + ) + + aggregate_sources_indexes = [self.indexOfSource(sh) for sh in self.source_shapes if sh.ShapeType in aggregate_types] + aggregate_pieces = [sh for sh in self.pieces if sh.ShapeType in aggregate_types] + assert(len(aggregate_sources_indexes) == len(aggregate_pieces)) + for i_aggregate in range(len(aggregate_sources_indexes)): + iSource = aggregate_sources_indexes[i_aggregate] + if len(map[iSource]) == 0:#recover only if info is actually missing + map[iSource] = [aggregate_pieces[i_aggregate]] + #search if any plain pieces are also in this aggregate piece. If yes, we need to add the piece to map. + for sh in extractor(aggregate_pieces[i_aggregate]): + hash = HashableShape(sh) + iPiece = self._piece_to_index.get(hash) + if iPiece is not None: + #print "found piece {num} in compound {numc}".format(num= iPiece, numc= i_aggregate) + if not map[iSource][-1].isSame(self.pieces[iPiece]): + map[iSource].append(self.pieces[iPiece]) + + # check the map was recovered successfully + for iSource in range(len(map)): + if len(map[iSource]) == 0: + import FreeCAD as App + App.Console.PrintWarning("Map entry {num} is empty. Source-to-piece correspondence information is probably incomplete.".format(num= iSource)) + + self._pieces_of_source = [[] for i in range(len(self.source_shapes))] + self._sources_of_piece = [[] for i in range(len(self.pieces))] + assert(len(map) == len(self.source_shapes)) + for iSource in range(len(self.source_shapes)): + list_pieces = map[iSource] + for piece in list_pieces: + iPiece = self.indexOfPiece(piece) + self._sources_of_piece[iPiece].append(iSource) + self._pieces_of_source[iSource].append(iPiece) + + def parse_elements(self): + """Fills element-to-source map. Potentially slow, so separated from general parse. + Needed for splitAggregates; called automatically from splitAggregates.""" + + if len(self._element_to_source)>0: + return #already parsed. + + for iPiece in range(len(self.pieces)): + piece = self.pieces[iPiece] + for element in piece.Vertexes + piece.Edges + piece.Faces + piece.Solids: + el_h = HashableShape(element) + if el_h in self._element_to_source: + self._element_to_source[el_h].update(set(self._sources_of_piece[iPiece])) + else: + self._element_to_source[el_h] = set(self._sources_of_piece[iPiece]) + + def indexOfPiece(self, piece_shape): + "indexOfPiece(piece_shape): returns index of piece_shape in list of pieces" + return self._piece_to_index[HashableShape(piece_shape)] + def indexOfSource(self, source_shape): + "indexOfSource(source_shape): returns index of source_shape in list of arguments" + return self._source_to_index[HashableShape(source_shape)] + + def piecesFromSource(self, source_shape): + """piecesFromSource(source_shape): returns list of pieces (shapes) that came from + given source shape. + + Note: aggregate pieces (e.g. wire, shell, compound) always have only one source - the + shape they came directly from. Only after executing splitAggregates and + explodeCompounds the source lists become completely populated.""" + + ilist = self._pieces_of_source[self.indexOfSource(source_shape)] + return [self.pieces[i] for i in ilist] + + def sourcesOfPiece(self, piece_shape): + """sourcesOfPiece(piece_shape): returns list of source shapes given piece came from. + + Note: aggregate pieces (e.g. wire, shell, compound) always have only one source - the + shape they came directly from. Only after executing splitAggregates and + explodeCompounds the source lists become completely populated.""" + + ilist = self._sources_of_piece[self.indexOfPiece(piece_shape)] + return [self.source_shapes[i] for i in ilist] + + def largestOverlapCount(self): + """largestOverlapCount(self): returns the largest overlap count. For example, if three + spheres intersect and have some volume common to all three, largestOverlapCount + returns 3. + + Note: the return value may be incorrect if some of the pieces are wires/shells/ + compsolids/compounds. Please use explodeCompounds and splitAggregates before using this function.""" + + return max([len(ilist) for ilist in self._sources_of_piece]) + + def splitAggregates(self, pieces_to_split = None): + """splitAggregates(pieces_to_split = None): splits aggregate shapes (wires, shells, + compsolids) in pieces of GF result as cut by intersections. Also splits aggregates + inside compounds. After running this, 'self' is replaced with new data, where the + pieces_to_split are split. + + 'pieces_to_split': list of shapes (from self.pieces), that are to be processed. If + None, all pieces will be split if possible. + + Notes: + * this routine is very important to functioning of Connect on shells and wires. + * Warning: convoluted and slow.""" + + if pieces_to_split is None: + pieces_to_split = self.pieces + pieces_to_split = [HashableShape(piece) for piece in pieces_to_split] + pieces_to_split = set(pieces_to_split) + + self.parse_elements() + new_data = GeneralFuseReturnBuilder(self.source_shapes) + changed = False + + #split pieces that are not compounds.... + for iPiece in range(len(self.pieces)): + piece = self.pieces[iPiece] + + if HashableShape(piece) in pieces_to_split: + new_pieces = self.makeSplitPieces(piece) + changed = changed or len(new_pieces)>1 + for new_piece in new_pieces: + new_data.addPiece(new_piece, self._sources_of_piece[iPiece]) + else: + new_data.addPiece(piece, self._sources_of_piece[iPiece]) + + #split pieces inside compounds + #prepare index of existing pieces. + existing_pieces = new_data._piece_to_index.copy() + for i_new_piece in range(len(new_data.pieces)): + new_piece = new_data.pieces[i_new_piece] + if HashableShape(new_piece) in pieces_to_split: + if new_piece.ShapeType == "Compound": + ret = self._splitInCompound(new_piece, existing_pieces) + if ret is not None: + changed = True + new_data.replacePiece(i_new_piece, ret) + + if len(new_data.pieces) > len(self.pieces) or changed: + self.gfa_return = new_data.getGFReturn() + self.parse() + #else: + #print "Nothing was split" + + def _splitInCompound(self, compound, existing_pieces): + """Splits aggregates inside compound. Returns None if nothing is split, otherwise + returns compound. + existing_pieces is a dict. Key is deep hash. Value is tuple (int, shape). It is + used to search for if this split piece was already generated, and re-use the old + one.""" + + changed = False + new_children = [] + for piece in compound.childShapes(): + if piece.ShapeType == "Compound": + subspl = self._splitInCompound(piece, existing_pieces) + if subspl is None: + new_children.append(piece) + else: + new_children.append(subspl) + changed = True + else: + new_pieces = self.makeSplitPieces(piece) + changed = changed or len(new_pieces)>1 + for new_piece in new_pieces: + hash = HashableShape_Deep(new_piece) + dummy,ex_piece = existing_pieces.get(hash, (None, None)) + if ex_piece is not None: + new_children.append(ex_piece) + changed = True + else: + new_children.append(new_piece) + existing_pieces[hash] = (-1, new_piece) + if changed: + return Part.Compound(new_children) + else: + return None + + + def makeSplitPieces(self, shape): + """makeSplitPieces(self, shape): splits a shell, wire or compsolid into pieces where + it intersects with other shapes. + + Returns list of split pieces. If no splits were done, returns list containing the + original shape.""" + + if shape.ShapeType == "Wire": + bit_extractor = lambda(sh): sh.Edges + joint_extractor = lambda(sh): sh.Vertexes + elif shape.ShapeType == "Shell": + bit_extractor = lambda(sh): sh.Faces + joint_extractor = lambda(sh): sh.Edges + elif shape.ShapeType == "CompSolid": + bit_extractor = lambda(sh): sh.Solids + joint_extractor = lambda(sh): sh.Faces + else: + #can't split the shape + return [shape] + + # for each joint, test if all bits it's connected to are from same number of sources. If not, this is a joint for splitting + # FIXME: this is slow, and maybe can be optimized + splits = [] + for joint in joint_extractor(shape): + joint_overlap_count = len(self._element_to_source[HashableShape(joint)]) + if joint_overlap_count > 1: + # find elements in pieces that are connected to joint + for bit in bit_extractor(self.gfa_return[0]): + for joint_bit in joint_extractor(bit): + if joint_bit.isSame(joint): + #bit is connected to joint! + bit_overlap_count = len(self._element_to_source[HashableShape(bit)]) + assert(bit_overlap_count <= joint_overlap_count) + if bit_overlap_count < joint_overlap_count: + if len(splits) == 0 or splits[-1] is not joint: + splits.append(joint) + if len(splits)==0: + #shape was not split - no split points found + return [shape] + + from . import ShapeMerge + + new_pieces = ShapeMerge.mergeShapes(bit_extractor(shape), split_connections= splits, bool_compsolid= True).childShapes() + if len(new_pieces) == 1: + #shape was not split (split points found, but the shape remained in one piece). + return [shape] + return new_pieces + + def explodeCompounds(self): + """explodeCompounds(): if any of self.pieces is a compound, the compound is exploded. + After running this, 'self' is filled with new data, where pieces are updated to + contain the stuff extracted from compounds.""" + + has_compounds = False + for piece in self.pieces: + if piece.ShapeType == "Compound": + has_compounds = True + if not has_compounds: + return + + from .Utils import compoundLeaves + + new_data = GeneralFuseReturnBuilder(self.source_shapes) + new_data.hasher_class = HashableShape #deep hashing not needed here. + + for iPiece in range(len(self.pieces)): + piece = self.pieces[iPiece] + if piece.ShapeType == "Compound": + for child in compoundLeaves(piece): + new_data.addPiece(child, self._sources_of_piece[iPiece]) + else: + new_data.addPiece(piece, self._sources_of_piece[iPiece]) + + self.gfa_return = new_data.getGFReturn() + self.parse() + + +class GeneralFuseReturnBuilder(FrozenClass): + "GeneralFuseReturnBuilder: utility class used by splitAggregates to build fake return of generalFuse, for re-parsing." + def __define_attributes(self): + self.pieces = [] + self._piece_to_index = {} # key = hasher_class(shape). Value = (index_into_self_dot_pieces, shape). Note that GeneralFuseResult uses this item directly. + + self._pieces_from_source = [] #list of list of ints + self.source_shapes = [] + + self.hasher_class = HashableShape_Deep + + self._freeze() + + def __init__(self, source_shapes): + self.__define_attributes() + self.source_shapes = source_shapes + self._pieces_from_source = [[] for i in range(len(source_shapes))] + + def addPiece(self, piece_shape, source_shape_index_list): + """addPiece(piece_shape, source_shape_index_list): adds a piece. If the piece + already exists, returns False, and only updates source<->piece map.""" + + ret = False + i_piece_existing = None + hash = None + if piece_shape.ShapeType != "Compound": # do not catch duplicate compounds + hash = self.hasher_class(piece_shape) + i_piece_existing, dummy = self._piece_to_index.get(hash, (None, None)) + + if i_piece_existing is None: + #adding + self.pieces.append(piece_shape) + i_piece_existing = len(self.pieces)-1 + if hash is not None: + self._piece_to_index[hash] = (i_piece_existing, piece_shape,) + ret = True + else: + #re-adding + ret = False + for iSource in source_shape_index_list: + if not i_piece_existing in self._pieces_from_source[iSource]: + self._pieces_from_source[iSource].append(i_piece_existing) + return ret + + def replacePiece(self, piece_index, new_shape): + assert(self.pieces[piece_index].ShapeType == "Compound") + assert(new_shape.ShapeType == "Compound") + self.pieces[piece_index] = new_shape + + def getGFReturn(self): + return (Part.Compound(self.pieces), [[self.pieces[iPiece] for iPiece in ilist] for ilist in self._pieces_from_source]) \ No newline at end of file diff --git a/src/Mod/Part/BOPTools/JoinAPI.py b/src/Mod/Part/BOPTools/JoinAPI.py new file mode 100644 index 000000000000..e6f5b95ff1ee --- /dev/null +++ b/src/Mod/Part/BOPTools/JoinAPI.py @@ -0,0 +1,175 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.JoinAPI module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "JoinFeatures functions that operate on shapes." + +import Part +from . import ShapeMerge +from . import generalFuseIsAvailable +from .GeneralFuseResult import GeneralFuseResult +from .Utils import compoundLeaves + +def shapeOfMaxSize(list_of_shapes): + """shapeOfMaxSize(list_of_shapes): finds the shape that has the largest mass in the list and returns it. The shapes in the list must be of same dimension.""" + #first, check if shapes can be compared by size + ShapeMerge.dimensionOfShapes(list_of_shapes) + + rel_precision = 1e-8 + + #find it! + max_size = -1e100 # max size encountered so far + count_max = 0 # number of shapes with size equal to max_size + shape_max = None # shape of max_size + for sh in list_of_shapes: + v = abs(Part.cast_to_shape(sh).Mass) + if v > max_size*(1 + rel_precision) : + max_size = v + shape_max = sh + count_max = 1 + elif (1-rel_precision) * max_size <= v and v <= (1+rel_precision) * max_size : + count_max = count_max + 1 + if count_max > 1 : + raise ValueError("There is more than one largest piece!") + return shape_max + +def connect(list_of_shapes, tolerance = 0.0): + """connect(list_of_shapes, tolerance = 0.0): connects solids (walled objects), shells and + wires by throwing off small parts that result when splitting them at intersections. + + Compounds in list_of_shapes are automatically exploded, so self-intersecting compounds + are valid for connect.""" + + # explode all compounds before GFA. + new_list_of_shapes = [] + for sh in list_of_shapes: + new_list_of_shapes.extend( compoundLeaves(sh) ) + list_of_shapes = new_list_of_shapes + + #test if shapes are compatible for connecting + dim = ShapeMerge.dimensionOfShapes(list_of_shapes) + if dim == 0: + raise TypeError("Cannot connect vertices!") + + if len(list_of_shapes) < 2: + return Part.makeCompound(list_of_shapes) + + if not generalFuseIsAvailable(): #fallback to legacy + result = list_of_shapes[0] + for i in range(1, len(list_of_shapes)): + result = connect_legacy(result, list_of_shapes[i], tolerance) + return result + + pieces, map = list_of_shapes[0].generalFuse(list_of_shapes[1:], tolerance) + ao = GeneralFuseResult(list_of_shapes, (pieces, map)) + ao.splitAggregates() + #print len(ao.pieces)," pieces total" + + keepers = [] + all_danglers = [] # debug + + #add all biggest dangling pieces + for src in ao.source_shapes: + danglers = [piece for piece in ao.piecesFromSource(src) if len(ao.sourcesOfPiece(piece)) == 1] + all_danglers.extend(danglers) + largest = shapeOfMaxSize(danglers) + if largest is not None: + keepers.append(largest) + + touch_test_list = Part.Compound(keepers) + #add all intersection pieces that touch danglers, triple intersection pieces that touch duals, and so on + for ii in range(2, ao.largestOverlapCount()+1): + list_ii_pieces = [piece for piece in ao.pieces if len(ao.sourcesOfPiece(piece)) == ii] + keepers_2_add = [] + for piece in list_ii_pieces: + if ShapeMerge.isConnected(piece, touch_test_list): + keepers_2_add.append(piece) + if len(keepers_2_add) == 0: + break + keepers.extend(keepers_2_add) + touch_test_list = Part.Compound(keepers_2_add) + + + #merge, and we are done! + #print len(keepers)," pieces to keep" + return ShapeMerge.mergeShapes(keepers) + +def connect_legacy(shape1, shape2, tolerance = 0.0): + """connect_legacy(shape1, shape2, tolerance = 0.0): alternative implementation of + connect, without use of generalFuse. Slow. Provided for backwards compatibility, and + for older OCC.""" + + if tolerance>0.0: + import FreeCAD as App + App.Console.PrintWarning("connect_legacy does not support tolerance (yet).\n") + cut1 = shape1.cut(shape2) + cut1 = shapeOfMaxSize(cut1.childShapes()) + cut2 = shape2.cut(shape1) + cut2 = shapeOfMaxSize(cut2.childShapes()) + return cut1.multiFuse([cut2, shape2.common(shape1)]) + +#def embed(shape_base, shape_tool, tolerance = 0.0): +# (TODO) + +def embed_legacy(shape_base, shape_tool, tolerance = 0.0): + """embed_legacy(shape_base, shape_tool, tolerance = 0.0): alternative implementation of + embed, without use of generalFuse. Slow. Provided for backwards compatibility, and + for older OCC.""" + if tolerance>0.0: + import FreeCAD as App + App.Console.PrintWarning("embed_legacy does not support tolerance (yet).\n") + + # using legacy implementation, except adding support for shells + pieces = compoundLeaves(shape_base.cut(shape_tool)) + piece = shapeOfMaxSize(pieces) + result = piece.fuse(shape_tool) + dim = ShapeMerge.dimensionOfShapes(pieces) + if dim == 2: + # fusing shells returns shells that are still split. Reassemble them + result = ShapeMerge.mergeShapes(result.Faces) + elif dim == 1: + result = ShapeMerge.mergeShapes(result.Edges) + return result + +def cutout_legacy(shape_base, shape_tool, tolerance = 0.0): + """cutout_legacy(shape_base, shape_tool, tolerance = 0.0): alternative implementation of + cutout, without use of generalFuse. Slow. Provided for backwards compatibility, and + for older OCC.""" + + if tolerance>0.0: + import FreeCAD as App + App.Console.PrintWarning("cutout_legacy does not support tolerance (yet).\n") + #if base is multi-piece, work on per-piece basis + shapes_base = compoundLeaves(shape_base) + if len(shapes_base) > 1: + result = [] + for sh in shapes_base: + result.append(cutout(sh, shape_tool)) + return Part.Compound(result) + + shape_base = shapes_base[0] + pieces = compoundLeaves(shape_base.cut(shape_tool)) + return shapeOfMaxSize(pieces) + diff --git a/src/Mod/Part/BOPTools/JoinFeatures.py b/src/Mod/Part/BOPTools/JoinFeatures.py new file mode 100644 index 000000000000..3a282798fbb8 --- /dev/null +++ b/src/Mod/Part/BOPTools/JoinFeatures.py @@ -0,0 +1,386 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.JoinFeatures module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Implementation of document objects (features) for connect, ebmed and cutout operations." + +from . import JoinAPI +import FreeCAD +import Part + +if FreeCAD.GuiUp: + import FreeCADGui + from PySide import QtCore, QtGui + + +# -------------------------- common stuff -------------------------------------------------- + +#-------------------------- translation-related code ---------------------------------------- +#Thanks, yorik! (see forum thread "A new Part tool is being born... JoinFeatures!" +#http://forum.freecadweb.org/viewtopic.php?f=22&t=11112&start=30#p90239 ) +try: + _fromUtf8 = QtCore.QString.fromUtf8 +except Exception: + def _fromUtf8(s): + return s +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except NameError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) +#--------------------------/translation-related code ---------------------------------------- + +def getParamRefine(): + return FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Part/Boolean").GetBool("RefineModel") + +def cmdCreateJoinFeature(name, mode): + "cmdCreateJoinFeature(name, mode): generalized implementaion of GUI commands." + sel = FreeCADGui.Selection.getSelectionEx() + FreeCAD.ActiveDocument.openTransaction("Create "+mode) + FreeCADGui.addModule("BOPTools.JoinFeatures") + FreeCADGui.doCommand("j = BOPTools.JoinFeatures.make{mode}(name = '{name}')".format(mode= mode, name= name)) + if mode == "Embed" or mode == "Cutout": + FreeCADGui.doCommand("j.Base = App.ActiveDocument."+sel[0].Object.Name) + FreeCADGui.doCommand("j.Tool = App.ActiveDocument."+sel[1].Object.Name) + elif mode == "Connect": + FreeCADGui.doCommand("j.Objects = {sel}".format( + sel= "[" + ", ".join(["App.ActiveDocument."+so.Object.Name for so in sel]) + "]" + )) + else: + raise ValueError("cmdCreateJoinFeature: Unexpected mode {mode}".format(mode= repr(mode))) + + try: + FreeCADGui.doCommand("j.Proxy.execute(j)") + FreeCADGui.doCommand("j.purgeTouched()") + except Exception as err: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_JoinFeatures","Computing the result failed with an error: \n\n{err}\n\n Click 'Continue' to create the feature anyway, or 'Abort' to cancel.", None) + .format(err= err.message)) + mb.setWindowTitle(_translate("Part_JoinFeatures","Bad selection", None)) + btnAbort = mb.addButton(QtGui.QMessageBox.StandardButton.Abort) + btnOK = mb.addButton(_translate("Part_JoinFeatures","Continue",None), QtGui.QMessageBox.ButtonRole.ActionRole) + mb.setDefaultButton(btnOK) + + mb.exec_() + + if mb.clickedButton() is btnAbort: + FreeCAD.ActiveDocument.abortTransaction() + return + + FreeCADGui.doCommand("for obj in j.ViewObject.Proxy.claimChildren():\n" + " obj.ViewObject.hide()") + + FreeCAD.ActiveDocument.commitTransaction() + +def getIconPath(icon_dot_svg): + return ":/icons/" + icon_dot_svg + +# -------------------------- /common stuff -------------------------------------------------- + +# -------------------------- Connect -------------------------------------------------- + +def makeConnect(name): + '''makeConnect(name): makes an Connect object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureConnect(obj) + if FreeCAD.GuiUp: + ViewProviderConnect(obj.ViewObject) + return obj + +class FeatureConnect: + "The PartJoinFeature object" + def __init__(self,obj): + obj.addProperty("App::PropertyLinkList","Objects","Connect","Object to be connectded.") + obj.addProperty("App::PropertyBool","Refine","Connect","True = refine resulting shape. False = output as is.") + obj.Refine = getParamRefine() + obj.addProperty("App::PropertyLength","Tolerance","Connect","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + rst = JoinAPI.connect([obj.Shape for obj in selfobj.Objects], selfobj.Tolerance) + if selfobj.Refine: + rst = rst.removeSplitter() + selfobj.Shape = rst + + +class ViewProviderConnect: + "A View Provider for the Part Connect feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_JoinConnect.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return self.Object.Objects + + def onDelete(self, feature, subelements): + try: + for obj in self.claimChildren(): + obj.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + +class CommandConnect: + "Command to create Connect feature" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_JoinConnect.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Connect objects"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Part_JoinConnect: Fuses objects, taking care to preserve voids.")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) >= 1 : + cmdCreateJoinFeature(name = "Connect", mode = "Connect") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_JoinFeatures", "Select at least two objects, or one or more compounds, first!", None)) + mb.setWindowTitle(_translate("Part_JoinFeatures","Bad selection", None)) + mb.exec_() + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /Connect -------------------------------------------------- + + +# -------------------------- Embed -------------------------------------------------- + + +def makeEmbed(name): + '''makeEmbed(name): makes an Embed object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureEmbed(obj) + if FreeCAD.GuiUp: + ViewProviderEmbed(obj.ViewObject) + return obj + +class FeatureEmbed: + "The Part Embed object" + def __init__(self,obj): + obj.addProperty("App::PropertyLink","Base","Embed","Object to embed into.") + obj.addProperty("App::PropertyLink","Tool","Embed","Object to be embedded.") + obj.addProperty("App::PropertyBool","Refine","Embed","True = refine resulting shape. False = output as is.") + obj.Refine = getParamRefine() + obj.addProperty("App::PropertyLength","Tolerance","Embed","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + rst = JoinAPI.embed_legacy(selfobj.Base.Shape, selfobj.Tool.Shape, selfobj.Tolerance) + if selfobj.Refine: + rst = rst.removeSplitter() + selfobj.Shape = rst + + +class ViewProviderEmbed: + "A View Provider for the Part Embed feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_JoinEmbed.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return [self.Object.Base, self.Object.Tool] + + def onDelete(self, feature, subelements): + try: + self.Object.Base.ViewObject.show() + self.Object.Tool.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + +class CommandEmbed: + "Command to create Part Embed feature" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_JoinEmbed.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Embed object"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Fuses one object into another, taking care to preserve voids.")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) == 2 : + cmdCreateJoinFeature(name = "Embed", mode = "Embed") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_JoinFeatures","Select base object, then the object to embed, and invoke this tool.", None)) + mb.setWindowTitle(_translate("Part_JoinFeatures","Bad selection", None)) + mb.exec_() + + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /Embed -------------------------------------------------- + + + +# -------------------------- Cutout -------------------------------------------------- + +def makeCutout(name): + '''makeCutout(name): makes an Cutout object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureCutout(obj) + if FreeCAD.GuiUp: + ViewProviderCutout(obj.ViewObject) + return obj + +class FeatureCutout: + "The Part Cutout object" + def __init__(self,obj): + obj.addProperty("App::PropertyLink","Base","Cutout","Object to be cut.") + obj.addProperty("App::PropertyLink","Tool","Cutout","Object to make cutout for.") + obj.addProperty("App::PropertyBool","Refine","Cutout","True = refine resulting shape. False = output as is.") + obj.Refine = getParamRefine() + obj.addProperty("App::PropertyLength","Tolerance","Cutout","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + rst = JoinAPI.cutout_legacy(selfobj.Base.Shape, selfobj.Tool.Shape, selfobj.Tolerance) + if selfobj.Refine: + rst = rst.removeSplitter() + selfobj.Shape = rst + + +class ViewProviderCutout: + "A View Provider for the Part Cutout feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_JoinCutout.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return [self.Object.Base, self.Object.Tool] + + def onDelete(self, feature, subelements): + try: + self.Object.Base.ViewObject.show() + self.Object.Tool.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + + +class CommandCutout: + "Command to create PartJoinFeature in Cutout mode" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_JoinCutout.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Cutout for object"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_JoinFeatures","Makes a cutout in one object to fit another object.")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) == 2 : + cmdCreateJoinFeature(name = "Cutout", mode = "Cutout") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_JoinFeatures","Select the object to make a cutout in, then the object that should fit into the cutout, and invoke this tool.", None)) + mb.setWindowTitle(_translate("Part_JoinFeatures","Bad selection", None)) + mb.exec_() + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /Cutout -------------------------------------------------- + +def addCommands(): + FreeCADGui.addCommand('Part_JoinCutout',CommandCutout()) + FreeCADGui.addCommand('Part_JoinEmbed',CommandEmbed()) + FreeCADGui.addCommand('Part_JoinConnect',CommandConnect()) diff --git a/src/Mod/Part/BOPTools/ShapeMerge.py b/src/Mod/Part/BOPTools/ShapeMerge.py new file mode 100644 index 000000000000..134aa98dd350 --- /dev/null +++ b/src/Mod/Part/BOPTools/ShapeMerge.py @@ -0,0 +1,227 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.ShapeMerge module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Tools for merging shapes with shared elements. Useful for final processing of results of Part.Shape.generalFuse()." + +import Part +from .Utils import HashableShape + +def findSharedElements(shape_list, element_extractor): + if len(shape_list) < 2: + raise ValueError("findSharedElements: at least two shapes must be provided (have {num})".format(num= len(shape_list))) + + all_elements = [] #list of sets of HashableShapes + for shape in shape_list: + all_elements.append(set( + [HashableShape(sh) for sh in element_extractor(shape)] + )) + shared_elements = None + for elements in all_elements: + if shared_elements is None: + shared_elements = elements + else: + shared_elements.intersection_update(elements) + return [el.Shape for el in shared_elements] + +def isConnected(shape1, shape2, shape_dim = -1): + if shape_dim == -1: + shape_dim = dimensionOfShapes([shape1, shape2]) + extractor = {0: None, + 1: (lambda(sh): sh.Vertexes), + 2: (lambda(sh): sh.Edges), + 3: (lambda(sh): sh.Faces) }[shape_dim] + return len(findSharedElements([shape1, shape2], extractor))>0 + +def splitIntoGroupsBySharing(list_of_shapes, element_extractor, split_connections = []): + """splitIntoGroupsBySharing(list_of_shapes, element_type, split_connections = []): find, + which shapes in list_of_shapes are connected into groups by sharing elements. + + element_extractor: function that takes shape as input, and returns list of shapes. + + split_connections: list of shapes to exclude when testing for connections. Use to + split groups on purpose. + + return: list of lists of shapes. Top-level list is list of groups; bottom level lists + enumerate shapes of a group.""" + + split_connections = set([HashableShape(element) for element in split_connections]) + + groups = [] #list of tuples (shapes,elements). Shapes is a list of plain shapes. Elements is a set of HashableShapes - all elements of shapes in the group, excluding split_connections. + + # add shapes to the list of groups, one by one. If not connected to existing groups, + # new group is created. If connected, shape is added to groups, and the groups are joined. + for shape in list_of_shapes: + shape_elements = set([HashableShape(element) for element in element_extractor(shape)]) + shape_elements.difference_update(split_connections) + #search if shape is connected to any groups + connected_to = [] + for iGroup in range(len(groups)): + connected = False + for element in shape_elements: + if element in groups[iGroup][1]: + connected_to.append(iGroup) + connected = True + break + # test if we need to join groups + if len(connected_to)>1: + #shape bridges a gap between some groups. Join them into one. + #rebuilding list of groups. First, add the new "supergroup", then add the rest + groups_new = [] + + supergroup = (list(),set()) + for iGroup in connected_to: + supergroup[0].extend( groups[iGroup][0] )# merge lists of shapes + supergroup[1].update( groups[iGroup][1] )# merge lists of elements + groups_new.append(supergroup) + + for iGroup in range(len(groups)): + if not iGroup in connected_to: #fixme: inefficient! + groups_new.append(groups[iGroup]) + groups = groups_new + connected_to = [0] + + # add shape to the group it is connected to (if to many, the groups should have been unified by the above code snippet) + if len(connected_to) > 0: + iGroup = connected_to[0] + groups[iGroup][0].append(shape) + groups[iGroup][1].update( shape_elements ) + else: + newgroup = ([shape], shape_elements) + groups.append(newgroup) + + # done. Discard unnecessary data and return result. + return [shapes for shapes,elements in groups] + +def mergeSolids(list_of_solids_compsolids, flag_single = False, split_connections = [], bool_compsolid = False): + """mergeSolids(list_of_solids, flag_single = False): merges touching solids that share + faces. If flag_single is True, it is assumed that all solids touch, and output is a + single solid. If flag_single is False, the output is a compound containing all + resulting solids. + + Note. CompSolids are treated as lists of solids - i.e., merged into solids.""" + + solids = [] + for sh in list_of_solids_compsolids: + solids.extend(sh.Solids) + if flag_single: + cs = Part.CompSolid(solids) + return cs if bool_compsolid else Part.makeSolid(cs) + else: + if len(solids)==0: + return Part.Compound([]) + groups = splitIntoGroupsBySharing(solids, lambda(sh): sh.Faces, split_connections) + if bool_compsolid: + merged_solids = [Part.CompSolid(group) for group in groups] + else: + merged_solids = [Part.makeSolid(Part.CompSolid(group)) for group in groups] + return Part.makeCompound(merged_solids) + +def mergeShells(list_of_faces_shells, flag_single = False, split_connections = []): + faces = [] + for sh in list_of_faces_shells: + faces.extend(sh.Faces) + if flag_single: + return Part.makeShell(faces) + else: + groups = splitIntoGroupsBySharing(faces, lambda(sh): sh.Edges, split_connections) + return Part.makeCompound([Part.Shell(group) for group in groups]) + +def mergeWires(list_of_edges_wires, flag_single = False, split_connections = []): + edges = [] + for sh in list_of_edges_wires: + edges.extend(sh.Edges) + if flag_single: + return Part.Wire(edges) + else: + groups = splitIntoGroupsBySharing(edges, lambda(sh): sh.Vertexes, split_connections) + return Part.makeCompound([Part.Wire(Part.getSortedClusters(group)[0]) for group in groups]) + +def mergeVertices(list_of_vertices, flag_single = False, split_connections = []): + # no comprehensive support, just following the footprint of other mergeXXX() + return Part.makeCompound(removeDuplicates(list_of_vertices)) + +def mergeShapes(list_of_shapes, flag_single = False, split_connections = [], bool_compsolid = False): + """mergeShapes(list_of_shapes, flag_single = False, split_connections = [], bool_compsolid = False): + merges list of edges/wires into wires, faces/shells into shells, solids/compsolids + into solids or compsolids. + + list_of_shapes: shapes to merge. Shapes must share elements in order to be merged. + + flag_single: assume all shapes in list are connected. If False, return is a compound. + If True, return is the single piece (e.g. a shell). + + split_connections: list of shapes that are excluded when searching for connections. + This can be used for example to split a wire in two by supplying vertices where to + split. If flag_single is True, this argument is ignored. + + bool_compsolid: determines behavior when dealing with solids/compsolids. If True, + result is compsolid/compound of compsolids. If False, all touching solids and + compsolids are unified into single solids. If not merging solids/compsolids, this + argument is ignored.""" + + if len(list_of_shapes)==0: + return Part.Compound([]) + args = [list_of_shapes, flag_single, split_connections] + dim = dimensionOfShapes(list_of_shapes) + if dim == 0: + return mergeVertices(*args) + elif dim == 1: + return mergeWires(*args) + elif dim == 2: + return mergeShells(*args) + elif dim == 3: + args.append(bool_compsolid) + return mergeSolids(*args) + else: + assert(dim >= 0 and dim <= 3) + +def removeDuplicates(list_of_shapes): + hashes = set() + new_list = [] + for sh in list_of_shapes: + hash = HashableShape(sh) + if hash in hashes: + pass + else: + new_list.append(sh) + hashes.add(hash) + return new_list + +def dimensionOfShapes(list_of_shapes): + """dimensionOfShapes(list_of_shapes): returns dimension (0D, 1D, 2D, or 3D) of shapes + in the list. If dimension of shapes varies, TypeError is raised.""" + + dimensions = [["Vertex"], ["Edge","Wire"], ["Face","Shell"], ["Solid","CompSolid"]] + dim = -1 + for sh in list_of_shapes: + sht = sh.ShapeType + for iDim in range(len(dimensions)): + if sht in dimensions[iDim]: + if dim == -1: + dim = iDim + if iDim != dim: + raise TypeError("Shapes are of different dimensions ({t1} and {t2}), and cannot be merged or compared.".format(t1= list_of_shapes[0].ShapeType, t2= sht)) + return dim \ No newline at end of file diff --git a/src/Mod/Part/BOPTools/SplitAPI.py b/src/Mod/Part/BOPTools/SplitAPI.py new file mode 100644 index 000000000000..af488d53526c --- /dev/null +++ b/src/Mod/Part/BOPTools/SplitAPI.py @@ -0,0 +1,103 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.SplitAPI module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Split functions that operate on list_of_shapes." + +import Part +from . import ShapeMerge +from .GeneralFuseResult import GeneralFuseResult +from . import Utils +import FreeCAD + +def booleanFragments(list_of_shapes, mode, tolerance = 0.0): + """booleanFragments(list_of_shapes, mode, tolerance = 0.0): functional part of + BooleanFragments feature. It's just result of generalFuse plus a bit of + post-processing. + + mode is a string. It can be "Standard", "Split" or "CompSolid". + "Standard" - return generalFuse as is. + "Split" - wires and shells will be split at intersections. + "CompSolid" - solids will be extracted from result of generelFuse, and compsolids will + be made from them; all other stuff is discarded.""" + + pieces, map = list_of_shapes[0].generalFuse(list_of_shapes[1:], tolerance) + if mode == "Standard": + return pieces + elif mode == "CompSolid": + solids = pieces.Solids + if len(solids) < 1: + raise ValueError("No solids in the result. Can't make CompSolid.") + elif len(solids) == 1: + FreeCAD.Console.PrintWarning("Part_BooleanFragments: only one solid in the result, generating trivial compsolid.") + return ShapeMerge.mergeSolids(solids, bool_compsolid= True) + elif mode == "Split": + gr = GeneralFuseResult(list_of_shapes, (pieces,map)) + gr.splitAggregates() + return Part.Compound(gr.pieces) + else: + raise ValueError("Unknown mode: {mode}".format(mode= mode)) + +def slice(base_shape, tool_shapes, mode, tolerance = 0.0): + """slice(base_shape, tool_shapes, mode, tolerance = 0.0): functional part of + Slice feature. Splits base_shape into pieces based on intersections with tool_shapes. + + mode is a string. It can be "Standard", "Split" or "CompSolid". + "Standard" - return like generalFuse: edges, faces and solids are split, but wires, + shells, compsolids get extra segments but remain in one piece. + "Split" - wires and shells will be split at intersections, too. + "CompSolid" - slice a solid and glue it back together to make a compsolid""" + + shapes = [base_shape] + [Part.Compound([tool_shape]) for tool_shape in tool_shapes] # hack: putting tools into compounds will prevent contamination of result with pieces of tools + if len(shapes) < 2: + raise ValueError("No slicing objects supplied!") + pieces, map = shapes[0].generalFuse(shapes[1:], tolerance) + gr = GeneralFuseResult(shapes, (pieces,map)) + if mode == "Standard": + result = gr.piecesFromSource(shapes[0]) + elif mode == "CompSolid": + solids = Part.Compound(gr.piecesFromSource(shapes[0])).Solids + if len(solids) < 1: + raise ValueError("No solids in the result. Can't make compsolid.") + elif len(solids) == 1: + FreeCAD.Console.PrintWarning("Part_Slice: only one solid in the result, generating trivial compsolid.") + result = ShapeMerge.mergeSolids(solids, bool_compsolid= True).childShapes() + elif mode == "Split": + gr.splitAggregates(gr.piecesFromSource(shapes[0])) + result = gr.piecesFromSource(shapes[0]) + return result[0] if len(result) == 1 else Part.Compound(result) + +def xor(list_of_shapes, tolerance = 0.0): + """xor(list_of_shapes, tolerance = 0.0): boolean XOR operation.""" + list_of_shapes = Utils.upgradeToAggregateIfNeeded(list_of_shapes) + pieces, map = list_of_shapes[0].generalFuse(list_of_shapes[1:], tolerance) + gr = GeneralFuseResult(list_of_shapes, (pieces,map)) + gr.explodeCompounds() + gr.splitAggregates() + pieces_to_keep = [] + for piece in gr.pieces: + if len(gr.sourcesOfPiece(piece)) % 2 == 1: + pieces_to_keep.append(piece) + return Part.Compound(pieces_to_keep) diff --git a/src/Mod/Part/BOPTools/SplitFeatures.py b/src/Mod/Part/BOPTools/SplitFeatures.py new file mode 100644 index 000000000000..54bd775260dd --- /dev/null +++ b/src/Mod/Part/BOPTools/SplitFeatures.py @@ -0,0 +1,441 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.SplitFeatures module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Shape splitting document objects (features)." + +from . import SplitAPI +import FreeCAD +import Part + +if FreeCAD.GuiUp: + import FreeCADGui + from PySide import QtCore, QtGui + +#-------------------------- translation-related code ---------------------------------------- +#(see forum thread "A new Part tool is being born... JoinFeatures!" +#http://forum.freecadweb.org/viewtopic.php?f=22&t=11112&start=30#p90239 ) +try: + _fromUtf8 = QtCore.QString.fromUtf8 +except Exception: + def _fromUtf8(s): + return s +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except NameError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) +#--------------------------/translation-related code ---------------------------------------- + +def getIconPath(icon_dot_svg): + return ":/icons/" + icon_dot_svg + +# -------------------------- /common stuff -------------------------------------------------- + +# -------------------------- BooleanFragments -------------------------------------------------- + +def makeBooleanFragments(name): + '''makeBooleanFragments(name): makes an BooleanFragments object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureBooleanFragments(obj) + if FreeCAD.GuiUp: + ViewProviderBooleanFragments(obj.ViewObject) + return obj + +class FeatureBooleanFragments: + "The BooleanFragments feature object" + def __init__(self,obj): + obj.addProperty("App::PropertyLinkList","Objects","BooleanFragments","Object to compute intersections between.") + obj.addProperty("App::PropertyEnumeration","Mode","BooleanFragments","Standard: wires, shells, compsolids remain in one piece. Split: wires, shells, compsolids are split. CompSolid: make compsolid from solid fragments.") + obj.Mode = ["Standard", "Split", "CompSolid"] + obj.addProperty("App::PropertyLength","Tolerance","BooleanFragments","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + shapes = [obj.Shape for obj in selfobj.Objects] + if len(shapes) == 1 and shapes[0].ShapeType == "Compound": + shapes = shapes[0].childShapes() + if len(shapes) < 2: + raise ValueError("At least two shapes are needed for computing boolean fragments. Got only {num}.".format(num= len(shapes))) + selfobj.Shape = SplitAPI.booleanFragments(shapes, selfobj.Mode, selfobj.Tolerance) + + +class ViewProviderBooleanFragments: + "A View Provider for the Part BooleanFragments feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_BooleanFragments.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return self.Object.Objects + + def onDelete(self, feature, subelements): + try: + for obj in self.claimChildren(): + obj.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + +def cmdCreateBooleanFragmentsFeature(name, mode): + """cmdCreateBooleanFragmentsFeature(name, mode): implementation of GUI command to create + BooleanFragments feature (GFA). Mode can be "Standard", "Split", or "CompSolid".""" + sel = FreeCADGui.Selection.getSelectionEx() + FreeCAD.ActiveDocument.openTransaction("Create Boolean Fragments") + FreeCADGui.addModule("BOPTools.SplitFeatures") + FreeCADGui.doCommand("j = BOPTools.SplitFeatures.makeBooleanFragments(name= '{name}')".format(name= name)) + FreeCADGui.doCommand("j.Objects = {sel}".format( + sel= "[" + ", ".join(["App.ActiveDocument."+so.Object.Name for so in sel]) + "]" + )) + FreeCADGui.doCommand("j.Mode = {mode}".format(mode= repr(mode))) + + try: + FreeCADGui.doCommand("j.Proxy.execute(j)") + FreeCADGui.doCommand("j.purgeTouched()") + except Exception as err: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures","Computing the result failed with an error: \n\n{err}\n\nClick 'Continue' to create the feature anyway, or 'Abort' to cancel.", None) + .format(err= err.message)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + btnAbort = mb.addButton(QtGui.QMessageBox.StandardButton.Abort) + btnOK = mb.addButton(_translate("Part_SplitFeatures","Continue",None), QtGui.QMessageBox.ButtonRole.ActionRole) + mb.setDefaultButton(btnOK) + + mb.exec_() + + if mb.clickedButton() is btnAbort: + FreeCAD.ActiveDocument.abortTransaction() + return + + FreeCADGui.doCommand("for obj in j.ViewObject.Proxy.claimChildren():\n" + " obj.ViewObject.hide()") + + FreeCAD.ActiveDocument.commitTransaction() + +class CommandBooleanFragments: + "Command to create BooleanFragments feature" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_BooleanFragments.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Boolean Fragments"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Part_BooleanFragments: split objects where they intersect")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) >= 1 : + cmdCreateBooleanFragmentsFeature(name= "BooleanFragments", mode= "Standard") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures", "Select at least two objects, or one or more compounds, first! If only one compound is selected, the compounded shapes will be intersected between each other (otherwise, compounds with self-intersections are invalid).", None)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + mb.exec_() + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /BooleanFragments -------------------------------------------------- + +# -------------------------- Slice -------------------------------------------------- + +def makeSlice(name): + '''makeSlice(name): makes an Slice object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureSlice(obj) + if FreeCAD.GuiUp: + ViewProviderSlice(obj.ViewObject) + return obj + +class FeatureSlice: + "The Slice feature object" + def __init__(self,obj): + obj.addProperty("App::PropertyLink","Base","Slice","Object to be sliced.") + obj.addProperty("App::PropertyLinkList","Tools","Slice","Objects that slice.") + obj.addProperty("App::PropertyEnumeration","Mode","Slice","Standard: wires, shells, compsolids remain in one piece. Split: wires, shells, compsolids are split. CompSolid: make compsolid from solid fragments.") + obj.Mode = ["Standard", "Split", "CompSolid"] + obj.addProperty("App::PropertyLength","Tolerance","Slice","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + if len(selfobj.Tools) < 1: + raise ValueError("No slicing objects supplied!") + selfobj.Shape = SplitAPI.slice(selfobj.Base.Shape, [obj.Shape for obj in selfobj.Tools], selfobj.Mode, selfobj.Tolerance) + + +class ViewProviderSlice: + "A View Provider for the Part Slice feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_Slice.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return [self.Object.Base] + self.Object.Tools + + def onDelete(self, feature, subelements): + try: + for obj in self.claimChildren(): + obj.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + +def cmdCreateSliceFeature(name, mode): + """cmdCreateSliceFeature(name, mode): implementation of GUI command to create + Slice feature. Mode can be "Standard", "Split", or "CompSolid".""" + sel = FreeCADGui.Selection.getSelectionEx() + FreeCAD.ActiveDocument.openTransaction("Create Slice") + FreeCADGui.addModule("BOPTools.SplitFeatures") + FreeCADGui.doCommand("j = BOPTools.SplitFeatures.makeSlice(name= '{name}')".format(name= name)) + FreeCADGui.doCommand("j.Base = {sel}[0]\n" + "j.Tools = {sel}[1:]".format( + sel= "[" + ", ".join(["App.ActiveDocument."+so.Object.Name for so in sel]) + "]" + )) + FreeCADGui.doCommand("j.Mode = {mode}".format(mode= repr(mode))) + + try: + FreeCADGui.doCommand("j.Proxy.execute(j)") + FreeCADGui.doCommand("j.purgeTouched()") + except Exception as err: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures","Computing the result failed with an error: \n\n{err}\n\nClick 'Continue' to create the feature anyway, or 'Abort' to cancel.", None) + .format(err= err.message)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + btnAbort = mb.addButton(QtGui.QMessageBox.StandardButton.Abort) + btnOK = mb.addButton(_translate("Part_SplitFeatures","Continue",None), QtGui.QMessageBox.ButtonRole.ActionRole) + mb.setDefaultButton(btnOK) + + mb.exec_() + + if mb.clickedButton() is btnAbort: + FreeCAD.ActiveDocument.abortTransaction() + return + + FreeCADGui.doCommand("for obj in j.ViewObject.Proxy.claimChildren():\n" + " obj.ViewObject.hide()") + + FreeCAD.ActiveDocument.commitTransaction() + +class CommandSlice: + "Command to create Slice feature" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_Slice.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Slice"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Part_Slice: split object by intersections with other objects")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) > 1 : + cmdCreateSliceFeature(name= "Slice", mode= "Split") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures", "Select at least two objects, first! First one is the object to be sliced; the rest are objects to slice with.", None)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + mb.exec_() + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /Slice -------------------------------------------------- + +# -------------------------- XOR -------------------------------------------------- + +def makeXOR(name): + '''makeXOR(name): makes an XOR object.''' + obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython",name) + FeatureXOR(obj) + if FreeCAD.GuiUp: + ViewProviderXOR(obj.ViewObject) + return obj + +class FeatureXOR: + "The XOR feature object" + def __init__(self,obj): + obj.addProperty("App::PropertyLinkList","Objects","XOR","Object to compute intersections between.") + obj.addProperty("App::PropertyLength","Tolerance","XOR","Tolerance when intersecting (fuzzy value). In addition to tolerances of the shapes.") + + obj.Proxy = self + + def execute(self,selfobj): + shapes = [obj.Shape for obj in selfobj.Objects] + if len(shapes) == 1 and shapes[0].ShapeType == "Compound": + shapes = shapes[0].childShapes() + if len(shapes) < 2: + raise ValueError("At least two shapes are needed for computing XOR. Got only {num}.".format(num= len(shapes))) + selfobj.Shape = SplitAPI.xor(shapes, selfobj.Tolerance) + + +class ViewProviderXOR: + "A View Provider for the Part XOR feature" + + def __init__(self,vobj): + vobj.Proxy = self + + def getIcon(self): + return getIconPath("Part_XOR.svg") + + def attach(self, vobj): + self.ViewObject = vobj + self.Object = vobj.Object + + + def setEdit(self,vobj,mode): + return False + + def unsetEdit(self,vobj,mode): + return + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def claimChildren(self): + return self.Object.Objects + + def onDelete(self, feature, subelements): + try: + for obj in self.claimChildren(): + obj.ViewObject.show() + except Exception as err: + FreeCAD.Console.PrintError("Error in onDelete: " + err.message) + return True + +def cmdCreateXORFeature(name): + """cmdCreateXORFeature(name): implementation of GUI command to create + XOR feature (GFA). Mode can be "Standard", "Split", or "CompSolid".""" + sel = FreeCADGui.Selection.getSelectionEx() + FreeCAD.ActiveDocument.openTransaction("Create Boolean XOR") + FreeCADGui.addModule("BOPTools.SplitFeatures") + FreeCADGui.doCommand("j = BOPTools.SplitFeatures.makeXOR(name= '{name}')".format(name= name)) + FreeCADGui.doCommand("j.Objects = {sel}".format( + sel= "[" + ", ".join(["App.ActiveDocument."+so.Object.Name for so in sel]) + "]" + )) + + try: + FreeCADGui.doCommand("j.Proxy.execute(j)") + FreeCADGui.doCommand("j.purgeTouched()") + except Exception as err: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures","Computing the result failed with an error: \n\n{err}\n\nClick 'Continue' to create the feature anyway, or 'Abort' to cancel.", None) + .format(err= err.message)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + btnAbort = mb.addButton(QtGui.QMessageBox.StandardButton.Abort) + btnOK = mb.addButton(_translate("Part_SplitFeatures","Continue",None), QtGui.QMessageBox.ButtonRole.ActionRole) + mb.setDefaultButton(btnOK) + + mb.exec_() + + if mb.clickedButton() is btnAbort: + FreeCAD.ActiveDocument.abortTransaction() + return + + FreeCADGui.doCommand("for obj in j.ViewObject.Proxy.claimChildren():\n" + " obj.ViewObject.hide()") + + FreeCAD.ActiveDocument.commitTransaction() + +class CommandXOR: + "Command to create XOR feature" + def GetResources(self): + return {'Pixmap' : getIconPath("Part_XOR.svg"), + 'MenuText': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Boolean XOR"), + 'Accel': "", + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("Part_SplitFeatures","Part_XOR: remove intersection fragments")} + + def Activated(self): + if len(FreeCADGui.Selection.getSelectionEx()) >= 1 : + cmdCreateXORFeature(name= "XOR") + else: + mb = QtGui.QMessageBox() + mb.setIcon(mb.Icon.Warning) + mb.setText(_translate("Part_SplitFeatures", "Select at least two objects, or one or more compounds, first! If only one compound is selected, the compounded shapes will be intersected between each other (otherwise, compounds with self-intersections are invalid).", None)) + mb.setWindowTitle(_translate("Part_SplitFeatures","Bad selection", None)) + mb.exec_() + + def IsActive(self): + if FreeCAD.ActiveDocument: + return True + else: + return False + +# -------------------------- /XOR -------------------------------------------------- + +def addCommands(): + FreeCADGui.addCommand('Part_BooleanFragments',CommandBooleanFragments()) + FreeCADGui.addCommand('Part_Slice',CommandSlice()) + FreeCADGui.addCommand('Part_XOR',CommandXOR()) diff --git a/src/Mod/Part/BOPTools/Utils.py b/src/Mod/Part/BOPTools/Utils.py new file mode 100644 index 000000000000..f615edad72c5 --- /dev/null +++ b/src/Mod/Part/BOPTools/Utils.py @@ -0,0 +1,130 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__="BOPTools.Utils module" +__author__ = "DeepSOIC" +__url__ = "http://www.freecadweb.org" +__doc__ = "Utility code, used by various modules of BOPTools." + +class HashableShape(object): + "Decorator for Part.Shape, that can be used as key in dicts. Based on isSame method." + def __init__(self, shape): + self.Shape = shape + self.hash = shape.hashCode() + + def __eq__(self, other): + return self.Shape.isSame(other.Shape) + + def __hash__(self): + return self.hash + +class HashableShape_Deep(object): + """Similar to HashableShape, except that the things the shape is composed of are compared. + +Example: + >>> wire2 = Part.Wire(wire1.childShapes()) + >>> wire2.isSame(wire1) + False # <--- the wire2 is a new wire, although made of edges of wire1 + >>> HashableShape_Deep(wire2) == HashableShape_Deep(wire1) + True # <--- made of same set of elements + """ + + def __init__(self, shape): + self.Shape = shape + self.hash = 0 + for el in shape.childShapes(): + self.hash = self.hash ^ el.hashCode() + + def __eq__(self, other): + # avoiding extensive comparison for now. Just doing a few extra tests should reduce the already-low chances of false-positives + if self.hash == other.hash: + if len(self.Shape.childShapes()) == len(other.Shape.childShapes()): + if self.Shape.ShapeType == other.Shape.ShapeType: + return True + return False + + def __hash__(self): + return self.hash + +def compoundLeaves(shape_or_compound): + """compoundLeaves(shape_or_compound): extracts all non-compound shapes from a nested compound. + Note: shape_or_compound may be a non-compound; then, it is the only thing in the + returned list.""" + + if shape_or_compound.ShapeType == "Compound": + leaves = [] + for child in shape_or_compound.childShapes(): + leaves.extend( compoundLeaves(child) ) + return leaves + else: + return [shape_or_compound] + +def upgradeToAggregateIfNeeded(list_of_shapes, types = None): + """upgradeToAggregateIfNeeded(list_of_shapes, types = None): upgrades non-aggregate type + shapes to aggregate-type shapes if the list has a mix of aggregate and non-aggregate + type shapes. Returns the new list. Recursively traverses into compounds. + + aggregate shape types are Wire, Shell, CompSolid + non-aggregate shape types are Vertex, Edge, Face, Solid + Compounds are something special: they are recursively traversed to upgrade the + contained shapes. + + Examples: + list_of_shapes contains only faces -> nothing happens + list_of_shapes contains faces and shells -> faces are converted to shells + + 'types' argument is needed for recursive traversal. Do not supply.""" + + import Part + if types is None: + types = set() + for shape in list_of_shapes: + types.add(shape.ShapeType) + subshapes = compoundLeaves(shape) + for subshape in subshapes: + types.add(subshape.ShapeType) + if "Wire" in types: + list_of_shapes = [(Part.Wire([shape]) if shape.ShapeType == "Edge" else shape) for shape in list_of_shapes] + if "Shell" in types: + list_of_shapes = [(Part.Shell([shape]) if shape.ShapeType == "Face" else shape) for shape in list_of_shapes] + if "CompSolid" in types: + list_of_shapes = [(Part.CompSolid([shape]) if shape.ShapeType == "Solid" else shape) for shape in list_of_shapes] + if "Compound" in types: + list_of_shapes = [(Part.Compound(upgradeToAggregateIfNeeded(shape.childShapes(), types)) if shape.ShapeType == "Compound" else shape) for shape in list_of_shapes] + return list_of_shapes + +# adapted from http://stackoverflow.com/a/3603824/6285007 +class FrozenClass(object): + '''FrozenClass: prevents adding new attributes to class outside of __init__''' + __isfrozen = False + def __setattr__(self, key, value): + if self.__isfrozen and not hasattr(self, key): + raise TypeError( "{cls} has no attribute {attr}".format(cls= self.__class__.__name__, attr= key) ) + object.__setattr__(self, key, value) + + def _freeze(self): + self.__isfrozen = True + + def _unfreeze(self): + self.__isfrozen = False + diff --git a/src/Mod/Part/BOPTools/__init__.py b/src/Mod/Part/BOPTools/__init__.py new file mode 100644 index 000000000000..fcc866a27ce9 --- /dev/null +++ b/src/Mod/Part/BOPTools/__init__.py @@ -0,0 +1,75 @@ +#/*************************************************************************** +# * Copyright (c) Victor Titov (DeepSOIC) * +# * (vv.titov@gmail.com) 2016 * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This library is free software; you can redistribute it and/or * +# * modify it under the terms of the GNU Library General Public * +# * License as published by the Free Software Foundation; either * +# * version 2 of the License, or (at your option) any later version. * +# * * +# * This library 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this library; see the file COPYING.LIB. If not, * +# * write to the Free Software Foundation, Inc., 59 Temple Place, * +# * Suite 330, Boston, MA 02111-1307, USA * +# * * +# ***************************************************************************/ + +__title__ = "BOPTools package" +__url__ = "http://www.freecadweb.org" +__doc__ = """BOPTools Package (part of FreeCAD). Routines that power Connect, Embed, Cutout, +BooleanFragments, Slice and XOR features of Part Workbench. Useful for other custom +BOP-like operations""" + +__all__ = [ +"GeneralFuseResult", +"JoinAPI", +"JoinFeatures", +"ShapeMerge", +"Utils", +"SplitAPI", +"SplitFeatures", +] + +def importAll(): + "importAll(): imports all modules of BOPTools package" + from . import GeneralFuseResult + from . import JoinAPI + from . import JoinFeatures + from . import ShapeMerge + from . import Utils + from . import SplitAPI + from . import SplitFeatures + +def reloadAll(): + "reloadAll(): reloads all modules of BOPTools package. Useful for debugging." + for modstr in __all__: + reload(globals()[modstr]) + import FreeCAD + if FreeCAD.GuiUp: + addCommands() + +def addCommands(): + "addCommands(): add all GUI commands of BOPTools package to FreeCAD command manager." + JoinFeatures.addCommands() + SplitFeatures.addCommands() + +def generalFuseIsAvailable(): + """generalFuseIsAvailable(): returns True if FreeCAD's Part.Shape.generalFuse is functional. + True if Part.OCC_VERSION >= 6.9.0.""" + import Part + if not hasattr(Part, "OCC_VERSION"): + return False + else: + ver_string = Part.OCC_VERSION + import re + match = re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)",ver_string) + major,minor,rev = match.groups() + major = int(major); minor = int(minor); rev = int(rev) + return (major,minor,rev)>=(6,9,0) diff --git a/src/Mod/Part/CMakeLists.txt b/src/Mod/Part/CMakeLists.txt index d75a927e7bb3..723612a0179c 100644 --- a/src/Mod/Part/CMakeLists.txt +++ b/src/Mod/Part/CMakeLists.txt @@ -26,3 +26,17 @@ INSTALL( DESTINATION Mod/Part/AttachmentEditor ) + +INSTALL( + FILES + BOPTools/__init__.py + BOPTools/GeneralFuseResult.py + BOPTools/JoinAPI.py + BOPTools/JoinFeatures.py + BOPTools/ShapeMerge.py + BOPTools/SplitAPI.py + BOPTools/SplitFeatures.py + BOPTools/Utils.py + DESTINATION + Mod/Part/BOPTools + ) diff --git a/src/Mod/Part/InitGui.py b/src/Mod/Part/InitGui.py index 046b42af87bc..4a99040b64fb 100644 --- a/src/Mod/Part/InitGui.py +++ b/src/Mod/Part/InitGui.py @@ -43,9 +43,9 @@ def Initialize(self): import PartGui import Part try: - import JoinFeatures - except ImportError: - print "JoinFeatures module cannot be loaded" + Part.BOPTools.addCommands() + except Exception as err: + FreeCAD.Console.PrintError("Features from BOPTools package cannot be loaded. {err}\n".format(err= err.message)) def GetClassName(self): return "PartGui::Workbench"