pycam 18.4 KB
Newer Older
1
#!/usr/bin/env python
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
# -*- coding: utf-8 -*-
"""
$Id$

Copyright 2010 Lars Kruse <devel@sumpfralle.de>
Copyright 2008-2009 Lode Leroy

This file is part of PyCAM.

PyCAM is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

PyCAM is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with PyCAM.  If not, see <http://www.gnu.org/licenses/>.
"""

from optparse import OptionParser
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from pycam.Physics.ode_physics import override_ode_availability
import pycam.Gui.common as GuiCommon
import pycam.Gui.Settings
import pycam.Importers.STLImporter
import pycam.Importers.TestModel
34
import pycam.Exporters.SimpleGCodeExporter
35 36 37 38 39 40 41 42
import pycam.Toolpath.Generator
import pycam.Toolpath
from pycam import VERSION
import pycam.Utils.log

log = pycam.Utils.log.get_logger()

EXAMPLE_MODEL_LOCATIONS = (
43 44 45
        os.path.join(os.path.dirname(__file__), "samples"),
        os.path.join(sys.prefix, "share", "pycam", "samples"),
        os.path.join("usr", "share", "pycam", "samples"))
46 47 48
DEFAULT_MODEL_FILE = "pycam.stl"


49
def show_gui(inputfile=None, task_settings_file=None):
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
    deps_gtk = GuiCommon.requirements_details_gtk()
    report_gtk = GuiCommon.get_dependency_report(deps_gtk, prefix="\t")
    if GuiCommon.check_dependencies(deps_gtk):
        from pycam.Gui.Project import ProjectGui
        gui_class = ProjectGui
    else:
        full_report = []
        full_report.append("PyCAM dependency problem")
        full_report.append("Error: Failed to load the GTK interface.")
        full_report.append("Details:")
        full_report.append(report_gtk)
        full_report.append("")
        full_report.append("Detailed list of requirements: %s" % GuiCommon.REQUIREMENTS_LINK)
        log.critical(os.linesep.join(full_report))
        sys.exit(1)

    gui = gui_class()

68 69 70 71 72 73 74 75 76
    # load the given model or the default
    if inputfile is None:
        default_model = get_default_model()
        if isinstance(default_model, basestring):
            gui.load_model_file(filename=default_model)
        else:
            gui.load_model(default_model)
    else:
        gui.load_model_file(filename=inputfile)
77 78 79 80 81 82 83 84 85

    # load task settings file
    if not task_settings_file is None:
        gui.open_task_settings_file(task_settings_file)

    # open the GUI
    gui.mainloop()


86 87
def get_default_model():
    """ return a filename or a Model instance """
88
    # try to load the default model file ("pycam" logo)
89 90 91 92
    for inputdir in EXAMPLE_MODEL_LOCATIONS:
        inputfile = os.path.join(inputdir, DEFAULT_MODEL_FILE)
        if os.path.isfile(inputfile):
            return inputfile
93
    else:
94 95 96 97
        # fall back to the simple test model
        log.warn("Failed to find the default model (%s) in the " \
                "following locations: %s" % (DEFAULT_MODEL_FILE,
                        ", ".join(EXAMPLE_MODEL_LOCATIONS)))
sumpfralle's avatar
sumpfralle committed
98
        return pycam.Importers.TestModel.get_test_model()
99 100 101 102


