From f3a3cde71e9312d1f07c156033de5b7fb4b99f2d Mon Sep 17 00:00:00 2001 From: capellancitizen Date: Wed, 14 Aug 2024 19:40:42 -0400 Subject: [PATCH] Clones now also clone commands attached to element and its children. (#3032, #3121) (#3086) --- lib/commands.py | 69 ++++++++++-- lib/elements/clone.py | 81 +++++++++++--- lib/elements/element.py | 24 ++-- lib/extensions/unlink_clone.py | 15 ++- lib/svg/path.py | 2 +- lib/svg/svg.py | 29 ++++- lib/utils/__init__.py | 1 + tests/test_clone.py | 198 ++++++++++++++++++++++++++++++++- tests/test_lib_svg_svg.py | 40 +++++++ 9 files changed, 415 insertions(+), 44 deletions(-) create mode 100644 tests/test_lib_svg_svg.py diff --git a/lib/commands.py b/lib/commands.py index 86abe52a0..9a352cfe5 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -7,13 +7,16 @@ import os import sys from copy import deepcopy from random import random +from typing import List import inkex from shapely import geometry as shgeo +from shapely import get_coordinates from .i18n import N_, _ from .svg import (apply_transforms, generate_unique_id, get_correction_transform, get_document, get_node_transform) +from .svg.svg import copy_no_children, point_upwards from .svg.tags import (CONNECTION_END, CONNECTION_START, CONNECTOR_TYPE, INKSCAPE_LABEL, INKSTITCH_ATTRIBS, SVG_SYMBOL_TAG, SVG_USE_TAG, XLINK_HREF) @@ -113,7 +116,7 @@ class BaseCommand(object): class Command(BaseCommand): def __init__(self, connector): - self.connector = connector + self.connector: inkex.Path = connector self.svg = self.connector.getroottree().getroot() self.parse_command() @@ -132,7 +135,8 @@ class Command(BaseCommand): (self.get_node_by_url(self.connector.get(CONNECTION_END)), path[0][-1][1]) ] - if neighbors[0][0].tag != SVG_USE_TAG: + self.symbol_is_end = neighbors[0][0].tag != SVG_USE_TAG + if self.symbol_is_end: neighbors.reverse() if neighbors[0][0].tag != SVG_USE_TAG: @@ -143,12 +147,41 @@ class Command(BaseCommand): self.symbol = self.get_node_by_url(neighbors[0][0].get(XLINK_HREF)) self.parse_symbol() - self.target = neighbors[1][0] + self.target: inkex.BaseElement = neighbors[1][0] self.target_point = neighbors[1][1] def __repr__(self): return "Command('%s', %s)" % (self.command, self.target_point) + def clone(self, new_target: inkex.BaseElement) -> inkex.BaseElement: + """ + Clone this command and point it to the new target, positioning it relative to the new target the same as the target + """ + relative_transform = new_target.composed_transform() @ -self.target.composed_transform() + + # Clone group + cloned_group = copy_no_children(self.connector.getparent()) + cloned_group.transform = relative_transform @ cloned_group.transform + new_target.getparent().append(cloned_group) + + symbol = copy_no_children(self.use) + cloned_group.append(symbol) + point_upwards(symbol) + + # Copy connector + connector = copy_no_children(self.connector) + cloned_group.append(connector) + if self.symbol_is_end: + symbol_attr = CONNECTION_END + target_attr = CONNECTION_START + else: + symbol_attr = CONNECTION_START + target_attr = CONNECTION_END + connector.set(symbol_attr, f"#{symbol.get_id()}") + connector.set(target_attr, f"#{new_target.get_id()}") + + return cloned_group + class StandaloneCommand(BaseCommand): def __init__(self, use): @@ -175,11 +208,11 @@ class StandaloneCommand(BaseCommand): return Point(*pos) -def get_command_description(command): +def get_command_description(command: str): return COMMANDS[command] -def find_commands(node): +def find_commands(node: BaseCommand) -> List[Command]: """Find the symbols this node is connected to and return them as Commands""" # find all paths that have this object as a connection @@ -307,10 +340,24 @@ def add_group(document, node, command): def add_connector(document, symbol, command, element): - # I'd like it if I could position the connector endpoint nicely but inkscape just - # moves it to the element's center immediately after the extension runs. + # "I'd like it if I could position the connector endpoint nicely but inkscape just + # moves it to the element's center immediately after the extension runs." - Lex Neva, rev. 4baced7085 + # "Maybe we should have the target point be a seperately-moveable node? Sometimes moving the command + # node so the line drawn from the command to the centroid of the target is awkward anyway?" - CapellanCitizen + + # Inkscape will draw this connector line from the bounding box center of the two nodes, but + # will stop at the first intersection with the path it's pointing to. It is necessary to + # compute the target point accurately to what inkscape will do. + # If not, then the target position will change when the document is loaded by inkscape and break. + # For example, not doing this caused issues when implementing commands attached to clones. start_pos = (symbol.get('x'), symbol.get('y')) - end_pos = element.shape.centroid + centroid_pos = element.node.bounding_box(inkex.Transform(get_node_transform(element.node.getparent()))).center + connector_line = shgeo.LineString([start_pos, centroid_pos]) + if connector_line.intersects(element.shape): + end_pos = get_coordinates(connector_line.intersection(element.shape))[0] + else: + # Sometimes the line won't intersect anything and will go straight to the centroid. + end_pos = centroid_pos # Make sure the element's XML node has an id so that we can reference it. if element.node.get('id') is None: @@ -318,10 +365,10 @@ def add_connector(document, symbol, command, element): path = inkex.PathElement(attrib={ "id": generate_unique_id(document, "command_connector"), - "d": "M %s,%s %s,%s" % (start_pos[0], start_pos[1], end_pos.x, end_pos.y), + "d": f"M {start_pos[0]},{start_pos[1]} {end_pos[0]},{end_pos[1]}", "style": "stroke:#000000;stroke-width:1px;stroke-opacity:0.5;fill:none;", - CONNECTION_START: "#%s" % symbol.get('id'), - CONNECTION_END: "#%s" % element.node.get('id'), + CONNECTION_START: f"#{symbol.get('id')}", + CONNECTION_END: f"#{element.node.get('id')}", # l10n: the name of the line that connects a command to the object it applies to INKSCAPE_LABEL: _("connector") diff --git a/lib/elements/clone.py b/lib/elements/clone.py index 9a89b6ff4..91c697409 100644 --- a/lib/elements/clone.py +++ b/lib/elements/clone.py @@ -5,18 +5,20 @@ from math import degrees from contextlib import contextmanager -from typing import Generator, List +from typing import Generator, List, Dict from inkex import Transform, BaseElement from shapely import MultiLineString from ..stitch_plan.stitch_group import StitchGroup -from ..commands import is_command_symbol +from ..commands import is_command_symbol, find_commands from ..i18n import _ +from ..svg.svg import copy_no_children from ..svg.path import get_node_transform from ..svg.tags import (EMBROIDERABLE_TAGS, INKSTITCH_ATTRIBS, SVG_USE_TAG, - XLINK_HREF, SVG_GROUP_TAG) + XLINK_HREF, CONNECTION_START, CONNECTION_END, + SVG_GROUP_TAG) from ..utils import cache from .element import EmbroideryElement, param from .validation import ValidationWarning @@ -88,7 +90,8 @@ class Clone(EmbroideryElement): stitch_groups = [] for element in elements: - element_stitch_groups = element.to_stitch_groups(last_stitch_group) + # Using `embroider` here to get trim/stop after commands, etc. + element_stitch_groups = element.embroider(last_stitch_group) if len(element_stitch_groups): last_stitch_group = element_stitch_groups[-1] stitch_groups.extend(element_stitch_groups) @@ -104,40 +107,40 @@ class Clone(EmbroideryElement): Could possibly be refactored into just a generator - being a context manager is mainly to control the lifecycle of the elements that are cloned (again, for testing convenience primarily) """ - parent: BaseElement = self.node.getparent() - cloned_node = self.resolve_clone() + cloned_nodes = self.resolve_clone() try: # In a try block so we can ensure that the cloned_node is removed from the tree in the event of an exception. # Otherwise, it might be left around on the document if we throw for some reason. - yield self.clone_to_elements(cloned_node) + yield self.clone_to_elements(cloned_nodes[0]) finally: # Remove the "manually cloned" tree. - parent.remove(cloned_node) + for cloned_node in cloned_nodes: + cloned_node.delete() - def resolve_clone(self, recursive=True) -> BaseElement: + def resolve_clone(self, recursive=True) -> List[BaseElement]: """ "Resolve" this clone element by copying the node it hrefs as if unlinking the clone in Inkscape. The node will be added as a sibling of this element's node, with its transform and style applied. The fill angles for resolved elements will be rotated per the transform and clone_fill_angle properties of the clone. :param recursive: Recursively "resolve" all child clones in the same manner - :returns: The "resolved" node + :returns: A list where the first element is the "resolved" node, and zero or more commands attached to that node """ parent: BaseElement = self.node.getparent() source_node: BaseElement = self.node.href source_parent: BaseElement = source_node.getparent() - cloned_node = source_node.copy() + cloned_node = clone_with_fixup(parent, source_node) if recursive: # Recursively resolve all clones as if the clone was in the same place as its source source_parent.add(cloned_node) if is_clone(cloned_node): - cloned_node = cloned_node.replace_with(Clone(cloned_node).resolve_clone()) + cloned_node = cloned_node.replace_with(Clone(cloned_node).resolve_clone()[0]) else: clones: List[BaseElement] = [n for n in cloned_node.iterdescendants() if is_clone(n)] for clone in clones: - clone.replace_with(Clone(clone).resolve_clone()) + clone.replace_with(Clone(clone).resolve_clone()[0]) source_parent.remove(cloned_node) @@ -153,12 +156,18 @@ class Clone(EmbroideryElement): # Compute angle transform: # Effectively, this is (local clone transform) * (to parent space) * (from clone's parent space) # There is a translation component here that will be ignored. - source_transform = source_parent.composed_transform() - clone_transform = self.node.composed_transform() + source_transform: Transform = source_parent.composed_transform() + clone_transform: Transform = self.node.composed_transform() angle_transform = clone_transform @ -source_transform self.apply_angles(cloned_node, angle_transform) - return cloned_node + ret = [cloned_node] + + # We need to copy all commands that were attached directly to the href'd node + for command in find_commands(source_node): + ret.append(command.clone(cloned_node)) + + return ret def apply_angles(self, cloned_node: BaseElement, transform: Transform) -> None: """ @@ -174,6 +183,11 @@ class Clone(EmbroideryElement): if node.tag not in EMBROIDERABLE_TAGS: continue + # Only need to adjust angles on fill elements. + element = EmbroideryElement(node) + if not (element.get_style("fill", "black") and not element.get_style('fill-opacity', 1) == "0"): + continue + # Normally, rotate the cloned element's angle by the clone's rotation. if self.clone_fill_angle is None: element_angle = float(node.get(INKSTITCH_ATTRIBS['angle'], 0)) @@ -212,3 +226,38 @@ def is_clone(node): if node.tag == SVG_USE_TAG and node.get(XLINK_HREF) and not is_command_symbol(node): return True return False + + +def clone_with_fixup(parent: BaseElement, node: BaseElement) -> BaseElement: + """ + Clone the node, placing the clone as a child of parent, and fix up + references in the cloned subtree to point to elements from the clone subtree. + """ + # A map of "#id" -> "#corresponding-id-in-the-cloned-subtree" + id_map: Dict[str, str] = {} + + def clone_children(parent: BaseElement, node: BaseElement) -> BaseElement: + # Copy the node without copying its children. + cloned = copy_no_children(node) + parent.append(cloned) + id_map[f"#{node.get_id()}"] = f"#{cloned.get_id()}" + + for child in node.getchildren(): + clone_children(cloned, child) + + return cloned + + ret = clone_children(parent, node) + + def fixup_id_attr(node: BaseElement, attr: str): + # Replace the id value for this attrib with the corresponding one in the clone subtree, if applicable. + val = node.get(attr) + if val is not None: + node.set(attr, id_map.get(val, val)) + + for n in ret.iter(): + fixup_id_attr(n, XLINK_HREF) + fixup_id_attr(n, CONNECTION_START) + fixup_id_attr(n, CONNECTION_END) + + return ret diff --git a/lib/elements/element.py b/lib/elements/element.py index 5cc131747..8884bc2aa 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -9,8 +9,10 @@ from copy import deepcopy import inkex import numpy as np from inkex import bezier, BaseElement +from typing import List, Optional -from ..commands import find_commands +from ..commands import Command, find_commands +from ..stitch_plan import StitchGroup from ..debug.debug import debug from ..exceptions import InkstitchException, format_uncaught_exception from ..i18n import _ @@ -436,19 +438,19 @@ class EmbroideryElement(object): @property @cache - def commands(self): + def commands(self) -> List[Command]: return find_commands(self.node) @cache - def get_commands(self, command): + def get_commands(self, command: str) -> List[Command]: return [c for c in self.commands if c.command == command] @cache - def has_command(self, command): + def has_command(self, command: str) -> bool: return len(self.get_commands(command)) > 0 @cache - def get_command(self, command): + def get_command(self, command: str) -> Optional[Command]: commands = self.get_commands(command) if commands: @@ -495,7 +497,7 @@ class EmbroideryElement(object): return lock_start, lock_end - def to_stitch_groups(self, last_stitch_group): + def to_stitch_groups(self, last_stitch_group: Optional[StitchGroup]) -> List[StitchGroup]: raise NotImplementedError("%s must implement to_stitch_groups()" % self.__class__.__name__) @debug.time @@ -517,7 +519,7 @@ class EmbroideryElement(object): return stitch_groups - def uses_previous_stitch(self): + def uses_previous_stitch(self) -> bool: """Returns True if the previous stitch can affect this Element's stitches. This function may be overridden in a subclass. @@ -589,7 +591,7 @@ class EmbroideryElement(object): return cache_key - def embroider(self, last_stitch_group): + def embroider(self, last_stitch_group: Optional[StitchGroup]) -> List[StitchGroup]: debug.log(f"starting {self.node.get('id')} {self.node.get(INKSCAPE_LABEL)}") with self.handle_unexpected_exceptions(): @@ -606,8 +608,10 @@ class EmbroideryElement(object): apply_patterns(stitch_groups, self.node) if stitch_groups: - stitch_groups[-1].trim_after = self.has_command("trim") or self.trim_after - stitch_groups[-1].stop_after = self.has_command("stop") or self.stop_after + # In some cases (clones) the last stitch group may have trim_after or stop_after already set, + # and we shouldn't override that with this element's values, hence the use of or-equals + stitch_groups[-1].trim_after |= self.has_command("trim") or self.trim_after + stitch_groups[-1].stop_after |= self.has_command("stop") or self.stop_after for stitch_group in stitch_groups: stitch_group.min_jump_stitch_length = self.min_jump_stitch_length diff --git a/lib/extensions/unlink_clone.py b/lib/extensions/unlink_clone.py index 0c1878088..e94bf0ea2 100644 --- a/lib/extensions/unlink_clone.py +++ b/lib/extensions/unlink_clone.py @@ -5,9 +5,10 @@ from inkex import Boolean, errormsg, BaseElement -from ..elements import Clone +from ..elements import Clone, EmbroideryElement from ..i18n import _ from .base import InkstitchExtension +from ..svg.tags import CONNECTION_END, CONNECTION_START from typing import List, Tuple @@ -34,8 +35,14 @@ class UnlinkClone(InkstitchExtension): for element in self.elements: if isinstance(element, Clone): resolved = element.resolve_clone(recursive=recursive) - clones_resolved.append((element.node, resolved)) + clones_resolved.append((element.node, resolved[0])) for (clone, resolved) in clones_resolved: - clone.getparent().remove(clone) - resolved.set_id(clone.get_id()) + clone.delete() + orig_id = resolved.get_id() + new_id = clone.get_id() + # Fix up command backlinks - note this has to happen before we rename so they can actually be found. + for command in EmbroideryElement(resolved).commands: + backlink_attrib = CONNECTION_START if command.connector.get(CONNECTION_START) == ("#"+orig_id) else CONNECTION_END + command.connector.set(backlink_attrib, "#"+new_id) + resolved.set_id(new_id) diff --git a/lib/svg/path.py b/lib/svg/path.py index 878d2a7cc..9d92058ba 100644 --- a/lib/svg/path.py +++ b/lib/svg/path.py @@ -31,7 +31,7 @@ def compose_parent_transforms(node, mat): return mat -def get_node_transform(node): +def get_node_transform(node: inkex.BaseElement): """ if getattr(node, "composed_transform", None): return node.composed_transform() diff --git a/lib/svg/svg.py b/lib/svg/svg.py index 2c5210980..99f5bf263 100644 --- a/lib/svg/svg.py +++ b/lib/svg/svg.py @@ -3,7 +3,9 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from inkex import NSS +import math + +from inkex import NSS, BaseElement, Transform from lxml import etree from ..utils import cache @@ -29,3 +31,28 @@ def find_elements(node, xpath): document = get_document(node) elements = document.xpath(xpath, namespaces=NSS) return elements + + +def copy_no_children(node: BaseElement) -> BaseElement: + return type(node)(attrib=node.attrib) + + +def point_upwards(node: BaseElement) -> None: + """ + Given a node, adjust the transform such that it is in the same spot, but pointing upwards (e.g. for command symbols) + """ + # Adjust the transform of the node so it's face-up and the right way around. + node_transform = node.composed_transform() + compensation = -Transform((node_transform.a, node_transform.b, node_transform.c, node_transform.d, 0, 0)) + scale_vector = compensation.capply_to_point(1+1j) + scale_factor = math.sqrt(2)/math.sqrt(scale_vector.real*scale_vector.real + scale_vector.imag*scale_vector.imag) + compensation.add_scale(scale_factor, scale_factor) + + node_correction = Transform().add_translate(float(node.get('x', 0)), float(node.get('y', 0))) + node_correction @= compensation + # Quick hack to compute the rotational angle - node_transform @ (1,0) = (a, b) + + node.transform = node.transform @ node_correction + # Clear the x and y coords, they've been incorporated to the transform above + node.set('x', None) + node.set('y', None) diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py index b24257be9..60a0bd363 100644 --- a/lib/utils/__init__.py +++ b/lib/utils/__init__.py @@ -3,6 +3,7 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from . import cache as cache_module # Slight hack to allow cache to be imported for monkeypatching from .cache import cache from .dotdict import DotDict from .geometry import * diff --git a/tests/test_clone.py b/tests/test_clone.py index 6ebb07968..1c84f3e9f 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -1,5 +1,7 @@ -from lib.elements import Clone, EmbroideryElement +from lib.elements import Clone, EmbroideryElement, FillStitch +from lib.commands import add_commands from lib.svg.tags import INKSTITCH_ATTRIBS, SVG_RECT_TAG +from lib.utils import cache_module from inkex import SvgDocumentElement, Rectangle, Circle, Group, Use, Transform, TextElement from inkex.tester import TestCase from inkex.tester.svg import svg @@ -17,6 +19,15 @@ def element_fill_angle(element: EmbroideryElement) -> Optional[float]: class CloneElementTest(TestCase): + def setUp(self): + from pytest import MonkeyPatch + self.monkeypatch = MonkeyPatch() + self.monkeypatch.setattr(cache_module, "is_cache_disabled", lambda: True) + + def tearDown(self): + self.monkeypatch.undo() + return super().tearDown() + def assertAngleAlmostEqual(self, a, b): # Take the mod 180 of the returned angles, because e.g. -130deg and 50deg produce fills along the same angle. # We have to use a precision of 4 decimal digits because of the precision of the matrices as they are stored in the svg trees @@ -180,6 +191,23 @@ class CloneElementTest(TestCase): # Angle goes from 0 -> -30 self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -30) + def test_angle_not_applied_to_non_fills(self): + """Make sure that angle changes are not applied to non-fill elements.""" + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + "style": "stroke: skyblue; fill-opacity: 0;" + })) + use = root.add(Use()) + use.href = rect + use.set('transform', Transform().add_rotate(30)) + + clone = Clone(use) + with clone.clone_elements() as elements: + self.assertEqual(len(elements), 1) # One for the stroke, one for the fill + self.assertIsNone(elements[0].get_param("angle", None)) # Angle as not set, as this isn't a fill + def test_style_inherits(self): root: SvgDocumentElement = svg() rect = root.add(Rectangle(attrib={ @@ -331,6 +359,8 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), 1) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -10) + # Recursive use tests + def test_recursive_uses(self): root: SvgDocumentElement = svg() g1 = root.add(Group()) @@ -440,3 +470,169 @@ class CloneElementTest(TestCase): self.assertEqual(len(elements), 1) # Angle goes from 0 (g -> u2) -> -7 (u3) self.assertAngleAlmostEqual(element_fill_angle(elements[0]), -7) + + # Command clone tests + + def test_copies_directly_attached_commands(self): + """ + Check that commands attached to the clone target directly are applied to clones. + """ + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + + use = root.add(Use()) + use.href = rect + use.set('transform', Transform().add_translate(10, 10)) + + original = FillStitch(rect) + add_commands(original, ["fill_end"]) + + clone = Clone(use) + with clone.clone_elements() as elements: + self.assertEqual(len(elements), 1) + cmd_orig = original.get_command("fill_end") + cmd_clone = elements[0].get_command("fill_end") + self.assertIsNotNone(cmd_clone) + self.assertAlmostEqual(cmd_orig.target_point[0]+10, cmd_clone.target_point[0], 4) + self.assertAlmostEqual(cmd_orig.target_point[1]+10, cmd_clone.target_point[1], 4) + + def test_copies_indirectly_attached_commands(self): + """ + Check that commands attached to children of the clone target are copied to clones. + """ + root: SvgDocumentElement = svg() + group = root.add(Group()) + rect = group.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + + use = root.add(Use()) + use.href = group + use.set('transform', Transform().add_translate(10, 10)) + + original = FillStitch(rect) + add_commands(original, ["fill_end"]) + + clone = Clone(use) + with clone.clone_elements() as elements: + self.assertEqual(len(elements), 1) + cmd_orig = original.get_command("fill_end") + cmd_clone = elements[0].get_command("fill_end") + self.assertIsNotNone(cmd_clone) + self.assertAlmostEqual(cmd_orig.target_point[0]+10, cmd_clone.target_point[0], 4) + self.assertAlmostEqual(cmd_orig.target_point[1]+10, cmd_clone.target_point[1], 4) + + # Checks that trim_after and stop_after commands and settings in cloned elements aren't overridden + + def test_trim_after(self): + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + INKSTITCH_ATTRIBS["trim_after"]: "true" + })) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].trim_after) + + def test_trim_after_command(self): + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + add_commands(FillStitch(rect), ["trim"]) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].trim_after) + + def test_trim_after_command_on_clone(self): + """ + If the clone element has a trim command, it should apply! + """ + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + add_commands(clone, ["trim"]) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].trim_after) + + def test_stop_after(self): + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + INKSTITCH_ATTRIBS["stop_after"]: "true" + })) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].stop_after) + + def test_stop_after_command(self): + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + fill_stitch = FillStitch(rect) + add_commands(fill_stitch, ["stop"]) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].stop_after) + + def test_stop_after_command_on_clone(self): + """ + If the clone element has a stop command, it should still apply! + """ + root: SvgDocumentElement = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + })) + + use = root.add(Use()) + use.href = rect + + clone = Clone(use) + add_commands(clone, ["stop"]) + + stitch_groups = clone.embroider(None) + self.assertGreater(len(stitch_groups), 0) + self.assertTrue(stitch_groups[-1].stop_after) diff --git a/tests/test_lib_svg_svg.py b/tests/test_lib_svg_svg.py new file mode 100644 index 000000000..1d873fb22 --- /dev/null +++ b/tests/test_lib_svg_svg.py @@ -0,0 +1,40 @@ +from lib.svg.svg import point_upwards + +from inkex import Rectangle, Transform, PathElement +from inkex.tester import TestCase +from inkex.tester.svg import svg + + +class LibSvgSvgTest(TestCase): + def test_point_upwards(self): + root = svg() + rect = root.add(Rectangle(attrib={ + "width": "10", + "height": "10", + "x": "10", + "y": "20" + })) + rect.transform = Transform().add_rotate(-45) + + point_upwards(rect) + + self.assertTransformEqual( + rect.transform, + Transform().add_translate(Transform().add_rotate(-45).apply_to_point((10, 20))), + 4 + ) + + def test_point_upwards_mirrored(self): + root = svg() + rect = root.add(PathElement(attrib={ + "d": "M 0,0 L 10,0 0,5 Z", + })) + rect.transform = Transform().add_rotate(-45).add_scale(-1, 1) + + point_upwards(rect) + + self.assertTransformEqual( + rect.transform, + Transform(), + 4 + )