import uuid
from typing import List, Optional, Tuple
from .color import Color
from .xform import Xform
from .plane import Plane
from .point import Point
from .tolerance import Tolerance
from .vector import Vector
[docs]
class Polyline:
"""A polyline defined by a collection of points with an associated plane."""
[docs]
def __init__(self, points: Optional[List[Point]] = None):
"""Creates a new Polyline with default guid and name.
Args:
points: The collection of points.
"""
self.guid = str(uuid.uuid4())
self.name = "my_polyline"
self.points = points if points is not None else []
self.width = 1.0
self.linecolor = Color.white()
self.xform = Xform.identity()
# Delegate plane computation to Plane.from_points
if len(self.points) >= 3:
self.plane = Plane.from_points(self.points)
else:
self.plane = Plane()
def __len__(self) -> int:
"""Returns the number of points in the polyline."""
return len(self.points)
[docs]
def is_empty(self) -> bool:
"""Returns true if the polyline has no points."""
return len(self.points) == 0
[docs]
def segment_count(self) -> int:
"""Returns the number of segments (n-1 for n points)."""
return len(self.points) - 1 if len(self.points) > 1 else 0
[docs]
def length(self) -> float:
"""Calculates the total length of the polyline."""
total_length = 0.0
for i in range(self.segment_count()):
segment_vector = self.points[i + 1] - self.points[i]
total_length += segment_vector.magnitude()
return total_length
[docs]
def get_point(self, index: int) -> Optional[Point]:
"""Returns the point at the given index, or None if out of bounds."""
if 0 <= index < len(self.points):
return self.points[index]
return None
[docs]
def add_point(self, point: Point) -> None:
"""Adds a point to the end of the polyline."""
self.points.append(point)
if len(self.points) == 3:
self._recompute_plane()
[docs]
def insert_point(self, index: int, point: Point) -> None:
"""Inserts a point at the specified index."""
self.points.insert(index, point)
if len(self.points) == 3:
self._recompute_plane()
[docs]
def remove_point(self, index: int) -> Optional[Point]:
"""Removes and returns the point at the specified index."""
if 0 <= index < len(self.points):
point = self.points.pop(index)
if len(self.points) == 3:
self._recompute_plane()
return point
return None
[docs]
def reverse(self) -> None:
"""Reverses the order of points in the polyline."""
self.points.reverse()
self.plane.reverse()
[docs]
def reversed(self) -> "Polyline":
"""Returns a new polyline with reversed point order."""
result = Polyline(self.points[:])
result.guid = self.guid
result.name = self.name
result.plane = self.plane
result.reverse()
return result
def _recompute_plane(self) -> None:
"""Helper to recompute plane when points change."""
if len(self.points) >= 3:
self.plane = Plane.from_points(self.points)
def __iadd__(self, vector: Vector) -> "Polyline":
"""Translates all points in the polyline by a vector (+=)."""
for point in self.points:
point += vector
# Update plane origin
self.plane = Plane(
self.plane.origin + vector, self.plane.x_axis, self.plane.y_axis
)
return self
def __add__(self, vector: Vector) -> "Polyline":
"""Translates the polyline by a vector and returns a new polyline (+)."""
result = Polyline([Point(p.x, p.y, p.z) for p in self.points])
result.guid = self.guid
result.name = self.name
result.plane = self.plane
result += vector
return result
def __isub__(self, vector: Vector) -> "Polyline":
"""Translates all points by the negative of a vector (-=)."""
for point in self.points:
point -= vector
# Update plane origin
self.plane = Plane(
self.plane.origin - vector, self.plane.x_axis, self.plane.y_axis
)
return self
def __sub__(self, vector: Vector) -> "Polyline":
"""Translates the polyline by the negative of a vector and returns a new polyline (-)."""
result = Polyline([Point(p.x, p.y, p.z) for p in self.points])
result.guid = self.guid
result.name = self.name
result.plane = self.plane
result -= vector
return result
def __str__(self) -> str:
"""Returns a string representation of the polyline."""
return (
f"Polyline(guid={self.guid}, name={self.name}, points={len(self.points)})"
)
def __repr__(self) -> str:
"""Returns a detailed string representation."""
return self.__str__()
# ===========================================================================================
# Geometric Utilities
# ===========================================================================================
[docs]
def shift(self, times: int) -> None:
"""Shift polyline points by specified number of positions."""
if not self.points:
return
n = len(self.points)
shift_amount = times % n
self.points = self.points[shift_amount:] + self.points[:shift_amount]
[docs]
def length_squared(self) -> float:
"""Calculate squared length of polyline (faster, no sqrt)."""
length = 0.0
for i in range(self.segment_count()):
segment = self.points[i + 1] - self.points[i]
length += segment.length_squared()
return length
[docs]
@staticmethod
def point_at_parameter(start: Point, end: Point, t: float) -> Point:
"""Get point at parameter t along a line segment (t=0 is start, t=1 is end)."""
s = 1.0 - t
return Point(
start.x if start.x == end.x else s * start.x + t * end.x,
start.y if start.y == end.y else s * start.y + t * end.y,
start.z if start.z == end.z else s * start.z + t * end.z,
)
[docs]
@staticmethod
def closest_point_to_line(
point: Point, line_start: Point, line_end: Point
) -> float:
"""Find closest point on line segment to given point, returns parameter t."""
d = line_end - line_start
dod = d.length_squared()
if dod > 0.0:
if (point - line_start).length_squared() <= (
point - line_end
).length_squared():
t = (point - line_start).dot(d) / dod
else:
t = 1.0 + (point - line_end).dot(d) / dod
return t
else:
return 0.0
[docs]
@staticmethod
def line_line_overlap(
line0_start: Point,
line0_end: Point,
line1_start: Point,
line1_end: Point,
) -> Optional[Tuple[Point, Point]]:
"""Check if two line segments overlap and return the overlapping segment."""
t = [0.0, 1.0, 0.0, 0.0]
t[2] = Polyline.closest_point_to_line(line1_start, line0_start, line0_end)
t[3] = Polyline.closest_point_to_line(line1_end, line0_start, line0_end)
do_overlap = not ((t[2] < 0.0 and t[3] < 0.0) or (t[2] > 1.0 and t[3] > 1.0))
t.sort()
overlap_valid = abs(t[2] - t[1]) > Tolerance.ZERO_TOLERANCE
if do_overlap and overlap_valid:
return (
Polyline.point_at_parameter(line0_start, line0_end, t[1]),
Polyline.point_at_parameter(line0_start, line0_end, t[2]),
)
else:
return None
[docs]
@staticmethod
def line_line_average(
line0_start: Point,
line0_end: Point,
line1_start: Point,
line1_end: Point,
) -> Tuple[Point, Point]:
"""Calculate average of two line segments."""
output_start = Point(
(line0_start.x + line1_start.x) * 0.5,
(line0_start.y + line1_start.y) * 0.5,
(line0_start.z + line1_start.z) * 0.5,
)
output_end = Point(
(line0_end.x + line1_end.x) * 0.5,
(line0_end.y + line1_end.y) * 0.5,
(line0_end.z + line1_end.z) * 0.5,
)
return output_start, output_end
[docs]
@staticmethod
def line_line_overlap_average(
line0_start: Point,
line0_end: Point,
line1_start: Point,
line1_end: Point,
) -> Tuple[Point, Point]:
"""Calculate overlap average of two line segments."""
line_a = Polyline.line_line_overlap(
line0_start, line0_end, line1_start, line1_end
)
line_b = Polyline.line_line_overlap(
line1_start, line1_end, line0_start, line0_end
)
if line_a and line_b:
line_a_start, line_a_end = line_a
line_b_start, line_b_end = line_b
mid_line0_start = Point(
(line_a_start.x + line_b_start.x) * 0.5,
(line_a_start.y + line_b_start.y) * 0.5,
(line_a_start.z + line_b_start.z) * 0.5,
)
mid_line0_end = Point(
(line_a_end.x + line_b_end.x) * 0.5,
(line_a_end.y + line_b_end.y) * 0.5,
(line_a_end.z + line_b_end.z) * 0.5,
)
mid_line1_start = Point(
(line_a_start.x + line_b_end.x) * 0.5,
(line_a_start.y + line_b_end.y) * 0.5,
(line_a_start.z + line_b_end.z) * 0.5,
)
mid_line1_end = Point(
(line_a_end.x + line_b_start.x) * 0.5,
(line_a_end.y + line_b_start.y) * 0.5,
(line_a_end.z + line_b_start.z) * 0.5,
)
mid0_vec = mid_line0_end - mid_line0_start
mid1_vec = mid_line1_end - mid_line1_start
if mid0_vec.length_squared() > mid1_vec.length_squared():
return mid_line0_start, mid_line0_end
else:
return mid_line1_start, mid_line1_end
else:
return Polyline.line_line_average(
line0_start, line0_end, line1_start, line1_end
)
[docs]
@staticmethod
def line_from_projected_points(
line_start: Point,
line_end: Point,
points: List[Point],
) -> Optional[Tuple[Point, Point]]:
"""Create line from projected points onto a base line."""
if not points:
return None
t_values = [
Polyline.closest_point_to_line(p, line_start, line_end) for p in points
]
t_values.sort()
output_start = Polyline.point_at_parameter(line_start, line_end, t_values[0])
output_end = Polyline.point_at_parameter(line_start, line_end, t_values[-1])
if abs(t_values[0] - t_values[-1]) > Tolerance.ZERO_TOLERANCE:
return output_start, output_end
else:
return None
[docs]
def closest_distance_and_point(self, point: Point) -> Tuple[float, int, Point]:
"""Find closest distance and point from a point to this polyline."""
edge_id = 0
closest_distance = float("inf")
best_t = 0.0
for i in range(self.segment_count()):
t = self.closest_point_to_line(point, self.points[i], self.points[i + 1])
point_on_segment = self.point_at_parameter(
self.points[i], self.points[i + 1], t
)
distance = point.distance(point_on_segment)
if distance < closest_distance:
closest_distance = distance
edge_id = i
best_t = t
if closest_distance < Tolerance.ZERO_TOLERANCE:
break
closest_point = self.point_at_parameter(
self.points[edge_id], self.points[edge_id + 1], best_t
)
return closest_distance, edge_id, closest_point
[docs]
def is_closed(self) -> bool:
"""Check if polyline is closed (first and last points are the same)."""
if len(self.points) < 2:
return False
return self.points[0].distance(self.points[-1]) < Tolerance.ZERO_TOLERANCE
[docs]
def center(self) -> Point:
"""Calculate center point of polyline."""
if not self.points:
return Point(0.0, 0.0, 0.0)
n = (
len(self.points) - 1
if self.is_closed() and len(self.points) > 1
else len(self.points)
)
sum_x = sum(self.points[i].x for i in range(n))
sum_y = sum(self.points[i].y for i in range(n))
sum_z = sum(self.points[i].z for i in range(n))
return Point(sum_x / n, sum_y / n, sum_z / n)
[docs]
def center_vec(self) -> Vector:
"""Calculate center as vector."""
center = self.center()
return Vector(center.x, center.y, center.z)
[docs]
def get_average_plane(self) -> Tuple[Point, Vector, Vector, Vector]:
"""Get average plane from polyline points."""
origin = self.center()
if len(self.points) >= 2:
x_axis = (self.points[1] - self.points[0]).normalize()
else:
x_axis = Vector(1.0, 0.0, 0.0)
z_axis = self._average_normal()
y_axis = z_axis.cross(x_axis).normalize()
return origin, x_axis, y_axis, z_axis
[docs]
def get_fast_plane(self) -> Tuple[Point, Plane]:
"""Get fast plane calculation from polyline."""
origin = self.points[0] if self.points else Point(0.0, 0.0, 0.0)
average_normal = self._average_normal()
plane = Plane.from_point_normal(origin, average_normal)
return origin, plane
[docs]
@staticmethod
def get_middle_line(
line0_start: Point,
line0_end: Point,
line1_start: Point,
line1_end: Point,
) -> Tuple[Point, Point]:
"""Calculate middle line between two line segments."""
p0 = Point(
(line0_start.x + line1_start.x) * 0.5,
(line0_start.y + line1_start.y) * 0.5,
(line0_start.z + line1_start.z) * 0.5,
)
p1 = Point(
(line0_end.x + line1_end.x) * 0.5,
(line0_end.y + line1_end.y) * 0.5,
(line0_end.z + line1_end.z) * 0.5,
)
return p0, p1
[docs]
@staticmethod
def extend_line(
line_start: Point, line_end: Point, distance0: float, distance1: float
) -> None:
"""Extend line segment by specified distances at both ends."""
v = (line_end - line_start).normalize()
line_start -= v * distance0
line_end += v * distance1
[docs]
@staticmethod
def scale_line(line_start: Point, line_end: Point, distance: float) -> None:
"""Scale line segment inward by specified distance."""
v = line_end - line_start
line_start += v * distance
line_end -= v * distance
[docs]
def extend_segment(
self,
segment_id: int,
dist0: float,
dist1: float,
proportion0: float = 0.0,
proportion1: float = 0.0,
) -> None:
"""Extend polyline segment."""
if segment_id < 0 or segment_id >= self.segment_count():
return
p0 = self.points[segment_id]
p1 = self.points[segment_id + 1]
v = p1 - p0
if proportion0 != 0.0 or proportion1 != 0.0:
p0 -= v * proportion0
p1 += v * proportion1
else:
v_norm = v.normalize()
p0 -= v_norm * dist0
p1 += v_norm * dist1
self.points[segment_id] = p0
self.points[segment_id + 1] = p1
if self.is_closed():
if segment_id == 0:
self.points[-1] = self.points[0]
elif segment_id + 1 == len(self.points) - 1:
self.points[0] = self.points[-1]
[docs]
@staticmethod
def extend_segment_equally_static(
segment_start: Point, segment_end: Point, dist: float, proportion: float = 0.0
) -> None:
"""Extend segment equally on both ends (static utility)."""
if dist == 0.0 and proportion == 0.0:
return
v = segment_end - segment_start
if proportion != 0.0:
segment_start -= v * proportion
segment_end += v * proportion
else:
v_norm = v.normalize()
segment_start -= v_norm * dist
segment_end += v_norm * dist
[docs]
def extend_segment_equally(
self, segment_id: int, dist: float, proportion: float = 0.0
) -> None:
"""Extend polyline segment equally."""
if segment_id < 0 or segment_id >= self.segment_count():
return
start = self.points[segment_id]
end = self.points[segment_id + 1]
self.extend_segment_equally_static(start, end, dist, proportion)
self.points[segment_id] = start
self.points[segment_id + 1] = end
if len(self.points) > 2 and self.is_closed():
if segment_id == 0:
self.points[-1] = self.points[0]
elif segment_id + 1 == len(self.points) - 1:
self.points[0] = self.points[-1]
[docs]
def move_by(self, direction: Vector) -> None:
"""Move polyline by direction vector."""
for point in self.points:
point += direction
[docs]
def is_clockwise(self, plane: Plane) -> bool:
"""Check if polyline is clockwise oriented."""
if len(self.points) < 3:
return False
sum_val = 0.0
n = len(self.points) - 1 if self.is_closed() else len(self.points)
for i in range(n):
current = self.points[i]
next_pt = self.points[(i + 1) % n]
sum_val += (next_pt.x - current.x) * (next_pt.y + current.y)
return sum_val > 0.0
[docs]
def flip(self) -> None:
"""Flip polyline direction (reverse point order)."""
self.points.reverse()
[docs]
def get_convex_corners(self) -> List[bool]:
"""Get convex/concave corners of polyline."""
if len(self.points) < 3:
return []
closed = self.is_closed()
normal = self._average_normal()
n = len(self.points) - 1 if closed else len(self.points)
convex_corners = []
for current in range(n):
prev = n - 1 if current == 0 else current - 1
next_pt = 0 if current == n - 1 else current + 1
dir0 = (self.points[current] - self.points[prev]).normalize()
dir1 = (self.points[next_pt] - self.points[current]).normalize()
cross = dir0.cross(dir1).normalize()
dot = cross.dot(normal)
is_convex = not (dot < 0.0)
convex_corners.append(is_convex)
return convex_corners
[docs]
@staticmethod
def tween_two_polylines(
polyline0: "Polyline", polyline1: "Polyline", weight: float
) -> "Polyline":
"""Interpolate between two polylines."""
if len(polyline0.points) != len(polyline1.points):
return Polyline(polyline0.points[:])
result_points = []
for i in range(len(polyline0.points)):
diff = polyline1.points[i] - polyline0.points[i]
interpolated = polyline0.points[i] + diff * weight
result_points.append(interpolated)
return Polyline(result_points)
def _average_normal(self) -> Vector:
"""Calculate average normal from polyline points."""
if len(self.points) < 3:
return Vector(0.0, 0.0, 1.0)
closed = self.is_closed()
n = (
len(self.points) - 1
if closed and len(self.points) > 1
else len(self.points)
)
average_normal = Vector(0.0, 0.0, 0.0)
for i in range(n):
prev = n - 1 if i == 0 else i - 1
next_pt = (i + 1) % n
v1 = self.points[prev] - self.points[i]
v2 = self.points[i] - self.points[next_pt]
cross = v1.cross(v2)
average_normal += cross
return average_normal.normalize()
###########################################################################################
# Polymorphic JSON Serialization
###########################################################################################
def __jsondump__(self):
"""Serialize to polymorphic JSON format with type field.
Returns
-------
dict
Dictionary with 'type', 'guid', 'name', and object fields.
"""
return {
"type": f"{self.__class__.__name__}",
"guid": self.guid,
"name": self.name,
"points": [p.__jsondump__() for p in self.points],
"plane": self.plane.__jsondump__() if self.plane else None,
"width": self.width,
"linecolor": self.linecolor.__jsondump__(),
}
@classmethod
def __jsonload__(cls, data, guid=None, name=None):
"""Deserialize from polymorphic JSON format.
Parameters
----------
data : dict
Dictionary containing polyline data.
guid : str, optional
GUID for the polyline.
name : str, optional
Name for the polyline.
Returns
-------
:class:`Polyline`
Reconstructed polyline instance.
"""
from .encoders import decode_node
points = [decode_node(p) for p in data["points"]]
plane = decode_node(data["plane"]) if data.get("plane") else None
polyline = cls(points)
polyline.plane = plane
polyline.guid = guid
polyline.name = name
if "width" in data:
polyline.width = data["width"]
if "linecolor" in data:
polyline.linecolor = decode_node(data["linecolor"])
if "xform" in data:
obj.xform = decode_node(data["xform"])
return polyline