From e57bf99d11df5d09d37098d2afc54a1c2ca95109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 30 Jan 2015 21:47:48 +0100 Subject: [PATCH] Fixed an issue causing slicing not to work due to a change in how the used sarge library works Starting commands asynchronously now necessitates a call to Pipeline.wait_events in order to be able to access the started Command instances. Not waiting causes the list of commands to be not fully populated and hence issues with code depending on that stuff being present. Conflicts: src/octoprint/plugins/cura/__init__.py --- src/octoprint/plugins/cura/__init__.py | 421 +++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/octoprint/plugins/cura/__init__.py diff --git a/src/octoprint/plugins/cura/__init__.py b/src/octoprint/plugins/cura/__init__.py new file mode 100644 index 00000000..b0509df4 --- /dev/null +++ b/src/octoprint/plugins/cura/__init__.py @@ -0,0 +1,421 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import logging +import logging.handlers +import os +import flask +import math + +import octoprint.plugin +import octoprint.util +import octoprint.slicing +import octoprint.settings + +from .profile import Profile + +class CuraPlugin(octoprint.plugin.SlicerPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.StartupPlugin): + + def __init__(self): + self._logger = logging.getLogger("octoprint.plugins.cura") + self._cura_logger = logging.getLogger("octoprint.plugins.cura.engine") + + # setup job tracking across threads + import threading + self._slicing_commands = dict() + self._cancelled_jobs = [] + self._job_mutex = threading.Lock() + + ##~~ StartupPlugin API + + def on_startup(self, host, port): + # setup our custom logger + cura_logging_handler = logging.handlers.RotatingFileHandler(self._settings.getPluginLogfilePath(postfix="engine"), maxBytes=2*1024*1024) + cura_logging_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + cura_logging_handler.setLevel(logging.DEBUG) + + self._cura_logger.addHandler(cura_logging_handler) + self._cura_logger.setLevel(logging.DEBUG if self._settings.getBoolean(["debug_logging"]) else logging.CRITICAL) + self._cura_logger.propagate = False + + ##~~ BlueprintPlugin API + + @octoprint.plugin.BlueprintPlugin.route("/import", methods=["POST"]) + def importCuraProfile(self): + import datetime + import tempfile + + from octoprint.server import slicingManager + + input_name = "file" + input_upload_name = input_name + "." + self._settings.globalGet(["server", "uploads", "nameSuffix"]) + input_upload_path = input_name + "." + self._settings.globalGet(["server", "uploads", "pathSuffix"]) + + if input_upload_name in flask.request.values and input_upload_path in flask.request.values: + filename = flask.request.values[input_upload_name] + try: + profile_dict = Profile.from_cura_ini(flask.request.values[input_upload_path]) + except Exception as e: + return flask.make_response("Something went wrong while converting imported profile: {message}".format(e.message), 500) + + elif input_name in flask.request.files: + temp_file = tempfile.NamedTemporaryFile("wb", delete=False) + try: + temp_file.close() + upload = flask.request.files[input_name] + upload.save(temp_file.name) + profile_dict = Profile.from_cura_ini(temp_file.name) + except Exception as e: + return flask.make_response("Something went wrong while converting imported profile: {message}".format(e.message), 500) + finally: + os.remove(temp_file) + + filename = upload.filename + + else: + return flask.make_response("No file included", 400) + + if profile_dict is None: + return flask.make_response("Could not convert Cura profile", 400) + + name, _ = os.path.splitext(filename) + + # default values for name, display name and description + profile_name = _sanitize_name(name) + profile_display_name = name + profile_description = "Imported from {filename} on {date}".format(filename=filename, date=octoprint.util.getFormattedDateTime(datetime.datetime.now())) + profile_allow_overwrite = False + + # overrides + if "name" in flask.request.values: + profile_name = flask.request.values["name"] + if "displayName" in flask.request.values: + profile_display_name = flask.request.values["displayName"] + if "description" in flask.request.values: + profile_description = flask.request.values["description"] + if "allowOverwrite" in flask.request.values: + from octoprint.server.api import valid_boolean_trues + profile_allow_overwrite = flask.request.values["allowOverwrite"] in valid_boolean_trues + + slicingManager.save_profile("cura", + profile_name, + profile_dict, + allow_overwrite=profile_allow_overwrite, + display_name=profile_display_name, + description=profile_description) + + result = dict( + resource=flask.url_for("api.slicingGetSlicerProfile", slicer="cura", name=profile_name, _external=True), + displayName=profile_display_name, + description=profile_description + ) + r = flask.make_response(flask.jsonify(result), 201) + r.headers["Location"] = result["resource"] + return r + + ##~~ AssetPlugin API + + def get_assets(self): + return { + "js": ["js/cura.js"], + "less": ["less/cura.less"], + "css": ["css/cura.css"] + } + + ##~~ SettingsPlugin API + + def on_settings_save(self, data): + old_debug_logging = self._settings.getBoolean(["debug_logging"]) + + super(CuraPlugin, self).on_settings_save(data) + + new_debug_logging = self._settings.getBoolean(["debug_logging"]) + if old_debug_logging != new_debug_logging: + if new_debug_logging: + self._cura_logger.setLevel(logging.DEBUG) + else: + self._cura_logger.setLevel(logging.CRITICAL) + + def get_settings_defaults(self): + return dict( + cura_engine=None, + default_profile=None, + debug_logging=False + ) + + ##~~ SlicerPlugin API + + def is_slicer_configured(self): + cura_engine = self._settings.get(["cura_engine"]) + if cura_engine is not None and os.path.exists(cura_engine): + return True + else: + self._logger.info("Path to CuraEngine has not been configured yet or does not exist (currently set to %r), Cura will not be selectable for slicing" % cura_engine) + + def get_slicer_properties(self): + return dict( + type="cura", + name="CuraEngine", + same_device=True, + progress_report=True + ) + + def get_slicer_default_profile(self): + path = self._settings.get(["default_profile"]) + if not path: + path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "profiles", "default.profile.yaml") + return self.get_slicer_profile(path) + + def get_slicer_profile(self, path): + profile_dict = self._load_profile(path) + + display_name = None + description = None + if "_display_name" in profile_dict: + display_name = profile_dict["_display_name"] + del profile_dict["_display_name"] + if "_description" in profile_dict: + description = profile_dict["_description"] + del profile_dict["_description"] + + properties = self.get_slicer_properties() + return octoprint.slicing.SlicingProfile(properties["type"], "unknown", profile_dict, display_name=display_name, description=description) + + def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): + new_profile = Profile.merge_profile(profile.data, overrides=overrides) + + if profile.display_name is not None: + new_profile["_display_name"] = profile.display_name + if profile.description is not None: + new_profile["_description"] = profile.description + + self._save_profile(path, new_profile, allow_overwrite=allow_overwrite) + + def do_slice(self, model_path, printer_profile, machinecode_path=None, profile_path=None, position=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): + try: + with self._job_mutex: + if not profile_path: + profile_path = self._settings.get(["default_profile"]) + if not machinecode_path: + path, _ = os.path.splitext(model_path) + machinecode_path = path + ".gco" + + if position and isinstance(position, dict) and "x" in position and "y" in position: + posX = position["x"] + posY = position["y"] + else: + posX = None + posY = None + + if on_progress: + if not on_progress_args: + on_progress_args = () + if not on_progress_kwargs: + on_progress_kwargs = dict() + + self._cura_logger.info("### Slicing %s to %s using profile stored at %s" % (model_path, machinecode_path, profile_path)) + + engine_settings = self._convert_to_engine(profile_path, printer_profile, posX, posY) + + executable = self._settings.get(["cura_engine"]) + if not executable: + return False, "Path to CuraEngine is not configured " + + working_dir, _ = os.path.split(executable) + args = ['"%s"' % executable, '-v', '-p'] + for k, v in engine_settings.items(): + args += ["-s", '"%s=%s"' % (k, str(v))] + args += ['-o', '"%s"' % machinecode_path, '"%s"' % model_path] + + import sarge + command = " ".join(args) + self._logger.info("Running %r in %s" % (command, working_dir)) + + p = sarge.run(command, cwd=working_dir, async=True, stdout=sarge.Capture(), stderr=sarge.Capture()) + p.wait_events() + self._slicing_commands[machinecode_path] = p.commands[0] + + try: + layer_count = None + step_factor = dict( + inset=0, + skin=1, + export=2 + ) + analysis = None + while p.returncode is None: + line = p.stderr.readline(timeout=0.5) + if not line: + p.commands[0].poll() + continue + + self._cura_logger.debug(line.strip()) + + if on_progress is not None: + # The Cura slicing process has three individual steps, each consisting of substeps: + # + # - inset + # - skin + # - export + # + # So each layer will be processed three times, once for each step, resulting in a total amount of + # substeps of 3 * . + # + # The CuraEngine reports the calculated layer count and the continuous progress on stderr. + # The layer count gets reported right at the beginning in a line of the format: + # + # Layer count: + # + # The individual progress per each of the three steps gets reported on stderr in a line of + # the format: + # + # Progress::: + # + # Thus, for determining the overall progress the following formula applies: + # + # progress = * + / * 3 + # + # with being 0 for "inset", 1 for "skin" and 2 for "export". + + if line.startswith("Layer count:") and layer_count is None: + try: + layer_count = float(line[len("Layer count:"):].strip()) + except: + pass + + elif line.startswith("Progress:"): + split_line = line[len("Progress:"):].strip().split(":") + if len(split_line) == 3: + step, current_layer, _ = split_line + try: + current_layer = float(current_layer) + except: + pass + else: + if not step in step_factor: + continue + on_progress_kwargs["_progress"] = (step_factor[step] * layer_count + current_layer) / (layer_count * 3) + on_progress(*on_progress_args, **on_progress_kwargs) + + elif line.startswith("Print time:"): + try: + print_time = int(line[len("Print time:"):].strip()) + if analysis is None: + analysis = dict() + analysis["estimatedPrintTime"] = print_time + except: + pass + + elif line.startswith("Filament:") or line.startswith("Filament2:"): + if line.startswith("Filament:"): + filament_str = line[len("Filament:"):].strip() + tool_key = "tool0" + else: + filament_str = line[len("Filament2:"):].strip() + tool_key = "tool1" + + try: + filament = int(filament_str) + if analysis is None: + analysis = dict() + if not "filament" in analysis: + analysis["filament"] = dict() + if not tool_key in analysis["filament"]: + analysis["filament"][tool_key] = dict() + analysis["filament"][tool_key]["length"] = filament + if "filamentDiameter" in engine_settings: + radius_in_cm = float(int(engine_settings["filamentDiameter"]) / 10000.0) / 2.0 + filament_in_cm = filament / 10.0 + analysis["filament"][tool_key]["volume"] = filament_in_cm * math.pi * radius_in_cm * radius_in_cm + except: + pass + finally: + p.close() + + with self._job_mutex: + if machinecode_path in self._cancelled_jobs: + self._cura_logger.info("### Cancelled") + raise octoprint.slicing.SlicingCancelled() + + self._cura_logger.info("### Finished, returncode %d" % p.returncode) + if p.returncode == 0: + return True, dict(analysis=analysis) + else: + self._logger.warn("Could not slice via Cura, got return code %r" % p.returncode) + return False, "Got returncode %r" % p.returncode + + except octoprint.slicing.SlicingCancelled as e: + raise e + except: + self._logger.exception("Could not slice via Cura, got an unknown error") + return False, "Unknown error, please consult the log file" + + finally: + with self._job_mutex: + if machinecode_path in self._cancelled_jobs: + self._cancelled_jobs.remove(machinecode_path) + if machinecode_path in self._slicing_commands: + del self._slicing_commands[machinecode_path] + + self._cura_logger.info("-" * 40) + + def cancel_slicing(self, machinecode_path): + with self._job_mutex: + if machinecode_path in self._slicing_commands: + self._cancelled_jobs.append(machinecode_path) + command = self._slicing_commands[machinecode_path] + if command is not None: + command.terminate() + self._logger.info("Cancelled slicing of %s" % machinecode_path) + + def _load_profile(self, path): + import yaml + profile_dict = dict() + with open(path, "r") as f: + try: + profile_dict = yaml.safe_load(f) + except: + raise IOError("Couldn't read profile from {path}".format(path=path)) + return profile_dict + + def _save_profile(self, path, profile, allow_overwrite=True): + if not allow_overwrite and os.path.exists(path): + raise IOError("Cannot overwrite {path}".format(path=path)) + + import yaml + with open(path, "wb") as f: + yaml.safe_dump(profile, f, default_flow_style=False, indent=" ", allow_unicode=True) + + def _convert_to_engine(self, profile_path, printer_profile, posX, posY): + profile = Profile(self._load_profile(profile_path), printer_profile, posX, posY) + return profile.convert_to_engine() + +def _sanitize_name(name): + if name is None: + return None + + if "/" in name or "\\" in name: + raise ValueError("name must not contain / or \\") + + import string + valid_chars = "-_.() {ascii}{digits}".format(ascii=string.ascii_letters, digits=string.digits) + sanitized_name = ''.join(c for c in name if c in valid_chars) + sanitized_name = sanitized_name.replace(" ", "_") + return sanitized_name.lower() + +__plugin_name__ = "CuraEngine" +__plugin_version__ = "0.1" +__plugin_author__ = "Gina Häußge" +__plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Cura" +__plugin_description__ = "Adds support for slicing via CuraEngine from within OctoPrint" +__plugin_license__ = "AGPLv3" +__plugin_implementations__ = [CuraPlugin()] \ No newline at end of file