diff --git a/boxes/__init__.py b/boxes/__init__.py
index a8b41f4d2..f19a48de2 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)')
@@ -586,7 +592,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
@@ -598,6 +628,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':
@@ -790,7 +822,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/dogbone.py b/boxes/dogbone.py
new file mode 100644
index 000000000..b66d0f4df
--- /dev/null
+++ b/boxes/dogbone.py
@@ -0,0 +1,479 @@
+from __future__ import annotations
+
+import math
+from typing import Any, Callable, MutableSequence
+
+from boxes.vectors import (
+ dotproduct,
+ vadd,
+ vdiff,
+ vlength,
+ vorthogonal,
+ vscalmul,
+)
+
+# Utilities for inserting dogbone reliefs into tool paths.
+PathLike = MutableSequence[Any]
+Vector = tuple[float, float]
+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]:
+ 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)))."""
+ # Closed form of the clearance distance used by Boxes.py.
+ return radius * DOGBONE_CLEARANCE_FACTOR
+
+
+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 = SQRT2 * radius
+ if offset < eps:
+ return False
+
+ # These offsets define where the auxiliary arcs start and end relative to the corner.
+ 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:
+ length = vlength(vec)
+ if length < eps:
+ return None
+ 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))
+ 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, eps)
+ 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]
+ if (
+ segment[0] == "C"
+ and i > 1
+ and i < len(path) - 1
+ 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]
+ p22 = path[i + 1][1:3]
+
+ lines_intersect, ox, oy = line_intersection((p11, p12), (p21, p22))
+ if not lines_intersect:
+ i += 1
+ continue
+
+ corner: Point = (ox, oy)
+ prev_vec = vdiff(p11, corner)
+ next_vec = vdiff(corner, p22)
+
+ d_prev = _normalize(prev_vec)
+ d_next = _normalize(next_vec)
+ if d_prev is None or d_next is None:
+ i += 1
+ continue
+
+ # Skip corners that are not approximately orthogonal.
+ if abs(dotproduct(d_prev, d_next)) > 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
+
+ sign = 1.0 if turn > 0.0 else -1.0
+ inward = vscalmul(vadd(vorthogonal(d_prev), vorthogonal(d_next)), sign)
+ n_in = _normalize(inward)
+ if n_in is None:
+ i += 1
+ continue
+
+ # Precompute axes to place the transition and finishing arcs.
+ axis_prev = (-d_prev[0], -d_prev[1])
+ axis_next = d_next
+
+ center = vadd(corner, vscalmul(n_in, radius))
+ 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),
+ ),
+ )
+ transition_point_next = vadd(
+ corner,
+ vadd(
+ vscalmul(axis_prev, end_offset_next),
+ vscalmul(axis_next, 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_next, prev_clearance))
+ new_arc_center_next = vadd(
+ corner,
+ vadd(vscalmul(axis_next, prev_clearance), vscalmul(axis_prev, -radius)),
+ )
+
+ 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
+
+ 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
+ orientation2 = -orientation
+
+ pre_arc = _arc_command(new_arc_center, new_arc_start, transition_point, orientation2)
+ if pre_arc is None:
+ i += 1
+ continue
+
+ main_arc = _arc_command(center, transition_point, transition_point_next, orientation)
+ if main_arc is None:
+ i += 1
+ continue
+
+ post_arc = _arc_command(new_arc_center_next, transition_point_next, new_arc_end, orientation2)
+ if post_arc is None:
+ i += 1
+ continue
+
+ 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
+
+ _trim_overlaps(path, eps)
+ return True
+
+
+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
+ 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 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(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])
+ end = (segment[1], segment[2])
+ line_state = {
+ "index": index,
+ "start": start,
+ "end": end,
+ "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])
+ 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 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(close_subpath=True)
+ 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
+ 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()
+ 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 in {"M", "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
diff --git a/boxes/drawing.py b/boxes/drawing.py
index 38db1ad6a..0fa92d944 100644
--- a/boxes/drawing.py
+++ b/boxes/drawing.py
@@ -1,5 +1,4 @@
from __future__ import annotations
-
import codecs
import io
import math
@@ -9,8 +8,80 @@
from affine import Affine
from boxes.extents import Extents
+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
@@ -76,6 +147,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:
@@ -190,42 +267,98 @@ 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):
+ 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":
- 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)
+ if inner_corners == "dogbone":
+ if not apply_dogbone(self.path, dogbone_radius, EPS, line_intersection):
+ return
+
+ 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,8 +620,8 @@ def _add_metadata(self, root) -> None:
m.tail = '\n'
root.insert(0, m)
- def finish(self, inner_corners="loop"):
- extents = self._adjust_coordinates()
+ def finish(self, inner_corners="loop", dogbone_radius=None):
+ extents = self.prepare_paths(inner_corners, dogbone_radius)
w = extents.width * self.scale
h = extents.height * self.scale
@@ -525,8 +658,8 @@ def finish(self, inner_corners="loop"):
x, y = 0, 0
start = None
last = None
- path.faster_edges(inner_corners)
- 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":
@@ -637,9 +770,9 @@ 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()
+ extents = self.prepare_paths(inner_corners, dogbone_radius)
w = extents.width
h = extents.height
@@ -681,9 +814,9 @@ def finish(self, inner_corners="loop"):
for j, path in enumerate(part.pathes):
p = []
x, y = 0, 0
- path.faster_edges(inner_corners)
+ 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":
@@ -695,6 +828,14 @@ def finish(self, inner_corners="loop"):
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))
@@ -770,9 +911,9 @@ 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()
+ extents = self.prepare_paths(inner_corners, dogbone_radius)
w = extents.width * self.scale
h = extents.height * self.scale
@@ -843,24 +984,24 @@ def finish(self, inner_corners="loop"):
C = ""
start = None
last = None
- path.faster_edges(inner_corners)
+ 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]
@@ -880,9 +1021,9 @@ def finish(self, inner_corners="loop"):
# 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/dxf_generator.py b/boxes/dxf_generator.py
new file mode 100644
index 000000000..2d6f2fc29
--- /dev/null
+++ b/boxes/dxf_generator.py
@@ -0,0 +1,778 @@
+from __future__ import annotations
+
+import io
+import logging
+import math
+from pathlib import Path
+from typing import Any, Iterable, Literal, Sequence, TypedDict, cast
+
+import ezdxf
+from ezdxf import units
+from ezdxf.math import Vec3
+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"]
+ 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."""
+
+ _POINT_TOL = 1e-6
+ _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)
+ 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
+ self.entity_attribs = {"layer": layer}
+ 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))
+ return min(211, max(0, hundredths))
+
+ @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)
+ )
+
+ 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) -> 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)
+
+ 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(
+ *,
+ 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: ArcSegment) -> Vec3:
+ return cls._point_on_arc(segment["center"], segment["radius"], segment["start_angle"])
+
+ @classmethod
+ def _arc_end_point(cls, segment: ArcSegment) -> 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) -> ArcSegment | None:
+ 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
+
+ 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
+
+ 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_TOL:
+ 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
+
+ full_circle = (
+ (end - start).magnitude <= max(radius_start, 1.0) * 1e-3
+ and abs(sweep - 2.0 * math.pi) <= cls._FULL_CIRCLE_TOL
+ )
+ 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[Segment]) -> CircleSummary | None:
+ if not segments:
+ return None
+ 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 = first["center"]
+ radius = first["radius"]
+ orientation = first["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[Segment]) -> None:
+ if not segments:
+ return
+ segments = self._merge_line_segments(segments)
+ 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[TextSpec]) -> 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[Segment], texts: list[TextSpec]) -> None:
+ if not segments:
+ self._emit_texts(texts)
+ return
+ 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(merged_segments)
+ self._emit_texts(texts)
+
+ def add_commands(self, commands: Iterable[Command]) -> None:
+ current_point: Vec3 | None = None
+ segments: list[Segment] = []
+ texts: list[TextSpec] = []
+
+ def flush():
+ self._flush_block(segments, texts)
+ segments.clear()
+ texts.clear()
+
+ for cmd in commands:
+ if not cmd:
+ continue
+ 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 letter == "C":
+ if current_point is None:
+ 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:
+ segments.append(arc)
+ else:
+ 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 letter == "A":
+ if current_point is None:
+ 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_param = float(cmd[7]) - float(cmd[6])
+ if orientation < 0:
+ sweep_param = -sweep_param
+ is_full_circle = (
+ 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)
+ )
+ segments.append(
+ 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: 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}")
+
+ flush()
+
+ 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:
+ 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 808f19b10..381592f4d 100644
--- a/boxes/formats.py
+++ b/boxes/formats.py
@@ -20,6 +20,7 @@
import tempfile
import io
from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface
+from boxes.dxf_generator import DXFSurface
class Formats:
@@ -27,14 +28,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 +64,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:
diff --git a/boxes/generators/toolbox.py b/boxes/generators/toolbox.py
new file mode 100644
index 000000000..c8be758d5
--- /dev/null
+++ b/boxes/generators/toolbox.py
@@ -0,0 +1,390 @@
+
+# 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 .
+
+import math
+
+from boxes import *
+
+
+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
+
+ 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)
+ 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 - 0.5) / 2.0)
+
+ self.edge(edges_spacing)
+ finger_edge(handle_thickness)
+ self.edge(inner_span + 0.5)
+ 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
+ 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:
+ # 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:
+ # 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.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)
+
+ self.move(th, tw, move, label="hinges")
+
+
+class Handle:
+ """Custom handle profile."""
+
+ 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
+
+ b = self.boxes
+ if b.move(self.width, self.height, move, before=True, label=label):
+ return
+
+ 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()
+
+ 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."""
+
+ 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)")
+ self.argparser.add_argument(
+ "--handle",
+ action="store",
+ type=bool,
+ default=True,
+ help="gera a peca de alca (True/False)")
+ self.argparser.add_argument(
+ "--handle-height",
+ action="store",
+ type=float,
+ default=70,
+ help="altura total da alca em mm")
+ self.argparser.add_argument(
+ "--handle_width",
+ action="store",
+ type=float,
+ default=100,
+ help="largura total da alca em mm")
+ self.argparser.add_argument(
+ "--handle_thickness",
+ action="store",
+ type=float,
+ default=30,
+ help="espessura (largura do perfil) da alca em mm")
+ self.argparser.add_argument(
+ "--handle_gap",
+ action="store",
+ 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:
+
+ 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
+ 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, "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_piece = Handle(self, handle_width, handle_height, handle_thickness, handle_gap)
+ handle_piece.render(move="right", label="Handle")
diff --git a/boxes/test_path.py b/boxes/test_path.py
new file mode 100644
index 000000000..336f0cbb3
--- /dev/null
+++ b/boxes/test_path.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+from typing import Any, MutableSequence
+
+from affine import Affine
+from boxes.dxf_generator 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()],
+ #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]
+
+
+
+]
+
+
+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
diff --git a/documentation/src/usermanual.rst b/documentation/src/usermanual.rst
index 72d20c62c..2d57a9947 100644
--- a/documentation/src/usermanual.rst
+++ b/documentation/src/usermanual.rst
@@ -145,6 +145,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 "激光补偿"
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
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);
}