Skip to content

Commit 6d0d649

Browse files
feat: G2/G3 arc support — bounding box, wipe path, and absolute extrusion fix
Arc bounding box: - compute_arc() on GCodeStateBBox computes true arc bbox via cardinal angle crossing detection for G2/G3 moves in loop depth calculation Arc-aware wipe: - wipe() and wipe_movement() use true arc path length and arc interpolation instead of chord distance for G2/G3 segments - New helpers: Point.arc_length(), Point.point_along_arc(), Point.parse_arc_ij(), _wipe_segment_info(), _wipe_interpolate() Absolute extrusion fix (issue #17): - G92 E reset after deferred perimeters uses pre-deferred E value (last_noninternalperimeter_state.e) instead of myline.previous.e - Fixes blob at outer wall start in PrusaSlicer absolute extrusion mode - No-op for Bambu Studio/OrcaSlicer (relative extrusion)
1 parent 59e581e commit 6d0d649

3 files changed

Lines changed: 447 additions & 29 deletions

File tree

bricklayers.py

Lines changed: 199 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,59 @@ def point_along_line_backward(from_pos, to_pos, distance):
159159
new_y = to_pos.y + direction[1] * scale
160160
return Point(new_x, new_y)
161161

162+
@staticmethod
163+
def arc_length(s_from, s_to, i_offset, j_offset, clockwise):
164+
"""Compute the arc path length for a G2/G3 move."""
165+
cx = s_from.x + i_offset
166+
cy = s_from.y + j_offset
167+
r = math.sqrt(i_offset * i_offset + j_offset * j_offset)
168+
if r < 1e-9:
169+
return Point.distance_between_points(s_from, s_to)
170+
a_start = math.atan2(s_from.y - cy, s_from.x - cx)
171+
a_end = math.atan2(s_to.y - cy, s_to.x - cx)
172+
if clockwise:
173+
sweep = a_start - a_end
174+
if sweep <= 0:
175+
sweep += 2 * math.pi
176+
else:
177+
sweep = a_end - a_start
178+
if sweep <= 0:
179+
sweep += 2 * math.pi
180+
return r * sweep
181+
182+
@staticmethod
183+
def point_along_arc(s_from, i_offset, j_offset, clockwise, distance, total_arc_length):
184+
"""Find a point at `distance` along an arc from s_from."""
185+
cx = s_from.x + i_offset
186+
cy = s_from.y + j_offset
187+
r = math.sqrt(i_offset * i_offset + j_offset * j_offset)
188+
if r < 1e-9:
189+
return Point(s_from.x, s_from.y)
190+
a_start = math.atan2(s_from.y - cy, s_from.x - cx)
191+
fraction = distance / total_arc_length if total_arc_length > 0 else 0
192+
sweep_angle = total_arc_length / r
193+
if clockwise:
194+
a_target = a_start - fraction * sweep_angle
195+
else:
196+
a_target = a_start + fraction * sweep_angle
197+
return Point(cx + r * math.cos(a_target), cy + r * math.sin(a_target))
198+
199+
@staticmethod
200+
def parse_arc_ij(gcode_str):
201+
"""Extract I, J offsets from a G2/G3 gcode string. Returns (i, j) or None."""
202+
parts = gcode_str.split()
203+
if not parts or parts[0] not in ('G2', 'G3'):
204+
return None
205+
i_val, j_val = 0.0, 0.0
206+
for p in parts[1:]:
207+
if p[0] == 'I':
208+
i_val = float(p[1:])
209+
elif p[0] == 'J':
210+
j_val = float(p[1:])
211+
if i_val == 0.0 and j_val == 0.0:
212+
return None
213+
return (i_val, j_val, parts[0] == 'G2')
214+
162215

163216
class GCodeState(NamedTuple):
164217
"""Printing State"""
@@ -228,6 +281,82 @@ def compute(self, state: GCodeState):
228281
self.min_y = min(self.min_y, y)
229282
self.max_y = max(self.max_y, y)
230283

284+
def compute_arc(self, prev_state, cur_state, i_offset, j_offset, clockwise):
285+
"""Computes the bounding box of a G2/G3 arc and feeds it into this bbox.
286+
287+
Args:
288+
prev_state: start point (has .x, .y)
289+
cur_state: end point (has .x, .y)
290+
i_offset: I parameter (center X offset from start, relative)
291+
j_offset: J parameter (center Y offset from start, relative)
292+
clockwise: True for G2 (CW), False for G3 (CCW)
293+
"""
294+
cx = prev_state.x + i_offset
295+
cy = prev_state.y + j_offset
296+
297+
# Angles from center to start and end
298+
a_start = math.atan2(prev_state.y - cy, prev_state.x - cx)
299+
a_end = math.atan2(cur_state.y - cy, cur_state.x - cx)
300+
r = math.sqrt(i_offset * i_offset + j_offset * j_offset)
301+
302+
if r < 1e-9:
303+
self.compute(cur_state)
304+
return
305+
306+
# Normalize sweep: CW is negative, CCW is positive
307+
if clockwise:
308+
sweep = a_start - a_end
309+
if sweep <= 0:
310+
sweep += 2 * math.pi
311+
sweep = -sweep # negative for CW
312+
else:
313+
sweep = a_end - a_start
314+
if sweep <= 0:
315+
sweep += 2 * math.pi
316+
317+
# Collect candidate extreme points: start, end, plus any cardinal
318+
# angles (0, pi/2, pi, 3pi/2) crossed during the sweep.
319+
xs = [prev_state.x, cur_state.x]
320+
ys = [prev_state.y, cur_state.y]
321+
322+
cardinals = [0.0, math.pi / 2, math.pi, -math.pi, -math.pi / 2]
323+
for card in cardinals:
324+
# Check if this cardinal angle is within the arc sweep
325+
diff = card - a_start
326+
# Normalize diff into the sweep direction
327+
if clockwise: # sweep is negative
328+
while diff > 0:
329+
diff -= 2 * math.pi
330+
while diff < -2 * math.pi:
331+
diff += 2 * math.pi
332+
if diff >= sweep: # sweep is negative, so >= means "within"
333+
xs.append(cx + r * math.cos(card))
334+
ys.append(cy + r * math.sin(card))
335+
else: # CCW, sweep is positive
336+
while diff < 0:
337+
diff += 2 * math.pi
338+
while diff > 2 * math.pi:
339+
diff -= 2 * math.pi
340+
if diff <= sweep:
341+
xs.append(cx + r * math.cos(card))
342+
ys.append(cy + r * math.sin(card))
343+
344+
# Feed all extreme points into the bbox
345+
for x in xs:
346+
self.min_x = min(self.min_x, x)
347+
self.max_x = max(self.max_x, x)
348+
for y in ys:
349+
self.min_y = min(self.min_y, y)
350+
self.max_y = max(self.max_y, y)
351+
352+
# Ensure nonzero size on first use
353+
if self.min_x == self.max_x:
354+
self.min_x -= 0.1
355+
self.max_x += 0.1
356+
if self.min_y == self.max_y:
357+
self.min_y -= 0.1
358+
self.max_y += 0.1
359+
231360
def contains(self, other) -> bool:
232361
"""Checks if this bounding box fully contains another bounding box."""
233362
return (
@@ -1136,25 +1265,14 @@ def wipe(self, loop, simulator, feature):
11361265

11371266
for line in path:
11381267
if line.current.is_extruding:
1139-
if wipe_mode == 'forward':
1140-
from_pos = line.previous
1141-
to_pos = line.current
1142-
else: # backward mode
1143-
from_pos = line.current
1144-
to_pos = line.previous
1145-
1146-
segment_length = Point.distance_between_points(from_pos, to_pos)
1268+
from_pos, to_pos, segment_length, is_arc, arc_params = BrickLayersProcessor._wipe_segment_info(line, wipe_mode)
11471269

11481270
if segment_length <= 1e-6:
11491271
continue
11501272

11511273
if traveled + segment_length >= wipe_distance:
11521274
needed_distance = wipe_distance - traveled
1153-
target_point = (
1154-
Point.point_along_line_forward(from_pos, to_pos, needed_distance)
1155-
if wipe_mode == 'forward'
1156-
else Point.point_along_line_backward(from_pos, to_pos, needed_distance)
1157-
)
1275+
target_point = BrickLayersProcessor._wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length)
11581276

11591277
moving_points.append(target_point)
11601278
moving_distances.append(needed_distance)
@@ -1193,6 +1311,45 @@ def wipe(self, loop, simulator, feature):
11931311

11941312

11951313
# TODO: salvage `experimental_arcflick` into the new wipe feature and remove this:
1314+
1315+
@staticmethod
1316+
def _wipe_segment_info(line, wipe_mode):
1317+
"""Compute segment length and arc info for a wipe path segment.
1318+
Returns (from_pos, to_pos, segment_length, is_arc, arc_params) where
1319+
arc_params is (i_val, j_val, clockwise) or None."""
1320+
if wipe_mode == 'forward':
1321+
from_pos = line.previous
1322+
to_pos = line.current
1323+
else:
1324+
from_pos = line.current
1325+
to_pos = line.previous
1326+
1327+
arc_info = Point.parse_arc_ij(line.gcode)
1328+
if arc_info and line.previous and line.current:
1329+
i_val, j_val, clockwise = arc_info
1330+
if wipe_mode == 'backward':
1331+
cx = line.previous.x + i_val
1332+
cy = line.previous.y + j_val
1333+
i_val = cx - line.current.x
1334+
j_val = cy - line.current.y
1335+
clockwise = not clockwise
1336+
seg_len = Point.arc_length(from_pos, to_pos, i_val, j_val, clockwise)
1337+
return from_pos, to_pos, seg_len, True, (i_val, j_val, clockwise)
1338+
else:
1339+
seg_len = Point.distance_between_points(from_pos, to_pos)
1340+
return from_pos, to_pos, seg_len, False, None
1341+
1342+
@staticmethod
1343+
def _wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length):
1344+
"""Find a point at needed_distance along a segment (line or arc)."""
1345+
if is_arc:
1346+
i_val, j_val, clockwise = arc_params
1347+
return Point.point_along_arc(from_pos, i_val, j_val, clockwise, needed_distance, segment_length)
1348+
elif wipe_mode == 'forward':
1349+
return Point.point_along_line_forward(from_pos, to_pos, needed_distance)
1350+
else:
1351+
return Point.point_along_line_backward(from_pos, to_pos, needed_distance)
1352+
11961353
def wipe_movement(self, loop, target_state, simulator, feature, z = None):
11971354
from_gcode = GCodeLine.from_gcode
11981355

