allow offset() to handle reverses; improved error messages

This commit is contained in:
Adrian Mariano 2023-11-16 19:39:59 -05:00
parent 1161e44710
commit 1b0653a8a4

View file

@ -633,6 +633,14 @@ function region_parts(region) =
function _offset_chamfer(center, points, delta) =
is_undef(points[1])?
let( points = select(points,[0,2]),
center = mean(points),
sign = -_tri_class( [points[0],sign(delta)*line_normal(points),points[1]]),
startvec = (points[0]-center)/cos(22.5)+center
)
[for(ang=lerpn(22.5, 157.6,4)) zrot(ang,startvec,cp=center)]
:
let(
dist = sign(delta)*norm(center-line_intersection(select(points,[0,2]), [center, points[1]])),
endline = _shift_segment(select(points,[0,2]), delta-dist)
@ -753,25 +761,41 @@ function _point_dist(path,pathseg_unit,pathseg_len,pt) =
// offset() module, you can use `r` to specify rounded offset and `delta` to specify offset with
// corners. If you used `delta` you can set `chamfer` to true to get chamfers.
// For paths and polygons positive offsets make the polygons larger. For paths,
// positive offsets shift the path to the left, relative to the direction of the path. Note
// that the path must not include any 180 degree turns, where the path reverses direction.
// positive offsets shift the path to the left, relative to the direction of the path.
// .
// If you use `delta` without chamfers, the path must not include any 180 degree turns, where the path
// reverses direction. Such reversals result in an offset with two parallel segments, so they cannot be
// extended to an intersection point. If you select chamfering the reversals are permitted and will result
// in a single segment connecting the parallel segments. With rounding, a semi-circle will connect the two offset segments.
// Note also that repeated points are always illegal in the input; remove them first with {{deduplicate()}}.
// .
// When offsets shrink the path, segments cross and become invalid. By default `offset()` checks
// for this situation. To test validity the code checks that segments have distance larger than (r
// or delta) from the input path. This check takes O(N^2) time and may mistakenly eliminate
// segments you wanted included in various situations, so you can disable it if you wish by setting
// check_valid=false. Another situation is that the test is not sufficiently thorough and some
// segments persist that should be eliminated. In this case, increase `quality` to 2 or 3. (This
// increases the number of samples on the segment that are checked.) Run time will increase. In
// some situations you may be able to decrease run time by setting quality to 0, which causes only
// segment ends to be checked.
// check_valid=false. When segments are mistakenly removed, you may get the wrong offset output, or you may
// get an error, depending on the effect of removing the segment.
// The erroneous removal of segments is more common when your input
// contains very small segments and in this case can result in an invalid situation where the remaining
// valid segments are parallel and cannot be connected to form an offset curve. If this happens, you
// will get an error message to this effect. The only solutions are to either remove the small segments with {{deduplicate()}},
// or if your path permits it, to set check_valid to false.
// .
// When invalid segments are eliminated, the path length decreases. If you use chamfering or rounding, then
// Another situation that can arise with validity testing is that the test is not sufficiently thorough and some
// segments persist that should be eliminated. In this case, increase `quality` from its default of 1 to a value of 2 or 3.
// This increases the number of samples on the segment that are checked, so it will increase run time. In
// some situations you may be able to decrease run time by setting quality to 0, which causes only
// segment ends to be checked.
// .
// When invalid segments are eliminated, the path length decreases, and multiple points on the input path map to the same point
// on the offset path. If you use chamfering or rounding, then
// the chamfers and roundings can increase the length of the output path. Hence points in the output may be
// difficult to associate with the input. If you want to maintain alignment between the points you
// can use the `same_length` option. This option requires that you use `delta=` with `chamfer=false` to ensure
// that no points are added. When points collapse to a single point in the offset, the output includes
// that point repeated to preserve the correct length.
// that no points are added. with `same_length`, when points collapse to a single point in the offset, the output includes
// that point repeated to preserve the correct length. Generally repeated points will not appear in the offset output
// unless you set `same_length` to true, but in some rare circumstances involving very short segments, it is possible for the
// repeated points to occur in the output, even when `same_length=false`.
// .
// Another way to obtain alignment information is to use the return_faces option, which can
// provide alignment information for all offset parameters: it returns a face list which lists faces between
@ -792,41 +816,73 @@ function _point_dist(path,pathseg_unit,pathseg_len,pt) =
// return_faces = return face list. Default: False.
// firstface_index = starting index for face list. Default: 0.
// flip_faces = flip face direction. Default: false
// Example(2D,NoAxes):
// Example(2D,NoAxes): Offset the red star out by 10 units.
// star = star(5, r=100, ir=30);
// #stroke(closed=true, star, width=3);
// stroke(closed=true, star, width=3, color="red");
// stroke(closed=true, width=3, offset(star, delta=10, closed=true));
// Example(2D,NoAxes):
// Example(2D,NoAxes): Offset the star with chamfering
// star = star(5, r=100, ir=30);
// #stroke(closed=true, star, width=3);
// stroke(closed=true, star, width=3, color="red");
// stroke(closed=true, width=3,
// offset(star, delta=10, chamfer=true, closed=true));
// Example(2D,NoAxes):
// Example(2D,NoAxes): Offset the star with rounding
// star = star(5, r=100, ir=30);
// #stroke(closed=true, star, width=3);
// stroke(closed=true, star, width=3, color="red");
// stroke(closed=true, width=3,
// offset(star, r=10, closed=true));
// Example(2D,NoAxes):
// Example(2D,NoAxes): Offset inward
// star = star(7, r=120, ir=50);
// #stroke(closed=true, width=3, star);
// stroke(closed=true, width=3, star, color="red");
// stroke(closed=true, width=3,
// offset(star, delta=-15, closed=true));
// Example(2D,NoAxes):
// Example(2D,NoAxes): Inward offset with chamfers
// star = star(7, r=120, ir=50);
// #stroke(closed=true, width=3, star);
// stroke(closed=true, width=3, star, color="red");
// stroke(closed=true, width=3,
// offset(star, delta=-15, chamfer=true, closed=true));
// Example(2D,NoAxes):
// Example(2D,NoAxes): Inward offset with rounding
// star = star(7, r=120, ir=50);
// #stroke(closed=true, width=3, star);
// stroke(closed=true, width=3, star, color="red");
// stroke(closed=true, width=3,
// offset(star, r=-15, closed=true, $fn=20));
// Example(2D,NoAxes): This case needs `quality=2` for success
// Example(2D): Open path. The path moves from left to right and the positive offset shifts to the left of the initial red path.
// sinpath = 2*[for(theta=[-180:5:180]) [theta/4,45*sin(theta)]];
// stroke(sinpath, width=2, color="red");
// stroke(offset(sinpath, r=17.5),width=2);
// Example(2D,NoAxes): Offsetting a line segment with closed=false on the left, chamfered with closed=true in the center, and rounded on the right. When the path turns back on itself, chamfering produces a simple flat end and rounding produces a semicircle. This offset is invalid in the closed case with delta offsetting and chamfer=false.
// seg = [[0,0],[0,50]];
// stroke(seg,color="red");
// stroke(offset(seg,r=15,closed=false));
// right(30){
// stroke(seg,color="red");
// stroke([offset(seg,delta=15,chamfer=true,closed=true)]);
// }
// right(80){
// stroke(seg,color="red");
// stroke([offset(seg,r=15,closed=true)]);
// }
// Example(2D,NoAxes): A more complex example where the path turns back on itself several times.
// $fn=32;
// path = [
// [0,0], [5,5],
// [10,0],[5,5],
// [11,8],[5,5],
// [5,10],[5,5],
// [-1,4],[5,5]
// ];
// op=offset(path, r=1.5,chamfer=true,closed=true);
// stroke([op],width=.1);
// Example(2D,NoAxes): This case produces an incorrect result because the offset edge corresponding to the long left edge (shown in green) is erroneously flagged as invalid. If you use `delta=` instead of `r=` with this example, it will fail with an error.
// test = [[0,0],[10,0],[10,7],[0,7], [-1,-3]];
// polygon(offset(test,r=-1.9, closed=true));
// //polygon(offset(test,delta=-1.9, closed=true)); // Fails with erroneous 180 deg path error
// stroke([test],width=.1,color="red");
// stroke(select(test,-2,-1), width=.1, color="green");
// Example(2D,NoAxes): Using `quality=2` produces the correct result
// test = [[0,0],[10,0],[10,7],[0,7], [-1,-3]];
// polygon(offset(test,r=-1.9, closed=true, quality=2));
// //polygon(offset(test,r=-1.9, closed=true, quality=1)); // Fails with erroneous 180 deg path error
// %down(.1)polygon(test);
// Example(2D,NoAxes): This case fails if `check_valid=true` when delta is large enough because segments are too close to the opposite side of the curve.
// stroke([test],width=.1,color="red");
// Example(2D,NoAxes): This case fails if `check_valid=true` when delta is large enough because segments are too close to the opposite side of the curve so they all get flagged as invalid and deleted from the output.
// star = star(5, r=22, ir=13);
// stroke(star,width=.3,closed=true);
// color("green")
@ -850,15 +906,11 @@ function _point_dist(path,pathseg_unit,pathseg_len,pt) =
// stroke(ellipse, closed=true, width=0.3);
// stroke(offset(ellipse, r=-3, check_valid=true, closed=true),
// width=0.3, closed=true);
// Example(2D): Open path. The path moves from left to right and the positive offset shifts to the left of the initial red path.
// sinpath = 2*[for(theta=[-180:5:180]) [theta/4,45*sin(theta)]];
// #stroke(sinpath, width=2);
// stroke(offset(sinpath, r=17.5),width=2);
// Example(2D,NoAxes): Region
// Example(2D,NoAxes): The region shown in red has the yellow offset region.
// rgn = difference(circle(d=100),
// union(square([20,40], center=true),
// square([40,20], center=true)));
// #linear_extrude(height=1.1) stroke(rgn, width=1);
// stroke(rgn, width=1, color="red");
// region(offset(rgn, r=-5));
// Example(2D,NoAxes): Using `same_length=true` to align the original curve to the offset. Note that lots of points map to the corner at the top.
// closed=false;
@ -880,7 +932,7 @@ function offset(
let(
ofsregs = [for(R=region_parts(path))
difference([for(i=idx(R)) offset(R[i], r=u_mul(i>0?-1:1,r), delta=u_mul(i>0?-1:1,delta),
chamfer=chamfer, check_valid=check_valid, quality=quality,closed=true)])]
chamfer=chamfer, check_valid=check_valid, quality=quality,same_length=same_length,closed=true)])]
)
union(ofsregs)
:
@ -896,13 +948,13 @@ function offset(
)
d==0 && !return_faces ? path :
let(
// shiftsegs = [for(i=[0:len(path)-1]) _shift_segment(select(path,i,i+1), d)],
shiftsegs = [for(i=[0:len(path)-2]) _shift_segment([path[i],path[i+1]], d),
if (closed) _shift_segment([last(path),path[0]],d)
else [path[0],path[1]] // dummy segment, not used
],
// good segments are ones where no point on the segment is less than distance d from any point on the path
good = check_valid ? _good_segments(path, abs(d), shiftsegs, closed, quality) : repeat(true,len(shiftsegs)),
good = check_valid ? _good_segments(path, abs(d), shiftsegs, closed, quality)
: repeat(true,len(shiftsegs)),
goodsegs = bselect(shiftsegs, good),
goodpath = bselect(path,good)
)
@ -912,22 +964,29 @@ function offset(
sharpcorners = [for(i=[0:len(goodsegs)-1]) _segment_extension(select(goodsegs,i-1), select(goodsegs,i))],
// If some segments are parallel then the extended segments are undefined. This case is not handled
// Note if !closed the last corner doesn't matter, so exclude it
parallelcheck =
(len(sharpcorners)==2 && !closed) ||
all_defined(closed? sharpcorners : select(sharpcorners, 1,-2))
)
assert(parallelcheck, "Path contains a segment that reverses direction (180 deg turn)")
let(
// true if sharpcorner is defined or if the corner has a reversal; false if corner has two parallel
// segments going in the same direction
cornercheck = [for(i=idx(goodsegs)) (!closed && (i==0 || i==len(goodsegs)-1))
|| is_def(sharpcorners[i])
|| approx(unit(deltas(select(goodsegs,i-1))[0]) * unit(deltas(goodsegs[i])[0]),-1)],
dummyA = assert(len(sharpcorners)==2 || all(cornercheck),"Two consecutive valid offset segments are parallel but do not meet at their ends, maybe because path contains very short segments that were mistakenly flagged as invalid; unable to compute offset"),
reversecheck =
!(is_def(delta) && !chamfer) // Reversals only a problem in delta mode without chamfers
|| (len(sharpcorners)==2 && !closed)
|| all_defined(closed? sharpcorners : select(sharpcorners, 1,-2)),
dummyB = assert(reversecheck, "Either validity check failed and removed a valid segment or the input 'path' contains a segment that reverses direction (180 deg turn), which is only allowed with r= or chamfer=true"),
// This is a Boolean array that indicates whether a corner is an outside or inside corner
// For outside corners, the newcorner is an extension (angle 0), for inside corners, it turns backward
// If either side turns back it is an inside corner---must check both.
// Outside corners can get rounded (if r is specified and there is space to round them)
outsidecorner = len(sharpcorners)==2 ? [false,false]
outsidecorner = len(sharpcorners)==2 ? [closed,closed]
:
[for(i=[0:len(goodsegs)-1])
let(prevseg=select(goodsegs,i-1))
(i==0 || i==len(goodsegs)-1) && !closed ? false // In open case first entry is bogus
:
:is_undef(sharpcorners[i]) ? true
:
(goodsegs[i][1]-goodsegs[i][0]) * (goodsegs[i][0]-sharpcorners[i]) > 0
&& (prevseg[1]-prevseg[0]) * (sharpcorners[i]-prevseg[1]) > 0
],
@ -948,25 +1007,37 @@ function offset(
// If rounding is on then newcorners[i] will be the point list that replaces goodpath[i] and newcorners later
// gets flattened. If rounding is off then we set it to [sharpcorners] so we can later flatten it and get
// plain sharpcorners back.
newcorners = is_def(delta) && !chamfer ? [sharpcorners]
: [for(i=[0:len(goodsegs)-1])
newcorners =
is_def(delta) && !chamfer
? [sharpcorners]
: [for(i=[0:len(goodsegs)-1])
let(
basepts = [
select(goodsegs,i-1)[1],
goodsegs[i][0]
]
)
(!chamfer && steps[i] <=1) // Don't round if steps is smaller than 2
|| !outsidecorner[i] // Don't round inside corners
|| (!closed && (i==0 || i==len(goodsegs)-1)) // Don't round ends of an open path
? [sharpcorners[i]]
: chamfer ? _offset_chamfer(
|| !outsidecorner[i] // Don't round inside corners
|| (!closed && (i==0 || i==len(goodsegs)-1)) // Don't round ends of an open path
? (is_def(sharpcorners[i]) ? [sharpcorners[i]] : basepts)
: chamfer //&& is_def(sharpcorners[i])
? _offset_chamfer(
goodpath[i], [
select(goodsegs,i-1)[1],
sharpcorners[i],
goodsegs[i][0]
], d
)
)
: chamfer ? basepts
: // rounded case
arc(cp=goodpath[i],
points=[
select(goodsegs,i-1)[1],
goodsegs[i][0]
],
let(
class =_tri_class( [ each select(goodsegs,i-1), goodsegs[i][0]]),
cw = class==1,
ccw = class==-1
)
arc(cp=goodpath[i], cw=cw, ccw=ccw,
points=basepts,
n=steps[i])
],
pointcount = (is_def(delta) && !chamfer)?
@ -982,7 +1053,11 @@ function offset(
flip_faces, firstface_index, good,
pointcount, closed
),
final_edges = same_length ? select(edges, [0,each list_head (cumsum([for(g=good) g?1:0]))])
final_edges = same_length ? select(edges,
[0,
each list_head(cumsum([for(g=good) g?1:0]))
]
)
: edges
) return_faces? [edges,faces] : final_edges;