|
| 1 | +# Authors: see git history |
| 2 | +# |
| 3 | +# Copyright (c) 2025 Authors |
| 4 | +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. |
| 5 | + |
| 6 | +from numpy import zeros, convolve, int32, diff, setdiff1d, sign |
| 7 | +from math import degrees, acos |
| 8 | +from ...svg import PIXELS_PER_MM |
| 9 | + |
| 10 | +from ...utils import Point |
| 11 | +from shapely import geometry as shgeo |
| 12 | +from inkex import errormsg |
| 13 | +from ...utils.geometry import remove_duplicate_points |
| 14 | +from shapely.ops import substring |
| 15 | +from shapely.affinity import scale |
| 16 | +from ...i18n import _ |
| 17 | +import sys |
| 18 | + |
| 19 | + |
| 20 | +class SelfIntersectionError(Exception): |
| 21 | + pass |
| 22 | + |
| 23 | + |
| 24 | +def convert_path_to_satin(path, stroke_width, style_args): |
| 25 | + path = remove_duplicate_points(fix_loop(path)) |
| 26 | + |
| 27 | + if len(path) < 2: |
| 28 | + # ignore paths with just one point -- they're not visible to the user anyway |
| 29 | + return None |
| 30 | + |
| 31 | + sections = list(convert_path_to_satins(path, stroke_width, style_args)) |
| 32 | + |
| 33 | + if sections: |
| 34 | + joined_satin = list(sections)[0] |
| 35 | + for satin in sections[1:]: |
| 36 | + joined_satin = merge(joined_satin, satin) |
| 37 | + return joined_satin |
| 38 | + return None |
| 39 | + |
| 40 | + |
| 41 | +def convert_path_to_satins(path, stroke_width, style_args, depth=0): |
| 42 | + try: |
| 43 | + rails, rungs = path_to_satin(path, stroke_width, style_args) |
| 44 | + yield (rails, rungs) |
| 45 | + except SelfIntersectionError: |
| 46 | + # The path intersects itself. Split it in two and try doing the halves |
| 47 | + # individually. |
| 48 | + |
| 49 | + if depth >= 20: |
| 50 | + # At this point we're slicing the path way too small and still |
| 51 | + # getting nowhere. Just give up on this section of the path. |
| 52 | + return |
| 53 | + |
| 54 | + halves = split_path(path) |
| 55 | + |
| 56 | + for path in halves: |
| 57 | + for section in convert_path_to_satins(path, stroke_width, style_args, depth=depth + 1): |
| 58 | + yield section |
| 59 | + |
| 60 | + |
| 61 | +def split_path(path): |
| 62 | + linestring = shgeo.LineString(path) |
| 63 | + halves = [ |
| 64 | + list(substring(linestring, 0, 0.5, normalized=True).coords), |
| 65 | + list(substring(linestring, 0.5, 1, normalized=True).coords), |
| 66 | + ] |
| 67 | + |
| 68 | + return halves |
| 69 | + |
| 70 | + |
| 71 | +def fix_loop(path): |
| 72 | + if path[0] == path[-1] and len(path) > 1: |
| 73 | + first = Point.from_tuple(path[0]) |
| 74 | + second = Point.from_tuple(path[1]) |
| 75 | + midpoint = (first + second) / 2 |
| 76 | + midpoint = midpoint.as_tuple() |
| 77 | + |
| 78 | + return [midpoint] + path[1:] + [path[0], midpoint] |
| 79 | + else: |
| 80 | + return path |
| 81 | + |
| 82 | + |
| 83 | +def path_to_satin(path, stroke_width, style_args): |
| 84 | + if Point(*path[0]).distance(Point(*path[-1])) < 1: |
| 85 | + raise SelfIntersectionError() |
| 86 | + |
| 87 | + path = shgeo.LineString(path) |
| 88 | + distance = stroke_width / 2.0 |
| 89 | + |
| 90 | + try: |
| 91 | + left_rail = path.offset_curve(-distance, **style_args) |
| 92 | + right_rail = path.offset_curve(distance, **style_args) |
| 93 | + except ValueError: |
| 94 | + # TODO: fix this error automatically |
| 95 | + # Error reference: https://github.com/inkstitch/inkstitch/issues/964 |
| 96 | + errormsg(_("Ink/Stitch cannot convert your stroke into a satin column. " |
| 97 | + "Please break up your path and try again.") + '\n') |
| 98 | + sys.exit(1) |
| 99 | + |
| 100 | + if left_rail.geom_type != 'LineString' or right_rail.geom_type != 'LineString': |
| 101 | + # If the offset curve come out as anything but a LineString, that means the |
| 102 | + # path intersects itself, when taking its stroke width into consideration. |
| 103 | + raise SelfIntersectionError() |
| 104 | + |
| 105 | + rungs = generate_rungs(path, stroke_width, left_rail, right_rail) |
| 106 | + |
| 107 | + left_rail = list(left_rail.coords) |
| 108 | + right_rail = list(right_rail.coords) |
| 109 | + |
| 110 | + return (left_rail, right_rail), rungs |
| 111 | + |
| 112 | + |
| 113 | +def get_scores(path): |
| 114 | + """Generate an array of "scores" of the sharpness of corners in a path |
| 115 | +
|
| 116 | + A higher score means that there are sharper corners in that section of |
| 117 | + the path. We'll divide the path into boxes, with the score in each |
| 118 | + box indicating the sharpness of corners at around that percentage of |
| 119 | + the way through the path. For example, if scores[40] is 100 and |
| 120 | + scores[45] is 200, then the path has sharper corners at a spot 45% |
| 121 | + along its length than at a spot 40% along its length. |
| 122 | + """ |
| 123 | + |
| 124 | + # need 101 boxes in order to encompass percentages from 0% to 100% |
| 125 | + scores = zeros(101, int32) |
| 126 | + path_length = path.length |
| 127 | + |
| 128 | + prev_point = None |
| 129 | + prev_direction = None |
| 130 | + length_so_far = 0 |
| 131 | + for point in path.coords: |
| 132 | + point = Point(*point) |
| 133 | + |
| 134 | + if prev_point is None: |
| 135 | + prev_point = point |
| 136 | + continue |
| 137 | + |
| 138 | + direction = (point - prev_point).unit() |
| 139 | + |
| 140 | + if prev_direction is not None: |
| 141 | + # The dot product of two vectors is |v1| * |v2| * cos(angle). |
| 142 | + # These are unit vectors, so their magnitudes are 1. |
| 143 | + cos_angle_between = prev_direction * direction |
| 144 | + |
| 145 | + # Clamp to the valid range for a cosine. The above _should_ |
| 146 | + # already be in this range, but floating point inaccuracy can |
| 147 | + # push it outside the range causing math.acos to throw |
| 148 | + # ValueError ("math domain error"). |
| 149 | + cos_angle_between = max(-1.0, min(1.0, cos_angle_between)) |
| 150 | + |
| 151 | + angle = abs(degrees(acos(cos_angle_between))) |
| 152 | + |
| 153 | + # Use the square of the angle, measured in degrees. |
| 154 | + # |
| 155 | + # Why the square? This penalizes bigger angles more than |
| 156 | + # smaller ones. |
| 157 | + # |
| 158 | + # Why degrees? This is kind of arbitrary but allows us to |
| 159 | + # use integer math effectively and avoid taking the square |
| 160 | + # of a fraction between 0 and 1. |
| 161 | + scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2 |
| 162 | + |
| 163 | + length_so_far += (point - prev_point).length() |
| 164 | + prev_direction = direction |
| 165 | + prev_point = point |
| 166 | + |
| 167 | + return scores |
| 168 | + |
| 169 | + |
| 170 | +def local_minima(array): |
| 171 | + # from: https://stackoverflow.com/a/9667121/4249120 |
| 172 | + # This finds spots where the curvature (second derivative) is > 0. |
| 173 | + # |
| 174 | + # This method has the convenient benefit of choosing points around |
| 175 | + # 5% before and after a sharp corner such as in a square. |
| 176 | + return (diff(sign(diff(array))) > 0).nonzero()[0] + 1 |
| 177 | + |
| 178 | + |
| 179 | +def generate_rungs(path, stroke_width, left_rail, right_rail): |
| 180 | + """Create rungs for a satin column. |
| 181 | +
|
| 182 | + Where should we put the rungs along a path? We want to ensure that the |
| 183 | + resulting satin matches the original path as closely as possible. We |
| 184 | + want to avoid having a ton of rungs that will annoy the user. We want |
| 185 | + to ensure that the rungs we choose actually intersect both rails. |
| 186 | +
|
| 187 | + We'll place a few rungs perpendicular to the tangent of the path. |
| 188 | + Things get pretty tricky at sharp corners. If we naively place a rung |
| 189 | + perpendicular to the path just on either side of a sharp corner, the |
| 190 | + rung may not intersect both paths: |
| 191 | + | | |
| 192 | + _______________| | |
| 193 | + ______|_ |
| 194 | + ____________________| |
| 195 | +
|
| 196 | + It'd be best to place rungs in the straight sections before and after |
| 197 | + the sharp corner and allow the satin column to bend the stitches around |
| 198 | + the corner automatically. |
| 199 | +
|
| 200 | + How can we find those spots? |
| 201 | +
|
| 202 | + The general algorithm below is: |
| 203 | +
|
| 204 | + * assign a "score" to each section of the path based on how sharp its |
| 205 | + corners are (higher means a sharper corner) |
| 206 | + * pick spots with lower scores |
| 207 | + """ |
| 208 | + |
| 209 | + scores = get_scores(path) |
| 210 | + |
| 211 | + # This is kind of like a 1-dimensional gaussian blur filter. We want to |
| 212 | + # avoid the area near a sharp corner, so we spread out its effect for |
| 213 | + # 5 buckets in either direction. |
| 214 | + scores = convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same') |
| 215 | + |
| 216 | + # Now we'll find the spots that aren't near corners, whose scores are |
| 217 | + # low -- the local minima. |
| 218 | + rung_locations = local_minima(scores) |
| 219 | + |
| 220 | + # Remove the start and end, because we can't stick a rung there. |
| 221 | + rung_locations = setdiff1d(rung_locations, [0, 100]) |
| 222 | + |
| 223 | + if len(rung_locations) == 0: |
| 224 | + # Straight lines won't have local minima, so add a rung in the center. |
| 225 | + rung_locations = [50] |
| 226 | + |
| 227 | + rungs = [] |
| 228 | + last_rung_center = None |
| 229 | + |
| 230 | + for location in rung_locations: |
| 231 | + # Convert percentage to a fraction so that we can use interpolate's |
| 232 | + # normalized parameter. |
| 233 | + location = location / 100.0 |
| 234 | + |
| 235 | + rung_center = path.interpolate(location, normalized=True) |
| 236 | + rung_center = Point(rung_center.x, rung_center.y) |
| 237 | + |
| 238 | + # Avoid placing rungs too close together. This somewhat |
| 239 | + # arbitrarily rejects the rung if there was one less than 2 |
| 240 | + # millimeters before this one. |
| 241 | + if last_rung_center is not None and \ |
| 242 | + (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM: |
| 243 | + continue |
| 244 | + else: |
| 245 | + last_rung_center = rung_center |
| 246 | + |
| 247 | + # We need to know the tangent of the path's curve at this point. |
| 248 | + # Pick another point just after this one and subtract them to |
| 249 | + # approximate a tangent vector. |
| 250 | + tangent_end = path.interpolate(location + 0.001, normalized=True) |
| 251 | + tangent_end = Point(tangent_end.x, tangent_end.y) |
| 252 | + tangent = (tangent_end - rung_center).unit() |
| 253 | + |
| 254 | + # Rotate 90 degrees left to make a normal vector. |
| 255 | + normal = tangent.rotate_left() |
| 256 | + |
| 257 | + # Extend the rungs by an offset value to make sure they will cross the rails |
| 258 | + offset = normal * (stroke_width / 2) * 1.2 |
| 259 | + rung_start = rung_center + offset |
| 260 | + rung_end = rung_center - offset |
| 261 | + |
| 262 | + rung_tuple = (rung_start.as_tuple(), rung_end.as_tuple()) |
| 263 | + rung_linestring = shgeo.LineString(rung_tuple) |
| 264 | + if (isinstance(rung_linestring.intersection(left_rail), shgeo.Point) and |
| 265 | + isinstance(rung_linestring.intersection(right_rail), shgeo.Point)): |
| 266 | + rungs.append(rung_tuple) |
| 267 | + |
| 268 | + return rungs |
| 269 | + |
| 270 | + |
| 271 | +def merge(section, other_section): |
| 272 | + """Merge this satin with another satin |
| 273 | +
|
| 274 | + This method expects that the provided satin continues on directly after |
| 275 | + this one, as would be the case, for example, if the two satins were the |
| 276 | + result of the split() method. |
| 277 | +
|
| 278 | + Returns a new SatinColumn instance that combines the rails and rungs of |
| 279 | + this satin and the provided satin. A rung is added at the end of this |
| 280 | + satin. |
| 281 | +
|
| 282 | + The returned SatinColumn will not be in the SVG document and will have |
| 283 | + its transforms applied. |
| 284 | + """ |
| 285 | + rails, rungs = section |
| 286 | + other_rails, other_rungs = other_section |
| 287 | + |
| 288 | + if len(rails) != 2 or len(other_rails) != 2: |
| 289 | + # weird non-satin things, give up and don't merge |
| 290 | + return section |
| 291 | + |
| 292 | + # remove first node of each other rail before merging (avoid duplicated nodes) |
| 293 | + rails[0].extend(other_rails[0][1:]) |
| 294 | + rails[1].extend(other_rails[1][1:]) |
| 295 | + |
| 296 | + # add a rung in between the two satins and extend it just a litte to ensure it is crossing the rails |
| 297 | + new_rung = shgeo.LineString([other_rails[0][0], other_rails[1][0]]) |
| 298 | + rungs.append(list(scale(new_rung, 1.2, 1.2).coords)) |
| 299 | + |
| 300 | + # add on the other satin's rungs |
| 301 | + rungs.extend(other_rungs) |
| 302 | + |
| 303 | + return (rails, rungs) |
0 commit comments