CAM: snapmaker toolheads and spindle speeds

* Removed generic snapmaker machine and toolhead configuration. The idea of running a CNC code that is not matched to the machine's abilities and work area seems dangerous.
* --machine argument is required
* --toolhead argument is required when the selected machine is compatible with more than one toolhead. When the selected machine only supports one toolhead, it is selected as the default.
* --spindle-percent defaults according to the selected toolhead capabilities. If the toolhead can do RPM, then RPM is used, otherwise falls back to percent. This option now functions as an override.
* fixed a bug in convert_spindle() when RPM is selected. The gcode was not returned.
This commit is contained in:
jalapenopuzzle
2025-04-07 00:03:57 +10:00
parent 699a25e243
commit 5eaa6326a4
2 changed files with 186 additions and 70 deletions

View File

@@ -76,7 +76,7 @@ class TestSnapmakerPost(PathTestUtils.PathTestBase):
expected_header = """\
;Header Start
;header_type: cnc
;machine: Snapmaker 2 A350
;machine: Snapmaker 2 A350 50W CNC module
;Post Processor: snapmaker_post
;Cam File: boxtest.fcstd
;Output Time: \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{0,6}
@@ -108,7 +108,7 @@ M5
"""
# test header and body with comments
gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent")
gcode = self.get_gcode([], "--machine=A350 --toolhead=50W_CNC")
g_lines = gcode.splitlines()
e_lines = expected_header.splitlines() + expected_body.splitlines()
@@ -121,12 +121,12 @@ M5
self.assertEqual(exp, line)
# test body without header
gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent --no-header")
gcode = self.get_gcode([], "--machine=A350 --toolhead=50W_CNC --no-header")
self.assertEqual(gcode, expected_body)
# test body without comments
gcode = self.get_gcode(
[], "--machine=A350 --toolhead=50W --spindle-percent --no-header --no-comments"
[], "--machine=A350 --toolhead=50W_CNC --no-header --no-comments"
)
expected = "".join(
[line for line in expected_body.splitlines(keepends=True) if not line.startswith(";")]
@@ -139,7 +139,7 @@ M5
expected = "G0 X10.000 Y20.000 Z30.000"
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
[command], "--machine=A350 --toolhead=50W_CNC --no-header"
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
@@ -151,7 +151,7 @@ M5
expected = "G0 X10.00 Y20.00 Z30.00"
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --precision=2"
[command], "--machine=A350 --toolhead=50W_CNC --no-header --precision=2"
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
@@ -163,7 +163,7 @@ M5
gcode = self.get_gcode(
[command],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --line-numbers --line-number=10 --line-increment=2",
"--machine=A350 --toolhead=50W_CNC --no-header --line-numbers --line-number=10 --line-increment=2",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
@@ -172,7 +172,7 @@ M5
"""Test Pre-amble"""
gcode = self.get_gcode(
[],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --preamble='G18 G55' --no-comments",
"--machine=A350 --toolhead=50W_CNC --no-header --preamble='G18 G55' --no-comments",
)
result = gcode.splitlines()[0]
self.assertEqual(result, "G18 G55")
@@ -181,7 +181,7 @@ M5
"""Test Post-amble"""
gcode = self.get_gcode(
[],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --postamble='G0 Z50\nM2' --no-comments",
"--machine=A350 --toolhead=50W_CNC --no-header --postamble='G0 Z50\nM2' --no-comments",
)
result = gcode.splitlines()[-2]
self.assertEqual(result, "G0 Z50")
@@ -195,7 +195,7 @@ M5
# test inches conversion
expected = "G0 X0.3937 Y0.7874 Z1.1811"
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches"
[command], "--machine=A350 --toolhead=50W_CNC --no-header --inches"
)
self.assertEqual(gcode.splitlines()[3], "G20")
result = gcode.splitlines()[18]
@@ -205,7 +205,7 @@ M5
expected = "G0 X0.39 Y0.79 Z1.18"
gcode = self.get_gcode(
[command],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --inches --precision=2",
"--machine=A350 --toolhead=50W_CNC --no-header --inches --precision=2",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
@@ -218,7 +218,7 @@ M5
expected = "G0 Y30.000"
gcode = self.get_gcode(
[c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header --axis-modal"
[c0, c1], "--machine=A350 --toolhead=50W_CNC --no-header --axis-modal"
)
result = gcode.splitlines()[19]
self.assertEqual(result, expected)
@@ -230,7 +230,7 @@ M5
c1 = Path.Command("M3 S3000")
gcode = self.get_gcode(
[c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
[c0, c1], "--machine=A350 --toolhead=50W_CNC --no-header"
)
self.assertEqual(gcode.splitlines()[19:22], ["M5", "M76", "M6 T2"])
self.assertEqual(
@@ -256,88 +256,155 @@ M5
gcode = self.get_gcode(
[command],
"--machine=Original --toolhead=50W --spindle-percent --no-header",
"--machine=Original --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A150 --toolhead=50W --spindle-percent --no-header",
"--machine=A150 --toolhead=50W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A250 --toolhead=50W --spindle-percent --no-header",
"--machine=A250 --toolhead=50W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A250T --toolhead=50W --spindle-percent --no-header",
"--machine=A250T --toolhead=50W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A250T --toolhead=200W --no-header",
"--machine=A250T --toolhead=200W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A350 --toolhead=50W --spindle-percent --no-header",
"--machine=A350 --toolhead=50W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A350T --toolhead=50W --spindle-percent --no-header",
"--machine=A350T --toolhead=50W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=A350T --toolhead=200W --no-header",
"--machine=A350T --toolhead=200W_CNC --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
gcode = self.get_gcode(
[command],
"--machine=Artisan --toolhead=200W --no-header",
"--machine=Artisan --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
def test_spindle(self):
def test_toolhead_selection(self):
"""Test automatic selection of toolhead where appropriate"""
# check succeeds
command = Path.Command("G0 X10 Y20 Z30")
expected = "G0 X10.000 Y20.000 Z30.000"
gcode = self.get_gcode(
[command],
"--machine=Original --no-header",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
self.assertEqual(self.post.values["TOOLHEAD_NAME"],"Original CNC module")
gcode = self.get_gcode(
[command],
"--machine=A350 --no-header",
)
self.assertFalse(isinstance(gcode,str))
gcode = self.get_gcode(
[command],
"--machine=A350T --no-header",
)
self.assertFalse(isinstance(gcode,str))
# check succeed with artisan (which base is bigger)
gcode = self.get_gcode(
[command],
"--machine=Artisan --no-header --boundaries-check",
)
result = gcode.splitlines()[18]
self.assertEqual(result, expected)
self.assertEqual(self.post.values["TOOLHEAD_NAME"],"200W CNC module")
def test_spindle_percent_rpm_auto_select(self):
"""Test automatic selection of spindle speed rpm vs percent"""
command = Path.Command("M3 S2100")
# test original toolhead
gcode = self.get_gcode(
[command], "--machine=Original --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 P30")
command = Path.Command("M3 S3600")
# test 50W toolhead
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W_CNC --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 P30")
# test 200W toolhead
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=200W_CNC --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 S3600")
# test 200W toolhead
gcode = self.get_gcode(
[command], "--machine=Artisan --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 S3600")
def test_spindle_percent(self):
"""Test spindle speed conversion from RPM to percents"""
command = Path.Command("M3 S3600")
# test 50W toolhead
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
[command], "--machine=A350 --toolhead=50W_CNC --spindle-percent --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 P30")
# test 200W toolhead
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=200W --spindle-percent --no-header"
[command], "--machine=A350 --toolhead=200W_CNC --spindle-percent --no-header"
)
self.assertEqual(gcode.splitlines()[18], "M3 P20")
# test custom spindle speed extrema
gcode = self.get_gcode(
[command],
"--machine=A350 --toolhead=200W --spindle-percent --no-header --spindle-speeds=3000,4000",
"--machine=A350 --toolhead=200W_CNC --spindle-percent --no-header --spindle-speeds=3000,4000",
)
self.assertEqual(gcode.splitlines()[18], "M3 P90")
@@ -346,7 +413,7 @@ M5
command = Path.Command("(comment)")
gcode = self.get_gcode(
[command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
[command], "--machine=A350 --toolhead=50W_CNC --spindle-percent --no-header"
)
result = gcode.splitlines()[18]
expected = ";comment"
@@ -360,7 +427,7 @@ M5
gcode = self.get_gcode(
[command],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check",
"--machine=A350 --toolhead=50W_CNC --no-header --boundaries-check",
)
self.assertTrue(self.post.check_boundaries(gcode.splitlines()))
@@ -369,20 +436,20 @@ M5
c1 = Path.Command("G02 Y260")
gcode = self.get_gcode(
[c0, c1],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check",
"--machine=A350 --toolhead=50W_CNC --no-header --boundaries-check",
)
self.assertFalse(self.post.check_boundaries(gcode.splitlines()))
# check succeed with artisan (which base is bigger)
gcode = self.get_gcode(
[c0, c1],
"--machine=Artisan --toolhead=50W --spindle-percent --no-header --boundaries-check",
"--machine=Artisan --no-header --boundaries-check",
)
self.assertTrue(self.post.check_boundaries(gcode.splitlines()))
# check fails with custom boundaries
gcode = self.get_gcode(
[c0, c1],
"--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check --boundaries='50,400,10'",
"--machine=A350 --toolhead=50W_CNC --no-header --boundaries-check --boundaries='50,400,10'",
)
self.assertFalse(self.post.check_boundaries(gcode.splitlines()))

View File

@@ -51,47 +51,79 @@ SNAPMAKER_MACHINES = dict(
key="Original",
name="Snapmaker Original",
boundaries=dict(X=90, Y=90, Z=50),
compatible_toolheads={"Original_CNC"},
),
Original_Z_Extension=dict(
key="Original_Z_Extension",
name="Snapmaker Original with Z extension",
boundaries=dict(X=90, Y=90, Z=146),
compatible_toolheads={"Original_CNC"},
),
A150=dict(
key="A150",
name="Snapmaker 2 A150",
boundaries=dict(X=160, Y=160, Z=90),
compatible_toolheads={"50W_CNC"},
),
A250=dict(
key="A250",
name="Snapmaker 2 A250",
boundaries=dict(X=230, Y=250, Z=180),
compatible_toolheads={"50W_CNC", "200W_CNC"},
),
A250T=dict(
key="A250T",
name="Snapmaker 2 A250T",
boundaries=dict(X=230, Y=250, Z=180),
compatible_toolheads={"50W_CNC", "200W_CNC"},
),
A350=dict(
key="A350",
name="Snapmaker 2 A350",
boundaries=dict(X=320, Y=350, Z=275),
compatible_toolheads={"50W_CNC", "200W_CNC"},
),
A350T=dict(
key="A350T",
name="Snapmaker 2 A350T",
boundaries=dict(X=320, Y=350, Z=275),
compatible_toolheads={"50W_CNC", "200W_CNC"},
),
Artisan=dict(
key="Artisan",
name="Snapmaker Artisan",
boundaries=dict(X=400, Y=400, Z=400),
boundaries=dict(X=400, Y=413, Z=400),
compatible_toolheads={"200W_CNC"},
),
)
# Could support other types of toolheads (laser, drag knife, 3DP, ...) in the future
# https://wiki.snapmaker.com/en/Snapmaker_Luban/manual/2_supported_gcode_references#m3m4-modified-cnclaser-on
SNAPMAKER_TOOLHEADS = {
"50W": dict(name="50W CNC module", min=0, max=12000, percent=True),
"200W": dict(name="200W CNC module", min=8000, max=18000, percent=False),
"Original_CNC": dict(
key="Original_CNC",
name="Original CNC module",
speed_rpm=dict(min=0, max=7000),
boundaries_delta=dict(X=0, Y=0, Z=0),
has_percent=True,
has_speed_s=False,
),
"50W_CNC": dict(
key="50W_CNC",
name="50W CNC module",
speed_rpm=dict(min=0, max=12000),
boundaries_delta=dict(X=0, Y=0, Z=0),
has_percent=True,
has_speed_s=False,
),
"200W_CNC": dict(
key="200W_CNC",
name="200W CNC module",
speed_rpm=dict(min=0, max=18000),
boundaries_delta=dict(X=0, Y=-13, Z=0),
has_percent=True,
has_speed_s=True,
),
}
@@ -223,16 +255,9 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
self.values["BOUNDARIES_CHECK"] = False
self.values["MACHINES"] = SNAPMAKER_MACHINES
self.values["TOOLHEADS"] = SNAPMAKER_TOOLHEADS
# default toolhead is 50W (the weakest one)
self.values["DEFAULT_TOOLHEAD"] = "50W"
self.values["TOOLHEAD_NAME"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["name"]
self.values["SPINDLE_SPEEDS"] = dict(
min=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["min"],
max=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["max"],
)
self.values["SPINDLE_PERCENT"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]][
"percent"
]
self.values["TOOLHEAD_NAME"] = None
self.values["SPINDLE_SPEEDS"] = dict()
self.values["SPINDLE_PERCENT"] = None
def snapmaker_init_argument_defaults(self) -> None:
"""Initialize which arguments (in a pair) are shown as the default argument."""
@@ -245,7 +270,6 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
self.argument_defaults["thumbnail"] = True
self.argument_defaults["gui"] = True
self.argument_defaults["boundaries-check"] = True
self.argument_defaults["spindle-percent"] = True
def snapmaker_init_arguments_visible(self) -> None:
"""Initialize which argument pairs are visible in TOOLTIP_ARGS."""
@@ -349,14 +373,8 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
group.add_argument(
"--spindle-percent",
action="store_true",
default=argument_defaults["spindle-percent"],
help="use percent as toolhead spindle speed unit",
)
group.add_argument(
"--spindle-rpm",
action="store_false",
dest="spindle_percent",
help="Use RPM as toolhead spindle speed unit",
default=None,
help="use percent as toolhead spindle speed unit (default: use RPM if supported by toolhead, otherwise percent)",
)
group.add_argument(
@@ -390,33 +408,63 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
# The deepcopy is necessary to avoid modifying the boundaries in the MACHINES dict.
self.values["BOUNDARIES"] = copy.deepcopy(machine["boundaries"])
if args.boundaries: # may override machine boundaries, which is expected
self.values["BOUNDARIES"] = args.boundaries
if args.toolhead:
if args.toolhead not in machine["compatible_toolheads"]:
FreeCAD.Console.PrintError(
f"Selected --toolhead={args.toolhead} is not compatible with machine {machine['name']}."
+f" Choose from [{machine['compatible_toolheads']}]\n")
flag = False
return (flag, args)
toolhead = self.values["TOOLHEADS"][args.toolhead]
self.values["TOOLHEAD_NAME"] = toolhead["name"]
elif len(machine["compatible_toolheads"]) == 1:
toolhead_key = next(iter(machine["compatible_toolheads"]))
toolhead = self.values["TOOLHEADS"][toolhead_key]
else:
FreeCAD.Console.PrintWarning(
f'No toolhead selected, using default ({self.values["TOOLHEAD_NAME"]}). '
f"Consider adding --toolhead\n"
FreeCAD.Console.PrintError(
f"Machine {machine['name']} has multiple compatible toolheads:\n"
f"{machine['compatible_toolheads']}\n"
"Please add --toolhead argument.\n"
)
toolhead = self.values["TOOLHEADS"][self.values["DEFAULT_TOOLHEAD"]]
flag = False
return (flag, args)
self.values["TOOLHEAD_KEY"] = toolhead["key"]
self.values["TOOLHEAD_NAME"] = toolhead["name"]
self.values["SPINDLE_SPEEDS"] = {key: toolhead[key] for key in ("min", "max")}
self.values["SPINDLE_SPEEDS"] = toolhead["speed_rpm"]
if args.spindle_speeds: # may override toolhead value, which is expected
self.values["SPINDLE_SPEEDS"] = args.spindle_speeds
if args.spindle_percent is not None:
if toolhead["percent"] is True:
if toolhead["has_percent"]:
self.values["SPINDLE_PERCENT"] = True
if args.spindle_percent is False:
FreeCAD.Console.PrintWarning(
"Toolhead does not handle RPM spindle speed, using percents instead.\n"
)
else:
self.values["SPINDLE_PERCENT"] = args.spindle_percent
FreeCAD.Console.PrintError(
f"Requested spindle speed in percent, but toolhead {toolhead['name']}"
+" does not support speed as percent.\n"
)
flag = False
return (flag, args)
else:
# Prefer speed S over percent P
self.values["SPINDLE_PERCENT"] = toolhead['has_percent'] and not toolhead['has_speed_s']
if self.values["SPINDLE_PERCENT"]:
FreeCAD.Console.PrintWarning(
"Spindle speed will be controlled using using percentages.\n"
)
else:
FreeCAD.Console.PrintWarning(
"Spindle speed will be controlled using using RPM.\n"
)
if args.boundaries: # may override machine boundaries, which is expected
self.values["BOUNDARIES"] = args.boundaries
self.values["MACHINE_NAME"] += " Boundaries overide=" + str(args.boundaries)
else:
# Update machine dimensions based on installed toolhead
for axis in toolhead["boundaries_delta"].keys():
self.values["BOUNDARIES"][axis] += toolhead["boundaries_delta"][axis]
self.values["MACHINE_NAME"] += " " + toolhead["name"]
self.values["THUMBNAIL"] = args.thumbnail
self.values["ALLOW_GUI"] = args.gui
@@ -539,9 +587,10 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
def convert_spindle(self, gcode: List[str]) -> List[str]:
"""convert spindle speed values from RPM to percent (%) (M3/M4 commands)"""
if self.values["SPINDLE_PERCENT"] is False:
return
return gcode
# TODO: check if percentage covers range 0-max (most probable) or min-max (200W has a documented min speed)
# https://wiki.snapmaker.com/en/Snapmaker_Luban/manual/2_supported_gcode_references#m3m4-modified-cnclaser-on
# Speed as percentage in [0,100]% range
for index, commandline in enumerate(
gcode
): # .split(self.values["END_OF_LINE_CHARACTERS"]):