# check if we were started as a separate program
if __name__ == "__main__":
103 104 105 106 107 108 109 110 111 112 113
    parser = OptionParser( prog="PyCAM", version="PyCAM v%s" % VERSION,
            usage="usage: %prog [options] [inputfile]\n\n" \
                    + "Use any of the '--export-???' parameters to disable " \
                    + "the graphical user interface (GUI).\n" \
                    + "When starting the GUI (default) all arguments except " \
                    + "'inputfile' are ignored.\n" \
                    + "'inputfile' may be an STL or DXF file.",
            epilog="Use any of the '--export-???' parameters to enable " \
                    + "non-interactive mode. Otherwise all options (except " \
                    + "'inputfile') are silently ignored and the GUI is " \
                    + "started.")
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
    group_general = parser.add_option_group("General options")
    group_export = parser.add_option_group("Export formats",
            "Export the resulting toolpath or meta-data in various formats. " \
            + "This option triggers the non-interactive mode. Thus the GUI " \
            + "is disabled.")
    group_tool = parser.add_option_group("Tool definition",
            "Specify the tool paramters. The default tool is spherical and " \
            + "has a diameter of 1mm. The default speeds are 1000 (feedrate) " \
            + "and 250 (drill spindle rotations per minute)")
    group_process = parser.add_option_group("Process definition",
            "Specify the process parameters: toolpath strategy, layer height," \
            + " and others. A typical roughing operation is configured by " \
            + "default.")
    group_bounds = parser.add_option_group("Boundary definition",
            "Specify the outer limits of the processing area (x/y/z). " \
            + "You may choose between 'relative_margin' (margin is given as " \
            + "percentage of the respective model dimension), 'fixed_margin' " \
            + "(margin for each face given in absolute units of length) " \
            + "and 'custom' (absolute coordinates of the bounding box - " \
            + "regardless of the model size and position). Negative values " \
            + "are allowed and can make sense (e.g. negative margin).")
    group_support_grid = parser.add_option_group("Support grid",
            "An optional support grid can be used to keep the object in " \
            + "place during the mill operation. The support grid can be " \
            + "removed manually afterwards. The support grid can have a " \
            + "rectangular profile. By default the support grid is disabled.")
    group_general.add_option("-c", "--config", dest="config_file",
            default=None, action="store", type="string",
            help="load a task settings file")
    group_general.add_option("", "--unit", dest="unit_size",
            default="mm", action="store", type="choice",
            choices=["mm", "inch"], help="choose 'mm' or 'inch' for all " \
            + "numbers. By default 'mm' is assumed.")
147 148 149 150 151 152 153 154 155 156 157 158
    group_general.add_option("", "--collision-engine", dest="collision_engine",
            default="triangles", action="store", type="choice",
            choices=["triangles", "ode", "help"],
            help="choose a specific collision detection engine. The default " \
                    + "is 'triangles'. Use 'help' to get a list of possible " \
                    + "engines.")
    group_general.add_option("", "--boundary-mode", dest="boundary_mode",
            default="along", action="store", type="choice",
            choices=["inside", "along", "outside"],
            help="specify if the mill tool (including its radius) should " \
                    + "move completely 'inside', 'along' or 'outside' the " \
                    + "defined processing boundary.")
159 160 161
    group_general.add_option("", "--disable-psyco", dest="disable_psyco",
            default=False, action="store_true", help="disable the Psyco " \
                    + "just-in-time-compiler even when it is available")
162 163 164 165 166 167 168 169 170 171 172 173
    group_export.add_option("", "--export-gcode", dest="export_gcode",
            default=None, action="store", type="string",
            help="export the generated toolpaths to a file")
    group_export.add_option("", "--export-task-config",
            dest="export_task_config", default=None, action="store",
            type="string",
            help="export the current task configuration (mainly for debugging)")
    group_tool.add_option("", "--tool-shape", dest="tool_shape",
            default="cylindrical", action="store", type="choice",
            choices=["cylindrical", "spherical", "toroidal"],
            help="tool shape for the operation (cylindrical, spherical, " \
            + "toroidal)")
