diff --git a/boxes/generators/photoframe.py b/boxes/generators/photoframe.py new file mode 100644 index 00000000..de481f00 --- /dev/null +++ b/boxes/generators/photoframe.py @@ -0,0 +1,652 @@ +# Copyright (C) 2013-2016 Florian Festi, 2024 marauder37 +# +# 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 inspect +import logging +import math +from dataclasses import dataclass, fields + +from boxes import BoolArg, Boxes, Color, edges + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + +@dataclass +class Dimensions: + """ + Calculate the dimensions of a photo frame with matting and glass. + + What changes if the user specifies the dimensions of the glass? + - the matting outer dimension is fixed by the glass instead of calculated out from the photo + - the matting width and height must be calculated from the photo dimensions + - so you can't have golden matting or user-specified matting width/height + """ + + x: float + y: float + golden_mat: bool + matting_w_param: float + matting_h_param: float + matting_overlap: float + glass_w: float + glass_h: float + frame_w: float + frame_overlap: float + split_front_param: bool + split_middle_param: bool + guide_fudge: float = 2.0 + + def __post_init__(self): + self.check_matting_params() + self.check() + + @property + def photo_x(self): + """Width of the photo""" + return self.x + + @property + def photo_y(self): + """Height of the photo""" + return self.y + + @property + def frame_h(self): + """ + Width of the top and bottom sections of the framing 'border' formed by the top layer + + I can't think of a reason for this to be different from frame_w, but if you think of one, + this is where to handle it. + """ + + return self.frame_w + + @property + def mat_hole_x(self): + """Width of the hole in the matting that shows the photo""" + return self.photo_x - 2 * self.matting_overlap + + @property + def mat_hole_y(self): + """Height of the hole in the matting that shows the photo""" + return self.photo_y - 2 * self.matting_overlap + + @property + def golden_matting_width(self): + """ + Calculate the width of the matting border that gives a golden ratio for the matting+photo area / photo area + See e.g. https://robertreiser.photography/golden-ratio-print-borders/ + """ + + phi = (1 + math.sqrt(5)) / 2 + a = 4 + x = self.mat_hole_x + y = self.mat_hole_y + b = 2 * (x + y) + c = -(phi - 1) * x * y + disc = b**2 - 4 * a * c + x1 = (-b + math.sqrt(disc)) / (2 * a) + return x1 + + @property + def fixed_glass_size(self) -> bool: + """ + Whether user has specified the size of the glass, or we should work it out from the photo + """ + return bool(self.glass_w and self.glass_h) + + @property + def matting_w(self): + """ + Width of the visible matting border on the sides of photo, not including the bit that's hidden by the frame + """ + + if self.fixed_glass_size: + visible = self.glass_w - 2 * self.frame_overlap + return (visible - self.mat_hole_x) / 2 + if self.golden_mat: + return self.golden_matting_width + return self.matting_w_param + + @property + def matting_h(self): + """ + Height of the visible matting border on the top and bottom of photo, not including the bit that's hidden by the frame + """ + if self.fixed_glass_size: + visible = self.glass_h - 2 * self.frame_overlap + return (visible - self.mat_hole_y) / 2 + if self.golden_mat: + return self.golden_matting_width + return self.matting_h_param + + @property + def mat_x(self): + """Width of the matting including the bit that's hidden by the frame""" + return self.mat_hole_x + 2 * self.matting_w + 2 * self.frame_overlap + + @property + def mat_y(self): + """Height of the matting including the bit that's hidden by the frame""" + return self.mat_hole_y + 2 * self.matting_h + 2 * self.frame_overlap + + @property + def visible_mat_ratio(self): + """ + Ratio of the visible matting area to the visible photo area + + For the most aesthetic result, this should be the golden ratio phi ~= 1.618. + Contrary to popular belief, this is all about the area, not length of side or perimeter. + """ + + visible_mat_area = self.window_x * self.window_y + visible_photo_area = self.mat_hole_x * self.mat_hole_y + return visible_mat_area / visible_photo_area + + @property + def pocket_x(self): + """Width of the pocket formed by the guide including the fudge""" + return self.mat_x + self.guide_fudge + + @property + def pocket_y(self): + """Height of the pocket formed by the guide including the fudge""" + return self.base_y - self.guide_h + + @property + def guide_w(self): + """Width of the guide that holds the matting and glass in place""" + return (self.base_x - self.pocket_x) / 2 + + @property + def guide_h(self): + """Height of the guide that holds the matting and glass in place""" + return (self.base_y - self.mat_y) / 2 + + @property + def window_x(self): + """Width of the window that shows the photo""" + return self.mat_x - self.frame_overlap * 2 + + @property + def window_y(self): + """Height of the window that shows the photo""" + return self.mat_y - self.frame_overlap * 2 + + @property + def base_x(self): + """Width of the base layer, which is also the overall width of the piece""" + return self.window_x + 2 * self.frame_w + + @property + def base_y(self): + """Height of the base layer, which is also the overall height of the piece""" + return self.window_y + 2 * self.frame_h + + @property + def centre_x(self): + """ + Midpoint of the whole frame + """ + return self.base_x / 2 + + @property + def centre_y(self): + """ + Midpoint of the whole frame + """ + return self.base_y / 2 + + @property + def split_middle(self): + return self.split_middle_param + + @property + def unsplit_middle(self): + return not self.split_middle_param + + @property + def split_front(self): + return self.split_front_param + + @property + def unsplit_front(self): + return not self.split_front_param + + def check_matting_params(self): + whinge_threshold_mm = 0.5 + if self.golden_mat and self.fixed_glass_size: + calc = f"Calculated matting width x: {self.matting_w:.1f}, y: {self.matting_h:.1f} for glass {self.glass_w:.1f} x {self.glass_h:.1f}." + advice = "If you want to specify the glass size, do not use golden matting." + raise ValueError(f"Cannot have golden matting and fixed glass size at the same time. {calc} {advice}") + + if self.fixed_glass_size and (self.matting_w_param or self.matting_h_param): + d_w = self.matting_w_param - self.matting_w + d_h = self.matting_h_param - self.matting_h + if abs(d_w) > whinge_threshold_mm or abs(d_h) > whinge_threshold_mm: + msg = f"Calculated matting width {self.matting_w:.1f} differs from specified matting widths {self.matting_w_param:.1f}, {self.matting_h_param:.1f}." + advice = "If you want to specify the matting widths, set the glass size to zero. If you want to specify the glass size, set the matting widths to 0." + logger.warning(msg) + raise ValueError(f"Fixed glass size and explicit matting dimensions are mutually exclusive. {msg} {advice}") + + if self.golden_mat and (self.matting_w_param or self.matting_h_param): + d_w = self.matting_w_param - self.golden_matting_width + d_h = self.matting_h_param - self.golden_matting_width + if abs(d_w) > whinge_threshold_mm or abs(d_h) > whinge_threshold_mm: + msg = f"Golden matting width {self.golden_matting_width:.1f} differs from specified matting widths {self.matting_w_param:.1f}, {self.matting_h_param:.1f}" + advice = "If you want to specify the matting width, set the glass size to zero. If you want to specify the glass size, set the matting widths to 0." + logger.warning(msg) + raise ValueError(f"Golden matting and explicit matting dimensions are mutually exclusive. {msg} {advice}") + + def check(self): + photo_info = f"Photo: {self.photo_x:.0f} x {self.photo_y:.0f}" + mat_hole_info = f"Matting hole: {self.mat_hole_x:.0f} x {self.mat_hole_y:.0f} (O {self.matting_overlap:.0f})" + matting_w_info = f"Matting widths: {self.matting_w:.0f} sides, {self.matting_h:.0f} top/bottom" + mat_info = f"Mat size: {self.mat_x:.0f} x {self.mat_y:.0f} (W {self.matting_w:.0f}, H {self.matting_h:.0f})" + base_info = f"Back of frame: {self.base_x} x {self.base_y} (W {self.frame_w:.0f}, H {self.frame_h:.0f})" + base_x_info = f"Back of frame x: {self.base_x:.0f} = {self.window_x:.0f} + 2 * ({self.frame_w:.0f} - {self.frame_overlap:.0f})" + base_y_info = f"Back of frame y: {self.base_y:.0f} = {self.window_y:.0f} + 2 * ({self.frame_h:.0f} - {self.frame_overlap:.0f})" + window_info = f"Viewing window in front layer: {self.window_x} x {self.window_y} (rim {self.frame_overlap:.0f})" + pocket_info = f"Pocket for glass and matting: {self.pocket_x:.0f} x {self.pocket_y:.0f} (guide {self.guide_fudge:.0f})" + if self.fixed_glass_size: + glass_info = f"Glass size: {self.glass_w:.0f} x {self.glass_h:.0f} (fixed)" + else: + glass_info = "Glass size: not specified" + info = [ + photo_info, + mat_hole_info, + matting_w_info, + glass_info, + mat_info, + window_info, + pocket_info, + base_info, + base_x_info, + base_y_info, + ] + + issues = [] + + for field in fields(self): + if isinstance(getattr(self, field.name), float): + v = getattr(self, field.name) + if v < 0: + issues.append(f"{field.name} must be positive") + + # Check all properties + for name, value in inspect.getmembers(self.__class__, lambda o: isinstance(o, property)): + prop_value = getattr(self, name) + if isinstance(prop_value, float): + if prop_value < 0: + issues.append(f"{name} must be positive") + + if issues: + info_str = "\n".join(info) + issues_str = "\n".join(issues) + raise ValueError(f"Invalid dimensions:\n{issues_str}\n{info_str}") + + +class PhotoFrame(Boxes): + """ + 3-layer photo frame with a slot at the top to slide matboard/acrylic/glass over the photo after glue-up. + """ + + ui_group = "Misc" + + description = """ +3-layer photo frame. + +Selected excellent features: + +* easy to change the photo after glue-up, without disassembling the frame +* calculates the ideal matting size for your photo based on ancient Greek mathematics +* can make the frame in one piece or split into 4 pieces to save material +* can make a frame to fit the piece of glass/acrylic you already have +* adds a hole for hanging the frame on the wall + +Features available in the mysterious future: + +* rounded corners +* a stand on the back to display the frame on a table + +## How to frame things like a pro + +There are 1 or 2 things that you can't change when framing: the size of the artwork +and the size of the glass. So we generate everything else to fit those measurements. + +Set `x` and `y` to the dimensions of the actual artwork. + +* If your photo has a border, measure inside it. +* If your photo does not have a border, still measure the actual artwork. Don't reduce the dimensions to allow for mounting. We do that separately. + +A real pro measures the photo, calculates the matting, and cuts the glass to fit the matting. +We will assume that you are not in fact a real pro, and can't be trusted with a glass cutter. +So measure your glass and we'll calculate the matting to suit it. If you aren't using glass, +we'll calculate the matting size based on "golden ratio of areas" like the pros do. Everyone +will think your frame is perfect, but they won't know why. + +Matting is just cardboard. Its jobs are to keep the glass off the photo, provide a clean border around the artwork, +and make the whole thing look fancy. You can attach the photo to the matting or to the back of the frame. +Either way, the matting conceals all sorts of sins like bad cuts, glue marks, or mounting with blue painter's tape. +Matting also lets you reuse the frame for different sized photos. Just generate a new mat with the same glass dimensions. + +The hole in the matting is smaller than the photo. This is so the photo doesn't fall out. +Even if your photo has a border, the hole needs to be a bit smaller than the photo, +or you will struggle to line up the photo without the edges showing. +Recommended overlaps are given in the settings. Don't worry about "losing" too much of the photo. +The matting will make the photo look bigger and more important. There's never anything +interesting in the last 2mm of a photo anyway. +""" + + x = 100 + y = 150 + golden_mat = True + matting_w = 0 + matting_h = 0 + matting_overlap = 2 + glass_w = 0 + glass_h = 0 + frame_w = 20.0 + frame_overlap = 5.0 + split_middle = True # not exposed in the UI + split_front = True + mount_hole_dia = 6.0 + guide_fudge = 2.0 + + d = None + + def __init__(self) -> None: + Boxes.__init__(self) + + self.add_arguments() + + def render(self): + self.d = Dimensions( + x=self.x, + y=self.y, + golden_mat=self.golden_mat, + matting_w_param=self.matting_w, + matting_h_param=self.matting_h, + matting_overlap=self.matting_overlap, + glass_w=self.glass_w, + glass_h=self.glass_h, + frame_w=self.frame_w, + frame_overlap=self.frame_overlap, + split_middle_param=self.split_middle, + split_front_param=self.split_front, + guide_fudge=self.guide_fudge, + ) + + self.render_base() + self.render_middle() + self.render_front() + self.render_matting() + self.render_photo() + + def render_middle(self): + """ + Render the middle layer of the frame, which holds the matting and glass in place + + Local variable `stack_n` is reserved for future use where multiple middle layers + are needed to hold thicker glass or matting. I have needed this in the past + but not often enough to add it to the UI. + """ + + stack_n = 1 + + if self.d.unsplit_middle: + for _ in range(stack_n): + self.middle_unsplit() + + if self.d.split_middle: + for _ in range(stack_n): + self.middle_split() + + def middle_split(self): + lyr = "Middle" + d = self.d + edge_types = "DeD" + edge_lengths = (d.guide_w, d.base_x - 2 * d.guide_w, d.guide_w) + e = edges.CompoundEdge(self, edge_types, edge_lengths) + move = "up" + self.rectangularWall(d.base_x, d.guide_h, ["e", "e", e, "e"], move=move, label=f"{lyr} btm {d.base_x:.0f}x{d.guide_h:.0f}") + self.rectangularWall(d.pocket_y, d.guide_w, "edee", move=move, label=f"{lyr} side {d.guide_w:.0f}x{d.pocket_y:.0f}") + self.rectangularWall(d.pocket_y, d.guide_w, "edee", move=move, label=f"{lyr} side {d.guide_w:.0f}x{d.pocket_y:.0f}") + + def middle_unsplit(self): + lyr = "Middle" + d = self.d + dims_str = f"{lyr} {d.base_x:.0f}x{d.base_y:.0f} with pocket {d.pocket_x:.0f}x{d.pocket_y:.0f} for mat {d.mat_x:.0f}x{d.mat_y:.0f}" + border_str = f"Widths {d.guide_w:.0f}x {d.guide_h:.0f}y x-fudge {d.guide_fudge:.0f}" + label = f"{dims_str}\n{border_str}" + + # start at bottom left + poly = [d.base_x, 90, d.base_y, 90, d.guide_w, 90, d.pocket_y, -90, d.pocket_x, -90, d.pocket_y, 90, d.guide_w, 90, d.base_y, 90] + self.polygonWall(poly, "eeee", move="up", label=label) + + def render_matting(self): + d = self.d + dims_str = f"Matting {d.mat_x:.0f}x{d.mat_y:.0f} - {d.mat_hole_x:.0f}x{d.mat_hole_y:.0f} (ratio {d.visible_mat_ratio:.2f})" + border_str = f"Borders {d.matting_w:.0f}w {d.matting_h:.0f}h" + overlap_str = f"Overlaps photo {d.matting_overlap:.0f}, frame {d.frame_overlap:.0f}" + label = f"{dims_str}\n{border_str}\n{overlap_str}" + + callback = [lambda: self.rectangularHole(d.mat_x / 2, d.mat_y / 2, d.mat_hole_x, d.mat_hole_y, r=0.0)] + self.rectangularWall(d.mat_x, d.mat_y, "eeee", callback=callback, move="right", label=label) + + def golden_matting_width(self, photo_width, photo_height): + # Calculate the width of the matting border + phi = (1 + math.sqrt(5)) / 2 + a = 4 + b = 2 * (photo_width + photo_height) + c = -(phi - 1) * photo_width * photo_height + disc = b**2 - 4 * a * c + x1 = (-b + math.sqrt(disc)) / (2 * a) + return x1 + + # Function to display the results + def display_results(self, photo_width, photo_height, matting_width): + photo_area = photo_width * photo_height + photo_perimeter = 2 * (photo_width + photo_height) + mat_x = photo_width + 2 * matting_width + mat_y = photo_height + 2 * matting_width + mat_perimeter = 2 * (mat_x + mat_y) + total_area = (photo_width + 2 * matting_width) * (photo_height + 2 * matting_width) + ratio = total_area / photo_area + diff = total_area - photo_area + logger.debug(f"\n\nPhoto dims: {photo_width:.0f} x {photo_height:.0f}") + logger.debug(f"Photo perimeter: {photo_perimeter:.0f}") + logger.debug(f"Photo area: {photo_area:.0f}") + logger.debug(f"Mat dims: {mat_x:.0f} x {mat_y:.0f}") + logger.debug(f"Mat perimeter: {mat_perimeter:.0f}") + logger.debug(f"Mat area: {diff:.0f}") + logger.debug(f"Total area: {total_area:.0f}") + logger.debug(f"Matting width: {matting_width:.1f}") + logger.debug(f"Ratio: {ratio:.2f}") + + def render_front(self): + if self.d.unsplit_front: + self.front_unsplit() + + if self.d.split_front: + self.front_split() + + def front_unsplit(self): + lyr = "Front" + d = self.d + dims_str = f"{lyr} {d.base_x:.0f}x{d.base_y:.0f} - {d.window_x:.0f}x{d.window_y:.0f}" + border_str = f"Widths {d.frame_w:.0f}x {d.frame_h:.0f}y {d.frame_overlap:.0f} overlap" + label = f"{dims_str}\n{border_str}" + + callback = [lambda: self.rectangularHole(d.centre_x, d.centre_y, d.window_x, d.window_y)] + self.rectangularWall(d.base_x, d.base_y, "eeee", callback=callback, move="up", label=label) + + def front_split(self): + lyr = "Front" + d = self.d + hypo_h = math.sqrt(2 * d.frame_h**2) + hypo_w = math.sqrt(2 * d.frame_w**2) + + tops = [d.base_x, 90 + 45, hypo_h, 90 - 45, d.base_x - 2 * d.frame_h, 90 - 45, hypo_h, None] + sides = [d.base_y, 90 + 45, hypo_w, 90 - 45, d.base_y - 2 * d.frame_w, 90 - 45, hypo_w, None] + + for bit in ("top", "btm"): + label = f"{lyr} {bit} {d.base_x:.0f}x{d.frame_h:.0f}" + self.polygonWall(tops, "eded", move="up", label=label) + + for bit in "LR": + label = f"{lyr} side {bit} {d.frame_w:.0f}x{d.base_y:.0f}" + self.polygonWall(sides, "eDeD", move="up", label=label) + + def render_base(self): + d = self.d + label = f"Base {d.base_x:.0f}x{d.base_y:.0f} for photo {d.photo_x:.0f}x{d.photo_y:.0f}" + + callback = [lambda: self.photo_registration_rectangle(), None, None, None] + holes = self.edgesettings.get("Mounting", {}).get("num", 0) + self.rectangularWall(d.base_x, d.base_y, "eeGe" if holes else "eeee", callback=callback, move="up", label=label) + + # I can't work out the interface for roundedPlate with edge settings other than "e" + # so no rounded corners for you! + # self.roundedPlate(d.base_x, d.base_y, d.corner_radius, "e", callback, extend_corners=False, move="up", label=label) + + def photo_registration_rectangle(self): + """ + Draw a rectangle with registration marks for the photo + """ + + d = self.d + self.set_source_color(Color.ETCHING) + self.rectangular_etching(d.centre_x, d.centre_y, d.photo_x, d.photo_y) + self.ctx.stroke() + + def rectangular_etching(self, x, y, dx, dy, r=0.0, center_x=True, center_y=True): + """ + Draw a rectangular etching (from GridfinityTrayLayout.rectangularEtching) + Same as rectangularHole, but with no burn margin + + :param x: position + :param y: position + :param dx: width + :param dy: height + :param r: (Default value = 0) radius of the corners + :param center_x: (Default value = True) if True, x position is the center, else the start + :param center_y: (Default value = True) if True, y position is the center, else the start + """ + + logger.debug(f"rectangular_etching: {x=} {y=} {dx=} {dy=} {r=} {center_x=} {center_y=}") + + r = min(r, dx / 2.0, dy / 2.0) + x_start = x if center_x else x + dx / 2.0 + y_start = y - dy / 2.0 if center_y else y + self.moveTo(x_start, y_start, 180) + self.edge(dx / 2.0 - r) # start with an edge to allow easier change of inner corners + for d in (dy, dx, dy, dx / 2.0 + r): + self.corner(-90, r) + self.edge(d - 2 * r) + + def add_arguments(self): + # landlords seem to love using 8GA screws in masonry sleeves for wall mounts + self.addSettingsArgs(edges.MountingSettings, num=3, d_head=8.0, d_shaft=4.0) + self.addSettingsArgs(edges.DoveTailSettings, size=2.0, depth=1.0) + self.buildArgParser() + self.argparser.add_argument( + "--x", + action="store", + type=float, + default=self.x, + help="Width of the photo, not including any border", + ) + self.argparser.add_argument( + "--y", + action="store", + type=float, + default=self.y, + help="Height of the photo, not including any border", + ) + self.argparser.add_argument( + "--golden_mat", action="store", type=BoolArg(), default=self.golden_mat, help="Use golden ratio to calculate matting width" + ) + self.argparser.add_argument( + "--matting_w", + action="store", + type=float, + default=self.matting_w, + help="Width of the matting border around the sides of the photo", + ) + self.argparser.add_argument( + "--matting_h", + action="store", + type=float, + default=self.matting_h, + help="Width of the matting border around top/bottom of the photo", + ) + self.argparser.add_argument( + "--matting_overlap", + action="store", + type=float, + default=self.matting_overlap, + help="Matting overlap of the photo, e.g. 2mm if photo has border, 5mm if not", + ) + self.argparser.add_argument( + "--glass_w", + action="store", + type=float, + default=self.glass_w, + help="Width of the pre-cut glass or acrylic", + ) + self.argparser.add_argument( + "--glass_h", + action="store", + type=float, + default=self.glass_h, + help="Height of the pre-cut glass or acrylic", + ) + self.argparser.add_argument( + "--frame_w", + action="store", + type=float, + default=self.frame_w, + help="Width of the frame border around the matting", + ) + self.argparser.add_argument( + "--guide_fudge", + action="store", + type=float, + default=self.guide_fudge, + help="Clearance in the guide pocket to help slide the matting/glass in", + ) + self.argparser.add_argument( + "--frame_overlap", + action="store", + type=float, + default=self.frame_overlap, + help="Frame overlap to hold the matting/glass in place", + ) + self.argparser.add_argument( + "--split_front", + action="store", + type=BoolArg(), + default=self.split_front, + help="Split front into thin rectangles to save material", + ) + + def render_photo(self): + d = self.d + self.set_source_color(Color.ANNOTATIONS) + label = f"Photo {d.photo_x:.0f}x{d.photo_y:.0f}" + self.rectangularWall(d.photo_x, d.photo_y, "eeee", label=label, move="up") + self.set_source_color(Color.BLACK) diff --git a/examples/PhotoFrame.svg b/examples/PhotoFrame.svg new file mode 100644 index 00000000..2de81ece --- /dev/null +++ b/examples/PhotoFrame.svg @@ -0,0 +1,156 @@ + + + +PhotoFrame + + +Misc - PhotoFrame +boxes PhotoFrame + + 3-layer photo frame with a slot at the top to slide matboard/acrylic/glass over the photo after glue-up. + + + +3-layer photo frame. + +Selected excellent features: + +* easy to change the photo after glue-up, without disassembling the frame +* calculates the ideal matting size for your photo based on ancient Greek mathematics +* can make the frame in one piece or split into 4 pieces to save material +* can make a frame to fit the piece of glass/acrylic you already have +* adds a hole for hanging the frame on the wall + +Features available in the mysterious future: + +* rounded corners +* a stand on the back to display the frame on a table + +## How to frame things like a pro + +There are 1 or 2 things that you can't change when framing: the size of the artwork +and the size of the glass. So we generate everything else to fit those measurements. + +Set `x` and `y` to the dimensions of the actual artwork. + +* If your photo has a border, measure inside it. +* If your photo does not have a border, still measure the actual artwork. Don't reduce the dimensions to allow for mounting. We do that separately. + +A real pro measures the photo, calculates the matting, and cuts the glass to fit the matting. +We will assume that you are not in fact a real pro, and can't be trusted with a glass cutter. +So measure your glass and we'll calculate the matting to suit it. If you aren't using glass, +we'll calculate the matting size based on "golden ratio of areas" like the pros do. Everyone +will think your frame is perfect, but they won't know why. + +Matting is just cardboard. Its jobs are to keep the glass off the photo, provide a clean border around the artwork, +and make the whole thing look fancy. You can attach the photo to the matting or to the back of the frame. +Either way, the matting conceals all sorts of sins like bad cuts, glue marks, or mounting with blue painter's tape. +Matting also lets you reuse the frame for different sized photos. Just generate a new mat with the same glass dimensions. + +The hole in the matting is smaller than the photo. This is so the photo doesn't fall out. +Even if your photo has a border, the hole needs to be a bit smaller than the photo, +or you will struggle to line up the photo without the edges showing. +Recommended overlaps are given in the settings. Don't worry about "losing" too much of the photo. +The matting will make the photo look bigger and more important. There's never anything +interesting in the last 2mm of a photo anyway. + + +Created with Boxes.py (https://boxes.hackerspace-bamberg.de/) +Command line: boxes PhotoFrame +Command line short: boxes PhotoFrame + + + + + 100.0mm, burn:0.10mm + + + + Base 168x218 for photo 100x150 + + + + + + Middle btm 168x15 + + + Middle side 14x203 + + + Middle side 14x203 + + + Front top 168x20 + + + Front btm 168x20 + + + Front side L 20x218 + + + Front side R 20x218 + + + + Overlaps photo 2, frame 5Borders 16w 16hMatting 138x188 - 96x146 (ratio 1.62) + + + Photo 100x150 + + \ No newline at end of file diff --git a/static/samples/PhotoFrame-thumb.jpg b/static/samples/PhotoFrame-thumb.jpg new file mode 100644 index 00000000..6743c17e Binary files /dev/null and b/static/samples/PhotoFrame-thumb.jpg differ diff --git a/static/samples/PhotoFrame.jpg b/static/samples/PhotoFrame.jpg new file mode 100644 index 00000000..0f50b979 Binary files /dev/null and b/static/samples/PhotoFrame.jpg differ diff --git a/static/samples/samples.sha256 b/static/samples/samples.sha256 index 99a59df5..082f46b4 100644 --- a/static/samples/samples.sha256 +++ b/static/samples/samples.sha256 @@ -176,3 +176,4 @@ a0865738425d5d9966dc6975d7e73559bac3c307c9614e8b48bae4abdf3efb5b ../static/samp 2dcb314cdfa8b136b59288d2f4f7e501b4290ff68560216b6bedd779a32095ad ../static/samples/Shadowbox-diagram.jpg 21333db253007b6e101333826a1231d788cf10e0e2afec84ff2a3f983330091d ../static/samples/Matrix.jpg 9855c836088d93e508f1c1899ae62733111062f932c1d79ddcc827da2b73335c ../static/samples/SideHingeBox.jpg +77452cf4c547102ce115b2d1c47ba83b740efa5d7a50cb3b6c17f4e771edbd4e ../static/samples/PhotoFrame.jpg diff --git a/static/self.js b/static/self.js index df063e42..da6a81ed 100644 --- a/static/self.js +++ b/static/self.js @@ -158,6 +158,157 @@ function GridfinityTrayLayoutInit() { layout_id.cols = 24; } +function PhotoFrameInit() { + console.log("PhotoFrameInit: setting event handlers for matting"); + window.photoFrameUserMattingW = null; + window.photoFrameUserMattingH = null; + window.photoFrameUserGlassW = null; + window.photoFrameUserGlassH = null; + + for (const id_string of ['matting_w', 'matting_h']) { + const id = document.getElementById(id_string); + id.addEventListener('input', PhotoFrame_MattingUpdate); + // id.addEventListener('change', PhotoFrame_MattingUpdate); + } + for (const id_string of ['glass_w', 'glass_h']) { + const id = document.getElementById(id_string); + id.addEventListener('input', PhotoFrame_GlassUpdate); + id.addEventListener('change', PhotoFrame_GlassUpdate); + } + for (const id_string of ['golden_mat']) { + const id = document.getElementById(id_string); + id.addEventListener('change', PhotoFrame_GoldenMattingChange); + } + for (const id_string of ['matting_overlap', 'x', 'y']) { + const id = document.getElementById(id_string); + id.addEventListener('input', PhotoFrame_GoldenMattingChange); + id.addEventListener('change', PhotoFrame_GoldenMattingChange); + } + + // Set the initial values + PhotoFrame_GoldenMattingChange(); +} + +function PhotoFrame_MattingUpdate(event) { + // If the user manually updates the matting, save the values and turn off golden matting + + const golden_mat = document.getElementById('golden_mat').checked; + const matting_w = document.getElementById('matting_w').value; + const matting_h = document.getElementById('matting_h').value; + + console.log("PhotoFrame_MattingUpdate", matting_w, matting_h, golden_mat); + window.photoFrameUserMattingW = matting_w; + window.photoFrameUserMattingH = matting_h; + + if (golden_mat) { + document.getElementById('golden_mat').checked = false; + } + if (matting_w || matting_h) { + document.getElementById('glass_w').value = 0; + document.getElementById('glass_h').value = 0; + } +} + +function PhotoFrame_GlassUpdate(event) { + // If the user enters glass dimensions, save the values and turn off golden matting + + // console.log("PhotoFrame_GlassUpdate"); + + const golden_mat = document.getElementById('golden_mat').checked; + const glass_w = parseFloat(document.getElementById('glass_w').value); + const glass_h = parseFloat(document.getElementById('glass_h').value); + const matting_w = parseFloat(document.getElementById('matting_w').value); + const matting_h = parseFloat(document.getElementById('matting_h').value); + + console.log("PhotoFrame_GlassUpdate", glass_w, glass_h, matting_w, matting_h, golden_mat); + window.photoFrameUserGlassW = glass_w; + window.photoFrameUserGlassH = glass_h; + + if (golden_mat) { + document.getElementById('golden_mat').checked = false; + } + if (glass_w || glass_h) { + document.getElementById('matting_w').value = 0; + document.getElementById('matting_h').value = 0; + } +} + +function PhotoFrame_GoldenMattingChange(event) { + // If the user turns on golden matting, calculate the values + // If the user turns off golden matting, restore the manual matting values + // If golden matting is on and the user changes the photo size or overlap, recalculate the matting + + const golden_mat = document.getElementById('golden_mat').checked; + console.log("PhotoFrame_GoldenMattingChange", golden_mat); + + if (golden_mat) { + try { + const mattingWidth = PhotoFrame_GoldenMattingWidth(); + document.getElementById('matting_w').value = mattingWidth; + document.getElementById('matting_h').value = mattingWidth; + } catch (error) { + document.getElementById('matting_w').value = 0; + document.getElementById('matting_h').value = 0; + } + document.getElementById('glass_w').value = 0; + document.getElementById('glass_h').value = 0; + } else { + if (window.photoFrameUserGlassW != null && window.photoFrameUserGlassH != null) { + document.getElementById('glass_w').value = window.photoFrameUserGlassW; + document.getElementById('glass_h').value = window.photoFrameUserGlassH; + document.getElementById('matting_w').value = 0; + document.getElementById('matting_h').value = 0; + } else if (window.photoFrameUserMattingW != null && window.photoFrameUserMattingH != null) { + document.getElementById('matting_w').value = window.photoFrameUserMattingW; + document.getElementById('matting_h').value = window.photoFrameUserMattingH; + document.getElementById('glass_w').value = 0; + document.getElementById('glass_h').value = 0; + } + } +} + +function PhotoFrame_GoldenMattingWidth() { + // Calculate the width of the matting border. The border is around the hole in the matting + // that the photo fits into, not the photo per se + + // Caller is responsible for catching errors + + let mattingWidth = goldenMattingWidth(PhotoFrame_MatHole("x"), PhotoFrame_MatHole("y")); + mattingWidth = parseFloat(mattingWidth.toFixed(1)); + return mattingWidth; +} + +function PhotoFrame_MatHole(element_id) { + const photo_x = parseFloat(document.getElementById(element_id).value); + const matting_overlap = parseFloat(document.getElementById('matting_overlap').value); + return photo_x - 2 * matting_overlap; +} + +function goldenMattingWidth(photoWidth, photoHeight) { + // Validate input dimensions + if (photoWidth <= 0 || photoHeight <= 0) { + throw new Error("Photo dimensions must be positive values"); + } + + // Calculate the width of the matting border + const phi = (1 + Math.sqrt(5)) / 2; + const a = 4; + const b = 2 * (photoWidth + photoHeight); + const c = -(phi - 1) * photoWidth * photoHeight; + + // It is mathematically impossible to get complex roots + // or for the other root to be the right answer, so relax + const disc = b**2 - 4 * a * c; + const x1 = (-b + Math.sqrt(disc)) / (2 * a); + + // Broad check for valid result in case user has achieved the impossible + if (!isFinite(x1) || isNaN(x1) || x1 <= 0) { + throw new Error("Calculation resulted in an invalid matting width"); + } + + return x1; +} + function ParseSections(s) { var sections = []; for (var section of s.split(":")) { @@ -240,7 +391,8 @@ function TrayLayoutInit() { function addCallbacks() { page_callbacks = { "TrayLayout": TrayLayoutInit, - "GridfinityTrayLayout": GridfinityTrayLayoutInit, + "GridfinityTrayLayout": GridfinityTrayLayoutInit, + "PhotoFrame": PhotoFrameInit, }; loc = new URL(window.location.href); pathname = loc.pathname;