added curvy_path as a complement to smooth_path, updated squircle()

This commit is contained in:
Alex Matulich 2024-12-29 21:06:35 -08:00
parent 93752014b6
commit ed683bde03
3 changed files with 228 additions and 40 deletions

View file

@ -1246,5 +1246,82 @@ function _assemble_path_fragments(fragments, eps=EPSILON, _finished=[]) =
);
/// Internal function: _bend_path_corner()
/// Usage:
/// _bend_path_corner(three_point_path, [sharpness], [cutlimit], [splinesteps], [midpoint]);
/// Description:
/// Used by squircle() in shapes2d.scad and curvy_path() in rounding.scad
/// Given a path with three points [p1, p2, p3] (2D or 3D), return a subdivided path that curves around from p1 to p3, with the sharpness parameter determining how close to a perfect circle (sharpness=0) or the p2 corner (sharpness=1) the path is from the shortest leg, with the amount of corner lopped off limited by cutlimit. The longer leg is stretched appropriately.
/// The error in using a cubic bezier curve to approximate a circular arc is about 0.00026 for a unit circle, with zero error at the endpoint and the corner bisector.
/// Arguments:
/// p = List of 3 points [p1, p2, p3]. The points may be 2D or 3D.
/// sharpness = curve is circular (sharpness=0) or sharp to the corner (sharpness=1) or anywhere in between
/// cutlimit = optionally constrain the curved path to be no farther than this from the corner
/// splinesteps = number of steps to use for each half of the corner
function _bend_path_corner(p, sharpness=0.5, cutlimit=999999, splinesteps=10, midpoint=[true,true]) =
sharpness==1 || cutlimit==0 ? [p[1]]
: let(
p2 = p[1],
p1 = midpoint[0] ? 0.5*(p[0]+p2) : p[0],
p3 = midpoint[1] ? 0.5*(p2+p[2]) : p[2],
a0 = 0.5*vector_angle(p1, p2, p3),
d1 = norm(p1-p2),
d3 = norm(p3-p2),
tana = tan(a0),
rmin = min(d1, d3) * tana,
rmax = max(d1, d3) * tana,
// A "perfect" unit circle quadrant constructed from cubic bezier points [1,0], [1,d], [d,1], [0,1], with d=0.55228474983 has exact radius=1 at 0°, 45°, and 90°, with a maximum radius (at 22.5° and 67.5°) of 1.00026163152; nearly a perfect circle arc.
fleg = let(a2=a0*a0)
// model of "perfect" circle leg lengths for a bezier unit circle arc depending on arc angle a0; the model error is ~1e-5
-4.4015E-08 * a2*a0 // tiny term, but reduces error by an order of magnitude
+0.0000113366 * a2
-0.00680018 * a0
+0.552244,
leglenmin = rmin * fleg,
leglenmax = rmax * fleg,
cp = circle_2tangents(rmin, p1, p2, p3)[0], // circle center
middir = unit(cp-p2), // unit vector from corner pointing to circle center
bzmid = cp - rmin*middir, // location of bezier point joining both halves of curve
maxcut = norm(bzmid-p2), // maximum possible distance from corner to curve
sharp = max(sharpness, 1-min(1, cutlimit/maxcut)),
bzdist = maxcut * (1-sharp), // distance from corner to tip of curve
cornerlegmin = min(leglenmin, bzdist*tana),
cornerlegmax = min(leglenmax, bzdist*tana),
p21unit = unit(p1-p2),
p23unit = unit(p3-p2),
midto12unit = unit(p21unit-p23unit),
// bezier points around the corner p1,p2,p3 (p2 is the vertex):
// bz0 is p1
// bz1 is on same leg as p1
// bz2 is on line perpendicular to bisector for first half of curve
// bz3 is bezier start/end point on the corner bisector
// bz4 is on line perpendicular to bisector for second half of curve
// bz5 is on same leg as p3
// bz6 is p3
bz3 = p2 + middir * bzdist, // center control point
bz2 = bz3 + midto12unit*(d1<d3 ? cornerlegmin : cornerlegmax),
bz1 = p1 - (d1<=d3 ? leglenmin :
leglenmax)*p21unit,
//norm(0.333*(bz2-p1)))*p21unit,
bz4 = bz3 - midto12unit*(d3<d1 ? cornerlegmin : cornerlegmax),
bz5 = p3 - (d3<=d1 ? leglenmin :
leglenmax)*p23unit,
//norm(0.333*(bz4-p3)))*p23unit,
bez1 = [p1, bz1, bz2, bz3],
bez2 = [bz3, bz4, bz5, p3],
ustep = 1/splinesteps
) [
for(u=[0:ustep:0.9999]) _bzpoint(bez1, u),
for(u=[0:ustep:0.9999]) _bzpoint(bez2, u)
];
/// Internal function to avoid pulling in all of beziers.scad for _bend_path_corner() above
/// Get the t coordinate of a cubic Bezier curve given 4 control points in cp
function _bzpoint(cp, t) = let(n=len(cp[0])-1, t2=t*t, tm=1-t, tm2=tm*tm) [
for(i=[0:n])
cp[0][i]*tm2*tm + 3*cp[1][i]*t*tm2 + 3*cp[2][i]*t2 * (1-t) + cp[3][i]*t2*t
];
// vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4 nowrap