174
    group_tool.add_option("", "--tool-size", dest="tool_diameter",
175
            default=1.0, action="store", type="float",
176 177
            help="diameter of the tool")
    group_tool.add_option("", "--tool-torus-size", dest="tool_torus_diameter",
178
            default=0.25, action="store", type="float",
179
            help="torus diameter of the tool (only for toroidal tool shape)")
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230
    group_tool.add_option("", "--tool-feedrate", dest="tool_feedrate",
            default=1000, action="store", type="float",
            help="allowed movement velocity of the tool (units/minute)")
    group_tool.add_option("", "--tool-speed", dest="tool_speed",
            default=250, action="store", type="float",
            help="rotation speed of the tool (per minute)")
    group_tool.add_option("", "--tool-id", dest="tool_id",
            default=1, action="store", type="int",
            help="tool ID - to be used for tool selection via GCode " \
            + "(default: 1)")
    group_process.add_option("", "--process-path-direction",
            dest="process_path_direction", default="x", action="store",
            type="choice", choices=["x", "y", "xy"],
            help="primary direction of the generated toolpath (x/y/xy)")
    group_process.add_option("", "--process-path-generator",
            dest="process_path_generator", default="layer", action="store",
            type="choice", choices=["layer", "surface", "engrave"],
            help="one of the available toolpath strategies (layer, surface, " \
            + "engrave)")
    group_process.add_option("", "--process-path-postprocessor",
            dest="process_path_postprocessor", default="polygon",
            action="store", type="choice",
            choices=["polygon", "contour", "zigzag"], help="one of the " \
            + "available toolpath postprocessors (polygon, zigzag, contour)")
    group_process.add_option("", "--process-material-allowance",
            dest="process_material_allowance", default=0.0, action="store",
            type="float", help="minimum distance between the tool and the " \
            + "object (for rough processing)")
    group_process.add_option("", "--process-step-down",
            dest="process_step_down", default=3.0, action="store", type="float",
            help="the maximum thickness of each processed material layer " \
            + "(only for 'layer' strategy)")
    group_process.add_option("", "--process-overlap-percent",
            dest="process_overlap_percent", default=0, action="store",
            type="int", help="how much should two adjacent parallel " \
            + "toolpaths overlap each other (0..99)")
    group_process.add_option("", "--safety-height", dest="safety_height",
            default=0.0, action="store", type="float",
            help="height for safe re-positioning moves")
    group_process.add_option("", "--process-engrave-offset",
            dest="process_engrave_offset", default=0.0, action="store",
            type="float", help="engrave along the contour of a model with a " \
            + "given distance (only for 'engrave' strategy)")
    group_bounds.add_option("", "--bounds-type", dest="bounds_type",
            default="relative-margin", action="store", type="choice",
            choices=["relative-margin", "fixed-margin", "custom"],
            help="type of the boundary definition (relative-margin, " \
            + "fixed-margin, custom)")
    group_bounds.add_option("", "--bounds-lower", dest="bounds_lower",
            default="", action="store", type="string",
            help="comma-separated x/y/z combination of the lower boundary " \
231
            + "(e.g. '4,4,-0.5')")
232 233 234
    group_bounds.add_option("", "--bounds-upper", dest="bounds_upper",
            default="", action="store", type="string",
            help="comma-separated x/y/z combination of the upper boundary " \
235
            + "(e.g. '12,5.5,0')")
236 237 238 239 240
    group_support_grid.add_option("", "--enable-support-grid",
            dest="support_grid_enabled", default=False, action="store_true",
            help="enable the support grid")
    group_support_grid.add_option("", "--support-grid-distance",
            dest="support_grid_distance", default=10.0, action="store",
241 242
            type="float", help="horizontal and vertical distance between two " \
                    + "adjacent parallel lines of the support grid pattern")
243 244 245 246 247 248 249 250 251 252 253 254 255
    group_support_grid.add_option("", "--support-grid-height",
            dest="support_grid_height", default=2.0, action="store",
            type="float", help="height of the support grid profile")
    group_support_grid.add_option("", "--support-grid-thickness",
            dest="support_grid_thickness", default=0.5, action="store",
            type="float", help="width of the support grid profile")
    (opts, args) = parser.parse_args()

    if len(args) > 0:
        inputfile = args[0]
    else:
        inputfile = None

256 257 258 259 260 261 262 263 264 265 266
    if not opts.disable_psyco:
        try:
            import psyco
            psyco.full()
            log.info("Psyco enabled")
        except ImportError:
            log.info("Psyco is not available (performance will probably " \
                    + "suffer slightly)")
    else:
        log.info("Psyco was disabled via the commandline")

267 268 269 270 271 272 273 274 275
    if not opts.export_gcode and not opts.export_task_config:
        show_gui(inputfile, opts.config_file)
    else:
        # generate toolpath
        tps = pycam.Gui.Settings.ToolpathSettings()
        tool_shape = {"cylindrical": "CylindricalCutter",
                "spherical": "SphericalCutter",
                "toroidal": "ToroidalCutter",
            }[opts.tool_shape]
276 277 278
        tps.set_tool(opts.tool_id, tool_shape, 0.5 * opts.tool_diameter,
                0.5 * opts.tool_torus_diameter, opts.tool_speed,
                opts.tool_feedrate)
279 280 281
        if opts.support_grid_enabled:
            tps.set_support_grid(opts.support_grid_distance,
                    opts.support_grid_thickness, opts.support_grid_height)
