diff --git a/boxes/edges.py b/boxes/edges.py
index 18f6643e..4523dcf4 100644
--- a/boxes/edges.py
+++ b/boxes/edges.py
@@ -367,6 +367,22 @@ def startwidth(self) -> float:
return self.settings if self.settings is not None else self.boxes.thickness
+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)
+
+ def margin(self) -> float:
+ return self._margin
+
#############################################################################
#### MountingEdge
#############################################################################
diff --git a/boxes/generators/agricolainsert.py b/boxes/generators/agricolainsert.py
index 3b1a8a63..b784c18e 100644
--- a/boxes/generators/agricolainsert.py
+++ b/boxes/generators/agricolainsert.py
@@ -557,7 +557,7 @@ def render_player_box(self, player_box_height, player_box_inner_width):
bed_edge = Bed2SidesEdge(
self, bed_inner_length, bed_head_length, bed_foot_height
)
- noop_edge = NoopEdge(self)
+ noop_edge = edges.NoopEdge(self)
self.ctx.save()
optim_180_x = (
bed_inner_length + self.thickness + bed_head_length + 2 * self.spacing
@@ -916,16 +916,3 @@ def __call__(self, bed_height, **kw):
(90, head_corner),
head_length,
)
-
-
-class NoopEdge(edges.BaseEdge):
- """
- Edge which does nothing, not even turn or move.
- """
-
- def __init__(self, boxes) -> None:
- super().__init__(boxes, None)
-
- def __call__(self, length, **kw):
- # cancel turn
- self.corner(-90)
diff --git a/boxes/generators/sidehingebox.py b/boxes/generators/sidehingebox.py
new file mode 100644
index 00000000..d8dbe194
--- /dev/null
+++ b/boxes/generators/sidehingebox.py
@@ -0,0 +1,324 @@
+# Copyright (C) 2024 Guillaume Collic
+#
+# 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 .
+
+import math
+
+from boxes import *
+
+
+class SideHingeBox(Boxes):
+ """Box, with an hinge that does not protrude from the back of the box, and a latch."""
+
+ description = """
+This box is another take on a hinge box.
+The hinges doesn't protrude from the box, but the sides needs double walls.
+When opened, 2 sides are opening, improving access inside the box.
+An optional latch is included, based on a mechanical switch and a 3D printed button.
+The latch is one-way: the box can be closed freely
+(this side of the button is angled, and totally smooth since it's the printing bed surface),
+but can't be inadvertently opened.
+"""
+
+ ui_group = "Box"
+
+ def __init__(self) -> None:
+ Boxes.__init__(self)
+ self.buildArgParser("x", "y", "h", "outside")
+ self.addSettingsArgs(edges.FingerJointSettings, finger=2.0, space=2.0)
+
+ self.argparser.add_argument(
+ "--play", action="store", type=float, default=0.15,
+ help="play between the two sides as multiple of the wall thickness")
+
+ self.argparser.add_argument(
+ "--hinge_center", action="store", type=float, default=0.0,
+ help="distance between the hinge center and adjacent sides (0.0 for default)")
+
+ self.argparser.add_argument(
+ "--hinge_radius", action="store", type=float, default=5.5,
+ help="radius of the hinge inner circle")
+
+ self.argparser.add_argument(
+ "--cherrymx_latches", action="store", type=int, default=0,
+ choices=[0, 1, 2],
+ help="add one or two latches, based on 3D printing and a cherry mx compatible mechanical keyboard switch")
+
+
+ def render(self):
+ x, yi, hi = self.x, self.y, self.h
+ t = self.thickness
+ p = self.play * t
+ hinge_radius = self.hinge_radius
+ hinge_center = self.hinge_center if self.hinge_center else 2*t + hinge_radius
+ latches = self.cherrymx_latches
+ self.mx_width = 15.4
+ self.mx_length = t+16.4+2.8 #2.8 can be removed if the switch is trimmed flush
+
+ if self.outside:
+ x -= 2*t
+ yi -= 4*t + 2*p
+ hi -= 2*t
+
+ yo = yi + 2*(t+p)
+ ho = hi + t
+
+ # one side is shared between inside and outside part,
+ # so that the lid can rotate and lay flat, without touching the inner bottom
+ fingered_hi = 2*hinge_center-t
+ # a small gap is also needed for both part to rotate freely
+ # (for a rounded angled finish, a gapless version could be added, with manual sanding or mechanical round milling)
+ gap = math.sqrt(abs(pow(hinge_center*math.sqrt(2),2)-pow(hinge_center-t,2)))-hinge_center
+ fingered_ho = ho - gap - 2*hinge_center
+
+ with self.saved_context():
+ self.inner_side(x, hi, hinge_center, hinge_radius, fingered_hi, latches, reverse=True)
+ self.rectangularWall(
+ yi,
+ hi,
+ "fFeF",
+ callback=[lambda:self.back_cb(yi, latches)],
+ move="right",
+ label="inner - full side D")
+ self.inner_side(x, hi, hinge_center, hinge_radius, fingered_hi, latches)
+ self.rectangularWall(0, hi, "ffef", move="up only")
+
+ with self.saved_context():
+ self.outer_side(x, ho, hinge_center, hinge_radius, fingered_ho, latches)
+ with self.saved_context():
+ self.rectangularWall(yo, fingered_ho, "fFeF", move="up", label="outer - small side B")
+ self.moveTo(t+p,0)
+ self.rectangularWall(yi, fingered_hi, "eFfF", move="right", label="inner - small side B")
+ self.rectangularWall(yo, 0, "fFeF", move="right only")
+ self.outer_side(x, ho, hinge_center, hinge_radius, fingered_ho, latches, reverse=True)
+ self.rectangularWall(0, ho, "ffef", move="up only")
+
+ bottom_callback = [
+ lambda:self.fingerHolesAt(x-self.mx_width-t/2, 0, self.mx_length),
+ lambda:self.back_cb(yi, latches),
+ lambda:self.fingerHolesAt(self.mx_width+t/2, 0, self.mx_length) if latches>1 else None,
+ ] if latches else None
+ self.rectangularWall(x, yi, "FFFF", callback=bottom_callback, move="right", label="inner - bottom")
+ self.rectangularWall(x, yo, "FEFF", move="right", label="outer - upper lid")
+ for _ in range(2):
+ self.rectangularWall(2*t, 1.5*t, "eeee", move="right")
+
+ if latches:
+ for _ in range(latches):
+ with self.saved_context():
+ self.rectangularWall(self.mx_width, self.mx_width, "eeee", move="right")
+ self.rectangularWall(self.mx_width, self.mx_width, "ffef", move="right")
+ self.rectangularWall(self.mx_length, self.mx_width, "ffeF", move="right")
+ self.rectangularWall(self.mx_length, self.mx_width, "ffeF", move="up only")
+ self.text(f"""
+OpenSCAD code for 3D printing the cherry MX latch button:
+#############################################
+play = 0.1;
+plywood_t = {t};
+ear_t = 0.4;
+ear_d = 15;
+btn_d = 11.4;
+btn_ext_h = min(plywood_t, 3.7);
+btn_h = ear_t + plywood_t + btn_ext_h;
+module mx_outer() {{
+ translate([0,0,btn_h+4])
+ mirror([0,0,1])
+ linear_extrude(height = 4.2) {{
+ offset(r=1, $fn=32){{
+ square([4.5, 2.8], center=true);
+ }}
+ }};
+}}
+module mx_inner() {{
+ translate([0,0,btn_h+4.01])
+ mirror([0,0,1])
+ for (rect = [ [4.05, 1.32], [1.22, 5] ]) {{
+ linear_extrude(height = 4)
+ square(rect, center=true);
+ hull() {{
+ linear_extrude(height = 0.01)
+ offset(delta = 0.4)
+ square(rect, center=true);
+ translate([0, 0, 0.5])
+ linear_extrude(height = 0.01)
+ square(rect, center=true);
+ }};
+ }}
+}}
+angle = atan2(btn_ext_h+0.2, btn_d/2);
+rotate([0, angle, 0]) difference(){{
+ union(){{
+ cylinder(d=btn_d-2*play, h=btn_h, $fn=512);
+ translate([0, 0, btn_h-ear_t/2])
+ cube([btn_d/2, ear_d, ear_t], center=true);
+ mx_outer();
+ }}
+ rotate([0, 90-angle, 0])
+ translate([0, -btn_d/2, 0])
+ cube(btn_d);
+ mx_inner();
+}}""")
+
+ def back_cb(self, y, latches):
+ if latches>0:
+ self.fingerHolesAt(self.mx_length+self.thickness/2, 0, self.mx_width)
+ if latches>1:
+ self.fingerHolesAt(y-self.mx_length-self.thickness/2, 0, self.mx_width)
+
+ def inner_side_cb(self, x, reverse):
+ if reverse:
+ self.fingerHolesAt(x-self.mx_width-self.thickness/2, 0, self.mx_width)
+ self.circle(x-self.mx_width/2, self.mx_width/2, 5.7+self.burn)
+ else:
+ self.fingerHolesAt(self.mx_width+self.thickness/2, 0, self.mx_width)
+ self.circle(self.mx_width/2, self.mx_width/2, 5.7+self.burn)
+
+ def inner_side(self, x, h, hinge_center, hinge_radius, fingered_h, latches, reverse=False):
+ sides = Inner2SidesEdge(
+ self, x, h, hinge_center, hinge_radius, fingered_h, reverse
+ )
+ noop_edge = edges.NoopEdge(self, margin=self.thickness if reverse else 0)
+
+ self.rectangularWall(
+ x,
+ h,
+ ["f", "f", sides, noop_edge] if reverse else["f", sides, noop_edge, "f"],
+ move="right",
+ label="inner - hinge side " + ("A" if reverse else "C"),
+ callback=[
+ lambda: self.inner_side_cb(x, reverse)
+ ] if (latches and reverse) or latches>1 else None,
+ )
+
+ def outer_side(self, x, h, hinge_center, hinge_radius, fingered_h, latches, reverse=False):
+ t = self.thickness
+ sides = Outer2SidesEdge(
+ self, x, h, hinge_center, hinge_radius, fingered_h, reverse
+ )
+ noop_edge = edges.NoopEdge(self, margin=t if reverse else 0)
+
+ latch_x, latch_y = (t+self.mx_width/2, self.mx_width/2)
+ if reverse:
+ latch_x, latch_y = latch_y, latch_x
+ self.rectangularWall(
+ x,
+ h,
+ ["f", "E", sides, noop_edge] if reverse else["f", sides, noop_edge, "E"],
+ move="right",
+ label="outer - hinge side " + ("C" if reverse else "A"),
+ callback=[
+ None,
+ None,
+ lambda: self.circle(latch_x, latch_y, 5.7+self.burn)
+ ] if (latches and not reverse) or latches>1 else None,
+ )
+
+class Inner2SidesEdge(edges.BaseEdge):
+ """
+ The next edge should be a NoopEdge
+ """
+
+ def __init__(self, boxes, length, height, hinge_center, hinge_radius, fingered_h, reverse) -> None:
+ super().__init__(boxes, None)
+ self.length = length
+ self.height = height
+ self.hinge_center=hinge_center
+ self.hinge_radius=hinge_radius
+ self.fingered_h=fingered_h
+ self.reverse=reverse
+
+ def __call__(self, _, **kw):
+ actions = [self.hinge_hole, self.fingers, self.smooth_corner]
+ actions = list(reversed(actions)) if self.reverse else actions
+ for action in actions:
+ action()
+
+ def fingers(self):
+ self.boxes.edges['f'](self.fingered_h)
+
+ def smooth_corner(self):
+ # the corner has to be rounded to rotate freely
+ hinge_to_lid = self.height+self.boxes.thickness-self.hinge_center
+ hinge_to_side = self.hinge_center-self.boxes.thickness
+ corner_height = hinge_to_lid-math.sqrt(math.pow(hinge_to_lid, 2) - math.pow(hinge_to_side, 2))
+ angle = math.degrees(math.asin(hinge_to_side/hinge_to_lid))
+ path = [
+ self.height-self.fingered_h-corner_height,
+ (90-angle, 0),
+ 0,
+ (angle, hinge_to_lid),
+ self.boxes.thickness+self.length-self.hinge_center,
+ ]
+ path = list(reversed(path)) if self.reverse else path
+ self.polyline(*path)
+
+ def hinge_hole(self):
+ direction = -1 if self.reverse else 1
+ x = direction*(self.hinge_center-self.boxes.thickness-self.boxes.burn)
+ y = self.hinge_center-self.boxes.thickness
+ t = self.boxes.thickness
+ self.boxes.rectangularHole(x, y, 1.5*t, t)
+
+ def margin(self) -> float:
+ return 0 if self.reverse else self.boxes.edges['f'].margin()
+
+class Outer2SidesEdge(edges.BaseEdge):
+ """
+ The next edge should be a NoopEdge
+ """
+
+ def __init__(self, boxes, length, height, hinge_center, hinge_radius, fingered_h, reverse) -> None:
+ super().__init__(boxes, None)
+ self.length = length
+ self.height = height
+ self.hinge_center=hinge_center
+ self.hinge_radius=hinge_radius
+ self.fingered_h=fingered_h
+ self.reverse=reverse
+
+ def __call__(self, _, **kw):
+ actions = [self.fingers, self.smooth_corner, self.hinge_hole]
+ actions = list(reversed(actions)) if self.reverse else actions
+ for action in actions:
+ action()
+
+ def fingers(self):
+ self.boxes.edges['f'](self.fingered_h)
+
+ def smooth_corner(self):
+ # the corner has to be rounded to rotate freely
+ path = [
+ 0,
+ (-90, 0),
+ self.boxes.thickness,
+ (90, 0),
+ self.height-self.fingered_h-self.hinge_center,
+ (90, self.hinge_center),
+ self.boxes.thickness+self.length-self.hinge_center,
+ ]
+ path = list(reversed(path)) if self.reverse else path
+ self.polyline(*path)
+
+ @restore
+ @holeCol
+ def hinge_hole(self):
+ direction = -1 if self.reverse else 1
+ x = direction*(self.hinge_center-self.length-self.boxes.thickness-self.boxes.burn)
+ y = self.hinge_center
+ t = self.boxes.thickness
+ self.boxes.circle(x, y, self.hinge_radius)
+ self.boxes.rectangularHole(x, y, t, 1.5*t)
+
+ def margin(self) -> float:
+ return 0 if self.reverse else self.boxes.edges['f'].margin()
diff --git a/examples/SideHingeBox.svg b/examples/SideHingeBox.svg
new file mode 100644
index 00000000..3e279a54
--- /dev/null
+++ b/examples/SideHingeBox.svg
@@ -0,0 +1,86 @@
+
+
\ No newline at end of file
diff --git a/static/samples/SideHingeBox-thumb.jpg b/static/samples/SideHingeBox-thumb.jpg
new file mode 100644
index 00000000..0d5a1921
Binary files /dev/null and b/static/samples/SideHingeBox-thumb.jpg differ
diff --git a/static/samples/SideHingeBox.jpg b/static/samples/SideHingeBox.jpg
new file mode 100644
index 00000000..a325c0e0
Binary files /dev/null and b/static/samples/SideHingeBox.jpg differ
diff --git a/static/samples/samples.sha256 b/static/samples/samples.sha256
index 99dafde5..99a59df5 100644
--- a/static/samples/samples.sha256
+++ b/static/samples/samples.sha256
@@ -175,3 +175,4 @@ f51e3cb0e74e380beda4c0966ee770142ff4fa38c266ca026177f9f4978190a4 ../static/samp
a0865738425d5d9966dc6975d7e73559bac3c307c9614e8b48bae4abdf3efb5b ../static/samples/BrickSorter-2.jpg
2dcb314cdfa8b136b59288d2f4f7e501b4290ff68560216b6bedd779a32095ad ../static/samples/Shadowbox-diagram.jpg
21333db253007b6e101333826a1231d788cf10e0e2afec84ff2a3f983330091d ../static/samples/Matrix.jpg
+9855c836088d93e508f1c1899ae62733111062f932c1d79ddcc827da2b73335c ../static/samples/SideHingeBox.jpg