diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/extensions/stitch_plan_preview.py | 68 | ||||
| -rw-r--r-- | lib/svg/rendering.py | 139 |
2 files changed, 128 insertions, 79 deletions
diff --git a/lib/extensions/stitch_plan_preview.py b/lib/extensions/stitch_plan_preview.py index 541c42f0..f8dfc60d 100644 --- a/lib/extensions/stitch_plan_preview.py +++ b/lib/extensions/stitch_plan_preview.py @@ -3,13 +3,19 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from inkex import Boolean +from tempfile import TemporaryDirectory +from base64 import b64encode +from typing import Optional, Tuple +import sys + +from inkex import errormsg, Boolean, BoundingBox, Image, BaseElement +from inkex.command import take_snapshot from ..marker import set_marker from ..stitch_plan import stitch_groups_to_stitch_plan from ..svg import render_stitch_plan from ..svg.tags import (INKSCAPE_GROUPMODE, INKSTITCH_ATTRIBS, - SODIPODI_INSENSITIVE, SVG_GROUP_TAG, SVG_PATH_TAG) + SODIPODI_INSENSITIVE, SVG_GROUP_TAG, SVG_PATH_TAG, XLINK_HREF) from .base import InkstitchExtension from .stitch_plan_preview_undo import reset_stitch_plan @@ -23,8 +29,11 @@ class StitchPlanPreview(InkstitchExtension): self.arg_parser.add_argument("-i", "--insensitive", type=Boolean, default=False, dest="insensitive") self.arg_parser.add_argument("-c", "--visual-commands", type=Boolean, default="symbols", dest="visual_commands") self.arg_parser.add_argument("-o", "--overwrite", type=Boolean, default=True, dest="overwrite") + self.arg_parser.add_argument("-m", "--render-mode", type=str, default="simple", dest="mode") def effect(self): + realistic, raster_mult = self.parse_mode() + # delete old stitch plan self.remove_old() @@ -33,17 +42,15 @@ class StitchPlanPreview(InkstitchExtension): return svg = self.document.getroot() - realistic = False visual_commands = self.options.visual_commands self.metadata = self.get_inkstitch_metadata() collapse_len = self.metadata['collapse_len_mm'] min_stitch_len = self.metadata['min_stitch_len_mm'] stitch_groups = self.elements_to_stitch_groups(self.elements) stitch_plan = stitch_groups_to_stitch_plan(stitch_groups, collapse_len=collapse_len, min_stitch_len=min_stitch_len) - render_stitch_plan(svg, stitch_plan, realistic, visual_commands) - # apply options - layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + layer = render_stitch_plan(svg, stitch_plan, realistic, visual_commands) + layer = self.rasterize(svg, layer, raster_mult) # update layer visibility (unchanged, hidden, lower opacity) groups = self.document.getroot().findall(SVG_GROUP_TAG) @@ -54,6 +61,31 @@ class StitchPlanPreview(InkstitchExtension): self.translate(svg, layer) self.set_needle_points(layer) + def parse_mode(self) -> Tuple[bool, Optional[int]]: + """ + Parse the "mode" option and return a tuple of a bool indicating if realistic rendering should be used, + and an optional int indicating the resolution multiplier to use for rasterization, or None if rasterization should not be used. + """ + realistic = False + raster_mult: Optional[int] = None + render_mode = self.options.mode + if render_mode == "simple": + pass + elif render_mode.startswith("realistic-"): + realistic = True + raster_option = render_mode.split('-')[1] + if raster_option != "vector": + try: + raster_mult = int(raster_option) + except ValueError: + errormsg(f"Invalid raster mode {raster_option}") + sys.exit(1) + else: + errormsg(f"Invalid render mode {render_mode}") + sys.exit(1) + + return (realistic, raster_mult) + def remove_old(self): svg = self.document.getroot() if self.options.overwrite: @@ -64,6 +96,26 @@ class StitchPlanPreview(InkstitchExtension): if layer is not None: layer.set('id', svg.get_unique_id('inkstitch_stitch_plan_')) + def rasterize(self, svg, layer: BaseElement, raster_mult: Optional[int]) -> BaseElement: + if raster_mult is None: + # Don't rasterize if there's no reason to. + return layer + else: + with TemporaryDirectory() as tempdir: + bbox: BoundingBox = layer.bounding_box() + rasterized_file = take_snapshot(svg, tempdir, dpi=96*raster_mult, + export_id=layer.get_id(), export_id_only=True) + with open(rasterized_file, "rb") as f: + image = Image(attrib={ + XLINK_HREF: f"data:image/png;base64,{b64encode(f.read()).decode()}", + "x": str(bbox.left), + "y": str(bbox.top), + "height": str(bbox.height), + "width": str(bbox.width), + }) + layer.replace_with(image) + return image + def set_invisible_layers_attribute(self, groups, layer): invisible_layers = [] for g in groups: @@ -95,9 +147,7 @@ class StitchPlanPreview(InkstitchExtension): if self.options.move_to_side: # translate stitch plan to the right side of the canvas translate = svg.get('viewBox', '0 0 800 0').split(' ')[2] - layer.set('transform', f'translate({ translate })') - else: - layer.set('transform', None) + layer.transform = layer.transform.add_translate(translate) def set_needle_points(self, layer): if self.options.needle_points: diff --git a/lib/svg/rendering.py b/lib/svg/rendering.py index b96fe9b7..4b9eda49 100644 --- a/lib/svg/rendering.py +++ b/lib/svg/rendering.py @@ -19,94 +19,85 @@ from .units import PIXELS_PER_MM, get_viewbox_transform # # It's 0.32mm high, which is the approximate thickness of common machine # embroidery threads. -# 1.216 pixels = 0.32mm -stitch_height = 1.216 +# 1.398 pixels = 0.37mm +stitch_height = 1.398 # This vector path starts at the upper right corner of the stitch shape and -# proceeds counter-clockwise.and contains a placeholder (%s) for the stitch +# proceeds counter-clockwise and contains a placeholder (%s) for the stitch # length. # -# It contains two invisible "whiskers" of zero width that go above and below +# It contains four invisible "whiskers" of zero width that go outwards # to ensure that the SVG renderer allocates a large enough canvas area when -# computing the gaussian blur steps. Otherwise, we'd have to expand the -# width and height attributes of the <filter> tag to add more buffer space. -# The width and height are specified in multiples of the bounding box -# size, It's the bounding box aligned with the global SVG canvas's axes, not -# the axes of the stitch itself. That means that having a big enough value +# computing the gaussian blur steps: +# \_____/ +# (_____) (whiskers not to scale) +# / \ +# This is necessary to avoid artifacting near the edges and corners that seems to be due to +# edge conditions for the feGaussianBlur, which is used to build the heightmap for +# the feDiffuseLighting node. So we need some extra buffer room around the shape. +# The whiskers let us specify a "fixed" amount of spacing around the stitch. +# Otherwise, we'd have to expand the width and height attributes of the <filter> +# tag to add more buffer space. The filter's width and height are specified in multiples of +# the bounding box size, It's the bounding box aligned with the global SVG canvas's axes, +# not the axes of the stitch itself. That means that having a big enough value # to add enough padding on the long sides of the stitch would waste a ton # of space on the short sides and significantly slow down rendering. -stitch_path = "M0,0c0.4,0,0.4,0.3,0.4,0.6c0,0.3,-0.1,0.6,-0.4,0.6v0.2,-0.2h-%sc-0.4,0,-0.4,-0.3,-0.4,-0.6c0,-0.3,0.1,-0.6,0.4,-0.6v-0.2,0.2z" -# This filter makes the above stitch path look like a real stitch with lighting. +# The specific extent of the whiskers (0.55 parallel to the stitch, 0.1 perpendicular) +# was found by experimentation. It seems to work with almost no artifacting. +stitch_path = ( + "M0,0" # Start point + "l0.55,-0.1,-0.55,0.1" # Bottom-right whisker + "c0.613,0,0.613,1.4,0,1.4" # Right endcap + "l0.55,0.1,-0.55,-0.1" # Top-right whisker + "h-%s" # Stitch length + "l-0.55,0.1,0.55,-0.1" # Top-left whisker + "c-0.613,0,-0.613,-1.4,0,-1.4" # Left endcap + "l-0.55,-0.1,0.55,0.1" # Bottom-left whisker + "z") # return to start + +# The filter needs the xmlns:inkscape declaration, or Inkscape will display a parse error +# "Namespace prefix inkscape for auto-region on filter is not defined" +# Even when the document itself has the namespace, go figure. realistic_filter = """ <filter style="color-interpolation-filters:sRGB" id="realistic-stitch-filter" - x="-0.1" - width="1.2" - y="-0.1" - height="1.2"> + x="0" + width="1" + y="0" + height="1" + inkscape:auto-region="false" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"> <feGaussianBlur - stdDeviation="1.5" + edgeMode="none" + stdDeviation="0.9" id="feGaussianBlur1542-6" in="SourceAlpha" /> - <feComponentTransfer - id="feComponentTransfer1544-7" - result="result1"> - <feFuncR - id="feFuncR1546-5" - type="identity" /> - <feFuncG - id="feFuncG1548-3" - type="identity" /> - <feFuncB - id="feFuncB1550-5" - type="identity" - slope="4.5300000000000002" /> - <feFuncA - id="feFuncA1552-6" - type="gamma" - slope="0.14999999999999999" - intercept="0" - amplitude="3.1299999999999999" - offset="-0.33000000000000002" /> - </feComponentTransfer> - <feComposite - in2="SourceAlpha" - id="feComposite1558-2" - operator="in" /> - <feGaussianBlur - stdDeviation="0.089999999999999997" - id="feGaussianBlur1969" /> - <feMorphology - id="feMorphology1971" - operator="dilate" - radius="0.10000000000000001" /> <feSpecularLighting id="feSpecularLighting1973" result="result2" - specularConstant="0.70899999" - surfaceScale="30"> - <fePointLight - id="fePointLight1975" - z="10" /> + surfaceScale="1.5" + specularConstant="0.78" + specularExponent="2.5"> + <feDistantLight + id="feDistantLight1975" + azimuth="-125" + elevation="20" /> </feSpecularLighting> - <feGaussianBlur - stdDeviation="0.040000000000000001" - id="feGaussianBlur1979" /> + <feComposite + in2="SourceAlpha" + id="feComposite1981" + operator="atop" /> <feComposite in2="SourceGraphic" - id="feComposite1977" + id="feComposite1982" operator="arithmetic" - k2="1" - k3="1" + k2="0.8" + k3="1.2" result="result3" k1="0" k4="0" /> - <feComposite - in2="SourceAlpha" - id="feComposite1981" - operator="in" /> </filter> """ @@ -124,17 +115,18 @@ def realistic_stitch(start, end): stitch_length = max(0, stitch_length - 0.2 * PIXELS_PER_MM) - # create the path by filling in the length in the template - path = inkex.Path(stitch_path % stitch_length).to_arrays() - # rotate the path to match the stitch rotation_center_x = -stitch_length / 2.0 rotation_center_y = stitch_height / 2.0 - path = inkex.Path(path).rotate(stitch_angle, (rotation_center_x, rotation_center_y)) + transform = ( + inkex.Transform() + .add_translate(stitch_center.x - rotation_center_x, stitch_center.y - rotation_center_y) + .add_rotate(stitch_angle, (rotation_center_x, rotation_center_y)) + ) - # move the path to the location of the stitch - path = inkex.Path(path).translate(stitch_center.x - rotation_center_x, stitch_center.y - rotation_center_y) + # create the path by filling in the length in the template, and transforming it as above + path = inkex.Path(stitch_path % stitch_length).transform(transform, True) return str(path) @@ -221,7 +213,7 @@ def color_block_to_paths(color_block, svg, destination, visual_commands): path.set(INKSTITCH_ATTRIBS['stop_after'], 'true') -def render_stitch_plan(svg, stitch_plan, realistic=False, visual_commands=True): +def render_stitch_plan(svg, stitch_plan, realistic=False, visual_commands=True) -> inkex.Group: layer = svg.findone(".//*[@id='__inkstitch_stitch_plan__']") if layer is None: layer = inkex.Group(attrib={ @@ -250,5 +242,12 @@ def render_stitch_plan(svg, stitch_plan, realistic=False, visual_commands=True): color_block_to_paths(color_block, svg, group, visual_commands) if realistic: + # Remove filter from defs, if any + filter: inkex.BaseElement = svg.defs.findone("//*[@id='realistic-stitch-filter']") + if filter is not None: + svg.defs.remove(filter) + filter_document = inkex.load_svg(realistic_filter) svg.defs.append(filter_document.getroot()) + + return layer |
