
Gcode
- 3.9k installs
- 11k repo stars
- Updated July 11, 2026
- earthtojake/text-to-cad
gcode is an agent skill that generates and statically validates plain FDM G-code from mesh files by orchestrating OrcaSlicer, PrusaSlicer, or CuraEngine CLI backends.
About
gcode orchestrates plain FDM .gcode generation from 3D mesh files through real slicer CLI backends without uploading or starting print jobs. Supported inputs include .stl, .obj, unsliced .3mf, .ply, .glb, and .gltf, with .ply and glTF converted to temporary STL via optional trimesh at execution time. Every slice requires an explicit wrapper profile JSON pointing to absolute native slicer profiles plus machine and filament validation bounds. The workflow runs discover to pick orcaslicer, prusa-slicer, or curaengine backends, inspect for mesh readiness, dry-run slice commands, execute slice, then validate generated G-code for temperatures, movement, extrusion, and XYZ bounds. OrcaSlicer split profiles use native_settings and native_filaments arrays alongside native_config. After G-code work the skill mandates handing file paths to $cad-viewer when installed. Bambu-specific upload workflows are explicitly out of scope; validated plain .gcode hands off to $bambu-labs instead.
- Slices STL OBJ 3MF PLY GLB GLTF meshes via OrcaSlicer PrusaSlicer or CuraEngine CLIs.
- Requires explicit wrapper profile JSON with absolute native slicer config paths.
- Workflow covers discover, inspect, dry-run slice, execute, and validate steps.
- Validates temperatures movement extrusion and XYZ bounds before printer handoff.
- Hands completed G-code paths to $cad-viewer and Bambu workflows to $bambu-labs.
Gcode by the numbers
- 3,869 all-time installs (skills.sh)
- +650 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #99 of 2,742 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
gcode capabilities & compatibility
- Capabilities
- slicer backend discovery and mesh inspect · profile wrapped slice with dry run and execute · static g code validation for temps and bounds · cad viewer and bambu workflow handoff boundaries
- Use cases
- orchestration
What gcode says it does
Always validate generated G-code before handing it to printer-specific workflows.
npx skills add https://github.com/earthtojake/text-to-cad --skill gcodeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.9k |
|---|---|
| repo stars | ★ 11k |
| Last updated | July 11, 2026 |
| Repository | earthtojake/text-to-cad ↗ |
How do I slice a mesh into printer-profiled G-code with dry-run safety checks and validation before any printer upload?
Slice mesh files into printer-profiled plain FDM G-code using OrcaSlicer, PrusaSlicer, or CuraEngine with dry-run and static validation.
Who is it for?
Developers converting 3D meshes to FDM G-code who need local slicer CLI orchestration and static validation.
Skip if: Skip for Bambu upload or print-start jobs; hand validated G-code to $bambu-labs instead.
When should I use this skill?
User needs to slice STL OBJ 3MF PLY GLB or GLTF into G-code, discover slicer backends, or validate generated G-code.
What you get
A validated plain .gcode file produced from an explicit profile wrapper with inspect and dry-run confirmation steps.
- Validated plain .gcode file
Files
G-code
Provenance: maintained in earthtojake/text-to-cad. Use the installed local skill files as the runtime source of truth; the repository link is only for provenance and release review.
Use this skill for plain .gcode generation from mesh files. It is printer-agnostic and never uploads, starts, or packages print jobs.
Workflow
1. Confirm the input is a supported mesh: .stl, .obj, unsliced .3mf, .ply, .glb, or .gltf. 2. Require an explicit printer/profile wrapper JSON. Do not invent real-printer profiles. 3. Discover slicer backends when the backend is unknown:
python scripts/gcode_tool.py discover4. Inspect the input:
python scripts/gcode_tool.py inspect --input path/to/model.stl --json5. Dry-run the slicer command before executing:
python scripts/gcode_tool.py slice \
--input path/to/model.stl \
--output /tmp/model.gcode \
--profile path/to/profile.json \
--backend auto \
--dry-run6. Execute only after the dry-run command and profile are appropriate:
python scripts/gcode_tool.py slice \
--input path/to/model.stl \
--output /tmp/model.gcode \
--profile path/to/profile.json \
--backend auto \
--execute7. Validate the generated G-code:
python scripts/gcode_tool.py validate \
--gcode /tmp/model.gcode \
--profile path/to/profile.json \
--jsonCAD Viewer Handoff
After completing G-code work that creates or modifies a plain .gcode, you must ALWAYS hand the explicit file path to $cad-viewer when that skill is installed. $cad-viewer must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); the preview is diagnostic only and does not replace this skill's static validation. If $cad-viewer is unavailable or startup fails, report that instead of silently omitting the handoff.
Profile Contract
Every slice requires a wrapper profile JSON with an absolute native slicer profile path:
{
"backend": "orcaslicer",
"native_config": "/absolute/path/to/native-slicer-profile",
"machine": {
"name": "Example Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180,
"motion_bounds_mm": {
"x": [0, 180],
"y": [0, 180],
"z": [0, 180]
}
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65
}
}The wrapper supplies validation bounds and backend selection. machine.motion_bounds_mm is optional; omit it for the default 0..bed_size and 0..z_height bounds, or set it from a native printer profile when start/end G-code intentionally uses safe wipe/purge positions outside the printable area. The native slicer profile remains the source of detailed process, printer, and filament behavior.
For OrcaSlicer, use native_settings and native_filaments when the real profile is split across machine, process, and filament JSON files. Keep native_config as an absolute path to the primary native profile for compatibility:
{
"backend": "orcaslicer",
"native_config": "/absolute/path/to/machine-or-process.json",
"native_settings": [
"/absolute/path/to/machine.json",
"/absolute/path/to/process.json"
],
"native_filaments": [
"/absolute/path/to/filament.json"
],
"machine": {
"name": "Example Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65
}
}Backends And Inputs
Preferred slicer backend order is orcaslicer, prusa-slicer, then curaengine. Prefer installing OrcaSlicer when no preferred backend is available; on macOS use brew install --cask orcaslicer and then rerun discover. The helper checks both PATH and the usual /Applications/OrcaSlicer.app cask location. Bambu Studio may be reported by discovery as available but is not preferred because its CLI export path has shown macOS instability.
Pass .stl, .obj, and unsliced .3mf directly to the slicer. Convert .ply, .glb, and .gltf to temporary STL at execution time with optional trimesh; if trimesh is unavailable, ask the user to install it or provide .stl, .obj, or unsliced .3mf.
Reject .step, .stp, .dxf, .svg, .urdf, and .sdf in v1. Use the existing CAD/render workflows to convert those to a supported mesh format before using this skill.
Read references/slicer-backends.md when backend behavior, profile expectations, or source links matter.
Validation
Always validate generated G-code before handing it to printer-specific workflows. The validator checks for non-empty content, temperature commands, movement commands, extrusion moves, XYZ bounds, and unknown command warnings.
Read references/gcode-validation.md when interpreting validation output or deciding whether a warning is acceptable.
Bambu Boundary
This skill generates plain .gcode only. It does not create Bambu .gcode.3mf archives and does not contact printers. For Bambu upload/start workflows, hand off the validated plain .gcode to $bambu-labs. Let $bambu-labs choose the printer-specific LAN handoff, such as an A1 Mini template project or an explicitly enabled bambox project package.
interface:
display_name: "G-code"
short_description: "Generate G-code from 3D mesh files"
default_prompt: "Use $gcode to generate printer-profiled G-code from a mesh file."
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
G-code Validation
scripts/gcode_tool.py validate performs static checks only. It does not simulate extrusion physics, firmware state, acceleration limits, or slicer-specific semantics.
Required Checks
Validation fails when:
- The file is empty.
- No
G0,G1,G2, orG3movement commands are present. - No extrusion moves are present.
- No nozzle or bed temperature commands are present.
- Parsed absolute
X,Y, orZmoves exceed the wrapper profile motion bounds.
Validation warns when:
- Unknown or unsupported G-code commands are encountered.
- Relative positioning is used; bounds checking is skipped while relative mode is active.
Warnings do not rewrite or delete commands. Treat them as review prompts, especially before sending G-code to unfamiliar firmware.
Bounds Policy
The validator assumes absolute positioning until G91 appears, and resumes absolute bounds checks after G90. This avoids false hard failures for relative motion blocks while still catching obvious out-of-bed absolute moves.
The wrapper profile provides printable bounds:
machine.bed_size_mm[0]: maximumXmachine.bed_size_mm[1]: maximumYmachine.z_height_mm: maximumZ
By default, motion bounds are X=0..bed_size_mm[0], Y=0..bed_size_mm[1], and Z=0..z_height_mm. If a native printer profile intentionally uses safe off-bed wipe, purge, or maintenance positions, set machine.motion_bounds_mm with explicit x, y, and/or z [min, max] ranges. Do this only from a real printer/profile source, not as a way to silence unknown G-code.
Interpreting Results
ok: true means the file passed these static checks. It does not mean the G-code is safe to print on real hardware. Still review:
- Printer/profile match.
- Filament and temperature settings.
- Start and end G-code.
- Bed origin and coordinate system.
- Any unknown command warnings.
Slicer Backends
Use real slicer CLIs for mesh-to-G-code work. Do not rely on Bambu Studio as the default backend for this skill.
Backend Order
1. orcaslicer 2. prusa-slicer 3. curaengine
If no preferred backend is installed, install OrcaSlicer first instead of treating the missing slicer as a user-facing blocker. On macOS:
brew install --cask orcaslicerAfter installation, rerun scripts/gcode_tool.py discover. The helper checks PATH, backend-specific environment variables, and common macOS app bundle locations such as /Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer.
scripts/gcode_tool.py discover checks PATH and backend-specific environment variables:
ORCASLICER_BINPRUSASLICER_BINCURAENGINE_BIN
Bambu Studio may also be reported as available but not preferred. It is intentionally excluded from default backend selection because the local Bambu CLI export path has previously crashed during .gcode.3mf generation.
Profile Expectations
Every slice needs a wrapper profile JSON. The wrapper must include:
backend: one oforcaslicer,prusa-slicer, orcuraengine.native_config: absolute path to the primary native slicer config/profile file.machine.bed_size_mm:[width, depth]in millimeters.machine.z_height_mm: maximum Z travel in millimeters.machine.motion_bounds_mm: optional per-axis motion limits for native start/end G-code that intentionally moves outside the printable area.filament.type,filament.nozzle_temp_c, andfilament.bed_temp_c.
The wrapper is not a complete slicer profile. The native config is authoritative for detailed printer, process, and filament settings.
For OrcaSlicer profiles split across native machine, process, and filament JSON files, add:
native_settings: string or list of absolute paths passed to--load-settings.native_filaments: string or list of absolute paths passed to--load-filaments.
If native_settings is omitted, native_config is passed as the only setting file.
Command Shapes
For OrcaSlicer:
OrcaSlicer --load-settings machine.json\;process.json --load-filaments filament.json --outputdir /tmp/out --slice 0 input.stlFor PrusaSlicer:
prusa-slicer --load profile.ini --export-gcode --output output.gcode input.stlFor CuraEngine:
CuraEngine slice -j profile.json -l input.stl -o output.gcodeAlways run a dry-run first and inspect the emitted command before --execute.
Input Handling
.stl,.obj, and unsliced.3mfare passed directly to the selected slicer..ply,.glb, and.gltfare converted to temporary STL withtrimeshduring--execute.- Sliced Bambu 3MF files containing
Metadata/plate_N.gcodeare already print jobs; do not re-slice them. .step,.stp,.dxf,.svg,.urdf, and.sdfare out of scope for this skill. Convert them to a supported mesh first.
Source Links
- FullControl procedural G-code context: https://github.com/FullControlXYZ/fullcontrol
- bambox Bambu packaging reference only: https://pypi.org/project/bambox/
- trimesh mesh format support: https://trimesh.org/formats.html
- CuraEngine slicing background: https://github.com/Ultimaker/CuraEngine/wiki/Slicing
#!/usr/bin/env python3
"""Dry-run-first helpers for generating and validating plain FDM G-code."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
DIRECT_MESH_EXTENSIONS = {".stl", ".obj", ".3mf"}
CONVERT_TO_STL_EXTENSIONS = {".ply", ".glb", ".gltf"}
UNSUPPORTED_EXTENSIONS = {".step", ".stp", ".dxf", ".svg", ".urdf", ".sdf"}
SLICED_BAMBU_PLATE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
PREFERRED_BACKEND_ORDER = ["orcaslicer", "prusa-slicer", "curaengine"]
BACKEND_EXECUTABLES = {
"orcaslicer": ["OrcaSlicer", "orca-slicer", "orcaslicer"],
"prusa-slicer": ["prusa-slicer", "PrusaSlicer", "prusa-slicer-console"],
"curaengine": ["CuraEngine", "curaengine"],
}
BACKEND_APP_GLOBS = {
"orcaslicer": ["OrcaSlicer*.app/Contents/MacOS/OrcaSlicer"],
"prusa-slicer": ["PrusaSlicer*.app/Contents/MacOS/PrusaSlicer"],
}
BACKEND_ENV_VARS = {
"orcaslicer": "ORCASLICER_BIN",
"prusa-slicer": "PRUSASLICER_BIN",
"curaengine": "CURAENGINE_BIN",
}
BAMBU_EXECUTABLES = ["bambu-studio", "BambuStudio"]
SUPPORTED_GCODE_COMMANDS = {
"G0",
"G1",
"G2",
"G3",
"G4",
"G21",
"G28",
"G29",
"G90",
"G91",
"G92",
"M18",
"M73",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M117",
"M118",
"M140",
"M190",
"M201",
"M203",
"M204",
"M205",
"M220",
"M221",
"M400",
"M500",
"M501",
"M900",
}
class GCodeToolError(RuntimeError):
"""Raised for expected user-facing workflow errors."""
@dataclass(frozen=True)
class BackendDiscovery:
id: str
available: bool
path: str | None
preferred: bool
reason: str = ""
@dataclass(frozen=True)
class GCodeProfile:
backend: str
native_config: str
native_settings: tuple[str, ...]
native_filaments: tuple[str, ...]
machine_name: str
bed_size_mm: tuple[float, float]
z_height_mm: float
motion_bounds_mm: dict[str, tuple[float, float]]
filament_type: str
nozzle_temp_c: float
bed_temp_c: float
@dataclass(frozen=True)
class InputInspection:
path: str
extension: str
size_bytes: int
supported: bool
direct_to_slicer: bool
needs_stl_conversion: bool
already_sliced_bambu: bool
status: str
def tail_text(value: str, *, max_lines: int = 80) -> str:
lines = value.splitlines()
if len(lines) <= max_lines:
return value
return "\n".join(lines[-max_lines:])
def find_executable(names: list[str], *, search_path: str | None = None) -> str | None:
for name in names:
found = shutil.which(name, path=search_path)
if found:
return found
return None
def find_app_executable(backend: str) -> str | None:
applications = Path("/Applications")
if not applications.exists():
return None
for pattern in BACKEND_APP_GLOBS.get(backend, []):
for path in sorted(applications.glob(pattern)):
if path.is_file():
return str(path)
return None
def find_backend_executable(backend: str, *, search_path: str | None = None) -> str | None:
env_value = os.environ.get(BACKEND_ENV_VARS[backend], "").strip()
if env_value:
return env_value
return find_executable(BACKEND_EXECUTABLES[backend], search_path=search_path) or find_app_executable(backend)
def discover_backends(*, search_path: str | None = None) -> list[BackendDiscovery]:
discoveries: list[BackendDiscovery] = []
for backend_id in PREFERRED_BACKEND_ORDER:
path = find_backend_executable(backend_id, search_path=search_path)
discoveries.append(
BackendDiscovery(
id=backend_id,
available=bool(path),
path=path or None,
preferred=True,
)
)
return discoveries
def discover_bambu_studio(*, search_path: str | None = None) -> list[BackendDiscovery]:
paths: list[str] = []
env_value = os.environ.get("BAMBU_STUDIO_BIN", "").strip()
if env_value:
paths.append(env_value)
which_value = find_executable(BAMBU_EXECUTABLES, search_path=search_path)
if which_value:
paths.append(which_value)
applications = Path("/Applications")
if applications.exists():
for pattern in ("BambuStudio*.app/Contents/MacOS/BambuStudio", "Bambu Studio*.app/Contents/MacOS/BambuStudio"):
paths.extend(str(path) for path in applications.glob(pattern))
unique_paths = sorted(dict.fromkeys(paths))
if not unique_paths:
return [
BackendDiscovery(
id="bambu-studio",
available=False,
path=None,
preferred=False,
reason="not preferred because Bambu Studio CLI has shown macOS export instability",
)
]
return [
BackendDiscovery(
id="bambu-studio",
available=True,
path=path,
preferred=False,
reason="available but not preferred because Bambu Studio CLI has shown macOS export instability",
)
for path in unique_paths
]
def discovery_report(*, search_path: str | None = None) -> dict[str, Any]:
backends = discover_backends(search_path=search_path)
not_preferred = discover_bambu_studio(search_path=search_path)
return {
"preferred_order": PREFERRED_BACKEND_ORDER,
"backends": [asdict(item) for item in backends],
"not_preferred": [asdict(item) for item in not_preferred],
}
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError as exc:
raise GCodeToolError(f"Profile does not exist: {path}") from exc
except json.JSONDecodeError as exc:
raise GCodeToolError(f"Profile is not valid JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise GCodeToolError("Profile JSON must be an object.")
return data
def require_number(value: Any, label: str) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise GCodeToolError(f"Profile field {label} must be a number.")
number = float(value)
if number <= 0:
raise GCodeToolError(f"Profile field {label} must be greater than zero.")
return number
def require_absolute_file(path_value: str, label: str) -> str:
profile_path = Path(path_value).expanduser()
if not profile_path.is_absolute():
raise GCodeToolError(f"Profile field {label} must be an absolute path.")
if not profile_path.is_file():
raise GCodeToolError(f"Profile {label} does not exist: {profile_path}")
return str(profile_path)
def optional_path_list(data: dict[str, Any], field: str) -> tuple[str, ...]:
value = data.get(field)
if value is None:
return ()
if isinstance(value, str):
values = [value]
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
values = value
else:
raise GCodeToolError(f"Profile field {field} must be a string path or list of string paths.")
paths = [item.strip() for item in values if item.strip()]
if not paths:
raise GCodeToolError(f"Profile field {field} cannot be empty when provided.")
return tuple(require_absolute_file(path, field) for path in paths)
def parse_axis_bounds(value: Any, label: str) -> tuple[float, float]:
if (
not isinstance(value, list)
or len(value) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value)
):
raise GCodeToolError(f"Profile field {label} must be [min, max].")
lower = float(value[0])
upper = float(value[1])
if lower >= upper:
raise GCodeToolError(f"Profile field {label} must have min less than max.")
return lower, upper
def load_profile(path: Path) -> GCodeProfile:
data = load_json(path)
backend = str(data.get("backend") or "").strip().lower()
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Profile backend must be one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
native_config = str(data.get("native_config") or "").strip()
if not native_config:
raise GCodeToolError("Profile field native_config is required.")
native_config = require_absolute_file(native_config, "native_config")
native_settings = optional_path_list(data, "native_settings") or (native_config,)
native_filaments = optional_path_list(data, "native_filaments")
machine = data.get("machine")
if not isinstance(machine, dict):
raise GCodeToolError("Profile field machine must be an object.")
machine_name = str(machine.get("name") or "").strip()
if not machine_name:
raise GCodeToolError("Profile field machine.name is required.")
bed_size = machine.get("bed_size_mm")
if (
not isinstance(bed_size, list)
or len(bed_size) != 2
or any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in bed_size)
):
raise GCodeToolError("Profile field machine.bed_size_mm must be [width, depth].")
bed_x = require_number(bed_size[0], "machine.bed_size_mm[0]")
bed_y = require_number(bed_size[1], "machine.bed_size_mm[1]")
z_height = require_number(machine.get("z_height_mm"), "machine.z_height_mm")
motion_bounds = {
"x": (0.0, bed_x),
"y": (0.0, bed_y),
"z": (0.0, z_height),
}
if "motion_bounds_mm" in machine:
raw_bounds = machine.get("motion_bounds_mm")
if not isinstance(raw_bounds, dict):
raise GCodeToolError("Profile field machine.motion_bounds_mm must be an object.")
for axis in ("x", "y", "z"):
if axis in raw_bounds:
motion_bounds[axis] = parse_axis_bounds(raw_bounds[axis], f"machine.motion_bounds_mm.{axis}")
filament = data.get("filament")
if not isinstance(filament, dict):
raise GCodeToolError("Profile field filament must be an object.")
filament_type = str(filament.get("type") or "").strip()
if not filament_type:
raise GCodeToolError("Profile field filament.type is required.")
nozzle_temp = require_number(filament.get("nozzle_temp_c"), "filament.nozzle_temp_c")
bed_temp = require_number(filament.get("bed_temp_c"), "filament.bed_temp_c")
return GCodeProfile(
backend=backend,
native_config=native_config,
native_settings=native_settings,
native_filaments=native_filaments,
machine_name=machine_name,
bed_size_mm=(bed_x, bed_y),
z_height_mm=z_height,
motion_bounds_mm=motion_bounds,
filament_type=filament_type,
nozzle_temp_c=nozzle_temp,
bed_temp_c=bed_temp,
)
def is_sliced_bambu_3mf(path: Path) -> bool:
try:
with zipfile.ZipFile(path) as archive:
return any(SLICED_BAMBU_PLATE_RE.match(info.filename) for info in archive.infolist())
except zipfile.BadZipFile:
return False
def inspect_input(path: Path) -> InputInspection:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"Input file does not exist: {resolved}")
extension = resolved.suffix.lower()
if extension in UNSUPPORTED_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"{extension} is out of scope for this skill. Supported v1 mesh inputs: {supported}.")
if extension not in DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS:
supported = ", ".join(sorted(DIRECT_MESH_EXTENSIONS | CONVERT_TO_STL_EXTENSIONS))
raise GCodeToolError(f"Unsupported input extension {extension or '<none>'}. Supported v1 mesh inputs: {supported}.")
already_sliced = extension == ".3mf" and is_sliced_bambu_3mf(resolved)
if already_sliced:
status = "already_sliced_bambu_3mf"
elif extension in DIRECT_MESH_EXTENSIONS:
status = "direct_to_slicer"
else:
status = "requires_stl_conversion"
return InputInspection(
path=str(resolved),
extension=extension,
size_bytes=resolved.stat().st_size,
supported=True,
direct_to_slicer=extension in DIRECT_MESH_EXTENSIONS and not already_sliced,
needs_stl_conversion=extension in CONVERT_TO_STL_EXTENSIONS,
already_sliced_bambu=already_sliced,
status=status,
)
def backend_path(backend: str, *, search_path: str | None = None) -> str:
path = find_backend_executable(backend, search_path=search_path)
if not path:
names = ", ".join(BACKEND_EXECUTABLES[backend])
raise GCodeToolError(f"Backend {backend} is not installed or not on PATH. Checked: {names}.")
return path
def build_backend_command(
*,
backend: str,
executable: str,
slicer_input: Path,
output: Path,
profile: GCodeProfile,
) -> list[str]:
if backend == "orcaslicer":
command = [
executable,
"--load-settings",
";".join(profile.native_settings),
]
if profile.native_filaments:
command.extend(["--load-filaments", ";".join(profile.native_filaments)])
command.extend(
[
"--outputdir",
str(output.parent),
"--slice",
"0",
str(slicer_input),
]
)
return command
if backend == "prusa-slicer":
return [
executable,
"--load",
profile.native_config,
"--export-gcode",
"--output",
str(output),
str(slicer_input),
]
if backend == "curaengine":
return [
executable,
"slice",
"-j",
profile.native_config,
"-l",
str(slicer_input),
"-o",
str(output),
]
raise GCodeToolError(f"Unsupported backend: {backend}")
def generated_gcode_candidate(output: Path, slicer_input: Path, existing_gcodes: set[Path]) -> Path | None:
expected_by_input = output.parent / f"{slicer_input.stem}.gcode"
if expected_by_input.is_file() and expected_by_input.resolve() != output.resolve():
return expected_by_input
generated = [
path
for path in output.parent.glob("*.gcode")
if path.resolve() not in existing_gcodes and path.resolve() != output.resolve()
]
if len(generated) == 1:
return generated[0]
return None
def build_slice_plan(args: argparse.Namespace, *, search_path: str | None = None, converted_input: Path | None = None) -> dict[str, Any]:
profile = load_profile(Path(args.profile).expanduser())
inspection = inspect_input(Path(args.input))
if inspection.already_sliced_bambu:
raise GCodeToolError("Input is already a sliced Bambu .gcode.3mf. Refusing to re-slice it.")
backend = profile.backend if args.backend == "auto" else args.backend
if backend not in PREFERRED_BACKEND_ORDER:
raise GCodeToolError(f"Backend must be auto or one of: {', '.join(PREFERRED_BACKEND_ORDER)}.")
executable = backend_path(backend, search_path=search_path)
input_path = Path(inspection.path)
slicer_input = converted_input or input_path
if inspection.needs_stl_conversion and converted_input is None:
slicer_input = Path(tempfile.gettempdir()) / f"{input_path.stem}.converted.stl"
output = Path(args.output).expanduser().resolve()
command = build_backend_command(
backend=backend,
executable=executable,
slicer_input=slicer_input,
output=output,
profile=profile,
)
return {
"dry_run": not args.execute,
"backend": backend,
"executable": executable,
"command": command,
"input": asdict(inspection),
"output": str(output),
"profile": asdict(profile),
"conversion": {
"required": inspection.needs_stl_conversion,
"slicer_input": str(slicer_input),
"note": "Requires trimesh at execution time." if inspection.needs_stl_conversion else "",
},
}
def convert_mesh_to_stl(input_path: Path, output_path: Path) -> None:
try:
import trimesh # type: ignore
except ImportError as exc:
raise GCodeToolError(
"trimesh is required to convert .ply/.glb/.gltf inputs to temporary STL. "
"Install it in the active Python environment or provide .stl/.obj/.3mf directly."
) from exc
loaded = trimesh.load(str(input_path))
if getattr(loaded, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty: {input_path}")
if hasattr(loaded, "dump"):
mesh = loaded.dump(concatenate=True)
else:
mesh = loaded
if getattr(mesh, "is_empty", False):
raise GCodeToolError(f"Input mesh appears empty after scene conversion: {input_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
mesh.export(str(output_path), file_type="stl")
def execute_slice(args: argparse.Namespace, *, search_path: str | None = None) -> tuple[int, dict[str, Any]]:
input_inspection = inspect_input(Path(args.input))
output = Path(args.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="gcode-slice-") as tmp:
converted_input = None
if input_inspection.needs_stl_conversion:
converted_input = Path(tmp) / f"{Path(input_inspection.path).stem}.stl"
convert_mesh_to_stl(Path(input_inspection.path), converted_input)
plan = build_slice_plan(args, search_path=search_path, converted_input=converted_input)
slicer_input = Path(plan["conversion"]["slicer_input"])
existing_gcodes = {path.resolve() for path in output.parent.glob("*.gcode")}
result = subprocess.run(plan["command"], check=False, capture_output=True, text=True)
plan["returncode"] = result.returncode
if result.stdout:
plan["stdout_tail"] = tail_text(result.stdout)
if result.stderr:
plan["stderr_tail"] = tail_text(result.stderr)
if result.returncode != 0:
return result.returncode, plan
if not output.is_file():
candidate = generated_gcode_candidate(output, slicer_input, existing_gcodes)
if candidate is None:
plan["error"] = "Slicer exited successfully but did not create the requested output file."
return 1, plan
candidate.replace(output)
plan["renamed_output_from"] = str(candidate)
plan["generated"] = {"path": str(output), "size_bytes": output.stat().st_size}
return 0, plan
def strip_gcode_comment(line: str) -> str:
return line.split(";", 1)[0].strip()
def parse_command(line: str) -> str:
stripped = strip_gcode_comment(line)
if not stripped:
return ""
first = stripped.split(None, 1)[0].upper()
if re.fullmatch(r"[GMT]\d+(?:\.\d+)?", first):
if "." in first:
first = first.split(".", 1)[0]
return first
return first
def parse_numeric_tokens(line: str) -> dict[str, float]:
stripped = strip_gcode_comment(line).upper()
tokens: dict[str, float] = {}
for key, value in re.findall(r"([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))", stripped):
try:
tokens[key] = float(value)
except ValueError:
continue
return tokens
def validate_gcode_file(path: Path, profile: GCodeProfile) -> dict[str, Any]:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise GCodeToolError(f"G-code file does not exist: {resolved}")
text = resolved.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errors: list[str] = []
warnings: list[str] = []
unknown_commands: dict[str, int] = {}
stats = {
"lines": len(lines),
"non_comment_lines": 0,
"movement_commands": 0,
"extrusion_moves": 0,
"temperature_commands": 0,
}
if not text.strip():
errors.append("G-code file is empty.")
absolute_positioning = True
x_min, x_max = profile.motion_bounds_mm["x"]
y_min, y_max = profile.motion_bounds_mm["y"]
z_min, z_max = profile.motion_bounds_mm["z"]
for line_number, line in enumerate(lines, start=1):
command = parse_command(line)
if not command:
continue
stats["non_comment_lines"] += 1
tokens = parse_numeric_tokens(line)
if command not in SUPPORTED_GCODE_COMMANDS and not re.fullmatch(r"T\d+", command):
unknown_commands.setdefault(command, line_number)
if command == "G90":
absolute_positioning = True
elif command == "G91":
absolute_positioning = False
if "Relative positioning found; XYZ bounds validation is skipped while relative mode is active." not in warnings:
warnings.append("Relative positioning found; XYZ bounds validation is skipped while relative mode is active.")
if command in {"G0", "G1", "G2", "G3"}:
stats["movement_commands"] += 1
if command == "G1" and "E" in tokens:
stats["extrusion_moves"] += 1
if absolute_positioning:
if "X" in tokens and not x_min <= tokens["X"] <= x_max:
errors.append(f"Line {line_number}: X={tokens['X']} is outside X motion range {x_min}..{x_max} mm.")
if "Y" in tokens and not y_min <= tokens["Y"] <= y_max:
errors.append(f"Line {line_number}: Y={tokens['Y']} is outside Y motion range {y_min}..{y_max} mm.")
if "Z" in tokens and not z_min <= tokens["Z"] <= z_max:
errors.append(f"Line {line_number}: Z={tokens['Z']} is outside Z motion range {z_min}..{z_max} mm.")
if command in {"M104", "M109", "M140", "M190"}:
stats["temperature_commands"] += 1
if stats["movement_commands"] == 0:
errors.append("No G0/G1/G2/G3 movement commands found.")
if stats["extrusion_moves"] == 0:
errors.append("No extrusion moves found.")
if stats["temperature_commands"] == 0:
errors.append("No nozzle or bed temperature commands found.")
if unknown_commands:
sample = ", ".join(f"{cmd} line {line}" for cmd, line in sorted(unknown_commands.items())[:12])
warnings.append(f"Unknown or unsupported commands were left unchanged: {sample}.")
return {
"ok": not errors,
"path": str(resolved),
"profile": asdict(profile),
"errors": errors,
"warnings": warnings,
"stats": stats,
}
def print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
def discover_main(args: argparse.Namespace) -> int:
print_json(discovery_report(search_path=args.search_path))
return 0
def inspect_main(args: argparse.Namespace) -> int:
inspection = inspect_input(Path(args.input))
payload = asdict(inspection)
if args.json:
print_json(payload)
else:
print(f"{payload['status']}: {payload['path']}")
return 0
def slice_main(args: argparse.Namespace) -> int:
if args.execute and args.dry_run:
raise GCodeToolError("--execute and --dry-run cannot be used together.")
if not args.execute:
plan = build_slice_plan(args, search_path=args.search_path)
plan["dry_run"] = True
print_json(plan)
return 0
code, result = execute_slice(args, search_path=args.search_path)
result["dry_run"] = False
print_json(result)
return code
def validate_main(args: argparse.Namespace) -> int:
profile = load_profile(Path(args.profile).expanduser())
result = validate_gcode_file(Path(args.gcode), profile)
if args.json:
print_json(result)
else:
status = "ok" if result["ok"] else "failed"
print(f"Validation {status}: {result['path']}")
for error in result["errors"]:
print(f"error: {error}")
for warning in result["warnings"]:
print(f"warning: {warning}")
return 0 if result["ok"] else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
discover = subparsers.add_parser("discover", help="Report installed slicer backends.")
discover.add_argument("--search-path", help=argparse.SUPPRESS)
discover.set_defaults(func=discover_main)
inspect = subparsers.add_parser("inspect", help="Classify a mesh input before slicing.")
inspect.add_argument("--input", required=True)
inspect.add_argument("--json", action="store_true")
inspect.set_defaults(func=inspect_main)
slice_parser = subparsers.add_parser("slice", help="Build or run a slicer CLI command.")
slice_parser.add_argument("--input", required=True)
slice_parser.add_argument("--output", required=True)
slice_parser.add_argument("--profile", required=True)
slice_parser.add_argument("--backend", default="auto", choices=["auto", *PREFERRED_BACKEND_ORDER])
slice_parser.add_argument("--dry-run", action="store_true", help="Print the planned slicer command without running it.")
slice_parser.add_argument("--execute", action="store_true", help="Run the slicer command and write local .gcode.")
slice_parser.add_argument("--search-path", help=argparse.SUPPRESS)
slice_parser.set_defaults(func=slice_main)
validate = subparsers.add_parser("validate", help="Statically validate generated G-code.")
validate.add_argument("--gcode", required=True)
validate.add_argument("--profile", required=True)
validate.add_argument("--json", action="store_true")
validate.set_defaults(func=validate_main)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except GCodeToolError as exc:
print_json({"ok": False, "error": str(exc)})
return 2
if __name__ == "__main__":
raise SystemExit(main())
Related skills
How it compares
Mesh-to-G-code slicer orchestrator, not Bambu upload or CAD modeling generation.
FAQ
Which slicer backends are supported?
Preferred order is orcaslicer, prusa-slicer, then curaengine, discovered via gcode_tool.py discover.
Does this skill upload to a printer?
No. It generates plain .gcode only and does not create Bambu archives or contact printers.