Graphics-Penplotter-GcodeXY
view release on metacpan or search on metacpan
lib/Graphics/Penplotter/GcodeXY/Swirl.pm view on Meta::CPAN
package Graphics::Penplotter::GcodeXY::Swirl v0.9.4;
use v5.38.2;
use feature qw( signatures );
no warnings qw( experimental::signatures );
use strict;
use warnings;
use Role::Tiny;
# ---------------------------------------------------------------------------
# Graphics::Penplotter::GcodeXY::Swirl
#
# Role providing whirl (pursuit-curve polygon) generation for GcodeXY.
# Based on: Kerry Mitchell, "Fun with Whirls", Bridges 2015, pp. 175-182.
# ---------------------------------------------------------------------------
requires qw( _croak line polygon );
# ---------------------------------------------------------------------------
# Direction constants (also exposed as exportable package scalars)
# ---------------------------------------------------------------------------
our $SWIRL_CW = 0; # clockwise (default)
our $SWIRL_CCW = 1; # counter-clockwise
# ===========================================================================
# PUBLIC API
# ===========================================================================
=for comment
swirl( points => \@pts, # flat [x0,y0, x1,y1, ...] array (compulsory)
d => \@d, # per-edge advance percentages (compulsory)
direction => 0|1, # 0=CW (default), 1=CCW
draw => \@bool, # per-edge draw flags (default: all 1)
iterations => $n, # stop after $n nested polygons (highest precedence)
min_size => $pct, # stop when first edge shrinks to $pct% of original
)
=cut
sub swirl ($self, %args) {
# -----------------------------------------------------------------------
# 1. Required parameters
# -----------------------------------------------------------------------
my $pts_ref = $args{points}
or $self->_croak('swirl: "points" is required');
my $d_ref = $args{d}
or $self->_croak('swirl: "d" (per-edge percentages) is required');
# -----------------------------------------------------------------------
# 2. Optional parameters with defaults
# -----------------------------------------------------------------------
my $direction = $args{direction} // $SWIRL_CW;
my $draw_ref = $args{draw};
my $iterations = $args{iterations}; # undef â use size-based termination
my $min_size = $args{min_size} // 1.0; # percent of original first-edge length
# -----------------------------------------------------------------------
# 3. Validate inputs
# -----------------------------------------------------------------------
my @pts = @{$pts_ref};
my @d = @{$d_ref};
$self->_croak('swirl: "points" must contain an even number of elements (x,y pairs)')
if @pts % 2;
my $n = @pts / 2; # number of vertices
$self->_croak('swirl: at least 3 vertices are required')
if $n < 3;
$self->_croak('swirl: "d" must have exactly one entry per edge (same count as vertices)')
if @d != $n;
# Per-edge draw flags â default all true
my @draw = defined $draw_ref ? @{$draw_ref} : ( (1) x $n );
$self->_croak('swirl: "draw" must have exactly one entry per edge (same count as vertices)')
if @draw != $n;
$self->_croak('swirl: "direction" must be 0 (clockwise) or 1 (counter-clockwise)')
if $direction != $SWIRL_CW && $direction != $SWIRL_CCW;
# -----------------------------------------------------------------------
# 4. Convert percentage d-values to fractions in [0,1]
# -----------------------------------------------------------------------
my @df = map { $_ / 100.0 } @d;
# -----------------------------------------------------------------------
# 5. Build vertex list as [[x,y], ...] pairs
# -----------------------------------------------------------------------
my @verts;
for my $i ( 0 .. $n - 1 ) {
push @verts, [ $pts[ 2 * $i ], $pts[ 2 * $i + 1 ] ];
}
# -----------------------------------------------------------------------
# 6. Termination threshold (size-based)
# -----------------------------------------------------------------------
my $orig_edge_len = _edge_len( $verts[0], $verts[1] );
# Avoid division-by-zero for degenerate polygons
my $threshold = $orig_edge_len > 0
? $orig_edge_len * $min_size / 100.0
: 0;
# Guard against a zero (or near-zero) threshold that would cause a
# near-infinite loop when min_size => 0. Clamp to a relative epsilon
# of 1e-9 so the loop always terminates within a reasonable number of
# iterations (the polygon simply converges to numerical noise).
if ( $orig_edge_len > 0 && $threshold < $orig_edge_len * 1e-9 ) {
$threshold = $orig_edge_len * 1e-9;
}
# -----------------------------------------------------------------------
# 7. Draw the base (outermost) polygon
# -----------------------------------------------------------------------
$self->_swirl_draw_poly( \@verts, \@draw );
# -----------------------------------------------------------------------
# 8. Iterate inward
# -----------------------------------------------------------------------
my $iter = 0;
ITERATE: while (1) {
# Fixed-iteration termination (highest precedence)
last ITERATE if defined $iterations && $iter >= $iterations;
# Compute next ring of vertices
my @new_verts;
for my $i ( 0 .. $n - 1 ) {
my $j = ( $i + 1 ) % $n;
if ( $direction == $SWIRL_CW ) {
# CW: new vertex i moves fraction df[i] from verts[i] toward verts[j]
push @new_verts, [
$verts[$i][0] + $df[$i] * ( $verts[$j][0] - $verts[$i][0] ),
$verts[$i][1] + $df[$i] * ( $verts[$j][1] - $verts[$i][1] ),
];
}
else {
# CCW: new vertex i moves fraction df[i] from verts[j] toward verts[i]
push @new_verts, [
$verts[$j][0] + $df[$i] * ( $verts[$i][0] - $verts[$j][0] ),
$verts[$j][1] + $df[$i] * ( $verts[$i][1] - $verts[$j][1] ),
];
}
}
$iter++;
@verts = @new_verts;
# Size-based termination (only when iterations not fixed)
unless ( defined $iterations ) {
last ITERATE if $orig_edge_len == 0;
my $cur_edge_len = _edge_len( $verts[0], $verts[1] );
last ITERATE if $cur_edge_len <= $threshold;
}
# Draw this ring
$self->_swirl_draw_poly( \@verts, \@draw );
}
return 1;
}
# ===========================================================================
# PRIVATE HELPERS
# ===========================================================================
# Draw one ring of the whirl.
# Uses polygon() when all edges are active (efficient), otherwise line().
sub _swirl_draw_poly ($self, $verts, $draw) {
my $n = scalar @{$verts};
# Check whether all edges should be drawn
my $all_draw = 1;
for my $flag ( @{$draw} ) {
unless ($flag) { $all_draw = 0; last; }
}
if ($all_draw) {
# polygon() is a moveto + sequence of line()s; include first vertex
# at the end to close the ring.
my @flat;
for my $v ( @{$verts} ) { push @flat, $v->[0], $v->[1]; }
push @flat, $verts->[0][0], $verts->[0][1]; # closing point
$self->polygon(@flat);
$self->stroke(); # flush psegments to currentpage
}
else {
# Draw only the enabled edges individually.
for my $i ( 0 .. $n - 1 ) {
next unless $draw->[$i];
my $j = ( $i + 1 ) % $n;
$self->line(
$verts->[$i][0], $verts->[$i][1],
$verts->[$j][0], $verts->[$j][1],
);
}
$self->stroke(); # flush psegments to currentpage
}
return 1;
}
# Euclidean distance between two [x,y] vertex refs.
sub _edge_len ($v1, $v2) {
return sqrt(
( $v2->[0] - $v1->[0] ) ** 2 +
( $v2->[1] - $v1->[1] ) ** 2
);
}
1;
__END__
=head1 NAME
Graphics::Penplotter::GcodeXY::Swirl - Whirl (pursuit-curve) polygon generation for GcodeXY
=head1 SYNOPSIS
use Graphics::Penplotter::GcodeXY;
my $g = Graphics::Penplotter::GcodeXY->new(
xsize => 200, ysize => 200, units => 'mm',
);
# Simple square whirl, 100 iterations, 20% advance per edge
$g->swirl(
points => [ 10,10, 190,10, 190,190, 10,190 ],
d => [ 20, 20, 20, 20 ],
iterations => 100,
);
# Triangular whirl, varying d, stops when first edge reaches 1% of original
$g->swirl(
points => [ 100,10, 190,170, 10,170 ],
d => [ 15, 25, 10 ],
min_size => 1,
);
# Counter-clockwise hexagonal whirl, two edges suppressed for visual effect
$g->swirl(
points => [ 100,10, 162,50, 162,130, 100,170, 38,130, 38,50 ],
d => [ (20) x 6 ],
direction => $Graphics::Penplotter::GcodeXY::Swirl::SWIRL_CCW,
draw => [ 1, 0, 1, 1, 0, 1 ],
min_size => 0.5,
);
$g->output('whirl.gcode');
=head1 DESCRIPTION
A L<Role::Tiny> role providing I<whirl> (also called I<pursuit-curve polygon>)
generation for L<Graphics::Penplotter::GcodeXY>.
A whirl is produced by iteratively constructing a series of nested polygons
where each new vertex lies a fixed fractional distance along an edge of the
enclosing polygon. The corners of successive polygons trace discrete
approximations to logarithmic spirals, as described in:
Kerry Mitchell, "Fun with Whirls", Bridges 2015, pp. 175-182.
The role is composed automatically when
L<Graphics::Penplotter::GcodeXY> is loaded; no extra C<use> statement is
required in user code.
=head1 CONSTRUCTION ALGORITHM
Given a base polygon with vertices V[0] to V[n-1] and per-edge advance
fractions f[0] to f[n-1] (converted from the supplied percentages):
=over 4
=item *
For clockwise mode (C<direction =E<gt> 0>), new vertex I<i> is placed at
fraction f[i] of the way from V[i] towards V[(i+1) mod n].
=item *
For counter-clockwise mode (C<direction =E<gt> 1>), new vertex I<i> is placed
at fraction f[i] of the way from V[(i+1) mod n] towards V[i].
=back
This produces a mirror-image whirl. The resulting nested polygon is drawn
after each iteration. The base polygon is always drawn first.
=head1 TERMINATION
Iteration stops when the first condition that has been configured is met:
=over 4
=item 1.
B<Fixed iteration count> (C<iterations =E<gt> $n>): exactly C<$n> nested
polygons are drawn inside the base (highest precedence).
=item 2.
B<Size threshold> (C<min_size =E<gt> $pct>): iteration stops when the length of
the first edge of the current polygon falls to or below C<$pct> percent of the
corresponding edge of the base polygon. The default is C<1.0> (one percent).
=back
If neither is given, the size threshold of 1% is used.
=head1 METHODS
=over 4
=item swirl(%args)
Draw a whirl from the given polygon. Named arguments:
=over 4
=item C<points =E<gt> \@pts> (B<compulsory>)
A reference to a flat array of vertex coordinates in alternating X, Y order:
C<[x0,y0, x1,y1, ...]> At least 3 vertices are required. Coordinates are
in the current drawing units.
=item C<d =E<gt> \@d> (B<compulsory>)
A reference to an array of I<advance percentages>, one per edge (same count as
vertices). Each value specifies how far along the corresponding edge the next
polygon's vertex is placed. Values are percentages in the range C<0> to C<100>.
For example, C<20> means 20% of the way along the edge.
When all values are equal to C<50>, consecutive polygons degenerate to straight
lines (no visible spiral). Values close to C<0> or C<100> produce densely
packed spirals; values close to C<50> produce loosely spaced ones.
=item C<direction =E<gt> 0|1> (optional, default C<0>)
Spiral direction. C<0> (C<$SWIRL_CW>) gives a clockwise whirl; C<1>
(C<$SWIRL_CCW>) gives a counter-clockwise whirl.
=item C<draw =E<gt> \@bool> (optional, default all C<1>)
A reference to an array of boolean flags, one per edge, that controls whether
each edge of every nested polygon is drawn. When all flags are true the
implementation uses C<polygon()> for efficiency; otherwise individual C<line()>
calls are made for the enabled edges. Setting some flags to false can produce
striking visual effects (see Mitchell, Figures 6 and 7).
=item C<iterations =E<gt> $n> (optional)
Draw exactly C<$n> nested polygons (not counting the base polygon). When
given, this takes precedence over C<min_size>.
=item C<min_size =E<gt> $pct> (optional, default C<1.0>)
Stop iterating once the length of the first edge of the current polygon has
shrunk to C<$pct> percent of the original first-edge length. Ignored when
C<iterations> is also given.
=back
Returns C<1> on success. Croaks on invalid input.
=back
=head1 PACKAGE VARIABLES
=over 4
=item C<$Graphics::Penplotter::GcodeXY::Swirl::SWIRL_CW>
Constant C<0>: clockwise direction (the default).
=item C<$Graphics::Penplotter::GcodeXY::Swirl::SWIRL_CCW>
Constant C<1>: counter-clockwise direction.
=back
=head1 REQUIRED METHODS
C<_croak>, C<line>, C<polygon>.
All of these are provided by the host class
L<Graphics::Penplotter::GcodeXY>.
=head1 SEE ALSO
L<Graphics::Penplotter::GcodeXY>, L<Graphics::Penplotter::GcodeXY::Hatch>
Kerry Mitchell, I<"Fun with Whirls">, Bridges Conference 2015, pp. 175-182.
L<https://archive.bridgesmathart.org/2015/bridges2015-175.html>
=head1 AUTHOR
Albert Koelmans (albert.koelmans@googlemail.com)
=head1 LICENSE
Same terms as Perl itself.
=cut
( run in 0.823 second using v1.01-cache-2.11-cpan-71847e10f99 )