diff --git a/bricklayers.py b/bricklayers.py index bd9b78d..db0f994 100755 --- a/bricklayers.py +++ b/bricklayers.py @@ -159,6 +159,59 @@ def point_along_line_backward(from_pos, to_pos, distance): new_y = to_pos.y + direction[1] * scale return Point(new_x, new_y) + @staticmethod + def arc_length(s_from, s_to, i_offset, j_offset, clockwise): + """Compute the arc path length for a G2/G3 move.""" + cx = s_from.x + i_offset + cy = s_from.y + j_offset + r = math.sqrt(i_offset * i_offset + j_offset * j_offset) + if r < 1e-9: + return Point.distance_between_points(s_from, s_to) + a_start = math.atan2(s_from.y - cy, s_from.x - cx) + a_end = math.atan2(s_to.y - cy, s_to.x - cx) + if clockwise: + sweep = a_start - a_end + if sweep <= 0: + sweep += 2 * math.pi + else: + sweep = a_end - a_start + if sweep <= 0: + sweep += 2 * math.pi + return r * sweep + + @staticmethod + def point_along_arc(s_from, i_offset, j_offset, clockwise, distance, total_arc_length): + """Find a point at `distance` along an arc from s_from.""" + cx = s_from.x + i_offset + cy = s_from.y + j_offset + r = math.sqrt(i_offset * i_offset + j_offset * j_offset) + if r < 1e-9: + return Point(s_from.x, s_from.y) + a_start = math.atan2(s_from.y - cy, s_from.x - cx) + fraction = distance / total_arc_length if total_arc_length > 0 else 0 + sweep_angle = total_arc_length / r + if clockwise: + a_target = a_start - fraction * sweep_angle + else: + a_target = a_start + fraction * sweep_angle + return Point(cx + r * math.cos(a_target), cy + r * math.sin(a_target)) + + @staticmethod + def parse_arc_ij(gcode_str): + """Extract I, J offsets from a G2/G3 gcode string. Returns (i, j) or None.""" + parts = gcode_str.split() + if not parts or parts[0] not in ('G2', 'G3'): + return None + i_val, j_val = 0.0, 0.0 + for p in parts[1:]: + if p[0] == 'I': + i_val = float(p[1:]) + elif p[0] == 'J': + j_val = float(p[1:]) + if i_val == 0.0 and j_val == 0.0: + return None + return (i_val, j_val, parts[0] == 'G2') + class GCodeState(NamedTuple): """Printing State""" @@ -228,6 +281,82 @@ def compute(self, state: GCodeState): self.min_y = min(self.min_y, y) self.max_y = max(self.max_y, y) + def compute_arc(self, prev_state, cur_state, i_offset, j_offset, clockwise): + """Computes the bounding box of a G2/G3 arc and feeds it into this bbox. + + Args: + prev_state: start point (has .x, .y) + cur_state: end point (has .x, .y) + i_offset: I parameter (center X offset from start, relative) + j_offset: J parameter (center Y offset from start, relative) + clockwise: True for G2 (CW), False for G3 (CCW) + """ + cx = prev_state.x + i_offset + cy = prev_state.y + j_offset + + # Angles from center to start and end + a_start = math.atan2(prev_state.y - cy, prev_state.x - cx) + a_end = math.atan2(cur_state.y - cy, cur_state.x - cx) + r = math.sqrt(i_offset * i_offset + j_offset * j_offset) + + if r < 1e-9: + self.compute(cur_state) + return + + # Normalize sweep: CW is negative, CCW is positive + if clockwise: + sweep = a_start - a_end + if sweep <= 0: + sweep += 2 * math.pi + sweep = -sweep # negative for CW + else: + sweep = a_end - a_start + if sweep <= 0: + sweep += 2 * math.pi + + # Collect candidate extreme points: start, end, plus any cardinal + # angles (0, pi/2, pi, 3pi/2) crossed during the sweep. + xs = [prev_state.x, cur_state.x] + ys = [prev_state.y, cur_state.y] + + cardinals = [0.0, math.pi / 2, math.pi, -math.pi, -math.pi / 2] + for card in cardinals: + # Check if this cardinal angle is within the arc sweep + diff = card - a_start + # Normalize diff into the sweep direction + if clockwise: # sweep is negative + while diff > 0: + diff -= 2 * math.pi + while diff < -2 * math.pi: + diff += 2 * math.pi + if diff >= sweep: # sweep is negative, so >= means "within" + xs.append(cx + r * math.cos(card)) + ys.append(cy + r * math.sin(card)) + else: # CCW, sweep is positive + while diff < 0: + diff += 2 * math.pi + while diff > 2 * math.pi: + diff -= 2 * math.pi + if diff <= sweep: + xs.append(cx + r * math.cos(card)) + ys.append(cy + r * math.sin(card)) + + # Feed all extreme points into the bbox + for x in xs: + self.min_x = min(self.min_x, x) + self.max_x = max(self.max_x, x) + for y in ys: + self.min_y = min(self.min_y, y) + self.max_y = max(self.max_y, y) + + # Ensure nonzero size on first use + if self.min_x == self.max_x: + self.min_x -= 0.1 + self.max_x += 0.1 + if self.min_y == self.max_y: + self.min_y -= 0.1 + self.max_y += 0.1 + def contains(self, other) -> bool: """Checks if this bounding box fully contains another bounding box.""" return ( @@ -1136,25 +1265,14 @@ def wipe(self, loop, simulator, feature): for line in path: if line.current.is_extruding: - if wipe_mode == 'forward': - from_pos = line.previous - to_pos = line.current - else: # backward mode - from_pos = line.current - to_pos = line.previous - - segment_length = Point.distance_between_points(from_pos, to_pos) + from_pos, to_pos, segment_length, is_arc, arc_params = BrickLayersProcessor._wipe_segment_info(line, wipe_mode) if segment_length <= 1e-6: continue if traveled + segment_length >= wipe_distance: needed_distance = wipe_distance - traveled - target_point = ( - Point.point_along_line_forward(from_pos, to_pos, needed_distance) - if wipe_mode == 'forward' - else Point.point_along_line_backward(from_pos, to_pos, needed_distance) - ) + target_point = BrickLayersProcessor._wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length) moving_points.append(target_point) moving_distances.append(needed_distance) @@ -1193,6 +1311,45 @@ def wipe(self, loop, simulator, feature): # TODO: salvage `experimental_arcflick` into the new wipe feature and remove this: + + @staticmethod + def _wipe_segment_info(line, wipe_mode): + """Compute segment length and arc info for a wipe path segment. + Returns (from_pos, to_pos, segment_length, is_arc, arc_params) where + arc_params is (i_val, j_val, clockwise) or None.""" + if wipe_mode == 'forward': + from_pos = line.previous + to_pos = line.current + else: + from_pos = line.current + to_pos = line.previous + + arc_info = Point.parse_arc_ij(line.gcode) + if arc_info and line.previous and line.current: + i_val, j_val, clockwise = arc_info + if wipe_mode == 'backward': + cx = line.previous.x + i_val + cy = line.previous.y + j_val + i_val = cx - line.current.x + j_val = cy - line.current.y + clockwise = not clockwise + seg_len = Point.arc_length(from_pos, to_pos, i_val, j_val, clockwise) + return from_pos, to_pos, seg_len, True, (i_val, j_val, clockwise) + else: + seg_len = Point.distance_between_points(from_pos, to_pos) + return from_pos, to_pos, seg_len, False, None + + @staticmethod + def _wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length): + """Find a point at needed_distance along a segment (line or arc).""" + if is_arc: + i_val, j_val, clockwise = arc_params + return Point.point_along_arc(from_pos, i_val, j_val, clockwise, needed_distance, segment_length) + elif wipe_mode == 'forward': + return Point.point_along_line_forward(from_pos, to_pos, needed_distance) + else: + return Point.point_along_line_backward(from_pos, to_pos, needed_distance) + def wipe_movement(self, loop, target_state, simulator, feature, z = None): from_gcode = GCodeLine.from_gcode @@ -1234,25 +1391,14 @@ def wipe_movement(self, loop, target_state, simulator, feature, z = None): for line in path: if line.current.is_extruding: - if wipe_mode == 'forward': - from_pos = line.previous - to_pos = line.current - else: # backward mode - from_pos = line.current - to_pos = line.previous - - segment_length = Point.distance_between_points(from_pos, to_pos) + from_pos, to_pos, segment_length, is_arc, arc_params = BrickLayersProcessor._wipe_segment_info(line, wipe_mode) if segment_length <= 1e-6: continue if traveled + segment_length >= wipe_distance: needed_distance = wipe_distance - traveled - target_point = ( - Point.point_along_line_forward(from_pos, to_pos, needed_distance) - if wipe_mode == 'forward' - else Point.point_along_line_backward(from_pos, to_pos, needed_distance) - ) + target_point = BrickLayersProcessor._wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length) moving_points.append(target_point) moving_distances.append(needed_distance) @@ -1409,7 +1555,26 @@ def calculate_loop_depth(group_perimeter): bb = GCodeStateBBox() for pline in ploop: if pline.current.is_extruding: # Only compute movements that are extruding, ignore wipes or travels - bb.compute(pline.current) # compute the bounding box that surrounds the current loop + gcode = pline.gcode.strip() + # Check for arc commands (G2/G3) and compute arc bounding box + if pline.previous and gcode and gcode[:2] in ('G2', 'G3'): + parts = gcode.split() + cmd = parts[0] + if cmd in ('G2', 'G3'): + i_val, j_val = 0.0, 0.0 + for arg in parts[1:]: + if arg[0] == 'I': + i_val = float(arg[1:]) + elif arg[0] == 'J': + j_val = float(arg[1:]) + if i_val != 0.0 or j_val != 0.0: + bb.compute_arc(pline.previous, pline.current, i_val, j_val, cmd == 'G2') + else: + bb.compute(pline.current) + else: + bb.compute(pline.current) + else: + bb.compute(pline.current) # compute the bounding box that surrounds the current loop # print(node) # print(bb) nodes.append(LoopNode(loop_index, bb, ploop)) @@ -1610,7 +1775,11 @@ def generate_deffered_perimeters(self, myline, deffered, extrusion_multiplier, e # If the gcode was using absolute extrusion, insert an M82 to return to Absolute Extrusion buffer.append(from_gcode("M82 ; BRICK: Return to Absolute Extrusion\n")) # Resets the correct absolute extrusion register for the next feature: - buffer.append(from_gcode(f"G92 E{myline.previous.e} ; BRICK: Resets the Extruder absolute position\n")) + # Use the E value from before the deferred perimeters, not after — + # the deferred block was replayed with relative extrusion so the + # firmware E register did not advance in absolute terms. + e_reset = self.last_noninternalperimeter_state.e if self.last_noninternalperimeter_state else myline.previous.e + buffer.append(from_gcode(f"G92 E{e_reset} ; BRICK: Resets the Extruder absolute position\n")) ########## if previous_perimeter != perimeter_index: @@ -1937,7 +2106,8 @@ def process_gcode(self, gcode_stream): # If the gcode was using absolute extrusion, insert an M82 to return to Absolute Extrusion buffer_lines.append(from_gcode("M82 ; BRICK: Return to Absolute Extrusion\n")) # Resets the correct absolute extrusion register for the next feature: - buffer_lines.append(from_gcode(f"G92 E{myline.previous.e} ; BRICK: Resets the Extruder absolute position\n")) + e_reset = self.last_noninternalperimeter_state.e if self.last_noninternalperimeter_state else myline.previous.e + buffer_lines.append(from_gcode(f"G92 E{e_reset} ; BRICK: Resets the Extruder absolute position\n")) self.last_internalperimeter_state = calculated_line.current #if myline.previous.width != kept_line.current.width: buffer_lines.append(from_gcode(f"{simulator.const_width}{myline.previous.width}\n")) # For the Preview @@ -2013,7 +2183,7 @@ def process_gcode(self, gcode_stream): buffer_lines.append(myline) self.last_noninternalperimeter_state = current_state - if simulator.moved_in_xy: + if simulator.moved_in_xy and not feature.wiping: myline.current = current_state self.last_noninternalperimeter_xy_line = myline diff --git a/tests/test_compute_arc.py b/tests/test_compute_arc.py new file mode 100644 index 0000000..ecf46fa --- /dev/null +++ b/tests/test_compute_arc.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Unit tests for GCodeStateBBox.compute_arc() +Validates bounding box computation for G2/G3 arc moves. + +Each test creates a known arc geometry and verifies the computed +bounding box matches the expected mathematical result. +""" +import sys, os, math + +# Import BrickLayers classes +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +from bricklayers import GCodeStateBBox, GCodeState + +TOL = 0.15 # tolerance (bbox adds ±0.1 on first point) + + +def make_state(x, y): + """Create a GCodeState with only x,y populated (rest are defaults).""" + return GCodeState(x=x, y=y, z=0, e=0, f=0, retracted=0, width=0, + absolute_positioning=True, relative_extrusion=False, + is_moving=False, is_extruding=False, is_retracting=False, + just_started_extruding=False, just_stopped_extruding=False) + + +def check_bbox(name, bb, exp_min_x, exp_max_x, exp_min_y, exp_max_y): + ok = True + for label, got, exp in [ + ("min_x", bb.min_x, exp_min_x), ("max_x", bb.max_x, exp_max_x), + ("min_y", bb.min_y, exp_min_y), ("max_y", bb.max_y, exp_max_y), + ]: + if abs(got - exp) > TOL: + print(f" FAIL {label}: got {got:.4f}, expected {exp:.4f}") + ok = False + status = "PASS" if ok else "FAIL" + print(f"[{status}] {name}") + return ok + + +def test_quarter_circle_ccw(): + """CCW quarter circle: (10,0) -> (0,10), center (0,0), r=10 + Expected bbox: x=[0,10], y=[0,10]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(10, 0), make_state(0, 10), -10, 0, clockwise=False) + return check_bbox("Quarter circle CCW", bb, 0, 10, 0, 10) + + +def test_quarter_circle_cw(): + """CW quarter circle: (0,10) -> (10,0), center (0,0), r=10 + Expected bbox: x=[0,10], y=[0,10]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(0, 10), make_state(10, 0), 0, -10, clockwise=True) + return check_bbox("Quarter circle CW", bb, 0, 10, 0, 10) + + +def test_semicircle_ccw_top(): + """CCW semicircle: (10,0) -> (-10,0), center (0,0), r=10 + Sweeps through top. Expected bbox: x=[-10,10], y=[0,10]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(10, 0), make_state(-10, 0), -10, 0, clockwise=False) + return check_bbox("Semicircle CCW (top)", bb, -10, 10, 0, 10) + + +def test_semicircle_cw_bottom(): + """CW semicircle: (10,0) -> (-10,0), center (0,0), r=10 + Sweeps through bottom. Expected bbox: x=[-10,10], y=[-10,0]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(10, 0), make_state(-10, 0), -10, 0, clockwise=True) + return check_bbox("Semicircle CW (bottom)", bb, -10, 10, -10, 0) + + +def test_full_circle_ccw(): + """CCW full circle: (10,0) -> (10,0), center (0,0), r=10 + Expected bbox: x=[-10,10], y=[-10,10]""" + bb = GCodeStateBBox() + # Full circle: start == end, I=-10 (center is at origin) + bb.compute_arc(make_state(10, 0), make_state(10, 0), -10, 0, clockwise=False) + return check_bbox("Full circle CCW", bb, -10, 10, -10, 10) + + +def test_270_degree_arc(): + """CCW 270°: (10,0) -> (0,-10), center (0,0), r=10 + Sweeps through top and left. Expected bbox: x=[-10,10], y=[-10,10]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(10, 0), make_state(0, -10), -10, 0, clockwise=False) + return check_bbox("270° arc CCW", bb, -10, 10, -10, 10) + + +def test_small_arc_no_cardinal_crossing(): + """Small CCW arc: 30° to 60° on r=10 circle centered at origin. + Start: (10*cos30, 10*sin30), End: (10*cos60, 10*sin60) + No cardinal angles crossed. Bbox = just start and end points.""" + s = make_state(10 * math.cos(math.radians(30)), 10 * math.sin(math.radians(30))) + e = make_state(10 * math.cos(math.radians(60)), 10 * math.sin(math.radians(60))) + i_off = -s.x # center at origin + j_off = -s.y + bb = GCodeStateBBox() + bb.compute_arc(s, e, i_off, j_off, clockwise=False) + return check_bbox("Small arc (no cardinal crossing)", bb, + min(s.x, e.x), max(s.x, e.x), + min(s.y, e.y), max(s.y, e.y)) + + +def test_arc_crossing_90_degrees(): + """CCW arc from 45° to 135° on r=10 circle. Crosses 90° cardinal. + Expected: max_y = 10 (the 90° point), not just the endpoints.""" + s = make_state(10 * math.cos(math.radians(45)), 10 * math.sin(math.radians(45))) + e = make_state(10 * math.cos(math.radians(135)), 10 * math.sin(math.radians(135))) + i_off = -s.x + j_off = -s.y + bb = GCodeStateBBox() + bb.compute_arc(s, e, i_off, j_off, clockwise=False) + return check_bbox("Arc crossing 90°", bb, + min(s.x, e.x), max(s.x, e.x), + min(s.y, e.y), 10.0) + + +def test_tiny_radius_fallback(): + """Near-zero radius arc should not crash, just use endpoint.""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(5, 5), make_state(5.001, 5.001), 0.0001, 0.0001, clockwise=False) + return check_bbox("Tiny radius fallback", bb, 4.9, 5.1, 4.9, 5.1) + + +def test_offset_center(): + """Arc not centered at origin. Quarter CCW: center at (50,50), r=10. + Start: (60,50), End: (50,60). Expected bbox: x=[50,60], y=[50,60]""" + bb = GCodeStateBBox() + bb.compute_arc(make_state(60, 50), make_state(50, 60), -10, 0, clockwise=False) + return check_bbox("Offset center quarter arc", bb, 50, 60, 50, 60) + + +if __name__ == "__main__": + tests = [ + test_quarter_circle_ccw, + test_quarter_circle_cw, + test_semicircle_ccw_top, + test_semicircle_cw_bottom, + test_full_circle_ccw, + test_270_degree_arc, + test_small_arc_no_cardinal_crossing, + test_arc_crossing_90_degrees, + test_tiny_radius_fallback, + test_offset_center, + ] + results = [t() for t in tests] + passed = sum(results) + total = len(results) + print(f"\n{'='*40}") + print(f"Results: {passed}/{total} passed") + sys.exit(0 if passed == total else 1) diff --git a/tests/visualize_arc_bbox.py b/tests/visualize_arc_bbox.py new file mode 100644 index 0000000..dcc2608 --- /dev/null +++ b/tests/visualize_arc_bbox.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Visual proof: draws test arcs with their computed bounding boxes. +Generates pr-validation/arc_bbox_visual.png for the PR. +""" +import sys, os, math +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +from bricklayers import GCodeStateBBox, GCodeState + + +def make_state(x, y): + return GCodeState(x=x, y=y, z=0, e=0, f=0, retracted=0, width=0, + absolute_positioning=True, relative_extrusion=False, + is_moving=False, is_extruding=False, is_retracting=False, + just_started_extruding=False, just_stopped_extruding=False) + + +def draw_arc(ax, cx, cy, r, start_deg, end_deg, clockwise, color, label): + """Draw an arc and its computed bounding box.""" + # Draw the arc curve + if clockwise: + if end_deg > start_deg: + end_deg -= 360 + angles = np.linspace(math.radians(start_deg), math.radians(end_deg), 200) + else: + if end_deg < start_deg: + end_deg += 360 + angles = np.linspace(math.radians(start_deg), math.radians(end_deg), 200) + + xs = cx + r * np.cos(angles) + ys = cy + r * np.sin(angles) + ax.plot(xs, ys, color=color, linewidth=2.5, label=label) + + # Start/end points + sx, sy = cx + r * math.cos(math.radians(start_deg)), cy + r * math.sin(math.radians(start_deg)) + ex, ey = cx + r * math.cos(math.radians(end_deg % 360)), cy + r * math.sin(math.radians(end_deg % 360)) + ax.plot(sx, sy, 'o', color=color, markersize=8) + ax.plot(ex, ey, 's', color=color, markersize=8) + + # Compute bbox + i_off = cx - sx + j_off = cy - sy + bb = GCodeStateBBox() + bb.compute_arc(make_state(sx, sy), make_state(ex, ey), i_off, j_off, clockwise=clockwise) + + # Draw bbox + rect = mpatches.Rectangle((bb.min_x, bb.min_y), bb.max_x - bb.min_x, bb.max_y - bb.min_y, + linewidth=1.5, edgecolor=color, facecolor=color, alpha=0.1, linestyle='--') + ax.add_patch(rect) + + # Draw what endpoint-only bbox would be (wrong) + wrong_min_x = min(sx, ex) - 0.1 + wrong_max_x = max(sx, ex) + 0.1 + wrong_min_y = min(sy, ey) - 0.1 + wrong_max_y = max(sy, ey) + 0.1 + rect2 = mpatches.Rectangle((wrong_min_x, wrong_min_y), + wrong_max_x - wrong_min_x, wrong_max_y - wrong_min_y, + linewidth=1, edgecolor='red', facecolor='none', linestyle=':') + ax.add_patch(rect2) + + +test_cases = [ + {"title": "Quarter CCW (0°→90°)", "cx": 0, "cy": 0, "r": 10, "start": 0, "end": 90, "cw": False}, + {"title": "Semicircle CCW (0°→180°)", "cx": 0, "cy": 0, "r": 10, "start": 0, "end": 180, "cw": False}, + {"title": "Semicircle CW (0°→180°)", "cx": 0, "cy": 0, "r": 10, "start": 0, "end": 180, "cw": True}, + {"title": "270° CCW (0°→270°)", "cx": 0, "cy": 0, "r": 10, "start": 0, "end": 270, "cw": False}, + {"title": "Arc crossing 90° (45°→135°)", "cx": 0, "cy": 0, "r": 10, "start": 45, "end": 135, "cw": False}, + {"title": "Offset center (50,50) r=10", "cx": 50, "cy": 50, "r": 10, "start": 0, "end": 90, "cw": False}, +] + +fig, axes = plt.subplots(2, 3, figsize=(16, 11)) +colors = ['#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#E91E63', '#00BCD4'] + +for ax, tc, color in zip(axes.flat, test_cases, colors): + draw_arc(ax, tc["cx"], tc["cy"], tc["r"], tc["start"], tc["end"], tc["cw"], color, tc["title"]) + ax.set_title(tc["title"], fontsize=11, fontweight='bold') + ax.set_aspect('equal') + ax.grid(True, alpha=0.3) + ax.axhline(y=tc["cy"], color='gray', linewidth=0.5) + ax.axvline(x=tc["cx"], color='gray', linewidth=0.5) + # Add padding + pad = tc["r"] * 0.3 + ax.set_xlim(tc["cx"] - tc["r"] - pad, tc["cx"] + tc["r"] + pad) + ax.set_ylim(tc["cy"] - tc["r"] - pad, tc["cy"] + tc["r"] + pad) + +fig.suptitle("compute_arc() Bounding Box Validation\n" + "Colored dashed + shaded = computed bbox (correct) | Red dotted = endpoint-only bbox (wrong)", + fontsize=13, fontweight='bold') +plt.tight_layout() + +out_path = os.path.join(os.path.dirname(__file__), "arc_bbox_visual.png") +plt.savefig(out_path, dpi=150, bbox_inches='tight') +print(f"Saved: {out_path}")