diff --git a/src/faebryk/exporters/pcb/layout/font.py b/src/faebryk/exporters/pcb/layout/font.py index dc13228f..11c0e6dc 100644 --- a/src/faebryk/exporters/pcb/layout/font.py +++ b/src/faebryk/exporters/pcb/layout/font.py @@ -11,7 +11,7 @@ ) from faebryk.libs.font import Font from faebryk.libs.geometry.basic import get_distributed_points_in_polygon -from rich.progress import track +from rich.progress import Progress logger = logging.getLogger(__name__) @@ -47,8 +47,17 @@ def __init__( logger.info(f"Finding points in polygons with density: {density}") nodes = [] - for p in track(polys, description="Finding points in polygons"): - nodes.extend(get_distributed_points_in_polygon(polygon=p, density=density)) + with Progress(*Progress.get_default_columns()) as progress: + tasks = [ + progress.add_task(f"Converging points for polygon {i}", total=100) + for i, _ in enumerate(polys) + ] + for i, p in enumerate(polys): + nodes.extend( + get_distributed_points_in_polygon( + polygon=p, density=density, progress=progress, task_id=tasks[i] + ) + ) logger.info(f"Creating {len(nodes)} nodes in polygons") diff --git a/src/faebryk/libs/font.py b/src/faebryk/libs/font.py index 2074ff4a..ad953003 100644 --- a/src/faebryk/libs/font.py +++ b/src/faebryk/libs/font.py @@ -5,8 +5,10 @@ from pathlib import Path import freetype +import numpy as np from faebryk.libs.geometry.basic import ( flatten_polygons, + get_point_on_bezier_curve, transform_polygon, ) from shapely import Point, Polygon @@ -27,6 +29,7 @@ def string_to_polygons( bbox: tuple[float, float] | None = None, wrap: bool = False, scale_to_fit: bool = False, + resolution: int = 10, ) -> list[Polygon]: """ Render the polygons of a string from a ttf font file @@ -37,6 +40,7 @@ def string_to_polygons( :param bbox: The bounding box to fit the text in, in points :param wrap: Wrap the text to fit the bounding box :param scale_to_fit: Scale the text to fit the bounding box + :param resolution: The resolution of the bezier curves :return: A list of polygons that represent the string """ @@ -53,7 +57,6 @@ def string_to_polygons( face = freetype.Face(str(self.path)) polygons = [] - offset = Point(0, 0) if scale_to_fit: font_size = 1 @@ -61,41 +64,76 @@ def string_to_polygons( text_size = Point(0, 0) scale = font_size / face.units_per_EM - for char in string: - face.load_char(char) - - if bbox and not scale_to_fit: - if offset.x + face.glyph.advance.x > bbox[0] / scale: - if not wrap: - break + for i, line in enumerate(reversed(string.split("\\n"))): + offset = Point(0, i * face.units_per_EM) + + for char in line: + face.load_char(char) + + if bbox and not scale_to_fit: + if offset.x + face.glyph.advance.x > bbox[0] / scale: + if not wrap: + break + offset = Point(0, offset.y + face.glyph.advance.y) + if offset.y > bbox[1] / scale: + break + + points = face.glyph.outline.points + tags = face.glyph.outline.tags + contours = face.glyph.outline.contours + + start = 0 + + ts = np.linspace(0, 1, resolution) + + for contour in contours: + contour_points = [] + point_info = list( + zip(points[start : contour + 1], tags[start : contour + 1]) + ) + point_info.append(point_info[0]) + i = 0 + while i < len(point_info): + # find segment of points that are off curve + segment = [point_info[i][0]] + i += 1 + for j in range(i, len(point_info)): + point, tag = point_info[j] + segment.append(point) + if tag & 1: + i = j + break + + contour_points.extend( + [ + Point( + get_point_on_bezier_curve( + [np.array(s) for s in segment], ts + ) + ) + for ts in ts + ] + ) + + # apply the offset + contour_points = [ + Point(p.x + offset.x, p.y + offset.y) for p in contour_points + ] + polygons.append(Polygon(contour_points)) + + start = contour + 1 + + offset = Point(offset.x + face.glyph.advance.x, offset.y) + + if not wrap or not bbox: + continue + + if offset.x > bbox[0]: offset = Point(0, offset.y + face.glyph.advance.y) - if offset.y > bbox[1] / scale: + if offset.y > bbox[1]: + logger.warning("Text does not fit in bounding box") break - points = face.glyph.outline.points - contours = face.glyph.outline.contours - - start = 0 - - for contour in contours: - contour_points = [Point(p) for p in points[start : contour + 1]] - contour_points.append(contour_points[0]) - start = contour + 1 - contour_points = [ - Point(p.x + offset.x, p.y + offset.y) for p in contour_points - ] - polygons.append(Polygon(contour_points)) - - offset = Point(offset.x + face.glyph.advance.x, offset.y) - - if not wrap or not bbox: - continue - - if offset.x > bbox[0]: - offset = Point(0, offset.y + face.glyph.advance.y) - if offset.y > bbox[1]: - break - bounds = [p.bounds for p in polygons] min_x, min_y, max_x, max_y = ( min(b[0] for b in bounds), diff --git a/src/faebryk/libs/geometry/basic.py b/src/faebryk/libs/geometry/basic.py index a737f5ea..aa60a9e2 100644 --- a/src/faebryk/libs/geometry/basic.py +++ b/src/faebryk/libs/geometry/basic.py @@ -7,11 +7,29 @@ from typing import Iterable, TypeVar import numpy as np +from rich.progress import Progress, TaskID from shapely import MultiPolygon, Point, Polygon, transform +from shapely.ops import nearest_points logger = logging.getLogger(__name__) +def get_point_on_bezier_curve(p, t: float) -> np.ndarray: + """ + Get a point on a quadratic bezier curve + + :param p: The points, including the start and end points + :param t: The parameter t [0, 1] + :return: The point on the curve + """ + if len(p) == 1: + return p[0] + else: + return (1 - t) * get_point_on_bezier_curve( + p[:-1], t + ) + t * get_point_on_bezier_curve(p[1:], t) + + def polygon_insert_cutout(polygon: Polygon, cutout: Polygon) -> Polygon: """ Insert a cutout into a polygon @@ -80,16 +98,27 @@ def flatten_polygons(polygons: list[Polygon]) -> list[Polygon]: def get_distributed_points_in_polygon( polygon: Polygon, density: float, + max_points: int = 50, + convergence_threshold: float = 0.02, + progress: Progress | None = None, + task_id: TaskID | None = None, ) -> list[Point]: """ Get a list of points that are distributed in a polygon - :param polygon: The polygon to distribute the points in :param density: The density of the points + :param density: The desired density of the points + :param max_points: The maximum number of points to distribute, used to limit the + execution time. Takes precedence over density + :param convergence_threshold: The threshold for the convergence of the points. The + algorithm will stop when the maximum distance between points is less than this value :return: A list of points that are distributed in the polygon """ - num_points = int(polygon.area * density) + if polygon.area == 0: + return [] + + num_points = min(int(polygon.area * density), max_points) if polygon.area > 0 and num_points == 0: num_points = 1 @@ -99,6 +128,10 @@ def get_distributed_points_in_polygon( size_x = max_x - min_x size_y = max_y - min_y + # iterate until threshold, then force points to be inside the polygon and iterate + # again + force_in_polygon = False + while True: point_distance_travel = [] for i, point in enumerate(points): @@ -141,8 +174,6 @@ def get_distributed_points_in_polygon( point_bubble = point_bubble.difference(exclusion_polygon) - assert point_bubble.contains(point) - if isinstance(point_bubble, MultiPolygon): for p in list(point_bubble.geoms): if p.contains(point): @@ -151,12 +182,8 @@ def get_distributed_points_in_polygon( new_point = point_bubble.centroid - # TODO: it is possible it will be outside, then just move it to the - # closest point on the polygon. - if not point_bubble.contains(new_point): - continue - - assert polygon.contains(new_point) + if force_in_polygon and not point_bubble.contains(new_point): + new_point, _ = nearest_points(polygon, new_point) point_distance_travel.append( Geometry.distance_euclid( @@ -171,8 +198,20 @@ def get_distributed_points_in_polygon( points[i] = new_point - if max(point_distance_travel) < density / 100: - break + if progress is not None and task_id is not None: + progress.update( + task_id, + completed=max( + (100 * convergence_threshold) + / max(point_distance_travel + [convergence_threshold]), + 100, + ), + ) + + if max(point_distance_travel + [0]) < convergence_threshold: + if force_in_polygon: + break + force_in_polygon = True return points