Release NaraMotion v0.26.0: Structure of Arrays (SoA), Path Arena Allocator, Uniform Grid Indexing, Dual-Buffer Rendering & Centroid Spring Pop-in
b516e00 verified Download blend2d/path.py from ArifiIslam/NaraMotion: direct link, hf CLI and curl.
- Browser
- Download file 21.8 kB
-
https://huggingface.co/ArifiIslam/NaraMotion/resolve/main/blend2d/path.py
- Command line
-
hf download hf://ArifiIslam/NaraMotion/blend2d/path.py
-
curl -L -o path.py https://huggingface.co/ArifiIslam/NaraMotion/resolve/main/blend2d/path.py
21.8 kB
| """ | |
| 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)) | |
| 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 | |
| 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})" | |