diff --git a/boxes/generators/wallhopper.py b/boxes/generators/wallhopper.py
new file mode 100644
index 00000000..7c152240
--- /dev/null
+++ b/boxes/generators/wallhopper.py
@@ -0,0 +1,153 @@
+# Copyright (C) 2024 Alex Roberts
+#
+# 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 .
+
+from boxes import *
+from boxes.walledges import _WallMountedBox
+
+class WallHopper(_WallMountedBox):
+ """Storage hopper with dispensing tray"""
+
+ description = '''
+####Assembly Notes:
+1. The generator produces three pieces with angled finger joints.
+Bottom panel, sloped front panel and label panel (if enabled).
+2. Joint lengths vary to accommodate the slope angles.
+3. Orient pieces as shown in the generated layout to assemble correctly.
+'''
+ def __init__(self) -> None:
+ super().__init__()
+
+ self.buildArgParser(x=80, h=150)
+
+ self.argparser.add_argument(
+ "--hopper_depth", action="store", type=float, default=50,
+ help="Depth of the hopper")
+ self.argparser.add_argument(
+ "--dispenser_depth", action="store", type=float, default=45,
+ help="Depth of the dispenser")
+ self.argparser.add_argument(
+ "--dispenser_height", action="store", type=float, default=50,
+ help="Height of the dispenser")
+ self.argparser.add_argument(
+ "--slope_ratio", action="store", type=float, default=0.4,
+ help="Fraction of the bottom slope of the dispenser")
+ self.argparser.add_argument(
+ "--slope_angle", action="store", type=float, default=30,
+ help="Angle of the bottom slope of the dispenser")
+ self.argparser.add_argument(
+ "--label", action="store", type=boolarg, default=True,
+ help="include a label area on the front")
+ self.argparser.add_argument(
+ "--label_ratio", action="store", type=float, default=0.2,
+ help="Fraction of the label of the dispenser")
+
+
+ def render(self):
+ self.generateWallEdges() # creates the aAbBcCdD| edges
+
+ hd = self.hopper_depth
+ dd = self.dispenser_depth
+ dh = self.dispenser_height
+ sr = self.slope_ratio if self.slope_ratio < 1 else 0.999
+ a = self.slope_angle
+ lr = self.label_ratio if self.label_ratio < 1 else 0.999
+ t = self.thickness
+
+ x = self.x
+ h = self.h
+
+ # Check that sa generates a valid dispenser
+ minsa = 0 # 0 degrees is a flat front
+ maxsa = math.degrees(math.atan(dd/(dh*sr))) # equivalent to no flat section on the dispenser
+ if a < minsa:
+ a = minsa
+ elif a > maxsa:
+ a = maxsa
+
+ # Get the width of the 'h' edge
+ wh = self.edges["h"].startwidth()
+
+ # Check that ratios are valid
+ if not self.label:
+ lr = 0
+ if sr + lr >= 1: # Check you haven't put in invalid values
+ # Scale proportionally to sum to 0.95
+ total = sr + lr
+ sr = (sr / total) * 0.95 # Scale to 95%
+ lr = (lr / total) * 0.95 # Scale to 95%
+
+ # Calculate angle between label and return to dispenser
+ b = math.degrees(math.atan(dd/((1-(lr+sr))*dh)))
+
+ # Dispenser flat is dispenser depth minus the slope
+ df = dd - dh*sr*math.tan(math.radians(a))
+
+ # Calculate the length of the slope
+ sl = dh*sr/math.cos(math.radians(a))
+
+ # calculate the length of the top slope
+ tl = (dd**2 + ((1-(lr+sr))*dh)**2)**0.5
+
+ # Configure angled finger joints for the sloped sections
+ # First set: For bottom-to-slope connection
+ angledsettings = copy.deepcopy(self.edges["f"].settings)
+ angledsettings.setValues(self.thickness, True, angle=90-a)
+ angledsettings.edgeObjects(self, chars="gG")
+
+ # Second set: For slope-to-label connection
+ angledsettings = copy.deepcopy(self.edges["f"].settings)
+ angledsettings.setValues(self.thickness, True, angle=a)
+ angledsettings.edgeObjects(self, chars="kK")
+
+
+ with self.saved_context():
+ # Bottom panel with finger joints
+ self.rectangularWall(x, hd+df, "ffGf", label="bottom", move="up")
+
+ if self.label:
+ # Sloped front with label area
+ self.rectangularWall(x, sl,
+ "gfkf", label="slope", move="up")
+ # Label panel
+ self.rectangularWall(x, dh*lr,
+ "Kfef", label="label", move="up")
+ else:
+ # Sloped front without label
+ self.rectangularWall(x, sl,
+ "gfef", label="slope", move="up")
+ # Back panel with wall mount edges
+ self.rectangularWall(x, h, "hCec", label="back", move="up")
+ # Front panel of hopper
+ self.rectangularWall(x, h-dh, "efef", label="front", move="up")
+
+ # Non drawn spacer to move wall pieces to the right
+ self.rectangularWall(self.x, 3, "DDDD", label="movement", move="right only")
+
+
+ sideEdges = [
+ t, 0, # nudge along by thickness
+ hd+df, (90-a, wh), # hopper depth + dispenser flat, then rotate slope angle with a radius of an 'h' edge
+ sl, (a, wh), # slope length, then rotate back to vertical with a radius of an 'h' edge
+ dh*lr, b, # label height, then rotate to the angle between label and dispenser
+ tl, -b, # top slope length, then rotate back to vertical
+ h-dh, 90, # Additional hopper height, then rotate to horizontal
+ hd+wh+t, 90, # Hopper depth + 'h' edge width + thickness, then rotate to vertical
+ h, 0, # Wall edge to the bottom
+ wh, 90, # Width of an 'h' edge to close the box
+ ]
+
+
+ self.polygonWall(sideEdges, "ehhhehebe",correct_corners=False, label="left", move="up")
+ self.polygonWall(sideEdges, "ehhhehebe",correct_corners=False, label="right", move="up mirror")
diff --git a/examples/WallHopper.svg b/examples/WallHopper.svg
new file mode 100644
index 00000000..8a076727
--- /dev/null
+++ b/examples/WallHopper.svg
@@ -0,0 +1,100 @@
+
+
\ No newline at end of file
diff --git a/static/samples/WallHopper-thumb.jpg b/static/samples/WallHopper-thumb.jpg
new file mode 100644
index 00000000..303537f6
Binary files /dev/null and b/static/samples/WallHopper-thumb.jpg differ
diff --git a/static/samples/WallHopper.jpg b/static/samples/WallHopper.jpg
new file mode 100644
index 00000000..f51df41d
Binary files /dev/null and b/static/samples/WallHopper.jpg differ
diff --git a/static/samples/samples.sha256 b/static/samples/samples.sha256
index 9e229057..07d57d89 100644
--- a/static/samples/samples.sha256
+++ b/static/samples/samples.sha256
@@ -189,4 +189,5 @@ d7f7fd6c1b5a51c4fdfc21a03d55597f04d78383fef2138bc6ade4ee95676bc9 ../static/samp
fec237bd76c18f1cad18a888f57299e5ff5033ab8032e7a26ea0b1259a42d150 ../static/samples/CompartmentBox-lid.jpg
5c2127a79948504f629ed792de539022411e428eb46dc8ad9c5286fb397cd603 ../static/samples/CompartmentBox.jpg
ca53e3c8b9ba8d46ca2d6fdff24865514dde27380b409fef82f0b87ac0bada2d ../static/samples/HobbyCase.jpg
+47910e8cf07e0339437d441b0fe270b05973317462495a7bb122bbcb779b135c ../static/samples/WallHopper.jpg
478769d7f422d0e47d50c7c1476b72a269f9acd03042d2d16dc0c00fa80941f6 ../static/samples/WallStackableBin.jpg