Source code for boxes.edges

# Copyright (C) 2013-2016 Florian Festi
#
#   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 3 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, see <http://www.gnu.org/licenses/>.
from __future__ import annotations

import argparse
import inspect
import math
import re
from abc import ABC, abstractmethod
from typing import Any

from boxes import gears


[docs] def argparseSections(s: str) -> list[float]: """ Parse sections parameter :param s: string to parse """ result: list[float] = [] parse = re.split(r"\s|:", s) try: for part in parse: m = re.match(r"^(\d+(\.\d+)?)/(\d+)$", part) if m: n = int(m.group(3)) result.extend([float(m.group(1)) / n] * n) continue m = re.match(r"^(\d+(\.\d+)?)\*(\d+)$", part) if m: n = int(m.group(3)) result.extend([float(m.group(1))] * n) continue result.append(float(part)) except ValueError: raise argparse.ArgumentTypeError("Don't understand sections string") if not result: result.append(0.0) return result
[docs] def getDescriptions() -> dict: d = {edge.char: edge.description for edge in globals().values() if inspect.isclass(edge) and issubclass(edge, BaseEdge) and edge.char} d['j'] = d['i'] + " (other end)" d['J'] = d['I'] + " (other end)" d['k'] = d['i'] + " (both ends)" d['K'] = d['I'] + " (both ends)" d['O'] = d['o'] + ' (other end)' d['P'] = d['p'] + ' (other end)' d['U'] = d['u'] + ' top side' d['v'] = d['u'] + ' for 90° lid' d['V'] = d['u'] + ' 90° lid' return d
[docs] class BoltPolicy(ABC): """Abstract class Distributes (bed) bolts on a number of segments (fingers of a finger joint) """
[docs] def drawbolt(self, pos) -> bool: """Add a bolt to this segment? :param pos: number of the finger """ return False
[docs] def numFingers(self, numFingers: int) -> int: """Return next smaller, possible number of fingers :param numFingers: number of fingers to aim for """ return numFingers
def _even(self, numFingers: int) -> int: """ Return same or next smaller even number :param numFingers: """ return (numFingers // 2) * 2 def _odd(self, numFingers: int) -> int: """ Return same or next smaller odd number :param numFingers: """ if numFingers % 2: return numFingers return numFingers - 1
[docs] class Bolts(BoltPolicy): """Distribute a fixed number of bolts evenly""" def __init__(self, bolts: int = 1) -> None: self.bolts = bolts
[docs] def numFingers(self, numFingers: int) -> int: if self.bolts % 2: self.fingers = self._even(numFingers) else: self.fingers = numFingers return self.fingers
[docs] def drawBolt(self, pos): """ Return if this finger needs a bolt :param pos: number of this finger """ if pos > self.fingers // 2: pos = self.fingers - pos if pos == 0: return False if pos == self.fingers // 2 and not (self.bolts % 2): return False return (math.floor((float(pos) * (self.bolts + 1) / self.fingers) - 0.01) != math.floor((float(pos + 1) * (self.bolts + 1) / self.fingers) - 0.01))
############################################################################# ### Settings #############################################################################
[docs] class Settings: """Generic Settings class Used by different other classes to store measurements and details. Supports absolute values and settings that grow with the thickness of the material used. Overload the absolute_params and relative_params class attributes with the supported keys and default values. The values are available via attribute access. Store values that are not supposed to be changed by the users in class or instance properties. This way API users can set them as needed while still be shared between all (Edge) instances using this settings object. """ absolute_params: dict[str, Any] = {} # TODO find better typing. relative_params: dict[str, Any] = {} # TODO find better typing.
[docs] @classmethod def parserArguments(cls, parser, prefix=None, **defaults): prefix = prefix or cls.__name__[:-len("Settings")] lines = cls.__doc__.split("\n") # Parse doc string descriptions = {} r = re.compile(r"^ +\* +(\S+) +: .* : +(.*)") for l in lines: m = r.search(l) if m: descriptions[m.group(1)] = m.group(2) group = parser.add_argument_group(lines[0] or lines[1]) group.prefix = prefix for name, default in (sorted(cls.absolute_params.items()) + sorted(cls.relative_params.items())): # Handle choices choices = None if isinstance(default, tuple): choices = default t = type(default[0]) for val in default: if (type(val) is not t or type(val) not in (bool, int, float, str)): raise ValueError("Type not supported: %r", val) default = default[0] # Overwrite default if name in defaults: default = type(default)(defaults[name]) if type(default) not in (bool, int, float, str): raise ValueError("Type not supported: %r", default) if type(default) is bool: from boxes import BoolArg t = BoolArg() else: t = type(default) group.add_argument(f"--{prefix}_{name}", type=t, action="store", default=default, choices=choices, help=descriptions.get(name))
def __init__(self, thickness, relative: bool = True, **kw) -> None: self.values = {} for name, value in self.absolute_params.items(): if isinstance(value, tuple): value = value[0] if type(value) not in (bool, int, float, str): raise ValueError("Type not supported: %r", value) self.values[name] = value self.thickness = thickness factor = 1.0 if relative: factor = thickness for name, value in self.relative_params.items(): self.values[name] = value * factor self.setValues(thickness, relative, **kw)
[docs] def edgeObjects(self, boxes, chars: str = "", add: bool = True): """ Generate Edge objects using this kind of settings :param boxes: Boxes object :param chars: sequence of chars to be used by Edge objects :param add: add the resulting Edge objects to the Boxes object's edges """ edges: list[Any] = [] return self._edgeObjects(edges, boxes, chars, add)
def _edgeObjects(self, edges, boxes, chars: str, add: bool): for i, edge in enumerate(edges): try: char = chars[i] edge.char = char except IndexError: pass except TypeError: pass if add: boxes.addParts(edges) return edges
[docs] def setValues(self, thickness, relative: bool = True, **kw): """ Set values :param thickness: thickness of the material used :param relative: Do scale by thickness (Default value = True) :param kw: parameters to set """ factor = 1.0 if relative: factor = thickness for name, value in kw.items(): if name in self.absolute_params: self.values[name] = value elif name in self.relative_params: self.values[name] = value * factor elif hasattr(self, name): setattr(self, name, value) else: raise ValueError(f"Unknown parameter for {self.__class__.__name__}: {name}") self.checkValues()
[docs] def checkValues(self) -> None: """ Check if all values are in the right range. Raise ValueError if needed. """ pass
def __getattr__(self, name): if "values" in self.__dict__ and name in self.values: return self.values[name] raise AttributeError
############################################################################# ### Edges #############################################################################
[docs] class BaseEdge(ABC): """Abstract base class for all Edges""" char: str | None = None description: str = "Abstract Edge Class" def __init__(self, boxes, settings) -> None: self.boxes = boxes self.ctx = boxes.ctx self.settings = settings def __getattr__(self, name): """Hack for using unalter code form Boxes class""" return getattr(self.boxes, name)
[docs] @abstractmethod def __call__(self, length, **kw): pass
[docs] def startwidth(self) -> float: """Amount of space the beginning of the edge is set below the inner space of the part """ return 0.0
[docs] def endwidth(self) -> float: return self.startwidth()
[docs] def margin(self) -> float: """Space needed right of the starting point""" return 0.0
[docs] def spacing(self) -> float: """Space the edge needs outside of the inner space of the part""" return self.startwidth() + self.margin()
[docs] def startAngle(self) -> float: """Not yet supported""" return 0.0
[docs] def endAngle(self) -> float: """Not yet supported""" return 0.0
[docs] class Edge(BaseEdge): """Straight edge""" char = 'e' description = "Straight Edge" positive = False def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw): """Draw edge of length mm""" if bedBolts: # distribute the bolts equidistantly interval_length = length / bedBolts.bolts if self.positive: d = (bedBoltSettings or self.bedBoltSettings)[0] for i in range(bedBolts.bolts): self.hole(0.5 * interval_length, 0.5 * self.thickness, 0.5 * d) self.edge(interval_length, tabs= (i == 0 or i == bedBolts.bolts - 1)) else: for i in range(bedBolts.bolts): self.bedBoltHole(interval_length, bedBoltSettings, tabs= (i == 0 or i == bedBolts.bolts - 1)) else: self.edge(length, tabs=2)
[docs] class OutSetEdge(Edge): """Straight edge out set by one thickness""" char = 'E' description = "Straight Edge (outset by thickness)" positive = True
[docs] def startwidth(self) -> float: return self.settings if self.settings is not None else self.boxes.thickness
[docs] class NoopEdge(BaseEdge): """ Edge which does nothing, not even turn or move. """ def __init__(self, boxes, margin=0) -> None: super().__init__(boxes, None) self._margin = margin def __call__(self, _, **kw): # cancel turn self.corner(-90)
[docs] def margin(self) -> float: return self._margin
############################################################################# #### MountingEdge #############################################################################
[docs] class MountingSettings(Settings): """Settings for Mounting Edge Values: * absolute_params * style : "straight edge, within" : edge style * side : "back" : side of box (not all valid configurations make sense...) * num : 2 : number of mounting holes (integer) * margin : 0.125 : minimum space left and right without holes (fraction of the edge length) * d_shaft : 3.0 : shaft diameter of mounting screw (in mm) * d_head : 6.5 : head diameter of mounting screw (in mm) """ PARAM_IN = "straight edge, within" PARAM_EXT = "straight edge, extended" PARAM_TAB = "mounting tab" PARAM_LEFT = "left" PARAM_BACK = "back" PARAM_RIGHT = "right" PARAM_FRONT = "front" absolute_params = { "style": (PARAM_IN, PARAM_EXT, PARAM_TAB), "side": (PARAM_BACK, PARAM_LEFT, PARAM_RIGHT, PARAM_FRONT), "num": 2, "margin": 0.125, "d_shaft": 3.0, "d_head": 6.5 }
[docs] def edgeObjects(self, boxes, chars: str = "G", add: bool = True): edges = [MountingEdge(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class MountingEdge(BaseEdge): description = """Edge with pear shaped mounting holes""" # for slide-on mounting using flat-head screws""" char = 'G'
[docs] def margin(self) -> float: if self.settings.style == MountingSettings.PARAM_TAB: return 2.75 * self.boxes.thickness + self.settings.d_head return 0.0
[docs] def startwidth(self) -> float: if self.settings.style == MountingSettings.PARAM_EXT: return 2.5 * self.boxes.thickness + self.settings.d_head return 0.0
def __call__(self, length, **kw): if length == 0.0: return def check_bounds(val, mn, mx, name): if not mn <= val <= mx: raise ValueError(f"MountingEdge: {name} needs to be in [{mn}, {mx}] but is {val}") style = self.settings.style margin = self.settings.margin num = self.settings.num ds = self.settings.d_shaft dh = self.settings.d_head if dh > 0: width = 3 * self.thickness + dh else: width = ds if num != int(num): raise ValueError(f"MountingEdge: num needs to be an integer number") check_bounds(margin, 0, 0.5, "margin") if not dh == 0: if not dh > ds: raise ValueError(f"MountingEdge: d_shaft needs to be in 0 or > {ds}, but is {dh}") # Check how many holes fit count = max(1, int(num)) if count > 1: margin_ = length * margin gap = (length - 2 * margin_ - width * count) / (count - 1) if gap < width: count = int(((length - 2 * margin + width) / (2 * width)) - 0.5) if count < 1: self.edge(length) return if count < 2: margin_ = (length - width) / 2 gap = 0 else: gap = (length - 2 * margin_ - width * count) / (count - 1) else: margin_ = (length - width) / 2 gap = 0 if style == MountingSettings.PARAM_TAB: # The edge until the first groove self.edge(margin_, tabs=1) for i in range(count): if i > 0: self.edge(gap) self.corner(-90, self.thickness / 2) self.edge(dh + 1.5 * ds - self.thickness / 4 - dh / 2) self.corner(90, self.thickness + dh / 2) self.corner(-90) self.corner(90) self.mountingHole(0, self.thickness * 1.25 + ds / 2, ds, dh, -90) self.corner(90, self.thickness + dh / 2) self.edge(dh + 1.5 * ds - self.thickness / 4 - dh / 2) self.corner(-90, self.thickness / 2) # The edge until the end self.edge(margin_, tabs=1) else: x = margin_ for i in range(count): x += width / 2 self.mountingHole(x, ds / 2 + self.thickness * 1.5, ds, dh, -90) x += width / 2 x += gap self.edge(length)
############################################################################# #### GroovedEdge #############################################################################
[docs] class GroovedSettings(Settings): """Settings for Grooved Edge Values: * absolute_params * style : "arc" : the style of grooves * tri_angle : 30 : the angle of triangular cuts * arc_angle : 120 : the angle of arc cuts * width : 0.2 : the width of each groove (fraction of the edge length) * gap : 0.1 : the gap between grooves (fraction of the edge length) * margin : 0.3 : minimum space left and right without grooves (fraction of the edge length) * inverse : False : invert the groove directions * interleave : False : alternate the direction of grooves """ PARAM_ARC = "arc" PARAM_FLAT = "flat" PARAM_SOFTARC = "softarc" PARAM_TRIANGLE = "triangle" absolute_params = { "style": (PARAM_ARC, PARAM_FLAT, PARAM_TRIANGLE, PARAM_SOFTARC), "tri_angle": 30, "arc_angle": 120, "width": 0.2, "gap": 0.1, "margin": 0.3, "inverse": False, "interleave": False, }
[docs] def edgeObjects(self, boxes, chars: str = "zZ", add: bool = True): edges = [GroovedEdge(boxes, self), GroovedEdgeCounterPart(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class GroovedEdgeBase(BaseEdge):
[docs] def is_inverse(self) -> bool: return self.settings.inverse != self.inverse
[docs] def groove_arc(self, width, angle: float = 90.0, inv: float = -1.0) -> None: side_length = width / math.sin(math.radians(angle)) / 2 self.corner(inv * -angle) self.corner(inv * angle, side_length) self.corner(inv * angle, side_length) self.corner(inv * -angle)
[docs] def groove_soft_arc(self, width, angle: float = 60.0, inv: float = -1.0) -> None: side_length = width / math.sin(math.radians(angle)) / 4 self.corner(inv * -angle, side_length) self.corner(inv * angle, side_length) self.corner(inv * angle, side_length) self.corner(inv * -angle, side_length)
[docs] def groove_triangle(self, width, angle: float = 45.0, inv: float = -1.0) -> None: side_length = width / math.cos(math.radians(angle)) / 2 self.corner(inv * -angle) self.edge(side_length) self.corner(inv * 2 * angle) self.edge(side_length) self.corner(inv * -angle)
def __call__(self, length, **kw): if length == 0.0: return def check_bounds(val, mn, mx, name): if not mn <= val <= mx: raise ValueError(f"{name} needs to be in [{mn}, {mx}] but is {val}") style = self.settings.style width = self.settings.width margin = self.settings.margin gap = self.settings.gap interleave = self.settings.interleave check_bounds(width, 0, 1, "width") check_bounds(margin, 0, 0.5, "margin") check_bounds(gap, 0, 1, "gap") # Check how many grooves fit count = max(0, int((1 - 2 * margin + gap) / (width + gap))) inside_width = max(0, count * (width + gap) - gap) margin = (1 - inside_width) / 2 # Convert to actual length margin = length * margin gap = length * gap width = length * width # Determine the initial inversion inv = 1 if self.is_inverse() else -1 if interleave and self.inverse and count % 2 == 0: inv = -inv # The edge until the first groove self.edge(margin, tabs=1) # Grooves for i in range(count): if i > 0: self.edge(gap) if interleave: inv = -inv if style == GroovedSettings.PARAM_FLAT: self.edge(width) elif style == GroovedSettings.PARAM_ARC: angle = self.settings.arc_angle / 2 self.groove_arc(width, angle, inv) elif style == GroovedSettings.PARAM_SOFTARC: angle = self.settings.arc_angle / 2 self.groove_soft_arc(width, angle, inv) elif style == GroovedSettings.PARAM_TRIANGLE: angle = self.settings.tri_angle self.groove_triangle(width, angle, inv) else: raise ValueError("Unknown GroovedEdge style: %s)" % style) # The final edge self.edge(margin, tabs=1)
[docs] class GroovedEdge(GroovedEdgeBase): description = """Edge with grooves""" char = 'z' inverse = False
[docs] class GroovedEdgeCounterPart(GroovedEdgeBase): description = """Edge with grooves (opposing side)""" char = 'Z' inverse = True
############################################################################# #### Gripping Edge #############################################################################
[docs] class GripSettings(Settings): """Settings for GrippingEdge Values: * absolute_params * style : "wave" : "wave" or "bumps" * outset : True : extend outward the straight edge * relative (in multiples of thickness) * depth : 0.3 : depth of the grooves """ absolute_params = { "style": ("wave", "bumps"), "outset": True, } relative_params = { "depth": 0.3, }
[docs] def edgeObjects(self, boxes, chars: str = "g", add: bool = True): edges = [GrippingEdge(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class GrippingEdge(BaseEdge): description = """Corrugated edge useful as an gipping area""" char = 'g'
[docs] def wave(self, length) -> None: depth = self.settings.depth grooves = int(length // (depth * 2.0)) + 1 depth = length / grooves / 4.0 o = 1 if self.settings.outset else -1 for groove in range(grooves): self.corner(o * -90, depth) self.corner(o * 180, depth) self.corner(o * -90, depth)
[docs] def bumps(self, length) -> None: depth = self.settings.depth grooves = int(length // (depth * 2.0)) + 1 depth = length / grooves / 2.0 o = 1 if self.settings.outset else -1 if self.settings.outset: self.corner(-90) else: self.corner(90) self.edge(depth) self.corner(-180) for groove in range(grooves): self.corner(180, depth) self.corner(-180, 0) if self.settings.outset: self.corner(90) else: self.edge(depth) self.corner(90)
[docs] def margin(self) -> float: if self.settings.outset: return self.settings.depth return 0.0
def __call__(self, length, **kw): if length == 0.0: return getattr(self, self.settings.style)(length)
[docs] class CompoundEdge(BaseEdge): """Edge composed of multiple different Edges""" description = "Compound Edge" def __init__(self, boxes, types, lengths) -> None: super().__init__(boxes, None) self.types = [self.edges.get(edge, edge) for edge in types] self.lengths = lengths self.length = sum(lengths)
[docs] def startwidth(self) -> float: return self.types[0].startwidth()
[docs] def endwidth(self) -> float: return self.types[-1].endwidth()
[docs] def margin(self) -> float: return max(e.margin() + e.startwidth() for e in self.types) - self.types[0].startwidth()
def __call__(self, length, **kw): if length and abs(length - self.length) > 1E-5: raise ValueError("Wrong length for CompoundEdge") lastwidth = self.types[0].startwidth() for e, l in zip(self.types, self.lengths): self.step(e.startwidth() - lastwidth) e(l) lastwidth = e.endwidth()
############################################################################# #### Slots #############################################################################
[docs] class Slot(BaseEdge): """Edge with a slot to slide another piece through """ description = "Slot" def __init__(self, boxes, depth) -> None: super().__init__(boxes, None) self.depth = depth def __call__(self, length, **kw): if self.depth: self.boxes.corner(90) self.boxes.edge(self.depth) self.boxes.corner(-90) self.boxes.edge(length) self.boxes.corner(-90) self.boxes.edge(self.depth) self.boxes.corner(90) else: self.boxes.edge(self.length)
[docs] class SlottedEdge(BaseEdge): """Edge with multiple slots""" description = "Straight Edge with slots" def __init__(self, boxes, sections, edge: str = "e", slots: int = 0) -> None: super().__init__(boxes, Settings(boxes.thickness)) self.edge = self.edges.get(edge, edge) self.sections = sections self.slots = slots
[docs] def startwidth(self) -> float: return self.edge.startwidth()
[docs] def endwidth(self) -> float: return self.edge.endwidth()
[docs] def margin(self) -> float: return self.edge.margin()
def __call__(self, length, **kw): for l in self.sections[:-1]: self.edge(l) if self.slots: Slot(self.boxes, self.slots)(self.settings.thickness) else: self.boxes.edge(self.settings.thickness) self.edge(self.sections[-1])
############################################################################# #### Finger Joints #############################################################################
[docs] class FingerJointSettings(Settings): """Settings for Finger Joints Values: * absolute * style : "rectangular" : style of the fingers * surroundingspaces : 2.0 : space at the start and end in multiple of normal spaces * relative (in multiples of thickness) * space : 2.0 : space between fingers (multiples of thickness) * finger : 2.0 : width of the fingers (multiples of thickness) * width : 1.0 : width of finger holes (multiples of thickness) * edge_width : 1.0 : space below holes of FingerHoleEdge (multiples of thickness) * play : 0.0 : extra space to allow finger move in and out (multiples of thickness) * extra_length : 0.0 : extra material to grind away burn marks (multiples of thickness) * bottom_lip : 0.0 : height of the bottom lips sticking out (multiples of thickness) FingerHoleEdge only! """ absolute_params = { "style": ("rectangular", "springs", "barbs", "snap"), "surroundingspaces": 2.0, } relative_params = { "space": 2.0, "finger": 2.0, "width": 1.0, "edge_width": 1.0, "play": 0.0, "extra_length": 0.0, "bottom_lip": 0.0, } angle = 90 # Angle of the walls meeting
[docs] def checkValues(self) -> None: if abs(self.space + self.finger) < 0.1: raise ValueError("FingerJointSettings: space + finger must not be close to zero")
[docs] def edgeObjects(self, boxes, chars: str = "fFh", add: bool = True): edges = [FingerJointEdge(boxes, self), FingerJointEdgeCounterPart(boxes, self), FingerHoleEdge(boxes, self), ] return self._edgeObjects(edges, boxes, chars, add)
[docs] class FingerJointBase(ABC): """Abstract base class for finger joint."""
[docs] def calcFingers(self, length: float, bedBolts) -> tuple[int, float]: space, finger = self.settings.space, self.settings.finger # type: ignore fingers = int((length - (self.settings.surroundingspaces - 1) * space) // (space + finger)) # type: ignore # shrink surrounding space up to half a thickness each side if fingers == 0 and length > finger + 1.0 * self.settings.thickness: # type: ignore fingers = 1 if not finger: fingers = 0 if bedBolts: fingers = bedBolts.numFingers(fingers) leftover = length - fingers * (space + finger) + space if fingers <= 0: fingers = 0 leftover = length return fingers, leftover
[docs] def fingerLength(self, angle: float) -> tuple[float, float]: # sharp corners if angle >= 90 or angle <= -90: return self.settings.thickness + self.settings.extra_length, 0.0 # type: ignore # inner blunt corners if angle < 0: return (math.sin(math.radians(-angle)) * self.settings.thickness + self.settings.extra_length), 0 # type: ignore # 0 to 90 (blunt corners) a = 90 - (180 - angle) / 2.0 fingerlength = self.settings.thickness * math.tan(math.radians(a)) # type: ignore b = 90 - 2 * a spacerecess = -math.sin(math.radians(b)) * fingerlength return fingerlength + self.settings.extra_length, spacerecess # type: ignore
[docs] class FingerJointEdge(BaseEdge, FingerJointBase): """Finger joint edge """ char = 'f' description = "Finger Joint" positive = True
[docs] def draw_finger(self, f, h, style, positive: bool = True, firsthalf: bool = True) -> None: t = self.settings.thickness if positive: if style == "springs": self.polyline( 0, -90, 0.8 * h, (90, 0.2 * h), 0.1 * h, 90, 0.9 * h, -180, 0.9 * h, 90, f - 0.6 * h, 90, 0.9 * h, -180, 0.9 * h, 90, 0.1 * h, (90, 0.2 * h), 0.8 * h, -90) elif style == "barbs": n = int((h - 0.1 * t) // (0.3 * t)) a = math.degrees(math.atan(0.5)) l = 5 ** 0.5 poly = [h - n * 0.3 * t] + \ ([-45, 0.1 * 2 ** 0.5 * t, 45 + a, l * 0.1 * t, -a, 0] * n) self.polyline( 0, -90, *poly, 90, f, 90, *reversed(poly), -90 ) elif style == "snap" and f > 1.9 * t: a12 = math.degrees(math.atan(0.5)) l12 = t / math.cos(math.radians(a12)) d = 4 * t d2 = d + 1 * t a = math.degrees(math.atan((0.5 * t) / (h + d2))) l = (h + d2) / math.cos(math.radians(a)) poly = [0, 90, d, -180, d + h, -90, 0.5 * t, 90 + a12, l12, 90 - a12, 0.5 * t, 90 - a, l, +a, 0, (-180, 0.1 * t), h + d2, 90, f - 1.7 * t, 90 - a12, l12, a12, h, -90, 0] if firsthalf: poly = list(reversed(poly)) self.polyline(*poly) else: self.polyline(0, -90, h, 90, f, 90, h, -90) else: self.polyline(0, 90, h, -90, f, -90, h, 90)
def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw): positive = self.positive t = self.settings.thickness s, f = self.settings.space, self.settings.finger thickness = self.settings.thickness style = self.settings.style play = self.settings.play fingers, leftover = self.calcFingers(length, bedBolts) # not enough space for normal fingers - use small rectangular one if (fingers == 0 and f and leftover > 0.75 * thickness and leftover > 4 * play): fingers = 1 f = leftover = leftover / 2.0 bedBolts = None style = "rectangular" if not positive: f += play s -= play leftover -= play self.edge(leftover / 2.0, tabs=1) l1, l2 = self.fingerLength(self.settings.angle) h = l1 - l2 d = (bedBoltSettings or self.bedBoltSettings)[0] for i in range(fingers): if i != 0: if not positive and bedBolts and bedBolts.drawBolt(i): self.hole(0.5 * s, 0.5 * self.settings.thickness, 0.5 * d) if positive and bedBolts and bedBolts.drawBolt(i): self.bedBoltHole(s, bedBoltSettings) else: self.edge(s) self.draw_finger(f, h, style, positive, i < fingers // 2) self.edge(leftover / 2.0, tabs=1)
[docs] def margin(self) -> float: """ """ widths = self.fingerLength(self.settings.angle) if self.positive: if self.settings.style == "snap": return widths[0] - widths[1] + self.settings.thickness return widths[0] - widths[1] return 0.0
[docs] def startwidth(self) -> float: widths = self.fingerLength(self.settings.angle) return widths[self.positive]
[docs] class FingerJointEdgeCounterPart(FingerJointEdge): """Finger joint edge - other side""" char = 'F' description = "Finger Joint (opposing side)" positive = False
[docs] class FingerHoles(FingerJointBase): """Hole matching a finger joint edge""" def __init__(self, boxes, settings) -> None: self.boxes = boxes self.ctx = boxes.ctx self.settings = settings def __call__(self, x, y, length, angle=90, bedBolts=None, bedBoltSettings=None): """ Draw holes for a matching finger joint edge :param x: x position :param y: y position :param length: length of matching edge :param angle: (Default value = 90) :param bedBolts: (Default value = None) :param bedBoltSettings: (Default value = None) """ with self.boxes.saved_context(): self.boxes.moveTo(x, y, angle) s, f = self.settings.space, self.settings.finger p = self.settings.play b = self.boxes.burn fingers, leftover = self.calcFingers(length, bedBolts) # not enough space for normal fingers - use small rectangular one if (fingers == 0 and f and leftover > 0.75 * self.settings.thickness and leftover > 4 * p): fingers = 1 f = leftover = leftover / 2.0 bedBolts = None if self.boxes.debug: self.ctx.rectangle(b, -self.settings.width / 2 + b, length - 2 * b, self.settings.width - 2 * b) for i in range(fingers): pos = leftover / 2.0 + i * (s + f) if bedBolts and bedBolts.drawBolt(i): d = (bedBoltSettings or self.boxes.bedBoltSettings)[0] self.boxes.hole(pos - 0.5 * s, 0, d * 0.5) self.boxes.rectangularHole(pos + 0.5 * f, 0, f + p, self.settings.width + p)
[docs] class FingerHoleEdge(BaseEdge): """Edge with holes for a parallel finger joint""" char = 'h' description = "Edge (parallel Finger Joint Holes)" def __init__(self, boxes, fingerHoles=None, **kw) -> None: settings = None if isinstance(fingerHoles, Settings): settings = fingerHoles fingerHoles = FingerHoles(boxes, settings) super().__init__(boxes, settings, **kw) self.fingerHoles = fingerHoles or boxes.fingerHolesAt def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw): dist = self.fingerHoles.settings.edge_width with self.saved_context(): self.fingerHoles( 0, self.burn + dist + self.settings.thickness / 2, length, 0, bedBolts=bedBolts, bedBoltSettings=bedBoltSettings) if self.settings.bottom_lip: h = self.settings.bottom_lip + \ self.fingerHoles.settings.edge_width sp = self.boxes.spacing self.moveTo(-sp / 2, -h - sp) self.rectangularWall(length - 1.05 * self.boxes.thickness, h) self.edge(length, tabs=2)
[docs] def startwidth(self) -> float: """ """ return self.fingerHoles.settings.edge_width + self.settings.thickness
[docs] def margin(self) -> float: if self.settings.bottom_lip: return self.settings.bottom_lip + self.fingerHoles.settings.edge_width + self.boxes.spacing return 0.0
[docs] class CrossingFingerHoleEdge(Edge): """Edge with holes for finger joints 90° above""" description = "Edge (orthogonal Finger Joint Holes)" char = '|' def __init__(self, boxes, height, fingerHoles=None, outset: float = 0.0, **kw) -> None: super().__init__(boxes, None, **kw) self.fingerHoles = fingerHoles or boxes.fingerHolesAt self.height = height self.outset = outset def __call__(self, length, **kw): self.fingerHoles(length / 2.0, self.outset + self.burn, self.height) super().__call__(length)
[docs] def startwidth(self) -> float: return self.outset
############################################################################# #### Stackable Joints #############################################################################
[docs] class StackableSettings(Settings): """Settings for Stackable Edges Values: * absolute_params * angle : 60 : inside angle of the feet * relative (in multiples of thickness) * height : 2.0 : height of the feet (multiples of thickness) * width : 4.0 : width of the feet (multiples of thickness) * holedistance : 1.0 : distance from finger holes to bottom edge (multiples of thickness) * bottom_stabilizers : 0.0 : height of strips to be glued to the inside of bottom edges (multiples of thickness) """ absolute_params = { "angle": 60, } relative_params = { "height": 2.0, "width": 4.0, "holedistance": 1.0, "bottom_stabilizers": 0.0, }
[docs] def checkValues(self) -> None: if self.angle < 20: raise ValueError("StackableSettings: 'angle' is too small. Use value >= 20") if self.angle > 260: raise ValueError("StackableSettings: 'angle' is too big. Use value < 260")
[docs] def edgeObjects(self, boxes, chars: str = "sSšŠ", add: bool = True, fingersettings=None): fingersettings = fingersettings or boxes.edges["f"].settings edges = [StackableEdge(boxes, self, fingersettings), StackableEdgeTop(boxes, self, fingersettings), StackableFeet(boxes, self, fingersettings), StackableHoleEdgeTop(boxes, self, fingersettings), ] return self._edgeObjects(edges, boxes, chars, add)
[docs] class StackableBaseEdge(BaseEdge): """Edge for having stackable Boxes. The Edge creates feet on the bottom and has matching recesses on the top corners.""" char = "s" description = "Abstract Stackable class" bottom = True def __init__(self, boxes, settings, fingerjointsettings) -> None: super().__init__(boxes, settings) self.fingerjointsettings = fingerjointsettings def __call__(self, length, **kw): s = self.settings r = s.height / 2.0 / (1 - math.cos(math.radians(s.angle))) l = r * math.sin(math.radians(s.angle)) p = 1 if self.bottom else -1 if self.bottom and s.bottom_stabilizers: with self.saved_context(): sp = self.boxes.spacing self.moveTo(-sp / 2) self.rectangularWall(length - 1.05 * self.boxes.thickness, s.bottom_stabilizers, move="down") self.boxes.edge(s.width, tabs=1) self.boxes.corner(p * s.angle, r) self.boxes.corner(-p * s.angle, r) self.boxes.edge(length - 2 * s.width - 4 * l) self.boxes.corner(-p * s.angle, r) self.boxes.corner(p * s.angle, r) self.boxes.edge(s.width, tabs=1) def _height(self): return self.settings.height + self.settings.holedistance + self.settings.thickness
[docs] def startwidth(self) -> float: return self._height() if self.bottom else 0
[docs] def margin(self) -> float: if self.bottom: if self.settings.bottom_stabilizers: return self.settings.bottom_stabilizers + self.boxes.spacing else: return 0 else: return self.settings.height
[docs] class StackableEdge(StackableBaseEdge): """Edge for having stackable Boxes. The Edge creates feet on the bottom and has matching recesses on the top corners.""" char = "s" description = "Stackable (bottom, finger joint holes)" def __call__(self, length, **kw): s = self.settings self.boxes.fingerHolesAt( 0, s.height + s.holedistance + 0.5 * self.boxes.thickness, length, 0) super().__call__(length, **kw)
[docs] class StackableEdgeTop(StackableBaseEdge): char = "S" description = "Stackable (top)" bottom = False
[docs] class StackableFeet(StackableBaseEdge): char = "š" description = "Stackable feet (bottom)" def _height(self): return self.settings.height
[docs] class StackableHoleEdgeTop(StackableBaseEdge): char = "Š" description = "Stackable edge with finger holes (top)" bottom = False
[docs] def startwidth(self) -> float: return self.settings.thickness + self.settings.holedistance
def __call__(self, length, **kw): s = self.settings self.boxes.fingerHolesAt( 0, s.holedistance + 0.5 * self.boxes.thickness, length, 0) super().__call__(length, **kw)
############################################################################# #### Hinges #############################################################################
[docs] class HingeSettings(Settings): """Settings for Hinges and HingePins Values: * absolute_params * outset : False : have lid overlap at the sides (similar to OutSetEdge) * pinwidth : 1.0 : set to lower value to get disks surrounding the pins * grip_percentage" : 0 : percentage of the lid that should get grips * relative (in multiples of thickness) * hingestrength : 1 : thickness of the arc holding the pin in place (multiples of thickness) * axle : 2 : diameter of the pin hole (multiples of thickness) * grip_length : 0 : fixed length of the grips on he lids (multiples of thickness) """ absolute_params = { "outset": False, "pinwidth": 0.5, "grip_percentage": 0, } relative_params = { "hingestrength": 1, # 1.5-0.5*2**0.5, "axle": 2.0, "grip_length": 0, } style = "outset" # "outset", "flush", "flush_inset"
[docs] def checkValues(self) -> None: if self.axle / self.thickness < 0.1: raise ValueError("HingeSettings: 'axle' need to be at least 0.1 strong")
[docs] def edgeObjects(self, boxes, chars: str = "iIjJkK", add: bool = True): edges = [ Hinge(boxes, self, 1), HingePin(boxes, self, 1), Hinge(boxes, self, 2), HingePin(boxes, self, 2), Hinge(boxes, self, 3), HingePin(boxes, self, 3), ] return self._edgeObjects(edges, boxes, chars, add)
[docs] class Hinge(BaseEdge): char = 'i' description = "Straight edge with hinge eye" def __init__(self, boxes, settings=None, layout: int = 1) -> None: super().__init__(boxes, settings) if not (0 < layout <= 3): raise ValueError("layout must be 1, 2 or 3 (got %i)" % layout) self.layout = layout self.char = "eijk"[layout] self.description = self.description + ('', ' (start)', ' (end)', ' (both ends)')[layout]
[docs] def margin(self) -> float: t: float = self.settings.thickness if self.settings.style == "outset": r = 0.5 * self.settings.axle alpha = math.degrees(math.asin(0.5 * t / r)) pos = math.cos(math.radians(alpha)) * r return 1.5 * t + pos else: # flush return 0.5 * t + 0.5 * self.settings.axle + self.settings.hingestrength
[docs] def outset(self, _reversed: bool = False) -> None: t: float = self.settings.thickness r = 0.5 * self.settings.axle alpha = math.degrees(math.asin(0.5 * t / r)) pinl = (self.settings.axle ** 2 - self.settings.thickness ** 2) ** 0.5 * self.settings.pinwidth pos = math.cos(math.radians(alpha)) * r hinge = ( 0., 90. - alpha, 0., (-360., r), 0., 90. + alpha, t, 90., 0.5 * t, (180., t + pos), 0., (-90., 0.5 * t), 0. ) if _reversed: hinge = reversed(hinge) # type: ignore self.polyline(*hinge) self.boxes.rectangularHole(-pos, -0.5 * t, pinl, self.settings.thickness) else: self.boxes.rectangularHole(pos, -0.5 * t, pinl, self.settings.thickness) self.polyline(*hinge)
[docs] def outsetlen(self) -> float: t = self.settings.thickness r = 0.5 * self.settings.axle alpha = math.degrees(math.asin(0.5 * t / r)) pos = math.cos(math.radians(alpha)) * r return 2.0 * pos + 1.5 * t
[docs] def flush(self, _reversed: bool = False) -> None: t = self.settings.thickness hinge = ( 0., -90., 0.5 * t, (180., 0.5 * self.settings.axle + self.settings.hingestrength), 0., (-90., 0.5 * t), 0. ) pos = 0.5 * self.settings.axle + self.settings.hingestrength pinl = (self.settings.axle ** 2 - self.settings.thickness ** 2) ** 0.5 * self.settings.pinwidth if _reversed: hinge = reversed(hinge) # type: ignore self.hole(0.5 * t + pos, -0.5 * t, 0.5 * self.settings.axle) self.boxes.rectangularHole(0.5 * t + pos, -0.5 * t, pinl, self.settings.thickness) else: self.hole(pos, -0.5 * t, 0.5 * self.settings.axle) self.boxes.rectangularHole(pos, -0.5 * t, pinl, self.settings.thickness) self.polyline(*hinge)
[docs] def flushlen(self) -> float: return self.settings.axle + 2.0 * self.settings.hingestrength + 0.5 * self.settings.thickness
def __call__(self, l, **kw): hlen = getattr(self, self.settings.style + 'len', self.flushlen)() if self.layout in (1, 3): getattr(self, self.settings.style, self.flush)() self.edge(l - (self.layout & 1) * hlen - bool(self.layout & 2) * hlen, tabs=2) if self.layout in (2, 3): getattr(self, self.settings.style, self.flush)(True)
[docs] class HingePin(BaseEdge): char = 'I' description = "Edge with hinge pin" def __init__(self, boxes, settings=None, layout: int = 1) -> None: super().__init__(boxes, settings) if not (0 < layout <= 3): raise ValueError("layout must be 1, 2 or 3 (got %i)" % layout) self.layout = layout self.char = "EIJK"[layout] self.description = self.description + ('', ' (start)', ' (end)', ' (both ends)')[layout]
[docs] def startwidth(self) -> float: if self.layout & 1: return 0.0 return self.settings.outset * self.boxes.thickness
[docs] def endwidth(self) -> float: if self.layout & 2: return 0.0 return self.settings.outset * self.boxes.thickness
[docs] def margin(self) -> float: if self.settings.outset and ( self.settings.grip_percentage > 0.0 or self.settings.grip_length > 0.0 ): return self.settings.thickness + self.boxes.edges['g'].margin() else: return self.settings.thickness
[docs] def outset(self, _reversed: bool = False) -> None: t: float = self.settings.thickness r = 0.5 * self.settings.axle alpha = math.degrees(math.asin(0.5 * t / r)) pos = math.cos(math.radians(alpha)) * r pinl = (self.settings.axle ** 2 - self.settings.thickness ** 2) ** 0.5 * self.settings.pinwidth pin = (pos - 0.5 * pinl, -90., t, 90., pinl, 90., t, -90.) if self.settings.outset: pin += ( # type: ignore pos - 0.5 * pinl + 1.5 * t, -90., t, 90., 0., ) else: pin += (pos - 0.5 * pinl,) # type: ignore if _reversed: pin = reversed(pin) # type: ignore self.polyline(*pin)
[docs] def outsetlen(self): t = self.settings.thickness r = 0.5 * self.settings.axle alpha = math.degrees(math.asin(0.5 * t / r)) pos = math.cos(math.radians(alpha)) * r if self.settings.outset: return 2 * pos + 1.5 * self.settings.thickness return 2 * pos
[docs] def flush(self, _reversed: bool = False) -> None: t: float = self.settings.thickness pinl = (self.settings.axle ** 2 - t ** 2) ** 0.5 * self.settings.pinwidth d = d1 = (self.settings.axle - pinl) / 2.0 if self.settings.style == "flush_inset": d1 -= self.settings.thickness pin = (self.settings.hingestrength + d1, -90., t, 90., pinl, 90., t, -90., d) if self.settings.outset: pin += ( # type: ignore 0., self.settings.hingestrength + 0.5 * t, -90., t, 90., 0., ) if _reversed: pin = reversed(pin) # type: ignore self.polyline(*pin)
[docs] def flushlen(self): l = self.settings.hingestrength + self.settings.axle if self.settings.style == "flush_inset": l -= self.settings.thickness if self.settings.outset: l += self.settings.hingestrength + 0.5 * self.settings.thickness return l
def __call__(self, l, **kw): plen = getattr(self, self.settings.style + 'len', self.flushlen)() glen = l * self.settings.grip_percentage / 100 + \ self.settings.grip_length if not self.settings.outset: glen = 0.0 glen = min(glen, l - plen) if self.layout == 3: getattr(self, self.settings.style, self.flush)() self.edge(l - 2 * plen, tabs=2) getattr(self, self.settings.style, self.flush)(True) elif self.layout == 1: getattr(self, self.settings.style, self.flush)() self.edge(l - plen - glen, tabs=2) self.edges['g'](glen) else: # self.layout == 2 self.edges['g'](glen) self.edge(l - plen - glen, tabs=2) getattr(self, self.settings.style, self.flush)(True)
############################################################################# #### Chest Hinge #############################################################################
[docs] class ChestHingeSettings(Settings): """Settings for Chest Hinges Values: * relative (in multiples of thickness) * pin_height : 2.0 : radius of the disc rotating in the hinge (multiples of thickness) * hinge_strength : 1.0 : thickness of the arc holding the pin in place (multiples of thickness) * absolute * finger_joints_on_box : False : whether to include finger joints on the edge with the box * finger_joints_on_lid : False : whether to include finger joints on the edge with the lid """ relative_params = { "pin_height": 2.0, "hinge_strength": 1.0, "play": 0.1, } absolute_params = { "finger_joints_on_box": False, "finger_joints_on_lid": False, }
[docs] def checkValues(self) -> None: if self.pin_height / self.thickness < 1.2: raise ValueError("ChestHingeSettings: 'pin_height' must be >= 1.2")
[docs] def pinheight(self): return ((0.9 * self.pin_height) ** 2 - self.thickness ** 2) ** 0.5
[docs] def edgeObjects(self, boxes, chars: str = "oOpPqQ", add: bool = True): edges = [ ChestHinge(boxes, self), ChestHinge(boxes, self, True), ChestHingeTop(boxes, self), ChestHingeTop(boxes, self, True), ChestHingePin(boxes, self), ChestHingeFront(boxes, self), ] return self._edgeObjects(edges, boxes, chars, add)
[docs] class ChestHinge(BaseEdge): description = "Edge with chest hinge" char = "o" def __init__(self, boxes, settings=None, reversed: bool = False) -> None: super().__init__(boxes, settings) self.reversed = reversed self.char = "oO"[reversed] self.description = self.description + (' (start)', ' (end)')[reversed] def __call__(self, l, **kw): t = self.settings.thickness p = self.settings.pin_height s = self.settings.hinge_strength pinh = self.settings.pinheight() if self.reversed: self.hole(l + t, 0, p, tabs=4) self.rectangularHole(l + 0.5 * t, -0.5 * pinh, t, pinh) else: self.hole(-t, -s - p, p, tabs=4) self.rectangularHole(-0.5 * t, -s - p - 0.5 * pinh, t, pinh) if self.settings.finger_joints_on_box: final_segment = t - s draw_rest_of_edge = lambda: self.edges["F"](l - p) else: final_segment = l + t - p - s draw_rest_of_edge = lambda: None poly = (0, -180, t, (270, p + s), 0, -90, final_segment) if self.reversed: draw_rest_of_edge() self.polyline(*reversed(poly)) else: self.polyline(*poly) draw_rest_of_edge()
[docs] def margin(self) -> float: if self.reversed: return 0.0 return 1 * (self.settings.pin_height + self.settings.hinge_strength)
[docs] def startwidth(self) -> float: if self.reversed: return self.settings.pin_height + self.settings.hinge_strength return 0.0
[docs] def endwidth(self) -> float: if self.reversed: return 0.0 return self.settings.pin_height + self.settings.hinge_strength
[docs] class ChestHingeTop(ChestHinge): """Edge above a chest hinge""" char = "p" def __init__(self, boxes, settings=None, reversed: bool = False) -> None: super().__init__(boxes, settings) self.reversed = reversed self.char = "oO"[reversed] self.description = self.description + (' (start)', ' (end)')[reversed] def __call__(self, l, **kw): t = self.settings.thickness p = self.settings.pin_height s = self.settings.hinge_strength play = self.settings.play if self.settings.finger_joints_on_lid: final_segment = t - s - play draw_rest_of_edge = lambda: self.edges["F"](l - p) else: final_segment = l + t - p - s - play draw_rest_of_edge = lambda: None poly = (0, -180, t, -180, 0, (-90, p + s + play), 0, 90, final_segment) if self.reversed: draw_rest_of_edge() self.polyline(*reversed(poly)) else: self.polyline(*poly) draw_rest_of_edge()
[docs] def startwidth(self) -> float: if self.reversed: return self.settings.play + self.settings.pin_height + self.settings.hinge_strength return 0.0
[docs] def endwidth(self) -> float: if self.reversed: return 0.0 return self.settings.play + self.settings.pin_height + self.settings.hinge_strength
[docs] def margin(self) -> float: if self.reversed: return 0.0 return 1 * (self.settings.play + self.settings.pin_height + self.settings.hinge_strength)
[docs] class ChestHingePin(BaseEdge): description = "Edge with pins for an chest hinge" char = "q" def __call__(self, l, **kw): t = self.settings.thickness p = self.settings.pin_height s = self.settings.hinge_strength pinh = self.settings.pinheight() if self.settings.finger_joints_on_lid: middle_segment = [0] draw_rest_of_edge = lambda: (self.edge(t), self.edges["F"](l), self.edge(t)) else: middle_segment = [l + 2 * t, ] draw_rest_of_edge = lambda: None poly = [0, -90, s + p - pinh, -90, t, 90, pinh, 90, ] self.polyline(*poly) draw_rest_of_edge() self.polyline(*(middle_segment + list(reversed(poly))))
[docs] def margin(self) -> float: return (self.settings.pin_height + self.settings.hinge_strength)
[docs] class ChestHingeFront(Edge): description = "Edge opposing a chest hinge" char = "Q"
[docs] def startwidth(self) -> float: return self.settings.pin_height + self.settings.hinge_strength
############################################################################# #### Cabinet Hinge #############################################################################
[docs] class CabinetHingeSettings(Settings): """Settings for Cabinet Hinges Values: * absolute_params * bore : 3.2 : diameter of the pin hole in mm * eyes_per_hinge : 5 : pieces per hinge * hinges : 2 : number of hinges per edge * style : inside : style of hinge used * relative (in multiples of thickness) * eye : 1.5 : radius of the eye (multiples of thickness) * play : 0.05 : space between eyes (multiples of thickness) * spacing : 2.0 : minimum space around the hinge (multiples of thickness) """ absolute_params = { "bore": 3.2, "eyes_per_hinge": 5, "hinges": 2, "style": ("inside", "outside"), } relative_params = { "eye": 1.5, "play": 0.05, "spacing": 2.0, }
[docs] def edgeObjects(self, boxes, chars: str = "uUvV", add: bool = True): edges = [CabinetHingeEdge(boxes, self), CabinetHingeEdge(boxes, self, top=True), CabinetHingeEdge(boxes, self, angled=True), CabinetHingeEdge(boxes, self, top=True, angled=True), ] for e, c in zip(edges, chars): e.char = c return self._edgeObjects(edges, boxes, chars, add)
[docs] class CabinetHingeEdge(BaseEdge): """Edge with cabinet hinges""" char = "u" description = "Edge with cabinet hinges" def __init__(self, boxes, settings=None, top: bool = False, angled: bool = False) -> None: super().__init__(boxes, settings) self.top = top self.angled = angled self.char = "uUvV"[bool(top) + 2 * bool(angled)]
[docs] def startwidth(self) -> float: return self.settings.thickness if self.top and self.angled else 0.0
def __poly(self): n = self.settings.eyes_per_hinge p = self.settings.play e = self.settings.eye t = self.settings.thickness spacing = self.settings.spacing if self.settings.style == "outside" and self.angled: e = t elif self.angled and not self.top: # move hinge up to leave space for lid e -= t if self.top: # start with space poly = [spacing, 90, e + p] else: # start with hinge eye poly = [spacing + p, 90, e + p, 0] for i in range(n): if (i % 2) ^ self.top: # space if i == 0: poly += [-90, t + 2 * p, 90] else: poly += [90, t + 2 * p, 90] else: # hinge eye poly += [t - p, -90, t, -90, t - p] if (n % 2) ^ self.top: # stopped with hinge eye poly += [0, e + p, 90, p + spacing] else: # stopped with space poly[-1:] = [-90, e + p, 90, 0 + spacing] width = (t + p) * n + p + 2 * spacing return poly, width def __call__(self, l, **kw): n = self.settings.eyes_per_hinge p = self.settings.play e = self.settings.eye t = self.settings.thickness hn = self.settings.hinges poly, width = self.__poly() if self.settings.style == "outside" and self.angled: e = t elif self.angled and not self.top: # move hinge up to leave space for lid e -= t hn = min(hn, int(l // width)) if hn == 1: self.edge((l - width) / 2, tabs=2) for j in range(hn): for i in range(n): if not (i % 2) ^ self.top: self.rectangularHole(self.settings.spacing + 0.5 * t + p + i * (t + p), e + 2.5 * t, t, t) self.polyline(*poly) if j < (hn - 1): self.edge((l - hn * width) / (hn - 1), tabs=2) if hn == 1: self.edge((l - width) / 2, tabs=2)
[docs] def parts(self, move=None) -> None: e, b = self.settings.eye, self.settings.bore t = self.settings.thickness n = self.settings.eyes_per_hinge * self.settings.hinges pairs = n // 2 + 2 * (n % 2) if self.settings.style == "outside": th = 2 * e + 4 * t tw = n * (max(3 * t, 2 * e) + self.boxes.spacing) else: th = 4 * e + 3 * t + self.boxes.spacing tw = max(e, 2 * t) * pairs if self.move(tw, th, move, True, label="hinges"): return if self.settings.style == "outside": ax = max(t / 2, e - t) self.moveTo(t + ax) for i in range(n): if self.angled: if i > n // 2: l = 4 * t + ax else: l = 5 * t + ax else: l = 3 * t + e self.hole(0, e, b / 2.0) da = math.asin((t - ax) / e) dad = math.degrees(da) dy = e * (1 - math.cos(da)) self.polyline(0, (180 - dad, e), 0, (-90 + dad), dy + l - e, (90, t)) self.polyline(0, 90, t, -90, t, 90, t, 90, t, -90, t, -90, t, 90, t, 90, (ax + t) - e, -90, l - 3 * t, (90, e)) self.moveTo(2 * max(e, 1.5 * t) + self.boxes.spacing) self.move(tw, th, move, label="hinges") return if e <= 2 * t: if self.angled: corner = [2 * e - t, (90, 2 * t - e), 0, -90, t, (90, e)] else: corner = [2 * e, (90, 2 * t)] else: a = math.asin(2 * t / e) ang = math.degrees(a) corner = [e * (1 - math.cos(a)) + 2 * t, -90 + ang, 0, (180 - ang, e)] self.moveTo(max(e, 2 * t)) for i in range(n): self.hole(0, e, b / 2.0) self.polyline(*[0, (180, e), 0, -90, t, 90, t, -90, t, -90, t, 90, t, 90, t, (90, t)] + corner) self.moveTo(self.boxes.spacing, 4 * e + 3 * t + self.boxes.spacing, 180) if i % 2: self.moveTo(2 * max(e, 2 * t) + 2 * self.boxes.spacing) self.move(th, tw, move, label="hinges")
############################################################################# #### Slide-on lid #############################################################################
[docs] class SlideOnLidSettings(FingerJointSettings): """Settings for Slide-on Lids Note that edge_width below also determines how much the sides extend above the lid. Values: * absolute_params * second_pin : True : additional pin for better positioning * spring : "both" : position(s) of the extra locking springs in the lid * hole_width : 0 : width of the "finger hole" in mm """ __doc__ += FingerJointSettings.__doc__ or "" absolute_params = FingerJointSettings.absolute_params.copy() relative_params = FingerJointSettings.relative_params.copy() relative_params.update({ "play": 0.05, "finger": 3.0, "space": 2.0, }) absolute_params.update({ "second_pin": True, "spring": ("both", "none", "left", "right"), "hole_width": 0 })
[docs] def edgeObjects(self, boxes, chars=None, add: bool = True): edges = [LidEdge(boxes, self), LidHoleEdge(boxes, self), LidRight(boxes, self), LidLeft(boxes, self), LidSideRight(boxes, self), LidSideLeft(boxes, self), ] return self._edgeObjects(edges, boxes, chars, add)
[docs] class LidEdge(FingerJointEdge): char = "l" description = "Edge for slide on lid (back)" def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw): hole_width = self.settings.hole_width if hole_width > 0: super().__call__((length - hole_width) / 2) GroovedEdgeBase.groove_arc(self, hole_width) super().__call__((length - hole_width) / 2) else: super().__call__(length)
[docs] class LidHoleEdge(FingerHoleEdge): char = "L" description = "Edge for slide on lid (box back)" def __call__(self, length, bedBolts=None, bedBoltSettings=None, **kw) -> None: hole_width = self.settings.hole_width if hole_width > 0: super().__call__((length - hole_width) / 2) self.edge(hole_width) super().__call__((length - hole_width) / 2) else: super().__call__(length)
[docs] class LidRight(BaseEdge): char = "n" description = "Edge for slide on lid (right)" rightside = True def __call__(self, length, **kw): t = self.boxes.thickness if self.rightside: spring = self.settings.spring in ("right", "both") else: spring = self.settings.spring in ("left", "both") if spring: l = min(6 * t, length - 2 * t) a = 30 sqt = 0.4 * t / math.cos(math.radians(a)) sw = 0.5 * t p = [0, 90, 1.5 * t + sw, -90, l, (-180, 0.25 * t), l - 0.2 * t, 90, sw, 90 - a, sqt, 2 * a, sqt, -a, length - t] else: p = [t, 90, t, -90, length - t] pin = self.settings.second_pin if pin: pinl = 2 * t p[-1:] = [length - 2 * t - pinl, -90, t, 90, pinl, 90, t, -90, t] if not self.rightside: p = list(reversed(p)) self.polyline(*p)
[docs] def startwidth(self) -> float: if self.rightside: # or self.settings.second_pin: return self.boxes.thickness return 0.0
[docs] def endwidth(self) -> float: if not self.rightside: # or self.settings.second_pin: return self.boxes.thickness return 0.0
[docs] def margin(self) -> float: if not self.rightside: # and not self.settings.second_pin: return self.boxes.thickness return 0.0
[docs] class LidLeft(LidRight): char = "m" description = "Edge for slide on lid (left)" rightside = False
[docs] class LidSideRight(BaseEdge): char = "N" description = "Edge for slide on lid (box right)" rightside = True def __call__(self, length, **kw): t = self.boxes.thickness s = self.settings.play pin = self.settings.second_pin edge_width = self.settings.edge_width r = edge_width / 3 if self.rightside: spring = self.settings.spring in ("right", "both") else: spring = self.settings.spring in ("left", "both") if spring: p = [s, -90, t + s, -90, t + s, 90, edge_width - s / 2, 90, length + t] else: p = [t + s, -90, t + s, -90, 2 * t + s, 90, edge_width - s / 2, 90, length + t] if pin: pinl = 2 * t p[-1:] = [p[-1] - 1.5 * t - 2 * pinl - r, (90, r), edge_width + t + s / 2 - r, -90, 2 * pinl + s + 0.5 * t, -90, t + s, -90, pinl - r, (90, r), edge_width - s / 2 - 2 * r, (90, r), pinl + t - s - r] holex = 0.6 * t holey = -0.5 * t + self.burn - s / 2 if self.rightside: p = list(reversed(p)) holex = length - holex holey = edge_width + 0.5 * t + self.burn if spring: self.rectangularHole(holex, holey, 0.4 * t, t + 2 * s) self.polyline(*p)
[docs] def startwidth(self) -> float: return self.boxes.thickness + self.settings.edge_width if self.rightside else -self.settings.play / 2
[docs] def endwidth(self) -> float: return self.boxes.thickness + self.settings.edge_width if not self.rightside else -self.settings.play / 2
[docs] def margin(self) -> float: return self.boxes.thickness + self.settings.edge_width + self.settings.play / 2 if not self.rightside else 0.0
[docs] class LidSideLeft(LidSideRight): char = "M" description = "Edge for slide on lid (box left)" rightside = False
############################################################################# #### Click Joints #############################################################################
[docs] class ClickSettings(Settings): """Settings for Click-on Lids Values: * absolute_params * angle : 5.0 : angle of the hooks bending outward * relative (in multiples of thickness) * depth : 3.0 : length of the hooks (multiples of thickness) * bottom_radius : 0.1 : radius at the bottom (multiples of thickness) """ absolute_params = { "angle": 5.0, } relative_params = { "depth": 3.0, "bottom_radius": 0.1, }
[docs] def edgeObjects(self, boxes, chars: str = "cC", add: bool = True): edges = [ClickConnector(boxes, self), ClickEdge(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class ClickConnector(BaseEdge): char = "c" description = "Click on (bottom side)"
[docs] def hook(self, reverse: bool = False) -> None: t = self.settings.thickness a = self.settings.angle d = self.settings.depth r = self.settings.bottom_radius c = math.cos(math.radians(a)) s = math.sin(math.radians(a)) p1 = (0, 90 - a, c * d) p2 = ( d + t, -90, t * 0.5, 135, t * 2 ** 0.5, 135, d + 2 * t + s * 0.5 * t) p3 = (c * d - s * c * 0.2 * t, -a, 0) if not reverse: self.polyline(*p1) self.corner(-180, r) self.polyline(*p2) self.corner(-180 + 2 * a, r) self.polyline(*p3) else: self.polyline(*reversed(p3)) self.corner(-180 + 2 * a, r) self.polyline(*reversed(p2)) self.corner(-180, r) self.polyline(*reversed(p1))
[docs] def hookWidth(self): t = self.settings.thickness a = self.settings.angle d = self.settings.depth r = self.settings.bottom_radius c = math.cos(math.radians(a)) s = math.sin(math.radians(a)) return 2 * s * d * c + 0.5 * c * t + c * 4 * r
[docs] def hookOffset(self): a = self.settings.angle d = self.settings.depth r = self.settings.bottom_radius c = math.cos(math.radians(a)) s = math.sin(math.radians(a)) return s * d * c + 2 * r
[docs] def finger(self, length) -> None: t = self.settings.thickness self.polyline( 2 * t, 90, length, 90, 2 * t, )
def __call__(self, length, **kw): t = self.settings.thickness self.edge(4 * t) self.hook() self.finger(2 * t) self.hook(reverse=True) self.edge(length - 2 * (6 * t + 2 * self.hookWidth()), tabs=2) self.hook() self.finger(2 * t) self.hook(reverse=True) self.edge(4 * t)
[docs] def margin(self) -> float: return 2 * self.settings.thickness
[docs] class ClickEdge(ClickConnector): char = "C" description = "Click on (top)"
[docs] def startwidth(self) -> float: return self.boxes.thickness
[docs] def margin(self) -> float: return 0.0
def __call__(self, length, **kw): t = self.settings.thickness o = self.hookOffset() w = self.hookWidth() p1 = ( 4 * t + o, 90, t, -90, 2 * (t + w - o), -90, t, 90, 0) self.polyline(*p1) self.edge(length - 2 * (6 * t + 2 * w) + 2 * o, tabs=2) self.polyline(*reversed(p1))
############################################################################# #### Dove Tail Joints #############################################################################
[docs] class DoveTailSettings(Settings): """Settings for Dove Tail Joints Values: * absolute * angle : 50 : how much should fingers widen (-80 to 80) * relative (in multiples of thickness) * size : 3 : from one middle of a dove tail to another (multiples of thickness) * depth : 1.5 : how far the dove tails stick out of/into the edge (multiples of thickness) * radius : 0.2 : radius used on all four corners (multiples of thickness) """ absolute_params = { "angle": 50, } relative_params = { "size": 3, "depth": 1.5, "radius": 0.2, }
[docs] def edgeObjects(self, boxes, chars: str = "dD", add: bool = True): edges = [DoveTailJoint(boxes, self), DoveTailJointCounterPart(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class DoveTailJoint(BaseEdge): """Edge with dove tail joints """ char = 'd' description = "Dove Tail Joint" positive = True def __call__(self, length, **kw): s = self.settings radius = max(s.radius, self.boxes.burn) # no smaller than burn positive = self.positive a = s.angle + 90 alpha = 0.5 * math.pi - math.pi * s.angle / 180.0 l1 = radius / math.tan(alpha / 2.0) diffx = 0.5 * s.depth / math.tan(alpha) l2 = 0.5 * s.depth / math.sin(alpha) sections = int((length) // (s.size * 2)) leftover = length - sections * s.size * 2 if sections == 0: self.edge(length) return p = 1 if positive else -1 self.edge((s.size + leftover) / 2.0 + diffx - l1, tabs=1) for i in range(sections): self.corner(-1 * p * a, radius) self.edge(2 * (l2 - l1)) self.corner(p * a, radius) self.edge(2 * (diffx - l1) + s.size) self.corner(p * a, radius) self.edge(2 * (l2 - l1)) self.corner(-1 * p * a, radius) if i < sections - 1: # all but the last self.edge(2 * (diffx - l1) + s.size) self.edge((s.size + leftover) / 2.0 + diffx - l1, tabs=1)
[docs] def margin(self) -> float: """ """ return self.settings.depth
[docs] class DoveTailJointCounterPart(DoveTailJoint): """Edge for other side of dove joints """ char = 'D' description = "Dove Tail Joint (opposing side)" positive = False
[docs] def margin(self) -> float: return 0.0
[docs] class FlexSettings(Settings): """Settings for Flex Values: * absolute * stretch : 1.05 : Hint of how much the flex part should be shortened * relative (in multiples of thickness) * distance : 0.5 : width of the pattern perpendicular to the cuts (multiples of thickness) * connection : 1.0 : width of the gaps in the cuts (multiples of thickness) * width : 5.0 : width of the pattern in direction of the cuts (multiples of thickness) """ relative_params = { "distance": 0.5, "connection": 1.0, "width": 5.0, } absolute_params = { "stretch": 1.05, }
[docs] def checkValues(self) -> None: if self.distance < 0.01: raise ValueError("Flex Settings: distance parameter must be > 0.01mm") if self.width < 0.1: raise ValueError("Flex Settings: width parameter must be > 0.1mm")
[docs] class FlexEdge(BaseEdge): """Edge with flex cuts - use straight edge for the opposing side""" char = 'X' description = "Flex cut" def __call__(self, x, h, **kw): dist = self.settings.distance connection = self.settings.connection width = self.settings.width burn = self.boxes.burn h += 2 * burn lines = int(x // dist) leftover = x - lines * dist sections = max(int((h - connection) // width), 1) sheight = ((h - connection) / sections) - connection self.ctx.stroke() for i in range(1, lines): pos = i * dist + leftover / 2 if i % 2: self.ctx.move_to(pos, 0) self.ctx.line_to(pos, connection + sheight) for j in range((sections - 1) // 2): self.ctx.move_to(pos, (2 * j + 1) * sheight + (2 * j + 2) * connection) self.ctx.line_to(pos, (2 * j + 3) * (sheight + connection)) if not sections % 2: self.ctx.move_to(pos, h - sheight - connection) self.ctx.line_to(pos, h) else: if sections % 2: self.ctx.move_to(pos, h) self.ctx.line_to(pos, h - connection - sheight) for j in range((sections - 1) // 2): self.ctx.move_to( pos, h - ((2 * j + 1) * sheight + (2 * j + 2) * connection)) self.ctx.line_to( pos, h - (2 * j + 3) * (sheight + connection)) else: for j in range(sections // 2): self.ctx.move_to(pos, h - connection - 2 * j * (sheight + connection)) self.ctx.line_to(pos, h - 2 * (j + 1) * (sheight + connection)) self.ctx.stroke() self.ctx.move_to(0, 0) self.ctx.line_to(x, 0) self.ctx.translate(*self.ctx.get_current_point())
[docs] class GearSettings(Settings): """Settings for rack (and pinion) edge Values: * absolute_params * dimension : 3.0 : modulus of the gear (in mm) * angle : 20.0 : pressure angle * profile_shift : 20.0 : Profile shift * clearance : 0.0 : clearance """ absolute_params = { "dimension": 3.0, "angle": 20.0, "profile_shift": 20.0, "clearance": 0.0, } relative_params: dict[str, Any] = {}
[docs] class RackEdge(BaseEdge): char = "R" description = "Rack (and pinion) Edge" def __init__(self, boxes, settings) -> None: super().__init__(boxes, settings) self.gear = gears.Gears(boxes) def __call__(self, length, **kw): params = self.settings.values.copy() params["draw_rack"] = True params["rack_base_height"] = -1E-36 params["rack_teeth_length"] = int(length // (params["dimension"] * math.pi)) params["rack_base_tab"] = (length - (params["rack_teeth_length"]) * params["dimension"] * math.pi) / 2.0 s_tmp = self.boxes.spacing self.boxes.spacing = 0 self.moveTo(length, 0, 180) self.gear(move="", **params) self.moveTo(0, 0, 180) self.boxes.spacing = s_tmp
[docs] def margin(self) -> float: return self.settings.dimension * 1.1
[docs] class RoundedTriangleEdgeSettings(Settings): """Settings for RoundedTriangleEdge Values: * absolute_params * height : 150. : height above the wall * radius : 30. : radius of top corner * r_hole : 0. : radius of hole * relative (in multiples of thickness) * outset : 0 : extend the triangle along the length of the edge (multiples of thickness) """ absolute_params = { "height": 50., "radius": 30., "r_hole": 2., } relative_params = { "outset": 0., }
[docs] def edgeObjects(self, boxes, chars: str = "t", add: bool = True): edges = [RoundedTriangleEdge(boxes, self), RoundedTriangleFingerHolesEdge(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
[docs] class RoundedTriangleEdge(Edge): """Makes an 'edge' with a rounded triangular bumpout and optional hole""" description = "Triangle for handle" char = "t" def __call__(self, length, **kw): length += 2 * self.settings.outset r = self.settings.radius if r > length / 2: r = length / 2 if length - 2 * r < self.settings.height: # avoid division by zero angle = 90 - math.degrees(math.atan( (length - 2 * r) / (2 * self.settings.height))) l = self.settings.height / math.cos(math.radians(90 - angle)) else: angle = math.degrees(math.atan( 2 * self.settings.height / (length - 2 * r))) l = 0.5 * (length - 2 * r) / math.cos(math.radians(angle)) if self.settings.outset: self.polyline(0, -180, self.settings.outset, 90) else: self.corner(-90) if self.settings.r_hole: self.hole(self.settings.height, length / 2., self.settings.r_hole) self.corner(90 - angle, r, tabs=1) self.edge(l, tabs=1) self.corner(2 * angle, r, tabs=1) self.edge(l, tabs=1) self.corner(90 - angle, r, tabs=1) if self.settings.outset: self.polyline(0, 90, self.settings.outset, -180) else: self.corner(-90)
[docs] def margin(self) -> float: return self.settings.height + self.settings.radius
[docs] class RoundedTriangleFingerHolesEdge(RoundedTriangleEdge): char = "T"
[docs] def startwidth(self) -> float: return self.settings.thickness
def __call__(self, length, **kw): self.fingerHolesAt(0, 0.5 * self.settings.thickness, length, 0) super().__call__(length, **kw)
[docs] class HandleEdgeSettings(Settings): """Settings for HandleEdge Values: * absolute_params * height : 20. : height above the wall in mm * radius : 10. : radius of corners in mm * hole_width : "40:40" : width of hole(s) in percentage of maximum hole width (width of edge - (n+1) * material thickness) * hole_height : 75. : height of hole(s) in percentage of maximum hole height (handle height - 2 * material thickness) * on_sides : True, : added to side panels if checked, to front and back otherwise (only used with top_edge parameter) * relative * outset : 1. : extend the handle along the length of the edge (multiples of thickness) """ absolute_params = { "height": 20., "radius": 10., "hole_width": "40:40", "hole_height": 75., "on_sides": True, } relative_params = { "outset": 1., }
[docs] def edgeObjects(self, boxes, chars: str = "yY", add: bool = True): edges = [HandleEdge(boxes, self), HandleHoleEdge(boxes, self)] return self._edgeObjects(edges, boxes, chars, add)
# inspiration came from https://www.thingiverse.com/thing:327393
[docs] class HandleEdge(Edge): """Extends an 'edge' by adding a rounded bumpout with optional holes""" description = "Handle for e.g. a drawer" char = "y" extra_height = 0.0 def __call__(self, length, **kw): length += 2 * self.settings.outset extra_height = self.extra_height * self.settings.thickness r = self.settings.radius if r > length / 2: r = length / 2 if r > self.settings.height: r = self.settings.height widths = argparseSections(self.settings.hole_width) if self.settings.outset: self.polyline(0, -180, self.settings.outset, 90) else: self.corner(-90) if self.settings.hole_height and sum(widths) > 0: if sum(widths) < 100: slot_offset = ((1 - sum(widths) / 100) * (length - (len(widths) + 1) * self.thickness)) / (len(widths) * 2) else: slot_offset = 0 slot_height = (self.settings.height - 2 * self.thickness) * self.settings.hole_height / 100 slot_x = self.thickness + slot_offset for w in widths: if sum(widths) > 100: slotwidth = w / sum(widths) * (length - (len(widths) + 1) * self.thickness) else: slotwidth = w / 100 * (length - (len(widths) + 1) * self.thickness) slot_x += slotwidth / 2 with self.saved_context(): self.moveTo((self.settings.height / 2) + extra_height, slot_x, 0) self.rectangularHole(0, 0, slot_height, slotwidth, slot_height / 2, True, True) slot_x += slotwidth / 2 + slot_offset + self.thickness + slot_offset self.edge(self.settings.height - r + extra_height, tabs=1) self.corner(90, r, tabs=1) self.edge(length - 2 * r, tabs=1) self.corner(90, r, tabs=1) self.edge(self.settings.height - r + extra_height, tabs=1) if self.settings.outset: self.polyline(0, 90, self.settings.outset, -180) else: self.corner(-90)
[docs] def margin(self) -> float: return self.settings.height
[docs] class HandleHoleEdge(HandleEdge): """Extends an 'edge' by adding a rounded bumpout with optional holes and holes for parallel finger joint""" description = "Handle with holes for parallel finger joint" char = "Y" extra_height = 1.0 def __call__(self, length, **kw): self.fingerHolesAt(0, -0.5 * self.settings.thickness, length, 0) super().__call__(length, **kw)
[docs] def margin(self) -> float: return self.settings.height + self.extra_height * self.settings.thickness