NaraMotion / blend2d /path.py
ArifiIslam's picture
Release NaraMotion v0.26.0: Structure of Arrays (SoA), Path Arena Allocator, Uniform Grid Indexing, Dual-Buffer Rendering & Centroid Spring Pop-in
b516e00 verified
Raw History Blame Contribute Delete
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))
@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})"