# blender CAM ops.py (c) 2012 Vilem Novak # # ***** BEGIN GPL LICENSE BLOCK ***** # # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ***** END GPL LICENCE BLOCK ***** # blender operators definitions are in this file. They mostly call the functions from utils.py from math import ( pi, tan ) import shapely from shapely.geometry import LineString import bpy from bpy.props import ( BoolProperty, EnumProperty, FloatProperty, ) from bpy.types import Operator from mathutils import Vector from . import ( polygon_utils_cam, simple, utils, ) # boolean operations for curve objects class CamCurveBoolean(Operator): """perform Boolean operation on two or more curves""" bl_idname = "object.curve_boolean" bl_label = "Curve Boolean" bl_options = {'REGISTER', 'UNDO'} boolean_type: EnumProperty( name='type', items=( ('UNION', 'Union', ''), ('DIFFERENCE', 'Difference', ''), ('INTERSECT', 'Intersect', '') ), description='boolean type', default='UNION' ) @classmethod def poll(cls, context): return context.active_object is not None and context.active_object.type in ['CURVE', 'FONT'] def execute(self, context): if len(context.selected_objects) > 1: utils.polygonBoolean(context, self.boolean_type) return {'FINISHED'} else: self.report({'ERROR'}, 'at least 2 curves must be selected') return {'CANCELLED'} class CamCurveConvexHull(Operator): """perform hull operation on single or multiple curves""" # by Alain Pelletier april 2021 bl_idname = "object.convex_hull" bl_label = "Convex Hull" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): return context.active_object is not None and context.active_object.type in ['CURVE', 'FONT'] def execute(self, context): utils.polygonConvexHull(context) return {'FINISHED'} # intarsion or joints class CamCurveIntarsion(Operator): """makes curve cuttable both inside and outside, for intarsion and joints""" bl_idname = "object.curve_intarsion" bl_label = "Intarsion" bl_options = {'REGISTER', 'UNDO', 'PRESET'} diameter: FloatProperty( name="cutter diameter", default=.001, min=0, max=0.025, precision=4, unit="LENGTH", ) tolerance: FloatProperty( name="cutout Tolerance", default=.0001, min=0, max=0.005, precision=4, unit="LENGTH", ) backlight: FloatProperty( name="Backlight seat", default=0.000, min=0, max=0.010, precision=4, unit="LENGTH", ) perimeter_cut: FloatProperty( name="Perimeter cut offset", default=0.000, min=0, max=0.100, precision=4, unit="LENGTH", ) base_thickness: FloatProperty( name="Base material thickness", default=0.000, min=0, max=0.100, precision=4, unit="LENGTH", ) intarsion_thickness: FloatProperty( name="Intarsion material thickness", default=0.000, min=0, max=0.100, precision=4, unit="LENGTH", ) backlight_depth_from_top: FloatProperty( name="Backlight well depth", default=0.000, min=0, max=0.100, precision=4, unit="LENGTH", ) @classmethod def poll(cls, context): return context.active_object is not None and (context.active_object.type in ['CURVE', 'FONT']) def execute(self, context): selected = context.selected_objects # save original selected items simple.remove_multiple('intarsion_') for ob in selected: ob.select_set(True) # select original curves # Perimeter cut largen then intarsion pocket externally, optional # make the diameter 5% larger and compensate for backlight diam = self.diameter * 1.05 + self.backlight * 2 utils.silhoueteOffset(context, -diam / 2) o1 = bpy.context.active_object utils.silhoueteOffset(context, diam) o2 = bpy.context.active_object utils.silhoueteOffset(context, -diam / 2) o3 = bpy.context.active_object o1.select_set(True) o2.select_set(True) o3.select_set(False) # delete o1 and o2 temporary working curves bpy.ops.object.delete(use_global=False) o3.name = "intarsion_pocket" # this is the pocket for intarsion bpy.context.object.location[2] = -self.intarsion_thickness if self.perimeter_cut > 0.0: utils.silhoueteOffset(context, self.perimeter_cut) bpy.context.active_object.name = "intarsion_perimeter" bpy.context.object.location[2] = -self.base_thickness bpy.ops.object.select_all(action='DESELECT') # deselect new curve o3.select_set(True) context.view_layer.objects.active = o3 # intarsion profile is the inside piece of the intarsion # make smaller curve for material profile utils.silhoueteOffset(context, -self.tolerance / 2) bpy.context.object.location[2] = self.intarsion_thickness o4 = bpy.context.active_object bpy.context.active_object.name = "intarsion_profil" o4.select_set(False) if self.backlight > 0.0: # Make a smaller curve for backlighting purposes utils.silhoueteOffset( context, (-self.tolerance / 2) - self.backlight) bpy.context.active_object.name = "intarsion_backlight" bpy.context.object.location[2] = - \ self.backlight_depth_from_top - self.intarsion_thickness o4.select_set(True) o3.select_set(True) return {'FINISHED'} # intarsion or joints class CamCurveOvercuts(Operator): """Adds overcuts for slots""" bl_idname = "object.curve_overcuts" bl_label = "Add Overcuts" bl_options = {'REGISTER', 'UNDO'} diameter: FloatProperty( name="diameter", default=.003175, min=0, max=100, precision=4, unit="LENGTH", ) threshold: FloatProperty( name="threshold", default=pi / 2 * .99, min=-3.14, max=3.14, precision=4, subtype="ANGLE", unit="ROTATION", ) do_outer: BoolProperty( name="Outer polygons", default=True, ) invert: BoolProperty( name="Invert", default=False, ) @classmethod def poll(cls, context): return context.active_object is not None and (context.active_object.type in ['CURVE', 'FONT']) def execute(self, context): bpy.ops.object.curve_remove_doubles() o1 = bpy.context.active_object shapes = utils.curveToShapely(o1) negative_overcuts = [] positive_overcuts = [] diameter = self.diameter * 1.001 for s in shapes.geoms: s = shapely.geometry.polygon.orient(s, 1) if s.boundary.geom_type == 'LineString': from shapely.geometry import MultiLineString loops = MultiLineString([s.boundary]) else: loops = s.boundary for ci, c in enumerate(loops.geoms): if ci > 0 or self.do_outer: for i, co in enumerate(c.coords): i1 = i - 1 if i1 == -1: i1 = -2 i2 = i + 1 if i2 == len(c.coords): i2 = 0 v1 = Vector( co) - Vector(c.coords[i1]) v1 = v1.xy # Vector((v1.x,v1.y,0)) v2 = Vector( c.coords[i2]) - Vector(co) v2 = v2.xy # v2 = Vector((v2.x,v2.y,0)) if not v1.length == 0 and not v2.length == 0: a = v1.angle_signed(v2) sign = 1 if self.invert: # and ci>0: sign *= -1 if (sign < 0 and a < -self.threshold) or (sign > 0 and a > self.threshold): p = Vector((co[0], co[1])) v1.normalize() v2.normalize() v = v1 - v2 v.normalize() p = p - v * diameter / 2 if abs(a) < pi / 2: shape = utils.Circle(diameter / 2, 64) shape = shapely.affinity.translate( shape, p.x, p.y) else: l = tan(a / 2) * diameter / 2 p1 = p - sign * v * l l = shapely.geometry.LineString((p, p1)) shape = l.buffer( diameter / 2, resolution=64) if sign > 0: negative_overcuts.append(shape) else: positive_overcuts.append(shape) negative_overcuts = shapely.ops.unary_union(negative_overcuts) positive_overcuts = shapely.ops.unary_union(positive_overcuts) fs = shapely.ops.unary_union(shapes) fs = fs.union(positive_overcuts) fs = fs.difference(negative_overcuts) utils.shapelyToCurve(o1.name + '_overcuts', fs, o1.location.z) return {'FINISHED'} # Overcut type B class CamCurveOvercutsB(Operator): """Adds overcuts for slots""" bl_idname = "object.curve_overcuts_b" bl_label = "Add Overcuts-B" bl_options = {'REGISTER', 'UNDO'} diameter: FloatProperty( name="Tool diameter", default=.003175, description='Tool bit diameter used in cut operation', min=0, max=100, precision=4, unit="LENGTH", ) style: EnumProperty( name="style", items=( ('OPEDGE', 'opposite edge', 'place corner overcuts on opposite edges'), ('DOGBONE', 'Dog-bone / Corner Point', 'place overcuts at center of corners'), ('TBONE', 'T-bone', 'place corner overcuts on the same edge') ), default='DOGBONE', description='style of overcut to use', ) threshold: FloatProperty( name="Max Inside Angle", default=pi / 2, min=-3.14, max=3.14, description='The maximum angle to be considered as an inside corner', precision=4, subtype="ANGLE", unit="ROTATION", ) do_outer: BoolProperty( name="Include outer curve", description='Include the outer curve if there are curves inside', default=True, ) do_invert: BoolProperty( name="Invert", description='invert overcut operation on all curves', default=True, ) otherEdge: BoolProperty( name="other edge", description='change to the other edge for the overcut to be on', default=False, ) @classmethod def poll(cls, context): return context.active_object is not None and context.active_object.type == 'CURVE' def execute(self, context): bpy.ops.object.curve_remove_doubles() o1 = bpy.context.active_object shapes = utils.curveToShapely(o1) negative_overcuts = [] positive_overcuts = [] # count all the corners including inside and out cornerCnt = 0 # a list of tuples for defining the inside corner # tuple is: (pos, v1, v2, angle, allCorners list index) insideCorners = [] diameter = self.diameter * 1.002 # make bit size slightly larger to allow cutter radius = diameter / 2 anglethreshold = pi - self.threshold centerv = Vector((0, 0)) extendedv = Vector((0, 0)) pos = Vector((0, 0)) sign = -1 if self.do_invert else 1 isTBone = self.style == 'TBONE' # indexes in insideCorner tuple POS, V1, V2, A, IDX = range(5) def addOvercut(a): nonlocal pos, centerv, radius, extendedv, sign, negative_overcuts, positive_overcuts # move the overcut shape center position 1 radius in direction v pos -= centerv * radius print("abs(a)", abs(a)) if abs(a) <= pi / 2 + 0.0001: print("<=pi/2") shape = utils.Circle(radius, 64) shape = shapely.affinity.translate(shape, pos.x, pos.y) else: # elongate overcut circle to make sure tool bit can fit into slot print(">pi/2") p1 = pos + (extendedv * radius) l = shapely.geometry.LineString((pos, p1)) shape = l.buffer(radius, resolution=64) if sign > 0: negative_overcuts.append(shape) else: positive_overcuts.append(shape) def setOtherEdge(v1, v2, a): nonlocal centerv, extendedv if self.otherEdge: centerv = v1 extendedv = v2 else: centerv = -v2 extendedv = -v1 addOvercut(a) def setCenterOffset(a): nonlocal centerv, extendedv, sign centerv = v1 - v2 centerv.normalize() extendedv = centerv * tan(a / 2) * -sign addOvercut(a) def getCorner(idx, offset): nonlocal insideCorners idx += offset if idx >= len(insideCorners): idx -= len(insideCorners) return insideCorners[idx] def getCornerDelta(curidx, nextidx): nonlocal cornerCnt delta = nextidx - curidx if delta < 0: delta += cornerCnt return delta for s in shapes.geoms: # ensure the shape is counterclockwise s = shapely.geometry.polygon.orient(s, 1) if s.boundary.geom_type == 'LineString': from shapely import MultiLineString loops = MultiLineString([s.boundary]) else: loops = s.boundary outercurve = self.do_outer or len(loops.geoms) == 1 for ci, c in enumerate(loops.geoms): if ci > 0 or outercurve: if isTBone: cornerCnt = 0 insideCorners = [] for i, co in enumerate(c.coords): i1 = i - 1 if i1 == -1: i1 = -2 i2 = i + 1 if i2 == len(c.coords): i2 = 0 v1 = Vector( co).xy - Vector(c.coords[i1]).xy v2 = Vector( c.coords[i2]).xy - Vector(co).xy if not v1.length == 0 and not v2.length == 0: a = v1.angle_signed(v2) insideCornerFound = False outsideCornerFound = False if a < -anglethreshold: if sign < 0: insideCornerFound = True else: outsideCornerFound = True elif a > anglethreshold: if sign > 0: insideCornerFound = True else: outsideCornerFound = True if insideCornerFound: # an inside corner with an overcut has been found # which means a new side has been found pos = Vector((co[0], co[1])) v1.normalize() v2.normalize() # figure out which direction vector to use # v is the main direction vector to move the overcut shape along # ev is the direction vector used to elongate the overcut shape if self.style != 'DOGBONE': # t-bone and opposite edge styles get treated nearly the same if isTBone: cornerCnt += 1 # insideCorner tuplet: (pos, v1, v2, angle, corner index) insideCorners.append( (pos, v1, v2, a, cornerCnt - 1)) # processing of corners for T-Bone are done after all points are processed continue setOtherEdge(v1, v2, a) else: # DOGBONE style setCenterOffset(a) elif isTBone and outsideCornerFound: # add an outside corner to the list cornerCnt += 1 # check if t-bone processing required # if no inside corners then nothing to do if isTBone and len(insideCorners) > 0: print("corner count", cornerCnt, "inside corner count", len(insideCorners)) # process all of the inside corners for i, corner in enumerate(insideCorners): pos, v1, v2, a, idx = corner # figure out which side of the corner to do overcut # if prev corner is outside corner # calc index distance between current corner and prev prevCorner = getCorner(i, -1) print('first:', i, idx, prevCorner[IDX]) if getCornerDelta(prevCorner[IDX], idx) == 1: # make sure there is an outside corner print(getCornerDelta( getCorner(i, -2)[IDX], idx)) if getCornerDelta(getCorner(i, -2)[IDX], idx) > 2: setOtherEdge(v1, v2, a) print('first won') continue nextCorner = getCorner(i, 1) print('second:', i, idx, nextCorner[IDX]) if getCornerDelta(idx, nextCorner[IDX]) == 1: # make sure there is an outside corner print(getCornerDelta( idx, getCorner(i, 2)[IDX])) if getCornerDelta(idx, getCorner(i, 2)[IDX]) > 2: print('second won') setOtherEdge(-v2, -v1, a) continue print('third') if getCornerDelta(prevCorner[IDX], idx) == 3: # check if they share the same edge a1 = v1.angle_signed( prevCorner[V2]) * 180.0 / pi print('third won', a1) if a1 < -135 or a1 > 135: setOtherEdge(-v2, -v1, a) continue print('fourth') if getCornerDelta(idx, nextCorner[IDX]) == 3: # check if they share the same edge a1 = v2.angle_signed( nextCorner[V1]) * 180.0 / pi print('fourth won', a1) if a1 < -135 or a1 > 135: setOtherEdge(v1, v2, a) continue print('***No Win***') # the default if no other rules pass setCenterOffset(a) negative_overcuts = shapely.ops.unary_union(negative_overcuts) positive_overcuts = shapely.ops.unary_union(positive_overcuts) fs = shapely.ops.unary_union(shapes) fs = fs.union(positive_overcuts) fs = fs.difference(negative_overcuts) utils.shapelyToCurve(o1.name + '_overcuts', fs, o1.location.z) return {'FINISHED'} class CamCurveRemoveDoubles(Operator): """curve remove doubles""" bl_idname = "object.curve_remove_doubles" bl_label = "C-Remove doubles" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): return context.active_object is not None and (context.active_object.type == 'CURVE') def execute(self, context): obs = bpy.context.selected_objects for ob in obs: bpy.context.view_layer.objects.active = ob if bpy.context.mode == 'OBJECT': bpy.ops.object.editmode_toggle() bpy.ops.curve.select_all() bpy.ops.curve.remove_double() bpy.ops.object.editmode_toggle() return {'FINISHED'} class CamMeshGetPockets(Operator): """Detect pockets in a mesh and extract them as curves""" bl_idname = "object.mesh_get_pockets" bl_label = "Get pocket surfaces" bl_options = {'REGISTER', 'UNDO'} threshold: FloatProperty( name="horizontal threshold", description="How horizontal the surface must be for a pocket: " "1.0 perfectly flat, 0.0 is any orientation", default=.99, min=0, max=1.0, precision=4, ) zlimit: FloatProperty( name="z limit", description="maximum z height considered for pocket operation, " "default is 0.0", default=0.0, min=-1000.0, max=1000.0, precision=4, unit='LENGTH', ) @classmethod def poll(cls, context): return context.active_object is not None and (context.active_object.type == 'MESH') def execute(self, context): obs = bpy.context.selected_objects s = bpy.context.scene cobs = [] for ob in obs: if ob.type == 'MESH': pockets = {} mw = ob.matrix_world mesh = ob.data bpy.ops.object.editmode_toggle() bpy.ops.mesh.select_mode( use_extend=False, use_expand=False, type='FACE') bpy.ops.mesh.select_all(action='DESELECT') bpy.ops.object.editmode_toggle() i = 0 for face in mesh.polygons: # n = mw @ face.normal n = face.normal.to_4d() n.w = 0 n = (mw @ n).to_3d().normalized() if n.z > self.threshold: face.select = True z = (mw @ mesh.vertices[face.vertices[0]].co).z if z < self.zlimit: if pockets.get(z) is None: pockets[z] = [i] else: pockets[z].append(i) i += 1 print(len(pockets)) for p in pockets: print(p) ao = bpy.context.active_object i = 0 for p in pockets: print(i) i += 1 sf = pockets[p] for face in mesh.polygons: face.select = False for fi in sf: face = mesh.polygons[fi] face.select = True bpy.ops.object.editmode_toggle() bpy.ops.mesh.select_mode( use_extend=False, use_expand=False, type='EDGE') bpy.ops.mesh.region_to_loop() bpy.ops.mesh.separate(type='SELECTED') bpy.ops.mesh.select_mode( use_extend=False, use_expand=False, type='FACE') bpy.ops.object.editmode_toggle() ao.select_set(state=False) bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] cobs.append(bpy.context.selected_objects[0]) bpy.ops.object.convert(target='CURVE') bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY') bpy.context.selected_objects[0].select_set(False) ao.select_set(state=True) bpy.context.view_layer.objects.active = ao # bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type='EDGE') # turn off selection of all objects in 3d view bpy.ops.object.select_all(action='DESELECT') # make new curves more visible by making them selected in the 3d view # This also allows the active object to still work with the operator # if the user decides to change the horizontal threshold property col = bpy.data.collections.new('multi level pocket ') s.collection.children.link(col) for obj in cobs: col.objects.link(obj) return {'FINISHED'} # this operator finds the silhouette of objects(meshes, curves just get converted) and offsets it. class CamOffsetSilhouete(Operator): """Curve offset operation """ bl_idname = "object.silhouete_offset" bl_label = "Silhouete offset" bl_options = {'REGISTER', 'UNDO', 'PRESET'} offset: FloatProperty( name="offset", default=.003, min=-100, max=100, precision=4, unit="LENGTH", ) mitrelimit: FloatProperty( name="Mitre Limit", default=.003, min=0.0, max=20, precision=4, unit="LENGTH", ) style: EnumProperty( name="type of curve", items=( ('1', 'Round', ''), ('2', 'Mitre', ''), ('3', 'Bevel', '') ), ) opencurve: BoolProperty( name="Dialate open curve", default=False, ) @classmethod def poll(cls, context): return context.active_object is not None and ( context.active_object.type == 'CURVE' or context.active_object.type == 'FONT' or context.active_object.type == 'MESH') # this is almost same as getobjectoutline, just without the need of operation data def execute(self, context): bpy.ops.object.curve_remove_doubles() ob = context.active_object if self.opencurve and ob.type == 'CURVE': bpy.ops.object.duplicate() obj = context.active_object bpy.ops.object.transform_apply( location=True, rotation=True, scale=True) # apply all transforms bpy.context.object.data.resolution_u = 60 bpy.ops.object.convert(target='MESH') bpy.context.active_object.name = "temp_mesh" coords = [] for v in obj.data.vertices: # extract X,Y coordinates from the vertices data coords.append((v.co.x, v.co.y)) simple.remove_multiple('temp_mesh') # delete temporary mesh simple.remove_multiple('dilation') # delete old dilation objects # convert coordinates to shapely LineString datastructure line = LineString(coords) print("line length=", round(line.length * 1000), 'mm') dilated = line.buffer(self.offset, cap_style=1, resolution=16, mitre_limit=self.mitrelimit) # use shapely to expand polygon_utils_cam.shapelyToCurve("dilation", dilated, 0) else: utils.silhoueteOffset(context, self.offset, int(self.style), self.mitrelimit) return {'FINISHED'} # Finds object silhouette, usefull for meshes, since with curves it's not needed. class CamObjectSilhouete(Operator): """Object silhouete """ bl_idname = "object.silhouete" bl_label = "Object silhouete" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): # return context.active_object is not None and (context.active_object.type == 'CURVE' # or context.active_object.type == 'FONT' or context.active_object.type == 'MESH') return context.active_object is not None and ( context.active_object.type == 'FONT' or context.active_object.type == 'MESH') # this is almost same as getobjectoutline, just without the need of operation data def execute(self, context): ob = bpy.context.active_object self.silh = utils.getObjectSilhouete( 'OBJECTS', objects=bpy.context.selected_objects) bpy.context.scene.cursor.location = (0, 0, 0) # smp=sgeometry.asMultiPolygon(self.silh) for smp in self.silh: polygon_utils_cam.shapelyToCurve( ob.name + '_silhouette', smp, 0) # # bpy.ops.object.convert(target='CURVE') bpy.context.scene.cursor.location = ob.location bpy.ops.object.origin_set(type='ORIGIN_CURSOR') return {'FINISHED'} # ---------------------------------------------------