diff options
-rwxr-xr-x | generate_sources.sh | 20 | ||||
-rw-r--r-- | readme.md | 205 | ||||
-rw-r--r-- | support/asymptote/__main__.py | 102 | ||||
-rw-r--r-- | support/inkscape/__main__.py | 108 | ||||
-rw-r--r-- | support/inkscape/effect.py | 520 | ||||
-rw-r--r-- | support/inkscape/inkscape.py | 226 | ||||
-rw-r--r-- | support/lib/make.py | 2 | ||||
-rw-r--r-- | support/lib/util.py | 197 | ||||
-rw-r--r-- | support/openscad/__main__.py | 70 |
9 files changed, 787 insertions, 663 deletions
diff --git a/generate_sources.sh b/generate_sources.sh index 8ac6afa..0a2af79 100755 --- a/generate_sources.sh +++ b/generate_sources.sh @@ -6,16 +6,16 @@ current_file_name=$1 # This function should be called for each generated file with the file's name as the first argument and the command to call to produce the file's content as the remaining arguments. function generate_file() { - file_name=$1 - shift - generate_command=("$@") - - if ! [ "$current_file_name" ]; then - echo "$file_name" - elif [ "$current_file_name" == "$file_name" ]; then - mkdir -p "$(dirname "$file_name")" - "${generate_command[@]}" > "$file_name" - fi + file_name=$1 + shift + generate_command=("$@") + + if ! [ "$current_file_name" ]; then + echo "$file_name" + elif [ "$current_file_name" == "$file_name" ]; then + mkdir -p "$(dirname "$file_name")" + "${generate_command[@]}" > "$file_name" + fi } # Call generate_file for each file to be generated. @@ -2,49 +2,71 @@ ## Repository structure -This repository, as it is maintained on [GitHub](http://github.com/Feuermurmel/openscad-template), contains two important branches, `master` and `examples`. `master` contains an empty project which is ready to be cloned and used for new project. +This repository, as it is maintained on +[GitHub](http://github.com/Feuermurmel/openscad-template), contains two +important branches, `master` and `examples`. `master` contains an empty project +which is ready to be cloned and used for new project. -Branch `examples` additionally contains a few example source files which are ready to be compiled. The root directory on that branch also contains a second text document `examples.creole`, describing the example project in more detail. +Branch `examples` additionally contains a few example source files which are +ready to be compiled. The root directory on that branch also contains a second +text document `examples.creole`, describing the example project in more detail. ## Prerequisites - OpenSCAD snapshot > 2014.11.05 - - Used to compile OpenSCAD source files to STL. - - A recent development snapshot is recommended, e.g. version 2014.11.05 or later. - - The current release version (2014.03) generates invalid dependency information if the path to the project contains spaces or other characters that need to be treated specially in a makefile and also has trouble with 2D shapes containing holes. The current development version solves these problems. + - Used to compile OpenSCAD source files to STL. + - A recent development snapshot is recommended, e.g. version 2014.11.05 or + later. + - The current release version (2014.03) generates invalid dependency + information if the path to the project contains spaces or other + characters that need to be treated specially in a makefile and also + has trouble with 2D shapes containing holes. The current development + version solves these problems. - Inkscape > 0.91 - - Used to export DXF files to SVG. - - Recommended to edit SVG files, especially if importing of separate layers in OpenSCAD is needed. - - At least version 0.91 (or maybe some earlier development snapshot) is necessary because the command line verbs used to transform and massage an SVG prior to export have only recently been added. + - Used to export DXF files to SVG. + - Recommended to edit SVG files, especially if importing of separate layers + in OpenSCAD is needed. + - At least version 0.91 (or maybe some earlier development snapshot) is + necessary because the command line verbs used to transform and massage an + SVG prior to export have only recently been added. - Python 2.7 - - Used for to run the plugin that exports DXF to SVG and to run scripts that wrap the OpenSCAD command line tool and work around problems with generation of dependency information in OpenSCAD. - - Should already be installed as a dependency to Inkscape. The most recent version of Python 2.7 is recommended. + - Used for to run the plugin that exports DXF to SVG and to run scripts + that wrap the OpenSCAD command line tool and work around problems with + generation of dependency information in OpenSCAD. + - Should already be installed as a dependency to Inkscape. The most recent + version of Python 2.7 is recommended. - Asymptote [0] - - Used to compile Asymptote files to PDF. - - Recommended when creating Vector cutting projects for Epilog laser cutters. + - Used to compile Asymptote files to PDF. + - Recommended when creating Vector cutting projects for Epilog laser + cutters. -[0]: This project was tested with Asymptote Version 2.35. Earlier Versions will probably also work. +[0]: This project was tested with Asymptote Version 2.35. Earlier Versions will + probably also work. ### Explicitly specifying paths to binaries -If any of the required binaries is not available on `$PATH` or a different version should be used, the paths to these binaries can be configured by creating a file called `config.mk` in the same directory as the makefile. There, variables can be set to the absolute or relative paths to these binaries. For example: - - # Path to the OpenSCAD binary - OPENSCAD := /Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD - - # Path to the Inkscape binary - INKSCAPE := /opt/local/bin/inkscape - - # Path to the Python 2.7 binary - PYTHON := /opt/local/bin/python2.7 - - # Path to the Asymptote binary - ASYMPTOTE := /opt/local/bin/asy +If any of the required binaries is not available on `$PATH` or a different +version should be used, the paths to these binaries can be configured by +creating a file called `config.mk` in the same directory as the makefile. +There, variables can be set to the absolute or relative paths to these +binaries. For example: + + # Path to the OpenSCAD binary + OPENSCAD := /Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD + + # Path to the Inkscape binary + INKSCAPE := /opt/local/bin/inkscape + + # Path to the Python 2.7 binary + PYTHON := /opt/local/bin/python2.7 + + # Path to the Asymptote binary + ASYMPTOTE := /opt/local/bin/asy ## Supported file types @@ -53,63 +75,104 @@ If any of the required binaries is not available on `$PATH` or a different versi Any file whose name ends in `.svg` may be used from an OpenSCAD file like this: - import("file.dxf"); - -The makefile will automatically convert the SVG file to a DXF file when building the project. If Inkscape is used to edit the SVG file, multiple layers can be created which can then be imported individually: - - import("file.dxf", "background"); - -The DXF export supports all shapes supported by Inkscape (e.g. rectangles, circles, paths, spiro lines, text, …). Before the objects are exported, all objects are converted to paths and combined using the union operation. For objects which have a stroke style, the stroke instead of the filled area is converted to a path. Then, the resulting path is converted to a set of line segments which closely follow the curved parts of the path. The resulting line segments are exported to DXF and combined to the original shapes when imported in OpenSCAD. For these transformations to work, the objects need to be placed in Inkscape layers. - -OpenSCAD itself does not define which unit is used to measure lengths [1]. Inkscape OTOH allows the user to define a document wide unit as well as using different units when specifying the size and position of shapes. When exporting the SVG document using Inkscape, all numbers are converted to the unit specified under _General_ in Inkscape's _Document Properties_ dialog. These numbers are the written to the DXF document and used OpenSCAD directly. - -DXF and OpenSCAD both use a right-handed coordinate system (the Y axis runs up while the X-axis runs to the right). While SVG uses a left-handed coordinate system (the Y axis runs down instead). But Inkscape, surprisingly, also uses a right-handed coordinate system. The DXF export script honors this and places the origin of the document in the lower left corner when exporting the document. + import("file.dxf"); + +The makefile will automatically convert the SVG file to a DXF file when +building the project. If Inkscape is used to edit the SVG file, multiple layers +can be created which can then be imported individually: + + import("file.dxf", "background"); + +The DXF export supports all shapes supported by Inkscape (e.g. rectangles, +circles, paths, spiro lines, text, …). Before the objects are exported, all +objects are converted to paths and combined using the union operation. For +objects which have a stroke style, the stroke instead of the filled area is +converted to a path. Then, the resulting path is converted to a set of line +segments which closely follow the curved parts of the path. The resulting line +segments are exported to DXF and combined to the original shapes when imported +in OpenSCAD. For these transformations to work, the objects need to be placed +in Inkscape layers. + +OpenSCAD itself does not define which unit is used to measure lengths [1]. +Inkscape OTOH allows the user to define a document wide unit as well as using +different units when specifying the size and position of shapes. When exporting +the SVG document using Inkscape, all numbers are converted to the unit +specified under _General_ in Inkscape's _Document Properties_ dialog. These +numbers are the written to the DXF document and used OpenSCAD directly. + +DXF and OpenSCAD both use a right-handed coordinate system (the Y axis runs up +while the X-axis runs to the right). While SVG uses a left-handed coordinate +system (the Y axis runs down instead). But Inkscape, surprisingly, also uses a +right-handed coordinate system. The DXF export script honors this and places +the origin of the document in the lower left corner when exporting the +document. [1]: Although millimeters seems to be the predominant unit. ### Using SVG files from Asymptote -SVG files may instead be used from Asymptote files. For each SVG file, an Asymptote file of the same name is generated. These files can be imported as modules from other Asymptote files. These modules will contain a member of type `path[]` for each layer in the original SVG file: +SVG files may instead be used from Asymptote files. For each SVG file, an +Asymptote file of the same name is generated. These files can be imported as +modules from other Asymptote files. These modules will contain a member of +type `path[]` for each layer in the original SVG file: - import test; - - draw(test.Layer_1, red + 0.001mm); + import test; + + draw(test.Layer_1, red + 0.001mm); -The module also contains a member `all`, which just contains all paths in one array. +The module also contains a member `all`, which just contains all paths in one +array. ### OpenSCAD files -Files whose names end in `.scad` are compiled to STL files using OpenSCAD. OpenSCAD files whose name start with `_` are treated as "library" files which will not be compiled to STL files. These files can still be used from other OpenSCAD files using one of the following commands: +Files whose names end in `.scad` are compiled to STL files using OpenSCAD. +OpenSCAD files whose name start with `_` are treated as "library" files which +will not be compiled to STL files. These files can still be used from other +OpenSCAD files using one of the following commands: - include <filename> - use <filename> + include <filename> + use <filename> -Please see the [OpenSCAD User Manual](http://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Print_version) for details this and other OpenSCAD functionality. +Please see the +[OpenSCAD User Manual](http://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Print_version) +for details this and other OpenSCAD functionality. ## Generating Source files -This template includes support for automatically generated source files. This works by editing the `generate_sources.sh` script. +This template includes support for automatically generated source files. This +works by editing the `generate_sources.sh` script. -The script defines a function `generate_file()`, which should be called in the remainder of the script once for each file to generate. The first argument to the function is be the name of the file. The remaining arguments are treated as a command, which, when run, should output the file's content to standard output. For example: +The script defines a function `generate_file()`, which should be called in the +remainder of the script once for each file to generate. The first argument to +the function is be the name of the file. The remaining arguments are treated as +a command, which, when run, should output the file's content to standard +output. For example: - generate_file "src/cube.scad" echo "cube(25);" + generate_file "src/cube.scad" echo "cube(25);" -How the function `generate_file()` is called is up to the script and may e.g. be done from a `for` loop or while iterating over a set of other source files. +How the function `generate_file()` is called is up to the script and may e.g. +be done from a `for` loop or while iterating over a set of other source files. ## Compiling -To compile the whole project, run `make` from the directory in which this readme is. This will generate all sources files, if any, process all SVG files and produce an STL file for each OpenSCAD source file whose name does not start with `_`. Individual files may be created or updated by passing their names to the make command, as usual. +To compile the whole project, run `make` from the directory in which this +readme is. This will generate all sources files, if any, process all SVG files +and produce an STL file for each OpenSCAD source file whose name does not start +with `_`. Individual files may be created or updated by passing their names to +the make command, as usual. ### Makefile targets -These are the special makefile targets which can be used in addition to the names of individual files to update: +These are the special makefile targets which can be used in addition to the +names of individual files to update: -- `all`: Builds all files that can be built from any source files. This is the default target when running `make` without arguments. +- `all`: Builds all files that can be built from any source files. This is the +default target when running `make` without arguments. - `clean`: Removes all built files [2]. - `generated`: Generates all files generated by `generate_sources.sh`. - `dxf`: Exports all SVG files to DXF files. @@ -117,24 +180,40 @@ These are the special makefile targets which can be used in addition to the name - `asy`: Exports all configured SVG files to Asymptote files. - `pdf`: Compiles all Asymptote files to PDF files. -[2]: This will not remove files for which the source file was removed. There is no simple way to detect whether a file was previously built from a source file or if it placed in the `src` directory manually. +[2]: This will not remove files for which the source file was removed. There is +no simple way to detect whether a file was previously built from a source file +or if it placed in the `src` directory manually. ### Settings used for compilation -The quality of the DXF export can be specified by creating a file called `settings.mk` in the same directory as the makefile. Setting `DXF_FLATNESS` to a smaller value (which defaults to `0.1`) creates a shape that more closely follows curved parts of the exported shapes. For example: +The quality of the DXF export can be specified by creating a file called +`settings.mk` in the same directory as the makefile. Setting `DXF_FLATNESS` to +a smaller value (which defaults to `0.1`) creates a shape that more closely +follows curved parts of the exported shapes. For example: - # Specify how far the exported approximation may deviate from the actual shape. The default is 0.1. - DXF_FLATNESS := 0.02 - - # Specify which SVG files should be exported to Asymptote files instead of DXF files. By default, this list is empty. - ASYMPTOTE_EXPORTED_SVG_FILES := src/example.svg + # Specify how far the exported approximation may deviate from the actual + # shape. The default is 0.1. + DXF_FLATNESS := 0.02 + + # Specify which SVG files should be exported to Asymptote files instead of + # DXF files. By default, this list is empty. + ASYMPTOTE_EXPORTED_SVG_FILES := src/example.svg ### Dependency tracking -OpenSCAD has the ability to write dependency files which record all files used while producing an STL file. These dependency files can be read by `make`. This ability is used to only recompile necessary files when running make. +OpenSCAD has the ability to write dependency files which record all files used +while producing an STL file. These dependency files can be read by `make`. This +ability is used to only recompile necessary files when running make. -This same mechanism is currently not used for converting SVG files referring to other files or for the script used to generate source files. Therefore, if other file used in the process are changed, the corresponding source files tracked by the makefile (the main SVG files or the files `generate_sources.sh` in case of generated sources) needs to be manually marked as changes by calling `touch` on the file before calling `make`. +This same mechanism is currently not used for converting SVG files referring to +other files or for the script used to generate source files. Therefore, if +other file used in the process are changed, the corresponding source files +tracked by the makefile (the main SVG files or the files `generate_sources.sh` +in case of generated sources) needs to be manually marked as changes by calling + `touch` on the file before calling `make`. -For Asymptote files, a safer approach is currently taken. If any of the Asymptote source files in the `src` directory are changed, all Asymptote source files are recompiled. +For Asymptote files, a safer approach is currently taken. If any of the +Asymptote source files in the `src` directory are changed, all Asymptote source +files are recompiled. diff --git a/support/asymptote/__main__.py b/support/asymptote/__main__.py index 705649a..77bbdc7 100644 --- a/support/asymptote/__main__.py +++ b/support/asymptote/__main__.py @@ -3,55 +3,61 @@ from lib import util, make def _asymptote(in_path, out_path, asymptote_dir, working_dir): - args = [os.environ['ASYMPTOTE'], '-vv', '-f', 'pdf', '-o', out_path, in_path] - - with util.command_context(args, set_env = { 'ASYMPTOTE_DIR': asymptote_dir }, working_dir = working_dir, use_stderr = True) as process: - def get_loaded_file(line): - if any(line.startswith(j) for j in ['Loading ', 'Including ']): - parts = line.rstrip('\n').split(' ') - - if len(parts) == 4: - _, _, from_, path = parts - - if from_ == 'from': - return path - - return None - - def iter_loaded_files(): - for i in process.stderr: - loaded_file = get_loaded_file(i) - - if loaded_file is not None: - yield loaded_file - elif not any(i.startswith(j) for j in ['cd ', 'Using configuration ']): - print >> sys.stderr, i, - - loaded_files = list(iter_loaded_files()) - - return loaded_files + args = [os.environ['ASYMPTOTE'], '-vv', '-f', 'pdf', '-o', out_path, in_path] + + with util.command_context(args, set_env={'ASYMPTOTE_DIR': asymptote_dir}, working_dir=working_dir, use_stderr=True) as process: + def get_loaded_file(line): + if any(line.startswith(j) for j in ['Loading ', 'Including ']): + parts = line.rstrip('\n').split(' ') + + if len(parts) == 4: + _, _, from_, path = parts + + if from_ == 'from': + return path + + return None + + def iter_loaded_files(): + for i in process.stderr: + loaded_file = get_loaded_file(i) + + if loaded_file is not None: + yield loaded_file + elif not any(i.startswith(j) for j in ['cd ', 'Using configuration ']): + print >> sys.stderr, i, + + loaded_files = list(iter_loaded_files()) + + return loaded_files @util.main def main(in_path, out_path): - try: - _, out_suffix = os.path.splitext(out_path) - - with util.TemporaryDirectory() as temp_dir: - absolute_in_path = os.path.abspath(in_path) - temp_out_path = os.path.join(temp_dir, 'out.pdf') - - # Asymptote creates A LOT of temp files (presumably when invoking LaTeX) and leaves some of them behind. Thus we run asymptote in a temporary directory. - loaded_files = _asymptote(absolute_in_path, 'out', os.path.dirname(absolute_in_path), temp_dir) - - if not os.path.exists(temp_out_path): - raise util.UserError('Asymptote did not generate a PDF file.', in_path) - - # All dependencies as paths relative to the project root. - dependencies = set(map(os.path.relpath, loaded_files)) - - # Write output files. - make.write_dependencies(out_path + '.d', out_path, dependencies - { in_path }) - shutil.copyfile(temp_out_path, out_path) - except util.UserError as e: - raise util.UserError('While processing {}: {}', in_path, e) + try: + _, out_suffix = os.path.splitext(out_path) + + with util.TemporaryDirectory() as temp_dir: + absolute_in_path = os.path.abspath(in_path) + temp_out_path = os.path.join(temp_dir, 'out.pdf') + + # Asymptote creates A LOT of temp files (presumably when invoking + # LaTeX) and leaves some of them behind. Thus we run asymptote + # in a temporary directory. + loaded_files = _asymptote( + absolute_in_path, + 'out', + os.path.dirname(absolute_in_path), + temp_dir) + + if not os.path.exists(temp_out_path): + raise util.UserError('Asymptote did not generate a PDF file.', in_path) + + # All dependencies as paths relative to the project root. + dependencies = set(map(os.path.relpath, loaded_files)) + + # Write output files. + make.write_dependencies(out_path + '.d', out_path, dependencies - {in_path}) + shutil.copyfile(temp_out_path, out_path) + except util.UserError as e: + raise util.UserError('While processing {}: {}', in_path, e) diff --git a/support/inkscape/__main__.py b/support/inkscape/__main__.py index 78ffb55..29ac745 100644 --- a/support/inkscape/__main__.py +++ b/support/inkscape/__main__.py @@ -4,61 +4,61 @@ from . import effect, inkscape def _unfuck_svg_document(temp_svg_path): - """ - Unfucks an SVG document so is can be processed by the better_dxf_export plugin (or what's left of it). - """ - - command_line = inkscape.InkscapeCommandLine(temp_svg_path) - layers = command_line.layers - - command_line.apply_to_document('LayerUnlockAll', 'LayerShowAll') - - layer_copies = [] - - for i in layers: - layer_copy = command_line.duplicate_layer(i) - layer_copies.append(layer_copy) - - command_line.apply_to_layer_content(layer_copy, 'ObjectToPath') - command_line.apply_to_layer_content(layer_copy, 'SelectionUnGroup') - - if not i.use_paths: - command_line.apply_to_layer_content(layer_copy, 'StrokeToPath') - command_line.apply_to_layer_content(layer_copy, 'SelectionUnion') - - for original, copy in zip(layers, layer_copies): - command_line.clear_layer(original) - command_line.move_content(copy, original) - command_line.delete_layer(copy) - - command_line.apply_to_document('FileSave', 'FileClose', 'FileQuit') - - command_line.run() + """ + Unfucks an SVG document so is can be processed by the better_dxf_export + plugin (or what's left of it). + """ + command_line = inkscape.InkscapeCommandLine(temp_svg_path) + layers = command_line.layers + + command_line.apply_to_document('LayerUnlockAll', 'LayerShowAll') + + layer_copies = [] + + for i in layers: + layer_copy = command_line.duplicate_layer(i) + layer_copies.append(layer_copy) + + command_line.apply_to_layer_content(layer_copy, 'ObjectToPath') + command_line.apply_to_layer_content(layer_copy, 'SelectionUnGroup') + + if not i.use_paths: + command_line.apply_to_layer_content(layer_copy, 'StrokeToPath') + command_line.apply_to_layer_content(layer_copy, 'SelectionUnion') + + for original, copy in zip(layers, layer_copies): + command_line.clear_layer(original) + command_line.move_content(copy, original) + command_line.delete_layer(copy) + + command_line.apply_to_document('FileSave', 'FileClose', 'FileQuit') + + command_line.run() @util.main def main(in_path, out_path): - try: - _, out_suffix = os.path.splitext(out_path) - - effect.ExportEffect.check_document_units(in_path) - - with util.TemporaryDirectory() as temp_dir: - temp_svg_path = os.path.join(temp_dir, os.path.basename(in_path)) - - shutil.copyfile(in_path, temp_svg_path) - - _unfuck_svg_document(temp_svg_path) - - export_effect = effect.ExportEffect() - export_effect.affect(args = [temp_svg_path], output = False) - - with open(out_path, 'w') as file: - if out_suffix == '.dxf': - export_effect.write_dxf(file) - elif out_suffix == '.asy': - export_effect.write_asy(file) - else: - raise Exception('Unknown file type: {}'.format(out_suffix)) - except util.UserError as e: - raise util.UserError('While processing {}: {}', in_path, e) + try: + _, out_suffix = os.path.splitext(out_path) + + effect.ExportEffect.check_document_units(in_path) + + with util.TemporaryDirectory() as temp_dir: + temp_svg_path = os.path.join(temp_dir, os.path.basename(in_path)) + + shutil.copyfile(in_path, temp_svg_path) + + _unfuck_svg_document(temp_svg_path) + + export_effect = effect.ExportEffect() + export_effect.affect(args=[temp_svg_path], output=False) + + with open(out_path, 'w') as file: + if out_suffix == '.dxf': + export_effect.write_dxf(file) + elif out_suffix == '.asy': + export_effect.write_asy(file) + else: + raise Exception('Unknown file type: {}'.format(out_suffix)) + except util.UserError as e: + raise util.UserError('While processing {}: {}', in_path, e) diff --git a/support/inkscape/effect.py b/support/inkscape/effect.py index 6acabfb..313032c 100644 --- a/support/inkscape/effect.py +++ b/support/inkscape/effect.py @@ -1,264 +1,280 @@ """ -Based on code from Aaron Spike. See http://www.bobcookdev.com/inkscape/inkscape-dxf.html +Based on code from Aaron Spike. See +http://www.bobcookdev.com/inkscape/inkscape-dxf.html """ -import pkgutil, os, re, collections, itertools +import collections +import itertools +import os +import pkgutil +import re from lxml import etree + from lib import util from . import inkex, simpletransform, cubicsuperpath, cspsubdiv, inkscape def _get_unit_factors_map(): - # Fluctuates somewhat between Inkscape releases _and_ between SVG version. - pixels_per_inch = 96. - pixels_per_mm = pixels_per_inch / 25.4 - - return { - 'px': 1.0, - 'mm': pixels_per_mm, - 'cm': pixels_per_mm * 10, - 'm' : pixels_per_mm * 1e3, - 'km': pixels_per_mm * 1e6, - 'pt': pixels_per_inch / 72, - 'pc': pixels_per_inch / 6, - 'in': pixels_per_inch, - 'ft': pixels_per_inch * 12, - 'yd': pixels_per_inch * 36 } + # Fluctuates somewhat between Inkscape releases _and_ between SVG version. + pixels_per_inch = 96. + pixels_per_mm = pixels_per_inch / 25.4 + + return { + 'px': 1.0, + 'mm': pixels_per_mm, + 'cm': pixels_per_mm * 10, + 'm': pixels_per_mm * 1e3, + 'km': pixels_per_mm * 1e6, + 'pt': pixels_per_inch / 72, + 'pc': pixels_per_inch / 6, + 'in': pixels_per_inch, + 'ft': pixels_per_inch * 12, + 'yd': pixels_per_inch * 36} class ExportEffect(inkex.Effect): - _unit_factors = _get_unit_factors_map() - _asymptote_all_paths_name = 'all' - - def __init__(self): - inkex.Effect.__init__(self) - - self._flatness = float(os.environ['DXF_FLATNESS']) - - self._layers = None - self._paths = None - - def _get_document_scale(self): - """ - Return scaling factor applied to the document because of a viewBox setting. This currently ignores any setting of a preserveAspectRatio attribute (like Inkscape). - """ - - document_height = self._get_height() - view_box = self._get_view_box() - - if view_box is None or document_height is None: - return 1 - else: - _, _, _, view_box_height = view_box - - return document_height / view_box_height - - def _get_document_height(self): - """ - Get the height of the document in pixels in the document coordinate system as it is interpreted by Inkscape. - """ - - view_box = self._get_view_box() - document_height = self._get_height() - - if view_box is not None: - _, _, _, view_box_height = view_box - - return view_box_height - elif document_height is not None: - return document_height - else: - return 0 - - def _get_height(self): - height_attr = self.document.getroot().get('height') - - if height_attr is None: - return None - else: - return self._measure_to_pixels(height_attr) - - def _get_view_box(self): - view_box_attr = self.document.getroot().get('viewBox') - - if view_box_attr is None: - return None - else: - return [float(i) for i in view_box_attr.split()] - - def _get_shape_paths(self, node, transform): - shape = cubicsuperpath.parsePath(node.get('d')) - - transform = simpletransform.composeTransform( - transform, - simpletransform.composeParents(node, [[1, 0, 0], [0, 1, 0]])) - - simpletransform.applyTransformToPath(transform, shape) - - def iter_paths(): - for path in shape: - cspsubdiv.subdiv(path, self._flatness) - - # path contains two control point coordinates and the actual coordinates per point. - yield [i for _, i, _ in path] - - return list(iter_paths()) - - def effect(self): - document_height = self._get_document_height() - document_scale = self._get_document_scale() - - transform = simpletransform.composeTransform( - [[document_scale, 0, 0], [0, document_scale, 0]], - [[1, 0, 0], [0, -1, document_height]]) - - layers = inkscape.get_inkscape_layers(self.svg_file) - layers_by_inkscape_name = { i.inkscape_name: i for i in layers } - - def iter_paths(): - for node in self.document.getroot().xpath('//svg:path', namespaces = inkex.NSS): - layer = layers_by_inkscape_name.get(self._get_inkscape_layer_name(node)) - - for path in self._get_shape_paths(node, transform): - yield layer, path - - self._layers = layers - self._paths = list(iter_paths()) - - def write_dxf(self, file): - # Scales pixels to millimeters. This is the predominant unit in CAD. - unit_factor = self._unit_factors['mm'] - - layer_indices = { l: i for i, l in enumerate(self._layers) } - - file.write(pkgutil.get_data(__name__, 'dxf_header.txt')) - - def write_instruction(code, value): - print >> file, code - print >> file, value - - handle_iter = itertools.count(256) - - for layer, path in self._paths: - for (x1, y1), (x2, y2) in zip(path, path[1:]): - write_instruction(0, 'LINE') - - if layer is not None: - write_instruction(8, layer.export_name) - write_instruction(62, layer_indices.get(layer, 0)) - - write_instruction(5, '{:x}'.format(next(handle_iter))) - write_instruction(100, 'AcDbEntity') - write_instruction(100, 'AcDbLine') - write_instruction(10, repr(x1 / unit_factor)) - write_instruction(20, repr(y1 / unit_factor)) - write_instruction(30, 0.0) - write_instruction(11, repr(x2 / unit_factor)) - write_instruction(21, repr(y2 / unit_factor)) - write_instruction(31, 0.0) - - file.write(pkgutil.get_data(__name__, 'dxf_footer.txt')) - - def write_asy(self, file): - def write_line(format, *args): - print >> file, format.format(*args) + ';' - - # Scales pixels to points. Asymptote uses PostScript points (1 / 72 inch) by default. - unit_factor = self._unit_factors['pt'] - - paths_by_layer = collections.defaultdict(list) - variable_names = [] - - for layer, path in self._paths: - paths_by_layer[layer].append(path) - - for layer in self._layers + [None]: - paths = paths_by_layer[layer] - variable_name = self._asymptote_identifier_from_layer(layer) - write_line('path[] {}', variable_name) - - variable_names.append(variable_name) - - for path in paths: - point_strs = ['({}, {})'.format(x / unit_factor, y / unit_factor) for x, y in path] - - # Hack. We should determine this from whether Z or z was used to close the path in the SVG document. - if path[0] == path[-1]: - point_strs[-1] = 'cycle' - - write_line('{}.push({})', variable_name, ' -- '.join(point_strs)) - - if self._asymptote_all_paths_name not in variable_names: - write_line('path[] {}', self._asymptote_all_paths_name) - - for i in variable_names: - write_line('{}.append({})', self._asymptote_all_paths_name, i) - - @classmethod - def _parse_measure(cls, string): - value_match = re.match(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)', string) - unit_match = re.search('(%s)$' % '|'.join(cls._unit_factors.keys()), string) - - value = float(string[value_match.start():value_match.end()]) - - if unit_match: - unit = string[unit_match.start():unit_match.end()] - else: - unit = None - - return value, unit - - @classmethod - def _measure_to_pixels(cls, string): - """ - Parse a string containing a measure and return it's value converted to pixels. - """ - - value, unit = cls._parse_measure(string) - - return value * cls._get_unit_factor(unit) - - @classmethod - def _get_inkscape_layer_name(cls, node): - while node is not None: - layer = node.get(inkex.addNS('label', 'inkscape')) - - if layer is not None: - return layer - - node = node.getparent() - - return None - - @classmethod - def _get_unit_factor(cls, unit): - if unit is None: - return 1 - else: - return cls._unit_factors[unit] - - @classmethod - def _asymptote_identifier_from_layer(cls, layer): - if layer is None: - return '_' - else: - return re.sub('[^a-zA-Z0-9]', '_', layer.export_name) - - @classmethod - def check_document_units(cls, path): - with open(path, 'r') as file: - p = etree.XMLParser(huge_tree = True) - document = etree.parse(file, parser = p) - - height_attr = document.getroot().get('height') - - if height_attr is None: - raise util.UserError('SVG document has no height attribute. See https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') - - _, height_unit = cls._parse_measure(height_attr) - - if height_unit is None or height_unit == 'px': - raise util.UserError('Height of SVG document is not an absolute measure. See https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') - - if document.getroot().get('viewBox') is None: - raise util.UserError('SVG document has no viewBox attribute. See https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') + _unit_factors = _get_unit_factors_map() + _asymptote_all_paths_name = 'all' + + def __init__(self): + inkex.Effect.__init__(self) + + self._flatness = float(os.environ['DXF_FLATNESS']) + + self._layers = None + self._paths = None + + def _get_document_scale(self): + """ + Return scaling factor applied to the document because of a viewBox + setting. This currently ignores any setting of a preserveAspectRatio + attribute (like Inkscape). + """ + document_height = self._get_height() + view_box = self._get_view_box() + + if view_box is None or document_height is None: + return 1 + else: + _, _, _, view_box_height = view_box + + return document_height / view_box_height + + def _get_document_height(self): + """ + Get the height of the document in pixels in the document coordinate + system as it is interpreted by Inkscape. + """ + view_box = self._get_view_box() + document_height = self._get_height() + + if view_box is not None: + _, _, _, view_box_height = view_box + + return view_box_height + elif document_height is not None: + return document_height + else: + return 0 + + def _get_height(self): + height_attr = self.document.getroot().get('height') + + if height_attr is None: + return None + else: + return self._measure_to_pixels(height_attr) + + def _get_view_box(self): + view_box_attr = self.document.getroot().get('viewBox') + + if view_box_attr is None: + return None + else: + return [float(i) for i in view_box_attr.split()] + + def _get_shape_paths(self, node, transform): + shape = cubicsuperpath.parsePath(node.get('d')) + + transform = simpletransform.composeTransform( + transform, + simpletransform.composeParents(node, [[1, 0, 0], [0, 1, 0]])) + + simpletransform.applyTransformToPath(transform, shape) + + def iter_paths(): + for path in shape: + cspsubdiv.subdiv(path, self._flatness) + + # path contains two control point coordinates and the actual + # coordinates per point. + yield [i for _, i, _ in path] + + return list(iter_paths()) + + def effect(self): + document_height = self._get_document_height() + document_scale = self._get_document_scale() + + transform = simpletransform.composeTransform( + [[document_scale, 0, 0], [0, document_scale, 0]], + [[1, 0, 0], [0, -1, document_height]]) + + layers = inkscape.get_inkscape_layers(self.svg_file) + layers_by_inkscape_name = {i.inkscape_name: i for i in layers} + + def iter_paths(): + for node in self.document.getroot().xpath('//svg:path', namespaces=inkex.NSS): + layer = layers_by_inkscape_name.get(self._get_inkscape_layer_name(node)) + + for path in self._get_shape_paths(node, transform): + yield layer, path + + self._layers = layers + self._paths = list(iter_paths()) + + def write_dxf(self, file): + # Scales pixels to millimeters. This is the predominant unit in CAD. + unit_factor = self._unit_factors['mm'] + + layer_indices = {l: i for i, l in enumerate(self._layers)} + + file.write(pkgutil.get_data(__name__, 'dxf_header.txt')) + + def write_instruction(code, value): + print >> file, code + print >> file, value + + handle_iter = itertools.count(256) + + for layer, path in self._paths: + for (x1, y1), (x2, y2) in zip(path, path[1:]): + write_instruction(0, 'LINE') + + if layer is not None: + write_instruction(8, layer.export_name) + write_instruction(62, layer_indices.get(layer, 0)) + + write_instruction(5, '{:x}'.format(next(handle_iter))) + write_instruction(100, 'AcDbEntity') + write_instruction(100, 'AcDbLine') + write_instruction(10, repr(x1 / unit_factor)) + write_instruction(20, repr(y1 / unit_factor)) + write_instruction(30, 0.0) + write_instruction(11, repr(x2 / unit_factor)) + write_instruction(21, repr(y2 / unit_factor)) + write_instruction(31, 0.0) + + file.write(pkgutil.get_data(__name__, 'dxf_footer.txt')) + + def write_asy(self, file): + def write_line(format, *args): + print >> file, format.format(*args) + ';' + + # Scales pixels to points. Asymptote uses PostScript points (1 / 72 + # inch) by default. + unit_factor = self._unit_factors['pt'] + + paths_by_layer = collections.defaultdict(list) + variable_names = [] + + for layer, path in self._paths: + paths_by_layer[layer].append(path) + + for layer in self._layers + [None]: + paths = paths_by_layer[layer] + variable_name = self._asymptote_identifier_from_layer(layer) + write_line('path[] {}', variable_name) + + variable_names.append(variable_name) + + for path in paths: + point_strs = ['({}, {})'.format(x / unit_factor, y / unit_factor) for x, y in path] + + # Hack. We should determine this from whether Z or z was used + # to close the path in the SVG document. + if path[0] == path[-1]: + point_strs[-1] = 'cycle' + + write_line('{}.push({})', variable_name, ' -- '.join(point_strs)) + + if self._asymptote_all_paths_name not in variable_names: + write_line('path[] {}', self._asymptote_all_paths_name) + + for i in variable_names: + write_line('{}.append({})', self._asymptote_all_paths_name, i) + + @classmethod + def _parse_measure(cls, string): + value_match = re.match(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)', string) + unit_match = re.search('(%s)$' % '|'.join(cls._unit_factors.keys()), string) + + value = float(string[value_match.start():value_match.end()]) + + if unit_match: + unit = string[unit_match.start():unit_match.end()] + else: + unit = None + + return value, unit + + @classmethod + def _measure_to_pixels(cls, string): + """ + Parse a string containing a measure and return it's value converted + to pixels. + """ + value, unit = cls._parse_measure(string) + + return value * cls._get_unit_factor(unit) + + @classmethod + def _get_inkscape_layer_name(cls, node): + while node is not None: + layer = node.get(inkex.addNS('label', 'inkscape')) + + if layer is not None: + return layer + + node = node.getparent() + + return None + + @classmethod + def _get_unit_factor(cls, unit): + if unit is None: + return 1 + else: + return cls._unit_factors[unit] + + @classmethod + def _asymptote_identifier_from_layer(cls, layer): + if layer is None: + return '_' + else: + return re.sub('[^a-zA-Z0-9]', '_', layer.export_name) + + @classmethod + def check_document_units(cls, path): + with open(path, 'r') as file: + p = etree.XMLParser(huge_tree = True) + document = etree.parse(file, parser = p) + + height_attr = document.getroot().get('height') + + if height_attr is None: + raise util.UserError( + 'SVG document has no height attribute. See ' + 'https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') + + _, height_unit = cls._parse_measure(height_attr) + + if height_unit is None or height_unit == 'px': + raise util.UserError( + 'Height of SVG document is not an absolute measure. See ' + 'https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') + + if document.getroot().get('viewBox') is None: + raise util.UserError( + 'SVG document has no viewBox attribute. See ' + 'https://github.com/Feuermurmel/openscad-template/wiki/Absolute-Measurements') diff --git a/support/inkscape/inkscape.py b/support/inkscape/inkscape.py index 21634d2..dee1542 100644 --- a/support/inkscape/inkscape.py +++ b/support/inkscape/inkscape.py @@ -1,125 +1,131 @@ import os import xml.etree.ElementTree as etree + from lib import util def get_inkscape_layers(svg_path): - document = etree.parse(svg_path) - - def iter_layers(): - nodes = document.findall( - '{http://www.w3.org/2000/svg}g[@{http://www.inkscape.org/namespaces/inkscape}groupmode="layer"]') - - for i in nodes: - inkscape_name = i.get('{http://www.inkscape.org/namespaces/inkscape}label').strip() - - if inkscape_name.endswith(']'): - export_name, args = inkscape_name[:-1].rsplit('[', 1) - - export_name = export_name.strip() - args = args.strip() - - use_paths = 'p' in args - else: - use_paths = False - export_name = inkscape_name - - yield Layer(inkscape_name, export_name, use_paths) - - return list(iter_layers()) + document = etree.parse(svg_path) + + def iter_layers(): + nodes = document.findall( + '{http://www.w3.org/2000/svg}g[@{http://www.inkscape.org/namespaces/inkscape}groupmode="layer"]') + + for i in nodes: + inkscape_name = i.get('{http://www.inkscape.org/namespaces/inkscape}label').strip() + + if inkscape_name.endswith(']'): + export_name, args = inkscape_name[:-1].rsplit('[', 1) + + export_name = export_name.strip() + args = args.strip() + + use_paths = 'p' in args + else: + use_paths = False + export_name = inkscape_name + + yield Layer(inkscape_name, export_name, use_paths) + + return list(iter_layers()) def _inkscape(svg_path, verbs): - def iter_args(): - yield os.environ['INKSCAPE'] - - for i in verbs: - yield '--verb' - yield i - - yield svg_path - - util.command(list(iter_args())) + def iter_args(): + yield os.environ['INKSCAPE'] + + for i in verbs: + yield '--verb' + yield i + + yield svg_path + + util.command(list(iter_args())) class Layer(object): - def __init__(self, inkscape_name, export_name, use_paths): - self.inkscape_name = inkscape_name - self.export_name = export_name - self.use_paths = use_paths + def __init__(self, inkscape_name, export_name, use_paths): + self.inkscape_name = inkscape_name + self.export_name = export_name + self.use_paths = use_paths class InkscapeCommandLine(object): - def __init__(self, path): - self._path = path - self._layers = get_inkscape_layers(path) - self._current_layer_index = None - self._verbs = [] - - def apply_to_document(self, *verb): - self._verbs.extend(verb) - - def apply_to_layer(self, layer, *verb): - self._go_to_layer(layer) - self.apply_to_document(*verb) - - def select_all_in_layer(self, layer): - self.apply_to_layer(layer, 'EditSelectAll') - - def apply_to_layer_content(self, layer, *verbs): - self.select_all_in_layer(layer) - self.apply_to_document(*verbs) - - def _go_to_layer(self, layer, with_selection = False): - if self._current_layer_index is None: - # Initialize to a known state. We cannot assume that any layer is selected and thus we need as many LayerPrev as we have layers. - self._current_layer_index = len(self._layers) - self._go_to_layer(self._layers[0]) - - target_index = self._layers.index(layer) - - if self._current_layer_index < target_index: - for _ in range(target_index - self._current_layer_index): - self.apply_to_document('LayerMoveToNext' if with_selection else 'LayerNext') - elif self._current_layer_index > target_index: - for _ in range(self._current_layer_index - target_index): - self.apply_to_document('LayerMoveToPrev' if with_selection else 'LayerPrev') - else: - return - - if with_selection: - # When using LayerMoveToNext and LayerMoveToPrev, inkscape does not reliably select the next/previous layer. - self._current_layer_index = None - else: - self._current_layer_index = target_index - - def duplicate_layer(self, layer): - self.apply_to_layer(layer, 'LayerDuplicate') - - # Inkscape 0.91 places a duplicated layer above (after) the selected one and selects the new layer. - new_layer = Layer(layer.inkscape_name + ' copy', layer.export_name, layer.use_paths) - self._current_layer_index += 1 - self._layers.insert(self._current_layer_index, new_layer) - - return new_layer - - def delete_layer(self, layer): - self.apply_to_layer(layer, 'LayerDelete') - - # Inkscape 0.91 selects the layer above (after) the deleted layer. - del self._layers[self._current_layer_index] - - def clear_layer(self, layer): - self.select_all_in_layer(layer) - self.apply_to_document('EditDelete') - - def move_content(self, source_layer, target_layer): - self.select_all_in_layer(source_layer) - self._go_to_layer(target_layer, True) - - def run(self): - _inkscape(self._path, self._verbs) - - @property - def layers(self): - return list(self._layers) + def __init__(self, path): + self._path = path + self._layers = get_inkscape_layers(path) + self._current_layer_index = None + self._verbs = [] + + def apply_to_document(self, *verb): + self._verbs.extend(verb) + + def apply_to_layer(self, layer, *verb): + self._go_to_layer(layer) + self.apply_to_document(*verb) + + def select_all_in_layer(self, layer): + self.apply_to_layer(layer, 'EditSelectAll') + + def apply_to_layer_content(self, layer, *verbs): + self.select_all_in_layer(layer) + self.apply_to_document(*verbs) + + def _go_to_layer(self, layer, with_selection=False): + if self._current_layer_index is None: + # Initialize to a known state. We cannot assume that any layer is + # selected and thus we need as many LayerPrev as we have layers. + self._current_layer_index = len(self._layers) + self._go_to_layer(self._layers[0]) + + target_index = self._layers.index(layer) + + if self._current_layer_index < target_index: + for _ in range(target_index - self._current_layer_index): + self.apply_to_document( + 'LayerMoveToNext' if with_selection else 'LayerNext') + elif self._current_layer_index > target_index: + for _ in range(self._current_layer_index - target_index): + self.apply_to_document( + 'LayerMoveToPrev' if with_selection else 'LayerPrev') + else: + return + + if with_selection: + # When using LayerMoveToNext and LayerMoveToPrev, inkscape does + # not reliably select the next/previous layer. + self._current_layer_index = None + else: + self._current_layer_index = target_index + + def duplicate_layer(self, layer): + self.apply_to_layer(layer, 'LayerDuplicate') + + # Inkscape 0.91 places a duplicated layer above (after) the selected + # one and selects the new layer. + new_layer = Layer(layer.inkscape_name + ' copy', layer.export_name, layer.use_paths) + self._current_layer_index += 1 + self._layers.insert(self._current_layer_index, new_layer) + + return new_layer + + def delete_layer(self, layer): + self.apply_to_layer(layer, 'LayerDelete') + + # Inkscape 0.91 selects the layer above (after) the deleted layer. + del self._layers[self._current_layer_index] + + def clear_layer(self, layer): + self.select_all_in_layer(layer) + self.apply_to_document('EditDelete') + + def move_content(self, source_layer, target_layer): + self.select_all_in_layer(source_layer) + self._go_to_layer(target_layer, True) + + def run(self): + _inkscape(self._path, self._verbs) + + @property + def layers(self): + return list(self._layers) diff --git a/support/lib/make.py b/support/lib/make.py index 3502ef6..c89b306 100644 --- a/support/lib/make.py +++ b/support/lib/make.py @@ -2,4 +2,4 @@ from . import util def write_dependencies(path, target, dependencies): - util.write_file(path, '{}: {}\n'.format(target, ' '.join(dependencies)).encode()) + util.write_file(path, '{}: {}\n'.format(target, ' '.join(dependencies)).encode()) diff --git a/support/lib/util.py b/support/lib/util.py index 8b38bc2..c00a5fe 100644 --- a/support/lib/util.py +++ b/support/lib/util.py @@ -1,117 +1,130 @@ -import sys, contextlib, subprocess, tempfile, shutil, re, os, inspect +import contextlib +import inspect +import os +import re +import shutil +import subprocess +import sys +import tempfile class UserError(Exception): - def __init__(self, message, *args): - super(UserError, self).__init__(message.format(*args)) + def __init__(self, message, *args): + super(UserError, self).__init__(message.format(*args)) def main(fn): - """Decorator for "main" functions. Decorates a function that should be called when the containing module is run as a script (e.g. via python -m <module>).""" - - frame = inspect.currentframe().f_back - - def wrapped_fn(*args, **kwargs): - try: - fn(*args, **kwargs) - except UserError as e: - print >> sys.stderr, 'Error:', e - sys.exit(1) - except KeyboardInterrupt: - sys.exit(2) - - if frame.f_globals['__name__'] == '__main__': - wrapped_fn(*sys.argv[1:]) - - # Allow the main function also to be called explicitly - return wrapped_fn + """ + Decorator for "main" functions. Decorates a function that should be + called when the containing module is run as a script (e.g. via python -m + <module>). + """ + frame = inspect.currentframe().f_back + + def wrapped_fn(*args, **kwargs): + try: + fn(*args, **kwargs) + except UserError as e: + print >> sys.stderr, 'Error:', e + sys.exit(1) + except KeyboardInterrupt: + sys.exit(2) + + if frame.f_globals['__name__'] == '__main__': + wrapped_fn(*sys.argv[1:]) + + # Allow the main function also to be called explicitly + return wrapped_fn def rename_atomic(source_path, target_path): - """ - Move the file at source_path to target_path. - - If both paths reside on the same device, os.rename() is used, otherwise the file is copied to a temporary name next to target_path and moved from there using os.rename(). - """ - - source_dir_stat = os.stat(os.path.dirname(source_path)) - target_dir_stat = os.stat(os.path.dirname(target_path)) - - if source_dir_stat.st_dev == target_dir_stat.st_dev: - os.rename(source_path, target_path) - else: - temp_path = target_path + '~' - - shutil.copyfile(source_path, temp_path) - os.rename(temp_path, target_path) + """ + Move the file at source_path to target_path. + + If both paths reside on the same device, os.rename() is used, otherwise + the file is copied to a temporary name next to target_path and moved from + there using os.rename(). + """ + source_dir_stat = os.stat(os.path.dirname(source_path)) + target_dir_stat = os.stat(os.path.dirname(target_path)) + + if source_dir_stat.st_dev == target_dir_stat.st_dev: + os.rename(source_path, target_path) + else: + temp_path = target_path + '~' + + shutil.copyfile(source_path, temp_path) + os.rename(temp_path, target_path) @contextlib.contextmanager def TemporaryDirectory(): - dir = tempfile.mkdtemp() - - try: - yield dir - finally: - shutil.rmtree(dir) + dir = tempfile.mkdtemp() + + try: + yield dir + finally: + shutil.rmtree(dir) @contextlib.contextmanager -def command_context(args, remove_env = [], set_env = { }, working_dir = None, use_stderr = False): - env = dict(os.environ) - - for i in remove_env: - del env[i] - - for k, v in set_env.items(): - env[k] = v - - if use_stderr: - stderr = subprocess.PIPE - else: - stderr = None - - try: - process = subprocess.Popen(args, env = env, cwd = working_dir, stderr = stderr) - except OSError as e: - raise UserError('Error running {}: {}', args[0], e) - - try: - yield process - except: - try: - process.kill() - except OSError: - # Ignore exceptions here so we don't mask the already-being-thrown exception. - pass - - raise - finally: - # Use communicate so that we won't deadlock if the process generates some unread output. - process.communicate() - - if process.returncode: - raise UserError('Command failed: {}', ' '.join(args)) - - -def command(args, remove_env = [], set_env = { }, working_dir = None): - with command_context(args, remove_env, set_env, working_dir): - pass +def command_context(args, remove_env=[], set_env={}, working_dir=None, use_stderr=False): + env = dict(os.environ) + + for i in remove_env: + del env[i] + + for k, v in set_env.items(): + env[k] = v + + if use_stderr: + stderr = subprocess.PIPE + else: + stderr = None + + try: + process = subprocess.Popen(args, env=env, cwd=working_dir, stderr=stderr) + except OSError as e: + raise UserError('Error running {}: {}', args[0], e) + + try: + yield process + except: + try: + process.kill() + except OSError: + # Ignore exceptions here so we don't mask the + # already-being-thrown exception. + pass + + raise + finally: + # Use communicate so that we won't deadlock if the process generates + # some unread output. + process.communicate() + + if process.returncode: + raise UserError('Command failed: {}', ' '.join(args)) + + +def command(args, remove_env=[], set_env={}, working_dir=None): + with command_context(args, remove_env, set_env, working_dir): + pass def bash_escape_string(string): - return "'{}'".format(re.sub("'", "'\"'\"'", string)) + return "'{}'".format(re.sub("'", "'\"'\"'", string)) def write_file(path, data): - temp_path = path + '~' - - with open(temp_path, 'wb') as file: - file.write(data) - - os.rename(temp_path, path) + temp_path = path + '~' + + with open(temp_path, 'wb') as file: + file.write(data) + + os.rename(temp_path, path) def read_file(path): - with open(path, 'rb') as file: - return file.read() + with open(path, 'rb') as file: + return file.read() diff --git a/support/openscad/__main__.py b/support/openscad/__main__.py index 7aeaa31..85f8b99 100644 --- a/support/openscad/__main__.py +++ b/support/openscad/__main__.py @@ -1,42 +1,46 @@ import os + from lib import util, make def _openscad(in_path, out_path, deps_path): - util.command([os.environ['OPENSCAD'], '-o', out_path, '-d', deps_path, in_path]) + util.command([os.environ['OPENSCAD'], '-o', out_path, '-d', deps_path, in_path]) @util.main def main(in_path, out_path): - cwd = os.getcwd() - - def relpath(path): - return os.path.relpath(path, cwd) - - with util.TemporaryDirectory() as temp_dir: - temp_deps_path = os.path.join(temp_dir, 'deps') - temp_mk_path = os.path.join(temp_dir, 'mk') - temp_files_path = os.path.join(temp_dir, 'files') - - _, out_ext = os.path.splitext(out_path) - - # OpenSCAD requires the output file name to end in .stl or .dxf. - temp_out_path = os.path.join(temp_dir, 'out' + out_ext) - - _openscad(in_path, temp_out_path, temp_deps_path) - - mk_content = '%:; echo "$@" >> {}'.format(util.bash_escape_string(temp_files_path)) - - # Use make to parse the dependency makefile written by OpenSCAD. - util.write_file(temp_mk_path, mk_content.encode()) - util.command(['make', '-s', '-B', '-f', temp_mk_path, '-f', temp_deps_path], remove_env = ['MAKELEVEL', 'MAKEFLAGS']) - - # All dependencies as paths relative to the project root. - deps = set(map(relpath, util.read_file(temp_files_path).decode().splitlines())) - - # Relative paths to all files that should not appear in the dependency makefile. - ignored_files = set(map(relpath, [in_path, temp_deps_path, temp_mk_path, temp_out_path])) - - # Write output files. - make.write_dependencies(out_path + '.d', out_path, deps - ignored_files) - util.rename_atomic(temp_out_path, out_path) + cwd = os.getcwd() + + def relpath(path): + return os.path.relpath(path, cwd) + + with util.TemporaryDirectory() as temp_dir: + temp_deps_path = os.path.join(temp_dir, 'deps') + temp_mk_path = os.path.join(temp_dir, 'mk') + temp_files_path = os.path.join(temp_dir, 'files') + + _, out_ext = os.path.splitext(out_path) + + # OpenSCAD requires the output file name to end in .stl or .dxf. + temp_out_path = os.path.join(temp_dir, 'out' + out_ext) + + _openscad(in_path, temp_out_path, temp_deps_path) + + mk_content = '%:; echo "$@" >> {}'.format(util.bash_escape_string(temp_files_path)) + + # Use make to parse the dependency makefile written by OpenSCAD. + util.write_file(temp_mk_path, mk_content.encode()) + util.command( + ['make', '-s', '-B', '-f', temp_mk_path, '-f', temp_deps_path], + remove_env=['MAKELEVEL', 'MAKEFLAGS']) + + # All dependencies as paths relative to the project root. + deps = set(map(relpath, util.read_file(temp_files_path).decode().splitlines())) + + # Relative paths to all files that should not appear in the + # dependency makefile. + ignored_files = set(map(relpath, [in_path, temp_deps_path, temp_mk_path, temp_out_path])) + + # Write output files. + make.write_dependencies(out_path + '.d', out_path, deps - ignored_files) + util.rename_atomic(temp_out_path, out_path) |