View file

@ -623,6 +623,7 @@ function _rounding_offsets(edgespec,z_dir=1) =
// ---
// relsize = relative size specification for the curve, a number or vector. Default: 0.1
// size = absolute size specification for the curve, a number or vector
// splinesteps = Number of steps for each bezier curve section. Default: 10
// uniform = set to true to compute tangents with uniform=true. Default: false
// closed = true if the curve is closed. Default: false.
// Example(2D): Original path in green, smoothed path in yellow:
@ -643,7 +644,7 @@ function _rounding_offsets(edgespec,z_dir=1) =
// color("green")stroke(square([10,4]), closed=true, width=0.1);
// stroke(smooth_path(square([10,4]),relsize=0.1,closed=true),
// width=0.1);
// Example(2D): Settting uniform to true biases the tangents to aline more with the line sides
// Example(2D): Settting uniform to true biases the tangents to align more with the line sides
// color("green")
// stroke(square([10,4]), closed=true, width=0.1);
// stroke(smooth_path(square([10,4]),uniform=true,
@ -665,9 +666,11 @@ function _rounding_offsets(edgespec,z_dir=1) =
// Example(2D): Or you can give a different size for each segment
// polygon(smooth_path(square(4),size = [.4, .05, 1, .3],
// closed=true));
// Example(FlatSpin,VPD=35,VPT=[4.5,4.5,1]): Works on 3d paths as well
// Example(FlatSpin,VPD=35,VPT=[4.5,4.5,1]): Works on 3d paths also.
// path = [[0,0,0],[3,3,2],[6,0,1],[9,9,0]];
// stroke(smooth_path(path,relsize=.1),width=.3);
// color("red") for(p=path) translate(p) sphere(d=0.3);
// stroke(path, width=0.1, color="red");
// Example(2D): This shows the type of overshoot that can occur with uniform=true. You can produce overshoots like this if you supply a tangent that is difficult to connect to the adjacent points
// pts = [[-3.3, 1.7], [-3.7, -2.2], [3.8, -4.8], [-0.9, -2.4]];
// stroke(smooth_path(pts, uniform=true, relsize=0.1),width=.1);
@ -686,6 +689,93 @@ function smooth_path(path, tangents, size, relsize, splinesteps=10, uniform=fals
closed ? list_unwrap(smoothed) : smoothed;
// Function: curvy_path()
// Synopsis: Create smoothed path that passes tangentially through all the midpoints of each segment of a given path.
// SynTags: Path
// Topics: Rounding, Paths
// See Also: round_corners(), smooth_path(), path_join(), offset_stroke(), squircle()
// Usage:
// curve = curvy_path(path, [sharpness], [cutlimit], [splinesteps=], [closed=]);
// Description:
// This is a complement to `smooth_path()`, which also smooths the input path using a cubic spline. However, instead of
// connecting each corner by a curved path, `curvy_path()` *replaces* every corner of the path by two cubic curves that join at
// the corner bisector, and are tangent to the midpoints of the segments making up the corner.
// The shortest leg of the corner determines the maximum radius of a circular corner for half the corner, and this half is
// circular when `sharpness=0`. The other half uses a bigger circle arc stretched to fit. The circle arc at `sharpness=0`
// is approximated by a cubic Bezier curve such that the maximum radial error is less than 0.0003 of the actual radius.
// .
// A sharply acute corner can result in much of the corner being cut off by the curve.
// An optional parameter `cutlimit` can be used to limit how much is cut from the corner vertex.
// Arguments:
// path = path to smooth
// sharpness = the sharpness of the curve from the midpoint of the shortest side of a corner to the corner bisector. When `sharpness=0`, the curve is circular. When `sharpness=1`, the curve matches the corner. Default: 0.5
// ---
// cutlimit = if included, limits the depth of the amount "cut" from the curner by the curve. If the `sharpness` value results in a curve less than the cut limit, the curved corner is unaffected. A curved corner that cuts off more than this limit is stretched outward to the corner, to the cut limit.
// splinesteps = Number of steps for each bezier curve section (two per corner). Default: 10
// closed = true if the curve is closed. If false, the curve starts from the start of the first segment and ends at the end of the last segment, rather than making curves from the midpoint of each segment as is done for corners. Default: false.
// Example(2D): Original path in green, curved path in yellow. Compare to `smooth_path()` Example 1 above.
// color("green")stroke(square(4), width=0.1);
// stroke(curvy_path(square(4), sharpness=0.2), width=0.1);
// Example(2D): Closing the path changes the end behavior. See also {{squircle()}}.
// color("green")stroke(square(4), width=0.1, closed=true);
// stroke(curvy_path(square(4), sharpness=0.2, closed=true),
// width=0.1, closed=true);
// Example(2D): Here's a wide rectangle.
// color("green")
// stroke(square([10,4]), closed=true, width=0.1);
// stroke(curvy_path(square([10,4]), sharpness=0.2, closed=true),
// width=0.1);
// Example(2D): In the top figure, the rounding cuts off a significant length of the upper right corner. Setting `cutlimit=3` in the bottom figure limits the amount cut from the corner to 3 units.
// shape = [[0,0], [10,0], [15,12], [6,5], [-1,7]];
// polygon(curvy_path(shape, sharpness=0, closed=true));
// color("red") down(.1) polygon(path2d(shape));
// curve2 = curvy_path(shape, sharpness=0, cutlimit=2, closed=false);
// translate([0,-10,0]) {
// polygon(curvy_path(shape, sharpness=0, cutlimit=3, closed=true));
// color("red") down(.1) polygon(path2d(shape));
// }
// Example(2D): Comparison of `smooth_path()` (yellow) with `curvy_path()` (red), showing the different behaviors of each type of curve, with `smooth_path()` passing through the vertices of the polygon, and `curvy_path()` passing through the polygon's segment midpoint tangents.
// path = [[0,0], [4,0], [7,14], [-3,12]];
// polygon(smooth_path(path,size=1,closed=true));
// color("red") polygon(curvy_path(path,sharpness=0.3,closed=true));
// stroke(path, color="green", width=0.2, closed=true);
// Example(2D): You can give a different sharpness for each corner. The first corner starts at the lower right, going around clockwise:
// polygon(curvy_path(square(4),
// sharpness = [0, 0.25, 0.75, 1],
// closed=true));
// Example(FlatSpin,VPD=45: A curvy 3D path resembling a trefoil knot.
// shape = [[8.66, -5, -5], [8.66, 5, 5], [-2, 3.46, 0],
// [-8.66, -5, -5], [0, -10, 5], [4, 0, 0],
// [0, 10, -5], [-8.66, 5, 5], [-2, -3.46, 0]];
// stroke(curvy_path(shape, sharpness=0, closed=true), closed=true);
// stroke(shape, color="green", width=0.15, closed=true);
module curvy_path(path, sharpness=0.5, splinesteps=10, cutlimit, closed=false) {no_module();}
function curvy_path(path, sharpness=0.5, splinesteps=16, cutlimit, closed=false) =
let(pathlen = len(path), sharplen = is_num(sharpness) ? undef : len(sharpness))
assert(pathlen>=3, "path must have at least 3 points")
assert(is_undef(sharplen) || sharplen==pathlen, "if sharpness is an array, it must have the same length as path")
assert(is_def(sharplen) || sharpness>=0 && sharpness<=1, "sharpness must be in the range [0...1]")
let(
p = is_1region(path) ? path[0] : path,
istart = closed ? 0 : 1,
istop = closed ? pathlen-1 : pathlen-2,
cutlim = is_undef(cutlimit) ? 999999 : cutlimit,
sharpvec = is_num(sharpness) ? repeat(sharpness, pathlen) : sharpness,
// bend_path_corner() is in paths.scad
roundpath = closed ? [
for(i=[0:pathlen-1])
_bend_path_corner(select(p,[i-1:i+1]), sharpvec[i], cutlim, splinesteps)
]
: [ for(i=[1:pathlen-2])
_bend_path_corner(select(p,[i-1:i+1]), sharpvec[i], cutlim, splinesteps, midpoint=[i>1,i<pathlen-2]),
[p[pathlen-1]]
]
) flatten(roundpath);
function _scalar_to_vector(value,length,varname) =
is_vector(value)
? assert(len(value)==length, str(varname," must be length ",length))

View file

@ -1747,45 +1747,54 @@ module glued_circles(r, spread=10, tangent=30, d, anchor=CENTER, spin=0) {
}
// Function&Module: squircle()
// Synopsis: Creates a shape between a circle and a square.
// SynTags: Geom, Path
// Topics: Shapes (2D), Paths (2D), Path Generators, Attachable
// See Also: circle(), square(), rect(), ellipse(), supershape()
// See Also: circle(), square(), rect(), ellipse(), supershape(), curvy_path()
// Usage: As Module
// squircle(size, [squareness], [style=]) [ATTACHMENTS];
// squircle(size, [sharpness], [style=]) [ATTACHMENTS];
// Usage: As Function
// path = squircle(size, [squareness], [style=]);
// path = squircle(size, [sharpness], [style=]);
// Description:
// A [squircle](https://en.wikipedia.org/wiki/Squircle) is a shape intermediate between a square/rectangle and a circle/ellipse.
// Squircles are sometimes used to make dinner plates (more area for the same radius as a circle), keyboard buttons, and smartphone
// icons. Old CRT television screens also resembled elongated squircles.
// A [squircle](https://en.wikipedia.org/wiki/Squircle) is a shape intermediate between a square/rectangle and a
// circle/ellipse. Squircles are sometimes used to make dinner plates (more area for the same radius as a circle), keyboard
// buttons, and smartphone icons. Old CRT television screens also resembled elongated squircles.
// .
// Multiple definitions exist for the squircle. We support two versions: the Fernandez-Guasti squircle and the superellipse
// ({{supershape()}} Example 3, also known as the Lamé upper squircle), and the . They are visually almost indistinguishable,
// with the superellipse having slightly rounder "corners" than FG at the same corner radius. These two squircles have different,
// unintuitive methods for controlling how square or circular the shape is. The `squareness` parameter determines the shape, specifying
// the corner position linearly, with 0 giving the circle and 1 giving the square. Vertices are positioned to be more dense near the
// corners to preserve smoothness at low values of `$fn`.
// Multiple definitions exist for the squircle. We support three versions: the Fernandez-Guasti squircle, the superellipse
// (see {{supershape()}} Example 3, also known as the Lamé upper squircle), and a squircle constructed from Bezier curves.
// They are visually almost indistinguishable, with the superellipse having slightly rounder "corners" than FG at the same
// corner radius, and the Bezier version having slightly sharper corners. These squircles have different, unintuitive methods
// for controlling how square or circular the shape is. The `sharpness` parameter determines the shape, specifying the
// corner position linearly, with 0 giving the circle and 1 giving the square. Vertices are positioned to be more dense near
// the corners to preserve smoothness at low values of `$fn`.
// .
// For the "superellipse" style, the special case where the superellipse exponent is 4 results in a squircle at the geometric mean
// between radial points on the circle and square, corresponding to squareness=0.456786.
// For the "superellipse" style, the special case where the superellipse exponent is 4 results in a squircle with corners at
// the geometric mean between radial points on the circle and square, corresponding to sharpness=0.456786.
// .
// When called as a module, creates a 2D squircle with the specified squareness.
// For the "bezier" style with sharpness=0, the "circle" approximated by Bezier curves is exact every 45°, with a maximum
// error of 0.00026 times the radius in between those eight angles.
// .
// When called as a module, creates a 2D squircle with the specified sharpness.
// When called as a function, returns a 2D path for a squircle.
// Arguments:
// size = Same as the `size` parameter in `square()`, can be a single number or a vector `[xsize,ysize]`.
// squareness = Value between 0 and 1. Controls the shape, setting the location of a squircle "corner" at the specified interpolated position between a circle and a square. When `squareness=0` the shape is a circle, and when `squareness=1` the shape is a square. Default: 0.5
// sharpness = Value between 0 and 1. Controls the shape, setting the location of a squircle "corner" at the specified interpolated position between a circle and a square. When `sharpness=0` the shape is a circle, and when `sharpness=1` the shape is a square. Default: 0.5
// ---
// style = method for generating a squircle, "fg" for Fernández-Guasti and "superellipse" for superellipse. Default: "fg"
// style = method for generating a squircle, "fg" for Fernández-Guasti, "superellipse" for superellipse, or "bezier" for Bezier. Default: "fg"
// anchor = Translate so anchor point is at origin (0,0,0). See [anchor](attachments.scad#subsection-anchor). Default: `CENTER`
// spin = Rotate this many degrees around the Z axis after anchor. See [spin](attachments.scad#subsection-spin). Default: `0`
// atype = anchor type, "box" for bounding box corners and sides, "perim" for the squircle corners. Default: "box"
// $fn = Number of points. The special variables `$fs` and `$fa` are ignored. If set, `$fn` must be 12 or greater, and is rounded to the nearest multiple of 4. Points are generated so they are more dense around sharper curves. Default if not set: 48
// Examples(2D):
// squircle(size=50, squareness=0.4);
// squircle(size=50, sharpness=0.4);
// squircle([80,60], 0.7, $fn=64);
// Example(2D,VPD=265,NoAxes): Ten increments of squareness parameter for a superellipse squircle
// Example(2D,VPD=48,VPR=[40,0,40],NoAxes): Corner differences between the three squircle styles for sharpness=0.5. Style "superellipse" is pink, "fg" is gold, "bezier" is blue.
// color("pink") squircle(size=50, style="superellipse", sharpness=0.5, $fn=256);
// color("yellow") up(1) squircle(size=50, style="fg", sharpness=0.5, $fn=256);
// color("lightblue") up(2) squircle(size=50, style="bezier", sharpness=0.5, $fn=256);
// Example(2D,VPD=265,NoAxes): Ten increments of sharpness parameter for a superellipse squircle
// color("green") for(sq=[0:0.1:1])
// stroke(squircle(100, sq, style="superellipse", $fn=96), closed=true, width=0.5);
// Example(2D): Standard vector anchors are based on the bounding box
@ -1794,12 +1803,12 @@ module glued_circles(r, spread=10, tangent=30, d, anchor=CENTER, spin=0) {
// squircle([60,40], 0.5, anchor=(BOTTOM+LEFT), atype="perim", spin=20)
// show_anchors();
module squircle(size, squareness=0.5, style="fg", anchor=CENTER, spin=0, atype="box" ) {
check = assert(squareness >= 0 && squareness <= 1);
module squircle(size, sharpness=0.5, style="fg", anchor=CENTER, spin=0, atype="box" ) {
check = assert(sharpness >= 0 && sharpness <= 1);
anchorchk = assert(in_list(atype, ["box", "perim"]));
size = is_num(size) ? [size,size] : point2d(size);
assert(all_positive(size), "All components of size must be positive.");
path = squircle(size, squareness, style, atype="box");
path = squircle(size, sharpness, style, atype="box");
if (atype == "box") {
attachable(anchor, spin, two_d=true, size=size, extent=false) {
polygon(path);
@ -1814,23 +1823,24 @@ module squircle(size, squareness=0.5, style="fg", anchor=CENTER, spin=0, atype="
}
function squircle(size, squareness=0.5, style="fg", anchor=CENTER, spin=0, atype="box") =
assert(squareness >= 0 && squareness <= 1)
function squircle(size, sharpness=0.5, style="fg", anchor=CENTER, spin=0, atype="box") =
assert(sharpness >= 0 && sharpness <= 1)
assert(is_num(size) || is_vector(size,2))
assert(in_list(atype, ["box", "perim"]))
let(
size = is_num(size) ? [size,size] : point2d(size),
path = style == "fg" ? _squircle_fg(size, squareness)
: style == "superellipse" ? _squircle_se(size, squareness)
path = style == "fg" ? _squircle_fg(size, sharpness)
: style == "superellipse" ? _squircle_se(size, sharpness)
: style == "bezier" ? _squircle_bz(size, sharpness)
: assert(false, "Style must be \"fg\" or \"superellipse\"")
) reorient(anchor, spin, two_d=true, size=atype=="box"?size:undef, path=atype=="box"?undef:path, p=path, extent=true);
/* FG squircle functions */
function _squircle_fg(size, squareness) = [
function _squircle_fg(size, sharpness) = [
let(
sq = _linearize_squareness(squareness),
sq = _linearize_sharpness(sharpness),
size = is_num(size) ? [size,size] : point2d(size),
aspect = size[1] / size[0],
r = 0.5 * size[0],
@ -1841,11 +1851,11 @@ function _squircle_fg(size, squareness) = [
) p*[cos(theta), aspect*sin(theta)]
];
function squircle_radius_fg(squareness, r, angle) = let(
s2a = abs(squareness*sin(2*angle))
function squircle_radius_fg(sharpness, r, angle) = let(
s2a = abs(sharpness*sin(2*angle))
) s2a>0 ? r*sqrt(2)/s2a * sqrt(1 - sqrt(1 - s2a*s2a)) : r;
function _linearize_squareness(s) =
function _linearize_sharpness(s) =
// from Chamberlain Fong (2016). "Squircular Calculations". arXiv.
// https://arxiv.org/pdf/1604.02174v5
let(c = 2 - 2*sqrt(2), d = 1 - 0.5*c*s)
@ -1854,14 +1864,14 @@ function _linearize_squareness(s) =
/* Superellipse squircle functions */
function _squircle_se(size, squareness) = [
function _squircle_se(size, sharpness) = [
let(
n = _squircle_se_exponent(squareness),
n = _squircle_se_exponent(sharpness),
size = is_num(size) ? [size,size] : point2d(size),
ra = 0.5*size[0],
rb = 0.5*size[1],
astep = $fn>=12 ? 90/round($fn/4) : 360/48,
fgsq = _linearize_squareness(min(0.998,squareness)) // works well for distributing theta
fgsq = _linearize_sharpness(min(0.998,sharpness)) // works well for distributing theta
) for(a=[360:-astep:0.01]) let(
theta = a + fgsq*sin(4*a)*30/PI, // tighter angle steps at corners
x = cos(theta),
@ -1876,14 +1886,25 @@ function squircle_radius_se(n, r, angle) = let(
y = sin(angle)
) (abs(x)^n + abs(y)^n)^(1/n) / r;
function _squircle_se_exponent(squareness) = let(
// limit squareness; error if >0.99889, limit is smaller for r>1
s=min(0.998,squareness),
function _squircle_se_exponent(sharpness) = let(
// limit sharpness; error if >0.99889, limit is smaller for r>1
s=min(0.998,sharpness),
rho = 1 + s*(sqrt(2)-1),
x = rho / sqrt(2)
) log(0.5) / log(x);
/* Bezier squircle function */
function _squircle_bz(size, sharpness) = let(
splinesteps = $fn>=12 ? round($fn/4) : 10,
size = is_num(size) ? [size,size] : point2d(size),
sq = square(size, center=true),
roundpath = [ for(i=[0:3])
_bend_path_corner(select(sq,[i-1:i+1]), sharpness, 999999, splinesteps)
]
) flatten(roundpath);
// Function&Module: keyhole()
// Synopsis: Creates a 2D keyhole shape.