"""Line2D and Polygon2D: 2D shape rendering nodes."""
import math
from itertools import pairwise
from ..descriptors import Property
from ..math.types import Vec2
from ..properties import Colour
from .node2d import Node2D
#: How finely a round joint or cap is approximated: one segment per this much
#: arc, so a right-angle bend costs four triangles and a half-disc cap eight.
_ARC_STEP = math.pi / 8.0
#: The ratio of miter length to half-width past which a sharp joint would shoot
#: a spike off a near-reversal and falls back to a bevel instead. This is the
#: SVG ``stroke-miterlimit`` default.
_MITER_LIMIT = 4.0
#: A gradient is drawn as abutting bands, because both 2D drawing surfaces take
#: one colour per call. The whole polyline is cut into at least this many, so a
#: step between neighbouring bands stays under two percent of the ramp.
_GRADIENT_BANDS = 64
def _unit(ax: float, ay: float, bx: float, by: float) -> tuple[float, float] | None:
"""The unit vector from a to b, or None when the two coincide."""
dx, dy = bx - ax, by - ay
length = math.hypot(dx, dy)
if length < 1e-9:
return None
return dx / length, dy / length
def _arc(cx: float, cy: float, radius: float, start: float, sweep: float) -> list[tuple[float, float]]:
"""Points along an arc, inclusive of both ends."""
steps = max(1, math.ceil(abs(sweep) / _ARC_STEP))
return [
(cx + math.cos(start + sweep * i / steps) * radius, cy + math.sin(start + sweep * i / steps) * radius)
for i in range(steps + 1)
]
def _joint_polygon(
before: tuple[float, float],
vertex: tuple[float, float],
after: tuple[float, float],
half_width: float,
mode: str,
) -> list[tuple[float, float]] | None:
"""The wedge that fills the notch a bend leaves between two segment quads.
Returns None when the two segments run straight on and leave no notch. The
wedge covers the outside of the bend only and never overlaps either quad,
so a translucent line does not blend twice along its own joints.
"""
incoming = _unit(*before, *vertex)
outgoing = _unit(*vertex, *after)
if incoming is None or outgoing is None:
return None
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
if abs(cross) < 1e-9 and incoming[0] * outgoing[0] + incoming[1] * outgoing[1] > 0.0:
return None
# The notch opens on the outside of the bend, which is the right-hand side
# of travel through a left turn and the left-hand side through a right one.
side = -1.0 if cross > 0.0 else 1.0
n0 = (-incoming[1] * side, incoming[0] * side)
n1 = (-outgoing[1] * side, outgoing[0] * side)
a = (vertex[0] + n0[0] * half_width, vertex[1] + n0[1] * half_width)
b = (vertex[0] + n1[0] * half_width, vertex[1] + n1[1] * half_width)
if mode == "round":
start = math.atan2(n0[1], n0[0])
sweep = (math.atan2(n1[1], n1[0]) - start + math.pi) % math.tau - math.pi
return [vertex, *_arc(vertex[0], vertex[1], half_width, start, sweep)]
if mode == "sharp":
mx, my = n0[0] + n1[0], n0[1] + n1[1]
length = math.hypot(mx, my)
if length > 1e-9:
mx, my = mx / length, my / length
# The cosine of half the turn, which is also the reciprocal of how
# far the miter tip reaches in half-widths.
cos_half = mx * n0[0] + my * n0[1]
if cos_half > 1.0 / _MITER_LIMIT:
reach = half_width / cos_half
return [vertex, a, (vertex[0] + mx * reach, vertex[1] + my * reach), b]
return [vertex, a, b]
def _cap_polygon(
point: tuple[float, float], outward: tuple[float, float], half_width: float
) -> list[tuple[float, float]]:
"""The half disc that caps one end of the line, centred on *point*."""
start = math.atan2(outward[1], outward[0]) - math.pi / 2.0
return _arc(point[0], point[1], half_width, start, math.pi)
def _arc_length_fractions(path: list[tuple[float, float]]) -> list[float]:
"""How far along the line each point sits, from 0.0 at the first to 1.0 at the last."""
lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in pairwise(path)]
total = sum(lengths)
offsets = [0.0]
run = 0.0
for length in lengths:
run += length
offsets.append(run / total)
return offsets
def _gradient_stops(gradient) -> list[tuple[float, tuple[float, ...]]]:
"""Normalise a gradient declaration into sorted ``(t, rgba)`` stops."""
stops: list[tuple[float, tuple[float, ...]]] = []
for stop in gradient:
try:
position, colour = stop
rgba = tuple(float(channel) for channel in colour)
except (TypeError, ValueError) as exc:
raise ValueError(f"Line2D.gradient: each stop is (t, colour), got {stop!r}") from exc
if len(rgba) == 3:
rgba = (*rgba, 1.0)
elif len(rgba) != 4:
raise ValueError(f"Line2D.gradient: a stop colour has three or four channels, got {colour!r}")
stops.append((float(position), rgba))
stops.sort(key=lambda stop: stop[0])
return stops
def _sample_gradient(stops: list[tuple[float, tuple[float, ...]]], t: float) -> tuple[float, ...]:
"""The colour the gradient shows at *t* along the line, clamped at both ends."""
if t <= stops[0][0]:
return stops[0][1]
if t >= stops[-1][0]:
return stops[-1][1]
for (t0, c0), (t1, c1) in pairwise(stops):
if t <= t1:
span = t1 - t0
k = 0.0 if span <= 0.0 else (t - t0) / span
return tuple(a + (b - a) * k for a, b in zip(c0, c1, strict=True))
return stops[-1][1]
[docs]
class Line2D(Node2D):
"""Anti-aliased polyline with configurable width and colour.
Stores a list of (x, y) points and draws connected line segments.
``joint_mode`` fills the notch a bend leaves between two segments, with a
miter (``"sharp"``, falling back to a bevel past a four-to-one spike), a
``"bevel"`` triangle or a ``"round"`` arc. ``begin_cap_mode`` and
``end_cap_mode`` add a half disc beyond the first and last point.
``gradient`` is a list of ``(t, colour)`` stops, ``t`` running from 0 at the
first point to 1 at the last measured along the line, and it replaces
``colour`` rather than tinting it.
Joints and caps are geometry expanded from ``width``, in the space the
points are drawn in, so they follow the same weight policy as the segments
themselves. A ``width`` of 1.0 or less is a hairline: it rides the one-pixel
line pipeline whatever the camera is doing, so it has no width for a joint
or a cap to fill and neither is drawn.
"""
points = Property((), hint="List of (x, y) tuples")
width = Property(2.0, range=(0.1, 1000.0), hint="Line width in pixels")
colour = Colour((1.0, 1.0, 1.0, 1.0))
joint_mode = Property("sharp", enum=["sharp", "bevel", "round"], hint="Joint style")
begin_cap_mode = Property("none", enum=["none", "round"], hint="Start cap style")
end_cap_mode = Property("none", enum=["none", "round"], hint="End cap style")
gradient = Property(None, hint="Optional list of (t, colour) for gradient along line")
gizmo_colour = Colour((0.2, 0.9, 0.4, 0.7))
[docs]
def on_draw(self, renderer):
"""Draw the polyline with its joints, caps and gradient.
Renderer must support ``draw_line()`` and ``draw_polygon()``.
"""
path = self._world_path()
if path is None:
return
width = self.width
gradient = self.gradient
stops = _gradient_stops(gradient) if gradient else None
offsets = _arc_length_fractions(path)
if stops is None:
colour = self.colour
for a, b in pairwise(path):
renderer.draw_line(a, b, colour=colour, thickness=width)
else:
self._draw_gradient_bands(renderer, path, offsets, stops, width)
if width <= 1.0:
return
half_width = width * 0.5
def colour_at(t):
return self.colour if stops is None else _sample_gradient(stops, t)
mode = self.joint_mode
for i in range(1, len(path) - 1):
wedge = _joint_polygon(path[i - 1], path[i], path[i + 1], half_width, mode)
if wedge is not None:
renderer.draw_polygon(wedge, colour=colour_at(offsets[i]))
# _world_path drops coincident points, so every adjacent pair of the path
# has a direction and _unit cannot come back empty-handed here.
if self.begin_cap_mode == "round":
outward = _unit(*path[1], *path[0])
assert outward is not None
renderer.draw_polygon(_cap_polygon(path[0], outward, half_width), colour=colour_at(0.0))
if self.end_cap_mode == "round":
outward = _unit(*path[-2], *path[-1])
assert outward is not None
renderer.draw_polygon(_cap_polygon(path[-1], outward, half_width), colour=colour_at(1.0))
def _world_path(self) -> list[tuple[float, float]] | None:
"""The drawn points in world space, or None when there is nothing to draw.
Repeated points are dropped: a zero-length segment has no direction, so
it can carry neither a quad nor a joint.
"""
pts = self.points
if len(pts) < 2:
return None
transformed = self.transform_points([Vec2(p[0], p[1]) for p in pts])
path = [(float(transformed[0].x), float(transformed[0].y))]
for point in transformed[1:]:
x, y = float(point.x), float(point.y)
if math.hypot(x - path[-1][0], y - path[-1][1]) > 1e-9:
path.append((x, y))
return path if len(path) >= 2 else None
def _draw_gradient_bands(self, renderer, path, offsets, stops, width) -> None:
"""Draw the segments as bands, each one flat-coloured at its midpoint.
The cuts carry every gradient stop, so a hard stop lands exactly where
it was authored rather than on the nearest band edge.
"""
cuts = {0.0, 1.0}
cuts.update(offsets)
cuts.update(position for position, _ in stops if 0.0 < position < 1.0)
cuts.update(i / _GRADIENT_BANDS for i in range(1, _GRADIENT_BANDS))
ordered = sorted(cuts)
cursor = 0
for i, (a, b) in enumerate(pairwise(path)):
t0, t1 = offsets[i], offsets[i + 1]
while cursor < len(ordered) and ordered[cursor] <= t0:
cursor += 1
previous_t, previous_point = t0, a
while True:
t = ordered[cursor] if cursor < len(ordered) and ordered[cursor] < t1 else t1
k = (t - t0) / (t1 - t0)
point = (a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k)
renderer.draw_line(
previous_point,
point,
colour=_sample_gradient(stops, (previous_t + t) * 0.5),
thickness=width,
)
if t >= t1:
break
cursor += 1
previous_t, previous_point = t, point
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return the polyline as connected line segments in world space."""
pts = self.points
if len(pts) < 2:
return []
transformed = self.transform_points([Vec2(p[0], p[1]) for p in pts])
return [(transformed[i], transformed[i + 1]) for i in range(len(transformed) - 1)]
[docs]
class Polygon2D(Node2D):
"""Filled polygon rendered from a list of vertices.
Vertices are in local space and transformed by the node's position,
rotation, and scale.
"""
polygon = Property((), hint="List of (x, y) vertices")
colour = Colour((1.0, 1.0, 1.0, 1.0))
[docs]
def on_draw(self, renderer):
"""Draw the filled polygon. Renderer must support draw_polygon()."""
verts = self.polygon
if len(verts) < 3:
return
# Apply full node transform (position + rotation + scale)
transformed = self.transform_points([Vec2(v[0], v[1]) for v in verts])
renderer.draw_polygon([(t.x, t.y) for t in transformed], colour=self.colour)