282
        if opts.collision_engine == "ode":
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
            tps.set_calculation_backend("ODE")
        tps.set_unit_size(opts.unit_size)
        path_generator = {"layer": "PushCutter",
                "surface": "DropCutter",
                "engrave": "EngraveCutter",
            }[opts.process_path_generator]
        postprocessor = {"zigzag": "ZigZagCutter",
                "contour": "ContourCutter",
                "polygon": "PolygonCutter",
            }[opts.process_path_postprocessor]
        tps.set_process_settings(path_generator, postprocessor,
                opts.process_path_direction, opts.process_material_allowance,
                opts.safety_height, opts.process_overlap_percent / 100.0,
                opts.process_step_down, opts.process_engrave_offset)
        model = get_model(inputfile)
        # calculate the processing boundary
        bounds = pycam.Toolpath.Bounds()
        # set the bounds type and let the default bounding box match the model
        if opts.bounds_type == "relative-margin":
            bounds.set_type(pycam.Toolpath.Bounds.TYPE_RELATIVE_MARGIN)
            bounds_default_low = (10, 10, 0)
            bounds_default_high = (10, 10, 0)
        elif opts.bounds_type == "fixed-margin":
            bounds.set_type(pycam.Toolpath.Bounds.TYPE_FIXED_MARGIN)
            bounds_default_low = (10, 10, 0)
            bounds_default_high = (10, 10, 0)
        else:
            # custom boundary setting
            bounds.set_type(pycam.Toolpath.Bounds.TYPE_CUSTOM)
            bounds_default_low = (model.minx, model.miny, model.minz)
            bounds_default_high = (model.maxx, model.maxy, model.maxz)
        # TODO: use the optparse conversion callback instead
        def parse_triple_float(text):
            nums = text.split(",")
            if len(nums) != 3:
                return None
            result = []
            for num in nums:
                try:
                    result.append(float(num))
                except ValueError:
                    return None
            return result
        bounds_lower_nums = parse_triple_float(opts.bounds_lower)
        bounds_upper_nums = parse_triple_float(opts.bounds_upper)
        if bounds_lower_nums is None \
                and bounds_upper_nums is None:
            # no bound was given 
            bounds.set_bounds(bounds_default_low, bounds_default_high)
        elif bounds_lower_nums is None:
            # only the upper bound was specified
            bounds.set_bounds(bounds_default_low, bounds_upper_nums)
        elif bounds_upper_nums is None:
            # only the lower bound was specified
            bounds.set_bounds(bounds_lower_nums, bounds_default_high)
        else:
            # both lower and upper bounds were specified
            bounds.set_bounds(bounds_lower_nums, bounds_upper_nums)
341 342 343 344
        # adjust the bounding box according to the "boundary_mode"
        if opts.boundary_mode == "along":
            offset = (0, 0, 0)
        elif opts.boundary_mode == "inside":
345
            offset = (-0.5 * opts.tool_diameter, -0.5 * opts.tool_diameter, 0)
346 347
        else:
            # "outside"
348
            offset = (0.5 * opts.tool_diameter, 0.5 * opts.tool_diameter, 0)
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
        process_bounds = Bounds(Bounds.TYPE_FIXED_MARGIN, offset, offset)
        process_bounds.set_reference(bounds)
        tps.set_bounds(process_bounds)
        if opts.export_gcode:
            # generate the toolpath
            tp = pycam.Toolpath.Generator.generate_toolpath_from_settings(model, tps)
            # write result
            if isinstance(tp, basestring):
                # an error occoured
                log.error(tp)
            else:
                start_pos = tp.get_start_position()
                meta_data = os.linesep.join(tp.get_meta_data())
                if opts.export_gcode == "-":
                    destination = sys.stdout
                    close_destination = False
                else:
                    destination = open(opts.export_gcode, "w")
                    close_destination = True
                pycam.Exporters.SimpleGCodeExporter.ExportPathList(destination,
                        tp.get_path(), opts.unit_size, start_pos.x,
                        start_pos.y, start_pos.z, opts.tool_feedrate,
                        opts.tool_speed, safety_height=opts.safety_height,
372
                        max_skip_safety_distance=opts.tool_diameter,
373 374 375 376 377 378
                        comment=meta_data)
                if close_destination:
                    destination.close()
        if opts.export_task_config:
            raise NotImplementedError("Export of task config is not " \
                    + "implemented, yet!")
379