Added realistic stitch preview option (#2838)

Includes multiple optimizations for the realistic stitch preview

Co-authored-by: Lex Neva <github.com@lexneva.name>
pull/2844/head
capellancitizen 2024-04-24 20:07:37 -04:00 zatwierdzone przez GitHub
rodzic 59db996eae
commit c164f8d458
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
3 zmienionych plików z 138 dodań i 82 usunięć

Wyświetl plik

@ -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:

Wyświetl plik

@ -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="SourceGraphic"
id="feComposite1977"
operator="arithmetic"
k2="1"
k3="1"
result="result3"
k1="0"
k4="0" />
<feComposite
in2="SourceAlpha"
id="feComposite1981"
operator="in" />
operator="atop" />
<feComposite
in2="SourceGraphic"
id="feComposite1982"
operator="arithmetic"
k2="0.8"
k3="1.2"
result="result3"
k1="0"
k4="0" />
</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

Wyświetl plik

@ -16,6 +16,13 @@
<option value="hidden">Hidden</option>
<option value="lower_opacity">Lower opacity</option>
</param>
<param name="render-mode" type="optiongroup" appearance="combo" gui-text="Render Mode"
gui-description="Realistic modes will render to a raster image for performance reasons. Realistic Vector may cause Inkscape to slow down for complex designs.">
<option value="simple">Simple</option>
<option value="realistic-8">Realistic</option>
<option value="realistic-16">Realistic High Quality</option>
<option value="realistic-vector">Realistic Vector (slow)</option>
</param>
<spacer />
<separator />
<spacer />