""" Vector Path Builder and Geometry Container for Blend2D. """ from __future__ import annotations import ctypes from typing import Optional, Sequence, Tuple, Union, List from naramotion.enums import FillRule, GeometryType, StrokeCap, StrokeJoin from naramotion.ffi import ( bl, check_result, BLPathCore, BLBox, BLPoint, BLStrokeOptionsCore, BLArrayView ) from naramotion.geometry import ( Box, Point, Rect, Circle, Ellipse, RoundRect, Triangle, Arc, Line ) from naramotion.matrix import Matrix2D try: import numpy as np _HAS_NUMPY = True except ImportError: _HAS_NUMPY = False class Path: """ 2D Vector Path holding commands and coordinate vertices. """ __slots__ = ("_core", "_is_initialized") def __init__(self): self._core = BLPathCore() check_result(bl.bl_path_init(ctypes.byref(self._core)), "bl_path_init failed") self._is_initialized = True def __del__(self): if getattr(self, "_is_initialized", False): bl.bl_path_destroy(ctypes.byref(self._core)) self._is_initialized = False def clear(self): """Clear all commands and vertices in the path.""" bl.bl_path_clear(ctypes.byref(self._core)) def reset(self): """Reset path to empty initial state.""" bl.bl_path_reset(ctypes.byref(self._core)) @property def size(self) -> int: """Number of vertices/commands in path.""" return bl.bl_path_get_size(ctypes.byref(self._core)) def is_empty(self) -> bool: return self.size == 0 # ======================================================================== # Path Verbs / Commands # ======================================================================== def move_to(self, x: float, y: float) -> Path: """Move pen to coordinate (x, y) without drawing a line.""" check_result(bl.bl_path_move_to(ctypes.byref(self._core), float(x), float(y))) return self def line_to(self, x: float, y: float) -> Path: """Draw a straight line to coordinate (x, y).""" check_result(bl.bl_path_line_to(ctypes.byref(self._core), float(x), float(y))) return self def quad_to(self, cx: float, cy: float, x: float, y: float) -> Path: """Draw a quadratic Bezier curve with control point (cx, cy) to end (x, y).""" check_result(bl.bl_path_quad_to( ctypes.byref(self._core), float(cx), float(cy), float(x), float(y) )) return self def conic_to(self, cx: float, cy: float, x: float, y: float, w: float) -> Path: """Draw a conic curve with control point (cx, cy), end point (x, y), and weight w.""" check_result(bl.bl_path_conic_to( ctypes.byref(self._core), float(cx), float(cy), float(x), float(y), float(w) )) return self def cubic_to( self, c1x: float, c1y: float, c2x: float, c2y: float, x: float, y: float ) -> Path: """Draw a cubic Bezier curve with control points (c1x, c1y) and (c2x, c2y) to (x, y).""" check_result(bl.bl_path_cubic_to( ctypes.byref(self._core), float(c1x), float(c1y), float(c2x), float(c2y), float(x), float(y) )) return self def smooth_quad_to(self, x: float, y: float) -> Path: """Draw a smooth quadratic Bezier curve to (x, y).""" check_result(bl.bl_path_smooth_quad_to(ctypes.byref(self._core), float(x), float(y))) return self def smooth_cubic_to(self, c2x: float, c2y: float, x: float, y: float) -> Path: """Draw a smooth cubic Bezier curve to (x, y).""" check_result(bl.bl_path_smooth_cubic_to( ctypes.byref(self._core), float(c2x), float(c2y), float(x), float(y) )) return self def arc_to( self, cx: float, cy: float, rx: float, ry: float, start_angle_rad: float, sweep_angle_rad: float, force_move_to: bool = False ) -> Path: """Add an arc segment centered at (cx, cy) with radius (rx, ry).""" check_result(bl.bl_path_arc_to( ctypes.byref(self._core), float(cx), float(cy), float(rx), float(ry), float(start_angle_rad), float(sweep_angle_rad), bool(force_move_to) )) return self def arc_quadrant_to(self, x1: float, y1: float, x2: float, y2: float) -> Path: check_result(bl.bl_path_arc_quadrant_to( ctypes.byref(self._core), float(x1), float(y1), float(x2), float(y2) )) return self def elliptic_arc_to( self, rx: float, ry: float, x_axis_rotation: float, large_arc: bool, sweep: bool, x: float, y: float ) -> Path: """Add an SVG-style elliptic arc.""" check_result(bl.bl_path_elliptic_arc_to( ctypes.byref(self._core), float(rx), float(ry), float(x_axis_rotation), bool(large_arc), bool(sweep), float(x), float(y) )) return self def close(self) -> Path: """Close the current contour by connecting back to the initial start point.""" check_result(bl.bl_path_close(ctypes.byref(self._core))) return self # ======================================================================== # Shape Helpers # ======================================================================== def add_rect(self, x: float, y: float, w: float, h: float, dir: int = 0) -> Path: rect = Rect(x, y, w, h).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.RECTD), ctypes.byref(rect), None, int(dir) )) return self def add_box(self, x0: float, y0: float, x1: float, y1: float, dir: int = 0) -> Path: box = Box(x0, y0, x1, y1).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.BOXD), ctypes.byref(box), None, int(dir) )) return self def add_circle(self, cx: float, cy: float, r: float, dir: int = 0) -> Path: circle = Circle(cx, cy, r).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.CIRCLE), ctypes.byref(circle), None, int(dir) )) return self def add_ellipse(self, cx: float, cy: float, rx: float, ry: float, dir: int = 0) -> Path: ellipse = Ellipse(cx, cy, rx, ry).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.ELLIPSE), ctypes.byref(ellipse), None, int(dir) )) return self def add_round_rect( self, x: float, y: float, w: float, h: float, rx: float, ry: Optional[float] = None, dir: int = 0 ) -> Path: if ry is None: ry = rx rr = RoundRect(x, y, w, h, rx, ry).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.ROUND_RECT), ctypes.byref(rr), None, int(dir) )) return self def add_triangle( self, x0: float, y0: float, x1: float, y1: float, x2: float, y2: float, dir: int = 0 ) -> Path: tri = Triangle(x0, y0, x1, y1, x2, y2).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.TRIANGLE), ctypes.byref(tri), None, int(dir) )) return self def add_line(self, x0: float, y0: float, x1: float, y1: float, dir: int = 0) -> Path: line = Line(x0, y0, x1, y1).to_c() check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.LINE), ctypes.byref(line), None, int(dir) )) return self def add_polygon(self, points: Union[Sequence[Union[Point, Tuple[float, float]]], np.ndarray]) -> Path: """Add a closed polygon of N vertices to this path in a single C call.""" if hasattr(points, "__array_interface__") or (hasattr(points, "dtype") and hasattr(points, "ctypes")): arr = np.ascontiguousarray(points, dtype=np.float64) view = BLArrayView(arr.ctypes.data, len(arr)) check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.POLYGOND), ctypes.byref(view), None, 0 )) else: if not points: return self n = len(points) c_array = (BLPoint * n)() for i, p in enumerate(points): if isinstance(p, Point): c_array[i] = p.to_c() else: c_array[i] = BLPoint(float(p[0]), float(p[1])) view = BLArrayView(ctypes.addressof(c_array), n) check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.POLYGOND), ctypes.byref(view), None, 0 )) return self def add_polyline(self, points: Union[Sequence[Union[Point, Tuple[float, float]]], np.ndarray]) -> Path: """Add an open polyline of N vertices to this path in a single C call.""" if hasattr(points, "__array_interface__") or (hasattr(points, "dtype") and hasattr(points, "ctypes")): arr = np.ascontiguousarray(points, dtype=np.float64) view = BLArrayView(arr.ctypes.data, len(arr)) check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.POLYLINED), ctypes.byref(view), None, 0 )) else: if not points: return self n = len(points) c_array = (BLPoint * n)() for i, p in enumerate(points): if isinstance(p, Point): c_array[i] = p.to_c() else: c_array[i] = BLPoint(float(p[0]), float(p[1])) view = BLArrayView(ctypes.addressof(c_array), n) check_result(bl.bl_path_add_geometry( ctypes.byref(self._core), int(GeometryType.POLYLINED), ctypes.byref(view), None, 0 )) return self def add_path(self, other: Path, matrix: Optional[Matrix2D] = None) -> Path: if matrix is not None: c_mat = matrix.to_c() check_result(bl.bl_path_add_transformed_path( ctypes.byref(self._core), ctypes.byref(other._core), None, ctypes.byref(c_mat) )) else: check_result(bl.bl_path_add_path( ctypes.byref(self._core), ctypes.byref(other._core), None )) return self # ======================================================================== # Transformations & Analysis # ======================================================================== def translate(self, x: float, y: float) -> Path: pt = BLPoint(float(x), float(y)) check_result(bl.bl_path_translate(ctypes.byref(self._core), None, ctypes.byref(pt))) return self def transform(self, matrix: Matrix2D) -> Path: c_mat = matrix.to_c() check_result(bl.bl_path_transform(ctypes.byref(self._core), None, ctypes.byref(c_mat))) return self def get_bounds(self) -> Box: """Calculate the tight bounding box of this path.""" box = BLBox() check_result(bl.bl_path_get_bounding_box(ctypes.byref(self._core), ctypes.byref(box))) return Box.from_c(box) get_bounding_box = get_bounds @property def bounds(self) -> Box: """Bounding box of this path.""" return self.get_bounds() def get_control_box(self) -> Box: """Calculate the control box (including curve control points).""" box = BLBox() check_result(bl.bl_path_get_control_box(ctypes.byref(self._core), ctypes.byref(box))) return Box.from_c(box) def fit_to(self, rect: Union[Rect, Box, Tuple[float, float, float, float]], fit_flags: int = 0) -> Path: """Fit path to bounding rectangle.""" r = Rect.from_val(rect).to_c() check_result(bl.bl_path_fit_to(ctypes.byref(self._core), None, ctypes.byref(r), int(fit_flags))) return self def set_vertex_at(self, index: int, cmd: int, x: float, y: float) -> Path: """Modify command and coordinate of vertex at index.""" check_result(bl.bl_path_set_vertex_at(ctypes.byref(self._core), int(index), int(cmd), float(x), float(y))) return self def stroke_to_path( self, width: float = 1.0, cap: Union[StrokeCap, int] = StrokeCap.BUTT, join: Union[StrokeJoin, int] = StrokeJoin.MITER_BEVEL, miter_limit: float = 4.0 ) -> Path: """ Convert this path's stroke outline into a 2D filled polygon path. """ opt = BLStrokeOptionsCore() bl.bl_stroke_options_init(ctypes.byref(opt)) try: opt.width = float(width) opt.miter_limit = float(miter_limit) opt.hints = (int(cap) & 0xFF) | ((int(cap) & 0xFF) << 8) | ((int(join) & 0xFF) << 16) dst = Path() check_result(bl.bl_path_add_stroked_path( ctypes.byref(dst._core), ctypes.byref(self._core), None, ctypes.byref(opt), None )) return dst finally: bl.bl_stroke_options_destroy(ctypes.byref(opt)) def get_stroke_bounds( self, width: float = 1.0, cap: Union[StrokeCap, int] = StrokeCap.BUTT, join: Union[StrokeJoin, int] = StrokeJoin.MITER_BEVEL, miter_limit: float = 4.0 ) -> Box: """Calculate the exact bounding box including stroke thickness and caps.""" stroked = self.stroke_to_path(width=width, cap=cap, join=join, miter_limit=miter_limit) return stroked.get_bounds() def get_vertex_count(self) -> int: """Return the number of vertices in this path.""" return int(bl.bl_path_get_size(ctypes.byref(self._core))) def get_vertex(self, index: int) -> Tuple[int, float, float]: """Return vertex (command, x, y) at the specified index.""" count = self.get_vertex_count() if index < 0 or index >= count: raise IndexError(f"Vertex index {index} out of range [0, {count})") vtx_ptr = bl.bl_path_get_vertex_data(ctypes.byref(self._core)) cmd_ptr = bl.bl_path_get_command_data(ctypes.byref(self._core)) pt = vtx_ptr[index] cmd = cmd_ptr[index] return (int(cmd), float(pt.x), float(pt.y)) def get_vertices(self) -> List[Tuple[int, float, float]]: """Return all vertices as a list of (command, x, y).""" count = self.get_vertex_count() if count == 0: return [] vtx_ptr = bl.bl_path_get_vertex_data(ctypes.byref(self._core)) cmd_ptr = bl.bl_path_get_command_data(ctypes.byref(self._core)) return [(int(cmd_ptr[i]), float(vtx_ptr[i].x), float(vtx_ptr[i].y)) for i in range(count)] def get_length(self) -> float: """ Calculate the total Euclidean arc length of this path (lines and curves). Essential for line-draw animations and precise dash-offset timing. """ vertices = self.get_vertices() if len(vertices) < 2: return 0.0 total_length = 0.0 curr_x, curr_y = 0.0, 0.0 start_x, start_y = 0.0, 0.0 i = 0 n = len(vertices) while i < n: cmd, x, y = vertices[i] # BL_PATH_CMD_MOVE = 0 if cmd == 0: curr_x, curr_y = x, y start_x, start_y = x, y i += 1 # BL_PATH_CMD_ON = 1 (Line) elif cmd == 1: dx = x - curr_x dy = y - curr_y total_length += (dx * dx + dy * dy) ** 0.5 curr_x, curr_y = x, y i += 1 # BL_PATH_CMD_QUAD = 2 (Quadratic Bezier: 1 control point + 1 end point) elif cmd == 2 and i + 1 < n: cx, cy = x, y _, ex, ey = vertices[i + 1] # Approximate quadratic curve length with 10 linear steps px, py = curr_x, curr_y for step in range(1, 11): t = step / 10.0 inv = 1.0 - t nx = inv * inv * curr_x + 2 * inv * t * cx + t * t * ex ny = inv * inv * curr_y + 2 * inv * t * cy + t * t * ey dx = nx - px dy = ny - py total_length += (dx * dx + dy * dy) ** 0.5 px, py = nx, ny curr_x, curr_y = ex, ey i += 2 # BL_PATH_CMD_CUBIC = 4 (Cubic Bezier: 2 control points + 1 end point) elif cmd == 4 and i + 2 < n: c1x, c1y = x, y _, c2x, c2y = vertices[i + 1] _, ex, ey = vertices[i + 2] # Approximate cubic curve length with 16 linear steps px, py = curr_x, curr_y for step in range(1, 17): t = step / 16.0 inv = 1.0 - t nx = inv**3 * curr_x + 3 * inv**2 * t * c1x + 3 * inv * t**2 * c2x + t**3 * ex ny = inv**3 * curr_y + 3 * inv**2 * t * c1y + 3 * inv * t**2 * c2y + t**3 * ey dx = nx - px dy = ny - py total_length += (dx * dx + dy * dy) ** 0.5 px, py = nx, ny curr_x, curr_y = ex, ey i += 3 # BL_PATH_CMD_CLOSE = 5 elif cmd == 5: dx = start_x - curr_x dy = start_y - curr_y total_length += (dx * dx + dy * dy) ** 0.5 curr_x, curr_y = start_x, start_y i += 1 else: i += 1 return total_length def subdivide(self, tolerance: float = 0.5) -> Path: """ Subdivide curve segments into finely interpolated linear vertices. """ opt = BLApproximationOptions() opt.flatten_mode = 0 # DEFAULT opt.flatten_tolerance = float(tolerance) dst = Path() check_result(bl.bl_path_add_stroked_path( ctypes.byref(dst._core), ctypes.byref(self._core), None, None, ctypes.byref(opt) )) return dst if not dst.is_empty() else self def to_svg_d(self) -> str: """ Convert path commands and vertices into a W3C-standard SVG 'd' path data string. """ vertices = self.get_vertices() if not vertices: return "" parts = [] i = 0 n = len(vertices) while i < n: cmd, x, y = vertices[i] if cmd == 0: # MOVE parts.append(f"M {x:.3f} {y:.3f}") i += 1 elif cmd == 1: # LINE parts.append(f"L {x:.3f} {y:.3f}") i += 1 elif cmd == 2 and i + 1 < n: # QUAD cx, cy = x, y _, ex, ey = vertices[i + 1] parts.append(f"Q {cx:.3f} {cy:.3f} {ex:.3f} {ey:.3f}") i += 2 elif cmd == 4 and i + 2 < n: # CUBIC c1x, c1y = x, y _, c2x, c2y = vertices[i + 1] _, ex, ey = vertices[i + 2] parts.append(f"C {c1x:.3f} {c1y:.3f} {c2x:.3f} {c2y:.3f} {ex:.3f} {ey:.3f}") i += 3 elif cmd == 5: # CLOSE parts.append("Z") i += 1 else: if not math.isnan(x) and not math.isnan(y): parts.append(f"L {x:.3f} {y:.3f}") i += 1 return " ".join(parts) def hit_test(self, x: float, y: float, fill_rule: FillRule = FillRule.NON_ZERO) -> bool: """Test if a coordinate (x, y) is inside the filled path.""" pt = BLPoint(float(x), float(y)) res = bl.bl_path_hit_test(ctypes.byref(self._core), ctypes.byref(pt), int(fill_rule)) return res in (0, 1) def __repr__(self) -> str: return f"Path(vertices={self.size}, length={self.get_length():.1f})"