From 72423df6b62847b838292ca3e77a1424a367bf97 Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Mon, 29 Sep 2025 08:29:36 -0300 Subject: [PATCH 01/30] Add dogbone inner corner option --- boxes/__init__.py | 2 +- boxes/drawing.py | 4 ++++ documentation/src/usermanual.rst | 2 ++ po/boxes.py.pot | 4 ++++ po/zh_CN.po | 4 ++++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/boxes/__init__.py b/boxes/__init__.py index b310a9bbe..830a8de91 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -379,7 +379,7 @@ def __init__(self) -> None: help="print reference rectangle with given length (in mm)(zero to disable) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#reference)") defaultgroup.add_argument( "--inner_corners", action="store", type=str, default="loop", - choices=["loop", "corner", "backarc"], + choices=["loop", "corner", "backarc", "dogbone"], help="style for inner corners [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#inner-corners)") defaultgroup.add_argument( "--burn", action="store", type=float, default=0.1, diff --git a/boxes/drawing.py b/boxes/drawing.py index b9da3fe74..9ba51b07c 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -209,6 +209,10 @@ def faster_edges(self, inner_corners): if inner_corners == "backarc": return + if inner_corners == "dogbone": + # TODO: Implement dogbone inner corner drawing logic here. + return + for (i, p) in enumerate(self.path): if p[0] == "C" and i > 1 and i < len(self.path) - 1: if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": diff --git a/documentation/src/usermanual.rst b/documentation/src/usermanual.rst index 836db5ec8..fd03c6a70 100644 --- a/documentation/src/usermanual.rst +++ b/documentation/src/usermanual.rst @@ -128,6 +128,8 @@ different options: radius untouched. * ``backarc`` naive implementation with inverted arcs connection the straight lines. +* ``dogbone`` reserved for dogbone-style corners (requires radius via + ``--R`` or ``--D``) See also :doc:`burn correction details ` diff --git a/po/boxes.py.pot b/po/boxes.py.pot index 62dbfd603..01faffda5 100644 --- a/po/boxes.py.pot +++ b/po/boxes.py.pot @@ -826,6 +826,10 @@ msgstr "" msgid "backarc" msgstr "" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "" + #. parameter name msgid "burn" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 88b93861c..b57889c6d 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -841,6 +841,10 @@ msgstr "角落" msgid "backarc" msgstr "弧后" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "dogbone" + #. parameter name msgid "burn" msgstr "激光补偿" From 09c6c48033fd7655d5b38e985b16d7ad05953348 Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Mon, 29 Sep 2025 08:46:52 -0300 Subject: [PATCH 02/30] Add radius and diameter arguments for dogbone corners --- boxes/__init__.py | 14 +++++++++++++- po/boxes.py.pot | 16 ++++++++++++++++ po/zh_CN.po | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/boxes/__init__.py b/boxes/__init__.py index 830a8de91..6cc52a471 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -381,6 +381,12 @@ def __init__(self) -> None: "--inner_corners", action="store", type=str, default="loop", choices=["loop", "corner", "backarc", "dogbone"], help="style for inner corners [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#inner-corners)") + defaultgroup.add_argument( + "--R", action="store", type=float, default=None, + help="radius for dogbone inner corners (in mm)") + defaultgroup.add_argument( + "--D", action="store", type=float, default=None, + help="diameter for dogbone inner corners (in mm)") defaultgroup.add_argument( "--burn", action="store", type=float, default=0.1, help='burn correction (in mm)(bigger values for tighter fit) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#burn)') @@ -578,7 +584,13 @@ def cliQuote(s: str) -> str: self.metadata["cli"] = "boxes " + self.__class__.__name__ + " " + " ".join(cliQuote(arg) for arg in args) self.metadata["cli"] = self.metadata["cli"].strip() - for key, value in vars(self.argparser.parse_args(args=args)).items(): + parsed_args = self.argparser.parse_args(args=args) + + if getattr(parsed_args, "inner_corners", "loop") == "dogbone": + if getattr(parsed_args, "R", None) is None and getattr(parsed_args, "D", None) is None: + self.argparser.error("dogbone inner corners require --R or --D") + + for key, value in vars(parsed_args).items(): default = self.argparser.get_default(key) # treat edge settings separately diff --git a/po/boxes.py.pot b/po/boxes.py.pot index 01faffda5..c234f6b41 100644 --- a/po/boxes.py.pot +++ b/po/boxes.py.pot @@ -830,6 +830,22 @@ msgstr "" msgid "dogbone" msgstr "" +#. parameter name +msgid "R" +msgstr "" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "" + +#. parameter name +msgid "D" +msgstr "" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "" + #. parameter name msgid "burn" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index b57889c6d..95377cc49 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -845,6 +845,22 @@ msgstr "弧后" msgid "dogbone" msgstr "dogbone" +#. parameter name +msgid "R" +msgstr "R" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "dogbone 内角的半径(毫米)" + +#. parameter name +msgid "D" +msgstr "D" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "dogbone 内角的直径(毫米)" + #. parameter name msgid "burn" msgstr "激光补偿" From a29e921e12b307c7456340465198731ebe74a03a Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Mon, 29 Sep 2025 20:49:06 -0300 Subject: [PATCH 03/30] Implement dogbone arc generation --- boxes/__init__.py | 38 ++++++- boxes/drawing.py | 170 +++++++++++++++++++++++++++---- documentation/src/usermanual.rst | 2 + po/boxes.py.pot | 20 ++++ po/zh_CN.po | 20 ++++ 5 files changed, 228 insertions(+), 22 deletions(-) diff --git a/boxes/__init__.py b/boxes/__init__.py index b310a9bbe..a177e160a 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -379,8 +379,14 @@ def __init__(self) -> None: help="print reference rectangle with given length (in mm)(zero to disable) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#reference)") defaultgroup.add_argument( "--inner_corners", action="store", type=str, default="loop", - choices=["loop", "corner", "backarc"], + choices=["loop", "corner", "backarc", "dogbone"], help="style for inner corners [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#inner-corners)") + defaultgroup.add_argument( + "--R", action="store", type=float, default=None, + help="radius for dogbone inner corners (in mm)") + defaultgroup.add_argument( + "--D", action="store", type=float, default=None, + help="diameter for dogbone inner corners (in mm)") defaultgroup.add_argument( "--burn", action="store", type=float, default=0.1, help='burn correction (in mm)(bigger values for tighter fit) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#burn)') @@ -578,7 +584,31 @@ def cliQuote(s: str) -> str: self.metadata["cli"] = "boxes " + self.__class__.__name__ + " " + " ".join(cliQuote(arg) for arg in args) self.metadata["cli"] = self.metadata["cli"].strip() - for key, value in vars(self.argparser.parse_args(args=args)).items(): + parsed_args = self.argparser.parse_args(args=args) + + dogbone_radius = None + if getattr(parsed_args, "inner_corners", "loop") == "dogbone": + R = getattr(parsed_args, "R", None) + D = getattr(parsed_args, "D", None) + + if R is None and D is None: + self.argparser.error("dogbone inner corners require --R or --D") + + if R is not None: + if R <= 0: + self.argparser.error("--R must be greater than zero") + dogbone_radius = R + + if D is not None: + if D <= 0: + self.argparser.error("--D must be greater than zero") + inferred_radius = D / 2.0 + if dogbone_radius is None: + dogbone_radius = inferred_radius + elif abs(dogbone_radius - inferred_radius) > 1e-9: + self.argparser.error("--R and --D specify different radii") + + for key, value in vars(parsed_args).items(): default = self.argparser.get_default(key) # treat edge settings separately @@ -590,6 +620,8 @@ def cliQuote(s: str) -> str: if value != default: self.non_default_args[key] = value + self.dogbone_radius = dogbone_radius + # Change file ending to format if not given explicitly fileFormat = getattr(self, "format", "svg") if getattr(self, 'output', None) == 'box.svg': @@ -782,7 +814,7 @@ def close(self): self.surface.set_metadata(self.metadata) self.surface.flush() - data = self.surface.finish(self.inner_corners) + data = self.surface.finish(self.inner_corners, getattr(self, "dogbone_radius", None)) data = self.formats.convert(data, self.format) return data diff --git a/boxes/drawing.py b/boxes/drawing.py index b9da3fe74..4e9fd061a 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -205,27 +205,159 @@ def transform(self, f, m, invert_y=False): if invert_y: c[3] *= Affine.scale(1, -1) - def faster_edges(self, inner_corners): + def faster_edges(self, inner_corners, dogbone_radius=None): if inner_corners == "backarc": return - for (i, p) in enumerate(self.path): - if p[0] == "C" and i > 1 and i < len(self.path) - 1: - if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": + if inner_corners == "dogbone": + if dogbone_radius is None or dogbone_radius <= 0: + return + + radius = float(dogbone_radius) + offset = math.sqrt(2.0) * radius + + if offset < EPS: + return + + def _normalize(vx, vy): + length = math.hypot(vx, vy) + if length < EPS: + return None + return (vx / length, vy / length) + + def _rotate_cw(vx, vy): + return (vy, -vx) + + def _rotate_ccw(vx, vy): + return (-vy, vx) + + i = 0 + while i < len(self.path): + p = self.path[i] + if ( + p[0] == "C" + and i > 1 + and i < len(self.path) - 1 + and self.path[i - 1][0] == "L" + and self.path[i + 1][0] == "L" + ): p11 = self.path[i - 2][1:3] p12 = self.path[i - 1][1:3] p21 = p[1:3] p22 = self.path[i + 1][1:3] - if (((p12[0]-p21[0])**2 + (p12[1]-p21[1])**2) > - self.params["lw"]**2): + lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22)) + if not lines_intersect: + i += 1 continue - lines_intersect, x, y = line_intersection((p11, p12), (p21, p22)) - if lines_intersect: - self.path[i - 1] = ("L", x, y) - if inner_corners == "loop": - self.path[i] = ("C", x, y, *p12, *p21) - else: - self.path[i] = ("L", x, y) + + prev_len = math.hypot(ox - p11[0], oy - p11[1]) + next_len = math.hypot(p22[0] - ox, p22[1] - oy) + if prev_len <= offset + EPS or next_len <= offset + EPS: + i += 1 + continue + + d_prev = _normalize(ox - p11[0], oy - p11[1]) + d_next = _normalize(p22[0] - ox, p22[1] - oy) + if d_prev is None or d_next is None: + i += 1 + continue + + dot = d_prev[0] * d_next[0] + d_prev[1] * d_next[1] + if abs(dot) > 1e-3: + i += 1 + continue + + turn = d_prev[0] * d_next[1] - d_prev[1] * d_next[0] + if abs(turn) < 1e-9: + i += 1 + continue + + if turn > 0: + inward = (-d_prev[1], d_prev[0]) + inward = (inward[0] + -d_next[1], inward[1] + d_next[0]) + else: + inward = (d_prev[1], -d_prev[0]) + inward = (inward[0] + d_next[1], inward[1] + -d_next[0]) + + n_in = _normalize(*inward) + if n_in is None: + i += 1 + continue + + cx, cy = ox + n_in[0] * radius, oy + n_in[1] * radius + sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset + ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset + + rad_start = (sx - cx, sy - cy) + rad_end = (ex - cx, ey - cy) + if ( + math.hypot(*rad_start) < EPS + or math.hypot(*rad_end) < EPS + ): + i += 1 + continue + + mid_cw_vec = _rotate_cw(*rad_start) + mid_ccw_vec = _rotate_ccw(*rad_start) + mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) + mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) + score_cw = (mid_cw[0] - ox) * n_in[0] + (mid_cw[1] - oy) * n_in[1] + score_ccw = (mid_ccw[0] - ox) * n_in[0] + (mid_ccw[1] - oy) * n_in[1] + orientation = -1 if score_cw >= score_ccw else 1 + + theta_start = math.atan2(rad_start[1], rad_start[0]) + theta_end = math.atan2(rad_end[1], rad_end[0]) + if orientation == 1: + while theta_end <= theta_start: + theta_end += 2 * math.pi + else: + while theta_end >= theta_start: + theta_end -= 2 * math.pi + + delta = theta_end - theta_start + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2)))) + new_segments: list[tuple[float, ...]] = [] + for seg in range(segments): + t0 = theta_start + delta * (seg / segments) + t1 = theta_start + delta * ((seg + 1) / segments) + k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + cos0, sin0 = math.cos(t0), math.sin(t0) + cos1, sin1 = math.cos(t1), math.sin(t1) + p0x, p0y = cx + radius * cos0, cy + radius * sin0 + p3x, p3y = cx + radius * cos1, cy + radius * sin1 + c1x = p0x - k * radius * sin0 + c1y = p0y + k * radius * cos0 + c2x = p3x + k * radius * sin1 + c2y = p3y - k * radius * cos1 + new_segments.append((p3x, p3y, c1x, c1y, c2x, c2y)) + + self.path[i - 1] = ("L", sx, sy) + self.path[i : i + 1] = [ + ("C", px, py, c1x, c1y, c2x, c2y) + for (px, py, c1x, c1y, c2x, c2y) in new_segments + ] + i += len(new_segments) + continue + + i += 1 + else: + for (i, p) in enumerate(self.path): + if p[0] == "C" and i > 1 and i < len(self.path) - 1: + if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": + p11 = self.path[i - 2][1:3] + p12 = self.path[i - 1][1:3] + p21 = p[1:3] + p22 = self.path[i + 1][1:3] + if (((p12[0]-p21[0])**2 + (p12[1]-p21[1])**2) > + self.params["lw"]**2): + continue + lines_intersect, x, y = line_intersection((p11, p12), (p21, p22)) + if lines_intersect: + self.path[i - 1] = ("L", x, y) + if inner_corners == "loop": + self.path[i] = ("C", x, y, *p12, *p21) + else: + self.path[i] = ("L", x, y) # filter duplicates if len(self.path) > 1: # no need to find duplicates if only one element in path self.path = [p for n, p in enumerate(self.path) if p != self.path[n-1]] @@ -487,7 +619,7 @@ def _add_metadata(self, root) -> None: m.tail = '\n' root.insert(0, m) - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): extents = self._adjust_coordinates() w = extents.width * self.scale h = extents.height * self.scale @@ -525,7 +657,7 @@ def finish(self, inner_corners="loop"): x, y = 0, 0 start = None last = None - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) for c in path.path: x0, y0 = x, y C, x, y = c[0:3] @@ -637,7 +769,7 @@ def _metadata(self) -> str: desc += f'%%SettingsUrl short: {md["url_short"].replace("&render=1", "")}\n' return desc - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): extents = self._adjust_coordinates() w = extents.width @@ -681,7 +813,7 @@ def finish(self, inner_corners="loop"): for j, path in enumerate(part.pathes): p = [] x, y = 0, 0 - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) for c in path.path: x0, y0 = x, y @@ -770,7 +902,7 @@ class LBRN2Surface(Surface): 8, # Colors.OUTER_CUT (WHITE) --> Lightburn C08 (grey) ] - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): if self.dbg: print("LBRN2 save") extents = self._adjust_coordinates() w = extents.width * self.scale @@ -843,7 +975,7 @@ def finish(self, inner_corners="loop"): C = "" start = None last = None - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) num = 0 cnt = 1 end = len(path.path) - 1 diff --git a/documentation/src/usermanual.rst b/documentation/src/usermanual.rst index 836db5ec8..fd03c6a70 100644 --- a/documentation/src/usermanual.rst +++ b/documentation/src/usermanual.rst @@ -128,6 +128,8 @@ different options: radius untouched. * ``backarc`` naive implementation with inverted arcs connection the straight lines. +* ``dogbone`` reserved for dogbone-style corners (requires radius via + ``--R`` or ``--D``) See also :doc:`burn correction details ` diff --git a/po/boxes.py.pot b/po/boxes.py.pot index 62dbfd603..c234f6b41 100644 --- a/po/boxes.py.pot +++ b/po/boxes.py.pot @@ -826,6 +826,26 @@ msgstr "" msgid "backarc" msgstr "" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "" + +#. parameter name +msgid "R" +msgstr "" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "" + +#. parameter name +msgid "D" +msgstr "" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "" + #. parameter name msgid "burn" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 88b93861c..95377cc49 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -841,6 +841,26 @@ msgstr "角落" msgid "backarc" msgstr "弧后" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "dogbone" + +#. parameter name +msgid "R" +msgstr "R" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "dogbone 内角的半径(毫米)" + +#. parameter name +msgid "D" +msgstr "D" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "dogbone 内角的直径(毫米)" + #. parameter name msgid "burn" msgstr "激光补偿" From 1e528dd94910c8af2c6a8bcd05df67f398ebef78 Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Mon, 29 Sep 2025 21:11:12 -0300 Subject: [PATCH 04/30] Fix dogbone arc orientation --- boxes/__init__.py | 38 ++++++- boxes/drawing.py | 172 +++++++++++++++++++++++++++---- documentation/src/usermanual.rst | 2 + po/boxes.py.pot | 20 ++++ po/zh_CN.po | 20 ++++ 5 files changed, 230 insertions(+), 22 deletions(-) diff --git a/boxes/__init__.py b/boxes/__init__.py index b310a9bbe..a177e160a 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -379,8 +379,14 @@ def __init__(self) -> None: help="print reference rectangle with given length (in mm)(zero to disable) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#reference)") defaultgroup.add_argument( "--inner_corners", action="store", type=str, default="loop", - choices=["loop", "corner", "backarc"], + choices=["loop", "corner", "backarc", "dogbone"], help="style for inner corners [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#inner-corners)") + defaultgroup.add_argument( + "--R", action="store", type=float, default=None, + help="radius for dogbone inner corners (in mm)") + defaultgroup.add_argument( + "--D", action="store", type=float, default=None, + help="diameter for dogbone inner corners (in mm)") defaultgroup.add_argument( "--burn", action="store", type=float, default=0.1, help='burn correction (in mm)(bigger values for tighter fit) [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#burn)') @@ -578,7 +584,31 @@ def cliQuote(s: str) -> str: self.metadata["cli"] = "boxes " + self.__class__.__name__ + " " + " ".join(cliQuote(arg) for arg in args) self.metadata["cli"] = self.metadata["cli"].strip() - for key, value in vars(self.argparser.parse_args(args=args)).items(): + parsed_args = self.argparser.parse_args(args=args) + + dogbone_radius = None + if getattr(parsed_args, "inner_corners", "loop") == "dogbone": + R = getattr(parsed_args, "R", None) + D = getattr(parsed_args, "D", None) + + if R is None and D is None: + self.argparser.error("dogbone inner corners require --R or --D") + + if R is not None: + if R <= 0: + self.argparser.error("--R must be greater than zero") + dogbone_radius = R + + if D is not None: + if D <= 0: + self.argparser.error("--D must be greater than zero") + inferred_radius = D / 2.0 + if dogbone_radius is None: + dogbone_radius = inferred_radius + elif abs(dogbone_radius - inferred_radius) > 1e-9: + self.argparser.error("--R and --D specify different radii") + + for key, value in vars(parsed_args).items(): default = self.argparser.get_default(key) # treat edge settings separately @@ -590,6 +620,8 @@ def cliQuote(s: str) -> str: if value != default: self.non_default_args[key] = value + self.dogbone_radius = dogbone_radius + # Change file ending to format if not given explicitly fileFormat = getattr(self, "format", "svg") if getattr(self, 'output', None) == 'box.svg': @@ -782,7 +814,7 @@ def close(self): self.surface.set_metadata(self.metadata) self.surface.flush() - data = self.surface.finish(self.inner_corners) + data = self.surface.finish(self.inner_corners, getattr(self, "dogbone_radius", None)) data = self.formats.convert(data, self.format) return data diff --git a/boxes/drawing.py b/boxes/drawing.py index b9da3fe74..5fec9933e 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -205,27 +205,161 @@ def transform(self, f, m, invert_y=False): if invert_y: c[3] *= Affine.scale(1, -1) - def faster_edges(self, inner_corners): + def faster_edges(self, inner_corners, dogbone_radius=None): if inner_corners == "backarc": return - for (i, p) in enumerate(self.path): - if p[0] == "C" and i > 1 and i < len(self.path) - 1: - if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": + if inner_corners == "dogbone": + if dogbone_radius is None or dogbone_radius <= 0: + return + + radius = float(dogbone_radius) + offset = math.sqrt(2.0) * radius + + if offset < EPS: + return + + def _normalize(vx, vy): + length = math.hypot(vx, vy) + if length < EPS: + return None + return (vx / length, vy / length) + + def _rotate_cw(vx, vy): + return (vy, -vx) + + def _rotate_ccw(vx, vy): + return (-vy, vx) + + i = 0 + while i < len(self.path): + p = self.path[i] + if ( + p[0] == "C" + and i > 1 + and i < len(self.path) - 1 + and self.path[i - 1][0] == "L" + and self.path[i + 1][0] == "L" + ): p11 = self.path[i - 2][1:3] p12 = self.path[i - 1][1:3] p21 = p[1:3] p22 = self.path[i + 1][1:3] - if (((p12[0]-p21[0])**2 + (p12[1]-p21[1])**2) > - self.params["lw"]**2): + lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22)) + if not lines_intersect: + i += 1 continue - lines_intersect, x, y = line_intersection((p11, p12), (p21, p22)) - if lines_intersect: - self.path[i - 1] = ("L", x, y) - if inner_corners == "loop": - self.path[i] = ("C", x, y, *p12, *p21) - else: - self.path[i] = ("L", x, y) + + prev_len = math.hypot(ox - p11[0], oy - p11[1]) + next_len = math.hypot(p22[0] - ox, p22[1] - oy) + if prev_len <= offset + EPS or next_len <= offset + EPS: + i += 1 + continue + + d_prev = _normalize(ox - p11[0], oy - p11[1]) + d_next = _normalize(p22[0] - ox, p22[1] - oy) + if d_prev is None or d_next is None: + i += 1 + continue + + dot = d_prev[0] * d_next[0] + d_prev[1] * d_next[1] + if abs(dot) > 1e-3: + i += 1 + continue + + turn = d_prev[0] * d_next[1] - d_prev[1] * d_next[0] + if abs(turn) < 1e-9: + i += 1 + continue + + if turn > 0: + inward = (-d_prev[1], d_prev[0]) + inward = (inward[0] + -d_next[1], inward[1] + d_next[0]) + else: + inward = (d_prev[1], -d_prev[0]) + inward = (inward[0] + d_next[1], inward[1] + -d_next[0]) + + n_in = _normalize(*inward) + if n_in is None: + i += 1 + continue + + n_out = (-n_in[0], -n_in[1]) + + cx, cy = ox + n_out[0] * radius, oy + n_out[1] * radius + sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset + ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset + + rad_start = (sx - cx, sy - cy) + rad_end = (ex - cx, ey - cy) + if ( + math.hypot(*rad_start) < EPS + or math.hypot(*rad_end) < EPS + ): + i += 1 + continue + + mid_cw_vec = _rotate_cw(*rad_start) + mid_ccw_vec = _rotate_ccw(*rad_start) + mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) + mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) + score_cw = (mid_cw[0] - ox) * n_out[0] + (mid_cw[1] - oy) * n_out[1] + score_ccw = (mid_ccw[0] - ox) * n_out[0] + (mid_ccw[1] - oy) * n_out[1] + orientation = -1 if score_cw >= score_ccw else 1 + + theta_start = math.atan2(rad_start[1], rad_start[0]) + theta_end = math.atan2(rad_end[1], rad_end[0]) + if orientation == 1: + while theta_end <= theta_start: + theta_end += 2 * math.pi + else: + while theta_end >= theta_start: + theta_end -= 2 * math.pi + + delta = theta_end - theta_start + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2)))) + new_segments: list[tuple[float, ...]] = [] + for seg in range(segments): + t0 = theta_start + delta * (seg / segments) + t1 = theta_start + delta * ((seg + 1) / segments) + k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + cos0, sin0 = math.cos(t0), math.sin(t0) + cos1, sin1 = math.cos(t1), math.sin(t1) + p0x, p0y = cx + radius * cos0, cy + radius * sin0 + p3x, p3y = cx + radius * cos1, cy + radius * sin1 + c1x = p0x - k * radius * sin0 + c1y = p0y + k * radius * cos0 + c2x = p3x + k * radius * sin1 + c2y = p3y - k * radius * cos1 + new_segments.append((p3x, p3y, c1x, c1y, c2x, c2y)) + + self.path[i - 1] = ("L", sx, sy) + self.path[i : i + 1] = [ + ("C", px, py, c1x, c1y, c2x, c2y) + for (px, py, c1x, c1y, c2x, c2y) in new_segments + ] + i += len(new_segments) + continue + + i += 1 + else: + for (i, p) in enumerate(self.path): + if p[0] == "C" and i > 1 and i < len(self.path) - 1: + if self.path[i - 1][0] == "L" and self.path[i + 1][0] == "L": + p11 = self.path[i - 2][1:3] + p12 = self.path[i - 1][1:3] + p21 = p[1:3] + p22 = self.path[i + 1][1:3] + if (((p12[0]-p21[0])**2 + (p12[1]-p21[1])**2) > + self.params["lw"]**2): + continue + lines_intersect, x, y = line_intersection((p11, p12), (p21, p22)) + if lines_intersect: + self.path[i - 1] = ("L", x, y) + if inner_corners == "loop": + self.path[i] = ("C", x, y, *p12, *p21) + else: + self.path[i] = ("L", x, y) # filter duplicates if len(self.path) > 1: # no need to find duplicates if only one element in path self.path = [p for n, p in enumerate(self.path) if p != self.path[n-1]] @@ -487,7 +621,7 @@ def _add_metadata(self, root) -> None: m.tail = '\n' root.insert(0, m) - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): extents = self._adjust_coordinates() w = extents.width * self.scale h = extents.height * self.scale @@ -525,7 +659,7 @@ def finish(self, inner_corners="loop"): x, y = 0, 0 start = None last = None - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) for c in path.path: x0, y0 = x, y C, x, y = c[0:3] @@ -637,7 +771,7 @@ def _metadata(self) -> str: desc += f'%%SettingsUrl short: {md["url_short"].replace("&render=1", "")}\n' return desc - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): extents = self._adjust_coordinates() w = extents.width @@ -681,7 +815,7 @@ def finish(self, inner_corners="loop"): for j, path in enumerate(part.pathes): p = [] x, y = 0, 0 - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) for c in path.path: x0, y0 = x, y @@ -770,7 +904,7 @@ class LBRN2Surface(Surface): 8, # Colors.OUTER_CUT (WHITE) --> Lightburn C08 (grey) ] - def finish(self, inner_corners="loop"): + def finish(self, inner_corners="loop", dogbone_radius=None): if self.dbg: print("LBRN2 save") extents = self._adjust_coordinates() w = extents.width * self.scale @@ -843,7 +977,7 @@ def finish(self, inner_corners="loop"): C = "" start = None last = None - path.faster_edges(inner_corners) + path.faster_edges(inner_corners, dogbone_radius) num = 0 cnt = 1 end = len(path.path) - 1 diff --git a/documentation/src/usermanual.rst b/documentation/src/usermanual.rst index 836db5ec8..fd03c6a70 100644 --- a/documentation/src/usermanual.rst +++ b/documentation/src/usermanual.rst @@ -128,6 +128,8 @@ different options: radius untouched. * ``backarc`` naive implementation with inverted arcs connection the straight lines. +* ``dogbone`` reserved for dogbone-style corners (requires radius via + ``--R`` or ``--D``) See also :doc:`burn correction details ` diff --git a/po/boxes.py.pot b/po/boxes.py.pot index 62dbfd603..c234f6b41 100644 --- a/po/boxes.py.pot +++ b/po/boxes.py.pot @@ -826,6 +826,26 @@ msgstr "" msgid "backarc" msgstr "" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "" + +#. parameter name +msgid "R" +msgstr "" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "" + +#. parameter name +msgid "D" +msgstr "" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "" + #. parameter name msgid "burn" msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index 88b93861c..95377cc49 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -841,6 +841,26 @@ msgstr "角落" msgid "backarc" msgstr "弧后" +#. possible choice for inner_corners +msgid "dogbone" +msgstr "dogbone" + +#. parameter name +msgid "R" +msgstr "R" + +#. help for parameter R +msgid "radius for dogbone inner corners (in mm)" +msgstr "dogbone 内角的半径(毫米)" + +#. parameter name +msgid "D" +msgstr "D" + +#. help for parameter D +msgid "diameter for dogbone inner corners (in mm)" +msgstr "dogbone 内角的直径(毫米)" + #. parameter name msgid "burn" msgstr "激光补偿" From 78e5b5ca94d0b48209a0586786dcca57b3346345 Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Mon, 29 Sep 2025 21:15:30 -0300 Subject: [PATCH 05/30] Revert "Fix dogbone arc orientation" --- boxes/__init__.py | 2 +- boxes/drawing.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/boxes/__init__.py b/boxes/__init__.py index a177e160a..4b5f01693 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -585,7 +585,7 @@ def cliQuote(s: str) -> str: self.metadata["cli"] = self.metadata["cli"].strip() parsed_args = self.argparser.parse_args(args=args) - + dogbone_radius = None if getattr(parsed_args, "inner_corners", "loop") == "dogbone": R = getattr(parsed_args, "R", None) diff --git a/boxes/drawing.py b/boxes/drawing.py index f65f2e3e4..4e9fd061a 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -284,10 +284,7 @@ def _rotate_ccw(vx, vy): i += 1 continue - - n_out = (-n_in[0], -n_in[1]) - - cx, cy = ox + n_out[0] * radius, oy + n_out[1] * radius + cx, cy = ox + n_in[0] * radius, oy + n_in[1] * radius sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset @@ -304,8 +301,8 @@ def _rotate_ccw(vx, vy): mid_ccw_vec = _rotate_ccw(*rad_start) mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) - score_cw = (mid_cw[0] - ox) * n_out[0] + (mid_cw[1] - oy) * n_out[1] - score_ccw = (mid_ccw[0] - ox) * n_out[0] + (mid_ccw[1] - oy) * n_out[1] + score_cw = (mid_cw[0] - ox) * n_in[0] + (mid_cw[1] - oy) * n_in[1] + score_ccw = (mid_ccw[0] - ox) * n_in[0] + (mid_ccw[1] - oy) * n_in[1] orientation = -1 if score_cw >= score_ccw else 1 theta_start = math.atan2(rad_start[1], rad_start[0]) From 9ba1183b7ca336b63a4cc5fe83e346e752dbfdfd Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 30 Sep 2025 22:05:03 -0300 Subject: [PATCH 06/30] a lot \ --- boxes/dogbone.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++ boxes/drawing.py | 130 +---------------------------------------- static/self.js | 119 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 128 deletions(-) create mode 100644 boxes/dogbone.py diff --git a/boxes/dogbone.py b/boxes/dogbone.py new file mode 100644 index 000000000..11d7d98f3 --- /dev/null +++ b/boxes/dogbone.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import math +from typing import Any, Callable, MutableSequence + +PathLike = MutableSequence[Any] + + +def apply_dogbone(path: PathLike, dogbone_radius: Any, eps: float, line_intersection: Callable[[tuple[Any, Any], tuple[Any, Any]], tuple[bool, float | None, float | None]]) -> bool: + """Apply dogbone adjustments to a path in-place. + + Returns False when the caller should abort further processing (e.g. invalid radius). + """ + if dogbone_radius is None: + return False + + radius = float(dogbone_radius) + if radius <= 0: + return False + + offset = math.sqrt(2.0) * radius + if offset < eps: + return False + + def _normalize(vx: float, vy: float) -> tuple[float, float] | None: + length = math.hypot(vx, vy) + if length < eps: + return None + return (vx / length, vy / length) + + def _rotate_cw(vx: float, vy: float) -> tuple[float, float]: + return (vy, -vx) + + def _rotate_ccw(vx: float, vy: float) -> tuple[float, float]: + return (-vy, vx) + + i = 0 + while i < len(path): + segment = path[i] + if ( + segment[0] == "C" + and i > 1 + and i < len(path) - 1 + and path[i - 1][0] == "L" + and path[i + 1][0] == "L" + ): + p11 = path[i - 2][1:3] + p12 = path[i - 1][1:3] + p21 = segment[1:3] + p22 = path[i + 1][1:3] + lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22)) + if not lines_intersect: + i += 1 + continue + + prev_len = math.hypot(ox - p11[0], oy - p11[1]) + next_len = math.hypot(p22[0] - ox, p22[1] - oy) + if prev_len <= offset + eps or next_len <= offset + eps: + i += 1 + continue + + d_prev = _normalize(ox - p11[0], oy - p11[1]) + d_next = _normalize(p22[0] - ox, p22[1] - oy) + if d_prev is None or d_next is None: + i += 1 + continue + + dot = d_prev[0] * d_next[0] + d_prev[1] * d_next[1] + if abs(dot) > 1e-3: + i += 1 + continue + + turn = d_prev[0] * d_next[1] - d_prev[1] * d_next[0] + if abs(turn) < 1e-9: + i += 1 + continue + + if turn > 0: + inward = (-d_prev[1], d_prev[0]) + inward = (inward[0] + -d_next[1], inward[1] + d_next[0]) + else: + inward = (d_prev[1], -d_prev[0]) + inward = (inward[0] + d_next[1], inward[1] + -d_next[0]) + + n_in = _normalize(*inward) + if n_in is None: + i += 1 + continue + + cx, cy = ox + n_in[0] * radius, oy + n_in[1] * radius + sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset + ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset + + rad_start = (sx - cx, sy - cy) + rad_end = (ex - cx, ey - cy) + if ( + math.hypot(*rad_start) < eps + or math.hypot(*rad_end) < eps + ): + i += 1 + continue + + mid_cw_vec = _rotate_cw(*rad_start) + mid_ccw_vec = _rotate_ccw(*rad_start) + mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) + mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) + score_cw = (mid_cw[0] - ox) * n_in[0] + (mid_cw[1] - oy) * n_in[1] + score_ccw = (mid_ccw[0] - ox) * n_in[0] + (mid_ccw[1] - oy) * n_in[1] + orientation = 1 if score_cw >= score_ccw else -1 + + theta_start = math.atan2(rad_start[1], rad_start[0]) + theta_end = math.atan2(rad_end[1], rad_end[0]) + if orientation == 1: + while theta_end <= theta_start: + theta_end += 2 * math.pi + else: + while theta_end >= theta_start: + theta_end -= 2 * math.pi + + delta = theta_end - theta_start + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2)))) + new_segments: list[tuple[float, ...]] = [] + for seg_idx in range(segments): + t0 = theta_start + delta * (seg_idx / segments) + t1 = theta_start + delta * ((seg_idx + 1) / segments) + k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + cos0, sin0 = math.cos(t0), math.sin(t0) + cos1, sin1 = math.cos(t1), math.sin(t1) + p0x, p0y = cx + radius * cos0, cy + radius * sin0 + p3x, p3y = cx + radius * cos1, cy + radius * sin1 + c1x = p0x - k * radius * sin0 + c1y = p0y + k * radius * cos0 + c2x = p3x + k * radius * sin1 + c2y = p3y - k * radius * cos1 + new_segments.append((p3x, p3y, c1x, c1y, c2x, c2y)) + + path[i - 1] = ("L", sx, sy) + path[i : i + 1] = [ + ("C", px, py, c1x, c1y, c2x, c2y) + for (px, py, c1x, c1y, c2x, c2y) in new_segments + ] + i += len(new_segments) + continue + + i += 1 + + return True diff --git a/boxes/drawing.py b/boxes/drawing.py index 4e9fd061a..5e56b5578 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -9,6 +9,7 @@ from affine import Affine from boxes.extents import Extents +from boxes.dogbone import apply_dogbone EPS = 1e-4 PADDING = 10 @@ -210,136 +211,9 @@ def faster_edges(self, inner_corners, dogbone_radius=None): return if inner_corners == "dogbone": - if dogbone_radius is None or dogbone_radius <= 0: + if not apply_dogbone(self.path, dogbone_radius, EPS, line_intersection): return - radius = float(dogbone_radius) - offset = math.sqrt(2.0) * radius - - if offset < EPS: - return - - def _normalize(vx, vy): - length = math.hypot(vx, vy) - if length < EPS: - return None - return (vx / length, vy / length) - - def _rotate_cw(vx, vy): - return (vy, -vx) - - def _rotate_ccw(vx, vy): - return (-vy, vx) - - i = 0 - while i < len(self.path): - p = self.path[i] - if ( - p[0] == "C" - and i > 1 - and i < len(self.path) - 1 - and self.path[i - 1][0] == "L" - and self.path[i + 1][0] == "L" - ): - p11 = self.path[i - 2][1:3] - p12 = self.path[i - 1][1:3] - p21 = p[1:3] - p22 = self.path[i + 1][1:3] - lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22)) - if not lines_intersect: - i += 1 - continue - - prev_len = math.hypot(ox - p11[0], oy - p11[1]) - next_len = math.hypot(p22[0] - ox, p22[1] - oy) - if prev_len <= offset + EPS or next_len <= offset + EPS: - i += 1 - continue - - d_prev = _normalize(ox - p11[0], oy - p11[1]) - d_next = _normalize(p22[0] - ox, p22[1] - oy) - if d_prev is None or d_next is None: - i += 1 - continue - - dot = d_prev[0] * d_next[0] + d_prev[1] * d_next[1] - if abs(dot) > 1e-3: - i += 1 - continue - - turn = d_prev[0] * d_next[1] - d_prev[1] * d_next[0] - if abs(turn) < 1e-9: - i += 1 - continue - - if turn > 0: - inward = (-d_prev[1], d_prev[0]) - inward = (inward[0] + -d_next[1], inward[1] + d_next[0]) - else: - inward = (d_prev[1], -d_prev[0]) - inward = (inward[0] + d_next[1], inward[1] + -d_next[0]) - - n_in = _normalize(*inward) - if n_in is None: - i += 1 - continue - - cx, cy = ox + n_in[0] * radius, oy + n_in[1] * radius - sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset - ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset - - rad_start = (sx - cx, sy - cy) - rad_end = (ex - cx, ey - cy) - if ( - math.hypot(*rad_start) < EPS - or math.hypot(*rad_end) < EPS - ): - i += 1 - continue - - mid_cw_vec = _rotate_cw(*rad_start) - mid_ccw_vec = _rotate_ccw(*rad_start) - mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) - mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) - score_cw = (mid_cw[0] - ox) * n_in[0] + (mid_cw[1] - oy) * n_in[1] - score_ccw = (mid_ccw[0] - ox) * n_in[0] + (mid_ccw[1] - oy) * n_in[1] - orientation = -1 if score_cw >= score_ccw else 1 - - theta_start = math.atan2(rad_start[1], rad_start[0]) - theta_end = math.atan2(rad_end[1], rad_end[0]) - if orientation == 1: - while theta_end <= theta_start: - theta_end += 2 * math.pi - else: - while theta_end >= theta_start: - theta_end -= 2 * math.pi - - delta = theta_end - theta_start - segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2)))) - new_segments: list[tuple[float, ...]] = [] - for seg in range(segments): - t0 = theta_start + delta * (seg / segments) - t1 = theta_start + delta * ((seg + 1) / segments) - k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) - cos0, sin0 = math.cos(t0), math.sin(t0) - cos1, sin1 = math.cos(t1), math.sin(t1) - p0x, p0y = cx + radius * cos0, cy + radius * sin0 - p3x, p3y = cx + radius * cos1, cy + radius * sin1 - c1x = p0x - k * radius * sin0 - c1y = p0y + k * radius * cos0 - c2x = p3x + k * radius * sin1 - c2y = p3y - k * radius * cos1 - new_segments.append((p3x, p3y, c1x, c1y, c2x, c2y)) - - self.path[i - 1] = ("L", sx, sy) - self.path[i : i + 1] = [ - ("C", px, py, c1x, c1y, c2x, c2y) - for (px, py, c1x, c1y, c2x, c2y) in new_segments - ] - i += len(new_segments) - continue - - i += 1 else: for (i, p) in enumerate(self.path): if p[0] == "C" and i > 1 and i < len(self.path) - 1: diff --git a/static/self.js b/static/self.js index f281ec01e..186084281 100644 --- a/static/self.js +++ b/static/self.js @@ -95,12 +95,131 @@ function initPage(num_hide = null) { for (let el of t) initThumbnail(el); } + + +function dogboneRowFromCell(cellId) { + const cell = document.getElementById(cellId); + if (!cell) { + return null; + } + return cell.parentElement; +} + +function updateDogboneInputs(mode) { + const showRadius = mode === "radius"; + const showDiameter = mode === "diameter"; + const radiusRow = dogboneRowFromCell("R_id"); + const diameterRow = dogboneRowFromCell("D_id"); + const radiusInput = document.getElementById("R"); + const diameterInput = document.getElementById("D"); + + if (radiusRow) { + radiusRow.style.display = showRadius ? "" : "none"; + } + if (radiusInput) { + radiusInput.disabled = !showRadius; + } + if (diameterRow) { + diameterRow.style.display = showDiameter ? "" : "none"; + } + if (diameterInput) { + diameterInput.disabled = !showDiameter; + } +} + +function ensureDogboneMeasurementRow() { + let row = document.getElementById("dogbone_measurement_row"); + if (row) { + return row; + } + const radiusCell = document.getElementById("R_id"); + if (!radiusCell) { + return null; + } + const radiusRow = radiusCell.parentElement; + if (!radiusRow || !radiusRow.parentElement) { + return null; + } + + row = document.createElement("tr"); + row.id = "dogbone_measurement_row"; + + const labelCell = document.createElement("td"); + labelCell.id = "dogbone_measurement_id"; + const label = document.createElement("label"); + label.setAttribute("for", "dogbone_measurement_select"); + label.textContent = "Dogbone input"; + labelCell.appendChild(label); + row.appendChild(labelCell); + + const inputCell = document.createElement("td"); + const select = document.createElement("select"); + select.id = "dogbone_measurement_select"; + select.setAttribute("aria-labeledby", "dogbone_measurement_id dogbone_measurement_description"); + select.innerHTML = ""; + inputCell.appendChild(select); + row.appendChild(inputCell); + + const descriptionCell = document.createElement("td"); + descriptionCell.id = "dogbone_measurement_description"; + descriptionCell.textContent = "Choose whether to enter the radius or the diameter for dogbone corners."; + row.appendChild(descriptionCell); + + radiusRow.parentElement.insertBefore(row, radiusRow); + + const radiusInput = document.getElementById("R"); + const diameterInput = document.getElementById("D"); + if (diameterInput && diameterInput.value && !(radiusInput && radiusInput.value)) { + select.value = "diameter"; + } + + select.addEventListener("change", () => { + updateDogboneInputs(select.value); + refreshPreview(); + }); + + return row; +} + +function toggleDogboneFields() { + const select = document.getElementById("inner_corners"); + if (!select) { + return; + } + + if (select.value !== "dogbone") { + const measurementRow = document.getElementById("dogbone_measurement_row"); + if (measurementRow) { + measurementRow.style.display = "none"; + } + updateDogboneInputs("none"); + return; + } + + const measurementRow = ensureDogboneMeasurementRow(); + if (measurementRow) { + measurementRow.style.display = ""; + } + + const measurementSelect = document.getElementById("dogbone_measurement_select"); + const mode = measurementSelect ? measurementSelect.value || "radius" : "radius"; + + updateDogboneInputs(mode); +} + + + function initArgsPage(num_hide = null) { initPage(num_hide); const i = document.querySelectorAll("td > input, td > select, td > textarea"); for (let el of i) { el.addEventListener("change", refreshPreview); } + const innerCorners = document.getElementById("inner_corners"); + if (innerCorners) { + innerCorners.addEventListener("change", toggleDogboneFields); + toggleDogboneFields(); + } refreshPreview(); document.getElementById("preview_chk").addEventListener("change", togglePreview); } From 7a989ebc289daa40fd9d2c9c1fb22f93d0e5056a Mon Sep 17 00:00:00 2001 From: Vinicius Date: Wed, 1 Oct 2025 09:54:31 -0300 Subject: [PATCH 07/30] Add kapa-gama function and refactor dogbone code --- boxes/dogbone.py | 116 +++++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 50 deletions(-) diff --git a/boxes/dogbone.py b/boxes/dogbone.py index 11d7d98f3..730c80bc7 100644 --- a/boxes/dogbone.py +++ b/boxes/dogbone.py @@ -1,12 +1,36 @@ -from __future__ import annotations +from __future__ import annotations import math from typing import Any, Callable, MutableSequence +from boxes.vectors import ( + dotproduct, + normalize as normalize_vec, + vadd, + vdiff, + vlength, + vorthogonal, + vscalmul, +) + PathLike = MutableSequence[Any] +Vector = tuple[float, float] +Point = tuple[float, float] + +def kappa_gamma(gamma_degrees: float) -> float: + """Return kappa(gamma) = sqrt(2) + (1 + sqrt(2)) * tan(gamma - 45 degrees).""" + sqrt2 = math.sqrt(2.0) + angle = math.radians(gamma_degrees - 45.0) + return sqrt2 + (1.0 + sqrt2) * math.tan(angle) -def apply_dogbone(path: PathLike, dogbone_radius: Any, eps: float, line_intersection: Callable[[tuple[Any, Any], tuple[Any, Any]], tuple[bool, float | None, float | None]]) -> bool: + +def apply_dogbone( + path: PathLike, + dogbone_radius: Any, + eps: float, + line_intersection: Callable[[tuple[Any, Any], tuple[Any, Any]], tuple[bool, float | None, float | None]], +) -> bool: """Apply dogbone adjustments to a path in-place. Returns False when the caller should abort further processing (e.g. invalid radius). @@ -22,17 +46,10 @@ def apply_dogbone(path: PathLike, dogbone_radius: Any, eps: float, line_intersec if offset < eps: return False - def _normalize(vx: float, vy: float) -> tuple[float, float] | None: - length = math.hypot(vx, vy) - if length < eps: + def _normalize(vec: Vector) -> Vector | None: + if vlength(vec) < eps: return None - return (vx / length, vy / length) - - def _rotate_cw(vx: float, vy: float) -> tuple[float, float]: - return (vy, -vx) - - def _rotate_ccw(vx: float, vy: float) -> tuple[float, float]: - return (-vy, vx) + return normalize_vec(vec) i = 0 while i < len(path): @@ -48,25 +65,29 @@ def _rotate_ccw(vx: float, vy: float) -> tuple[float, float]: p12 = path[i - 1][1:3] p21 = segment[1:3] p22 = path[i + 1][1:3] + lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22)) if not lines_intersect: i += 1 continue - prev_len = math.hypot(ox - p11[0], oy - p11[1]) - next_len = math.hypot(p22[0] - ox, p22[1] - oy) + corner: Point = (ox, oy) + prev_vec = vdiff(p11, corner) + next_vec = vdiff(corner, p22) + + prev_len = vlength(prev_vec) + next_len = vlength(next_vec) if prev_len <= offset + eps or next_len <= offset + eps: i += 1 continue - d_prev = _normalize(ox - p11[0], oy - p11[1]) - d_next = _normalize(p22[0] - ox, p22[1] - oy) + d_prev = _normalize(prev_vec) + d_next = _normalize(next_vec) if d_prev is None or d_next is None: i += 1 continue - dot = d_prev[0] * d_next[0] + d_prev[1] * d_next[1] - if abs(dot) > 1e-3: + if abs(dotproduct(d_prev, d_next)) > 1e-3: i += 1 continue @@ -75,37 +96,30 @@ def _rotate_ccw(vx: float, vy: float) -> tuple[float, float]: i += 1 continue - if turn > 0: - inward = (-d_prev[1], d_prev[0]) - inward = (inward[0] + -d_next[1], inward[1] + d_next[0]) - else: - inward = (d_prev[1], -d_prev[0]) - inward = (inward[0] + d_next[1], inward[1] + -d_next[0]) - - n_in = _normalize(*inward) + sign = 1.0 if turn > 0.0 else -1.0 + inward = vadd( + vscalmul(vorthogonal(d_prev), sign), + vscalmul(vorthogonal(d_next), sign), + ) + n_in = _normalize(inward) if n_in is None: i += 1 continue - cx, cy = ox + n_in[0] * radius, oy + n_in[1] * radius - sx, sy = ox - d_prev[0] * offset, oy - d_prev[1] * offset - ex, ey = ox + d_next[0] * offset, oy + d_next[1] * offset + center = vadd(corner, vscalmul(n_in, radius)) + start_point = vadd(corner, vscalmul(d_prev, -offset)) + end_point = vadd(corner, vscalmul(d_next, offset)) - rad_start = (sx - cx, sy - cy) - rad_end = (ex - cx, ey - cy) - if ( - math.hypot(*rad_start) < eps - or math.hypot(*rad_end) < eps - ): + rad_start = vdiff(center, start_point) + rad_end = vdiff(center, end_point) + if vlength(rad_start) < eps or vlength(rad_end) < eps: i += 1 continue - mid_cw_vec = _rotate_cw(*rad_start) - mid_ccw_vec = _rotate_ccw(*rad_start) - mid_cw = (cx + mid_cw_vec[0], cy + mid_cw_vec[1]) - mid_ccw = (cx + mid_ccw_vec[0], cy + mid_ccw_vec[1]) - score_cw = (mid_cw[0] - ox) * n_in[0] + (mid_cw[1] - oy) * n_in[1] - score_ccw = (mid_ccw[0] - ox) * n_in[0] + (mid_ccw[1] - oy) * n_in[1] + mid_cw = vadd(center, vscalmul(vorthogonal(rad_start), -1.0)) + mid_ccw = vadd(center, vorthogonal(rad_start)) + score_cw = dotproduct(vdiff(corner, mid_cw), n_in) + score_ccw = dotproduct(vdiff(corner, mid_ccw), n_in) orientation = 1 if score_cw >= score_ccw else -1 theta_start = math.atan2(rad_start[1], rad_start[0]) @@ -118,27 +132,29 @@ def _rotate_ccw(vx: float, vy: float) -> tuple[float, float]: theta_end -= 2 * math.pi delta = theta_end - theta_start - segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2)))) + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2.0)))) + new_segments: list[tuple[float, ...]] = [] for seg_idx in range(segments): t0 = theta_start + delta * (seg_idx / segments) t1 = theta_start + delta * ((seg_idx + 1) / segments) k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + cos0, sin0 = math.cos(t0), math.sin(t0) cos1, sin1 = math.cos(t1), math.sin(t1) - p0x, p0y = cx + radius * cos0, cy + radius * sin0 - p3x, p3y = cx + radius * cos1, cy + radius * sin1 + + p0x, p0y = center[0] + radius * cos0, center[1] + radius * sin0 + p3x, p3y = center[0] + radius * cos1, center[1] + radius * sin1 + c1x = p0x - k * radius * sin0 c1y = p0y + k * radius * cos0 c2x = p3x + k * radius * sin1 c2y = p3y - k * radius * cos1 - new_segments.append((p3x, p3y, c1x, c1y, c2x, c2y)) - path[i - 1] = ("L", sx, sy) - path[i : i + 1] = [ - ("C", px, py, c1x, c1y, c2x, c2y) - for (px, py, c1x, c1y, c2x, c2y) in new_segments - ] + new_segments.append(("C", p3x, p3y, c1x, c1y, c2x, c2y)) + + path[i - 1] = ("L", start_point[0], start_point[1]) + path[i : i + 1] = new_segments i += len(new_segments) continue From e23db3b3ba8feecc7c344f1f24e469ec2fb5c512 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 2 Oct 2025 09:51:36 -0300 Subject: [PATCH 08/30] final dogbone corner drawing TODO: fix broken corner wen 2R is bigger than half of the sizes of the edge fix arc segment becoming lines in dxf export --- boxes/dogbone.py | 124 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/boxes/dogbone.py b/boxes/dogbone.py index 730c80bc7..a085583c5 100644 --- a/boxes/dogbone.py +++ b/boxes/dogbone.py @@ -12,17 +12,15 @@ vorthogonal, vscalmul, ) - PathLike = MutableSequence[Any] Vector = tuple[float, float] Point = tuple[float, float] -def kappa_gamma(gamma_degrees: float) -> float: - """Return kappa(gamma) = sqrt(2) + (1 + sqrt(2)) * tan(gamma - 45 degrees).""" +def dogbone_clearance(radius: float) -> float: + """Return c = R * (1 + sqrt(2)/2 + sqrt(5/2 - sqrt(2))).""" sqrt2 = math.sqrt(2.0) - angle = math.radians(gamma_degrees - 45.0) - return sqrt2 + (1.0 + sqrt2) * math.tan(angle) + return radius * (1.0 + sqrt2 / 2.0 + math.sqrt(2.5 - sqrt2)) def apply_dogbone( @@ -42,15 +40,46 @@ def apply_dogbone( if radius <= 0: return False - offset = math.sqrt(2.0) * radius + sqrt2 = math.sqrt(2.0) + offset = sqrt2 * radius if offset < eps: return False + sqrt_inner = math.sqrt(2.5 - sqrt2) + clearance = dogbone_clearance(radius) + prev_clearance = clearance - radius + end_offset_next = (radius / 2.0) * (sqrt2 / 2.0 - 1.0) + end_offset_prev = (radius / 2.0) * (sqrt2 + sqrt_inner) + def _normalize(vec: Vector) -> Vector | None: if vlength(vec) < eps: return None return normalize_vec(vec) + from boxes.drawing import Context, Surface + + def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> list[tuple[float, ...]]: + radius = vlength(vdiff(center, start)) + if radius < eps or vlength(vdiff(center, end)) < eps: + return [] + angle_start = math.atan2(start[1] - center[1], start[0] - center[0]) + angle_end = math.atan2(end[1] - center[1], end[0] - center[0]) + full_turn = 2.0 * math.pi + if orientation > 0: + while angle_end <= angle_start + eps: + angle_end += full_turn + else: + while angle_end >= angle_start - eps: + angle_end -= full_turn + surface = Surface() + ctx = Context(surface) + ctx.move_to(*start) + (ctx.arc if orientation > 0 else ctx.arc_negative)(center[0], center[1], radius, angle_start, angle_end) + ctx.stroke() + parts = surface.parts + if not parts or not parts[0].pathes: + return [] + return [tuple(cmd) for cmd in parts[0].pathes[-1].path if cmd[0] == 'C'] i = 0 while i < len(path): segment = path[i] @@ -106,12 +135,40 @@ def _normalize(vec: Vector) -> Vector | None: i += 1 continue + axis_prev = (-d_prev[0], -d_prev[1]) + axis_next = d_next + center = vadd(corner, vscalmul(n_in, radius)) - start_point = vadd(corner, vscalmul(d_prev, -offset)) - end_point = vadd(corner, vscalmul(d_next, offset)) + main_end = vadd(corner, vscalmul(d_next, offset)) + transition_point = vadd( + corner, + vadd( + vscalmul(axis_next, end_offset_next), + vscalmul(axis_prev, end_offset_prev), + ), + ) + axis_prev_post = axis_next + axis_next_post = axis_prev + transition_point_next = vadd( + corner, + vadd( + vscalmul(axis_next_post, end_offset_next), + vscalmul(axis_prev_post, end_offset_prev), + ), + ) + new_arc_start = vadd(corner, vscalmul(axis_prev, prev_clearance)) + new_arc_center = vadd( + corner, + vadd(vscalmul(axis_prev, prev_clearance), vscalmul(axis_next, -radius)), + ) + new_arc_end = vadd(corner, vscalmul(axis_prev_post, prev_clearance)) + new_arc_center_next = vadd( + corner, + vadd(vscalmul(axis_prev_post, prev_clearance), vscalmul(axis_next_post, -radius)), + ) - rad_start = vdiff(center, start_point) - rad_end = vdiff(center, end_point) + rad_start = vdiff(center, transition_point) + rad_end = vdiff(center, main_end) if vlength(rad_start) < eps or vlength(rad_end) < eps: i += 1 continue @@ -121,41 +178,28 @@ def _normalize(vec: Vector) -> Vector | None: score_cw = dotproduct(vdiff(corner, mid_cw), n_in) score_ccw = dotproduct(vdiff(corner, mid_ccw), n_in) orientation = 1 if score_cw >= score_ccw else -1 + orientation2 = -orientation - theta_start = math.atan2(rad_start[1], rad_start[0]) - theta_end = math.atan2(rad_end[1], rad_end[0]) - if orientation == 1: - while theta_end <= theta_start: - theta_end += 2 * math.pi - else: - while theta_end >= theta_start: - theta_end -= 2 * math.pi - - delta = theta_end - theta_start - segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2.0)))) - - new_segments: list[tuple[float, ...]] = [] - for seg_idx in range(segments): - t0 = theta_start + delta * (seg_idx / segments) - t1 = theta_start + delta * ((seg_idx + 1) / segments) - k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) - - cos0, sin0 = math.cos(t0), math.sin(t0) - cos1, sin1 = math.cos(t1), math.sin(t1) + pre_segments = arc_segments(new_arc_center, new_arc_start, transition_point, orientation2) + if not pre_segments: + i += 1 + continue - p0x, p0y = center[0] + radius * cos0, center[1] + radius * sin0 - p3x, p3y = center[0] + radius * cos1, center[1] + radius * sin1 + main_segments = arc_segments(center, transition_point, transition_point_next, orientation) + if not main_segments: + i += 1 + continue - c1x = p0x - k * radius * sin0 - c1y = p0y + k * radius * cos0 - c2x = p3x + k * radius * sin1 - c2y = p3y - k * radius * cos1 + post_segments = arc_segments(new_arc_center_next, transition_point_next, new_arc_end, orientation2) + if not post_segments: + i += 1 + continue - new_segments.append(("C", p3x, p3y, c1x, c1y, c2x, c2y)) + combined_segments = pre_segments + main_segments + post_segments - path[i - 1] = ("L", start_point[0], start_point[1]) - path[i : i + 1] = new_segments - i += len(new_segments) + path[i - 1] = ("L", new_arc_start[0], new_arc_start[1]) + path[i : i + 1] = combined_segments + i += len(combined_segments) continue i += 1 From 2345ab276cc9a97ae258e9334feb323f715a53dc Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 7 Oct 2025 22:09:11 -0300 Subject: [PATCH 09/30] Passei o pipeline a tratar arcos nativos, o que deixa o dogbone mais limpo e libera um exportador DXF interno. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Em boxes/dogbone.py:63 e boxes/dogbone.py:209 normalizei ângulos e troquei a geração de splines por comandos 'A' completos, atualizando a substituição no caminho para operar só com listas e sem depender de Context. Em boxes/drawing.py:17, boxes/drawing.py:151 e boxes/drawing.py:285 adicionei utilitários para arcos, centralizei o pré-processamento de caminhos em Surface.prepare_paths e ensinei Path.extents/transform a recalcular bounding boxes e orientações quando aparecem comandos 'A'. Em boxes/drawing.py:662, boxes/drawing.py:832 e boxes/drawing.py:1177 adaptei SVG, PostScript e LBRN2: SVG/LBRN2 expandem arcos para curvas quando necessário e o backend PS agora emite arc/arcn nativos. boxes/drawing.py:892 introduz DXFSurface, que converte cada comando em entidades LINE/ARC/TEXT e escreve um DXF AC1009 completo sem depender do pstoedit. boxes/formats.py:22, boxes/formats.py:30, boxes/formats.py:65 e boxes/formats.py:72 passaram a registrar o formato 'dxf' como base e a instanciar o novo DXFSurface diretamente. Sugestão: 1) gerar um DXF e abrir no CAD de referência para validar os arcos; 2) exportar SVG/PS para garantir que a expansão dos arcos mantém o contorno esperado. --- boxes/dogbone.py | 75 +++++----- boxes/drawing.py | 361 ++++++++++++++++++++++++++++++++++++++++++++--- boxes/formats.py | 12 +- 3 files changed, 392 insertions(+), 56 deletions(-) diff --git a/boxes/dogbone.py b/boxes/dogbone.py index a085583c5..2e21d1e01 100644 --- a/boxes/dogbone.py +++ b/boxes/dogbone.py @@ -12,6 +12,8 @@ vorthogonal, vscalmul, ) + +# Utilities for inserting dogbone reliefs into tool paths. PathLike = MutableSequence[Any] Vector = tuple[float, float] Point = tuple[float, float] @@ -19,6 +21,7 @@ def dogbone_clearance(radius: float) -> float: """Return c = R * (1 + sqrt(2)/2 + sqrt(5/2 - sqrt(2))).""" + # Closed form of the clearance distance used by Boxes.py. sqrt2 = math.sqrt(2.0) return radius * (1.0 + sqrt2 / 2.0 + math.sqrt(2.5 - sqrt2)) @@ -45,6 +48,7 @@ def apply_dogbone( if offset < eps: return False + # These offsets define where the auxiliary arcs start and end relative to the corner. sqrt_inner = math.sqrt(2.5 - sqrt2) clearance = dogbone_clearance(radius) prev_clearance = clearance - radius @@ -56,30 +60,34 @@ def _normalize(vec: Vector) -> Vector | None: return None return normalize_vec(vec) - from boxes.drawing import Context, Surface + def _normalize_angles(start_angle: float, end_angle: float, orientation: int) -> tuple[float, float]: + if orientation > 0: + while end_angle <= start_angle + eps: + end_angle += 2 * math.pi + else: + while end_angle >= start_angle - eps: + end_angle -= 2 * math.pi + return start_angle, end_angle - def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> list[tuple[float, ...]]: + def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> list[Any] | None: radius = vlength(vdiff(center, start)) if radius < eps or vlength(vdiff(center, end)) < eps: - return [] - angle_start = math.atan2(start[1] - center[1], start[0] - center[0]) - angle_end = math.atan2(end[1] - center[1], end[0] - center[0]) - full_turn = 2.0 * math.pi - if orientation > 0: - while angle_end <= angle_start + eps: - angle_end += full_turn - else: - while angle_end >= angle_start - eps: - angle_end -= full_turn - surface = Surface() - ctx = Context(surface) - ctx.move_to(*start) - (ctx.arc if orientation > 0 else ctx.arc_negative)(center[0], center[1], radius, angle_start, angle_end) - ctx.stroke() - parts = surface.parts - if not parts or not parts[0].pathes: - return [] - return [tuple(cmd) for cmd in parts[0].pathes[-1].path if cmd[0] == 'C'] + return None + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + start_angle, end_angle = _normalize_angles(start_angle, end_angle, orientation) + return [ + "A", + end[0], + end[1], + center[0], + center[1], + radius, + start_angle, + end_angle, + orientation, + ] + i = 0 while i < len(path): segment = path[i] @@ -90,6 +98,7 @@ def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> l and path[i - 1][0] == "L" and path[i + 1][0] == "L" ): + # Work on inner corners expressed as line-curve-line. p11 = path[i - 2][1:3] p12 = path[i - 1][1:3] p21 = segment[1:3] @@ -106,7 +115,7 @@ def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> l prev_len = vlength(prev_vec) next_len = vlength(next_vec) - if prev_len <= offset + eps or next_len <= offset + eps: + if prev_len <= eps or next_len <= eps: i += 1 continue @@ -116,6 +125,7 @@ def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> l i += 1 continue + # Skip corners that are not approximately orthogonal. if abs(dotproduct(d_prev, d_next)) > 1e-3: i += 1 continue @@ -135,6 +145,7 @@ def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> l i += 1 continue + # Precompute axes to place the transition and finishing arcs. axis_prev = (-d_prev[0], -d_prev[1]) axis_next = d_next @@ -180,26 +191,24 @@ def arc_segments(center: Point, start: Point, end: Point, orientation: int) -> l orientation = 1 if score_cw >= score_ccw else -1 orientation2 = -orientation - pre_segments = arc_segments(new_arc_center, new_arc_start, transition_point, orientation2) - if not pre_segments: + pre_arc = _arc_command(new_arc_center, new_arc_start, transition_point, orientation2) + if pre_arc is None: i += 1 continue - main_segments = arc_segments(center, transition_point, transition_point_next, orientation) - if not main_segments: + main_arc = _arc_command(center, transition_point, transition_point_next, orientation) + if main_arc is None: i += 1 continue - post_segments = arc_segments(new_arc_center_next, transition_point_next, new_arc_end, orientation2) - if not post_segments: + post_arc = _arc_command(new_arc_center_next, transition_point_next, new_arc_end, orientation2) + if post_arc is None: i += 1 continue - combined_segments = pre_segments + main_segments + post_segments - - path[i - 1] = ("L", new_arc_start[0], new_arc_start[1]) - path[i : i + 1] = combined_segments - i += len(combined_segments) + path[i - 1] = ["L", new_arc_start[0], new_arc_start[1]] + path[i : i + 1] = [pre_arc, main_arc, post_arc] + i += 3 continue i += 1 diff --git a/boxes/drawing.py b/boxes/drawing.py index 5e56b5578..823103e2b 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -12,6 +12,77 @@ from boxes.dogbone import apply_dogbone EPS = 1e-4 + + +def normalize_arc_angles(start_angle: float, end_angle: float, orientation: int) -> tuple[float, float]: + if orientation > 0: + while end_angle <= start_angle + EPS: + end_angle += 2 * math.pi + else: + while end_angle >= start_angle - EPS: + end_angle -= 2 * math.pi + return start_angle, end_angle + + +def arc_to_cubic_segments(cx: float, cy: float, radius: float, start_angle: float, end_angle: float) -> list[list[float]]: + delta = end_angle - start_angle + if abs(delta) < 1e-12: + return [] + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2.0)))) + result: list[list[float]] = [] + for seg_idx in range(segments): + t0 = start_angle + delta * (seg_idx / segments) + t1 = start_angle + delta * ((seg_idx + 1) / segments) + k = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + + cos0, sin0 = math.cos(t0), math.sin(t0) + cos1, sin1 = math.cos(t1), math.sin(t1) + + p0x = cx + radius * cos0 + p0y = cy + radius * sin0 + p3x = cx + radius * cos1 + p3y = cy + radius * sin1 + + c1x = p0x - k * radius * sin0 + c1y = p0y + k * radius * cos0 + c2x = p3x + k * radius * sin1 + c2y = p3y - k * radius * cos1 + + result.append(['C', p3x, p3y, c1x, c1y, c2x, c2y]) + return result + + +def angle_on_arc(angle: float, start: float, end: float, orientation: int) -> float | None: + full_turn = 2 * math.pi + if orientation > 0: + k = math.ceil((start - angle) / full_turn) + candidate = angle + full_turn * k + if start - EPS <= candidate <= end + EPS: + return candidate + else: + k = math.floor((start - angle) / full_turn) + candidate = angle + full_turn * k + if end - EPS <= candidate <= start + EPS: + return candidate + return None + + +def expand_path_arcs(commands): + expanded = [] + current = None + for cmd in commands: + letter = cmd[0] + if letter == 'A': + _, ex, ey, cx, cy, radius, start_angle, end_angle, orientation = cmd + segments = arc_to_cubic_segments(cx, cy, radius, start_angle, end_angle) + for seg in segments: + expanded.append(seg) + current = (ex, ey) + else: + expanded.append(cmd) + if letter != 'T': + current = (cmd[1], cmd[2]) + return expanded PADDING = 10 RANDOMIZE_COLORS = False # enable to ease check for continuity of paths @@ -77,6 +148,12 @@ def _adjust_coordinates(self): return Extents(0, 0, extents.width * self.scale, extents.height * self.scale) + def prepare_paths(self, inner_corners, dogbone_radius=None): + for part in self.parts: + for path in part.pathes: + path.faster_edges(inner_corners, dogbone_radius) + return self._adjust_coordinates() + def render(self, renderer): renderer.init(**self.args) for p in self.parts: @@ -191,20 +268,71 @@ def extents(self): for y in (0, h): x_, y_ = m * (x, y) e.add(x_, y_) + elif p[0] == 'A': + _, _, _, cx, cy, radius, ang_start, ang_end, orientation = p + radius = abs(radius) + angles = {ang_start, ang_end} + for base in (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0): + candidate = angle_on_arc(base, ang_start, ang_end, orientation) + if candidate is not None: + angles.add(candidate) + for angle in angles: + px = cx + radius * math.cos(angle) + py = cy + radius * math.sin(angle) + e.add(px, py) return e def transform(self, f, m, invert_y=False): self.params["lw"] *= f - for c in self.path: + current = None + for idx, c in enumerate(self.path): + if isinstance(c, tuple): + c = list(c) + self.path[idx] = c C = c[0] + if C == "M": + c[1], c[2] = m * (c[1], c[2]) + current = (c[1], c[2]) + continue c[1], c[2] = m * (c[1], c[2]) - if C == 'C': + if C == 'L': + current = (c[1], c[2]) + elif C == 'C': c[3], c[4] = m * (c[3], c[4]) c[5], c[6] = m * (c[5], c[6]) - if C == "T": + current = (c[1], c[2]) + elif C == 'A': + cx0, cy0 = c[3], c[4] + r0 = c[5] + start0 = c[6] + end0 = c[7] + orient0 = c[8] + c[3], c[4] = m * (cx0, cy0) + c[5] = abs(r0) * f + cx, cy = c[3], c[4] + radius = c[5] + if current is None: + sx0 = cx0 + abs(r0) * math.cos(start0) + sy0 = cy0 + abs(r0) * math.sin(start0) + current = m * (sx0, sy0) + start_vec = (current[0] - cx, current[1] - cy) + end_vec = (c[1] - cx, c[2] - cy) + start_angle = math.atan2(start_vec[1], start_vec[0]) + end_angle = math.atan2(end_vec[1], end_vec[0]) + cross = start_vec[0] * end_vec[1] - start_vec[1] * end_vec[0] + if abs(cross) < EPS: + orientation = 1 if orient0 >= 0 else -1 + else: + orientation = 1 if cross > 0 else -1 + start_angle, end_angle = normalize_arc_angles(start_angle, end_angle, orientation) + c[6], c[7], c[8] = start_angle, end_angle, orientation + current = (c[1], c[2]) + elif C == "T": c[3] = m * c[3] if invert_y: c[3] *= Affine.scale(1, -1) + else: + current = (c[1], c[2]) def faster_edges(self, inner_corners, dogbone_radius=None): if inner_corners == "backarc": @@ -494,7 +622,7 @@ def _add_metadata(self, root) -> None: root.insert(0, m) def finish(self, inner_corners="loop", dogbone_radius=None): - extents = self._adjust_coordinates() + extents = self.prepare_paths(inner_corners, dogbone_radius) w = extents.width * self.scale h = extents.height * self.scale @@ -531,8 +659,8 @@ def finish(self, inner_corners="loop", dogbone_radius=None): x, y = 0, 0 start = None last = None - path.faster_edges(inner_corners, dogbone_radius) - for c in path.path: + commands = expand_path_arcs(path.path) + for c in commands: x0, y0 = x, y C, x, y = c[0:3] if C == "M": @@ -645,7 +773,7 @@ def _metadata(self) -> str: def finish(self, inner_corners="loop", dogbone_radius=None): - extents = self._adjust_coordinates() + extents = self.prepare_paths(inner_corners, dogbone_radius) w = extents.width h = extents.height @@ -687,9 +815,9 @@ def finish(self, inner_corners="loop", dogbone_radius=None): for j, path in enumerate(part.pathes): p = [] x, y = 0, 0 - path.faster_edges(inner_corners, dogbone_radius) + commands = path.path - for c in path.path: + for c in commands: x0, y0 = x, y C, x, y = c[0:3] if C == "M": @@ -701,6 +829,14 @@ def finish(self, inner_corners="loop", dogbone_radius=None): p.append( f"{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x:.3f} {y:.3f} curveto" ) + elif C == "A": + cx, cy, radius, start_angle, end_angle, orientation = c[3], c[4], c[5], c[6], c[7], c[8] + start_deg = math.degrees(start_angle) + end_deg = math.degrees(end_angle) + cmd = "arc" if orientation > 0 else "arcn" + p.append( + f"{cx:.3f} {cy:.3f} {radius:.3f} {start_deg:.6f} {end_deg:.6f} {cmd}" + ) elif C == "T": m, text, params = c[3:] tm = " ".join(f"{m[i]:.3f}" for i in (0, 3, 1, 4, 2, 5)) @@ -753,6 +889,195 @@ def finish(self, inner_corners="loop", dogbone_radius=None): data.seek(0) return data +class DXFSurface(Surface): + + scale = 1.0 + invert_y = False + + def finish(self, inner_corners="loop", dogbone_radius=None): + extents = self.prepare_paths(inner_corners, dogbone_radius) + entities: list[str] = [] + for part in self.parts: + if not part.pathes: + continue + for path in part.pathes: + entities.extend(self._entities_from_path(path.path)) + + return self._build_dxf(extents, entities) + + @staticmethod + def _pair(container: list[str], code: int, value: Any) -> None: + container.append(f"{code:>3}") + container.append(str(value)) + + @staticmethod + def _format_angle(angle_deg: float) -> float: + angle = angle_deg % 360.0 + if math.isclose(angle, 360.0, abs_tol=1e-9): + angle = 0.0 + return angle + + def _entities_from_path(self, commands): + entities: list[str] = [] + current: tuple[float, float] | None = None + for cmd in commands: + letter = cmd[0] + if letter == "M": + current = (cmd[1], cmd[2]) + elif letter == "L": + target = (cmd[1], cmd[2]) + if current and not points_equal(current[0], current[1], target[0], target[1]): + entities.extend(self._line_entity(current, target)) + current = target + elif letter == "C": + if current is None: + current = (cmd[1], cmd[2]) + continue + control1 = (cmd[3], cmd[4]) + control2 = (cmd[5], cmd[6]) + end_point = (cmd[1], cmd[2]) + prev = current + for point in self._approximate_cubic(current, control1, control2, end_point): + if not points_equal(prev[0], prev[1], point[0], point[1]): + entities.extend(self._line_entity(prev, point)) + prev = point + current = end_point + elif letter == "A": + if current is None: + current = (cmd[1], cmd[2]) + end_point = (cmd[1], cmd[2]) + center = (cmd[3], cmd[4]) + radius = cmd[5] + start_angle = math.degrees(cmd[6]) + end_angle = math.degrees(cmd[7]) + orientation = cmd[8] + if radius > EPS: + if orientation < 0: + start_angle, end_angle = end_angle, start_angle + start_angle = self._format_angle(start_angle) + end_angle = self._format_angle(end_angle) + entities.extend(self._arc_entity(center, radius, start_angle, end_angle)) + current = end_point + elif letter == "T": + text_entities = self._text_entity(cmd) + if text_entities: + entities.extend(text_entities) + return entities + + def _line_entity(self, start, end): + if points_equal(start[0], start[1], end[0], end[1]): + return [] + items: list[str] = [] + self._pair(items, 0, "LINE") + self._pair(items, 8, "0") + self._pair(items, 10, f"{start[0]:.6f}") + self._pair(items, 20, f"{start[1]:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 11, f"{end[0]:.6f}") + self._pair(items, 21, f"{end[1]:.6f}") + self._pair(items, 31, "0.0") + return items + + def _arc_entity(self, center, radius, start_angle, end_angle): + items: list[str] = [] + self._pair(items, 0, "ARC") + self._pair(items, 8, "0") + self._pair(items, 10, f"{center[0]:.6f}") + self._pair(items, 20, f"{center[1]:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 40, f"{abs(radius):.6f}") + self._pair(items, 50, f"{start_angle:.6f}") + self._pair(items, 51, f"{end_angle:.6f}") + return items + + def _text_entity(self, cmd): + _, x, y, _m, text, params = cmd + if not text: + return [] + height = params.get("fs", 10.0) + items: list[str] = [] + self._pair(items, 0, "TEXT") + self._pair(items, 8, "0") + self._pair(items, 10, f"{x:.6f}") + self._pair(items, 20, f"{y:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 40, f"{height:.6f}") + self._pair(items, 1, text) + return items + + def _approximate_cubic(self, p0, p1, p2, p3, steps=12): + result: list[tuple[float, float]] = [] + for step in range(1, steps + 1): + t = step / steps + mt = 1.0 - t + x = ( + mt * mt * mt * p0[0] + + 3 * mt * mt * t * p1[0] + + 3 * mt * t * t * p2[0] + + t * t * t * p3[0] + ) + y = ( + mt * mt * mt * p0[1] + + 3 * mt * mt * t * p1[1] + + 3 * mt * t * t * p2[1] + + t * t * t * p3[1] + ) + result.append((x, y)) + return result + + def _build_dxf(self, extents, entities): + lines: list[str] = [] + add = lambda code, value: self._pair(lines, code, value) + + add(0, "SECTION") + add(2, "HEADER") + add(9, "$ACADVER") + add(1, "AC1009") + add(9, "$INSUNITS") + add(70, 4) + add(9, "$MEASUREMENT") + add(70, 1) + add(9, "$EXTMIN") + add(10, f"{extents.xmin:.6f}") + add(20, f"{extents.ymin:.6f}") + add(30, "0.0") + add(9, "$EXTMAX") + add(10, f"{extents.xmax:.6f}") + add(20, f"{extents.ymax:.6f}") + add(30, "0.0") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "TABLES") + add(0, "TABLE") + add(2, "LAYER") + add(70, 1) + add(0, "LAYER") + add(2, "0") + add(70, 0) + add(62, 7) + add(6, "CONTINUOUS") + add(0, "ENDTAB") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "ENTITIES") + lines.extend(entities) + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "BLOCKS") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "OBJECTS") + add(0, "ENDSEC") + add(0, "EOF") + data = ("\r\n".join(lines) + "\r\n").encode("ascii", "ignore") + buffer = io.BytesIO(data) + buffer.seek(0) + return buffer + class LBRN2Surface(Surface): @@ -778,7 +1103,7 @@ class LBRN2Surface(Surface): def finish(self, inner_corners="loop", dogbone_radius=None): if self.dbg: print("LBRN2 save") - extents = self._adjust_coordinates() + extents = self.prepare_paths(inner_corners, dogbone_radius) w = extents.width * self.scale h = extents.height * self.scale @@ -849,24 +1174,24 @@ def finish(self, inner_corners="loop", dogbone_radius=None): C = "" start = None last = None - path.faster_edges(inner_corners, dogbone_radius) + commands = expand_path_arcs(path.path) num = 0 cnt = 1 - end = len(path.path) - 1 + end = len(commands) - 1 if self.dbg: - for c in path.path: + for c in commands: print ("6",num, c) num += 1 num = 0 - c = path.path[num] + c = commands[num] C, x, y = c[0:3] if self.dbg: print("end:", end) - while num < end or (C == "T" and num <= end): # len(path.path): + while num < end or (C == "T" and num <= end): # len(commands): if self.dbg: print("0", num) - c = path.path[num] + c = commands[num] if self.dbg: print("first: ", num, c) C, x, y = c[0:3] @@ -886,9 +1211,9 @@ def finish(self, inner_corners="loop", dogbone_radius=None): # do something with M done = False bspline = False - while done == False and num < end: # len(path.path): + while done == False and num < end: # len(commands): num += 1 - c = path.path[num] + c = commands[num] if self.dbg: print ("next: ",num, c) C, x, y = c[0:3] if C == "M": diff --git a/boxes/formats.py b/boxes/formats.py index 808f19b10..93182168c 100644 --- a/boxes/formats.py +++ b/boxes/formats.py @@ -19,7 +19,7 @@ import subprocess import tempfile import io -from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface +from boxes.drawing import Context, DXFSurface, LBRN2Surface, PSSurface, SVGSurface class Formats: @@ -27,14 +27,13 @@ class Formats: pstoedit_candidates = ["/usr/bin/pstoedit", "pstoedit", r"C:\Program Files\pstoedit\pstoedit.exe", "pstoedit.exe"] ps2pdf_candidates = ["/usr/bin/ps2pdf", "ps2pdf", "ps2pdf.exe"] - _BASE_FORMATS = ['svg', 'svg_Ponoko', 'ps', 'lbrn2'] + _BASE_FORMATS = ['svg', 'svg_Ponoko', 'ps', 'lbrn2', 'dxf'] formats = { "svg": None, "svg_Ponoko": None, "ps": None, "lbrn2": None, - "dxf": "{pstoedit} -flat 0.1 -f dxf:-mm {input} {output}", "gcode": "{pstoedit} -f gcode {input} {output}", "plt": "{pstoedit} -f hpgl {input} {output}", # "ai": "{pstoedit} -f ps2ai", @@ -64,13 +63,16 @@ def __init__(self) -> None: break def getFormats(self): + base = set(self._BASE_FORMATS) if self.pstoedit: - return sorted(self.formats.keys()) - return self._BASE_FORMATS + base.update(self.formats.keys()) + return sorted(base) def getSurface(self, fmt): if fmt in ("svg", "svg_Ponoko"): surface = SVGSurface() + elif fmt == "dxf": + surface = DXFSurface() elif fmt == "lbrn2": surface = LBRN2Surface() else: From 4d5aaa72f8c758e1075a7e54e5e612e620181856 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 14 Oct 2025 10:53:53 -0300 Subject: [PATCH 10/30] Move DXF surface implementation into new boxes/dxf.py, update existing imports to keep exports stable, and leave drawing.py focused on shared rendering helpers. --- boxes/drawing.py | 187 +------------------------------------------ boxes/dxf.py | 203 +++++++++++++++++++++++++++++++++++++++++++++++ boxes/formats.py | 3 +- 3 files changed, 206 insertions(+), 187 deletions(-) create mode 100644 boxes/dxf.py diff --git a/boxes/drawing.py b/boxes/drawing.py index 823103e2b..997a82ee6 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -889,194 +889,9 @@ def finish(self, inner_corners="loop", dogbone_radius=None): data.seek(0) return data -class DXFSurface(Surface): - scale = 1.0 - invert_y = False +from .dxf import DXFSurface # noqa: E402 - def finish(self, inner_corners="loop", dogbone_radius=None): - extents = self.prepare_paths(inner_corners, dogbone_radius) - entities: list[str] = [] - for part in self.parts: - if not part.pathes: - continue - for path in part.pathes: - entities.extend(self._entities_from_path(path.path)) - - return self._build_dxf(extents, entities) - - @staticmethod - def _pair(container: list[str], code: int, value: Any) -> None: - container.append(f"{code:>3}") - container.append(str(value)) - - @staticmethod - def _format_angle(angle_deg: float) -> float: - angle = angle_deg % 360.0 - if math.isclose(angle, 360.0, abs_tol=1e-9): - angle = 0.0 - return angle - - def _entities_from_path(self, commands): - entities: list[str] = [] - current: tuple[float, float] | None = None - for cmd in commands: - letter = cmd[0] - if letter == "M": - current = (cmd[1], cmd[2]) - elif letter == "L": - target = (cmd[1], cmd[2]) - if current and not points_equal(current[0], current[1], target[0], target[1]): - entities.extend(self._line_entity(current, target)) - current = target - elif letter == "C": - if current is None: - current = (cmd[1], cmd[2]) - continue - control1 = (cmd[3], cmd[4]) - control2 = (cmd[5], cmd[6]) - end_point = (cmd[1], cmd[2]) - prev = current - for point in self._approximate_cubic(current, control1, control2, end_point): - if not points_equal(prev[0], prev[1], point[0], point[1]): - entities.extend(self._line_entity(prev, point)) - prev = point - current = end_point - elif letter == "A": - if current is None: - current = (cmd[1], cmd[2]) - end_point = (cmd[1], cmd[2]) - center = (cmd[3], cmd[4]) - radius = cmd[5] - start_angle = math.degrees(cmd[6]) - end_angle = math.degrees(cmd[7]) - orientation = cmd[8] - if radius > EPS: - if orientation < 0: - start_angle, end_angle = end_angle, start_angle - start_angle = self._format_angle(start_angle) - end_angle = self._format_angle(end_angle) - entities.extend(self._arc_entity(center, radius, start_angle, end_angle)) - current = end_point - elif letter == "T": - text_entities = self._text_entity(cmd) - if text_entities: - entities.extend(text_entities) - return entities - - def _line_entity(self, start, end): - if points_equal(start[0], start[1], end[0], end[1]): - return [] - items: list[str] = [] - self._pair(items, 0, "LINE") - self._pair(items, 8, "0") - self._pair(items, 10, f"{start[0]:.6f}") - self._pair(items, 20, f"{start[1]:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 11, f"{end[0]:.6f}") - self._pair(items, 21, f"{end[1]:.6f}") - self._pair(items, 31, "0.0") - return items - - def _arc_entity(self, center, radius, start_angle, end_angle): - items: list[str] = [] - self._pair(items, 0, "ARC") - self._pair(items, 8, "0") - self._pair(items, 10, f"{center[0]:.6f}") - self._pair(items, 20, f"{center[1]:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 40, f"{abs(radius):.6f}") - self._pair(items, 50, f"{start_angle:.6f}") - self._pair(items, 51, f"{end_angle:.6f}") - return items - - def _text_entity(self, cmd): - _, x, y, _m, text, params = cmd - if not text: - return [] - height = params.get("fs", 10.0) - items: list[str] = [] - self._pair(items, 0, "TEXT") - self._pair(items, 8, "0") - self._pair(items, 10, f"{x:.6f}") - self._pair(items, 20, f"{y:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 40, f"{height:.6f}") - self._pair(items, 1, text) - return items - - def _approximate_cubic(self, p0, p1, p2, p3, steps=12): - result: list[tuple[float, float]] = [] - for step in range(1, steps + 1): - t = step / steps - mt = 1.0 - t - x = ( - mt * mt * mt * p0[0] - + 3 * mt * mt * t * p1[0] - + 3 * mt * t * t * p2[0] - + t * t * t * p3[0] - ) - y = ( - mt * mt * mt * p0[1] - + 3 * mt * mt * t * p1[1] - + 3 * mt * t * t * p2[1] - + t * t * t * p3[1] - ) - result.append((x, y)) - return result - - def _build_dxf(self, extents, entities): - lines: list[str] = [] - add = lambda code, value: self._pair(lines, code, value) - - add(0, "SECTION") - add(2, "HEADER") - add(9, "$ACADVER") - add(1, "AC1009") - add(9, "$INSUNITS") - add(70, 4) - add(9, "$MEASUREMENT") - add(70, 1) - add(9, "$EXTMIN") - add(10, f"{extents.xmin:.6f}") - add(20, f"{extents.ymin:.6f}") - add(30, "0.0") - add(9, "$EXTMAX") - add(10, f"{extents.xmax:.6f}") - add(20, f"{extents.ymax:.6f}") - add(30, "0.0") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "TABLES") - add(0, "TABLE") - add(2, "LAYER") - add(70, 1) - add(0, "LAYER") - add(2, "0") - add(70, 0) - add(62, 7) - add(6, "CONTINUOUS") - add(0, "ENDTAB") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "ENTITIES") - lines.extend(entities) - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "BLOCKS") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "OBJECTS") - add(0, "ENDSEC") - add(0, "EOF") - data = ("\r\n".join(lines) + "\r\n").encode("ascii", "ignore") - buffer = io.BytesIO(data) - buffer.seek(0) - return buffer class LBRN2Surface(Surface): diff --git a/boxes/dxf.py b/boxes/dxf.py new file mode 100644 index 000000000..85e9cc053 --- /dev/null +++ b/boxes/dxf.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import io +import math +from typing import Any + +from .drawing import EPS, Surface, points_equal + +__all__ = ["DXFSurface"] + + +class DXFSurface(Surface): + """Surface capable of turning drawing commands into a DXF byte stream.""" + + scale = 1.0 + invert_y = False + + def finish(self, inner_corners: str = "loop", dogbone_radius=None): + extents = self.prepare_paths(inner_corners, dogbone_radius) + entities: list[str] = [] + for part in self.parts: + if not part.pathes: + continue + for path in part.pathes: + entities.extend(self._entities_from_path(path.path)) + + return self._build_dxf(extents, entities) + + @staticmethod + def _pair(container: list[str], code: int, value: Any) -> None: + container.append(f"{code:>3}") + container.append(str(value)) + + @staticmethod + def _format_angle(angle_deg: float) -> float: + angle = angle_deg % 360.0 + if math.isclose(angle, 360.0, abs_tol=1e-9): + angle = 0.0 + return angle + + def _entities_from_path(self, commands): + entities: list[str] = [] + current: tuple[float, float] | None = None + for cmd in commands: + letter = cmd[0] + if letter == "M": + current = (cmd[1], cmd[2]) + elif letter == "L": + target = (cmd[1], cmd[2]) + if current and not points_equal(current[0], current[1], target[0], target[1]): + entities.extend(self._line_entity(current, target)) + current = target + elif letter == "C": + if current is None: + current = (cmd[1], cmd[2]) + continue + control1 = (cmd[3], cmd[4]) + control2 = (cmd[5], cmd[6]) + end_point = (cmd[1], cmd[2]) + prev = current + for point in self._approximate_cubic(current, control1, control2, end_point): + if not points_equal(prev[0], prev[1], point[0], point[1]): + entities.extend(self._line_entity(prev, point)) + prev = point + current = end_point + elif letter == "A": + if current is None: + current = (cmd[1], cmd[2]) + end_point = (cmd[1], cmd[2]) + center = (cmd[3], cmd[4]) + radius = cmd[5] + start_angle = math.degrees(cmd[6]) + end_angle = math.degrees(cmd[7]) + orientation = cmd[8] + if radius > EPS: + if orientation < 0: + start_angle, end_angle = end_angle, start_angle + start_angle = self._format_angle(start_angle) + end_angle = self._format_angle(end_angle) + entities.extend(self._arc_entity(center, radius, start_angle, end_angle)) + current = end_point + elif letter == "T": + text_entities = self._text_entity(cmd) + if text_entities: + entities.extend(text_entities) + return entities + + def _line_entity(self, start, end): + if points_equal(start[0], start[1], end[0], end[1]): + return [] + items: list[str] = [] + self._pair(items, 0, "LINE") + self._pair(items, 8, "0") + self._pair(items, 10, f"{start[0]:.6f}") + self._pair(items, 20, f"{start[1]:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 11, f"{end[0]:.6f}") + self._pair(items, 21, f"{end[1]:.6f}") + self._pair(items, 31, "0.0") + return items + + def _arc_entity(self, center, radius, start_angle, end_angle): + items: list[str] = [] + self._pair(items, 0, "ARC") + self._pair(items, 8, "0") + self._pair(items, 10, f"{center[0]:.6f}") + self._pair(items, 20, f"{center[1]:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 40, f"{abs(radius):.6f}") + self._pair(items, 50, f"{start_angle:.6f}") + self._pair(items, 51, f"{end_angle:.6f}") + return items + + def _text_entity(self, cmd): + _, x, y, _m, text, params = cmd + if not text: + return [] + height = params.get("fs", 10.0) + items: list[str] = [] + self._pair(items, 0, "TEXT") + self._pair(items, 8, "0") + self._pair(items, 10, f"{x:.6f}") + self._pair(items, 20, f"{y:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 40, f"{height:.6f}") + self._pair(items, 1, text) + return items + + def _approximate_cubic(self, p0, p1, p2, p3, steps=12): + result: list[tuple[float, float]] = [] + for step in range(1, steps + 1): + t = step / steps + mt = 1.0 - t + x = ( + mt * mt * mt * p0[0] + + 3 * mt * mt * t * p1[0] + + 3 * mt * t * t * p2[0] + + t * t * t * p3[0] + ) + y = ( + mt * mt * mt * p0[1] + + 3 * mt * mt * t * p1[1] + + 3 * mt * t * t * p2[1] + + t * t * t * p3[1] + ) + result.append((x, y)) + return result + + def _build_dxf(self, extents, entities): + lines: list[str] = [] + + def add(code: int, value: Any) -> None: + self._pair(lines, code, value) + + add(0, "SECTION") + add(2, "HEADER") + add(9, "$ACADVER") + add(1, "AC1009") + add(9, "$INSUNITS") + add(70, 4) + add(9, "$MEASUREMENT") + add(70, 1) + add(9, "$EXTMIN") + add(10, f"{extents.xmin:.6f}") + add(20, f"{extents.ymin:.6f}") + add(30, "0.0") + add(9, "$EXTMAX") + add(10, f"{extents.xmax:.6f}") + add(20, f"{extents.ymax:.6f}") + add(30, "0.0") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "TABLES") + add(0, "TABLE") + add(2, "LAYER") + add(70, 1) + add(0, "LAYER") + add(2, "0") + add(70, 0) + add(62, 7) + add(6, "CONTINUOUS") + add(0, "ENDTAB") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "ENTITIES") + lines.extend(entities) + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "BLOCKS") + add(0, "ENDSEC") + + add(0, "SECTION") + add(2, "OBJECTS") + add(0, "ENDSEC") + add(0, "EOF") + + data = ("\r\n".join(lines) + "\r\n").encode("ascii", "ignore") + buffer = io.BytesIO(data) + buffer.seek(0) + return buffer diff --git a/boxes/formats.py b/boxes/formats.py index 93182168c..a1439f47c 100644 --- a/boxes/formats.py +++ b/boxes/formats.py @@ -19,7 +19,8 @@ import subprocess import tempfile import io -from boxes.drawing import Context, DXFSurface, LBRN2Surface, PSSurface, SVGSurface +from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface +from boxes.dxf import DXFSurface class Formats: From 70a194672daacc3a3843a138747c4e6eefb80591 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 14 Oct 2025 22:14:16 -0300 Subject: [PATCH 11/30] =?UTF-8?q?Atualizei=20boxes/dogbone.py=20para=20faz?= =?UTF-8?q?er=20um=20p=C3=B3s-processamento=20autom=C3=A1tico=20da=20trilh?= =?UTF-8?q?a=20ap=C3=B3s=20apply=5Fdogbone,=20detectando=20interse=C3=A7?= =?UTF-8?q?=C3=B5es=20entre=20arcos=20consecutivos=20(tanto=20p=C3=B3s/pr?= =?UTF-8?q?=C3=A9=20quanto=20arcos=20principais)=20e=20ajustando=20seus=20?= =?UTF-8?q?pontos=20de=20in=C3=ADcio/fim=20para=20que=20se=20encontrem=20n?= =?UTF-8?q?o=20ponto=20correto;=20os=20segmentos=20intermedi=C3=A1rios=20r?= =?UTF-8?q?edundantes=20s=C3=A3o=20removidos=20e=20os=20=C3=A2ngulos=20rec?= =?UTF-8?q?alculados=20com=20as=20novas=20geometrias.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- boxes/dogbone.py | 213 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 203 insertions(+), 10 deletions(-) diff --git a/boxes/dogbone.py b/boxes/dogbone.py index 2e21d1e01..35b689423 100644 --- a/boxes/dogbone.py +++ b/boxes/dogbone.py @@ -18,6 +18,62 @@ Vector = tuple[float, float] Point = tuple[float, float] +TAU = math.tau + + +def _normalize_angles(start: float, end: float, orientation: int, eps: float) -> tuple[float, float]: + if orientation > 0: + while end <= start + eps: + end += TAU + else: + while end >= start - eps: + end -= TAU + return start, end + + +def _angle_in_span(angle: float, start: float, end: float, orientation: int, eps: float) -> bool: + if orientation > 0: + while angle < start - eps: + angle += TAU + while angle > end + eps: + angle -= TAU + return start - eps <= angle <= end + eps + while angle > start + eps: + angle -= TAU + while angle < end - eps: + angle += TAU + return end - eps <= angle <= start + eps + + +def _point_on_arc(segment: dict[str, Any], point: Point, eps: float) -> bool: + cx, cy = segment["center"] + radius = segment["radius"] + if abs(math.hypot(point[0] - cx, point[1] - cy) - radius) > eps: + return False + angle = math.atan2(point[1] - cy, point[0] - cx) + return _angle_in_span(angle, segment["start_angle"], segment["end_angle"], segment["orientation"], eps) + + +def _circle_intersections(center_a: Point, radius_a: float, center_b: Point, radius_b: float, tol: float) -> list[Point]: + dx = center_b[0] - center_a[0] + dy = center_b[1] - center_a[1] + d2 = dx * dx + dy * dy + if d2 <= tol: + return [] + dist = math.sqrt(d2) + if dist > radius_a + radius_b + tol or dist < abs(radius_a - radius_b) - tol: + return [] + a = (radius_a * radius_a - radius_b * radius_b + d2) / (2 * dist) + h_sq = radius_a * radius_a - a * a + if h_sq < -tol: + return [] + h = math.sqrt(max(0.0, h_sq)) + xm = center_a[0] + a * dx / dist + ym = center_a[1] + a * dy / dist + rx = -dy * (h / dist) + ry = dx * (h / dist) + return [(xm + rx, ym + ry), (xm - rx, ym - ry)] + def dogbone_clearance(radius: float) -> float: """Return c = R * (1 + sqrt(2)/2 + sqrt(5/2 - sqrt(2))).""" @@ -60,22 +116,13 @@ def _normalize(vec: Vector) -> Vector | None: return None return normalize_vec(vec) - def _normalize_angles(start_angle: float, end_angle: float, orientation: int) -> tuple[float, float]: - if orientation > 0: - while end_angle <= start_angle + eps: - end_angle += 2 * math.pi - else: - while end_angle >= start_angle - eps: - end_angle -= 2 * math.pi - return start_angle, end_angle - def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> list[Any] | None: radius = vlength(vdiff(center, start)) if radius < eps or vlength(vdiff(center, end)) < eps: return None start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) - start_angle, end_angle = _normalize_angles(start_angle, end_angle, orientation) + start_angle, end_angle = _normalize_angles(start_angle, end_angle, orientation, eps) return [ "A", end[0], @@ -213,4 +260,150 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l i += 1 + _trim_overlaps(path, eps) return True + + +def _trim_overlaps(path: PathLike, eps: float) -> None: + overlaps: list[dict[str, Any]] = [] + arcs_since_line: list[dict[str, Any]] = [] + line_state: dict[str, Any] | None = None + current: Point | None = None + + def evaluate_candidate(line_data: dict[str, Any], right_arc: dict[str, Any]) -> dict[str, Any] | None: + best: dict[str, Any] | None = None + start = line_data["start"] + end = line_data["end"] + lx = end[0] - start[0] + ly = end[1] - start[1] + length = math.hypot(lx, ly) + if length <= eps: + return None + length_sq = length * length + for left_arc in line_data["left_arcs"]: + for point in _circle_intersections(left_arc["center"], left_arc["radius"], right_arc["center"], right_arc["radius"], eps): + if not _point_on_arc(left_arc, point, eps): + continue + if not _point_on_arc(right_arc, point, eps): + continue + px = point[0] - start[0] + py = point[1] - start[1] + projection = (px * lx + py * ly) / length_sq + distance = abs(px * ly - py * lx) / length + score = ( + 0 if 0.0 <= projection <= 1.0 else 1, + distance, + abs(projection - 0.5), + ) + candidate = { + "score": score, + "point": point, + "left_arc": left_arc, + "right_arc": right_arc, + "line_index": line_data["index"], + } + if best is None or candidate["score"] < best["score"]: + best = candidate + return best + + def finalize_line_state() -> None: + nonlocal line_state + if line_state and line_state.get("best"): + overlaps.append(line_state["best"]) + line_state = None + + for index, segment in enumerate(path): + code = segment[0] + if code == "M": + finalize_line_state() + current = (segment[1], segment[2]) + arcs_since_line = [] + elif code == "L": + finalize_line_state() + start = current if current is not None else (segment[1], segment[2]) + end = (segment[1], segment[2]) + line_state = { + "index": index, + "start": start, + "end": end, + "left_arcs": arcs_since_line.copy(), + "best": None, + } + arcs_since_line = [] + current = end + elif code == "A": + if current is None: + current = (segment[1], segment[2]) + arc_info = { + "index": index, + "start": current, + "end": (segment[1], segment[2]), + "center": (segment[3], segment[4]), + "radius": float(segment[5]), + "start_angle": float(segment[6]), + "end_angle": float(segment[7]), + "orientation": int(segment[8]), + } + arcs_since_line.append(arc_info) + if line_state and line_state["left_arcs"]: + candidate = evaluate_candidate(line_state, arc_info) + if candidate and (line_state["best"] is None or candidate["score"] < line_state["best"]["score"]): + line_state["best"] = candidate + current = arc_info["end"] + else: + current = (segment[1], segment[2]) if len(segment) >= 3 else current + + finalize_line_state() + if not overlaps: + return + + left_targets: dict[int, Point] = {} + line_targets: dict[int, Point] = {} + skip_indices: set[int] = set() + + for overlap in overlaps: + left_idx = overlap["left_arc"]["index"] + right_idx = overlap["right_arc"]["index"] + line_idx = overlap["line_index"] + point = overlap["point"] + left_targets[left_idx] = point + line_targets[line_idx] = point + for idx in range(left_idx + 1, right_idx): + if idx != line_idx: + skip_indices.add(idx) + + updated_path: PathLike = [] + for idx, segment in enumerate(path): + if idx in skip_indices: + continue + cmd = segment.copy() + if idx in line_targets and cmd[0] == "L": + px, py = line_targets[idx] + cmd[1], cmd[2] = px, py + if idx in left_targets and cmd[0] == "A": + px, py = left_targets[idx] + cmd[1], cmd[2] = px, py + updated_path.append(cmd) + + current_point: Point | None = None + for segment in updated_path: + code = segment[0] + if code == "M": + current_point = (segment[1], segment[2]) + elif code == "L": + current_point = (segment[1], segment[2]) + elif code == "A": + if current_point is None: + current_point = (segment[1], segment[2]) + cx, cy = segment[3], segment[4] + orientation = int(segment[8]) + start_angle = math.atan2(current_point[1] - cy, current_point[0] - cx) + end_angle = math.atan2(segment[2] - cy, segment[1] - cx) + start_angle, end_angle = _normalize_angles(start_angle, end_angle, orientation, eps) + segment[6] = start_angle + segment[7] = end_angle + current_point = (segment[1], segment[2]) + elif len(segment) >= 3: + current_point = (segment[1], segment[2]) + + path[:] = updated_path From c3b7e448ad6a4790e123a7ca2b41505292080a02 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Wed, 15 Oct 2025 22:05:34 -0300 Subject: [PATCH 12/30] new generator for a toolbox --- boxes/generators/toolbox.py | 103 ++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 boxes/generators/toolbox.py diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py new file mode 100644 index 000000000..48d149586 --- /dev/null +++ b/boxes/generators/toolbox.py @@ -0,0 +1,103 @@ +# Copyright (C) 2013-2025 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 . + +from boxes import * + +class Handle: + """Simple rectangular handle piece.""" + + def __init__(self, boxes: Boxes, width: float, height: float, thickness: float) -> None: + self.boxes = boxes + self.width = width + self.height = height + self.thickness = thickness + + def render(self, move: str = "", label: str = "Handle") -> None: + if self.width <= 0 or self.height <= 0: + return + + boxes = self.boxes + if boxes.move(self.width, self.height, move, before=True, label=label): + return + + ctx = boxes.ctx + ctx.rectangle(0, 0, self.width, self.height) + ctx.stroke() + + boxes.move(self.width, self.height, move, label=label) + +class ToolBox(Boxes): + """Finger jointed toolbox with four walls and a bottom panel.""" + + ui_group = "Box" + + description = """A straightforward rectangular toolbox that generates four +walls and a bottom panel using finger joints. Dimensions can be provided either +as internal or external measurements.""" + + def __init__(self) -> None: + Boxes.__init__(self) + self.addSettingsArgs(edges.FingerJointSettings) + self.addSettingsArgs(edges.CabinetHingeSettings) + + self.buildArgParser("x", "y", "h", "outside") + self.argparser.add_argument( + "--custom_spacing", + action="store", + type=float, + default=None, + help="override spacing between parts (in mm)") + + def render(self) -> None: + + x, y, h = self.x, self.y, self.h + + if self.outside: + x = self.adjustSize(x) + y = self.adjustSize(y) + h = self.adjustSize(h) + + material_thickness = self.thickness + spacing = self.custom_spacing if self.custom_spacing is not None else self.spacing + if self.custom_spacing is not None: + self.spacing = spacing + half_height = h / 2 + move_spacing = (2 * material_thickness) + spacing + + self.rectangularWall(x, half_height, "FFuF", move="right", label="Lower Wall 1") + self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 2") + self.rectangularWall(x, half_height, "FFeF", move="left right", label="Lower Wall 3") + self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 4") + + self.moveTo(-(x + move_spacing), 0) + + self.rectangularWall(x, y, "ffff", move="right", label="Bottom") + self.rectangularWall(x, y, "ffff", move="up", label="Top") + + self.moveTo(-(x + move_spacing), 0) + + self.rectangularWall(x, half_height, "UFFF", move="right", label="Upper Wall 1") + self.rectangularWall(y, half_height, "efFf", move="up", label="Upper Wall 2") + self.rectangularWall(x, half_height, "eFFF", move="left right", label="Upper Wall 3") + self.rectangularWall(y, half_height, "efFf", move="up", label="Upper Wall 4") + + self.moveTo(-(x + move_spacing), 0) + self.edges['u'].parts( move="right right right") + + handle_width = max(10.0, min(x, y) / 3.0) + handle_height = max(10.0, material_thickness * 4) + + handle_piece = Handle(self, handle_width, handle_height, material_thickness) + handle_piece.render(move="right", label="Handle") From 26355d44aa052a69e3b70d17e750a742cdacc953 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 16 Oct 2025 12:25:13 -0300 Subject: [PATCH 13/30] working --- boxes/generators/toolbox.py | 208 +++++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 5 deletions(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index 48d149586..f43587c3b 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -1,3 +1,4 @@ + # Copyright (C) 2013-2025 Florian Festi # # This program is free software: you can redistribute it and/or modify @@ -13,8 +14,179 @@ # 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 CustomCabinetHingeEdge(edges.BaseEdge): + """Local variant of the cabinet hinge edge with additional finger joints.""" + + CHAR_MAP = "wWxX" + + 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 = self.CHAR_MAP[bool(top) + 2 * bool(angled)] + self.description = "Custom cabinet hinge variant" + + def startwidth(self) -> float: + return self.settings.thickness if self.top and self.angled else 0.0 + + def _poly(self) -> tuple[list[float], float]: + 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: + e -= t + + if self.top: + poly = [spacing, 90, e + p] + else: + poly = [spacing + p, 90, e + p, 0] + for i in range(n): + if (i % 2) ^ self.top: + if i == 0: + poly += [-90, t + 2 * p, 90] + else: + poly += [90, t + 2 * p, 90] + else: + poly += [t - p, -90, t, -90, t - p] + + if (n % 2) ^ self.top: + poly += [0, e + p, 90, p + spacing] + else: + poly[-1:] = [-90, e + p, 90, spacing] + + width = (t + p) * n + p + 2 * spacing + prt = [n,p,e,t,spacing,poly] + print(prt) + 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: + 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) + + self._draw_handle_fingers(l) + + def _draw_handle_fingers(self, total_length: float) -> None: + boxes = self.boxes + handle_width = getattr(boxes, "handle_width_value", None) + handle_thickness = getattr(boxes, "handle_thickness_value", None) + if not handle_width or not handle_thickness: + return + finger_width = handle_thickness + if finger_width <= 0 or handle_width <= 2 * finger_width: + return + start_offset = total_length / 2.0 - handle_width / 2.0 - finger_width + print(f"[handle-debug] start_offset={total_length:.3f}") + if start_offset < 0: + start_offset = 0.0 + mid_gap = handle_width - 2.0 * finger_width + finger_edge = boxes.edges.get('f') + if finger_edge is None: + return + with boxes.saved_context(): + self.moveTo(start_offset, 0) + finger_edge(finger_width) + if mid_gap > 0: + self.edge(mid_gap) + finger_edge(finger_width) + + 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: + l = 4 * t + ax if i > n // 2 else 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") + + class Handle: """Simple rectangular handle piece.""" @@ -38,6 +210,7 @@ def render(self, move: str = "", label: str = "Handle") -> None: boxes.move(self.width, self.height, move, label=label) + class ToolBox(Boxes): """Finger jointed toolbox with four walls and a bottom panel.""" @@ -60,6 +233,29 @@ def __init__(self) -> None: default=None, help="override spacing between parts (in mm)") + def open(self) -> None: + """Ensure custom hinge edges are available after opening.""" + super().open() + self._add_custom_hinge_edges() + + def _add_custom_hinge_edges(self) -> None: + """Register local cabinet hinge variants with characters w/W/x/X.""" + base_edge = self.edges.get('u') + if base_edge is None: + return + if 'w' in self.edges: + return + base_settings = base_edge.settings + combinations = ( + (False, False), # w + (True, False), # W + (False, True), # x + (True, True), # X + ) + for top, angled in combinations: + edge = CustomCabinetHingeEdge(self, base_settings, top=top, angled=angled) + self.addPart(edge) + def render(self) -> None: x, y, h = self.x, self.y, self.h @@ -75,8 +271,13 @@ def render(self) -> None: self.spacing = spacing half_height = h / 2 move_spacing = (2 * material_thickness) + spacing + handle_width = 100 + handle_height = 50 + handle_thickness = material_thickness + self.handle_width_value = handle_width + self.handle_thickness_value = handle_thickness - self.rectangularWall(x, half_height, "FFuF", move="right", label="Lower Wall 1") + self.rectangularWall(x, half_height, "FFwF", move="right", label="Lower Wall 1") self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 2") self.rectangularWall(x, half_height, "FFeF", move="left right", label="Lower Wall 3") self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 4") @@ -94,10 +295,7 @@ def render(self) -> None: self.rectangularWall(y, half_height, "efFf", move="up", label="Upper Wall 4") self.moveTo(-(x + move_spacing), 0) - self.edges['u'].parts( move="right right right") - - handle_width = max(10.0, min(x, y) / 3.0) - handle_height = max(10.0, material_thickness * 4) + self.edges['u'].parts(move="right right right") handle_piece = Handle(self, handle_width, handle_height, material_thickness) handle_piece.render(move="right", label="Handle") From 8063acdef094a0c716c7e9107fdba3432a620d59 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 16 Oct 2025 21:51:01 -0300 Subject: [PATCH 14/30] WIP --- boxes/generators/toolbox.py | 270 ++++++++---------------------------- 1 file changed, 61 insertions(+), 209 deletions(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index f43587c3b..2be4aadac 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -14,201 +14,46 @@ # 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 CustomCabinetHingeEdge(edges.BaseEdge): - """Local variant of the cabinet hinge edge with additional finger joints.""" - - CHAR_MAP = "wWxX" - - 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 = self.CHAR_MAP[bool(top) + 2 * bool(angled)] - self.description = "Custom cabinet hinge variant" - - def startwidth(self) -> float: - return self.settings.thickness if self.top and self.angled else 0.0 - - def _poly(self) -> tuple[list[float], float]: - 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: - e -= t - - if self.top: - poly = [spacing, 90, e + p] - else: - poly = [spacing + p, 90, e + p, 0] - for i in range(n): - if (i % 2) ^ self.top: - if i == 0: - poly += [-90, t + 2 * p, 90] - else: - poly += [90, t + 2 * p, 90] - else: - poly += [t - p, -90, t, -90, t - p] - - if (n % 2) ^ self.top: - poly += [0, e + p, 90, p + spacing] - else: - poly[-1:] = [-90, e + p, 90, spacing] - - width = (t + p) * n + p + 2 * spacing - prt = [n,p,e,t,spacing,poly] - print(prt) - 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: - 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) - - self._draw_handle_fingers(l) - - def _draw_handle_fingers(self, total_length: float) -> None: - boxes = self.boxes - handle_width = getattr(boxes, "handle_width_value", None) - handle_thickness = getattr(boxes, "handle_thickness_value", None) - if not handle_width or not handle_thickness: - return - finger_width = handle_thickness - if finger_width <= 0 or handle_width <= 2 * finger_width: - return - start_offset = total_length / 2.0 - handle_width / 2.0 - finger_width - print(f"[handle-debug] start_offset={total_length:.3f}") - if start_offset < 0: - start_offset = 0.0 - mid_gap = handle_width - 2.0 * finger_width - finger_edge = boxes.edges.get('f') - if finger_edge is None: - return - with boxes.saved_context(): - self.moveTo(start_offset, 0) - finger_edge(finger_width) - if mid_gap > 0: - self.edge(mid_gap) - finger_edge(finger_width) - - 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: - l = 4 * t + ax if i > n // 2 else 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") - - class Handle: - """Simple rectangular handle piece.""" + """Custom handle profile.""" - def __init__(self, boxes: Boxes, width: float, height: float, thickness: float) -> None: + def __init__(self, boxes: Boxes, width: float, height: float, thickness: float, gap: float) -> None: self.boxes = boxes self.width = width self.height = height self.thickness = thickness + self.gap = gap def render(self, move: str = "", label: str = "Handle") -> None: + h = self.height + w = self.width + t = self.thickness + g = self.gap + cr = 0.5 + + if self.width <= 0 or self.height <= 0: return - boxes = self.boxes - if boxes.move(self.width, self.height, move, before=True, label=label): + b = self.boxes + if b.move(self.width, self.height, move, before=True, label=label): return - ctx = boxes.ctx - ctx.rectangle(0, 0, self.width, self.height) - ctx.stroke() + finger_edge = b.edges.get('f') + b.moveTo(0, 0) + b.polyline(w+cr,[90, t / 2],h-t/2,[90,0]) + finger_edge(t) + b.corner(90,0) + b.polyline(g-cr,[-90,cr],w-cr-t,[-90,cr],g-cr,90) + finger_edge(t) + b.corner(90,0) + b.polyline(h-t/2,[90,t/2]) + b.ctx.stroke() - boxes.move(self.width, self.height, move, label=label) + b.move(self.width, self.height, move, label=label) class ToolBox(Boxes): @@ -232,29 +77,37 @@ def __init__(self) -> None: type=float, default=None, help="override spacing between parts (in mm)") - - def open(self) -> None: - """Ensure custom hinge edges are available after opening.""" - super().open() - self._add_custom_hinge_edges() - - def _add_custom_hinge_edges(self) -> None: - """Register local cabinet hinge variants with characters w/W/x/X.""" - base_edge = self.edges.get('u') - if base_edge is None: - return - if 'w' in self.edges: - return - base_settings = base_edge.settings - combinations = ( - (False, False), # w - (True, False), # W - (False, True), # x - (True, True), # X - ) - for top, angled in combinations: - edge = CustomCabinetHingeEdge(self, base_settings, top=top, angled=angled) - self.addPart(edge) + self.argparser.add_argument( + "--handle", + action="store", + type=bool, + default=True, + help="add handle") + self.argparser.add_argument( + "--handle-height", + action="store", + type=float, + default=70, + help="") + self.argparser.add_argument( + "--handle_width", + action="store", + type=float, + default=100, + help="") + self.argparser.add_argument( + "--handle_thickness", + action="store", + type=float, + default=30, + help="") + self.argparser.add_argument( + "--handle_gap", + action="store", + type=float, + default=30, + help="") + def render(self) -> None: @@ -271,13 +124,12 @@ def render(self) -> None: self.spacing = spacing half_height = h / 2 move_spacing = (2 * material_thickness) + spacing - handle_width = 100 - handle_height = 50 - handle_thickness = material_thickness - self.handle_width_value = handle_width - self.handle_thickness_value = handle_thickness + handle_width = self.handle_width + handle_height = self.handle_height + handle_thickness = self.handle_thickness + handle_gap = self.handle_gap - self.rectangularWall(x, half_height, "FFwF", move="right", label="Lower Wall 1") + self.rectangularWall(x, half_height, "FFuF", move="right", label="Lower Wall 1") self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 2") self.rectangularWall(x, half_height, "FFeF", move="left right", label="Lower Wall 3") self.rectangularWall(y, half_height, "Ffef", move="up", label="Lower Wall 4") @@ -295,7 +147,7 @@ def render(self) -> None: self.rectangularWall(y, half_height, "efFf", move="up", label="Upper Wall 4") self.moveTo(-(x + move_spacing), 0) - self.edges['u'].parts(move="right right right") + self.edges['u'].parts(move="right right right ") - handle_piece = Handle(self, handle_width, handle_height, material_thickness) + handle_piece = Handle(self, handle_width, handle_height, handle_thickness, handle_gap) handle_piece.render(move="right", label="Handle") From 9c5b23b78c70e7b7ad38063028e8a40495ec48c1 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 16 Oct 2025 21:53:59 -0300 Subject: [PATCH 15/30] add helper --- boxes/generators/toolbox.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index 2be4aadac..681584c9f 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -82,31 +82,31 @@ def __init__(self) -> None: action="store", type=bool, default=True, - help="add handle") + help="gera a peca de alca (True/False)") self.argparser.add_argument( "--handle-height", action="store", type=float, default=70, - help="") + help="altura total da alca em mm") self.argparser.add_argument( "--handle_width", action="store", type=float, default=100, - help="") + help="largura total da alca em mm") self.argparser.add_argument( "--handle_thickness", action="store", type=float, default=30, - help="") + help="espessura (largura do perfil) da alca em mm") self.argparser.add_argument( "--handle_gap", action="store", type=float, default=30, - help="") + help="abertura central da alca (gap) em mm") def render(self) -> None: From f7a94a53764aa9518486b7e1c1bde487075e9615 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 16 Oct 2025 22:11:25 -0300 Subject: [PATCH 16/30] WIP --- boxes/generators/toolbox.py | 59 ++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index 681584c9f..2a0d2eb55 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -17,6 +17,51 @@ from boxes import * +class CustomCabinetHingeEdge(edges.CabinetHingeEdge): + """Cabinet hinge edge with adjustable straight section between fingers.""" + + + + def _space_segment(self, first: bool, t: float, p: float) -> list[float]: + base_length = t + 2 * p + base_length = max(0.0, base_length) + if first and not self.top: + return [-90, base_length, 90] + return [90, base_length, 90] + + # override the mangled helper used by the base class + def _CabinetHingeEdge__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: + e -= t + + if self.top: + poly = [spacing, 90, e + p] + else: + poly = [spacing + p, 90, e + p, 0] + + for i in range(n): + if (i % 2) ^ self.top: + poly += self._space_segment(i == 0, t, p) + else: + poly += [t - p, -90, t, -90, t - p] + + if (n % 2) ^ self.top: + poly += [0, e + p, 90, p + spacing] + else: + poly[-1:] = [-90, e + p, 90, spacing] + + width = (t + p) * n + p + 2 * spacing + return poly, width + + class Handle: """Custom handle profile.""" @@ -107,7 +152,19 @@ def __init__(self) -> None: type=float, default=30, help="abertura central da alca (gap) em mm") - + + def open(self) -> None: + super().open() + self._override_cabinet_hinge_edges() + + def _override_cabinet_hinge_edges(self) -> None: + base_edge = self.edges.get('U') + if base_edge is None: + return + settings = base_edge.settings + for top, angled in ((False, False), (True, False), (False, True), (True, True)): + edge = CustomCabinetHingeEdge(self, settings, top=top, angled=angled) + self.addPart(edge) def render(self) -> None: From b9a839d2f989e5cded360591240ee62f6d57f6f1 Mon Sep 17 00:00:00 2001 From: vinicius Date: Fri, 17 Oct 2025 01:05:16 -0300 Subject: [PATCH 17/30] =?UTF-8?q?Copia=20CabinetHingeEdge=20para=20CustomC?= =?UTF-8?q?abinetHingeEdge,=20mantendo=20o=20comportamento=20original=20ma?= =?UTF-8?q?s=20expondo=20=5Fhinge=5Fspacing=5Fsegment=20para=20customiza?= =?UTF-8?q?=C3=A7=C3=A3o.=20Implementa=20uso=20condicional=20do=20perfil?= =?UTF-8?q?=20de=20dedo=20nos=20v=C3=A3os=20entre=20m=C3=B3dulos=20de=20do?= =?UTF-8?q?bradi=C3=A7a=20(boxes/generators/toolbox.py:37-63),=20aplicando?= =?UTF-8?q?=20apenas=20=C3=A0=20borda=20'u'=20e=20respeitando=20as=20dimen?= =?UTF-8?q?s=C3=B5es=20da=20al=C3=A7a.=20Substitui=20as=20inst=C3=A2ncias?= =?UTF-8?q?=20padr=C3=A3o=20da=20dobradi=C3=A7a=20pelo=20edge=20customizad?= =?UTF-8?q?o=20no=20ToolBox,=20preservando=20as=20demais=20paredes=20e=20r?= =?UTF-8?q?enderiza=C3=A7=C3=B5es.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- boxes/generators/toolbox.py | 188 +++++++++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 15 deletions(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index 2a0d2eb55..1044ff0da 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -14,23 +14,69 @@ # 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 CustomCabinetHingeEdge(edges.CabinetHingeEdge): - """Cabinet hinge edge with adjustable straight section between fingers.""" +class CustomCabinetHingeEdge(edges.BaseEdge): + """Edge with cabinet hinges and customizable spacing segments.""" - + 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)] + + def startwidth(self) -> float: + return self.settings.thickness if self.top and self.angled else 0.0 + + def _hinge_spacing_segment(self, length: float, index: int, total: int, total_length: float) -> None: + """Draw the straight segment that spaces hinge modules. + + ``index`` can be ``-1`` for the leading segment and ``total`` for the trailing one. + """ + if length <= 0: + return - def _space_segment(self, first: bool, t: float, p: float) -> list[float]: - base_length = t + 2 * p - base_length = max(0.0, base_length) - if first and not self.top: - return [-90, base_length, 90] - return [90, base_length, 90] + if self.char != "u": + self.edge(length, tabs=2) + return + + finger_edge = self.boxes.edges.get('F') + handle_width = getattr(self.boxes, "handle_width", None) + handle_thickness = getattr(self.boxes, "handle_thickness", None) + + if finger_edge is None or handle_width is None or handle_thickness is None: + self.edge(length, tabs=2) + return + + inner_span = max(0.0, handle_width - handle_thickness - 0.5) + required = 2 * handle_thickness + inner_span + if length < required: + # Not enough room for the custom profile, fall back to simple spacing + self.edge(length, tabs=2) + return - # override the mangled helper used by the base class - def _CabinetHingeEdge__poly(self): + edges_spacing = max(0.0, (length - required) / 2.0) + + self.edge(edges_spacing) + finger_edge(handle_thickness) + self.edge(inner_span) + finger_edge(handle_thickness) + self.edge(edges_spacing) + + + def _should_use_custom_spacing(self, length: float) -> bool: + handle_width = getattr(self.boxes, "handle_width", None) + if handle_width is None: + return True + return length > handle_width + + def __poly(self): n = self.settings.eyes_per_hinge p = self.settings.play e = self.settings.eye @@ -40,27 +86,139 @@ def _CabinetHingeEdge__poly(self): 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: - poly += self._space_segment(i == 0, t, p) + # 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, 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: + lead = (l - width) / 2 + if self._should_use_custom_spacing(lead): + self._hinge_spacing_segment(lead, -1, hn, l) + else: + self.edge(lead, 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): + segment = (l - hn * width) / (hn - 1) + if self._should_use_custom_spacing(segment): + self._hinge_spacing_segment(segment, j, hn, l) + else: + self.edge(segment, tabs=2) + + if hn == 1: + tail = (l - width) / 2 + if self._should_use_custom_spacing(tail): + self._hinge_spacing_segment(tail, hn, hn, l) + else: + self.edge(tail, tabs=2) + + 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") + class Handle: """Custom handle profile.""" @@ -156,9 +314,9 @@ def __init__(self) -> None: def open(self) -> None: super().open() self._override_cabinet_hinge_edges() - + def _override_cabinet_hinge_edges(self) -> None: - base_edge = self.edges.get('U') + base_edge = self.edges.get('u') if base_edge is None: return settings = base_edge.settings From b14959609c7fb6db9d50036060d1d69fcd3eae12 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 17 Oct 2025 09:50:20 -0300 Subject: [PATCH 18/30] fix fingers position on edge --- boxes/generators/toolbox.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index 1044ff0da..c109c0a26 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -54,18 +54,18 @@ def _hinge_spacing_segment(self, length: float, index: int, total: int, total_le self.edge(length, tabs=2) return - inner_span = max(0.0, handle_width - handle_thickness - 0.5) + inner_span = max(0.0, handle_width - handle_thickness) required = 2 * handle_thickness + inner_span if length < required: # Not enough room for the custom profile, fall back to simple spacing self.edge(length, tabs=2) return - edges_spacing = max(0.0, (length - required) / 2.0) + edges_spacing = max(0.0, (length - required - 0.5) / 2.0) self.edge(edges_spacing) finger_edge(handle_thickness) - self.edge(inner_span) + self.edge(inner_span + 0.5) finger_edge(handle_thickness) self.edge(edges_spacing) From 70be4252c88458240d62346ac7ded4996398f3d6 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 17 Oct 2025 10:47:07 -0300 Subject: [PATCH 19/30] add latch --- boxes/generators/toolbox.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index c109c0a26..f2e7ce9c0 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -69,7 +69,6 @@ def _hinge_spacing_segment(self, length: float, index: int, total: int, total_le finger_edge(handle_thickness) self.edge(edges_spacing) - def _should_use_custom_spacing(self, length: float) -> bool: handle_width = getattr(self.boxes, "handle_width", None) if handle_width is None: @@ -259,6 +258,26 @@ def render(self, move: str = "", label: str = "Handle") -> None: b.move(self.width, self.height, move, label=label) +class Latche: + + def __init__(self, boxes, settings=None, width: float = 50, height: float = 50) -> None: + super().__init__(boxes, settings) + self.width = width + self.height = height + + def render(self, move: str = "", label: str = "Handle"): + b = self.boxes + w = self.width + h = self.height + mt = self.settings.thickness + + + + + b.move(self.width, self.height, move, label=label) + + + class ToolBox(Boxes): """Finger jointed toolbox with four walls and a bottom panel.""" From 2e9cec5c4535fdb25263fb499c42a78d83abcac8 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 17 Oct 2025 12:06:15 -0300 Subject: [PATCH 20/30] fix, corner ins hinges parts --- boxes/generators/toolbox.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py index f2e7ce9c0..c8be758d5 100644 --- a/boxes/generators/toolbox.py +++ b/boxes/generators/toolbox.py @@ -211,7 +211,10 @@ def parts(self, move=None) -> None: 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.corner(180,4.5) + self.moveTo(0,0,-90) + self.polyline(*[ t-self.burn, 90, t, -90, t, -90, t, 90, t, 90, t, (90, t)] + corner + [self.burn]) 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) From 9a6dcad5661fedd36033f5c101a19ddf893c3ef7 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 17 Oct 2025 12:16:40 -0300 Subject: [PATCH 21/30] =?UTF-8?q?Melhora=20convers=C3=A3o=20de=20arcos=20n?= =?UTF-8?q?o=20DXF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agrupa segmentos de arco contíguos com mesmo centro/raio em uma única entidade - detecta varreduras completas e passa a emitir CIRCLE em vez de vários ARC - converte curvas cúbicas compatíveis diretamente em arcos/círculos, evitando discretizações TODO Alguns arcos estão com o sentido invertido Loops são desenhados como seguimentos de linhas, devem ser desenhados como splines Desenhos fechados devem ser desenhados como polyline TODO Dogbone *Arco principal do dogbone não esta encontrando com os arcos complementares *Geometria quebrada em situações especificas do dogbone (quadrado com lado menor que 2*--D) --- boxes/dxf.py | 291 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 283 insertions(+), 8 deletions(-) diff --git a/boxes/dxf.py b/boxes/dxf.py index 85e9cc053..6b5d0be79 100644 --- a/boxes/dxf.py +++ b/boxes/dxf.py @@ -2,7 +2,7 @@ import io import math -from typing import Any +from typing import Any, Sequence from .drawing import EPS, Surface, points_equal @@ -41,11 +41,129 @@ def _format_angle(angle_deg: float) -> float: def _entities_from_path(self, commands): entities: list[str] = [] current: tuple[float, float] | None = None + pending_arc: dict[str, Any] | None = None + sweep_tol = 1e-4 + circle_sweep = 2.0 * math.pi + + def flush_pending_arc() -> None: + nonlocal pending_arc + if not pending_arc: + return + emit_arc_entity( + pending_arc["center"], + pending_arc["radius"], + pending_arc["start_angle"], + pending_arc["end_angle"], + pending_arc["orientation"], + ) + pending_arc = None + + def emit_arc_entity( + center: tuple[float, float], + radius: float, + start_angle: float, + end_angle: float, + orientation: int, + ) -> None: + if orientation < 0: + start_angle, end_angle = end_angle, start_angle + start_deg = self._format_angle(math.degrees(start_angle)) + end_deg = self._format_angle(math.degrees(end_angle)) + entities.extend( + self._arc_entity(center, radius, start_deg, end_deg) + ) + + def process_arc_segment( + start_point: tuple[float, float], + end_point: tuple[float, float], + center: tuple[float, float], + radius: float, + start_angle_rad: float, + end_angle_rad: float, + orientation: int, + ) -> None: + nonlocal pending_arc + if radius <= EPS: + flush_pending_arc() + return + orient = 1 if orientation >= 0 else -1 + start_angle, end_angle = self._normalize_arc_angles( + float(start_angle_rad), float(end_angle_rad), orient + ) + sweep = end_angle - start_angle if orient > 0 else start_angle - end_angle + if sweep <= 1e-9: + flush_pending_arc() + return + segment = { + "center": center, + "radius": abs(radius), + "orientation": orient, + "start_angle": start_angle, + "end_angle": end_angle, + "start_point": start_point, + "end_point": end_point, + "sweep": sweep, + } + if points_equal(start_point[0], start_point[1], end_point[0], end_point[1]) and abs( + sweep - circle_sweep + ) <= sweep_tol: + flush_pending_arc() + entities.extend(self._circle_entity(center, abs(radius))) + return + + if pending_arc: + same_orientation = orient == pending_arc["orientation"] + same_radius = abs(segment["radius"] - pending_arc["radius"]) <= 1e-4 + center_dx = segment["center"][0] - pending_arc["center"][0] + center_dy = segment["center"][1] - pending_arc["center"][1] + same_center = math.hypot(center_dx, center_dy) <= 1e-4 + contiguous = points_equal( + pending_arc["end_point"][0], + pending_arc["end_point"][1], + start_point[0], + start_point[1], + ) + if same_orientation and same_radius and same_center and contiguous: + predicted_sweep = pending_arc["total_sweep"] + sweep + if predicted_sweep > circle_sweep + sweep_tol: + flush_pending_arc() + else: + pending_arc["total_sweep"] = predicted_sweep + pending_arc["end_point"] = end_point + pending_arc["end_angle"] = segment["end_angle"] + if ( + points_equal( + pending_arc["start_point"][0], + pending_arc["start_point"][1], + end_point[0], + end_point[1], + ) + and abs(predicted_sweep - circle_sweep) <= sweep_tol + ): + entities.extend(self._circle_entity(center, abs(radius))) + pending_arc = None + return + else: + flush_pending_arc() + + pending_arc = { + "center": segment["center"], + "radius": segment["radius"], + "orientation": segment["orientation"], + "total_sweep": segment["sweep"], + "start_angle": segment["start_angle"], + "end_angle": segment["end_angle"], + "start_point": start_point, + "end_point": end_point, + } + for cmd in commands: letter = cmd[0] if letter == "M": + flush_pending_arc() current = (cmd[1], cmd[2]) elif letter == "L": + flush_pending_arc() target = (cmd[1], cmd[2]) if current and not points_equal(current[0], current[1], target[0], target[1]): entities.extend(self._line_entity(current, target)) @@ -54,6 +172,25 @@ def _entities_from_path(self, commands): if current is None: current = (cmd[1], cmd[2]) continue + arc = self._cubic_to_arc(current, cmd) + if arc: + center, radius, start_angle_deg, end_angle_deg, is_full_circle = arc + if is_full_circle: + flush_pending_arc() + entities.extend(self._circle_entity(center, radius)) + else: + process_arc_segment( + current, + (cmd[1], cmd[2]), + center, + radius, + math.radians(start_angle_deg), + math.radians(end_angle_deg), + 1, + ) + current = (cmd[1], cmd[2]) + continue + flush_pending_arc() control1 = (cmd[3], cmd[4]) control2 = (cmd[5], cmd[6]) end_point = (cmd[1], cmd[2]) @@ -69,20 +206,26 @@ def _entities_from_path(self, commands): end_point = (cmd[1], cmd[2]) center = (cmd[3], cmd[4]) radius = cmd[5] - start_angle = math.degrees(cmd[6]) - end_angle = math.degrees(cmd[7]) + start_angle_rad = float(cmd[6]) + end_angle_rad = float(cmd[7]) orientation = cmd[8] if radius > EPS: - if orientation < 0: - start_angle, end_angle = end_angle, start_angle - start_angle = self._format_angle(start_angle) - end_angle = self._format_angle(end_angle) - entities.extend(self._arc_entity(center, radius, start_angle, end_angle)) + process_arc_segment( + current, + end_point, + center, + radius, + start_angle_rad, + end_angle_rad, + orientation, + ) current = end_point elif letter == "T": + flush_pending_arc() text_entities = self._text_entity(cmd) if text_entities: entities.extend(text_entities) + flush_pending_arc() return entities def _line_entity(self, start, end): @@ -111,6 +254,138 @@ def _arc_entity(self, center, radius, start_angle, end_angle): self._pair(items, 51, f"{end_angle:.6f}") return items + def _circle_entity(self, center, radius): + items: list[str] = [] + self._pair(items, 0, "CIRCLE") + self._pair(items, 8, "0") + self._pair(items, 10, f"{center[0]:.6f}") + self._pair(items, 20, f"{center[1]:.6f}") + self._pair(items, 30, "0.0") + self._pair(items, 40, f"{abs(radius):.6f}") + return items + + @staticmethod + def _normalize_vector(dx: float, dy: float) -> tuple[float, float] | None: + length = math.hypot(dx, dy) + if length <= EPS: + return None + return dx / length, dy / length + + @staticmethod + def _rotate_vector(vec: tuple[float, float], orientation: int) -> tuple[float, float]: + x, y = vec + return (-y, x) if orientation > 0 else (y, -x) + + @staticmethod + def _cross(a: tuple[float, float], b: tuple[float, float]) -> float: + return a[0] * b[1] - a[1] * b[0] + + def _line_intersection_params( + self, + origin_a: tuple[float, float], + dir_a: tuple[float, float], + origin_b: tuple[float, float], + dir_b: tuple[float, float], + ) -> tuple[float, float, tuple[float, float]] | None: + det = self._cross(dir_a, dir_b) + if abs(det) <= 1e-9: + return None + diff = (origin_b[0] - origin_a[0], origin_b[1] - origin_a[1]) + u = self._cross(diff, dir_b) / det + v = self._cross(diff, dir_a) / det + center = ( + origin_a[0] + u * dir_a[0], + origin_a[1] + u * dir_a[1], + ) + return u, v, center + + @staticmethod + def _evaluate_cubic( + start: tuple[float, float], + ctrl1: tuple[float, float], + ctrl2: tuple[float, float], + end: tuple[float, float], + t: float, + ) -> tuple[float, float]: + mt = 1.0 - t + x = ( + mt * mt * mt * start[0] + + 3 * mt * mt * t * ctrl1[0] + + 3 * mt * t * t * ctrl2[0] + + t * t * t * end[0] + ) + y = ( + mt * mt * mt * start[1] + + 3 * mt * mt * t * ctrl1[1] + + 3 * mt * t * t * ctrl2[1] + + t * t * t * end[1] + ) + return x, y + + @staticmethod + def _normalize_arc_angles(start_angle: float, end_angle: float, orientation: int) -> tuple[float, float]: + if orientation > 0: + while end_angle <= start_angle + 1e-9: + end_angle += 2.0 * math.pi + else: + while end_angle >= start_angle - 1e-9: + end_angle -= 2.0 * math.pi + return start_angle, end_angle + + def _cubic_to_arc(self, start: tuple[float, float], cmd: Sequence[float]) -> tuple[tuple[float, float], float, float, float, bool] | None: + end = (cmd[1], cmd[2]) + ctrl1 = (cmd[3], cmd[4]) + ctrl2 = (cmd[5], cmd[6]) + + tangent_start = self._normalize_vector(ctrl1[0] - start[0], ctrl1[1] - start[1]) + tangent_end = self._normalize_vector(end[0] - ctrl2[0], end[1] - ctrl2[1]) + if tangent_start is None or tangent_end is None: + return None + + for orientation in (1, -1): + normal_start = self._rotate_vector(tangent_start, orientation) + normal_end = self._rotate_vector(tangent_end, orientation) + intersection = self._line_intersection_params(start, normal_start, end, normal_end) + if intersection is None: + continue + u, v, center = intersection + if u <= EPS or v <= EPS: + continue + radius_start = math.hypot(center[0] - start[0], center[1] - start[1]) + radius_end = math.hypot(center[0] - end[0], center[1] - end[1]) + if radius_start <= EPS or abs(radius_start - radius_end) > max(1.0, radius_start) * 1e-3: + continue + dot_start = (center[0] - start[0]) * normal_start[0] + (center[1] - start[1]) * normal_start[1] + dot_end = (center[0] - end[0]) * normal_end[0] + (center[1] - end[1]) * normal_end[1] + if dot_start < 0 or dot_end < 0: + continue + start_vec = (start[0] - center[0], start[1] - center[1]) + end_vec = (end[0] - center[0], end[1] - center[1]) + cross = self._cross(start_vec, end_vec) + actual_orientation = 1 if cross > 0 else -1 if cross < 0 else 0 + if actual_orientation == 0 or actual_orientation != orientation: + continue + start_angle = math.atan2(start_vec[1], start_vec[0]) + end_angle = math.atan2(end_vec[1], end_vec[0]) + start_angle, end_angle = self._normalize_arc_angles(start_angle, end_angle, orientation) + midpoint = self._evaluate_cubic(start, ctrl1, ctrl2, end, 0.5) + radius_mid = math.hypot(midpoint[0] - center[0], midpoint[1] - center[1]) + if abs(radius_mid - radius_start) > max(1.0, radius_start) * 5e-4: + continue + sweep = end_angle - start_angle if orientation > 0 else start_angle - end_angle + is_full_circle = ( + points_equal(start[0], start[1], end[0], end[1]) + and math.isclose(abs(sweep), 2.0 * math.pi, abs_tol=1e-6) + ) + if orientation < 0: + start_angle, end_angle = end_angle, start_angle + start_angle_deg = math.degrees(start_angle) + end_angle_deg = math.degrees(end_angle) + start_angle_deg = self._format_angle(start_angle_deg) + end_angle_deg = self._format_angle(end_angle_deg) + return center, radius_start, start_angle_deg, end_angle_deg, is_full_circle + return None + def _text_entity(self, cmd): _, x, y, _m, text, params = cmd if not text: From c0145053d20302fc83d9aa4421a5a82b5c98c205 Mon Sep 17 00:00:00 2001 From: vinicius795 Date: Fri, 17 Oct 2025 21:38:31 -0300 Subject: [PATCH 22/30] Fix orientation for cubic arc conversion --- boxes/dxf.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/boxes/dxf.py b/boxes/dxf.py index 6b5d0be79..f68d9fa6b 100644 --- a/boxes/dxf.py +++ b/boxes/dxf.py @@ -174,7 +174,14 @@ def process_arc_segment( continue arc = self._cubic_to_arc(current, cmd) if arc: - center, radius, start_angle_deg, end_angle_deg, is_full_circle = arc + ( + center, + radius, + start_angle_deg, + end_angle_deg, + orientation, + is_full_circle, + ) = arc if is_full_circle: flush_pending_arc() entities.extend(self._circle_entity(center, radius)) @@ -186,7 +193,7 @@ def process_arc_segment( radius, math.radians(start_angle_deg), math.radians(end_angle_deg), - 1, + orientation, ) current = (cmd[1], cmd[2]) continue @@ -332,7 +339,9 @@ def _normalize_arc_angles(start_angle: float, end_angle: float, orientation: int end_angle -= 2.0 * math.pi return start_angle, end_angle - def _cubic_to_arc(self, start: tuple[float, float], cmd: Sequence[float]) -> tuple[tuple[float, float], float, float, float, bool] | None: + def _cubic_to_arc( + self, start: tuple[float, float], cmd: Sequence[float] + ) -> tuple[tuple[float, float], float, float, float, int, bool] | None: end = (cmd[1], cmd[2]) ctrl1 = (cmd[3], cmd[4]) ctrl2 = (cmd[5], cmd[6]) @@ -377,13 +386,18 @@ def _cubic_to_arc(self, start: tuple[float, float], cmd: Sequence[float]) -> tup points_equal(start[0], start[1], end[0], end[1]) and math.isclose(abs(sweep), 2.0 * math.pi, abs_tol=1e-6) ) - if orientation < 0: - start_angle, end_angle = end_angle, start_angle start_angle_deg = math.degrees(start_angle) end_angle_deg = math.degrees(end_angle) start_angle_deg = self._format_angle(start_angle_deg) end_angle_deg = self._format_angle(end_angle_deg) - return center, radius_start, start_angle_deg, end_angle_deg, is_full_circle + return ( + center, + radius_start, + start_angle_deg, + end_angle_deg, + orientation, + is_full_circle, + ) return None def _text_entity(self, cmd): From 717d820f783602a003dd1c68bcb10256bdcf2b9f Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 21 Oct 2025 21:17:43 -0300 Subject: [PATCH 23/30] Create a test_path for testing DXF generator --- boxes/test_path.py | 143 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 boxes/test_path.py diff --git a/boxes/test_path.py b/boxes/test_path.py new file mode 100644 index 000000000..6ac5b70d8 --- /dev/null +++ b/boxes/test_path.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any, MutableSequence + +from affine import Affine +from boxes.dxf import DXFSurface +from boxes.drawing import SVGSurface + +PathLike = MutableSequence[MutableSequence[Any]] + + +def _text_params() -> dict[str, Any]: + """Return default text styling for labels in the test path.""" + return { + "ff": ("sans-serif", False, False), + "fs": 2.0, + "lw": 0.0, + "rgb": (0.0, 0.0, 0.0), + "align": "middle", + } + + +Test_Path: PathLike = [ + # Retangulo 6 x 3 + ["M", 0.0, 0.0], + ["L", 6.0, 0.0], + ["C", 6.0, 0.0, 6.0, 0.0, 6.0, 0.0], + ["L", 6.0, 3.0], + ["C", 6.0, 3.0, 6.0, 3.0, 6.0, 3.0], + ["L", 0.0, 3.0], + ["C", 0.0, 3.0, 0.0, 3.0, 0.0, 3.0], + ["L", 0.0, 0.0], + ["C", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ["T", 3.0, 1.5, Affine.identity(), "1", _text_params()], + # Circulo de diametro 10 descrito com segmentos cubicos. + ["M", 18.0, 5.0], + ["C", 13.0, 10.0, 18.0, 7.761423749153968, 15.761423749153968, 10.0], + ["C", 8.0, 5.0, 10.238576250846032, 10.0, 8.0, 7.761423749153968], + ["C", 13.0, 0.0, 8.0, 2.2385762508460325, 10.238576250846032, 0.0], + ["C", 18.0, 5.0, 15.761423749153968, 0.0, 18.0, 2.2385762508460325], + ["T", 13.0, 5.0, Affine.identity(), "2", _text_params()], + # Circulo de diametro 10 descrito com arco. + ["M", 33.0, 5.0], + ["A", 33.0, 5.0, 28.0, 5.0, 5.0, 0.0, 6.283185307179586, 1], + ["T", 28.0, 5.0, Affine.identity(), "3", _text_params()], + # Segmento: linha 10, curva equivalente ao arco horario, linha 10. + ["M", 38.0, 5.0], + ["L", 43.0, 5.0], + ["C", 48.0, 0.0, 45.76142374915397, 5.0, 48.0, 2.761423749153968], + ["L", 48.0, -10.0], + ["T", 45.5, -2.5, Affine.identity(), "4", _text_params()], + # Loop estilo inner-corner loop. + ["M", 63.0, 0.0], + ["L", 73.0, 0.0], + ["C", 73.0, 0.0, 83.0, 0.0, 73.0, -10.0], + ["L", 73.0, -20.0], + ["T", 78.0, -5.0, Affine.identity(), "5", _text_params()], + # Canto 90 graus com dogbone (D=5). + ["M", 83.0, 0.0], # start=(83.0,0.0) + ["L", 84.25441226126739, 0.0], + # start=(83.0,0.0) end=(84.25441226126739,0.0) + ["A", 86.85943917766733, 0.7322330470336311, 84.25441226126739, 5.0, 5.0, -1.5707963267948966, -1.0227679191745838, 1], + # start=(84.25441226126739,0.0) end=(86.85943917766733,0.7322330470336311) + ["A", 93.73223304703363, -6.140560822332672, 89.46446609406726, -3.5355339059327373, 5.0, 2.1188247344152096, -0.5480284076203126, -1], + # start=(86.85943917766733,0.7322330470336311) end=(93.73223304703363,-6.140560822332672) + ["A", 93.0, -8.745587738732608, 98.0, -8.745587738732608, 5.0, 2.5935642459694805, 3.141592653589793, 1], + # start=(93.73223304703363,-6.140560822332672) end=(93.0,-8.745587738732608) + ["L", 93.0, -10.0], + # start=(93.0,-8.745587738732608) end=(93.0,-10.0) + ["T", 88.5, -3.0, Affine.identity(), "6", _text_params()], +] + + +def export_test_path_dxf( + output: str | Path = "test_path.dxf", + *, + inner_corners: str = "loop", + dogbone_diameter: float | None = None, +) -> Path: + """Render Test_Path to a DXF file and return the written path.""" + surface = DXFSurface() + surface.set_metadata( + { + "name": "TestPath", + "short_description": "Sample path for manual testing", + "description": "", + "group": "dev", + "url": "", + "url_short": "", + "cli": "", + "cli_short": "", + "creation_date": datetime.now(), + "reproducible": True, + } + ) + + for cmd in Test_Path: + surface.append(*cmd) + surface.stroke(lw=0.1, rgb=(0.0, 0.0, 0.0)) + + dogbone_radius = dogbone_diameter / 2.0 if dogbone_diameter else None + buffer = surface.finish(inner_corners=inner_corners, dogbone_radius=dogbone_radius) + + output_path = Path(output) + output_path.write_bytes(buffer.getvalue()) + return output_path + + +def export_test_path_svg( + output: str | Path = "test_path.svg", + *, + inner_corners: str = "loop", + dogbone_diameter: float | None = None, +) -> Path: + """Render Test_Path to an SVG file and return the written path.""" + surface = SVGSurface() + surface.set_metadata( + { + "name": "TestPath", + "short_description": "Sample path for manual testing", + "description": "", + "group": "dev", + "url": "", + "url_short": "", + "cli": "", + "cli_short": "", + "creation_date": datetime.now(), + "reproducible": True, + } + ) + + for cmd in Test_Path: + surface.append(*cmd) + surface.stroke(lw=0.1, rgb=(0.0, 0.0, 0.0)) + + dogbone_radius = dogbone_diameter / 2.0 if dogbone_diameter else None + buffer = surface.finish(inner_corners=inner_corners, dogbone_radius=dogbone_radius) + + output_path = Path(output) + output_path.write_bytes(buffer.getvalue()) + return output_path From 054996bfa48e06429adaa1e2c42daf17f3349d41 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Tue, 21 Oct 2025 22:14:41 -0300 Subject: [PATCH 24/30] start new dxf generator with ezdxf --- boxes/ezdxf_generator.py | 257 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 boxes/ezdxf_generator.py diff --git a/boxes/ezdxf_generator.py b/boxes/ezdxf_generator.py new file mode 100644 index 000000000..6fb3910c0 --- /dev/null +++ b/boxes/ezdxf_generator.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import math +from pathlib import Path +from typing import Iterable, Sequence + +import ezdxf +from ezdxf import units +from ezdxf.math import Vec3, Bezier4P, bezier_to_bspline + +from boxes.test_path import Test_Path + +Command = Sequence[object] + + +class EZDXFBuilder: + """Utility that translates drawing commands into DXF entities using ezdxf.""" + + _POINT_TOL = 1e-6 + _ARC_DEVIATION_FACTOR = 2e-3 + + def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None: + self.doc = ezdxf.new("R2010", setup=True) + self.doc.units = units.MM + self.msp = self.doc.modelspace() + self.layer = layer + lw_value = self._lineweight_to_hundredths(lineweight) if lineweight is not None else None + self.entity_attribs = {"layer": layer} + if lw_value is not None: + self.entity_attribs["lineweight"] = lw_value + + @staticmethod + def _lineweight_to_hundredths(value: float) -> int: + return max(0, int(round(value * 100.0))) + + @staticmethod + def _vec(x: float, y: float) -> Vec3: + return Vec3(float(x), float(y), 0.0) + + @staticmethod + def _evaluate_cubic(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: float) -> Vec3: + mt = 1.0 - t + return ( + p0 * (mt ** 3) + + p1 * (3.0 * mt * mt * t) + + p2 * (3.0 * mt * t * t) + + p3 * (t ** 3) + ) + + @classmethod + def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): + # Reject degenerate curves with minimal length. + span = (end - start).magnitude + if span <= cls._POINT_TOL: + return None + + t_start = (ctrl1 - start) * 3.0 + t_end = (end - ctrl2) * 3.0 + det = t_start.x * t_end.y - t_start.y * t_end.x + if abs(det) <= 1e-8: + return None + + s_dot = start.x * t_start.x + start.y * t_start.y + e_dot = end.x * t_end.x + end.y * t_end.y + cx = (s_dot * t_end.y - e_dot * t_start.y) / det + cy = (e_dot * t_start.x - s_dot * t_end.x) / det + center = Vec3(cx, cy, 0.0) + + radius_start = (start - center).magnitude + radius_end = (end - center).magnitude + if radius_start <= cls._POINT_TOL: + return None + if abs(radius_start - radius_end) > max(radius_start, 1.0) * 1e-3: + return None + + # Ensure tangents are perpendicular to radius, within tolerance. + if abs((start - center).dot(t_start)) > max(radius_start, 1.0) * 1e-3: + return None + if abs((end - center).dot(t_end)) > max(radius_start, 1.0) * 1e-3: + return None + + # Sample deviation from fitted circle. + max_dev = 0.0 + for t in (0.25, 0.5, 0.75): + point = cls._evaluate_cubic(start, ctrl1, ctrl2, end, t) + deviation = abs((point - center).magnitude - radius_start) + max_dev = max(max_dev, deviation) + if max_dev > max(radius_start, 1.0) * cls._ARC_DEVIATION_FACTOR: + return None + + r0 = start - center + r1 = end - center + cross = r0.x * r1.y - r0.y * r1.x + if abs(cross) <= max(radius_start, 1.0) * 1e-6: + return None + orientation = 1 if cross > 0 else -1 + start_angle = math.atan2(r0.y, r0.x) + end_angle = math.atan2(r1.y, r1.x) + if orientation > 0: + while end_angle <= start_angle: + end_angle += 2.0 * math.pi + else: + while end_angle >= start_angle: + end_angle -= 2.0 * math.pi + + sweep = abs(end_angle - start_angle) + if sweep <= 1e-3: + return None + + is_full_circle = ( + (end - start).magnitude <= max(radius_start, 1.0) * 1e-3 + and abs(sweep - 2.0 * math.pi) <= 1e-2 + ) + return { + "center": center, + "radius": radius_start, + "start_angle": start_angle, + "end_angle": end_angle, + "orientation": orientation, + "full_circle": is_full_circle, + } + + def add_commands(self, commands: Iterable[Command]) -> None: + current_point: Vec3 | None = None + for raw in commands: + if not raw: + continue + code = raw[0] + if code == "M": + current_point = self._vec(raw[1], raw[2]) + elif code == "L": + end_point = self._vec(raw[1], raw[2]) + if current_point is not None: + self.msp.add_line(current_point, end_point, dxfattribs=self.entity_attribs) + current_point = end_point + elif code == "C": + if current_point is None: + raise ValueError("Bezier segment without a defined starting point.") + end_point = self._vec(raw[1], raw[2]) + ctrl1 = self._vec(raw[3], raw[4]) + ctrl2 = self._vec(raw[5], raw[6]) + if ( + (end_point - current_point).magnitude <= self._POINT_TOL + and (ctrl1 - current_point).magnitude <= self._POINT_TOL + and (ctrl2 - current_point).magnitude <= self._POINT_TOL + ): + current_point = end_point + continue + arc = self._try_cubic_as_arc(current_point, ctrl1, ctrl2, end_point) + if arc: + start_deg = math.degrees(arc["start_angle"]) + end_deg = math.degrees(arc["end_angle"]) + center_tuple = (arc["center"].x, arc["center"].y) + attribs = dict(self.entity_attribs) + if arc["full_circle"]: + self.msp.add_circle(center_tuple, arc["radius"], dxfattribs=attribs) + else: + self.msp.add_arc( + center_tuple, + arc["radius"], + start_angle=start_deg, + end_angle=end_deg, + is_counter_clockwise=arc["orientation"] > 0, + dxfattribs=attribs, + ) + else: + bezier = Bezier4P((current_point, ctrl1, ctrl2, end_point)) + nurbs = bezier_to_bspline([bezier]) + spline_entity = self.msp.add_spline(dxfattribs=self.entity_attribs) + spline_entity.apply_construction_tool(nurbs) + current_point = end_point + elif code == "A": + end_point = self._vec(raw[1], raw[2]) + center = self._vec(raw[3], raw[4]) + radius = abs(float(raw[5])) + start_param = float(raw[6]) + end_param = float(raw[7]) + orientation = int(raw[8]) + + start_point = self._vec( + center.x + radius * math.cos(start_param), + center.y + radius * math.sin(start_param), + ) + if current_point is None: + current_point = start_point + elif math.hypot(current_point.x - start_point.x, current_point.y - start_point.y) > 1e-4: + current_point = start_point + + start_angle_deg = math.degrees(math.atan2(current_point.y - center.y, current_point.x - center.x)) + end_angle_deg = math.degrees(math.atan2(end_point.y - center.y, end_point.x - center.x)) + + sweep = end_param - start_param + if orientation < 0: + sweep = -sweep + start_angle_deg, end_angle_deg = end_angle_deg, start_angle_deg + + is_full_circle = ( + math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) <= 1e-4 + and math.isclose(abs(sweep), 2.0 * math.pi, rel_tol=1e-6) + ) + + center_tuple = (center.x, center.y) + if is_full_circle: + self.msp.add_circle(center_tuple, radius, dxfattribs=self.entity_attribs) + else: + self.msp.add_arc( + center_tuple, + radius, + start_angle=start_angle_deg, + end_angle=end_angle_deg, + dxfattribs=self.entity_attribs, + ) + current_point = end_point + elif code == "T": + text = raw[4] + if not text: + continue + x, y = float(raw[1]), float(raw[2]) + params = raw[5] if len(raw) > 5 else {} + height = float(params.get("fs", 2.0)) + align = params.get("align", "left") + align_map = { + "left": "LEFT", + "middle": "CENTER", + "end": "RIGHT", + } + text_entity = self.msp.add_text( + text, + dxfattribs={ + "height": height, + "layer": self.layer, + }, + ) + text_entity.dxf.insert = (x, y, 0.0) + alignment = align_map.get(align, "LEFT") + if alignment != "LEFT": + halign_codes = {"CENTER": 1, "RIGHT": 2} + text_entity.dxf.halign = halign_codes.get(alignment, 0) + text_entity.dxf.align_point = (x, y, 0.0) + else: + raise ValueError(f"Unsupported drawing command: {code}") + + def save(self, output: str | Path) -> Path: + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + self.doc.saveas(output_path) + return output_path + + +def export_test_path_ezdxf(output: str | Path = "test_path_ezdxf.dxf") -> Path: + builder = EZDXFBuilder(lineweight=0.1) + builder.add_commands(Test_Path) + return builder.save(output) + + +if __name__ == "__main__": + export_test_path_ezdxf() From c92d7f14c792122a1feb8c503cdf25fd46fcad46 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Wed, 22 Oct 2025 12:48:42 -0300 Subject: [PATCH 25/30] all polyline now arcs is now circles TODO: --inner-corner loop is faling back to line segments --- boxes/ezdxf_generator.py | 361 +++++++++++++++++++++++++++------------ 1 file changed, 248 insertions(+), 113 deletions(-) diff --git a/boxes/ezdxf_generator.py b/boxes/ezdxf_generator.py index 6fb3910c0..73a01c826 100644 --- a/boxes/ezdxf_generator.py +++ b/boxes/ezdxf_generator.py @@ -6,7 +6,7 @@ import ezdxf from ezdxf import units -from ezdxf.math import Vec3, Bezier4P, bezier_to_bspline +from ezdxf.math import Vec3 from boxes.test_path import Test_Path @@ -14,10 +14,11 @@ class EZDXFBuilder: - """Utility that translates drawing commands into DXF entities using ezdxf.""" + """Render drawing commands into DXF entities using ezdxf.""" _POINT_TOL = 1e-6 - _ARC_DEVIATION_FACTOR = 2e-3 + _ARC_TOL = 1e-3 + _FULL_CIRCLE_TOL = 1e-2 def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None: self.doc = ezdxf.new("R2010", setup=True) @@ -47,9 +48,42 @@ def _evaluate_cubic(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: float) -> Vec3: + p3 * (t ** 3) ) + def _approximate_cubic(self, p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, steps: int = 16) -> list[Vec3]: + points = [p0] + for step in range(1, steps): + t = step / steps + points.append(self._evaluate_cubic(p0, p1, p2, p3, t)) + points.append(p3) + return points + + @staticmethod + def _line_segment(start: Vec3, end: Vec3) -> dict[str, Vec3]: + return {"type": "line", "start": start, "end": end} + + @staticmethod + def _arc_sweep(segment: dict) -> float: + if segment["orientation"] > 0: + return segment["end_angle"] - segment["start_angle"] + return segment["start_angle"] - segment["end_angle"] + + @classmethod + def _arc_start_point(cls, segment: dict) -> Vec3: + return cls._point_on_arc(segment["center"], segment["radius"], segment["start_angle"]) + + @classmethod + def _arc_end_point(cls, segment: dict) -> Vec3: + return cls._point_on_arc(segment["center"], segment["radius"], segment["end_angle"]) + + @staticmethod + def _point_on_arc(center: Vec3, radius: float, angle: float) -> Vec3: + return Vec3( + center.x + radius * math.cos(angle), + center.y + radius * math.sin(angle), + 0.0, + ) + @classmethod def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): - # Reject degenerate curves with minimal length. span = (end - start).magnitude if span <= cls._POINT_TOL: return None @@ -73,19 +107,17 @@ def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): if abs(radius_start - radius_end) > max(radius_start, 1.0) * 1e-3: return None - # Ensure tangents are perpendicular to radius, within tolerance. if abs((start - center).dot(t_start)) > max(radius_start, 1.0) * 1e-3: return None if abs((end - center).dot(t_end)) > max(radius_start, 1.0) * 1e-3: return None - # Sample deviation from fitted circle. max_dev = 0.0 for t in (0.25, 0.5, 0.75): point = cls._evaluate_cubic(start, ctrl1, ctrl2, end, t) deviation = abs((point - center).magnitude - radius_start) max_dev = max(max_dev, deviation) - if max_dev > max(radius_start, 1.0) * cls._ARC_DEVIATION_FACTOR: + if max_dev > max(radius_start, 1.0) * cls._ARC_TOL: return None r0 = start - center @@ -94,6 +126,7 @@ def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): if abs(cross) <= max(radius_start, 1.0) * 1e-6: return None orientation = 1 if cross > 0 else -1 + start_angle = math.atan2(r0.y, r0.x) end_angle = math.atan2(r1.y, r1.x) if orientation > 0: @@ -107,138 +140,240 @@ def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): if sweep <= 1e-3: return None - is_full_circle = ( + full_circle = ( (end - start).magnitude <= max(radius_start, 1.0) * 1e-3 - and abs(sweep - 2.0 * math.pi) <= 1e-2 + and abs(sweep - 2.0 * math.pi) <= cls._FULL_CIRCLE_TOL ) return { + "type": "arc", "center": center, "radius": radius_start, "start_angle": start_angle, "end_angle": end_angle, "orientation": orientation, - "full_circle": is_full_circle, + "start": start, + "end": end, + "full_circle": full_circle, + } + + def _segments_form_circle(self, segments: list[dict]) -> dict | None: + if not segments: + return None + if len(segments) == 1 and segments[0]["type"] == "arc" and segments[0]["full_circle"]: + arc = segments[0] + return {"center": arc["center"], "radius": arc["radius"]} + if any(seg["type"] != "arc" for seg in segments): + return None + center = segments[0]["center"] + radius = segments[0]["radius"] + orientation = segments[0]["orientation"] + tol = max(radius, 1.0) * self._ARC_TOL + sweep = 0.0 + start_point = segments[0]["start"] + prev_end = start_point + for seg in segments: + if seg["orientation"] != orientation: + return None + if (seg["center"] - center).magnitude > tol: + return None + if abs(seg["radius"] - radius) > tol: + return None + if (seg["start"] - prev_end).magnitude > tol: + return None + sweep += self._arc_sweep(seg) + prev_end = seg["end"] + if (prev_end - start_point).magnitude > tol: + return None + if abs(abs(sweep) - 2.0 * math.pi) > self._FULL_CIRCLE_TOL: + return None + return {"center": center, "radius": radius} + + def _emit_circle(self, center: Vec3, radius: float) -> None: + self.msp.add_circle( + (center.x, center.y), + radius, + dxfattribs=self.entity_attribs, + ) + + def _emit_polyline(self, segments: list[dict]) -> None: + if not segments: + return + vertices: list[list[float]] = [] + tol = self._POINT_TOL + + def add_vertex(point: Vec3, bulge: float) -> None: + if vertices: + last = Vec3(vertices[-1][0], vertices[-1][1], 0.0) + if (point - last).magnitude <= tol: + vertices[-1][2] = bulge + return + vertices.append([point.x, point.y, bulge]) + + for idx, seg in enumerate(segments): + if seg["type"] == "line": + start = seg["start"] + end = seg["end"] + if (end - start).magnitude <= tol: + continue + if not vertices: + add_vertex(start, 0.0) + vertices[-1][2] = 0.0 + add_vertex(end, 0.0) + elif seg["type"] == "arc": + start = seg["start"] + end = seg["end"] + if (end - start).magnitude <= tol and not seg["full_circle"]: + continue + if not vertices: + add_vertex(start, 0.0) + sweep = self._arc_sweep(seg) + bulge = math.tan(abs(sweep) / 4.0) + if seg["orientation"] < 0: + bulge = -bulge + vertices[-1][2] = bulge + add_vertex(end, 0.0) + else: + raise ValueError(f"Unsupported segment type in polyline: {seg['type']}") + + if len(vertices) < 2: + return + start_pt = Vec3(vertices[0][0], vertices[0][1], 0.0) + end_pt = Vec3(vertices[-1][0], vertices[-1][1], 0.0) + is_closed = (end_pt - start_pt).magnitude <= tol + if is_closed: + vertices.pop() # closing flag already handles closing edge + + self.msp.add_lwpolyline( + [tuple(v) for v in vertices], + format="xyb", + close=is_closed, + dxfattribs=self.entity_attribs, + ) + + def _emit_texts(self, texts: list[tuple]) -> None: + if not texts: + return + align_map = { + "left": "LEFT", + "middle": "CENTER", + "end": "RIGHT", } + for x, y, text, params in texts: + if not text: + continue + height = float(params.get("fs", 2.0)) + alignment = align_map.get(params.get("align", "left"), "LEFT") + text_entity = self.msp.add_text( + text, + dxfattribs={ + "height": height, + "layer": self.layer, + }, + ) + text_entity.dxf.insert = (x, y, 0.0) + if alignment != "LEFT": + halign_codes = {"CENTER": 1, "RIGHT": 2} + text_entity.dxf.halign = halign_codes.get(alignment, 0) + text_entity.dxf.align_point = (x, y, 0.0) + + def _flush_block(self, segments: list[dict], texts: list[tuple]) -> None: + if not segments: + self._emit_texts(texts) + return + circle = self._segments_form_circle(segments) + if circle: + self._emit_circle(circle["center"], circle["radius"]) + else: + self._emit_polyline(segments) + self._emit_texts(texts) def add_commands(self, commands: Iterable[Command]) -> None: current_point: Vec3 | None = None - for raw in commands: - if not raw: + segments: list[dict] = [] + texts: list[tuple] = [] + + def flush(): + self._flush_block(segments, texts) + segments.clear() + texts.clear() + + for cmd in commands: + if not cmd: continue - code = raw[0] - if code == "M": - current_point = self._vec(raw[1], raw[2]) - elif code == "L": - end_point = self._vec(raw[1], raw[2]) - if current_point is not None: - self.msp.add_line(current_point, end_point, dxfattribs=self.entity_attribs) + letter = cmd[0] + if letter == "M": + flush() + current_point = self._vec(cmd[1], cmd[2]) + elif letter == "L": + if current_point is None: + raise ValueError("Line command without a starting point.") + end_point = self._vec(cmd[1], cmd[2]) + if (end_point - current_point).magnitude > self._POINT_TOL: + segments.append(self._line_segment(current_point, end_point)) current_point = end_point - elif code == "C": + elif letter == "C": if current_point is None: - raise ValueError("Bezier segment without a defined starting point.") - end_point = self._vec(raw[1], raw[2]) - ctrl1 = self._vec(raw[3], raw[4]) - ctrl2 = self._vec(raw[5], raw[6]) - if ( - (end_point - current_point).magnitude <= self._POINT_TOL - and (ctrl1 - current_point).magnitude <= self._POINT_TOL - and (ctrl2 - current_point).magnitude <= self._POINT_TOL - ): - current_point = end_point - continue + raise ValueError("Cubic command without a starting point.") + end_point = self._vec(cmd[1], cmd[2]) + ctrl1 = self._vec(cmd[3], cmd[4]) + ctrl2 = self._vec(cmd[5], cmd[6]) arc = self._try_cubic_as_arc(current_point, ctrl1, ctrl2, end_point) if arc: - start_deg = math.degrees(arc["start_angle"]) - end_deg = math.degrees(arc["end_angle"]) - center_tuple = (arc["center"].x, arc["center"].y) - attribs = dict(self.entity_attribs) - if arc["full_circle"]: - self.msp.add_circle(center_tuple, arc["radius"], dxfattribs=attribs) - else: - self.msp.add_arc( - center_tuple, - arc["radius"], - start_angle=start_deg, - end_angle=end_deg, - is_counter_clockwise=arc["orientation"] > 0, - dxfattribs=attribs, - ) + segments.append(arc) else: - bezier = Bezier4P((current_point, ctrl1, ctrl2, end_point)) - nurbs = bezier_to_bspline([bezier]) - spline_entity = self.msp.add_spline(dxfattribs=self.entity_attribs) - spline_entity.apply_construction_tool(nurbs) + points = self._approximate_cubic(current_point, ctrl1, ctrl2, end_point) + for idx in range(1, len(points)): + start = points[idx - 1] + end = points[idx] + if (end - start).magnitude > self._POINT_TOL: + segments.append(self._line_segment(start, end)) current_point = end_point - elif code == "A": - end_point = self._vec(raw[1], raw[2]) - center = self._vec(raw[3], raw[4]) - radius = abs(float(raw[5])) - start_param = float(raw[6]) - end_param = float(raw[7]) - orientation = int(raw[8]) - - start_point = self._vec( - center.x + radius * math.cos(start_param), - center.y + radius * math.sin(start_param), - ) + elif letter == "A": if current_point is None: - current_point = start_point - elif math.hypot(current_point.x - start_point.x, current_point.y - start_point.y) > 1e-4: - current_point = start_point - - start_angle_deg = math.degrees(math.atan2(current_point.y - center.y, current_point.x - center.x)) - end_angle_deg = math.degrees(math.atan2(end_point.y - center.y, end_point.x - center.x)) + raise ValueError("Arc command without a starting point.") + end_point = self._vec(cmd[1], cmd[2]) + center = self._vec(cmd[3], cmd[4]) + radius = abs(float(cmd[5])) + start_angle = math.atan2(current_point.y - center.y, current_point.x - center.x) + end_angle = math.atan2(end_point.y - center.y, end_point.x - center.x) + orientation = int(cmd[8]) + if orientation > 0: + while end_angle <= start_angle: + end_angle += 2.0 * math.pi + else: + while end_angle >= start_angle: + end_angle -= 2.0 * math.pi - sweep = end_param - start_param + sweep_param = float(cmd[7]) - float(cmd[6]) if orientation < 0: - sweep = -sweep - start_angle_deg, end_angle_deg = end_angle_deg, start_angle_deg - + sweep_param = -sweep_param is_full_circle = ( - math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) <= 1e-4 - and math.isclose(abs(sweep), 2.0 * math.pi, rel_tol=1e-6) + math.hypot(end_point.x - current_point.x, end_point.y - current_point.y) <= 1e-4 + and math.isclose(abs(sweep_param), 2.0 * math.pi, rel_tol=1e-6) ) - - center_tuple = (center.x, center.y) - if is_full_circle: - self.msp.add_circle(center_tuple, radius, dxfattribs=self.entity_attribs) - else: - self.msp.add_arc( - center_tuple, - radius, - start_angle=start_angle_deg, - end_angle=end_angle_deg, - dxfattribs=self.entity_attribs, - ) - current_point = end_point - elif code == "T": - text = raw[4] - if not text: - continue - x, y = float(raw[1]), float(raw[2]) - params = raw[5] if len(raw) > 5 else {} - height = float(params.get("fs", 2.0)) - align = params.get("align", "left") - align_map = { - "left": "LEFT", - "middle": "CENTER", - "end": "RIGHT", - } - text_entity = self.msp.add_text( - text, - dxfattribs={ - "height": height, - "layer": self.layer, - }, + segments.append( + { + "type": "arc", + "center": center, + "radius": radius, + "start_angle": start_angle, + "end_angle": end_angle, + "orientation": orientation if orientation != 0 else 1, + "start": current_point, + "end": end_point, + "full_circle": is_full_circle, + } ) - text_entity.dxf.insert = (x, y, 0.0) - alignment = align_map.get(align, "LEFT") - if alignment != "LEFT": - halign_codes = {"CENTER": 1, "RIGHT": 2} - text_entity.dxf.halign = halign_codes.get(alignment, 0) - text_entity.dxf.align_point = (x, y, 0.0) + current_point = end_point + elif letter == "T": + x, y = float(cmd[1]), float(cmd[2]) + text = cmd[4] + params = cmd[5] if len(cmd) > 5 else {} + texts.append((x, y, text, params)) else: - raise ValueError(f"Unsupported drawing command: {code}") + raise ValueError(f"Unsupported drawing command: {letter}") + + flush() def save(self, output: str | Path) -> Path: output_path = Path(output) From 4a3eb4d8ff213afb86c9424d58c5eaedb4f5bdf1 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 23 Oct 2025 08:59:16 -0300 Subject: [PATCH 26/30] =?UTF-8?q?boxes/ezdxf=5Fgenerator.py:16=20Added=20T?= =?UTF-8?q?ypedDict=20definitions=20for=20line,=20arc,=20circle,=20and=20t?= =?UTF-8?q?ext=20data=20so=20segment=20helpers=20share=20a=20consistent=20?= =?UTF-8?q?contract.=20boxes/ezdxf=5Fgenerator.py:62=20=5Flineweight=5Fto?= =?UTF-8?q?=5Fhundredths=20now=20clamps=20values=20to=20211=20hundredths,?= =?UTF-8?q?=20matching=20the=20DXF=20specification=20and=20avoiding=20inva?= =?UTF-8?q?lid=20entity=20weights.=20boxes/ezdxf=5Fgenerator.py:93=20Centr?= =?UTF-8?q?alized=20arc=20creation=20through=20=5Farc=5Fsegment,=20reducin?= =?UTF-8?q?g=20duplicated=20dictionary=20literals=20and=20keeping=20type?= =?UTF-8?q?=20hints=20aligned.=20boxes/ezdxf=5Fgenerator.py:211=20Updated?= =?UTF-8?q?=20circle=20detection=20to=20operate=20on=20the=20new=20Segment?= =?UTF-8?q?=20alias=20without=20changing=20the=20geometric=20checks.=20box?= =?UTF-8?q?es/ezdxf=5Fgenerator.py:424=20Text=20command=20parameters=20are?= =?UTF-8?q?=20copied=20only=20when=20a=20dict;=20double-check=20upstream?= =?UTF-8?q?=20callers=20don=E2=80=99t=20rely=20on=20non-dict=20mappings=20?= =?UTF-8?q?or=20those=20options=20will=20now=20be=20dropped.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- boxes/ezdxf_generator.py | 137 +++++++++++++++++++++++++++------------ 1 file changed, 95 insertions(+), 42 deletions(-) diff --git a/boxes/ezdxf_generator.py b/boxes/ezdxf_generator.py index 73a01c826..0aca18282 100644 --- a/boxes/ezdxf_generator.py +++ b/boxes/ezdxf_generator.py @@ -2,7 +2,7 @@ import math from pathlib import Path -from typing import Iterable, Sequence +from typing import Any, Iterable, Literal, Sequence, TypedDict import ezdxf from ezdxf import units @@ -13,6 +13,34 @@ Command = Sequence[object] +class LineSegment(TypedDict): + type: Literal["line"] + start: Vec3 + end: Vec3 + + +class ArcSegment(TypedDict): + type: Literal["arc"] + center: Vec3 + radius: float + start_angle: float + end_angle: float + orientation: int + start: Vec3 + end: Vec3 + full_circle: bool + + +class CircleSummary(TypedDict): + center: Vec3 + radius: float + + +Segment = LineSegment | ArcSegment +TextParams = dict[str, Any] +TextSpec = tuple[float, float, str, TextParams] + + class EZDXFBuilder: """Render drawing commands into DXF entities using ezdxf.""" @@ -32,7 +60,8 @@ def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None @staticmethod def _lineweight_to_hundredths(value: float) -> int: - return max(0, int(round(value * 100.0))) + hundredths = int(round(value * 100.0)) + return min(211, max(0, hundredths)) @staticmethod def _vec(x: float, y: float) -> Vec3: @@ -57,21 +86,45 @@ def _approximate_cubic(self, p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, steps: int return points @staticmethod - def _line_segment(start: Vec3, end: Vec3) -> dict[str, Vec3]: + def _line_segment(start: Vec3, end: Vec3) -> LineSegment: return {"type": "line", "start": start, "end": end} @staticmethod - def _arc_sweep(segment: dict) -> float: + def _arc_segment( + *, + center: Vec3, + radius: float, + start_angle: float, + end_angle: float, + orientation: int, + start: Vec3, + end: Vec3, + full_circle: bool, + ) -> ArcSegment: + return { + "type": "arc", + "center": center, + "radius": radius, + "start_angle": start_angle, + "end_angle": end_angle, + "orientation": orientation, + "start": start, + "end": end, + "full_circle": full_circle, + } + + @staticmethod + def _arc_sweep(segment: ArcSegment) -> float: if segment["orientation"] > 0: return segment["end_angle"] - segment["start_angle"] return segment["start_angle"] - segment["end_angle"] @classmethod - def _arc_start_point(cls, segment: dict) -> Vec3: + def _arc_start_point(cls, segment: ArcSegment) -> Vec3: return cls._point_on_arc(segment["center"], segment["radius"], segment["start_angle"]) @classmethod - def _arc_end_point(cls, segment: dict) -> Vec3: + def _arc_end_point(cls, segment: ArcSegment) -> Vec3: return cls._point_on_arc(segment["center"], segment["radius"], segment["end_angle"]) @staticmethod @@ -83,7 +136,7 @@ def _point_on_arc(center: Vec3, radius: float, angle: float) -> Vec3: ) @classmethod - def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): + def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3) -> ArcSegment | None: span = (end - start).magnitude if span <= cls._POINT_TOL: return None @@ -144,29 +197,30 @@ def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3): (end - start).magnitude <= max(radius_start, 1.0) * 1e-3 and abs(sweep - 2.0 * math.pi) <= cls._FULL_CIRCLE_TOL ) - return { - "type": "arc", - "center": center, - "radius": radius_start, - "start_angle": start_angle, - "end_angle": end_angle, - "orientation": orientation, - "start": start, - "end": end, - "full_circle": full_circle, - } + return cls._arc_segment( + center=center, + radius=radius_start, + start_angle=start_angle, + end_angle=end_angle, + orientation=orientation, + start=start, + end=end, + full_circle=full_circle, + ) - def _segments_form_circle(self, segments: list[dict]) -> dict | None: + def _segments_form_circle(self, segments: list[Segment]) -> CircleSummary | None: if not segments: return None - if len(segments) == 1 and segments[0]["type"] == "arc" and segments[0]["full_circle"]: - arc = segments[0] - return {"center": arc["center"], "radius": arc["radius"]} + first = segments[0] + if first["type"] != "arc": + return None + if len(segments) == 1 and first["full_circle"]: + return {"center": first["center"], "radius": first["radius"]} if any(seg["type"] != "arc" for seg in segments): return None - center = segments[0]["center"] - radius = segments[0]["radius"] - orientation = segments[0]["orientation"] + center = first["center"] + radius = first["radius"] + orientation = first["orientation"] tol = max(radius, 1.0) * self._ARC_TOL sweep = 0.0 start_point = segments[0]["start"] @@ -195,7 +249,7 @@ def _emit_circle(self, center: Vec3, radius: float) -> None: dxfattribs=self.entity_attribs, ) - def _emit_polyline(self, segments: list[dict]) -> None: + def _emit_polyline(self, segments: list[Segment]) -> None: if not segments: return vertices: list[list[float]] = [] @@ -250,7 +304,7 @@ def add_vertex(point: Vec3, bulge: float) -> None: dxfattribs=self.entity_attribs, ) - def _emit_texts(self, texts: list[tuple]) -> None: + def _emit_texts(self, texts: list[TextSpec]) -> None: if not texts: return align_map = { @@ -276,7 +330,7 @@ def _emit_texts(self, texts: list[tuple]) -> None: text_entity.dxf.halign = halign_codes.get(alignment, 0) text_entity.dxf.align_point = (x, y, 0.0) - def _flush_block(self, segments: list[dict], texts: list[tuple]) -> None: + def _flush_block(self, segments: list[Segment], texts: list[TextSpec]) -> None: if not segments: self._emit_texts(texts) return @@ -289,8 +343,8 @@ def _flush_block(self, segments: list[dict], texts: list[tuple]) -> None: def add_commands(self, commands: Iterable[Command]) -> None: current_point: Vec3 | None = None - segments: list[dict] = [] - texts: list[tuple] = [] + segments: list[Segment] = [] + texts: list[TextSpec] = [] def flush(): self._flush_block(segments, texts) @@ -352,23 +406,22 @@ def flush(): and math.isclose(abs(sweep_param), 2.0 * math.pi, rel_tol=1e-6) ) segments.append( - { - "type": "arc", - "center": center, - "radius": radius, - "start_angle": start_angle, - "end_angle": end_angle, - "orientation": orientation if orientation != 0 else 1, - "start": current_point, - "end": end_point, - "full_circle": is_full_circle, - } + self._arc_segment( + center=center, + radius=radius, + start_angle=start_angle, + end_angle=end_angle, + orientation=orientation if orientation != 0 else 1, + start=current_point, + end=end_point, + full_circle=is_full_circle, + ) ) current_point = end_point elif letter == "T": x, y = float(cmd[1]), float(cmd[2]) text = cmd[4] - params = cmd[5] if len(cmd) > 5 else {} + params: TextParams = dict(cmd[5]) if len(cmd) > 5 and isinstance(cmd[5], dict) else {} texts.append((x, y, text, params)) else: raise ValueError(f"Unsupported drawing command: {letter}") From 2939426fa3d62fb525e5715c6e9b336592be180d Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 23 Oct 2025 12:41:26 -0300 Subject: [PATCH 27/30] Replace custom DXF exporter with ezdxf builder - introduce an ezdxf-based DXFSurface and helper builder for generating entities - update format/test-path imports to use the new surface - declare the ezdxf dependency needed for DXF output --- boxes/drawing.py | 5 - boxes/dxf.py | 492 ------------------ .../{ezdxf_generator.py => dxf_generator.py} | 58 ++- boxes/formats.py | 2 +- boxes/test_path.py | 2 +- requirements.txt | 1 + 6 files changed, 59 insertions(+), 501 deletions(-) delete mode 100644 boxes/dxf.py rename boxes/{ezdxf_generator.py => dxf_generator.py} (88%) diff --git a/boxes/drawing.py b/boxes/drawing.py index 997a82ee6..b2760b7ad 100644 --- a/boxes/drawing.py +++ b/boxes/drawing.py @@ -1,5 +1,4 @@ from __future__ import annotations - import codecs import io import math @@ -889,10 +888,6 @@ def finish(self, inner_corners="loop", dogbone_radius=None): data.seek(0) return data - -from .dxf import DXFSurface # noqa: E402 - - class LBRN2Surface(Surface): diff --git a/boxes/dxf.py b/boxes/dxf.py deleted file mode 100644 index f68d9fa6b..000000000 --- a/boxes/dxf.py +++ /dev/null @@ -1,492 +0,0 @@ -from __future__ import annotations - -import io -import math -from typing import Any, Sequence - -from .drawing import EPS, Surface, points_equal - -__all__ = ["DXFSurface"] - - -class DXFSurface(Surface): - """Surface capable of turning drawing commands into a DXF byte stream.""" - - scale = 1.0 - invert_y = False - - def finish(self, inner_corners: str = "loop", dogbone_radius=None): - extents = self.prepare_paths(inner_corners, dogbone_radius) - entities: list[str] = [] - for part in self.parts: - if not part.pathes: - continue - for path in part.pathes: - entities.extend(self._entities_from_path(path.path)) - - return self._build_dxf(extents, entities) - - @staticmethod - def _pair(container: list[str], code: int, value: Any) -> None: - container.append(f"{code:>3}") - container.append(str(value)) - - @staticmethod - def _format_angle(angle_deg: float) -> float: - angle = angle_deg % 360.0 - if math.isclose(angle, 360.0, abs_tol=1e-9): - angle = 0.0 - return angle - - def _entities_from_path(self, commands): - entities: list[str] = [] - current: tuple[float, float] | None = None - pending_arc: dict[str, Any] | None = None - sweep_tol = 1e-4 - circle_sweep = 2.0 * math.pi - - def flush_pending_arc() -> None: - nonlocal pending_arc - if not pending_arc: - return - emit_arc_entity( - pending_arc["center"], - pending_arc["radius"], - pending_arc["start_angle"], - pending_arc["end_angle"], - pending_arc["orientation"], - ) - pending_arc = None - - def emit_arc_entity( - center: tuple[float, float], - radius: float, - start_angle: float, - end_angle: float, - orientation: int, - ) -> None: - if orientation < 0: - start_angle, end_angle = end_angle, start_angle - start_deg = self._format_angle(math.degrees(start_angle)) - end_deg = self._format_angle(math.degrees(end_angle)) - entities.extend( - self._arc_entity(center, radius, start_deg, end_deg) - ) - - def process_arc_segment( - start_point: tuple[float, float], - end_point: tuple[float, float], - center: tuple[float, float], - radius: float, - start_angle_rad: float, - end_angle_rad: float, - orientation: int, - ) -> None: - nonlocal pending_arc - if radius <= EPS: - flush_pending_arc() - return - orient = 1 if orientation >= 0 else -1 - start_angle, end_angle = self._normalize_arc_angles( - float(start_angle_rad), float(end_angle_rad), orient - ) - sweep = end_angle - start_angle if orient > 0 else start_angle - end_angle - if sweep <= 1e-9: - flush_pending_arc() - return - segment = { - "center": center, - "radius": abs(radius), - "orientation": orient, - "start_angle": start_angle, - "end_angle": end_angle, - "start_point": start_point, - "end_point": end_point, - "sweep": sweep, - } - if points_equal(start_point[0], start_point[1], end_point[0], end_point[1]) and abs( - sweep - circle_sweep - ) <= sweep_tol: - flush_pending_arc() - entities.extend(self._circle_entity(center, abs(radius))) - return - - if pending_arc: - same_orientation = orient == pending_arc["orientation"] - same_radius = abs(segment["radius"] - pending_arc["radius"]) <= 1e-4 - center_dx = segment["center"][0] - pending_arc["center"][0] - center_dy = segment["center"][1] - pending_arc["center"][1] - same_center = math.hypot(center_dx, center_dy) <= 1e-4 - contiguous = points_equal( - pending_arc["end_point"][0], - pending_arc["end_point"][1], - start_point[0], - start_point[1], - ) - if same_orientation and same_radius and same_center and contiguous: - predicted_sweep = pending_arc["total_sweep"] + sweep - if predicted_sweep > circle_sweep + sweep_tol: - flush_pending_arc() - else: - pending_arc["total_sweep"] = predicted_sweep - pending_arc["end_point"] = end_point - pending_arc["end_angle"] = segment["end_angle"] - if ( - points_equal( - pending_arc["start_point"][0], - pending_arc["start_point"][1], - end_point[0], - end_point[1], - ) - and abs(predicted_sweep - circle_sweep) <= sweep_tol - ): - entities.extend(self._circle_entity(center, abs(radius))) - pending_arc = None - return - else: - flush_pending_arc() - - pending_arc = { - "center": segment["center"], - "radius": segment["radius"], - "orientation": segment["orientation"], - "total_sweep": segment["sweep"], - "start_angle": segment["start_angle"], - "end_angle": segment["end_angle"], - "start_point": start_point, - "end_point": end_point, - } - - for cmd in commands: - letter = cmd[0] - if letter == "M": - flush_pending_arc() - current = (cmd[1], cmd[2]) - elif letter == "L": - flush_pending_arc() - target = (cmd[1], cmd[2]) - if current and not points_equal(current[0], current[1], target[0], target[1]): - entities.extend(self._line_entity(current, target)) - current = target - elif letter == "C": - if current is None: - current = (cmd[1], cmd[2]) - continue - arc = self._cubic_to_arc(current, cmd) - if arc: - ( - center, - radius, - start_angle_deg, - end_angle_deg, - orientation, - is_full_circle, - ) = arc - if is_full_circle: - flush_pending_arc() - entities.extend(self._circle_entity(center, radius)) - else: - process_arc_segment( - current, - (cmd[1], cmd[2]), - center, - radius, - math.radians(start_angle_deg), - math.radians(end_angle_deg), - orientation, - ) - current = (cmd[1], cmd[2]) - continue - flush_pending_arc() - control1 = (cmd[3], cmd[4]) - control2 = (cmd[5], cmd[6]) - end_point = (cmd[1], cmd[2]) - prev = current - for point in self._approximate_cubic(current, control1, control2, end_point): - if not points_equal(prev[0], prev[1], point[0], point[1]): - entities.extend(self._line_entity(prev, point)) - prev = point - current = end_point - elif letter == "A": - if current is None: - current = (cmd[1], cmd[2]) - end_point = (cmd[1], cmd[2]) - center = (cmd[3], cmd[4]) - radius = cmd[5] - start_angle_rad = float(cmd[6]) - end_angle_rad = float(cmd[7]) - orientation = cmd[8] - if radius > EPS: - process_arc_segment( - current, - end_point, - center, - radius, - start_angle_rad, - end_angle_rad, - orientation, - ) - current = end_point - elif letter == "T": - flush_pending_arc() - text_entities = self._text_entity(cmd) - if text_entities: - entities.extend(text_entities) - flush_pending_arc() - return entities - - def _line_entity(self, start, end): - if points_equal(start[0], start[1], end[0], end[1]): - return [] - items: list[str] = [] - self._pair(items, 0, "LINE") - self._pair(items, 8, "0") - self._pair(items, 10, f"{start[0]:.6f}") - self._pair(items, 20, f"{start[1]:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 11, f"{end[0]:.6f}") - self._pair(items, 21, f"{end[1]:.6f}") - self._pair(items, 31, "0.0") - return items - - def _arc_entity(self, center, radius, start_angle, end_angle): - items: list[str] = [] - self._pair(items, 0, "ARC") - self._pair(items, 8, "0") - self._pair(items, 10, f"{center[0]:.6f}") - self._pair(items, 20, f"{center[1]:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 40, f"{abs(radius):.6f}") - self._pair(items, 50, f"{start_angle:.6f}") - self._pair(items, 51, f"{end_angle:.6f}") - return items - - def _circle_entity(self, center, radius): - items: list[str] = [] - self._pair(items, 0, "CIRCLE") - self._pair(items, 8, "0") - self._pair(items, 10, f"{center[0]:.6f}") - self._pair(items, 20, f"{center[1]:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 40, f"{abs(radius):.6f}") - return items - - @staticmethod - def _normalize_vector(dx: float, dy: float) -> tuple[float, float] | None: - length = math.hypot(dx, dy) - if length <= EPS: - return None - return dx / length, dy / length - - @staticmethod - def _rotate_vector(vec: tuple[float, float], orientation: int) -> tuple[float, float]: - x, y = vec - return (-y, x) if orientation > 0 else (y, -x) - - @staticmethod - def _cross(a: tuple[float, float], b: tuple[float, float]) -> float: - return a[0] * b[1] - a[1] * b[0] - - def _line_intersection_params( - self, - origin_a: tuple[float, float], - dir_a: tuple[float, float], - origin_b: tuple[float, float], - dir_b: tuple[float, float], - ) -> tuple[float, float, tuple[float, float]] | None: - det = self._cross(dir_a, dir_b) - if abs(det) <= 1e-9: - return None - diff = (origin_b[0] - origin_a[0], origin_b[1] - origin_a[1]) - u = self._cross(diff, dir_b) / det - v = self._cross(diff, dir_a) / det - center = ( - origin_a[0] + u * dir_a[0], - origin_a[1] + u * dir_a[1], - ) - return u, v, center - - @staticmethod - def _evaluate_cubic( - start: tuple[float, float], - ctrl1: tuple[float, float], - ctrl2: tuple[float, float], - end: tuple[float, float], - t: float, - ) -> tuple[float, float]: - mt = 1.0 - t - x = ( - mt * mt * mt * start[0] - + 3 * mt * mt * t * ctrl1[0] - + 3 * mt * t * t * ctrl2[0] - + t * t * t * end[0] - ) - y = ( - mt * mt * mt * start[1] - + 3 * mt * mt * t * ctrl1[1] - + 3 * mt * t * t * ctrl2[1] - + t * t * t * end[1] - ) - return x, y - - @staticmethod - def _normalize_arc_angles(start_angle: float, end_angle: float, orientation: int) -> tuple[float, float]: - if orientation > 0: - while end_angle <= start_angle + 1e-9: - end_angle += 2.0 * math.pi - else: - while end_angle >= start_angle - 1e-9: - end_angle -= 2.0 * math.pi - return start_angle, end_angle - - def _cubic_to_arc( - self, start: tuple[float, float], cmd: Sequence[float] - ) -> tuple[tuple[float, float], float, float, float, int, bool] | None: - end = (cmd[1], cmd[2]) - ctrl1 = (cmd[3], cmd[4]) - ctrl2 = (cmd[5], cmd[6]) - - tangent_start = self._normalize_vector(ctrl1[0] - start[0], ctrl1[1] - start[1]) - tangent_end = self._normalize_vector(end[0] - ctrl2[0], end[1] - ctrl2[1]) - if tangent_start is None or tangent_end is None: - return None - - for orientation in (1, -1): - normal_start = self._rotate_vector(tangent_start, orientation) - normal_end = self._rotate_vector(tangent_end, orientation) - intersection = self._line_intersection_params(start, normal_start, end, normal_end) - if intersection is None: - continue - u, v, center = intersection - if u <= EPS or v <= EPS: - continue - radius_start = math.hypot(center[0] - start[0], center[1] - start[1]) - radius_end = math.hypot(center[0] - end[0], center[1] - end[1]) - if radius_start <= EPS or abs(radius_start - radius_end) > max(1.0, radius_start) * 1e-3: - continue - dot_start = (center[0] - start[0]) * normal_start[0] + (center[1] - start[1]) * normal_start[1] - dot_end = (center[0] - end[0]) * normal_end[0] + (center[1] - end[1]) * normal_end[1] - if dot_start < 0 or dot_end < 0: - continue - start_vec = (start[0] - center[0], start[1] - center[1]) - end_vec = (end[0] - center[0], end[1] - center[1]) - cross = self._cross(start_vec, end_vec) - actual_orientation = 1 if cross > 0 else -1 if cross < 0 else 0 - if actual_orientation == 0 or actual_orientation != orientation: - continue - start_angle = math.atan2(start_vec[1], start_vec[0]) - end_angle = math.atan2(end_vec[1], end_vec[0]) - start_angle, end_angle = self._normalize_arc_angles(start_angle, end_angle, orientation) - midpoint = self._evaluate_cubic(start, ctrl1, ctrl2, end, 0.5) - radius_mid = math.hypot(midpoint[0] - center[0], midpoint[1] - center[1]) - if abs(radius_mid - radius_start) > max(1.0, radius_start) * 5e-4: - continue - sweep = end_angle - start_angle if orientation > 0 else start_angle - end_angle - is_full_circle = ( - points_equal(start[0], start[1], end[0], end[1]) - and math.isclose(abs(sweep), 2.0 * math.pi, abs_tol=1e-6) - ) - start_angle_deg = math.degrees(start_angle) - end_angle_deg = math.degrees(end_angle) - start_angle_deg = self._format_angle(start_angle_deg) - end_angle_deg = self._format_angle(end_angle_deg) - return ( - center, - radius_start, - start_angle_deg, - end_angle_deg, - orientation, - is_full_circle, - ) - return None - - def _text_entity(self, cmd): - _, x, y, _m, text, params = cmd - if not text: - return [] - height = params.get("fs", 10.0) - items: list[str] = [] - self._pair(items, 0, "TEXT") - self._pair(items, 8, "0") - self._pair(items, 10, f"{x:.6f}") - self._pair(items, 20, f"{y:.6f}") - self._pair(items, 30, "0.0") - self._pair(items, 40, f"{height:.6f}") - self._pair(items, 1, text) - return items - - def _approximate_cubic(self, p0, p1, p2, p3, steps=12): - result: list[tuple[float, float]] = [] - for step in range(1, steps + 1): - t = step / steps - mt = 1.0 - t - x = ( - mt * mt * mt * p0[0] - + 3 * mt * mt * t * p1[0] - + 3 * mt * t * t * p2[0] - + t * t * t * p3[0] - ) - y = ( - mt * mt * mt * p0[1] - + 3 * mt * mt * t * p1[1] - + 3 * mt * t * t * p2[1] - + t * t * t * p3[1] - ) - result.append((x, y)) - return result - - def _build_dxf(self, extents, entities): - lines: list[str] = [] - - def add(code: int, value: Any) -> None: - self._pair(lines, code, value) - - add(0, "SECTION") - add(2, "HEADER") - add(9, "$ACADVER") - add(1, "AC1009") - add(9, "$INSUNITS") - add(70, 4) - add(9, "$MEASUREMENT") - add(70, 1) - add(9, "$EXTMIN") - add(10, f"{extents.xmin:.6f}") - add(20, f"{extents.ymin:.6f}") - add(30, "0.0") - add(9, "$EXTMAX") - add(10, f"{extents.xmax:.6f}") - add(20, f"{extents.ymax:.6f}") - add(30, "0.0") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "TABLES") - add(0, "TABLE") - add(2, "LAYER") - add(70, 1) - add(0, "LAYER") - add(2, "0") - add(70, 0) - add(62, 7) - add(6, "CONTINUOUS") - add(0, "ENDTAB") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "ENTITIES") - lines.extend(entities) - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "BLOCKS") - add(0, "ENDSEC") - - add(0, "SECTION") - add(2, "OBJECTS") - add(0, "ENDSEC") - add(0, "EOF") - - data = ("\r\n".join(lines) + "\r\n").encode("ascii", "ignore") - buffer = io.BytesIO(data) - buffer.seek(0) - return buffer diff --git a/boxes/ezdxf_generator.py b/boxes/dxf_generator.py similarity index 88% rename from boxes/ezdxf_generator.py rename to boxes/dxf_generator.py index 0aca18282..d8132478d 100644 --- a/boxes/ezdxf_generator.py +++ b/boxes/dxf_generator.py @@ -1,5 +1,7 @@ from __future__ import annotations +import io +import logging import math from pathlib import Path from typing import Any, Iterable, Literal, Sequence, TypedDict @@ -7,11 +9,17 @@ import ezdxf from ezdxf import units from ezdxf.math import Vec3 - -from boxes.test_path import Test_Path +from .drawing import Surface Command = Sequence[object] +__all__ = ["EZDXFBuilder", "DXFSurface", "export_test_path_ezdxf"] + + +_ezdxf_logger = logging.getLogger("ezdxf") +_ezdxf_logger.setLevel(logging.ERROR) +_ezdxf_logger.propagate = False + class LineSegment(TypedDict): type: Literal["line"] @@ -51,6 +59,8 @@ class EZDXFBuilder: def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None: self.doc = ezdxf.new("R2010", setup=True) self.doc.units = units.MM + self.doc.header["$INSUNITS"] = units.MM + self.doc.header["$MEASUREMENT"] = 1 self.msp = self.doc.modelspace() self.layer = layer lw_value = self._lineweight_to_hundredths(lineweight) if lineweight is not None else None @@ -58,6 +68,27 @@ def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None if lw_value is not None: self.entity_attribs["lineweight"] = lw_value + def set_lineweight(self, value: float | None) -> None: + """Update the current entity lineweight in hundredths of millimeters.""" + if value is None or value <= 0.0: + self.entity_attribs.pop("lineweight", None) + return + self.entity_attribs["lineweight"] = self._lineweight_to_hundredths(value) + + def set_extents(self, xmin: float, ymin: float, xmax: float, ymax: float) -> None: + """Write drawing extents to the DXF header.""" + self.doc.header["$EXTMIN"] = (float(xmin), float(ymin), 0.0) + self.doc.header["$EXTMAX"] = (float(xmax), float(ymax), 0.0) + + def to_buffer(self) -> io.BytesIO: + """Return the DXF document encoded into an in-memory buffer.""" + stream = io.StringIO() + self.doc.write(stream) + data = self.doc.encode(stream.getvalue()) + buffer = io.BytesIO(data) + buffer.seek(0) + return buffer + @staticmethod def _lineweight_to_hundredths(value: float) -> int: hundredths = int(round(value * 100.0)) @@ -436,10 +467,33 @@ def save(self, output: str | Path) -> Path: def export_test_path_ezdxf(output: str | Path = "test_path_ezdxf.dxf") -> Path: + from boxes.test_path import Test_Path + builder = EZDXFBuilder(lineweight=0.1) builder.add_commands(Test_Path) return builder.save(output) +class DXFSurface(Surface): + """Surface capable of producing DXF output via EZDXFBuilder.""" + + scale = 1.0 + invert_y = False + + def finish(self, inner_corners: str = "loop", dogbone_radius=None): + extents = self.prepare_paths(inner_corners, dogbone_radius) + builder = EZDXFBuilder() + builder.set_extents(extents.xmin, extents.ymin, extents.xmax, extents.ymax) + for part in self.parts: + if not part.pathes: + continue + for path in part.pathes: + if not path.path: + continue + builder.set_lineweight(path.params.get("lw")) + builder.add_commands(path.path) + return builder.to_buffer() + + if __name__ == "__main__": export_test_path_ezdxf() diff --git a/boxes/formats.py b/boxes/formats.py index a1439f47c..381592f4d 100644 --- a/boxes/formats.py +++ b/boxes/formats.py @@ -20,7 +20,7 @@ import tempfile import io from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface -from boxes.dxf import DXFSurface +from boxes.dxf_generator import DXFSurface class Formats: diff --git a/boxes/test_path.py b/boxes/test_path.py index 6ac5b70d8..d3a093638 100644 --- a/boxes/test_path.py +++ b/boxes/test_path.py @@ -5,7 +5,7 @@ from typing import Any, MutableSequence from affine import Affine -from boxes.dxf import DXFSurface +from boxes.dxf_generator import DXFSurface from boxes.drawing import SVGSurface PathLike = MutableSequence[MutableSequence[Any]] diff --git a/requirements.txt b/requirements.txt index 4cbdfce2c..4af167567 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ markdown qrcode>=7.3.1 setuptools shapely>=1.8.2 +ezdxf>=1.3 From c44a0471e27eafc2aad7f9a2db1237c2d865d446 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Thu, 23 Oct 2025 21:33:45 -0300 Subject: [PATCH 28/30] Finish dogbone corners implementation --- boxes/dogbone.py | 172 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 51 deletions(-) diff --git a/boxes/dogbone.py b/boxes/dogbone.py index 35b689423..b66d0f4df 100644 --- a/boxes/dogbone.py +++ b/boxes/dogbone.py @@ -5,7 +5,6 @@ from boxes.vectors import ( dotproduct, - normalize as normalize_vec, vadd, vdiff, vlength, @@ -19,6 +18,12 @@ Point = tuple[float, float] TAU = math.tau +SQRT2 = math.sqrt(2.0) +SQRT_INNER = math.sqrt(2.5 - SQRT2) +DOGBONE_CLEARANCE_FACTOR = 1.0 + SQRT2 / 2.0 + SQRT_INNER +DOGBONE_PREV_CLEARANCE_FACTOR = DOGBONE_CLEARANCE_FACTOR - 1.0 +END_OFFSET_NEXT_FACTOR = 0.5 * (SQRT2 / 2.0 - 1.0) +END_OFFSET_PREV_FACTOR = 0.5 * (SQRT2 + SQRT_INNER) def _normalize_angles(start: float, end: float, orientation: int, eps: float) -> tuple[float, float]: @@ -78,8 +83,7 @@ def _circle_intersections(center_a: Point, radius_a: float, center_b: Point, rad def dogbone_clearance(radius: float) -> float: """Return c = R * (1 + sqrt(2)/2 + sqrt(5/2 - sqrt(2))).""" # Closed form of the clearance distance used by Boxes.py. - sqrt2 = math.sqrt(2.0) - return radius * (1.0 + sqrt2 / 2.0 + math.sqrt(2.5 - sqrt2)) + return radius * DOGBONE_CLEARANCE_FACTOR def apply_dogbone( @@ -99,22 +103,20 @@ def apply_dogbone( if radius <= 0: return False - sqrt2 = math.sqrt(2.0) - offset = sqrt2 * radius + offset = SQRT2 * radius if offset < eps: return False # These offsets define where the auxiliary arcs start and end relative to the corner. - sqrt_inner = math.sqrt(2.5 - sqrt2) - clearance = dogbone_clearance(radius) - prev_clearance = clearance - radius - end_offset_next = (radius / 2.0) * (sqrt2 / 2.0 - 1.0) - end_offset_prev = (radius / 2.0) * (sqrt2 + sqrt_inner) + prev_clearance = radius * DOGBONE_PREV_CLEARANCE_FACTOR + end_offset_next = radius * END_OFFSET_NEXT_FACTOR + end_offset_prev = radius * END_OFFSET_PREV_FACTOR def _normalize(vec: Vector) -> Vector | None: - if vlength(vec) < eps: + length = vlength(vec) + if length < eps: return None - return normalize_vec(vec) + return (vec[0] / length, vec[1] / length) def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> list[Any] | None: radius = vlength(vdiff(center, start)) @@ -160,12 +162,6 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l prev_vec = vdiff(p11, corner) next_vec = vdiff(corner, p22) - prev_len = vlength(prev_vec) - next_len = vlength(next_vec) - if prev_len <= eps or next_len <= eps: - i += 1 - continue - d_prev = _normalize(prev_vec) d_next = _normalize(next_vec) if d_prev is None or d_next is None: @@ -183,10 +179,7 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l continue sign = 1.0 if turn > 0.0 else -1.0 - inward = vadd( - vscalmul(vorthogonal(d_prev), sign), - vscalmul(vorthogonal(d_next), sign), - ) + inward = vscalmul(vadd(vorthogonal(d_prev), vorthogonal(d_next)), sign) n_in = _normalize(inward) if n_in is None: i += 1 @@ -205,13 +198,11 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l vscalmul(axis_prev, end_offset_prev), ), ) - axis_prev_post = axis_next - axis_next_post = axis_prev transition_point_next = vadd( corner, vadd( - vscalmul(axis_next_post, end_offset_next), - vscalmul(axis_prev_post, end_offset_prev), + vscalmul(axis_prev, end_offset_next), + vscalmul(axis_next, end_offset_prev), ), ) new_arc_start = vadd(corner, vscalmul(axis_prev, prev_clearance)) @@ -219,10 +210,10 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l corner, vadd(vscalmul(axis_prev, prev_clearance), vscalmul(axis_next, -radius)), ) - new_arc_end = vadd(corner, vscalmul(axis_prev_post, prev_clearance)) + new_arc_end = vadd(corner, vscalmul(axis_next, prev_clearance)) new_arc_center_next = vadd( corner, - vadd(vscalmul(axis_prev_post, prev_clearance), vscalmul(axis_next_post, -radius)), + vadd(vscalmul(axis_next, prev_clearance), vscalmul(axis_prev, -radius)), ) rad_start = vdiff(center, transition_point) @@ -266,9 +257,30 @@ def _arc_command(center: Point, start: Point, end: Point, orientation: int) -> l def _trim_overlaps(path: PathLike, eps: float) -> None: overlaps: list[dict[str, Any]] = [] + overlap_positions: dict[int, int] = {} arcs_since_line: list[dict[str, Any]] = [] line_state: dict[str, Any] | None = None current: Point | None = None + first_right_arcs: list[dict[str, Any]] = [] + capture_first_right_arcs = False + first_line_active = False + subpath_start: Point | None = None + subpath_move_index: int | None = None + first_line_state: dict[str, Any] | None = None + first_left_arc_indices: set[int] = set() + move_targets: dict[int, Point] = {} + + def update_best(line_data: dict[str, Any] | None, candidate: dict[str, Any] | None) -> None: + if line_data is None or candidate is None: + return + best = line_data.get("best") + if best is None or candidate["score"] < best["score"]: + line_data["best"] = candidate + + def consider_right_arc(line_data: dict[str, Any] | None, right_arc: dict[str, Any]) -> None: + if line_data is None or not line_data.get("left_arcs"): + return + update_best(line_data, evaluate_candidate(line_data, right_arc)) def evaluate_candidate(line_data: dict[str, Any], right_arc: dict[str, Any]) -> dict[str, Any] | None: best: dict[str, Any] | None = None @@ -306,18 +318,67 @@ def evaluate_candidate(line_data: dict[str, Any], right_arc: dict[str, Any]) -> best = candidate return best - def finalize_line_state() -> None: - nonlocal line_state - if line_state and line_state.get("best"): - overlaps.append(line_state["best"]) + def record_overlap(candidate: dict[str, Any]) -> None: + line_idx = candidate["line_index"] + existing_pos = overlap_positions.get(line_idx) + if existing_pos is None: + overlap_positions[line_idx] = len(overlaps) + overlaps.append(candidate) + else: + existing = overlaps[existing_pos] + if candidate["score"] < existing["score"]: + overlaps[existing_pos] = candidate + + def finalize_line_state(close_subpath: bool = False) -> None: + nonlocal line_state, first_line_state + if ( + close_subpath + and line_state + and first_right_arcs + and subpath_start is not None + ): + end = line_state["end"] + if abs(end[0] - subpath_start[0]) <= eps and abs(end[1] - subpath_start[1]) <= eps: + wrap_left_arcs = line_state["left_arcs"] + if first_line_state and wrap_left_arcs: + for arc in wrap_left_arcs: + idx = arc["index"] + if idx not in first_left_arc_indices: + first_line_state["left_arcs"].append(arc) + first_left_arc_indices.add(idx) + for right_arc in first_right_arcs: + consider_right_arc(first_line_state, right_arc) + for right_arc in first_right_arcs: + consider_right_arc(line_state, right_arc) + if line_state: + best_candidate = line_state.get("best") + if best_candidate: + if close_subpath and subpath_move_index is not None: + move_targets[subpath_move_index] = best_candidate["point"] + record_overlap(best_candidate) + if close_subpath and first_line_state: + best_candidate = first_line_state.get("best") + if best_candidate: + record_overlap(best_candidate) + if close_subpath: + first_right_arcs.clear() + first_line_state = None + first_left_arc_indices.clear() line_state = None for index, segment in enumerate(path): code = segment[0] if code == "M": - finalize_line_state() + finalize_line_state(close_subpath=True) current = (segment[1], segment[2]) arcs_since_line = [] + subpath_start = current + subpath_move_index = index + first_right_arcs.clear() + first_line_active = False + capture_first_right_arcs = False + first_line_state = None + first_left_arc_indices.clear() elif code == "L": finalize_line_state() start = current if current is not None else (segment[1], segment[2]) @@ -326,11 +387,19 @@ def finalize_line_state() -> None: "index": index, "start": start, "end": end, - "left_arcs": arcs_since_line.copy(), + "left_arcs": arcs_since_line, "best": None, } arcs_since_line = [] current = end + if not first_line_active: + first_line_active = True + capture_first_right_arcs = True + first_line_state = line_state + first_left_arc_indices.clear() + first_left_arc_indices.update(arc["index"] for arc in line_state["left_arcs"]) + else: + capture_first_right_arcs = False elif code == "A": if current is None: current = (segment[1], segment[2]) @@ -345,15 +414,22 @@ def finalize_line_state() -> None: "orientation": int(segment[8]), } arcs_since_line.append(arc_info) - if line_state and line_state["left_arcs"]: - candidate = evaluate_candidate(line_state, arc_info) - if candidate and (line_state["best"] is None or candidate["score"] < line_state["best"]["score"]): - line_state["best"] = candidate + if capture_first_right_arcs: + first_right_arcs.append(arc_info) + consider_right_arc(line_state, arc_info) current = arc_info["end"] + elif code == "Z": + finalize_line_state(close_subpath=True) + current = subpath_start + arcs_since_line = [] + first_line_active = False + capture_first_right_arcs = False + first_line_state = None + first_left_arc_indices.clear() else: current = (segment[1], segment[2]) if len(segment) >= 3 else current - finalize_line_state() + finalize_line_state(close_subpath=True) if not overlaps: return @@ -368,29 +444,23 @@ def finalize_line_state() -> None: point = overlap["point"] left_targets[left_idx] = point line_targets[line_idx] = point - for idx in range(left_idx + 1, right_idx): - if idx != line_idx: - skip_indices.add(idx) + skip_indices.update(idx for idx in range(left_idx + 1, right_idx) if idx != line_idx) updated_path: PathLike = [] for idx, segment in enumerate(path): if idx in skip_indices: continue cmd = segment.copy() - if idx in line_targets and cmd[0] == "L": - px, py = line_targets[idx] - cmd[1], cmd[2] = px, py - if idx in left_targets and cmd[0] == "A": - px, py = left_targets[idx] - cmd[1], cmd[2] = px, py + for targets, expected_code in ((move_targets, "M"), (line_targets, "L"), (left_targets, "A")): + target = targets.get(idx) + if target is not None and cmd[0] == expected_code: + cmd[1], cmd[2] = target updated_path.append(cmd) current_point: Point | None = None for segment in updated_path: code = segment[0] - if code == "M": - current_point = (segment[1], segment[2]) - elif code == "L": + if code in {"M", "L"}: current_point = (segment[1], segment[2]) elif code == "A": if current_point is None: From 31c62b822b25cb7343908223e6c631865a76b8ea Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 24 Oct 2025 09:06:01 -0300 Subject: [PATCH 29/30] now colinear lines are merged --- boxes/dxf_generator.py | 169 ++++++++++++++++++++++++++++++++++++++++- boxes/test_path.py | 15 ++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/boxes/dxf_generator.py b/boxes/dxf_generator.py index d8132478d..f5d0206a4 100644 --- a/boxes/dxf_generator.py +++ b/boxes/dxf_generator.py @@ -4,7 +4,7 @@ import logging import math from pathlib import Path -from typing import Any, Iterable, Literal, Sequence, TypedDict +from typing import Any, Iterable, Literal, Sequence, TypedDict, cast import ezdxf from ezdxf import units @@ -120,6 +120,172 @@ def _approximate_cubic(self, p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, steps: int def _line_segment(start: Vec3, end: Vec3) -> LineSegment: return {"type": "line", "start": start, "end": end} + def _points_close(self, a: Vec3, b: Vec3, *, tol: float | None = None) -> bool: + threshold = self._POINT_TOL if tol is None else tol + return (a - b).magnitude <= threshold + + def _vectors_collinear( + self, v1: Vec3, v2: Vec3, *, tol: float | None = None, allow_reverse: bool = False + ) -> bool: + threshold = self._POINT_TOL if tol is None else tol + len1 = v1.magnitude + len2 = v2.magnitude + if len1 <= threshold or len2 <= threshold: + return True + cross = abs(v1.x * v2.y - v1.y * v2.x) + max_len = max(len1, len2, 1e-9) + if cross > threshold * max_len: + return False + norm = len1 * len2 + if norm <= threshold * threshold: + return True + cosine = v1.dot(v2) / norm + angle_tol = min(1e-6, threshold / max_len) + if allow_reverse: + return abs(cosine) >= 1.0 - angle_tol + return cosine >= -angle_tol + + def _merge_line_run_once(self, run: Sequence[LineSegment]) -> tuple[list[LineSegment], bool]: + if not run: + return [], False + merged: list[LineSegment] = [] + tol = self._POINT_TOL + changed = False + for segment in run: + start = segment["start"] + end = segment["end"] + if (end - start).magnitude <= tol: + changed = True + continue + if merged: + prev = merged[-1] + v_prev = prev["end"] - prev["start"] + v_curr = end - start + tol_vec = max(v_prev.magnitude, v_curr.magnitude, 1.0) * self._POINT_TOL + if self._points_close(prev["end"], start, tol=tol_vec): + if self._vectors_collinear(v_prev, v_curr, tol=tol_vec): + merged[-1] = self._line_segment(prev["start"], end) + changed = True + continue + merged.append(self._line_segment(start, end)) + + if len(merged) <= 1: + return merged, changed + + first = merged[0] + last = merged[-1] + v_first = first["end"] - first["start"] + v_last = last["end"] - last["start"] + tol_vec = max(v_first.magnitude, v_last.magnitude, 1.0) * self._POINT_TOL + share_both = ( + ( + self._points_close(first["start"], last["start"], tol=tol_vec) + and self._points_close(first["end"], last["end"], tol=tol_vec) + ) + or ( + self._points_close(first["start"], last["end"], tol=tol_vec) + and self._points_close(first["end"], last["start"], tol=tol_vec) + ) + ) + if share_both: + merged.pop() + changed = True + return merged, changed + if self._points_close(first["start"], last["end"], tol=tol_vec): + if ( + v_first.magnitude > tol + and v_last.magnitude > tol + and self._vectors_collinear(v_last, v_first, tol=tol_vec, allow_reverse=True) + ): + merged[0] = self._line_segment(last["start"], first["end"]) + merged.pop() + changed = True + elif self._points_close(first["end"], last["start"], tol=tol_vec): + if ( + v_first.magnitude > tol + and v_last.magnitude > tol + and self._vectors_collinear(v_first, v_last, tol=tol_vec, allow_reverse=True) + ): + merged[-1] = self._line_segment(first["start"], last["end"]) + merged.pop(0) + changed = True + return merged, changed + + def _merge_line_run(self, run: Sequence[LineSegment]) -> tuple[list[LineSegment], bool]: + current = list(run) + overall_changed = False + while True: + current, changed = self._merge_line_run_once(current) + overall_changed = overall_changed or changed + if not changed: + break + return current, overall_changed + + def _merge_line_segments_once(self, segments: Sequence[Segment]) -> tuple[list[Segment], bool]: + merged: list[Segment] = [] + line_run: list[LineSegment] = [] + changed = False + + def flush_run() -> None: + nonlocal changed + if not line_run: + return + merged_run, run_changed = self._merge_line_run(line_run) + if run_changed: + changed = True + merged.extend(merged_run) + line_run.clear() + + for segment in segments: + if segment["type"] == "line": + line_run.append(cast(LineSegment, segment)) + else: + flush_run() + merged.append(segment) + flush_run() + return merged, changed + + def _merge_line_segments(self, segments: Sequence[Segment]) -> list[Segment]: + if not segments: + return [] + merged, changed = self._merge_line_segments_once(segments) + if len(merged) >= 2 and merged[0]["type"] == "line" and merged[-1]["type"] == "line": + first = cast(LineSegment, merged[0]) + last = cast(LineSegment, merged[-1]) + v_first = first["end"] - first["start"] + v_last = last["end"] - last["start"] + tol_vec = max(v_first.magnitude, v_last.magnitude, 1.0) * self._POINT_TOL + same_direction = self._points_close(first["start"], last["start"], tol=tol_vec) and self._points_close( + first["end"], last["end"], tol=tol_vec + ) + opposite_direction = self._points_close(first["start"], last["end"], tol=tol_vec) and self._points_close( + first["end"], last["start"], tol=tol_vec + ) + if same_direction or opposite_direction: + merged = list(merged[:-1]) + return self._merge_line_segments(merged) + if self._points_close(first["start"], last["end"], tol=tol_vec) and self._vectors_collinear( + v_last, v_first, tol=tol_vec, allow_reverse=True + ): + new_seg = self._line_segment(last["start"], first["end"]) + if not self._points_close(new_seg["start"], new_seg["end"], tol=tol_vec): + merged = [new_seg] + list(merged[1:-1]) + else: + merged = list(merged[1:-1]) + return self._merge_line_segments(merged) + if self._points_close(first["end"], last["start"], tol=tol_vec) and self._vectors_collinear( + v_first, v_last, tol=tol_vec, allow_reverse=True + ): + new_seg = self._line_segment(first["start"], last["end"]) + if not self._points_close(new_seg["start"], new_seg["end"], tol=tol_vec): + merged = [new_seg] + list(merged[1:-1]) + else: + merged = list(merged[1:-1]) + return self._merge_line_segments(merged) + if not changed: + return merged + return self._merge_line_segments(merged) + @staticmethod def _arc_segment( *, @@ -283,6 +449,7 @@ def _emit_circle(self, center: Vec3, radius: float) -> None: def _emit_polyline(self, segments: list[Segment]) -> None: if not segments: return + segments = self._merge_line_segments(segments) vertices: list[list[float]] = [] tol = self._POINT_TOL diff --git a/boxes/test_path.py b/boxes/test_path.py index d3a093638..336f0cbb3 100644 --- a/boxes/test_path.py +++ b/boxes/test_path.py @@ -70,6 +70,21 @@ def _text_params() -> dict[str, Any]: ["L", 93.0, -10.0], # start=(93.0,-8.745587738732608) end=(93.0,-10.0) ["T", 88.5, -3.0, Affine.identity(), "6", _text_params()], + #retangulo com linhas colineares + ["M", 110, 0], + ["L", 150, 0.0], + ["C", 150, 0.0, 150, 0.0, 150, 0.0], + ["L", 150, 25.0], + ["L", 150, 50.0], + ["C", 150, 50.0, 150, 50.0, 150, 50.0], + ["L", 100.0, 50.0], + ["C", 100.0, 50.0, 100.0, 50.0, 100.0, 50.0], + ["L", 100.0, 0.0], + ["C", 100.0, 0.0, 100.0, 0.0, 100.0, 0.0], + ['L', 110.0,0] + + + ] From 8553184c8a1308671556e0871a8814a2ee12478c Mon Sep 17 00:00:00 2001 From: Vinicius Date: Fri, 24 Oct 2025 10:23:09 -0300 Subject: [PATCH 30/30] fix arc segmentation --- boxes/dxf_generator.py | 116 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/boxes/dxf_generator.py b/boxes/dxf_generator.py index f5d0206a4..2d6f2fc29 100644 --- a/boxes/dxf_generator.py +++ b/boxes/dxf_generator.py @@ -286,6 +286,117 @@ def _merge_line_segments(self, segments: Sequence[Segment]) -> list[Segment]: return merged return self._merge_line_segments(merged) + def _merge_arc_segments(self, segments: Sequence[Segment]) -> list[Segment]: + merged: list[Segment] = [] + pending: ArcSegment | None = None + pending_start_angle = 0.0 + sweep_acc = 0.0 + + def start_pending(arc: ArcSegment) -> None: + nonlocal pending, pending_start_angle, sweep_acc + pending = self._arc_segment( + center=arc["center"], + radius=arc["radius"], + start_angle=arc["start_angle"], + end_angle=arc["end_angle"], + orientation=arc["orientation"], + start=arc["start"], + end=arc["end"], + full_circle=arc["full_circle"], + ) + pending_start_angle = arc["start_angle"] + sweep_acc = self._arc_sweep(arc) + + def flush_pending() -> None: + nonlocal pending, sweep_acc, pending_start_angle + if pending is None: + return + tol = max(pending["radius"], 1.0) * self._ARC_TOL + if ( + not pending["full_circle"] + and self._points_close(pending["start"], pending["end"], tol=tol) + and abs(sweep_acc - 2.0 * math.pi) <= self._FULL_CIRCLE_TOL + ): + pending = self._arc_segment( + center=pending["center"], + radius=pending["radius"], + start_angle=pending["start_angle"], + end_angle=pending["end_angle"], + orientation=pending["orientation"], + start=pending["start"], + end=pending["end"], + full_circle=True, + ) + pending_start_angle = 0.0 + merged.append(pending) + pending = None + sweep_acc = 0.0 + + for segment in segments: + if segment["type"] != "arc": + flush_pending() + merged.append(segment) + continue + + arc = cast(ArcSegment, segment) + if arc["full_circle"]: + flush_pending() + merged.append( + self._arc_segment( + center=arc["center"], + radius=arc["radius"], + start_angle=arc["start_angle"], + end_angle=arc["end_angle"], + orientation=arc["orientation"], + start=arc["start"], + end=arc["end"], + full_circle=True, + ) + ) + continue + + if pending is None: + start_pending(arc) + continue + + tol = max(pending["radius"], arc["radius"], 1.0) * self._ARC_TOL + centers_close = (arc["center"] - pending["center"]).magnitude <= tol + radii_close = abs(arc["radius"] - pending["radius"]) <= tol + contiguous = self._points_close(pending["end"], arc["start"], tol=tol) + same_orientation = arc["orientation"] == pending["orientation"] + next_sweep = sweep_acc + self._arc_sweep(arc) + + if ( + centers_close + and radii_close + and contiguous + and same_orientation + and next_sweep <= 2.0 * math.pi + self._FULL_CIRCLE_TOL + ): + sweep_acc = next_sweep + orientation = pending["orientation"] + if orientation > 0: + end_angle = pending_start_angle + sweep_acc + else: + end_angle = pending_start_angle - sweep_acc + pending = self._arc_segment( + center=pending["center"], + radius=pending["radius"], + start_angle=pending_start_angle, + end_angle=end_angle, + orientation=orientation, + start=pending["start"], + end=arc["end"], + full_circle=pending["full_circle"] or arc["full_circle"], + ) + continue + + flush_pending() + start_pending(arc) + + flush_pending() + return merged + @staticmethod def _arc_segment( *, @@ -532,11 +643,12 @@ def _flush_block(self, segments: list[Segment], texts: list[TextSpec]) -> None: if not segments: self._emit_texts(texts) return - circle = self._segments_form_circle(segments) + merged_segments = self._merge_arc_segments(segments) + circle = self._segments_form_circle(merged_segments) if circle: self._emit_circle(circle["center"], circle["radius"]) else: - self._emit_polyline(segments) + self._emit_polyline(merged_segments) self._emit_texts(texts) def add_commands(self, commands: Iterable[Command]) -> None: