# -*- encoding: utf-8; frozen_string_literal: true -*-
#
#--
# This file is part of HexaPDF.
#
# HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
# Copyright (C) 2014-2021 Thomas Leitner
#
# HexaPDF is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3 as
# published by the Free Software Foundation with the addition of the
# following permission added to Section 15 as permitted in Section 7(a):
# FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
# THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
# INFRINGEMENT OF THIRD PARTY RIGHTS.
#
# HexaPDF 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 Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with HexaPDF. If not, see .
#
# The interactive user interfaces in modified source and object code
# versions of HexaPDF must display Appropriate Legal Notices, as required
# under Section 5 of the GNU Affero General Public License version 3.
#
# In accordance with Section 7(b) of the GNU Affero General Public
# License, a covered work must retain the producer line in every PDF that
# is created or manipulated using HexaPDF.
#
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at .
#++
require 'hexapdf/utils/math_helpers'
module HexaPDF
module Content
module GraphicObject
# This class describes an elliptical arc in center parameterization that is approximated using
# Bezier curves. It can be used to draw circles, circular arcs, ellipses and elliptical arcs,
# all either in clockwise or counterclockwise direction and optionally inclined in respect to
# the x-axis.
#
# This graphic object is registered under the :arc key for use with the
# HexaPDF::Content::Canvas class.
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 100, b: 50).stroke
# arc.draw(canvas).stroke # or: canvas.draw(arc).stroke
#
# See: ELL - https://spaceroots.org/documents/ellipse/elliptical-arc.pdf
class Arc
include HexaPDF::Utils::MathHelpers
# Creates and configures a new elliptical arc object.
#
# See #configure for the allowed keyword arguments.
def self.configure(**kwargs)
new.configure(**kwargs)
end
# The maximal number of curves used for approximating a complete ellipse.
#
# The higher the value the better the approximation will be but it will also take longer
# to compute. The value should not be lower than 4. Default value is 6 which already
# provides a good approximation.
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, cx: -50, a: 40, b: 40)
# arc.max_curves = 2
# canvas.draw(arc)
# arc.max_curves = 10
# canvas.draw(arc, cx: 50)
# canvas.stroke
attr_accessor :max_curves
# x-coordinate of center point
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 20)
# canvas.draw(arc).stroke
# canvas.stroke_color("red").draw(arc, cx: -50).stroke
attr_reader :cx
# y-coordinate of center point
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 20)
# canvas.draw(arc).stroke
# canvas.stroke_color("red").draw(arc, cy: 50).stroke
attr_reader :cy
# Length of semi-major axis which (without altering the #inclination) is parallel to the
# x-axis
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 30)
# canvas.draw(arc).stroke
# canvas.stroke_color("red").draw(arc, a: 60).stroke
attr_reader :a
# Length of semi-minor axis which (without altering the #inclination) is parallel to the
# y-axis
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 30)
# canvas.draw(arc).stroke
# canvas.stroke_color("red").draw(arc, b: 60).stroke
attr_reader :b
# Start angle of the arc in degrees
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 30)
# canvas.draw(arc, start_angle: 45).stroke
attr_reader :start_angle
# End angle of the arc in degrees
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 30, b: 30)
# canvas.draw(arc, end_angle: 160).stroke
attr_reader :end_angle
# Inclination in degrees of the semi-major axis with respect to the x-axis
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 60, b: 30)
# canvas.draw(arc, inclination: 45).stroke
attr_reader :inclination
# Direction of arc - if +true+ in clockwise direction, else in counterclockwise direction
#
# This is needed when filling paths using the nonzero winding number rule to achieve
# different effects.
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 40, b: 40)
# canvas.draw(arc, cx: -50).draw(arc, cx: 50).
# draw(arc, cx: -50, b: 80).
# draw(arc, cx: 50, b: 80, clockwise: true).
# fill(:nonzero)
attr_reader :clockwise
# Creates an elliptical arc with default values (a counterclockwise unit circle at the
# origin).
#
# Examples:
#
# #>pdf-center
# canvas.draw(:arc).stroke
def initialize
@max_curves = nil
@cx = @cy = 0
@a = @b = 1
@start_angle = 0
@end_angle = 360
@inclination = 0
@clockwise = false
calculate_cached_values
end
# Configures the arc with
#
# * center point (+cx+, +cy+),
# * semi-major axis +a+,
# * semi-minor axis +b+,
# * start angle of +start_angle+ degrees,
# * end angle of +end_angle+ degrees and
# * an inclination in respect to the x-axis of +inclination+ degrees.
#
# The +clockwise+ argument determines if the arc is drawn in the counterclockwise direction
# (+false+) or in the clockwise direction (+true+).
#
# Any arguments not specified are not modified and retain their old value, see #initialize
# for the inital values.
#
# Returns self.
def configure(cx: nil, cy: nil, a: nil, b: nil, start_angle: nil, end_angle: nil,
inclination: nil, clockwise: nil)
@cx = cx if cx
@cy = cy if cy
@a = a.abs if a
@b = b.abs if b
if @a == 0 || @b == 0
raise HexaPDF::Error, "Semi-major and semi-minor axes must be greater than zero"
end
@start_angle = start_angle if start_angle
@end_angle = end_angle if end_angle
@inclination = inclination if inclination
@clockwise = clockwise unless clockwise.nil?
calculate_cached_values
self
end
# Returns the start point of the elliptical arc.
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 40, b: 30, start_angle: 60)
# canvas.draw(arc).stroke
# canvas.fill_color("red").circle(*arc.start_point, 2).fill
def start_point
evaluate(@start_eta)
end
# Returns the end point of the elliptical arc.
#
# Examples:
#
# #>pdf-center
# arc = canvas.graphic_object(:arc, a: 40, b: 30, end_angle: 245)
# canvas.draw(arc).stroke
# canvas.fill_color("red").circle(*arc.end_point, 2).fill
def end_point
evaluate(@end_eta)
end
# Returns the point at +angle+ degrees on the ellipse.
#
# Note that the point may not lie on the arc itself!
def point_at(angle)
evaluate(angle_to_param(angle))
end
# Draws the arc on the given Canvas.
#
# If the argument +move_to_start+ is +true+, a Canvas#move_to operation is executed to
# move the current point to the start point of the arc. Otherwise it is assumed that the
# current point already coincides with the start point
#
# The #max_curves value, if not already changed, is set to the value of the configuration
# option 'graphic_object.arc.max_curves' before drawing.
def draw(canvas, move_to_start: true)
@max_curves ||= canvas.context.document.config['graphic_object.arc.max_curves']
canvas.move_to(*start_point) if move_to_start
curves.each {|x, y, hash| canvas.curve_to(x, y, **hash) }
end
# Returns an array of arrays that contain the points for the Bezier curves which are used
# for approximating the elliptical arc between #start_point and #end_point.
#
# One subarray consists of
#
# [end_point_x, end_point_y, p1: control_point_1, p2: control_point_2]
#
# The first start point is the one returned by #start_point, the other start points are
# the end points of the curve before.
#
# The format of the subarray is chosen so that it can be fed to the Canvas#curve_to
# method by using array splatting.
#
# See: ELL s3.4.1 (especially the last box on page 18)
def curves
result = []
# Number of curves to use, maximal segment angle is 2*PI/max_curves
max_curves = @max_curves || 6
n = [max_curves, ((@end_eta - @start_eta).abs / (2 * Math::PI / max_curves)).ceil].min
d_eta = (@end_eta - @start_eta) / n
alpha = Math.sin(d_eta) * (Math.sqrt(4 + 3 * Math.tan(d_eta / 2)**2) - 1) / 3
eta2 = @start_eta
p2x, p2y = evaluate(eta2)
p2x_prime, p2y_prime = derivative_evaluate(eta2)
1.upto(n) do
p1x = p2x
p1y = p2y
p1x_prime = p2x_prime
p1y_prime = p2y_prime
eta2 += d_eta
p2x, p2y = evaluate(eta2)
p2x_prime, p2y_prime = derivative_evaluate(eta2)
result << [p2x, p2y,
{p1: [p1x + alpha * p1x_prime, p1y + alpha * p1y_prime],
p2: [p2x - alpha * p2x_prime, p2y - alpha * p2y_prime]}]
end
result
end
private
# Calculates the values that are derived from the input values and needed for the
# calculations
def calculate_cached_values
theta = deg_to_rad(@inclination)
@cos_theta = Math.cos(theta)
@sin_theta = Math.sin(theta)
# (see ELL s2.2.1) Calculating start_eta and end_eta so that
# start_eta < end_eta <= start_eta + 2*PI if counterclockwise
# end_eta < start_eta <= end_eta + 2*PI if clockwise
@start_eta = angle_to_param(@start_angle)
@end_eta = angle_to_param(@end_angle)
if !@clockwise && @end_eta <= @start_eta
@end_eta += 2 * Math::PI
elsif @clockwise && @end_eta >= @start_eta
@start_eta += 2 * Math::PI
end
end
# Converts the +angle+ in degrees to the parameter used for the parametric function
# defining the ellipse.
#
# The return value is between 0 and 2*PI.
def angle_to_param(angle)
angle = deg_to_rad(angle % 360)
eta = Math.atan2(Math.sin(angle) / @b, Math.cos(angle) / @a)
eta += 2 * Math::PI if eta < 0
eta
end
# Returns an array containing the x and y coordinates of the point on the elliptical arc
# specified by the parameter +eta+.
#
# See: ELL s2.2.1 (3)
def evaluate(eta)
a_cos_eta = @a * Math.cos(eta)
b_sin_eta = @b * Math.sin(eta)
[@cx + a_cos_eta * @cos_theta - b_sin_eta * @sin_theta,
@cy + a_cos_eta * @sin_theta + b_sin_eta * @cos_theta]
end
# Returns an array containing the derivative of the parametric function defining the
# ellipse evaluated at +eta+.
#
# See: ELL s2.2.1 (4)
def derivative_evaluate(eta)
a_sin_eta = @a * Math.sin(eta)
b_cos_eta = @b * Math.cos(eta)
[- a_sin_eta * @cos_theta - b_cos_eta * @sin_theta,
- a_sin_eta * @sin_theta + b_cos_eta * @cos_theta]
end
end
end
end
end