diff --git a/chalk/arrowheads.py b/chalk/arrowheads.py new file mode 100644 index 0000000..4a38493 --- /dev/null +++ b/chalk/arrowheads.py @@ -0,0 +1,76 @@ +from colour import Color + +import chalk.transform as tx +from chalk.path import Path, from_list_of_tuples +from chalk.types import Diagram + +black = Color("black") + + +def tri() -> Diagram: + from chalk.core import Empty + + return ( + from_list_of_tuples( + [(1.0, 0), (0.0, -1.0), (-1.0, 0), (1.0, 0)], closed=True + ) + # .remove_scale() + .stroke() + .rotate_by(-0.25) + .fill_color(Color("black")) + .center_xy() + .align_r() + .line_width(0) + .with_envelope(Empty()) + ) + + +def dart(cut: float = 0.2) -> Diagram: + from chalk.core import Empty + + pts = tx.np.stack( + [ + tx.P2(0, -cut), + tx.P2(1.0, cut), + tx.P2(0.0, -1.0 - cut), + tx.P2(-1.0, +cut), + tx.P2(0, -cut), + ] + ) + pts = ( + tx.rotation_angle(-90) + @ tx.translation(tx.V2(1.5 * cut, 1 + 3 * cut)) + @ pts + ) + + return ( + Path.from_array( + pts, + closed=True, + ) + .remove_scale() + .stroke() + .fill_color(Color("black")) + # .rotate_by(-0.25) + # .center_xy() + # .align_r() + .line_width(0) + .with_envelope(Empty()) + ) + + +# @dataclass(unsafe_hash=True, frozen=True) +# class ArrowHead(Shape): +# """Arrow Head.""" + +# arrow_shape: Diagram + +# def get_bounding_box(self) -> BoundingBox: +# # Arrow head don't have a bounding box since we can't accurately know +# # the size until rendering +# eps = 1e-4 +# self.bb = BoundingBox(tx.origin, tx.origin + P2(eps, eps)) +# return self.bb + +# def accept(self, visitor: ShapeVisitor[C], **kwargs: Any) -> C: +# return visitor.visit_arrowhead(self, **kwargs) diff --git a/chalk/backend/matplotlib.py b/chalk/backend/matplotlib.py new file mode 100644 index 0000000..55b4e56 --- /dev/null +++ b/chalk/backend/matplotlib.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import List, Optional + +import matplotlib.axes +import matplotlib.collections +import matplotlib.patches +import matplotlib.pyplot as plt +from matplotlib.path import Path + +import chalk.transform as tx +from chalk.backend.patch import Patch, order_patches +from chalk.style import StyleHolder +from chalk.types import Diagram + +EMPTY_STYLE = StyleHolder.empty() + + +def render_patches(patches: List[Patch], ax: matplotlib.axes.Axes) -> None: + ps = [] + for ind, patch, style_new in order_patches(patches): + ps.append( + matplotlib.patches.PathPatch( + Path(patch.vert[ind] * [1, -1], patch.command[ind]), + **style_new, + ) + ) + + collection = matplotlib.collections.PatchCollection( + ps, match_original=True + ) + ax.add_collection(collection) + + +def patches_to_file( + patches: List[Patch], path: str, height: tx.IntLike, width: tx.IntLike +) -> None: + fig, ax = plt.subplots() + render_patches(patches, ax) + ax.set_xlim((0, width)) + ax.set_ylim((-height, 0)) + ax.set_aspect("equal") + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + ax.set_axis_off() + fig.savefig(path, dpi=400) + + +def render( + self: Diagram, + path: str, + height: int = 128, + width: Optional[int] = None, + draw_height: Optional[int] = None, +) -> None: + prims, h, w = self.layout(height, width, draw_height) + patches_to_file(prims, path, h, w) # type: ignore diff --git a/chalk/path.py b/chalk/path.py new file mode 100644 index 0000000..c8b6573 --- /dev/null +++ b/chalk/path.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Optional, Tuple + +from chalk import transform as tx +from chalk.segment import Segment +from chalk.trail import Located, Trail +from chalk.transform import Batched, P2_t, Transformable +from chalk.types import BatchDiagram + + +@dataclass(frozen=True) +class Text: + text: tx.Array + + def to_str(self) -> str: + return self.text.tostring().decode("utf-8") # type: ignore + + +@dataclass(unsafe_hash=True) +class Path(Transformable, tx.Batchable): + """Path class.""" + + loc_trails: Tuple[Located, ...] + text: Optional[Text] = None + scale_invariant: Optional[tx.Mask] = None + + @property + def shape(self) -> Tuple[int, ...]: + if not self.loc_trails: + return () + return self.loc_trails[0].trail.segments.angles.shape[:-3] + + def remove_scale(self) -> Path: + return Path(self.loc_trails, self.text, tx.np.array(True)) + + def located_segments(self) -> Segment: + ls = Segment.empty() + for loc_trail in self.loc_trails: + if ls is None: # type: ignore + ls = loc_trail.located_segments() + else: + ls += loc_trail.located_segments() + return ls + + # Monoid - compose + @staticmethod + def empty() -> Path: + return Path(()) + + def __add__(self: BatchPath, other: BatchPath) -> BatchPath: + return Path(self.loc_trails + other.loc_trails) + + def apply_transform(self: BatchPath, t: tx.Affine) -> BatchPath: + return Path( + tuple( + [loc_trail.apply_transform(t) for loc_trail in self.loc_trails] + ) + ) + + def points(self) -> Iterable[P2_t]: + for loc_trails in self.loc_trails: + for pt in loc_trails.points(): + yield pt + + def stroke(self: BatchPath) -> BatchDiagram: + "Returns a primitive diagram from a path" + + from chalk.core import Primitive + + return Primitive.from_path(self) + + # Constructors + @staticmethod + def from_array(points: P2_t, closed: bool = False) -> Path: + l = points.shape[0] + if l == 0: + return Path.empty() + offsets = points[tx.np.arange(1, l)] - points[tx.np.arange(0, l - 1)] + trail = Trail.from_array(offsets, closed) + return Path(tuple([trail.at(points[0])])) + + # Constructors + + +def from_points(points: List[P2_t], closed: bool = False) -> Path: + return Path.from_array(tx.np.stack(points)) + + +def from_point(point: P2_t) -> Path: + return from_points([point]) + + +def from_text(s: str) -> Path: + return Path((), Text(tx.np.array(list(s), dtype="S1"))) + + +def from_pairs(segs: List[Tuple[P2_t, P2_t]], closed: bool = False) -> Path: + if not segs: + return Path.empty() + ls = [segs[0][0]] + for seg in segs: + assert seg[0] == ls[-1] + ls.append(seg[1]) + return from_points(ls, closed) + + +def from_list_of_tuples( + coords: List[Tuple[tx.Floating, tx.Floating]], closed: bool = False +) -> Path: + points = list([tx.P2(x, y) for x, y in coords]) + return from_points(points, closed) + + +BatchPath = Batched[Path, "*#B"] diff --git a/chalk/segment.py b/chalk/segment.py new file mode 100644 index 0000000..3cb27a2 --- /dev/null +++ b/chalk/segment.py @@ -0,0 +1,190 @@ +""" +Segment is a collection of ellipse arcs with starting angle and the delta. +Every diagram in chalk is made up of these segments. +They may be either located or at the origin depending on how they are used. +""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Tuple + +import chalk.transform as tx +from chalk.monoid import Monoid +from chalk.transform import Affine, Angles, Batchable, Batched, P2_t, V2_t + +if TYPE_CHECKING: + from jaxtyping import Array + + from chalk.trail import Trail + + +def ensure_3d(x: tx.Array) -> tx.Array: + if len(x.shape) < 3: + return x.reshape(-1, *x.shape) + return x + + +def ensure_2d(x: tx.Array) -> tx.Array: + if len(x.shape) < 2: + return x.reshape(-1, *x.shape) + return x + + +@dataclass(frozen=True) +class Segment(Monoid, Batchable): + """ + A batch of ellipse arcs with starting angle and the delta. + The monoid operation is the concat along the batch dimension. + """ + + transform: Affine + angles: Angles + + @property + def shape(self) -> Tuple[int, ...]: + return self.transform.shape[:-2] + + @property + def dtype(self) -> str: + return "seg" + + def tuple(self) -> Tuple[Affine, Angles]: + return self.transform, self.angles + + @staticmethod + def empty() -> Batched[Segment, "0"]: + return Segment( + tx.np.empty((0, 3, 3)), + tx.np.empty((0, 2)), + ) + + @staticmethod + def make(transform: Affine, angles: Angles) -> Segment_t: + assert angles.shape[-1] == 2 + angles = tx.prefix_broadcast(angles, transform.shape[:-2], 1) # type: ignore + return Segment(transform, angles.astype(float)) + + def promote(self) -> Segment: + "Ensures that there is a batch axis" + return Segment(ensure_3d(self.transform), ensure_2d(self.angles)) + + def to_trail(self) -> Trail: + from chalk.trail import Trail + + return Trail(self) + + def reduce(self, axis: int = 0) -> Segment: + return Segment( + self.transform.reshape(-1, 3, 3), self.angles.reshape(-1, 2) + ) + + # Transformable + def apply_transform(self, t: Affine) -> Segment_t: + return Segment.make(t @ self.transform, self.angles) + + def __add__(self, other: Segment) -> Segment: + if self.transform.shape[0] == 0: + return other + self, other = self.promote(), other.promote() + trans = [self.transform, other.transform] + angles = [self.angles, other.angles] + return Segment.make( + tx.np.concatenate(trans, axis=-3), + tx.np.concatenate(angles, axis=-2), + ) + + @property + def q(self: Segment_t) -> P2_t: + "Target point" + q: P2_t = tx.to_point(tx.polar(self.angles.sum(-1))) + q = self.transform @ q + return q + + @property + def center(self) -> P2_t: + center: P2_t = self.transform @ tx.P2(0, 0) + return center + + def is_in_mod_360(self, d: V2_t) -> tx.Mask: + angle0_deg = self.angles[..., 0] + angle1_deg = self.angles.sum(-1) + + low = tx.np.minimum(angle0_deg, angle1_deg) + high = tx.np.maximum(angle0_deg, angle1_deg) + check = (high - low) % 360 + return tx.np.asarray(((tx.angle(d) - low) % 360) <= check) + + +def arc_between(p: P2_t, q: P2_t, height: tx.Scalars) -> Segment_t: + h = abs(height) + d = tx.length(q - p) + # Determine the arc's angle θ and its radius r + θ = tx.np.arccos((d**2 - 4.0 * h**2) / (d**2 + 4.0 * h**2)) + r = d / (2 * tx.np.sin(θ)) + + # bend left + bl = height > 0 + φ = tx.np.where(bl, +tx.np.pi / 2, -tx.np.pi / 2) + dy = tx.np.where(bl, r - h, h - r) + flip = tx.np.where(bl, 1, -1) + + diff = q - p + angles = tx.np.stack( + [flip * -tx.from_radians(θ), flip * 2 * tx.from_radians(θ)], -1 + ) + ret = ( + tx.translation(p) + @ tx.rotation(-tx.rad(diff)) + @ tx.translation(tx.V2(d / 2, dy)) + @ tx.rotation(φ) + @ tx.scale(tx.V2(r, r)) + ) + return Segment.make(ret, angles) + + +@tx.jit +@partial(tx.vectorize, signature="(3,3),(2),(3,1)->()") +def arc_envelope(trans: Affine, angles: Angles, d: tx.V2_tC) -> Array: + """ + Compute the envelope for a batch of segments. + """ + angle0_deg = angles[..., 0] + angle1_deg = angles.sum(-1) + + is_circle = abs(angle0_deg - angle1_deg) >= 360 + v1 = tx.polar(angle0_deg) + v2 = tx.polar(angle1_deg) + + return tx.np.where( # type: ignore + (is_circle | Segment(trans, angles).is_in_mod_360(d)), + # Case 1: P2 at arc + 1 / tx.length(d), + # Case 2: P2 outside of arc + tx.np.maximum(tx.dot(d, v1), tx.dot(d, v2)), + ) + + +@tx.jit +@partial(tx.vectorize, signature="(3,3),(2),(3,1),(3,1)->(2),(2)") +def arc_trace( + trans: Affine, angles: Angles, p: tx.P2_tC, v: tx.V2_tC +) -> Tuple[tx.Array, tx.Array]: + """ + Computes the trace for a batch of segments. + """ + ray = tx.Ray(p, v) + segment = Segment(trans, angles) + d1, mask1, d2, mask2 = tx.ray_circle_intersection(ray.pt, ray.v, 1) + + # Mask out traces that are not in the angle range. + mask1 = mask1 & segment.is_in_mod_360(ray.point(d1)) + mask2 = mask2 & segment.is_in_mod_360(ray.point(d2)) + + d = tx.np.stack([d1, d2], -1) + mask = tx.np.stack([mask1, mask2], -1) + return d, mask + + +BatchSegment = Batched[Segment, "*#B"] +Segment_t = BatchSegment diff --git a/chalk/shapes.py b/chalk/shapes.py new file mode 100644 index 0000000..872db6e --- /dev/null +++ b/chalk/shapes.py @@ -0,0 +1,161 @@ +from typing import List, Optional, Tuple, Union + +import chalk.trail as Trail +import chalk.transform as tx +from chalk.path import from_list_of_tuples, from_text # noqa: F401 +from chalk.trail import arc_seg, arc_seg_angle # noqa: F401 +from chalk.transform import P2, P2_t +from chalk.types import BatchDiagram, Diagram + +# Functions mirroring Diagrams.2d.Shapes + + +def text(s: str, size: tx.Floating) -> Diagram: + return from_text(s).stroke().scale(size).scale_y(-1) + + +def hrule(length: tx.Floating) -> BatchDiagram: + return Trail.hrule(length).stroke().center_xy() + + +def vrule(length: tx.Floating) -> Diagram: + return Trail.vrule(length).stroke().center_xy() + + +# def polygon(sides: int, radius: float, rotation: float = 0) -> Diagram: +# """ +# Draw a polygon. + +# Args: +# sides (int): Number of sides. +# radius (float): Internal radius. +# rotation: (int): Rotation in degrees + +# Returns: +# Diagram +# """ +# return Trail.polygon(sides, radius, to_radians(rotation)).stroke() + + +def regular_polygon(sides: int, side_length: tx.Floating) -> Diagram: + """Draws a regular polygon with given number of sides and given side + length. The polygon is oriented with one edge parallel to the x-axis.""" + return Trail.regular_polygon(sides, side_length).centered().stroke() + + +def triangle(width: tx.Floating) -> Diagram: + """Draws an equilateral triangle with the side length specified by + the ``width`` argument. The origin is the traingle's centroid.""" + return regular_polygon(3, width) + + +def make_path( + segments: List[Tuple[tx.Floating, tx.Floating]], closed: bool = False +) -> Diagram: + p = from_list_of_tuples(segments, closed).stroke() + return p + + +def rectangle( + width: tx.Floating, height: tx.Floating, radius: Optional[float] = None +) -> BatchDiagram: + """ + Draws a rectangle. + + Args: + width (float): Width + height (float): Height + radius (Optional[float]): Radius for rounded corners. + + Returns: + Diagrams + """ + if radius is None: + return ( + Trail.square() + .stroke() + .scale_x(width) + .scale_y(height) + .translate(-width / 2, -height / 2) + ) + else: + return ( + Trail.rounded_rectangle(width, height, radius) + .stroke() + .translate(-width / 2, -height / 2) + ) + + +def square(side: tx.Floating) -> BatchDiagram: + """Draws a square with the specified side length. The origin is the + center of the square.""" + return rectangle(side, side) + + +def circle(radius: tx.Floating) -> BatchDiagram: + "Draws a circle with the specified ``radius``." + return ( + Trail.circle().stroke().translate(radius, 0).scale(radius).center_xy() + ) + + +def arc( + radius: tx.Floating, angle0: tx.Floating, angle1: tx.Floating +) -> Diagram: + """ + Draws an arc. + + Args: + radius (float): Circle radius. + angle0 (float): Starting cutoff in degrees. + angle1 (float): Finishing cutoff in degrees. + + Returns: + Diagram + + """ + return ( + arc_seg_angle(tx.ftos(angle0), tx.ftos(angle1 - angle0)) + .at(tx.polar(angle0)) + .stroke() + .scale(radius) + .fill_opacity(0) + ) + + +def arc_between( + point1: Union[P2_t, Tuple[float, float]], + point2: Union[P2_t, Tuple[float, float]], + height: float, +) -> Diagram: + """Makes an arc starting at point1 and ending at point2, with the midpoint + at a distance of abs(height) away from the straight line from point1 to + point2. A positive value of height results in an arc to the left of the + line from point1 to point2; a negative value yields one to the right. + The implementation is based on the the function arcBetween from Haskell's + diagrams: + https://hackage.haskell.org/package/diagrams-lib-1.4.5.1/docs/src/Diagrams.TwoD.Arc.html#arcBetween + """ + p = point1 if not isinstance(point1, tuple) else P2(*point1) + q = point2 if not isinstance(point2, tuple) else P2(*point2) + return arc_seg(q - p, height).at(p).stroke().fill_opacity(0) + + +def Spacer(width: tx.Floating, height: tx.Floating) -> Diagram: + return ( + rectangle(tx.np.maximum(width, 1e-5), tx.np.maximum(height, 1e-5)) + .fill_opacity(0) + .line_width(0) + ) + + +def hstrut(width: tx.Floating) -> Diagram: + return Spacer(tx.ftos(width), tx.ftos(0)) + + +def strut(width: tx.Floating, height: tx.Floating) -> Diagram: + return Spacer(tx.ftos(width), tx.ftos(height)) + + +def vstrut(height: tx.Floating) -> Diagram: + return Spacer(tx.ftos(0), tx.ftos(height))