-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathgcoder.py
More file actions
2319 lines (1797 loc) · 82.9 KB
/
Copy pathgcoder.py
File metadata and controls
2319 lines (1797 loc) · 82.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#
# gcoder.py - python library for writing g-code
#
# Copyright (C) 2018-2020 Sebastian Kuzminsky
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
from __future__ import print_function
import math
import os
import re
import sys
import numpy
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'svgpathtools'))
import svgpathtools
class line(object):
"""The Line class represents a linear feed move (g1) to the specified
endpoint.
It can be used as an element in the list of moves passed to
z_path2()."""
def __init__(self, x=None, y=None, z=None):
self.x = x
self.y = y
self.z = z
def __str__(self):
have_arg = False
r = "Line("
if self.x is not None:
r += "x=%.4f" % self.x
have_arg = True
if self.y is not None:
if have_arg:
r += ", "
r += "y=%.4f" % self.y
have_arg = True
if self.z is not None:
if have_arg:
r += ", "
r += "z=%.4f" % self.z
have_arg = True
r += ")"
return r
class arc(object):
"""arc() is a base class representing a circular feed move (g2 or g3)
to the specified endpoint. It is not suitable for use by itself, you
should use one of its subclasses, arc_cw() or arc_ccw(), instead."""
def __init__(self, x=None, y=None, z=None, i=None, j=None, p=None):
self.x = x
self.y = y
self.z = z
self.i = i
self.j = j
self.p = p
def __str__(self):
have_arg = False
r = self.__class__.__name__ + "("
if self.x is not None:
r += "x=%.4f" % self.x
have_arg = True
if self.y is not None:
if have_arg:
r += ", "
r += "y=%.4f" % self.y
have_arg = True
if self.z is not None:
if have_arg:
r += ", "
r += "z=%.4f" % self.z
have_arg = True
if self.i is not None:
if have_arg:
r += ", "
r += "i=%.4f" % self.i
have_arg = True
if self.j is not None:
if have_arg:
r += ", "
r += "j=%.4f" % self.j
have_arg = True
if self.p is not None:
if have_arg:
r += ", "
r += "p=%.4f" % self.p
have_arg = True
r += ")"
return r
class arc_cw(arc):
"""The arc_cw() class represents a circular clockwise feed move (g2)
to the specified endpoint.
It can be used as an element in the list of moves passed to
z_path2()."""
class arc_ccw(arc):
"""The arc_ccw() class represents a circular counter-clockwise feed
move (g3) to the specified endpoint.
It can be used as an element in the list of moves passed to
z_path2()."""
class svg():
GCODE_ORIGIN_IS_VIEWBOX_LOWER_LEFT=0
GCODE_ORIGIN_IS_SVG_ORIGIN=1
def __init__(self, svg_file, gcode_origin=GCODE_ORIGIN_IS_VIEWBOX_LOWER_LEFT):
self.svg_file = svg_file
self.gcode_origin = gcode_origin
print("gcode_origin:", gcode_origin, file=sys.stderr)
#
# We need to convert from whatever units the SVG input file
# (self) is in, to mm for the output gcode.
#
# The SVG spec lets SVG files program paths in arbitrary "user"
# units. The conversion from user units to mm is done as follows:
#
# The SVG file specifies its width and height in one of the
# accepted "real-world" units, all of which can be converted
# to mm with a straight-forward linear scale (see the viewport
# handling below).
#
# The SVG file *may* specify a viewBox.
#
# If no viewBox is specified, the viewport units are used for
# the user units.
#
# If a viewBox is specified, it provides the (X, Y) coordinates
# (in user units) of the lower left corner of the viewport and
# the width and height (again in user units) of the viewport.
#
# From all this we compute an x_scale and y_scale, such that:
#
# x_gcode(mm) = x_svg * x_scale
# y_gcode(mm) = y_svg * y_scale
#
# (This ignores the fact that SVG specifies Y upside-down compared
# to gcode, and that the viewBox can be offset from the origin,
# see svg.x_to_mm() and svg.y_to_mm() below.)
#
self.doc = svgpathtools.Document(self.svg_file)
self.paths = []
for path in self.doc.paths():
for continuous_path in path.continuous_subpaths():
self.paths.append(continuous_path)
self.svg_attributes = self.doc.root.attrib
#
# Deal with the viewport.
#
val, units, scale = self._parse_height_width(self.svg_attributes['height'])
self.viewport_height = val
self.viewport_y_units = units
self.x_scale = scale
val, units, scale = self._parse_height_width(self.svg_attributes['width'])
self.viewport_width = val
self.viewport_x_units = units
self.y_scale = scale
print("svg viewport:", file=sys.stderr)
print(" width: %.3f%s" % (self.viewport_width, self.viewport_x_units), file=sys.stderr)
print(" height: %.3f%s" % (self.viewport_height, self.viewport_y_units), file=sys.stderr)
#
# Deal with the viewBox.
#
if 'viewBox' in self.svg_attributes:
print("svg viewBox:", self.svg_attributes['viewBox'], file=sys.stderr)
(x_min, y_min, width, height) = re.split(',|(?: +(?:, *)?)', self.svg_attributes['viewBox'])
self.viewBox_x = float(x_min)
self.viewBox_y = float(y_min)
self.viewBox_width = float(width)
self.viewBox_height = float(height)
self.x_scale *= self.viewport_width / self.viewBox_width
self.y_scale *= self.viewport_height / self.viewBox_height
print(" viewBox_x:", self.viewBox_x, file=sys.stderr)
print(" viewBox_y:", self.viewBox_y, file=sys.stderr)
print(" viewBox_width:", self.viewBox_width, file=sys.stderr)
print(" viewBox_height:", self.viewBox_height, file=sys.stderr)
print(" x_scale:", self.x_scale, file=sys.stderr)
print(" y_scale:", self.y_scale, file=sys.stderr)
else:
self.viewBox_x = 0.0
self.viewBox_y = 0.0
self.viewBox_width = self.viewport_width
self.viewBox_height = self.viewport_height
def _parse_height_width(self, s):
m = re.match('^([0-9.]+)([a-zA-Z]*)$', s)
if m == None or len(m.groups()) != 2:
raise SystemExit("failed to parse SVG viewport height/width: %s" % s)
val = float(m.group(1))
unit = m.group(2)
units = {
# "px" (or "no units") is 96 dpi: 1 inch/96 px * 25.4 mm/1 inch = 25.4/96 mm/px
'': 25.4/96,
'px': 25.4/96,
# "pt" is 72 dpi: 1 inch/72 pt * 25.4 mm/1 inch = 25.4/72 mm/pt
'pt': 25.4/72,
# Units are Picas "pc", 6 dpi: 1 inch/6 pc * 25.4 mm/1 inch = 25.4/6 mm/pc
'pc': 25.4/6,
'cm': 10.0,
'mm': 1.0,
# Units are inches: 25.4 mm/1 inch
'in': 25.4
}
if unit not in units:
raise SystemExit("unknwn SVG viewport units: '%s'" % unit)
scale = units[unit]
return (val, unit, scale)
def x_to_mm(self, x):
if type(x) not in [ float, numpy.float64 ]:
raise SystemExit(f"non-float input, it's {type(x)}")
if self.gcode_origin == self.GCODE_ORIGIN_IS_SVG_ORIGIN:
out = x * self.x_scale
elif self.gcode_origin == self.GCODE_ORIGIN_IS_VIEWBOX_LOWER_LEFT:
out = (x - self.viewBox_x) * self.x_scale
return out
def y_to_mm(self, y):
if type(y) not in [ float, numpy.float64 ]:
raise SystemExit(f"non-float input, it's {type(y)}")
# Y is upside down in SVG.
if self.gcode_origin == self.GCODE_ORIGIN_IS_SVG_ORIGIN:
out = -y * self.y_scale
elif self.gcode_origin == self.GCODE_ORIGIN_IS_VIEWBOX_LOWER_LEFT:
out = (self.viewBox_height - (y - self.viewBox_y)) * self.y_scale
return out
def xy_to_mm(self, xy):
if type(xy) not in [ complex, numpy.complex128 ]:
raise SystemExit(f"non-complex input, it's {type(xy)}")
x = self.x_to_mm(xy.real)
y = self.y_to_mm(xy.imag)
return (x, y)
def close_path(p):
def join_segments(this_seg, next_seg):
if this_seg.end != next_seg.start:
if close_enough(this_seg.end, next_seg.start):
avg = (this_seg.end + next_seg.start) / 2.0
this_seg.end = avg
next_seg.start = avg
else:
raise ValueError("segments are not even close to closed: %s, %s" % (this_seg, next_seg))
return (this_seg, next_seg)
for i in range(len(p)-1):
this_seg = p[i]
next_seg = p[i+1]
(this_seg, next_seg) = join_segments(this_seg, next_seg)
p[i] = this_seg
p[i+1] = next_seg
this_seg = p[-1]
next_seg = p[0]
(this_seg, next_seg) = join_segments(this_seg, next_seg)
p[-1] = this_seg
p[0] = next_seg
p.closed = True
return p
def split_path_at_intersections(path_list, debug=False):
"""`path_list` is a list of connected path segments, or a
svgpathtools.path.Path() object. This function identifies
each place where the path intersects intself, and splits each
non-self-intersecting subset of the path into a separate path list.
This may involve splitting segments.
Returns a list of path lists."""
# If path_list is a Path object, convert it to a regular list (of
# segments), because it's easier to work with.
if type(path_list) == svgpathtools.path.Path:
l = []
for i in range(len(path_list)):
l.append(path_list[i])
path_list = l
def find_earliest_intersection(path_list, this_seg_index):
this_seg = path_list[this_seg_index]
if debug: print("looking for earliest intersection of this seg(%d):" % this_seg_index, this_seg, file=sys.stderr)
earliest_this_t = None
earliest_other_seg_index = None
earliest_other_t = None
for other_seg_index in range(this_seg_index+2, len(path_list)):
other_seg = path_list[other_seg_index]
if debug: print(" other[%d]:" % other_seg_index, other_seg, file=sys.stderr)
intersections = this_seg.intersect(other_seg)
if len(intersections) == 0:
continue
if debug: print(" intersect! %s" % intersections, file=sys.stderr)
# The intersection that comes earliest in `this_seg` is
# the interesting one, except that intersections at the
# segments' endpoints don't count.
for intersection in intersections:
if close_enough(intersection[0], 0.0) or close_enough(intersection[0], 1.0):
if debug: print(" at end of this segment, ignoring", file=sys.stderr)
continue
if intersection[0] > 1.0 or intersection[0] < 0.0:
if debug: print(" off the end of this segment?! ignoring", file=sys.stderr)
continue
if (earliest_this_t == None) or (intersection[0] < earliest_this_t):
if debug: print(" earliest!", file=sys.stderr)
earliest_this_t = intersection[0]
earliest_other_seg_index = other_seg_index
earliest_other_t = intersection[1]
return earliest_this_t, earliest_other_seg_index, earliest_other_t
if debug: print("splitting path:", file=sys.stderr)
if debug: print(" ", path_list, file=sys.stderr)
# This is a list of pairs. Each pair represents a place where the
# input path crosses itself. The two members of the pair are the
# indexes of the segments that end at the intersection point.
intersections = []
this_seg_index = 0
while this_seg_index < len(path_list):
this_seg = path_list[this_seg_index]
this_t, other_seg_index, other_t = find_earliest_intersection(path_list, this_seg_index)
if this_t == None:
this_seg_index += 1
continue
other_seg = path_list[other_seg_index]
# Found the next intersection. Split the segments and note
# the intersection.
if debug: print("intersection:", file=sys.stderr)
if debug: print(" this:", file=sys.stderr)
if debug: print(" %d @ %f" % (this_seg_index, this_t), file=sys.stderr)
if debug: print(" %s" % this_seg, file=sys.stderr)
if debug: print(" other:", file=sys.stderr)
if debug: print(" %d @ %f" % (other_seg_index, other_t), file=sys.stderr)
if debug: print(" %s" % other_seg, file=sys.stderr)
this_first_seg, this_second_seg = this_seg.split(this_t)
other_first_seg, other_second_seg = other_seg.split(other_t)
if debug: print("split this seg:", this_seg, file=sys.stderr)
if debug: print(" t:", this_t, file=sys.stderr)
if debug: print(" ", this_first_seg, file=sys.stderr)
if debug: print(" ", this_second_seg, file=sys.stderr)
if debug: print("split other seg:", other_seg, file=sys.stderr)
if debug: print(" t:", other_t, file=sys.stderr)
if debug: print(" ", other_first_seg, file=sys.stderr)
if debug: print(" ", other_second_seg, file=sys.stderr)
# FIXME: This fixup is bogus, but the two segments'
# `t` parameters don't put the intersection at the
# same point...
other_first_seg.end = this_first_seg.end
other_second_seg.start = other_first_seg.end
assert(close_enough(this_first_seg.end, this_second_seg.start))
assert(close_enough(this_first_seg.end, other_first_seg.end))
assert(close_enough(this_first_seg.end, other_second_seg.start))
assert(close_enough(this_first_seg.start, this_seg.start))
assert(close_enough(this_second_seg.end, this_seg.end))
assert(close_enough(other_first_seg.start, other_seg.start))
assert(close_enough(other_second_seg.end, other_seg.end))
# Replace the old (pre-split) this_seg with the first sub-segment.
path_list[this_seg_index] = this_first_seg
# Insert the second sub-segment after the first one.
path_list.insert(this_seg_index+1, this_second_seg)
# We inserted a segment before other_seg, so we increment
# its index.
other_seg_index += 1
# Replace the old (pre-split) other_seg with the first sub-segment.
path_list[other_seg_index] = other_first_seg
# Insert the second sub-segment after the first one.
path_list.insert(other_seg_index+1, other_second_seg)
for i in range(len(intersections)):
if debug: print("bumping intersection:", file=sys.stderr)
if debug: print(" ", intersections[i], file=sys.stderr)
if intersections[i][1] >= this_seg_index:
intersections[i][1] += 1 # for this_seg that got split
if intersections[i][1] >= other_seg_index:
intersections[i][1] += 1 # for other_seg that got split
if debug: print(" ", intersections[i], file=sys.stderr)
# Add this new intersection we just made.
i = [this_seg_index, other_seg_index]
if debug: print(" new:", i, file=sys.stderr)
intersections.append(i)
# Look for intersections in the remainder of this_seg (the second
# part of the split).
this_seg_index += 1
if len(intersections) > 0:
if debug: print("found some intersections:", file=sys.stderr)
for i in intersections:
if debug: print(" ", i, file=sys.stderr)
if debug: print(" ", path_list[i[0]], file=sys.stderr)
if debug: print(" ", path_list[i[1]], file=sys.stderr)
else:
if debug: print("path does not self-intersect", file=sys.stderr)
paths = []
while True:
if debug: print("starting a new path", file=sys.stderr)
path = []
# Start at the first unused segment
seg_index = 0
for seg_index in range(len(path_list)):
if path_list[seg_index] != None:
break
while seg_index < len(path_list):
if path_list[seg_index] == None:
# Done with this path.
break
if debug: print(" adding segment %d:" % seg_index, path_list[seg_index], file=sys.stderr)
path.append(path_list[seg_index])
path_list[seg_index] = None
i = None
for i in intersections:
if seg_index == i[0] or seg_index == i[1]:
break
if debug: print("i:", i, file=sys.stderr)
if debug: print("seg_index:", seg_index, file=sys.stderr)
if (i is not None) and (i[0] == seg_index):
# This segment is the first entrance to an intersection,
# take the second exit.
if debug: print(" intersection!", file=sys.stderr)
seg_index = i[1] + 1
elif (i is not None) and (i[1] == seg_index):
# This segment is the second entrance to an intersection,
# take the first exit.
if debug: print(" intersection!", file=sys.stderr)
seg_index = i[0] + 1
else:
# This segment doesn't end in an intersection, just go
# to the next one.
seg_index += 1
if path == []:
break
paths.append(path)
return paths
def approximate_path_area(path):
"""Approximates the path area by converting each Arc to 1,000
Lines."""
assert(path.isclosed())
assert(path.iscontinuous())
tmp = svgpathtools.path.Path()
for seg in path:
if type(seg) == svgpathtools.path.Arc:
p0 = seg.start
for i in range(1, 1000):
t1 = i/1000.0
p1 = seg.point(t1)
l = svgpathtools.path.Line(start=p0, end=p1)
tmp.append(l)
p0 = p1
l = svgpathtools.path.Line(start=p0, end=seg.end)
tmp.append(l)
else:
tmp.append(seg)
return tmp.area()
def offset_paths(path, offset_distance, steps=100, debug=False):
"""Takes an svgpathtools.path.Path object, `path`, and a float
distance, `offset_distance`, and returns the parallel offset curves
(in the form of a list of svgpathtools.path.Path objects)."""
def is_enclosed(path, check_paths):
"""`path` is an svgpathtools.path.Path object, `check_paths`
is a list of svgpath.path.Path objects. This function returns
True if `path` lies inside any of the paths in `check_paths`,
and returns False if it lies outside all of them."""
seg = path[0]
point = seg.point(0.5)
for i in range(len(check_paths)):
test_path = check_paths[i]
if path == test_path:
continue
# find outside_point, which lies outside other_path
(xmin, xmax, ymin, ymax) = test_path.bbox()
outside_point = complex(xmax+100, ymax+100)
if svgpathtools.path_encloses_pt(point, outside_point, test_path):
if debug: print("point is within path", i, file=sys.stderr)
return True
return False
def intersect(this_seg, next_seg, intersection):
this_point = this_seg.point(intersection[0])
next_point = next_seg.point(intersection[1])
if debug:
print(f" this_seg: {this_seg}", file=sys.stderr)
print(f" next_seg: {next_seg}", file=sys.stderr)
print(f" intersection: {intersection} {this_point} {next_point}", file=sys.stderr)
if close_enough(intersection[0], 0.0) and close_enough(intersection[1], 1.0):
# Start of `this_seg` touches end of `next_seg`, that's ok.
point = (this_point + next_point) / 2
this_seg.start = point
next_seg.end = point
# If you change an Arc you have to re-parameterize it.
if type(this_seg) is svgpathtools.path.Arc:
this_seg._parameterize()
if type(next_seg) is svgpathtools.path.Arc:
next_seg._parameterize()
elif close_enough(intersection[0], 1.0) and close_enough(intersection[1], 0.0):
# End of `this_seg` touches start of `next_seg`, that's ok.
point = (this_point + next_point) / 2
this_seg.end = point
next_seg.start = point
# If you change an Arc you have to re-parameterize it.
if type(this_seg) is svgpathtools.path.Arc:
this_seg._parameterize()
if type(next_seg) is svgpathtools.path.Arc:
next_seg._parameterize()
else:
# Trim the end off `this_seg` and the start off `next_seg`
# so they meet at their intersection.
this_seg = this_seg.cropped(0.0, intersection[0])
next_seg = next_seg.cropped(intersection[1], 1.0)
def remove_connected_zero_length_segments(path_list):
if debug: print("removing too-short connected segments...", file=sys.stderr)
new_path_list = []
for i in range(len(path_list)):
prev_seg = path_list[i-1]
this_seg = path_list[i]
next_seg = path_list[(i+1) % len(path_list)]
if close_enough(prev_seg.end, this_seg.start) and close_enough(this_seg.end, next_seg.start) and this_seg.length() < epsilon:
if debug: print(f"removing {this_seg.length()} long segment: {this_seg}", file=sys.stderr)
midpoint = (prev_seg.end + next_seg.start) / 2
prev_seg.end = midpoint
next_seg.start = midpoint
continue
new_path_list.append(this_seg)
return new_path_list
# This only works on closed paths.
if debug: print("input path:", file=sys.stderr)
if debug: print(path, file=sys.stderr)
if debug: print("offset:", offset_distance, file=sys.stderr)
assert(path.isclosed())
#
# First generate a list of Path elements (Lines and Arcs),
# corresponding to the offset versions of the Path elements in the
# input path.
#
if debug: print("generating offset segments...", file=sys.stderr)
offset_path_list = []
for seg in path:
if type(seg) == svgpathtools.path.Line:
if close_enough(seg.point(0), seg.point(1)):
if debug: print(" skipping zero-length line segment", file=sys.stderr)
continue
start = seg.point(0) + (offset_distance * seg.normal(0))
end = seg.point(1) + (offset_distance * seg.normal(1))
seg = svgpathtools.Line(start, end)
offset_path_list.append(seg)
if debug: print(" %s" % offset_path_list[-1], file=sys.stderr)
elif type(seg) == svgpathtools.path.Arc and (seg.radius.real == seg.radius.imag):
# Circular arcs remain arcs, elliptical arcs become linear
# approximations below.
#
# Polygons (input paths) are counter-clockwise.
#
# Positive offsets are to the inside of the polygon, negative
# offsets are to the outside.
#
# If this arc is counter-clockwise (sweep == False),
# *subtract* the `offset_distance` from its radius, so
# insetting makes the arc smaller and outsetting makes
# it larger.
#
# If this arc is clockwise (sweep == True), *add* the
# `offset_distance` from its radius, so insetting makes the
# arc larger and outsetting makes it smaller.
#
# If the radius of the offset arc is negative, use its
# absolute value and invert the sweep.
if seg.sweep == False:
new_radius = seg.radius.real - offset_distance
else:
new_radius = seg.radius.real + offset_distance
start = seg.start + (offset_distance * seg.normal(0))
end = seg.end + (offset_distance * seg.normal(1))
sweep = seg.sweep
flipped = False
if new_radius < 0.0:
if debug: print(" inverting Arc!", file=sys.stderr)
flipped = True
new_radius = abs(new_radius)
sweep = not sweep
if new_radius > minimum_arc_radius:
radius = complex(new_radius, new_radius)
offset_arc = svgpathtools.path.Arc(
start = start,
end = end,
radius = radius,
rotation = seg.rotation,
large_arc = seg.large_arc,
sweep = sweep
)
else:
# Offset Arc radius is smaller than the minimum that
# LinuxCNC accepts, replace with a Line.
if debug: print(" arc too small, replacing with a line", file=sys.stderr)
if flipped:
old_start = start
start = end
end = old_start
offset_arc = svgpathtools.path.Line(start = start, end = end)
offset_path_list.append(offset_arc)
if debug: print(" %s" % offset_path_list[-1], file=sys.stderr)
else:
# Deal with any segment that's not a line or a circular arc.
# This includes elliptic arcs and bezier curves. Use linear
# approximation.
#
# FIXME: Steps should probably be computed dynamically to make
# the length of the *offset* line segments manageable.
points = []
for k in range(steps+1):
t = k / float(steps)
normal = seg.normal(t)
offset_vector = offset_distance * normal
points.append(seg.point(t) + offset_vector)
for k in range(len(points)-1):
start = points[k]
end = points[k+1]
seg = svgpathtools.Line(start, end)
offset_path_list.append(seg)
if debug: print(" (long list of short lines)", file=sys.stderr)
offset_path_list = remove_connected_zero_length_segments(offset_path_list)
#
# Find all the places where one segment intersects the next, and
# trim to the intersection.
#
if debug: print("trimming intersecting segments...", file=sys.stderr)
if len(offset_path_list) == 2:
i = 0
this_seg = offset_path_list[i]
next_i = 1
next_seg = offset_path_list[next_i]
if debug: print("intersecting 2-segment path", file=sys.stderr)
if debug: print(" this", this_seg, file=sys.stderr)
if debug: print(" length", this_seg.length(), file=sys.stderr)
if debug: print(" next", next_seg, file=sys.stderr)
if debug: print(" length", next_seg.length(), file=sys.stderr)
intersections = this_seg.intersect(next_seg)
if debug: print(" intersections:", intersections, file=sys.stderr)
for intersection in intersections:
intersect(this_seg, next_seg, intersection)
offset_path_list[i] = this_seg
offset_path_list[next_i] = next_seg
if debug: print(" trimmed:", file=sys.stderr)
if debug: print(" this", this_seg, file=sys.stderr)
if debug: print(" length", this_seg.length(), file=sys.stderr)
if debug: print(" next", next_seg, file=sys.stderr)
if debug: print(" length", next_seg.length(), file=sys.stderr)
else:
for i in range(len(offset_path_list)):
this_seg = offset_path_list[i]
next_i = (i + 1) % len(offset_path_list)
next_seg = offset_path_list[next_i]
if debug: print("intersecting", file=sys.stderr)
if debug: print(" this", this_seg, file=sys.stderr)
if debug: print(" length", this_seg.length(), file=sys.stderr)
if debug: print(" next", next_seg, file=sys.stderr)
if debug: print(" length", next_seg.length(), file=sys.stderr)
intersections = this_seg.intersect(next_seg)
if debug: print(" intersections:", intersections, file=sys.stderr)
if len(intersections) > 0:
intersection = intersections[0]
if debug:
this_point = this_seg.point(intersections[0][0])
next_point = next_seg.point(intersections[0][1])
print(" first intersection: {} {} {}".format(intersection, this_point, next_point), file=sys.stderr)
# Trim the end off `this_seg` and the start off `next_seg`
# so they meet at their intersection.
this_seg = this_seg.cropped(0.0, intersection[0])
next_seg = next_seg.cropped(intersection[1], 1.0)
offset_path_list[i] = this_seg
offset_path_list[next_i] = next_seg
if debug: print(" trimmed:", file=sys.stderr)
if debug: print(" this", this_seg, file=sys.stderr)
if debug: print(" length", this_seg.length(), file=sys.stderr)
if debug: print(" next", next_seg, file=sys.stderr)
if debug: print(" length", next_seg.length(), file=sys.stderr)
offset_path_list = remove_connected_zero_length_segments(offset_path_list)
#
# Find all the places where adjacent segments do not end/start close
# to each other, and join them with Arcs.
#
if debug: print("joining non-connecting segments with arcs...", file=sys.stderr)
joined_offset_path_list = []
for i in range(len(offset_path_list)):
this_seg = offset_path_list[i]
if (i+1) < len(offset_path_list):
next_seg = offset_path_list[i+1]
else:
next_seg = offset_path_list[0]
if close_enough(this_seg.end, next_seg.start):
joined_offset_path_list.append(this_seg)
continue
if debug: print("these segments don't touch end to end:", file=sys.stderr)
if debug: print(" this", this_seg, file=sys.stderr)
if debug: print(" next", next_seg, file=sys.stderr)
if debug: print(" error: %s (%.7f)" % (this_seg.end-next_seg.start, abs(this_seg.end-next_seg.start)), file=sys.stderr)
# FIXME: Choose values for `large_arc` and `sweep` correctly here.
# I think the goal is to make the joining arc tangent to the segments it joins.
# large_arc should always be False
# sweep means "clockwise" (but +Y is down)
if debug: print("determining joining arc:", file=sys.stderr)
if debug: print(" this_seg ending normal:", this_seg.normal(1), file=sys.stderr)
if debug: print(" next_seg starting normal:", next_seg.normal(0), file=sys.stderr)
sweep_arc = svgpathtools.path.Arc(
start = this_seg.end,
end = next_seg.start,
radius = complex(offset_distance, offset_distance),
rotation = 0,
large_arc = False,
sweep = True
)
sweep_start_error = this_seg.normal(1) - sweep_arc.normal(0)
sweep_end_error = next_seg.normal(0) - sweep_arc.normal(1)
sweep_error = pow(abs(sweep_start_error), 2) + pow(abs(sweep_end_error), 2)
if debug: print(" sweep arc starting normal:", sweep_arc.normal(0), file=sys.stderr)
if debug: print(" sweep arc ending normal:", sweep_arc.normal(1), file=sys.stderr)
if debug: print(" sweep starting error:", sweep_start_error, file=sys.stderr)
if debug: print(" sweep end error:", sweep_end_error, file=sys.stderr)
if debug: print(" sweep error:", sweep_error, file=sys.stderr)
antisweep_arc = svgpathtools.path.Arc(
start = this_seg.end,
end = next_seg.start,
radius = complex(offset_distance, offset_distance),
rotation = 0,
large_arc = False,
sweep = False
)
antisweep_start_error = this_seg.normal(1) - antisweep_arc.normal(0)
antisweep_end_error = next_seg.normal(0) - antisweep_arc.normal(1)
antisweep_error = pow(abs(antisweep_start_error), 2) + pow(abs(antisweep_end_error), 2)
if debug: print(" antisweep arc starting normal:", antisweep_arc.normal(0), file=sys.stderr)
if debug: print(" antisweep arc ending normal:", antisweep_arc.normal(1), file=sys.stderr)
if debug: print(" antisweep starting error:", antisweep_start_error, file=sys.stderr)
if debug: print(" antisweep end error:", antisweep_end_error, file=sys.stderr)
if debug: print(" antisweep error:", antisweep_error, file=sys.stderr)
joining_arc = None
if sweep_error < antisweep_error:
if debug: print("joining arc is sweep", file=sys.stderr)
joining_arc = sweep_arc
else:
if debug: print("joining arc is antisweep", file=sys.stderr)
joining_arc = antisweep_arc
if debug: print("joining arc:", file=sys.stderr)
if debug: print(joining_arc, file=sys.stderr)
if debug: print(" length:", joining_arc.length(), file=sys.stderr)
if debug: print(" start-end distance:", joining_arc.start-joining_arc.end, file=sys.stderr)
# FIXME: this is kind of arbitrary
# FIXME: we should really just drop any segment that doesn't move
# the controlled point enough that the current coordinates
# change. That's currently 0.0001mm, but should probably
# be configurable.
joining_seg = joining_arc
if joining_arc.length() < 1e-4:
joining_seg = svgpathtools.path.Line(joining_arc.start, joining_arc.end)
if debug: print(" too short! replacing with a line:", joining_seg, file=sys.stderr)
joined_offset_path_list.append(this_seg)
joined_offset_path_list.append(joining_seg)
offset_path_list = joined_offset_path_list
#
# Find the places where the path intersects itself, split into
# multiple separate paths in those places.
#
if debug: print("splitting path at intersections...", file=sys.stderr)
offset_paths_list = split_path_at_intersections(offset_path_list, debug=debug)
new_offset_paths_list = []
for path_list in offset_paths_list:
new_path_list = remove_connected_zero_length_segments(path_list)
new_offset_paths_list.append(new_path_list)
offset_paths_list = new_offset_paths_list
#
# Smooth the path: adjacent segments whose start/end points are
# "close enough" to each other are adjusted so they actually touch.
#
if debug: print("smoothing paths...", file=sys.stderr)
for path_list in offset_paths_list:
for i in range(len(path_list)):
this_seg = path_list[i]
next_seg = path_list[(i+1) % len(path_list)]
midpoint = (this_seg.end + next_seg.start) / 2
next_seg.start = this_seg.end = midpoint
#
# Convert each path list to a Path object and sanity check.
#
if debug: print("converting path lists to paths...", file=sys.stderr)
offset_paths = []
for path_list in offset_paths_list:
offset_path = svgpathtools.Path(*path_list)
if debug: print("offset path:", file=sys.stderr)
if debug: print(offset_path, file=sys.stderr)
assert(offset_path.isclosed())
offset_paths.append(offset_path)
#
# The set of paths we got from split_path_at_intersections() has
# zero or more 'true paths' that we actually want to return, plus
# zero or more 'false paths' that should be discarded.
#
# When offsetting a path to the inside, the false paths will be
# outside the true path and will wind in the opposite direction of
# the input path.
#
# When offsetting a path to the outside, the false paths will be
# inside the true paths, and will wind in the same direction as the
# input path.
#
# [citation needed]
#
if debug: print("pruning false paths...", file=sys.stderr)
path_area = approximate_path_area(path)
if debug: print("input path area:", path_area, file=sys.stderr)
keepers = []
if offset_distance > 0:
# The offset is positive (inwards), discard paths with opposite
# direction from input path, and paths inside any other path.
for offset_path in offset_paths:
if debug: print("checking path:", offset_path, file=sys.stderr)
offset_path_area = approximate_path_area(offset_path)
if debug: print("offset path area:", offset_path_area, file=sys.stderr)
if is_enclosed(offset_path, offset_paths):
if debug: print("path is enclosed, dropping", file=sys.stderr)
elif path_area * offset_path_area < 0.0:
# Input path and offset path go in the opposite directions,
# drop offset path.
if debug: print("wrong direction, dropping", file=sys.stderr)