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); }