Source code for boxes.generators.fatballdispenser

# Copyright (C) 2013-2024 Florian Festi / Melchior Rabe
#
#   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 boxes import *


[docs] class FatBallDispenser(Boxes): """Birdhouse for fat balls.""" description = """ ### Description Fat balls are quite common to feed tits. Here you can build a dispenser that protects the fat balls from rain and snow. The design was tested using 4mm plywood for the main structural parts. For the spacer of the locking mechanism two layers of 3mm plywood have been stacked. The poles were made from 8mm diameter beech rod and 19cm in length. Some basic consistency checks have been made for different numbers, but only the defaults have ever been built. The final dispenser consists of two parts which slide into each other. You can mount a hook to hang it into a tree. All edges that have angles different from 90 degrees (chamfers) have engraved guiding lines which indicate the angle. You have to sand the edge such that the pieces fit together. The top part (roof) consists of the the following elements for a N sided dispenser: * The N sided roof bottom plate which supports the roof tiles. It has a central cut out which is needed for assembly and on the bottom side an engraved line which indicates the position of the mounting bracket. * N triangles forming the roof. They also have a small arc at the tip (roof_hole_diameter) which acts as a drilling guide if you want to mount a hook. * The N sided roof support piece which has a central hole for the hook. It is been mounted to the roof tiles through the hole in the roof bottom plate to support the roof tiles and acts as a mounting plate for the hook. * Spacer(s); in the reference builds 2 spacers using 3mm plywood have been used. The spacer needs to be a bit thicker than the ceiling (see below). That's the smaller "U" shaped part which is glued to the bottom side of the roof bottom plate. * The bracket is also "U" shaped and a bit wider than the spacer. This is glued to the bottom of the spacer and holds the cage (bottom part). The bottom part (cage) consists of the follwoing elements: * The ceiling is N sided with the central refill hole and N smaller holes into which the poles are fitted. * The N sided floor part with finger joints for the balcony walls and holes for the poles. A central hole to drain rain water can be added as well. * N balcony walls surrounding the floor. * N poles that join floor and ceiling (not part of the drawing) Other parts needed for assembly: * Wooden rod (N times the length of the poles). * A hook (e.g. M5x50mm) * A washer to distribute the load of the hook to the support piece * One or two nuts to secure the hook * Optional, a pice of wire mesh Assembly: * Sand all chamfers * Glue balcony walls to the floor * Glue pairs of roof tiles and use the roof bottom plate as a jig, Hot glue may be used to tack the parts. * Glue all roof tiles to form the roof * Glue spacer(s) and the bracket into a stack * Glue roof tiles to the roof bottom plate and attach the support piece * Glue the stack to the roof bottom plate * Cut the poles to length * If you want to use multiple colors paint now, otherwise you also can paint later * After painting you may need to get the paint out of the pole holes again using a drillbit * Glue the poles into floor plate and ceiling * Attach the hanger * Attach the wire mesh """ # naming conventions used in the code: # Radii (center to corner for polygons) are prefixed with `r_` # Angles in degrees use `a_` and angles in radians use `ar_` as prefix # Edges of polygons are prefixed with `l_` ui_group = "Misc" def __init__(self) -> None: Boxes.__init__(self) self.buildArgParser() # Add non default cli params if needed (see argparse std lib) self.argparser.add_argument( "--sides", action="store", type=int, default=6, help="The number of sides of the floor plan.") self.argparser.add_argument( "--pole_diameter", action="store", type=float, default=8.0, help="The diameter of the poles.") self.argparser.add_argument( "--ball_diameter", action="store", type=float, default=75.0, help="The diameter of the fat balls. Give some extra mm to make it a loose fit") self.argparser.add_argument( "--balcony_width", action="store", type=float, default=15.0, help="The width of the area outside of the poles.") self.argparser.add_argument( "--balcony_height", action="store", type=float, default=20.0, help="The height of the balcony in mm. Set to 0 if no walls are needed.") self.argparser.add_argument( "--drain_hole_diameter", action="store", type=float, default=5.0, help="""The diameter of the hole of the floor (to drain rainwater) in mm. Set to 0 if you don't need it.""") self.argparser.add_argument( "--pole_clearance", action="store", type=float, default=9.0, help="""The minimum distance between a pole and the central refill hole in the ceiling in mm.""") self.argparser.add_argument( "--slide_clearance", action="store", type=float, default=1.0, help="""The gap between the parts that slide into each other in the locking mechanism in mm.""") self.argparser.add_argument( "--spacer_width", action="store", type=float, default=15.0, help="The width of the spacer (part of the locking mechanism) in mm.") self.argparser.add_argument( "--pole_clearance_factor", action="store", type=float, default=0.9, help="""The fraction of the pole clearance which is being used for the locking mechanism.""") self.argparser.add_argument( "--roof_overhang", action="store", type=float, default=20.0, help="Defines how much wider than the bottom floor the roof is.") self.argparser.add_argument( "--roof_height", action="store", type=float, default=50.0, help="The height of the roof in mm.") self.argparser.add_argument( "--roof_hole_diameter", action="store", type=float, default=5.0, help="""The diameter of the hole of the roof in mm. Set to 0 if you don't want to attach a hanger.""") self.argparser.add_argument( "--roof_maintenance_clearance", action="store", type=float, default=20.0, help="The distance from on bottom corner of the roof to the maintenance hole in mm.") self.argparser.add_argument( "--roof_support_fraction", action="store", type=float, default=0.3, help="The radius of the roof support part as a fraction of the roof radius.") def calc_tile_angle(self, radius, height): """ Calculate the angle (deg) between two roof tiles. """ def cross(a, b): """ The cross product of two vectors """ c = [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]] return c def scalar(a, b): """ The scalar product of two vectors """ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] def norm(a): """ The norm of a vector """ return math.sqrt(a[0]**2 + a[1]**2 + a[2]**2) def add(a, b): """ The sum of two vectors """ return [aa + bb for aa, bb in zip(a, b)] ar_center = math.radians(360/self.sides) base = radius * math.sin(ar_center / 2) * 2 base_height = radius * math.cos(ar_center / 2) # Points are # P one corner at the bottom of the pyramid # C The center of the bottom of the pyramid # O The tip of the pyramid # Q, B the corners of the pyramid left and right of P pc = [-base/2, base_height, 0] pb = [-base, 0, 0] co = [0, 0, height] pq = [base * math.cos(ar_center), base * math.sin(ar_center), 0] po = add(pc, co) n1 = cross(pq, po) n2 = cross(po, pb) cos_e = scalar(n1, n2) / (norm(n1) * norm(n2)) e = math.degrees(math.acos(cos_e)) return e def get_pole_callback(self, pole_inset, pole_diameter, r_ceiling): """ Returns a callback function that renders the pole (and other) holes into floor and ceiling. """ def cb(number): if number == 0: if r_ceiling > 0: self.hole(0, 0, self.ball_diameter / 2) elif self.drain_hole_diameter > 0: self.hole(0, 0, self.drain_hole_diameter / 2) else: rads = math.radians((180 - (360/self.sides))/2) x = pole_inset * math.cos(rads) y = pole_inset * math.sin(rads) self.hole(x, y, pole_diameter / 2.0) if r_ceiling > 0 and 1 <= number <= (self.sides // 2 + 1): a_center = 360.0 / self.sides a_base = (180 - (360/self.sides))/2 clearance = self.pole_clearance_factor * self.pole_clearance r_clearance = clearance / math.sin(math.radians(a_base)) r = r_ceiling - r_clearance base_length = 2 * r * math.sin(math.radians(a_center / 2)) dy = r_clearance * math.sin(math.radians(a_base)) dx = r_clearance * math.cos(math.radians(a_base)) dx_outside = dy / math.tan(math.radians(a_center)) length = base_length if number == 1: length += dx_outside + dx dx = - dx_outside elif number == (self.sides // 2 + 1): length += dx_outside + dx self.ctx.stroke() with self.saved_context(): self.set_source_color(Color.ETCHING) self.moveTo(dx, dy) self.ctx.line_to(length, 0) self.ctx.stroke() return cb def get_roof_callback(self, r_polygon, r_hole): """ Returns a callback function that renders the additional features of the roof bottom plate. """ def cb(number): if number == 0: # maintenance hole self.hole(0, 0, r_hole) # engraving to indicate the bracket position self.ctx.stroke() with self.saved_context(): self.set_source_color(Color.ETCHING) self.regularPolygonAt(0, 0, self.sides, r=r_polygon) self.ctx.stroke() return cb def balcony_wall(self, x, y, finger_padding, callback=None, move=None, label=""): """ Function that renders a balcony wall. Due to the calculation of the length of the sides of the regularPolygonWall used for the floor plate, the finger joints need a bit of padding left and right such that they align with the floor plate. This is achieved using an "efe" edge at the bottom. """ edges = "efeeee" edges = [self.edges.get(e, e) for e in edges] edges += edges # append for wrapping around overallwidth = x + edges[-1].spacing() + edges[3].spacing() overallheight = y + edges[1].spacing() + edges[4].spacing() if self.move(overallwidth, overallheight, move, before=True): return self.moveTo(0, edges[1].margin()) for i, l in enumerate((finger_padding, x, finger_padding, y, x+2*finger_padding, y)): self.cc(callback, i, y=edges[i].startwidth() + self.burn) e1, e2 = edges[i], edges[i + 1] edges[i](l) if i >= 2: self.edgeCorner(e1, e2, 90) a_inner = (90 - 360.0 / self.sides) inset = self.thickness * math.tan(math.radians(a_inner)) self.ctx.stroke() with self.saved_context(): self.set_source_color(Color.ETCHING) self.moveTo(inset, 0) self.ctx.line_to(0, self.balcony_height) self.moveTo(x+2*finger_padding-2*inset, 0) self.ctx.line_to(0, self.balcony_height) self.ctx.stroke() self.move(overallwidth, overallheight, move, label=label) def roof_tile(self, r_roof, move=None, label=""): """ creates on rooftile :param move: (Default value = None) :param label: rendered to identify parts, it is not meant to be cut or etched (Default value = "") """ # calculate the roof dimensions a_base = (180 - (360 / self.sides)) / 2.0 ar_base = math.radians(a_base) h_roof = self.roof_height l_roof = 2 * r_roof * math.cos(ar_base) r_roof_hole = self.roof_hole_diameter / 2 h_roof_floor = r_roof * math.sin(ar_base) h_roof_tile = math.sqrt(h_roof_floor**2 + h_roof**2) a_roof = math.degrees(math.atan(h_roof/h_roof_floor)) ar_roof = math.radians(a_roof) ar_tile_base = math.atan(h_roof_tile / (l_roof/2)) a_tile_base = math.degrees(ar_tile_base) edges = [self.edges.get(e, e) for e in "eee"] overallwidth = l_roof + 2*edges[0].spacing() overallheight = h_roof_tile + 2*edges[0].spacing() l_side = math.sqrt((0.5*l_roof)**2 + h_roof_tile**2) - r_roof_hole if self.move(overallwidth, overallheight, move, before=True): return self.moveTo(0, edges[0].margin()) e1, e2, e3 = edges[0], edges[1], edges[2] e1(l_roof) self.edgeCorner(e1, e2, 180-a_tile_base) e2(l_side) self.corner(90) self.corner(-(180-2*a_tile_base), radius=r_roof_hole) self.corner(90) e3(l_side) self.edgeCorner(e3, e1, 180-a_tile_base) # draw grinding line on base dy = self.thickness * math.tan(ar_roof) dx = dy / math.tan(ar_tile_base) self.ctx.stroke() with self.saved_context(): self.set_source_color(Color.ETCHING) self.moveTo(dx, dy) self.ctx.line_to(l_roof-2*dx, 0) self.ctx.stroke() # draw grinding lines on sides a_face = self.calc_tile_angle(r_roof, self.roof_height) a_face /= 2.0 l_grinding = self.thickness * math.tan(math.radians(a_face)) # first we need to calculate the height of the line # dy is the distance from the roof tip down to the tip of the "grinding triangle" dy = h_roof_tile/l_roof * dx dy *= 2 if r_roof_hole > 0: # this is only an approximation, but good enough dy = max(dy, r_roof_hole) dx_bottom = l_grinding/math.sin(ar_base) dx_top = l_roof/2 - (l_roof/2-dx_bottom)/(h_roof_tile/r_roof_hole) with self.saved_context(): self.set_source_color(Color.ETCHING) self.moveTo(dx_bottom, 0) self.ctx.line_to(dx_top, h_roof_tile-dy) self.moveTo(l_roof-2*dx_bottom, 0) self.ctx.line_to(-dx_top, h_roof_tile-dy) self.ctx.stroke() self.move(overallwidth, overallheight, move, label=label) def lock_part(self, r_inner, r_outer, move=None, label=""): """ Part of the locking mechanism (spacer and bracket). """ number_of_edges = 2 * (2 + (self.sides // 2)) edges = [self.edges.get(e, e) for e in "e"*number_of_edges] a_center = 360/self.sides a_base = (180 - (360/self.sides))/2 r_inner, _, l_inner = self.regularPolygon( corners=self.sides, radius=r_inner) r_outer, _, l_outer = self.regularPolygon( corners=self.sides, radius=r_outer) overallwidth, overallheight = 1.5 * r_outer, 3**0.5 * r_outer if self.move(overallwidth, overallheight, move, before=True): return second_half = number_of_edges / 2 for i in range(number_of_edges): e1, e2 = edges[i], edges[(i+1) % number_of_edges] if i in [second_half-1, number_of_edges-1]: length = r_outer - r_inner elif i < second_half: length = l_outer else: length = l_inner if i in [second_half-2]: angle = 180 - a_base elif i in [second_half-1]: angle = a_base elif i in [number_of_edges-2]: angle = a_base elif i in [number_of_edges-1]: angle = 180 - a_base elif i < second_half: angle = a_center else: angle = - a_center e1(length) self.edgeCorner(e1, e2, angle) self.move(overallwidth, overallheight, move, label=label) def render(self): # adjust to the variables you want in the local scope r_ball = self.ball_diameter / 2 d_pole = self.pole_diameter r_pole = d_pole / 2 w_balcony = self.balcony_width h_balcony = self.balcony_height ar_base = math.radians((180 - (360/self.sides))/2) r_clearance = self.pole_clearance / math.sin(ar_base) r_poles = r_ball + max(r_pole, r_clearance) + r_pole r_floor = r_poles + r_pole + max(w_balcony, r_clearance, r_pole) r_ceiling = r_poles + r_pole + max(r_clearance, r_pole) r_roof = r_floor + self.roof_overhang l_roof = 2 * r_roof * math.cos(ar_base) t = self.thickness floor_outset = t / math.tan(math.radians(90.0-180/self.sides)) l_floor = 2 * r_floor * math.sin(math.radians(360.0 / self.sides / 2)) h_roof = self.roof_height h_roof_floor = r_roof * math.sin(ar_base) a_roof = math.degrees(math.atan(h_roof/h_roof_floor)) ar_roof = math.radians(a_roof) r_support = self.roof_support_fraction * r_roof # render the ceiling self.regularPolygonWall(corners=self.sides, r=r_ceiling, edges="e", callback=self.get_pole_callback(r_ceiling - r_poles, d_pole, r_ceiling), move="up") # render the roof base r_hole = r_ceiling - self.roof_maintenance_clearance self.regularPolygonWall(corners=self.sides, r=r_roof, edges="e", callback=self.get_roof_callback(r_polygon=r_ceiling, r_hole=r_hole), move="up") # only render a balcony if height is >0 if h_balcony > 0: # render the floor plate self.regularPolygonWall(corners=self.sides, r=r_floor, edges="F", callback=self.get_pole_callback(r_floor-r_poles, d_pole, 0), move="up") # render the balcony walls for _ in range(self.sides): self.balcony_wall(x=l_floor, y=h_balcony, finger_padding=floor_outset, move="up") else: # render the floor plate self.regularPolygonWall(corners=self.sides, r=r_floor, edges="e", callback=self.get_pole_callback(r_floor - r_poles, d_pole, False), move="up") if self.roof_hole_diameter > 0: (_, h_poly, _) = self.regularPolygon(corners=self.sides, radius=r_support) new_height = h_poly - self.thickness / math.atan(ar_roof) r_polygon = r_support * new_height / h_poly r_hole = self.roof_hole_diameter / 2 self.regularPolygonWall(corners=self.sides, r=r_support, edges="e", callback=self.get_roof_callback(r_polygon=r_polygon, r_hole=r_hole), move="up") # render the spacer self.lock_part(r_ceiling + self.slide_clearance, r_ceiling + self.slide_clearance + self.spacer_width, move="up") # render the bracket r_inner = r_ceiling - r_clearance * self.pole_clearance_factor r_outer = r_ceiling + self.slide_clearance + self.spacer_width self.lock_part(r_inner, r_outer, move="right") # render the roof tiles for _ in range(self.sides): self.roof_tile(r_roof, move="up") self.moveTo((0.5 * l_roof) if _ % 2 else (1.5 * l_roof + 2*self.spacing), 0, -180)