@@ -1234,25 +1391,14 @@ def wipe_movement(self, loop, target_state, simulator, feature, z = None):
12341391

12351392
for line in path:
12361393
if line.current.is_extruding:
1237-
if wipe_mode == 'forward':
1238-
from_pos = line.previous
1239-
to_pos = line.current
1240-
else: # backward mode
1241-
from_pos = line.current
1242-
to_pos = line.previous
1243-
1244-
segment_length = Point.distance_between_points(from_pos, to_pos)
1394+
from_pos, to_pos, segment_length, is_arc, arc_params = BrickLayersProcessor._wipe_segment_info(line, wipe_mode)
12451395

12461396
if segment_length <= 1e-6:
12471397
continue
12481398

12491399
if traveled + segment_length >= wipe_distance:
12501400
needed_distance = wipe_distance - traveled
1251-
target_point = (
1252-
Point.point_along_line_forward(from_pos, to_pos, needed_distance)
1253-
if wipe_mode == 'forward'
1254-
else Point.point_along_line_backward(from_pos, to_pos, needed_distance)
1255-
)
1401+
target_point = BrickLayersProcessor._wipe_interpolate(from_pos, to_pos, needed_distance, wipe_mode, is_arc, arc_params, segment_length)
12561402

12571403
moving_points.append(target_point)
12581404
moving_distances.append(needed_distance)
@@ -1409,7 +1555,26 @@ def calculate_loop_depth(group_perimeter):
14091555
bb = GCodeStateBBox()
14101556
for pline in ploop:
14111557
if pline.current.is_extruding: # Only compute movements that are extruding, ignore wipes or travels
1412-
bb.compute(pline.current) # compute the bounding box that surrounds the current loop
1558+
gcode = pline.gcode.strip()
1559+
# Check for arc commands (G2/G3) and compute arc bounding box
1560+
if pline.previous and gcode and gcode[:2] in ('G2', 'G3'):
1561+
parts = gcode.split()
1562+
cmd = parts[0]
1563+
if cmd in ('G2', 'G3'):
1564+
i_val, j_val = 0.0, 0.0
1565+
for arg in parts[1:]:
1566+
if arg[0] == 'I':
1567+
i_val = float(arg[1:])
1568+
elif arg[0] == 'J':
1569+
j_val = float(arg[1:])
1570+
if i_val != 0.0 or j_val != 0.0:
1571+
bb.compute_arc(pline.previous, pline.current, i_val, j_val, cmd == 'G2')
1572+
else:
1573+
bb.compute(pline.current)
1574+
else:
1575+
bb.compute(pline.current)
1576+
else:
1577+
bb.compute(pline.current) # compute the bounding box that surrounds the current loop
14131578
# print(node)
14141579
# print(bb)
14151580
nodes.append(LoopNode(loop_index, bb, ploop))
@@ -1610,7 +1775,11 @@ def generate_deffered_perimeters(self, myline, deffered, extrusion_multiplier, e
16101775
# If the gcode was using absolute extrusion, insert an M82 to return to Absolute Extrusion
16111776
buffer.append(from_gcode("M82 ; BRICK: Return to Absolute Extrusion\n"))
16121777
# Resets the correct absolute extrusion register for the next feature:
1613-
buffer.append(from_gcode(f"G92 E{myline.previous.e} ; BRICK: Resets the Extruder absolute position\n"))
1778+
# Use the E value from before the deferred perimeters, not after —
1779+
# the deferred block was replayed with relative extrusion so the
1780+
# firmware E register did not advance in absolute terms.
1781+
e_reset = self.last_noninternalperimeter_state.e if self.last_noninternalperimeter_state else myline.previous.e
1782+
buffer.append(from_gcode(f"G92 E{e_reset} ; BRICK: Resets the Extruder absolute position\n"))
16141783
##########
16151784

16161785
if previous_perimeter != perimeter_index:
@@ -1937,7 +2106,8 @@ def process_gcode(self, gcode_stream):
19372106
# If the gcode was using absolute extrusion, insert an M82 to return to Absolute Extrusion
19382107
buffer_lines.append(from_gcode("M82 ; BRICK: Return to Absolute Extrusion\n"))
19392108
# Resets the correct absolute extrusion register for the next feature:
1940-
buffer_lines.append(from_gcode(f"G92 E{myline.previous.e} ; BRICK: Resets the Extruder absolute position\n"))
2109+
e_reset = self.last_noninternalperimeter_state.e if self.last_noninternalperimeter_state else myline.previous.e
2110+
buffer_lines.append(from_gcode(f"G92 E{e_reset} ; BRICK: Resets the Extruder absolute position\n"))
19412111
self.last_internalperimeter_state = calculated_line.current
19422112
#if myline.previous.width != kept_line.current.width:
19432113
buffer_lines.append(from_gcode(f"{simulator.const_width}{myline.previous.width}\n")) # For the Preview

0 commit comments

Comments
 (0)