302 lines
12 KiB
Python
302 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from tests.python.support.paths import add_repo_path
|
|
|
|
add_repo_path("skills/gcode/scripts")
|
|
|
|
import gcode_tool as gcode
|
|
|
|
|
|
def make_executable(path: Path) -> None:
|
|
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
path.chmod(0o755)
|
|
|
|
|
|
def write_profile(tmp: Path, backend: str = "orcaslicer") -> Path:
|
|
native_config = tmp / f"{backend}.ini"
|
|
native_config.write_text("# slicer profile\n", encoding="utf-8")
|
|
profile = tmp / "profile.json"
|
|
profile.write_text(
|
|
json.dumps(
|
|
{
|
|
"backend": backend,
|
|
"native_config": str(native_config),
|
|
"machine": {
|
|
"name": "Test Printer",
|
|
"bed_size_mm": [180, 180],
|
|
"z_height_mm": 180,
|
|
},
|
|
"filament": {
|
|
"type": "PLA",
|
|
"nozzle_temp_c": 220,
|
|
"bed_temp_c": 65,
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return profile
|
|
|
|
|
|
def write_profile_with_motion_bounds(tmp: Path) -> Path:
|
|
native_config = tmp / "orcaslicer.ini"
|
|
native_config.write_text("# slicer profile\n", encoding="utf-8")
|
|
profile = tmp / "profile_with_motion_bounds.json"
|
|
profile.write_text(
|
|
json.dumps(
|
|
{
|
|
"backend": "orcaslicer",
|
|
"native_config": str(native_config),
|
|
"machine": {
|
|
"name": "Test Printer",
|
|
"bed_size_mm": [180, 180],
|
|
"z_height_mm": 180,
|
|
"motion_bounds_mm": {
|
|
"x": [-14, 181],
|
|
"y": [-4, 185],
|
|
"z": [-1.1, 180],
|
|
},
|
|
},
|
|
"filament": {
|
|
"type": "PLA",
|
|
"nozzle_temp_c": 220,
|
|
"bed_temp_c": 65,
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return profile
|
|
|
|
|
|
class GCodeToolTests(unittest.TestCase):
|
|
def test_discovers_fake_preferred_backend_paths(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
bin_dir = Path(tmp) / "bin"
|
|
bin_dir.mkdir()
|
|
make_executable(bin_dir / "OrcaSlicer")
|
|
make_executable(bin_dir / "prusa-slicer")
|
|
|
|
report = gcode.discovery_report(search_path=str(bin_dir))
|
|
|
|
backends = {item["id"]: item for item in report["backends"]}
|
|
self.assertTrue(backends["orcaslicer"]["available"])
|
|
self.assertTrue(backends["prusa-slicer"]["available"])
|
|
self.assertFalse(backends["curaengine"]["available"])
|
|
self.assertEqual(report["preferred_order"], ["orcaslicer", "prusa-slicer", "curaengine"])
|
|
|
|
def test_profile_validation_requires_backend_native_config_and_bed_limits(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
missing_backend = root / "missing_backend.json"
|
|
missing_backend.write_text("{}", encoding="utf-8")
|
|
with self.assertRaisesRegex(gcode.GCodeToolError, "backend"):
|
|
gcode.load_profile(missing_backend)
|
|
|
|
missing_native = root / "missing_native.json"
|
|
missing_native.write_text(
|
|
json.dumps(
|
|
{
|
|
"backend": "orcaslicer",
|
|
"machine": {"name": "Printer", "bed_size_mm": [180, 180], "z_height_mm": 180},
|
|
"filament": {"type": "PLA", "nozzle_temp_c": 220, "bed_temp_c": 65},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaisesRegex(gcode.GCodeToolError, "native_config"):
|
|
gcode.load_profile(missing_native)
|
|
|
|
bad_bed = root / "bad_bed.json"
|
|
native_config = root / "profile.ini"
|
|
native_config.write_text("# config\n", encoding="utf-8")
|
|
bad_bed.write_text(
|
|
json.dumps(
|
|
{
|
|
"backend": "orcaslicer",
|
|
"native_config": str(native_config),
|
|
"machine": {"name": "Printer", "bed_size_mm": [180], "z_height_mm": 180},
|
|
"filament": {"type": "PLA", "nozzle_temp_c": 220, "bed_temp_c": 65},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaisesRegex(gcode.GCodeToolError, "bed_size_mm"):
|
|
gcode.load_profile(bad_bed)
|
|
|
|
def test_input_classification_for_supported_rejected_and_sliced_bambu_files(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
stl = root / "part.stl"
|
|
stl.write_text("solid part\nendsolid part\n", encoding="utf-8")
|
|
stl_info = gcode.inspect_input(stl)
|
|
self.assertTrue(stl_info.direct_to_slicer)
|
|
self.assertFalse(stl_info.needs_stl_conversion)
|
|
|
|
glb = root / "part.glb"
|
|
glb.write_bytes(b"glTF")
|
|
glb_info = gcode.inspect_input(glb)
|
|
self.assertTrue(glb_info.needs_stl_conversion)
|
|
|
|
sliced = root / "job.gcode.3mf"
|
|
with zipfile.ZipFile(sliced, "w") as archive:
|
|
archive.writestr("[Content_Types].xml", "<Types/>")
|
|
archive.writestr("Metadata/plate_1.gcode", "G1 X1\n")
|
|
sliced_info = gcode.inspect_input(sliced)
|
|
self.assertTrue(sliced_info.already_sliced_bambu)
|
|
self.assertEqual(sliced_info.status, "already_sliced_bambu_3mf")
|
|
|
|
step = root / "part.step"
|
|
step.write_text("ISO-10303-21;", encoding="utf-8")
|
|
with self.assertRaisesRegex(gcode.GCodeToolError, "out of scope"):
|
|
gcode.inspect_input(step)
|
|
|
|
def test_dry_run_command_construction_for_each_backend(self) -> None:
|
|
cases = [
|
|
("orcaslicer", "OrcaSlicer", ["--load-settings", "--outputdir", "--slice"]),
|
|
("prusa-slicer", "prusa-slicer", ["--load", "--export-gcode", "--output"]),
|
|
("curaengine", "CuraEngine", ["slice", "-j", "-l", "-o"]),
|
|
]
|
|
for backend, executable, expected_parts in cases:
|
|
with self.subTest(backend=backend), tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
bin_dir = root / "bin"
|
|
bin_dir.mkdir()
|
|
make_executable(bin_dir / executable)
|
|
profile = write_profile(root, backend)
|
|
model = root / "part.obj"
|
|
model.write_text("o part\n", encoding="utf-8")
|
|
output = root / "part.gcode"
|
|
args = gcode.build_parser().parse_args(
|
|
[
|
|
"slice",
|
|
"--input",
|
|
str(model),
|
|
"--output",
|
|
str(output),
|
|
"--profile",
|
|
str(profile),
|
|
"--backend",
|
|
"auto",
|
|
"--dry-run",
|
|
]
|
|
)
|
|
|
|
plan = gcode.build_slice_plan(args, search_path=str(bin_dir))
|
|
|
|
command = plan["command"]
|
|
self.assertEqual(Path(command[0]).name, executable)
|
|
for part in expected_parts:
|
|
self.assertIn(part, command)
|
|
self.assertEqual(plan["backend"], backend)
|
|
self.assertFalse(plan["conversion"]["required"])
|
|
|
|
def test_refuses_to_slice_already_sliced_bambu_3mf(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
bin_dir = root / "bin"
|
|
bin_dir.mkdir()
|
|
make_executable(bin_dir / "OrcaSlicer")
|
|
profile = write_profile(root)
|
|
sliced = root / "job.gcode.3mf"
|
|
with zipfile.ZipFile(sliced, "w") as archive:
|
|
archive.writestr("Metadata/plate_1.gcode", "G1 X1\n")
|
|
args = gcode.build_parser().parse_args(
|
|
[
|
|
"slice",
|
|
"--input",
|
|
str(sliced),
|
|
"--output",
|
|
str(root / "job.gcode"),
|
|
"--profile",
|
|
str(profile),
|
|
"--dry-run",
|
|
]
|
|
)
|
|
|
|
with self.assertRaisesRegex(gcode.GCodeToolError, "already a sliced Bambu"):
|
|
gcode.build_slice_plan(args, search_path=str(bin_dir))
|
|
|
|
def test_gcode_validation_passes_valid_simple_file(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
profile = gcode.load_profile(write_profile(root))
|
|
toolpath = root / "valid.gcode"
|
|
toolpath.write_text(
|
|
"\n".join(
|
|
[
|
|
"M104 S220",
|
|
"M140 S65",
|
|
"G90",
|
|
"G1 X10 Y10 Z0.2 F1800",
|
|
"G1 X20 Y10 E0.4 F1200",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = gcode.validate_gcode_file(toolpath, profile)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(result["errors"], [])
|
|
self.assertGreaterEqual(result["stats"]["extrusion_moves"], 1)
|
|
|
|
def test_gcode_validation_reports_empty_no_extrusion_and_out_of_bounds(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
profile = gcode.load_profile(write_profile(root))
|
|
|
|
empty = root / "empty.gcode"
|
|
empty.write_text("", encoding="utf-8")
|
|
empty_result = gcode.validate_gcode_file(empty, profile)
|
|
self.assertFalse(empty_result["ok"])
|
|
self.assertIn("G-code file is empty.", empty_result["errors"])
|
|
|
|
no_extrusion = root / "no_extrusion.gcode"
|
|
no_extrusion.write_text("M104 S220\nG1 X10 Y10 Z0.2\n", encoding="utf-8")
|
|
no_extrusion_result = gcode.validate_gcode_file(no_extrusion, profile)
|
|
self.assertFalse(no_extrusion_result["ok"])
|
|
self.assertIn("No extrusion moves found.", no_extrusion_result["errors"])
|
|
|
|
out_of_bounds = root / "out_of_bounds.gcode"
|
|
out_of_bounds.write_text("M104 S220\nG1 X999 Y10 Z0.2 E0.1\n", encoding="utf-8")
|
|
out_of_bounds_result = gcode.validate_gcode_file(out_of_bounds, profile)
|
|
self.assertFalse(out_of_bounds_result["ok"])
|
|
self.assertTrue(any("X=999.0" in error for error in out_of_bounds_result["errors"]))
|
|
|
|
def test_gcode_validation_uses_optional_motion_bounds(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
profile = gcode.load_profile(write_profile_with_motion_bounds(root))
|
|
toolpath = root / "native_start_positions.gcode"
|
|
toolpath.write_text("M104 S220\nM140 S65\nG90\nG1 X-13.5 Y-4 Z-1 E0.1\n", encoding="utf-8")
|
|
|
|
result = gcode.validate_gcode_file(toolpath, profile)
|
|
|
|
self.assertTrue(result["ok"])
|
|
|
|
def test_gcode_validation_warns_for_unknown_commands(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
profile = gcode.load_profile(write_profile(root))
|
|
toolpath = root / "unknown.gcode"
|
|
toolpath.write_text("M104 S220\nM999\nG1 X10 Y10 Z0.2 E0.1\n", encoding="utf-8")
|
|
|
|
result = gcode.validate_gcode_file(toolpath, profile)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertTrue(any("M999" in warning for warning in result["warnings"]))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|