123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- # cython: language_level=3
- # distutils: define_macros=CYTHON_TRACE_NOGIL=1
- # Copyright 2023 Google Inc. All Rights Reserved.
- # Copyright 2023 Behdad Esfahbod. All Rights Reserved.
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
- try:
- import cython
- COMPILED = cython.compiled
- except (AttributeError, ImportError):
- # if cython not installed, use mock module with no-op decorators and types
- from fontTools.misc import cython
- COMPILED = False
- from fontTools.misc.bezierTools import splitCubicAtTC
- from collections import namedtuple
- import math
- from typing import (
- List,
- Tuple,
- Union,
- )
- __all__ = ["quadratic_to_curves"]
- # Copied from cu2qu
- @cython.cfunc
- @cython.returns(cython.int)
- @cython.locals(
- tolerance=cython.double,
- p0=cython.complex,
- p1=cython.complex,
- p2=cython.complex,
- p3=cython.complex,
- )
- @cython.locals(mid=cython.complex, deriv3=cython.complex)
- def cubic_farthest_fit_inside(p0, p1, p2, p3, tolerance):
- """Check if a cubic Bezier lies within a given distance of the origin.
- "Origin" means *the* origin (0,0), not the start of the curve. Note that no
- checks are made on the start and end positions of the curve; this function
- only checks the inside of the curve.
- Args:
- p0 (complex): Start point of curve.
- p1 (complex): First handle of curve.
- p2 (complex): Second handle of curve.
- p3 (complex): End point of curve.
- tolerance (double): Distance from origin.
- Returns:
- bool: True if the cubic Bezier ``p`` entirely lies within a distance
- ``tolerance`` of the origin, False otherwise.
- """
- # First check p2 then p1, as p2 has higher error early on.
- if abs(p2) <= tolerance and abs(p1) <= tolerance:
- return True
- # Split.
- mid = (p0 + 3 * (p1 + p2) + p3) * 0.125
- if abs(mid) > tolerance:
- return False
- deriv3 = (p3 + p2 - p1 - p0) * 0.125
- return cubic_farthest_fit_inside(
- p0, (p0 + p1) * 0.5, mid - deriv3, mid, tolerance
- ) and cubic_farthest_fit_inside(mid, mid + deriv3, (p2 + p3) * 0.5, p3, tolerance)
- @cython.locals(
- p0=cython.complex,
- p1=cython.complex,
- p2=cython.complex,
- p1_2_3=cython.complex,
- )
- def elevate_quadratic(p0, p1, p2):
- """Given a quadratic bezier curve, return its degree-elevated cubic."""
- # https://pomax.github.io/bezierinfo/#reordering
- p1_2_3 = p1 * (2 / 3)
- return (
- p0,
- (p0 * (1 / 3) + p1_2_3),
- (p2 * (1 / 3) + p1_2_3),
- p2,
- )
- @cython.cfunc
- @cython.locals(
- start=cython.int,
- n=cython.int,
- k=cython.int,
- prod_ratio=cython.double,
- sum_ratio=cython.double,
- ratio=cython.double,
- t=cython.double,
- p0=cython.complex,
- p1=cython.complex,
- p2=cython.complex,
- p3=cython.complex,
- )
- def merge_curves(curves, start, n):
- """Give a cubic-Bezier spline, reconstruct one cubic-Bezier
- that has the same endpoints and tangents and approxmates
- the spline."""
- # Reconstruct the t values of the cut segments
- prod_ratio = 1.0
- sum_ratio = 1.0
- ts = [1]
- for k in range(1, n):
- ck = curves[start + k]
- c_before = curves[start + k - 1]
- # |t_(k+1) - t_k| / |t_k - t_(k - 1)| = ratio
- assert ck[0] == c_before[3]
- ratio = abs(ck[1] - ck[0]) / abs(c_before[3] - c_before[2])
- prod_ratio *= ratio
- sum_ratio += prod_ratio
- ts.append(sum_ratio)
- # (t(n) - t(n - 1)) / (t_(1) - t(0)) = prod_ratio
- ts = [t / sum_ratio for t in ts[:-1]]
- p0 = curves[start][0]
- p1 = curves[start][1]
- p2 = curves[start + n - 1][2]
- p3 = curves[start + n - 1][3]
- # Build the curve by scaling the control-points.
- p1 = p0 + (p1 - p0) / (ts[0] if ts else 1)
- p2 = p3 + (p2 - p3) / ((1 - ts[-1]) if ts else 1)
- curve = (p0, p1, p2, p3)
- return curve, ts
- @cython.locals(
- count=cython.int,
- num_offcurves=cython.int,
- i=cython.int,
- off1=cython.complex,
- off2=cython.complex,
- on=cython.complex,
- )
- def add_implicit_on_curves(p):
- q = list(p)
- count = 0
- num_offcurves = len(p) - 2
- for i in range(1, num_offcurves):
- off1 = p[i]
- off2 = p[i + 1]
- on = off1 + (off2 - off1) * 0.5
- q.insert(i + 1 + count, on)
- count += 1
- return q
- Point = Union[Tuple[float, float], complex]
- @cython.locals(
- cost=cython.int,
- is_complex=cython.int,
- )
- def quadratic_to_curves(
- quads: List[List[Point]],
- max_err: float = 0.5,
- all_cubic: bool = False,
- ) -> List[Tuple[Point, ...]]:
- """Converts a connecting list of quadratic splines to a list of quadratic
- and cubic curves.
- A quadratic spline is specified as a list of points. Either each point is
- a 2-tuple of X,Y coordinates, or each point is a complex number with
- real/imaginary components representing X,Y coordinates.
- The first and last points are on-curve points and the rest are off-curve
- points, with an implied on-curve point in the middle between every two
- consequtive off-curve points.
- Returns:
- The output is a list of tuples of points. Points are represented
- in the same format as the input, either as 2-tuples or complex numbers.
- Each tuple is either of length three, for a quadratic curve, or four,
- for a cubic curve. Each curve's last point is the same as the next
- curve's first point.
- Args:
- quads: quadratic splines
- max_err: absolute error tolerance; defaults to 0.5
- all_cubic: if True, only cubic curves are generated; defaults to False
- """
- is_complex = type(quads[0][0]) is complex
- if not is_complex:
- quads = [[complex(x, y) for (x, y) in p] for p in quads]
- q = [quads[0][0]]
- costs = [1]
- cost = 1
- for p in quads:
- assert q[-1] == p[0]
- for i in range(len(p) - 2):
- cost += 1
- costs.append(cost)
- costs.append(cost)
- qq = add_implicit_on_curves(p)[1:]
- costs.pop()
- q.extend(qq)
- cost += 1
- costs.append(cost)
- curves = spline_to_curves(q, costs, max_err, all_cubic)
- if not is_complex:
- curves = [tuple((c.real, c.imag) for c in curve) for curve in curves]
- return curves
- Solution = namedtuple("Solution", ["num_points", "error", "start_index", "is_cubic"])
- @cython.locals(
- i=cython.int,
- j=cython.int,
- k=cython.int,
- start=cython.int,
- i_sol_count=cython.int,
- j_sol_count=cython.int,
- this_sol_count=cython.int,
- tolerance=cython.double,
- err=cython.double,
- error=cython.double,
- i_sol_error=cython.double,
- j_sol_error=cython.double,
- all_cubic=cython.int,
- is_cubic=cython.int,
- count=cython.int,
- p0=cython.complex,
- p1=cython.complex,
- p2=cython.complex,
- p3=cython.complex,
- v=cython.complex,
- u=cython.complex,
- )
- def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False):
- """
- q: quadratic spline with alternating on-curve / off-curve points.
- costs: cumulative list of encoding cost of q in terms of number of
- points that need to be encoded. Implied on-curve points do not
- contribute to the cost. If all points need to be encoded, then
- costs will be range(1, len(q)+1).
- """
- assert len(q) >= 3, "quadratic spline requires at least 3 points"
- # Elevate quadratic segments to cubic
- elevated_quadratics = [
- elevate_quadratic(*q[i : i + 3]) for i in range(0, len(q) - 2, 2)
- ]
- # Find sharp corners; they have to be oncurves for sure.
- forced = set()
- for i in range(1, len(elevated_quadratics)):
- p0 = elevated_quadratics[i - 1][2]
- p1 = elevated_quadratics[i][0]
- p2 = elevated_quadratics[i][1]
- if abs(p1 - p0) + abs(p2 - p1) > tolerance + abs(p2 - p0):
- forced.add(i)
- # Dynamic-Programming to find the solution with fewest number of
- # cubic curves, and within those the one with smallest error.
- sols = [Solution(0, 0, 0, False)]
- impossible = Solution(len(elevated_quadratics) * 3 + 1, 0, 1, False)
- start = 0
- for i in range(1, len(elevated_quadratics) + 1):
- best_sol = impossible
- for j in range(start, i):
- j_sol_count, j_sol_error = sols[j].num_points, sols[j].error
- if not all_cubic:
- # Solution with quadratics between j:i
- this_count = costs[2 * i - 1] - costs[2 * j] + 1
- i_sol_count = j_sol_count + this_count
- i_sol_error = j_sol_error
- i_sol = Solution(i_sol_count, i_sol_error, i - j, False)
- if i_sol < best_sol:
- best_sol = i_sol
- if this_count <= 3:
- # Can't get any better than this in the path below
- continue
- # Fit elevated_quadratics[j:i] into one cubic
- try:
- curve, ts = merge_curves(elevated_quadratics, j, i - j)
- except ZeroDivisionError:
- continue
- # Now reconstruct the segments from the fitted curve
- reconstructed_iter = splitCubicAtTC(*curve, *ts)
- reconstructed = []
- # Knot errors
- error = 0
- for k, reconst in enumerate(reconstructed_iter):
- orig = elevated_quadratics[j + k]
- err = abs(reconst[3] - orig[3])
- error = max(error, err)
- if error > tolerance:
- break
- reconstructed.append(reconst)
- if error > tolerance:
- # Not feasible
- continue
- # Interior errors
- for k, reconst in enumerate(reconstructed):
- orig = elevated_quadratics[j + k]
- p0, p1, p2, p3 = tuple(v - u for v, u in zip(reconst, orig))
- if not cubic_farthest_fit_inside(p0, p1, p2, p3, tolerance):
- error = tolerance + 1
- break
- if error > tolerance:
- # Not feasible
- continue
- # Save best solution
- i_sol_count = j_sol_count + 3
- i_sol_error = max(j_sol_error, error)
- i_sol = Solution(i_sol_count, i_sol_error, i - j, True)
- if i_sol < best_sol:
- best_sol = i_sol
- if i_sol_count == 3:
- # Can't get any better than this
- break
- sols.append(best_sol)
- if i in forced:
- start = i
- # Reconstruct solution
- splits = []
- cubic = []
- i = len(sols) - 1
- while i:
- count, is_cubic = sols[i].start_index, sols[i].is_cubic
- splits.append(i)
- cubic.append(is_cubic)
- i -= count
- curves = []
- j = 0
- for i, is_cubic in reversed(list(zip(splits, cubic))):
- if is_cubic:
- curves.append(merge_curves(elevated_quadratics, j, i - j)[0])
- else:
- for k in range(j, i):
- curves.append(q[k * 2 : k * 2 + 3])
- j = i
- return curves
- def main():
- from fontTools.cu2qu.benchmark import generate_curve
- from fontTools.cu2qu import curve_to_quadratic
- tolerance = 0.05
- reconstruct_tolerance = tolerance * 1
- curve = generate_curve()
- quadratics = curve_to_quadratic(curve, tolerance)
- print(
- "cu2qu tolerance %g. qu2cu tolerance %g." % (tolerance, reconstruct_tolerance)
- )
- print("One random cubic turned into %d quadratics." % len(quadratics))
- curves = quadratic_to_curves([quadratics], reconstruct_tolerance)
- print("Those quadratics turned back into %d cubics. " % len(curves))
- print("Original curve:", curve)
- print("Reconstructed curve(s):", curves)
- if __name__ == "__main__":
- main()
|