"""BlenderCAM 'joinery.py' © 2021 Alain Pelletier
Functions to create various woodworking joints - mortise, finger etc.
"""
# blender operators definitions are in this file. They mostly call the functions from utils.py
from math import (
asin,
atan2,
degrees,
hypot,
pi,
)
from shapely.geometry import (
LineString,
Point,
)
import bpy
from . import (
puzzle_joinery,
simple,
utils,
)
# boolean operations for curve objects
[docs]
def finger_amount(space, size):
"""Calculates the amount of fingers needed from the available space vs the size of the finger
Args:
space (float):available distance to cover
size (float): size of the finger
"""
finger_amt = space / size
if (finger_amt % 1) != 0:
finger_amt = round(finger_amt) + 1
if (finger_amt % 2) != 0:
finger_amt = round(finger_amt) + 1
return finger_amt
[docs]
def mortise(length, thickness, finger_play, cx=0, cy=0, rotation=0):
"""Generates a mortise of length, thickness and finger_play tolerance
cx and cy are the center position and rotation is the angle
Args:
length (float): length of the mortise
thickness (float): thickness of material
finger_play (float): tolerance for good fit
cx (float): coordinate for x center of the finger
cy (float):coordinate for y center of the finger
rotation (float): angle of rotation
"""
bpy.ops.curve.simple(align='WORLD',
location=(cx, cy, 0),
rotation=(0, 0, rotation), Simple_Type='Rectangle',
Simple_width=length + finger_play,
Simple_length=thickness, shape='3D', outputType='POLY',
use_cyclic_u=True,
handleType='AUTO', edit_mode=False)
simple.active_name("_mortise")
[docs]
def interlock_groove(length, thickness, finger_play, cx=0, cy=0, rotation=0):
"""Generates an interlocking groove.
Args:
length (float): Length of groove
thickness (float): thickness of groove
finger_play (float): tolerance for proper fit
cx (float): center offset x
cy (float): center offset y
rotation (float): angle of rotation
"""
mortise(length, thickness, finger_play, 0, 0, 0)
bpy.ops.transform.translate(value=(length / 2 - finger_play / 2, 0.0, 0.0))
bpy.ops.object.transform_apply(location=True, rotation=False, scale=False)
bpy.context.active_object.rotation_euler.z = rotation
bpy.ops.transform.translate(value=(cx, cy, 0.0))
simple.active_name("_groove")
[docs]
def interlock_twist(length, thickness, finger_play, cx=0, cy=0, rotation=0, percentage=0.5):
"""Generates an interlocking twist.
Args:
length (float): Length of groove
thickness (float): thickness of groove
finger_play (float): tolerance for proper fit
cx (float): center offset x
cy (float): center offset y
rotation (float): angle of rotation
percentage (float): percentage amount the twist will take (between 0 and 1)
"""
mortise(length, thickness, finger_play, 0, 0, 0)
simple.active_name("_tmp")
mortise(length * percentage, thickness, finger_play, 0, 0, pi / 2)
simple.active_name("_tmp")
h = hypot(thickness, length * percentage)
oangle = degrees(asin(length * percentage / h))
bpy.ops.curve.simple(align='WORLD', location=(0, 0, 0), rotation=(0, 0, 0), Simple_Type='Sector',
Simple_startangle=90 + oangle, Simple_endangle=180 - oangle, Simple_radius=h / 2,
use_cyclic_u=True, edit_mode=False)
simple.active_name("_tmp")
bpy.ops.curve.simple(align='WORLD', location=(0, 0, 0), rotation=(0, 0, 0), Simple_Type='Sector',
Simple_startangle=270 + oangle, Simple_endangle=360 - oangle, Simple_radius=h / 2,
use_cyclic_u=True, edit_mode=False)
simple.active_name("_tmp")
simple.union('_tmp')
simple.rotate(rotation)
simple.move(x=cx, y=cy)
simple.active_name("_groove")
simple.remove_doubles()
[docs]
def twist_line(length, thickness, finger_play, percentage, amount, distance, center=True):
"""Generates a multiple interlocking twist.
Args:
length (float): Length of groove
thickness (float): thickness of groove
finger_play (float): tolerance for proper fit
percentage (float): percentage amount the twist will take (between 0 and 1)
amount (int):amount of twists generated
distance (float): distance between twists
center (bool): center or not from origin
"""
spacing = distance / amount
while amount > 0:
position = spacing * amount
interlock_twist(length, thickness, finger_play, percentage=percentage, cx=position)
print('twistline', amount, distance, position)
amount -= 1
simple.join_multiple('_groove')
simple.active_name('twist_line')
if center:
simple.move(x=(-distance-spacing)/2)
[docs]
def twist_separator_slot(length, thickness, finger_play=0.00005, percentage=0.5):
"""Generates a slot for interlocking twist separator.
Args:
length (float): Length of slot
thickness (float): thickness of slot
finger_play (float): tolerance for proper fit
percentage (float): percentage amount the twist will take (between 0 and 1)
"""
simple.add_rectangle(thickness+finger_play/2, length, center_y=False)
simple.move(y=((length*percentage-finger_play/2)/2))
simple.duplicate()
simple.mirrory()
simple.join_multiple('simple_rectangle')
simple.active_name('_separator_slot')
[docs]
def interlock_twist_separator(length, thickness, amount, spacing, edge_distance, finger_play=0.00005, percentage=0.5,
start='rounded', end='rounded'):
"""Generates a interlocking twist separator.
Args:
length (float): Length of separator
thickness (float): thickness of separator
amount (int): quantity of separation grooves
spacing (float): distance between slots
edge_distance (float): distance of the first slots close to the edge
finger_play (float): tolerance for proper fit
percentage (float): percentage amount the twist will take (between 0 and 1)
start (string): type of start wanted (rounded, flat or other) not implemented
start (string): type of end wanted (rounded, flat or other) not implemented
"""
amount -= 1
base_width = 2*edge_distance+spacing*amount+thickness
simple.add_rectangle(base_width, length-finger_play*2, center_x=False)
simple.active_name('_base')
twist_separator_slot(length, thickness, finger_play, percentage)
while amount > 0:
simple.duplicate(x=spacing)
amount -= 1
simple.join_multiple('_separator_slot')
simple.move(x=edge_distance+thickness/2)
simple.difference('_', '_base')
simple.active_name('twist_separator')
[docs]
def horizontal_finger(length, thickness, finger_play, amount, center=True):
"""Generates an interlocking horizontal finger pair _wfa and _wfb.
_wfa is centered at 0,0
_wfb is _wfa offset by one length
Args:
length (float): Length of mortise
thickness (float): thickness of material
amount (int): quantity of fingers
finger_play (float): tolerance for proper fit
center (bool): centered of not
"""
if center:
for i in range(amount):
if i == 0:
mortise(length, thickness, finger_play, 0, thickness / 2)
simple.active_name("_width_finger")
else:
mortise(length, thickness, finger_play, i * 2 * length, thickness / 2)
simple.active_name("_width_finger")
mortise(length, thickness, finger_play, -i * 2 * length, thickness / 2)
simple.active_name("_width_finger")
else:
for i in range(amount):
mortise(length, thickness, finger_play, length / 2 + 2 * i * length, 0)
simple.active_name("_width_finger")
simple.join_multiple("_width_finger")
simple.active_name("_wfa")
bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'},
TRANSFORM_OT_translate={"value": (length, 0.0, 0.0)})
simple.active_name("_wfb")
[docs]
def vertical_finger(length, thickness, finger_play, amount):
"""Generates an interlocking horizontal finger pair _vfa and _vfb.
_vfa is starts at 0,0
_vfb is _vfa offset by one length
Args:
length (float): Length of mortise
thickness (float): thickness of material
amount (int): quantity of fingers
finger_play (float): tolerance for proper fit
"""
for i in range(amount):
mortise(length, thickness, finger_play, 0, i * 2 *
length + length / 2, rotation=pi / 2)
simple.active_name("_height_finger")
simple.join_multiple("_height_finger")
simple.active_name("_vfa")
bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'},
TRANSFORM_OT_translate={"value": (0, -length, 0.0)})
simple.active_name("_vfb")
[docs]
def finger_pair(name, dx=0, dy=0):
"""Creates a duplicate set of fingers.
Args:
name (str): name of original finger
dx (float): x offset
dy (float): y offset
"""
simple.make_active(name)
xpos = (dx / 2) * 1.006
ypos = 1.006 * dy / 2
bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'},
TRANSFORM_OT_translate={"value": (xpos, ypos, 0.0)})
simple.active_name("_finger_pair")
simple.make_active(name)
bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'},
TRANSFORM_OT_translate={"value": (-xpos, -ypos, 0.0)})
simple.active_name("_finger_pair")
simple.join_multiple("_finger_pair")
bpy.ops.object.select_all(action='DESELECT')
return bpy.context.active_object
[docs]
def create_base_plate(height, width, depth):
""" Creates blank plates for a box.
Args:
height (float): height size for box
width (float): width size for box
depth (float): depth size for box
"""
bpy.ops.curve.simple(align='WORLD', location=(0, height / 2, 0), rotation=(0, 0, 0), Simple_Type='Rectangle',
Simple_width=width, Simple_length=height, shape='3D', outputType='POLY',
use_cyclic_u=True,
handleType='AUTO', edit_mode=False)
simple.active_name("_back")
bpy.ops.curve.simple(align='WORLD', location=(0, height / 2, 0), rotation=(0, 0, 0), Simple_Type='Rectangle',
Simple_width=depth, Simple_length=height, shape='3D', outputType='POLY',
use_cyclic_u=True,
handleType='AUTO', edit_mode=False)
simple.active_name("_side")
bpy.ops.curve.simple(align='WORLD', location=(0, 0, 0), rotation=(0, 0, 0), Simple_Type='Rectangle',
Simple_width=width, Simple_length=depth, shape='3D', outputType='POLY',
use_cyclic_u=True,
handleType='AUTO', edit_mode=False)
simple.active_name("_bottom")
[docs]
def make_flex_pocket(length, height, finger_thick, finger_width, pocket_width):
"""creates pockets using mortise function for kerf bending
Args:
length (float): Length of pocket
height (float): height of pocket
finger_thick (float): thickness of finger
finger_width (float): width of finger
pocket_width (float): width of pocket
"""
dist = 3 * finger_width / 2
while dist < length:
mortise(height - 2 * finger_thick, pocket_width, 0, dist, 0, pi / 2)
simple.active_name("_flex_pocket")
dist += finger_width * 2
simple.join_multiple("_flex_pocket")
simple.active_name("flex_pocket")
[docs]
def make_variable_flex_pocket(height, finger_thick, pocket_width, locations):
"""creates pockets pocket using mortise function for kerf bending
Args:
height (float): height of the side
finger_thick (float): thickness of the finger
pocket_width (float): width of pocket
locations (tuple): coordinates for pocket
"""
for dist in locations:
mortise(height + 2 * finger_thick, pocket_width, 0, dist, 0, pi / 2)
simple.active_name("_flex_pocket")
simple.join_multiple("_flex_pocket")
simple.active_name("flex_pocket")
[docs]
def create_flex_side(length, height, finger_thick, top_bottom=False):
""" crates a flex side for mortise on curve. Assumes the base fingers were created and exist
Args:
length (float): length of curve
height (float): height of side
finger_thick (float): finger thickness or thickness of material
top_bottom (bool): fingers on top and bottom if true, just on bottom if false
"""
if top_bottom:
fingers = finger_pair("base", 0, height - finger_thick)
else:
simple.make_active("base")
fingers = bpy.context.active_object
bpy.ops.transform.translate(value=(0.0, height / 2 - finger_thick / 2 + 0.0003, 0.0))
bpy.ops.curve.simple(align='WORLD', location=(length / 2 + 0.00025, 0, 0), rotation=(0, 0, 0),
Simple_Type='Rectangle', Simple_width=length, Simple_length=height, shape='3D',
outputType='POLY', use_cyclic_u=True, handleType='AUTO', edit_mode=False)
simple.active_name("no_fingers")
bpy.ops.curve.simple(align='WORLD', location=(length / 2 + 0.00025, 0, 0), rotation=(0, 0, 0),
Simple_Type='Rectangle', Simple_width=length, Simple_length=height, shape='3D',
outputType='POLY', use_cyclic_u=True, handleType='AUTO', edit_mode=False)
simple.active_name("_side")
simple.make_active('_side')
fingers.select_set(True)
bpy.ops.object.curve_boolean(boolean_type='DIFFERENCE')
simple.active_name("side")
simple.remove_multiple('_')
simple.remove_multiple('base')
[docs]
def angle(a, b):
"""returns angle of a vector
Args:
a (tuple): point a x,y coordinates
b (tuple): point b x,y coordinates
"""
return atan2(b[1] - a[1], b[0] - a[0])
[docs]
def angle_difference(a, b, c):
"""returns the difference between two lines with three points
Args:
a (tuple): point a x,y coordinates
b (tuple): point b x,y coordinates
c (tuple): point c x,y coordinates
"""
return angle(a, b) - angle(b, c)
[docs]
def fixed_finger(loop, loop_length, finger_size, finger_thick, finger_tolerance, base=False):
"""distributes mortises of a fixed distance. Dynamically changes the finger tolerance with the angle differences
Args:
loop (list of tuples): takes in a shapely shape
loop_length (float): length of loop
finger_size (float): size of the mortise
finger_thick (float): thickness of the material
finger_tolerance (float): minimum finger tolerance
base (bool): if base exists, it will join with it
"""
coords = list(loop.coords)
old_mortise_angle = 0
distance = finger_size / 2
j = 0
print("Joinery Loop Length", round(loop_length * 1000), "mm")
for i, p in enumerate(coords):
if i == 0:
p_start = p
if p != p_start:
not_start = True
else:
not_start = False
pd = loop.project(Point(p))
if not_start:
while distance <= pd:
mortise_angle = angle(oldp, p)
mortise_angle_difference = abs(mortise_angle - old_mortise_angle)
mad = (1 + 6 * min(mortise_angle_difference, pi / 4) / (
pi / 4)) # factor for tolerance for the finger
if base:
mortise(finger_size, finger_thick, finger_tolerance * mad, distance, 0, 0)
simple.active_name("_base")
else:
mortise_point = loop.interpolate(distance)
mortise(finger_size, finger_thick, finger_tolerance * mad, mortise_point.x, mortise_point.y,
mortise_angle)
j += 1
distance = j * 2 * finger_size + finger_size / 2
old_mortise_angle = mortise_angle
oldp = p
if base:
simple.join_multiple("_base")
simple.active_name("base")
simple.move(x=finger_size)
else:
simple.join_multiple("_mort")
simple.active_name("mortise")
[docs]
def find_slope(p1, p2):
"""returns slope of a vector
Args:
p1 (tuple): point 1 x,y coordinates
p2 (tuple): point 2 x,y coordinates
"""
return (p2[1] - p1[1]) / max(p2[0] - p1[0], 0.00001)
[docs]
def slope_array(loop):
"""Returns an array of slopes from loop coordinates.
Args:
loop (list of tuples): list of coordinates for a curve
"""
simple.remove_multiple("-")
coords = list(loop.coords)
# pnt_amount = round(length / resolution)
sarray = []
dsarray = []
for i, p in enumerate(coords):
distance = loop.project(Point(p))
if i != 0:
slope = find_slope(p, oldp)
sarray.append((distance, slope * -0.001))
oldp = p
for i, p in enumerate(sarray):
distance = p[0]
if i != 0:
slope = find_slope(p, oldp)
if abs(slope) > 10:
print(distance)
dsarray.append((distance, slope * -0.00001))
oldp = p
derivative = LineString(sarray)
dderivative = LineString(dsarray)
utils.shapelyToCurve('-derivative', derivative, 0.0)
utils.shapelyToCurve('-doublederivative', dderivative, 0.0)
return sarray
[docs]
def dslope_array(loop, resolution=0.001):
"""Returns a double derivative array or slope of the slope
Args:
loop (list of tuples): list of coordinates for a curve
resolution (float): granular resolution of the array
"""
length = loop.length
pnt_amount = round(length / resolution)
sarray = []
dsarray = []
for i in range(pnt_amount):
distance = i * resolution
pt = loop.interpolate(distance)
p = (pt.x, pt.y)
if i != 0:
slope = abs(angle(p, oldp))
sarray.append((distance, slope * -0.01))
oldp = p
for i, p in enumerate(sarray):
distance = p[0]
if i != 0:
slope = find_slope(p, oldp)
if abs(slope) > 10:
print(distance)
dsarray.append((distance, slope * -0.1))
oldp = p
dderivative = LineString(dsarray)
utils.shapelyToCurve('doublederivative', dderivative, 0.0)
return sarray
[docs]
def variable_finger(loop, loop_length, min_finger, finger_size, finger_thick, finger_tolerance, adaptive, base=False,
double_adaptive=False):
"""Distributes mortises of a fixed distance. Dynamically changes the finger tolerance with the angle differences
Args:
loop (list of tuples): takes in a shapely shape
loop_length (float): length of loop
finger_size (float): size of the mortise
finger_thick (float): thickness of the material
min_finger (float): minimum finger size
finger_tolerance (float): minimum finger tolerance
adaptive (float): angle threshold to reduce finger size
base (bool): join with base if true
double_adaptive (bool): uses double adaptive algorithm if true
"""
coords = list(loop.coords)
old_mortise_angle = 0
distance = min_finger / 2
finger_sz = min_finger
oldfinger_sz = min_finger
hpos = [] # hpos is the horizontal positions of the middle of the mortise
# slope_array(loop)
print("joinery loop length", round(loop_length * 1000), "mm")
for i, p in enumerate(coords):
if i == 0:
p_start = p
if p != p_start:
not_start = True
else:
not_start = False
pd = loop.project(Point(p))
if not_start:
while distance <= pd:
mortise_angle = angle(oldp, p)
mortise_angle_difference = abs(mortise_angle - old_mortise_angle)
mad = (1 + 6 * min(mortise_angle_difference, pi / 4) / (
pi / 4)) # factor for tolerance for the finger
# move finger by the factor mad greater with larger angle difference
distance += mad * finger_tolerance
mortise_point = loop.interpolate(distance)
if mad > 2 and double_adaptive:
hpos.append(distance) # saves the mortise center
hpos.append(distance + finger_sz) # saves the mortise center
if base:
mortise(finger_sz, finger_thick, finger_tolerance *
mad, distance + finger_sz, 0, 0)
simple.active_name("_base")
else:
mortise(finger_sz, finger_thick, finger_tolerance * mad, mortise_point.x, mortise_point.y,
mortise_angle)
if i == 1:
# put a mesh cylinder at the first coordinates to indicate start
simple.remove_multiple("start_here")
bpy.ops.mesh.primitive_cylinder_add(radius=finger_thick / 2, depth=0.025, enter_editmode=False,
align='WORLD',
location=(mortise_point.x,
mortise_point.y, 0),
scale=(1, 1, 1))
simple.active_name("start_here_mortise")
old_distance = distance
old_mortise_point = mortise_point
finger_sz = finger_size
next_angle_difference = pi
# adaptive finger length start
while finger_sz > min_finger and next_angle_difference > adaptive:
# while finger_sz > min_finger and next_angle_difference > adaptive:
# reduce the size of finger by a percentage... the closer to 1.0, the slower
finger_sz *= 0.95
distance = old_distance + 3 * oldfinger_sz / 2 + finger_sz / 2
mortise_point = loop.interpolate(distance) # get the next mortise point
next_mortise_angle = angle((old_mortise_point.x, old_mortise_point.y),
(mortise_point.x, mortise_point.y)) # calculate next angle
next_angle_difference = abs(next_mortise_angle - mortise_angle)
oldfinger_sz = finger_sz
old_mortise_angle = mortise_angle
oldp = p
if base:
simple.join_multiple("_base")
simple.active_name("base")
else:
print("placeholder")
simple.join_multiple("_mort")
simple.active_name("variable_mortise")
return hpos
[docs]
def single_interlock(finger_depth, finger_thick, finger_tolerance, x, y, groove_angle, type, amount=1,
twist_percentage=0.5):
"""Generates a single interlock at coodinate x,y.
Args:
finger_depth (float): depth of finger
finger_thick (float): thickness of finger
finger_tolerance (float): tolerance for proper fit
x (float): offset x
y (float): offset y
groove_angle (float): angle of rotation
type (str): GROOVE, TWIST, PUZZLE are the valid choices
twist_percentage: percentage of thickness for twist (not used in puzzle or groove)
"""
if type == "GROOVE":
interlock_groove(finger_depth, finger_thick, finger_tolerance, x, y, groove_angle)
elif type == "TWIST":
interlock_twist(finger_depth, finger_thick, finger_tolerance,
x, y, groove_angle, percentage=twist_percentage)
elif type == "PUZZLE":
puzzle_joinery.fingers(finger_thick, finger_tolerance)
[docs]
def distributed_interlock(loop, loop_length, finger_depth, finger_thick, finger_tolerance, finger_amount, tangent=0,
fixed_angle=0, start=0.01, end=0.01, closed=True, type='GROOVE', twist_percentage=0.5):
"""Distributes interlocking joints of a fixed amount.
Dynamically changes the finger tolerance with the angle differences
Args:
loop (list of tuples): coordinates curve
loop_length (float): length of the curve
finger_depth (float): depth of the mortise
finger_thick (float) thickness of the material
finger_tolerance (float): minimum finger tolerance
finger_amount (int): quantity of fingers
tangent (int):
fixed_angle (float): 0 will be variable, desired angle for the finger
closed (bool): False:open curve - True:closed curved
twist_percentage = portion of twist finger which is the stem (for twist joint only)
type (str): GROOVE, TWIST, PUZZLE are the valid choices
start (float): start distance from first point
end (float): end distance from last point
"""
coords = list(loop.coords)
print(closed)
if not closed:
spacing = (loop_length - start - end) / (finger_amount-1)
distance = start
end_distance = loop_length - end
else:
spacing = loop_length / finger_amount
distance = 0
end_distance = loop_length
j = 0
print("Joinery Loop Length", round(loop_length * 1000), "mm")
print("Distance Between Joints", round(spacing * 1000), "mm")
for i, p in enumerate(coords):
if i == 0:
p_start = p
if p != p_start:
not_start = True
else:
not_start = False
pd = loop.project(Point(p))
if not_start:
while distance <= pd and end_distance >= distance:
if fixed_angle == 0:
groove_angle = angle(oldp, p) + pi / 2 + tangent
else:
groove_angle = fixed_angle
groove_point = loop.interpolate(distance)
print(j, "groove_angle", round(180 * groove_angle / pi),
"distance", round(distance * 1000), "mm")
single_interlock(finger_depth, finger_thick, finger_tolerance, groove_point.x, groove_point.y,
groove_angle, type, twist_percentage=twist_percentage)
j += 1
distance = j * spacing + start
oldp = p
simple.join_multiple("_groove")
simple.active_name("interlock")