diff --git a/src/specklepy/objects/__init__.py b/src/specklepy/objects/__init__.py new file mode 100644 index 0000000..a7c9ec8 --- /dev/null +++ b/src/specklepy/objects/__init__.py @@ -0,0 +1,3 @@ +from .geometry import * + +__all__ = ["Point", "Vector", "Plane"] diff --git a/src/specklepy/objects/geometry.py b/src/specklepy/objects/geometry.py deleted file mode 100644 index dcf089d..0000000 --- a/src/specklepy/objects/geometry.py +++ /dev/null @@ -1,300 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Tuple - -from specklepy.objects.base import Base -from specklepy.objects.interfaces import ICurve, IHasArea, IHasUnits, IHasVolume -from specklepy.objects.models.units import ( - Units, - get_encoding_from_units, - get_scale_factor, - get_units_from_string, -) -from specklepy.objects.primitive import Interval - - -@dataclass(kw_only=True) -class Point(Base, IHasUnits, speckle_type="Objects.Geometry.Point"): - """ - a 3-dimensional point - """ - - x: float - y: float - z: float - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(x: {self.x}, y: {self.y}, z: {self.z}, units: {self.units})" - - def to_list(self) -> List[float]: - return [self.x, self.y, self.z] - - @classmethod - def from_list(cls, coords: List[float], units: str | Units) -> "Point": - return cls(x=coords[0], y=coords[1], z=coords[2], units=units) - - @classmethod - def from_coords(cls, x: float, y: float, z: float, units: str | Units) -> "Point": - return cls(x=x, y=y, z=z, units=units) - - def distance_to(self, other: "Point") -> float: - """ - calculates the distance between this point and another given point. - """ - if not isinstance(other, Point): - raise TypeError(f"Expected Point object, got {type(other)}") - - # if units are the same perform direct calculation - if self.units == other.units: - dx = other.x - self.x - dy = other.y - self.y - dz = other.z - self.z - return (dx * dx + dy * dy + dz * dz) ** 0.5 - - # convert other point's coordinates to this point's units - scale_factor = get_scale_factor( - get_units_from_string( - other.units), get_units_from_string(self.units) - ) - - dx = (other.x * scale_factor) - self.x - dy = (other.y * scale_factor) - self.y - dz = (other.z * scale_factor) - self.z - - return (dx * dx + dy * dy + dz * dz) ** 0.5 - - -@dataclass(kw_only=True) -class Line(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Line"): - """ - a line defined by two points in 3D space - """ - - start: Point - end: Point - domain: Interval = field(default_factory=Interval.unit_interval) - - @property - def length(self) -> float: - """ - calculate the length of the line using Point's distance_to method - """ - return self.start.distance_to(self.end) - - @property - def _domain(self) -> Interval: - return self.domain - - def to_list(self) -> List[float]: - result = [] - result.extend(self.start.to_list()) - result.extend(self.end.to_list()) - result.extend([self.domain.start, self.domain.end]) - return result - - @classmethod - def from_list(cls, coords: List[float], units: str | Units) -> "Line": - if len(coords) < 6: - raise ValueError( - "Line from coordinate array requires 6 coordinates.") - - start = Point(x=coords[0], y=coords[1], z=coords[2], units=units) - end = Point(x=coords[3], y=coords[4], z=coords[5], units=units) - - return cls(start=start, end=end, units=units) - - @classmethod - def from_coords( - cls, - start_x: float, - start_y: float, - start_z: float, - end_x: float, - end_y: float, - end_z: float, - units: str, - ) -> "Line": - start = Point(x=start_x, y=start_y, z=start_z, units=units) - end = Point(x=end_x, y=end_y, z=end_z, units=units) - return cls(start=start, end=end, units=units) - - -@dataclass(kw_only=True) -class Polyline(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Polyline"): - """ - a polyline curve, defined by a set of vertices. - """ - - value: List[float] - closed: bool = False - domain: Interval = field(default_factory=Interval.unit_interval) - - @property - def length(self) -> float: - points = self.get_points() - total_length = 0.0 - for i in range(len(points) - 1): - total_length += points[i].distance_to(points[i + 1]) - if self.closed and points: - total_length += points[-1].distance_to(points[0]) - return total_length - - @property - def _domain(self) -> Interval: - """ - internal domain property for ICurve interface - """ - return self.domain - - def get_points(self) -> List[Point]: - """ - converts the raw coordinate list into Point objects - """ - if len(self.value) % 3 != 0: - raise ValueError( - "Polyline value list is malformed: expected length to be multiple of 3" - ) - - points = [] - for i in range(0, len(self.value), 3): - points.append( - Point( - x=self.value[i], - y=self.value[i + 1], - z=self.value[i + 2], - units=self.units, - ) - ) - return points - - def to_list(self) -> List[float]: - """ - returns the values of this Polyline as a list of numbers - """ - result = [] - result.append(len(self.value) + 6) # total list length - # type indicator for polyline ?? not sure about this - result.append("Objects.Geometry.Polyline") - result.append(1 if self.closed else 0) - result.append(self.domain.start) - result.append(self.domain.end) - result.append(len(self.value)) - result.extend(self.value) - result.append(get_encoding_from_units(self.units)) - return result - - @classmethod - def from_list(cls, coords: List[float], units: str | Units) -> "Polyline": - """ - creates a new Polyline based on a list of coordinates - """ - point_count = int(coords[5]) - return cls( - closed=(int(coords[2]) == 1), - domain=Interval(start=coords[3], end=coords[4]), - value=coords[6: 6 + point_count], - units=units, - ) - - -@dataclass(kw_only=True) -class Mesh( - Base, - IHasArea, - IHasVolume, - IHasUnits, - speckle_type="Objects.Geometry.Mesh", - detachable={"vertices", "faces", "colors", "textureCoordinates"}, - chunkable={ - "vertices": 31250, - "faces": 62500, - "colors": 62500, - "textureCoordinates": 31250, - }, -): - - vertices: List[float] - faces: List[int] - colors: List[int] = field(default_factory=list) - textureCoordinates: List[float] = field(default_factory=list) - - @property - def vertices_count(self) -> int: - return len(self.vertices) // 3 - - @property - def texture_coordinates_count(self) -> int: - return len(self.textureCoordinates) // 2 - - def get_point(self, index: int) -> Point: - - index *= 3 - return Point( - x=self.vertices[index], - y=self.vertices[index + 1], - z=self.vertices[index + 2], - units=self.units, - ) - - def get_points(self) -> List[Point]: - - if len(self.vertices) % 3 != 0: - raise ValueError( - "Mesh vertices list is malformed: expected length to be multiple of 3" - ) - - points = [] - for i in range(0, len(self.vertices), 3): - points.append( - Point( - x=self.vertices[i], - y=self.vertices[i + 1], - z=self.vertices[i + 2], - units=self.units, - ) - ) - return points - - def get_texture_coordinate(self, index: int) -> Tuple[float, float]: - - index *= 2 - return (self.textureCoordinates[index], self.textureCoordinates[index + 1]) - - def align_vertices_with_texcoords_by_index(self) -> None: - - if not self.textureCoordinates: - return - - if self.texture_coordinates_count == self.vertices_count: - return - - faces_unique = [] - vertices_unique = [] - has_colors = len(self.colors) > 0 - colors_unique = [] if has_colors else None - - n_index = 0 - while n_index < len(self.faces): - n = self.faces[n_index] - if n < 3: - n += 3 - - if n_index + n >= len(self.faces): - break - - faces_unique.append(n) - for i in range(1, n + 1): - vert_index = self.faces[n_index + i] - new_vert_index = len(vertices_unique) // 3 - - point = self.get_point(vert_index) - vertices_unique.extend([point.x, point.y, point.z]) - - if colors_unique is not None: - colors_unique.append(self.colors[vert_index]) - faces_unique.append(new_vert_index) - - n_index += n + 1 - - self.vertices = vertices_unique - self.colors = colors_unique if colors_unique is not None else self.colors - self.faces = faces_unique diff --git a/src/specklepy/objects/geometry/__init__.py b/src/specklepy/objects/geometry/__init__.py new file mode 100644 index 0000000..dba4988 --- /dev/null +++ b/src/specklepy/objects/geometry/__init__.py @@ -0,0 +1,17 @@ +from .point import Point +from .line import Line +from .vector import Vector +from .polyline import Polyline +from .arc import Arc +from .mesh import Mesh +from .plane import Plane + +__all__ = [ + "Point", + "Vector", + "Line", + "Polyline", + "Mesh", + "Plane", + "Arc" +] diff --git a/src/specklepy/objects/geometry/arc.py b/src/specklepy/objects/geometry/arc.py new file mode 100644 index 0000000..2835a7a --- /dev/null +++ b/src/specklepy/objects/geometry/arc.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass, field +from typing import List, Tuple, Any +import math + +from specklepy.objects.base import Base +from specklepy.objects.geometry.point import Point +from specklepy.objects.geometry.plane import Plane +from specklepy.objects.geometry.vector import Vector +from specklepy.objects.interfaces import ICurve, IHasUnits, ITransformable +from specklepy.objects.primitive import Interval +from specklepy.objects.models.units import ( + get_encoding_from_units, + get_units_from_encoding +) + + +@dataclass(kw_only=True) +class Arc(Base, IHasUnits, ICurve, ITransformable, speckle_type="Objects.Geometry.Arc"): + + plane: Plane + startPoint: Point + midPoint: Point + endPoint: Point + domain: Interval = field(default_factory=Interval.unit_interval) + + @property + def radius(self) -> float: + return self.startPoint.distance_to(self.plane.origin) + + @property + def measure(self) -> float: + start_to_mid = self.startPoint.distance_to(self.midPoint) + mid_to_end = self.midPoint.distance_to(self.endPoint) + r = self.radius + return (2 * math.asin(start_to_mid / (2 * r))) + (2 * math.asin(mid_to_end / (2 * r))) + + @property + def length(self) -> float: + return self.radius * self.measure + + @property + def _domain(self) -> Interval: + return self.domain + + def transform_to(self, transform) -> Tuple[bool, "Arc"]: + _, transformed_start = self.startPoint.transform_to(transform) + _, transformed_mid = self.midPoint.transform_to(transform) + _, transformed_end = self.endPoint.transform_to(transform) + _, transformed_plane = self.plane.transform_to(transform) + + transformed = Arc( + startPoint=transformed_start, + endPoint=transformed_end, + midPoint=transformed_mid, + plane=transformed_plane, + domain=self.domain, + units=self.units, + applicationId=self.applicationId + ) + return True, transformed + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, + radius, measure, + domain_start, domain_end, + plane_origin_x, plane_origin_y, plane_origin_z, + plane_normal_x, plane_normal_y, plane_normal_z, + plane_xdir_x, plane_xdir_y, plane_xdir_z, + plane_ydir_x, plane_ydir_y, plane_ydir_z, + start_x, start_y, start_z, + mid_x, mid_y, mid_z, + end_x, end_y, end_z] + """ + result = [] + result.extend([self.radius, self.measure]) + result.extend([self.domain.start, self.domain.end]) + # skip length, type, units from Plane + result.extend(self.plane.to_list()[3:]) + # skip length, type, units from Point + result.extend(self.startPoint.to_list()[3:]) + # skip length, type, units from Point + result.extend(self.midPoint.to_list()[3:]) + # skip length, type, units from Point + result.extend(self.endPoint.to_list()[3:]) + + # add header information + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) + return result + + @classmethod + def from_list(cls, coords: List[Any]) -> "Arc": + """ + creates an Arc from a list of format: + [total_length, speckle_type, units_encoding, + radius, measure, + domain_start, domain_end, + plane_origin_x, plane_origin_y, plane_origin_z, + plane_normal_x, plane_normal_y, plane_normal_z, + plane_xdir_x, plane_xdir_y, plane_xdir_z, + plane_ydir_x, plane_ydir_y, plane_ydir_z, + start_x, start_y, start_z, + mid_x, mid_y, mid_z, + end_x, end_y, end_z] + """ + units = get_units_from_encoding(coords[2]) + + domain = Interval(start=coords[5], end=coords[6]) + + # extract plane components + plane = Plane( + origin=Point(x=coords[7], y=coords[8], z=coords[9], units=units), + normal=Vector(x=coords[10], y=coords[11], + z=coords[12], units=units), + xdir=Vector(x=coords[13], y=coords[14], z=coords[15], units=units), + ydir=Vector(x=coords[16], y=coords[17], z=coords[18], units=units), + units=units + ) + + # extract points + start_point = Point( + x=coords[19], y=coords[20], z=coords[21], units=units) + mid_point = Point(x=coords[22], y=coords[23], + z=coords[24], units=units) + end_point = Point(x=coords[25], y=coords[26], + z=coords[27], units=units) + + return cls( + plane=plane, + startPoint=start_point, + midPoint=mid_point, + endPoint=end_point, + domain=domain, + units=units + ) diff --git a/src/specklepy/objects/geometry/line.py b/src/specklepy/objects/geometry/line.py new file mode 100644 index 0000000..09a3d89 --- /dev/null +++ b/src/specklepy/objects/geometry/line.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass, field +from typing import List, Tuple, Any + +from specklepy.objects.base import Base +from specklepy.objects.geometry.point import Point +from specklepy.objects.interfaces import ICurve, IHasUnits +from specklepy.objects.primitive import Interval +from specklepy.objects.models.units import ( + Units, + get_encoding_from_units +) + + +@dataclass(kw_only=True) +class Line(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Line"): + """ + a line defined by two points in 3D space + """ + + start: Point + end: Point + domain: Interval = field(default_factory=Interval.unit_interval) + + @property + def length(self) -> float: + """ + calculate the length of the line using Point's distance_to method + """ + return self.start.distance_to(self.end) + + @property + def _domain(self) -> Interval: + return self.domain + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, + start_x, start_y, start_z, + end_x, end_y, end_z, + domain_start, domain_end] + """ + result = [] + # skip length, type, units from Point + result.extend(self.start.to_list()[3:]) + # skip length, type, units from Point + result.extend(self.end.to_list()[3:]) + result.extend([self.domain.start, self.domain.end]) + + # add header information + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) # +1 for the length we're adding + return result + + @classmethod + def from_list(cls, coords: List[Any], units: str | Units) -> "Line": + """ + creates a Line from a list of format: + [total_length, speckle_type, units_encoding, + start_x, start_y, start_z, + end_x, end_y, end_z, + domain_start, domain_end] + """ + start = Point( + x=coords[3], + y=coords[4], + z=coords[5], + units=units + ) + end = Point( + x=coords[6], + y=coords[7], + z=coords[8], + units=units + ) + domain = Interval( + start=coords[9], + end=coords[10] + ) + return cls(start=start, end=end, domain=domain, units=units) + + @classmethod + def from_coords( + cls, + start_x: float, + start_y: float, + start_z: float, + end_x: float, + end_y: float, + end_z: float, + units: str, + ) -> "Line": + start = Point(x=start_x, y=start_y, z=start_z, units=units) + end = Point(x=end_x, y=end_y, z=end_z, units=units) + return cls(start=start, end=end, units=units) + + def transform_to(self, transform) -> Tuple[bool, "Line"]: + """ + transform this line using the given transform by transforming its start and end points + """ + success_start, transformed_start = self.start.transform_to(transform) + success_end, transformed_end = self.end.transform_to(transform) + + if not (success_start and success_end): + return False, self + + transformed = Line( + start=transformed_start, + end=transformed_end, + domain=self.domain, + units=self.units, + applicationId=self.applicationId + ) + return True, transformed diff --git a/src/specklepy/objects/geometry/mesh.py b/src/specklepy/objects/geometry/mesh.py new file mode 100644 index 0000000..6f012be --- /dev/null +++ b/src/specklepy/objects/geometry/mesh.py @@ -0,0 +1,366 @@ +from dataclasses import dataclass, field +from typing import List, Tuple, Any + +from specklepy.objects.base import Base +from specklepy.objects.geometry.point import Point +from specklepy.objects.interfaces import IHasArea, IHasVolume, IHasUnits, ITransformable +from specklepy.objects.models.units import ( + Units, + get_scale_factor, + get_units_from_string, + get_units_from_encoding, + get_encoding_from_units +) + + +@dataclass(kw_only=True) +class Mesh( + Base, + IHasArea, + IHasVolume, + IHasUnits, + ITransformable, + speckle_type="Objects.Geometry.Mesh", + detachable={"vertices", "faces", "colors", "textureCoordinates"}, + chunkable={ + "vertices": 31250, + "faces": 62500, + "colors": 62500, + "textureCoordinates": 31250, + }, +): + """ + a 3D mesh consisting of vertices and faces with optional colors and texture coordinates. + """ + + vertices: List[float] + faces: List[int] + colors: List[int] = field(default_factory=list) + textureCoordinates: List[float] = field(default_factory=list) + area: float = field(init=False) + volume: float = field(init=False) + _vertices_count: int = field(init=False, repr=False) + + def __post_init__(self): + """ + calculate initial values and validate vertices + """ + if not self.vertices: + self._vertices_count = 0 + else: + if len(self.vertices) % 3 != 0: + raise ValueError( + f"Invalid vertices list: length ({len( + self.vertices)}) must be a multiple of 3" + ) + self._vertices_count = len(self.vertices) // 3 + + self._calculate_area_and_volume() + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"vertices: {self._vertices_count}, " + f"faces: {self.faces_count}, " + f"units: {self.units}, " + f"has_colors: {len(self.colors) > 0}, " + f"has_texture_coords: {len(self.textureCoordinates) > 0})" + ) + + def _calculate_area_and_volume(self): + """ + internal method to update area and volume calculations + """ + # Calculate area + total_area = 0.0 + face_index = 0 + i = 0 + + while i < len(self.faces): + vertex_count = self.faces[i] + if vertex_count >= 3: + face_vertices = self.get_face_vertices(face_index) + for j in range(1, vertex_count - 1): + v0 = face_vertices[0] + v1 = face_vertices[j] + v2 = face_vertices[j + 1] + a = [v1.x - v0.x, v1.y - v0.y, v1.z - v0.z] + b = [v2.x - v0.x, v2.y - v0.y, v2.z - v0.z] + cx = a[1] * b[2] - a[2] * b[1] + cy = a[2] * b[0] - a[0] * b[2] + cz = a[0] * b[1] - a[1] * b[0] + area = 0.5 * (cx * cx + cy * cy + cz * cz) ** 0.5 + total_area += area + i += vertex_count + 1 + face_index += 1 + + self.area = total_area + + # Calculate volume + total_volume = 0.0 + if self.is_closed(): + face_index = 0 + i = 0 + while i < len(self.faces): + vertex_count = self.faces[i] + if vertex_count >= 3: + face_vertices = self.get_face_vertices(face_index) + v0 = face_vertices[0] + for j in range(1, vertex_count - 1): + v1 = face_vertices[j] + v2 = face_vertices[j + 1] + a = [v0.x, v0.y, v0.z] + b = [v1.x - v0.x, v1.y - v0.y, v1.z - v0.z] + c = [v2.x - v0.x, v2.y - v0.y, v2.z - v0.z] + cx = b[1] * c[2] - b[2] * c[1] + cy = b[2] * c[0] - b[0] * c[2] + cz = b[0] * c[1] - b[1] * c[0] + v = (a[0] * cx + a[1] * cy + a[2] * cz) / 6.0 + total_volume += v + i += vertex_count + 1 + face_index += 1 + + self.volume = abs(total_volume) + + @property + def vertices_count(self) -> int: + """ + get the number of vertices in the mesh. + """ + return self._vertices_count + + @property + def faces_count(self) -> int: + """ + get the number of faces in the mesh. + """ + count = 0 + i = 0 + while i < len(self.faces): + n = self.faces[i] + count += 1 + i += n + 1 + return count + + @property + def texture_coordinates_count(self) -> int: + """ + get the number of texture coordinates in the mesh. + """ + return len(self.textureCoordinates) // 2 + + def get_point(self, index: int) -> Point: + """ + get vertex at index as a Point object. + """ + if index < 0 or index >= self._vertices_count: # use cached count + raise IndexError(f"Vertex index {index} out of range") + + index *= 3 + return Point( + x=self.vertices[index], + y=self.vertices[index + 1], + z=self.vertices[index + 2], + units=self.units, + ) + + def get_points(self) -> List[Point]: + """ + get all vertices as Point objects. + """ + return [self.get_point(i) for i in range(self._vertices_count)] # use cached count + + def get_texture_coordinate(self, index: int) -> Tuple[float, float]: + """ + get texture coordinate at index. + """ + if index < 0 or index >= self.texture_coordinates_count: + raise IndexError(f"Texture coordinate index {index} out of range") + + index *= 2 + return (self.textureCoordinates[index], self.textureCoordinates[index + 1]) + + def get_face_vertices(self, face_index: int) -> List[Point]: + """ + get the vertices of a specific face. + """ + i = 0 + current_face = 0 + + while i < len(self.faces): + if current_face == face_index: + vertex_count = self.faces[i] + vertices = [] + for j in range(vertex_count): + vertex_index = self.faces[i + j + 1] + if vertex_index >= self._vertices_count: # use cached count + raise IndexError( + f"Vertex index {vertex_index} out of range") + vertices.append(self.get_point(vertex_index)) + return vertices + + # skip this face and move to next + vertex_count = self.faces[i] + i += vertex_count + 1 + current_face += 1 + + raise IndexError(f"Face index {face_index} out of range") + + def transform_to(self, transform) -> Tuple[bool, "Mesh"]: + """ + transform this mesh using the given transform. + """ + transformed_vertices = [] + + for i in range(0, len(self.vertices), 3): + point = Point( + x=self.vertices[i], + y=self.vertices[i + 1], + z=self.vertices[i + 2], + units=self.units + ) + success, transformed_point = point.transform_to(transform) + if not success: + return False, self + + transformed_vertices.extend([ + transformed_point.x, + transformed_point.y, + transformed_point.z + ]) + + transformed = Mesh( + vertices=transformed_vertices, + faces=self.faces.copy(), + colors=self.colors.copy(), + textureCoordinates=self.textureCoordinates.copy(), + units=self.units, + applicationId=self.applicationId + ) + + return True, transformed + + def convert_units(self, to_units: str | Units) -> None: + """ + convert the mesh vertices to different units. + """ + if isinstance(to_units, Units): + to_units = to_units.value + + scale_factor = get_scale_factor( + get_units_from_string(self.units), + get_units_from_string(to_units) + ) + + for i in range(len(self.vertices)): + self.vertices[i] *= scale_factor + + self.units = to_units + self._calculate_area_and_volume() + + def is_closed(self) -> bool: + """ + check if the mesh is closed by verifying each edge appears exactly twice. + """ + edge_counts = {} + + i = 0 + while i < len(self.faces): + vertex_count = self.faces[i] + for j in range(vertex_count): + v1 = self.faces[i + 1 + j] + v2 = self.faces[i + 1 + ((j + 1) % vertex_count)] + edge = tuple(sorted([v1, v2])) + edge_counts[edge] = edge_counts.get(edge, 0) + 1 + + i += vertex_count + 1 + + return all(count == 2 for count in edge_counts.values()) + + def to_list(self) -> List[Any]: + """ + Returns a serializable list of format: + [total_length, speckle_type, units_encoding, + vertices_count, v1x, v1y, v1z, v2x, v2y, v2z, ..., + faces_count, f1, f2, f3, ..., + colors_count, c1, c2, c3, ..., + texture_coords_count, t1u, t1v, t2u, t2v, ..., + area, volume] + """ + self._calculate_area_and_volume() + + result = [] + + # add vertices + result.append(len(self.vertices)) + result.extend(self.vertices) + + # add faces + result.append(len(self.faces)) + result.extend(self.faces) + + # add colors (if any) + result.append(len(self.colors)) + if self.colors: + result.extend(self.colors) + + # add texture coordinates (if any) + result.append(len(self.textureCoordinates)) + if self.textureCoordinates: + result.extend(self.textureCoordinates) + + # add area and volume (calculated properties) + result.extend([self.area, self.volume]) + + # add header information + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) + return result + + @classmethod + def from_list(cls, coords: List[Any]) -> "Mesh": + """ + Creates a Mesh from a list of format: + [total_length, speckle_type, units_encoding, + vertices_count, v1x, v1y, v1z, v2x, v2y, v2z, ..., + faces_count, f1, f2, f3, ..., + colors_count, c1, c2, c3, ..., + texture_coords_count, t1u, t1v, t2u, t2v, ..., + area, volume] + """ + units = get_units_from_encoding(coords[2]) + + index = 3 + + vertices_count = int(coords[index]) + index += 1 + vertices = coords[index:index + vertices_count] + index += vertices_count + + faces_count = int(coords[index]) + index += 1 + faces = [int(f) for f in coords[index:index + faces_count]] + index += faces_count + + colors_count = int(coords[index]) + index += 1 + colors = [] + if colors_count > 0: + colors = [int(c) for c in coords[index:index + colors_count]] + index += colors_count + + texture_coords_count = int(coords[index]) + index += 1 + texture_coords = [] + if texture_coords_count > 0: + texture_coords = coords[index:index + texture_coords_count] + index += texture_coords_count + + return cls( + vertices=vertices, + faces=faces, + colors=colors, + textureCoordinates=texture_coords, + units=units + ) diff --git a/src/specklepy/objects/geometry/plane.py b/src/specklepy/objects/geometry/plane.py new file mode 100644 index 0000000..d6e6a7a --- /dev/null +++ b/src/specklepy/objects/geometry/plane.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass +from typing import List, Tuple, Any + +from specklepy.objects.base import Base +from specklepy.objects.geometry.point import Point +from specklepy.objects.geometry.vector import Vector +from specklepy.objects.interfaces import IHasUnits, ITransformable +from specklepy.objects.models.units import ( + get_encoding_from_units, + get_units_from_encoding +) + + +@dataclass(kw_only=True) +class Plane(Base, ITransformable, IHasUnits, speckle_type="Objects.Geometry.Plane"): + """ + a plane consisting of an origin Point, and 3 Vectors as its X, Y and Z axis. + """ + + origin: Point + normal: Vector + xdir: Vector + ydir: Vector + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"origin: {self.origin}, " + f"normal: {self.normal}, " + f"xdir: {self.xdir}, " + f"ydir: {self.ydir}, " + f"units: {self.units})" + ) + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, + origin_x, origin_y, origin_z, + normal_x, normal_y, normal_z, + xdir_x, xdir_y, xdir_z, + ydir_x, ydir_y, ydir_z] + """ + result = [] + # skip length, type, units from Point + result.extend(self.origin.to_list()[3:]) + # skip length, type, units from Vector + result.extend(self.normal.to_list()[3:]) + # skip length, type, units from Vector + result.extend(self.xdir.to_list()[3:]) + # skip length, type, units from Vector + result.extend(self.ydir.to_list()[3:]) + + # add header information + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) + return result + + @classmethod + def from_list(cls, coords: List[Any]) -> "Plane": + """ + creates a Plane from a list of format: + [total_length, speckle_type, units_encoding, + origin_x, origin_y, origin_z, + normal_x, normal_y, normal_z, + xdir_x, xdir_y, xdir_z, + ydir_x, ydir_y, ydir_z] + """ + units = get_units_from_encoding(coords[2]) + + origin = Point( + x=coords[3], y=coords[4], z=coords[5], + units=units + ) + normal = Vector( + x=coords[6], y=coords[7], z=coords[8], + units=units + ) + xdir = Vector( + x=coords[9], y=coords[10], z=coords[11], + units=units + ) + ydir = Vector( + x=coords[12], y=coords[13], z=coords[14], + units=units + ) + + return cls( + origin=origin, + normal=normal, + xdir=xdir, + ydir=ydir, + units=units + ) + + def transform_to(self, transform) -> Tuple[bool, Base]: + """ + transform this plane using the given transform + """ + _, transformed_origin = self.origin.transform_to(transform) + _, transformed_normal = self.normal.transform_to(transform) + _, transformed_xdir = self.xdir.transform_to(transform) + _, transformed_ydir = self.ydir.transform_to(transform) + + transformed = Plane( + origin=transformed_origin, + normal=transformed_normal, + xdir=transformed_xdir, + ydir=transformed_ydir, + applicationId=self.applicationId, + units=self.units, + ) + + return True, transformed diff --git a/src/specklepy/objects/geometry/point.py b/src/specklepy/objects/geometry/point.py new file mode 100644 index 0000000..851e456 --- /dev/null +++ b/src/specklepy/objects/geometry/point.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass +from typing import List, Tuple, Any + +from specklepy.objects.base import Base +from specklepy.objects.interfaces import IHasUnits, ITransformable +from specklepy.objects.models.units import ( + Units, + get_scale_factor, + get_units_from_string, + get_encoding_from_units +) + + +@dataclass(kw_only=True) +class Point(Base, IHasUnits, ITransformable, speckle_type="Objects.Geometry.Point"): + """ + a 3-dimensional point + """ + + x: float + y: float + z: float + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(x: {self.x}, y: {self.y}, z: {self.z}, units: {self.units})" + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, x, y, z] + """ + result = [self.x, self.y, self.z] + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) # +1 for the length we're adding + return result + + @classmethod + def from_list(cls, coords: List[Any], units: str | Units) -> "Point": + """ + creates a Point from a list of format: + [total_length, speckle_type, units_encoding, x, y, z] + """ + x, y, z = coords[3:6] # geometric data starts at index 3 + return cls(x=x, y=y, z=z, units=units) + + @classmethod + def from_coords(cls, x: float, y: float, z: float, units: str | Units) -> "Point": + return cls(x=x, y=y, z=z, units=units) + + def transform_to(self, transform) -> Tuple[bool, "Point"]: + """ + transform this point using the given transform + """ + m = transform.matrix + tx = self.x * m[0] + self.y * m[1] + self.z * m[2] + m[3] + ty = self.x * m[4] + self.y * m[5] + self.z * m[6] + m[7] + tz = self.x * m[8] + self.y * m[9] + self.z * m[10] + m[11] + + transformed = Point( + x=tx, + y=ty, + z=tz, + units=self.units, + applicationId=self.applicationId + ) + return True, transformed + + def distance_to(self, other: "Point") -> float: + """ + calculates the distance between this point and another given point. + """ + if not isinstance(other, Point): + raise TypeError(f"Expected Point object, got {type(other)}") + + # if units are the same perform direct calculation + if self.units == other.units: + dx = other.x - self.x + dy = other.y - self.y + dz = other.z - self.z + return (dx * dx + dy * dy + dz * dz) ** 0.5 + + # convert other point's coordinates to this point's units + scale_factor = get_scale_factor( + get_units_from_string( + other.units), get_units_from_string(self.units) + ) + + dx = (other.x * scale_factor) - self.x + dy = (other.y * scale_factor) - self.y + dz = (other.z * scale_factor) - self.z + + return (dx * dx + dy * dy + dz * dz) ** 0.5 diff --git a/src/specklepy/objects/geometry/polyline.py b/src/specklepy/objects/geometry/polyline.py new file mode 100644 index 0000000..323a2fb --- /dev/null +++ b/src/specklepy/objects/geometry/polyline.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass, field +from typing import List, Tuple, Any + +from specklepy.objects.base import Base +from specklepy.objects.geometry.point import Point +from specklepy.objects.interfaces import ICurve, IHasUnits, ITransformable +from specklepy.objects.models.units import ( + Units, + get_encoding_from_units +) +from specklepy.objects.primitive import Interval + + +@dataclass(kw_only=True) +class Polyline(Base, IHasUnits, ICurve, ITransformable, speckle_type="Objects.Geometry.Polyline"): + """ + a polyline curve, defined by a set of vertices. + """ + + value: List[float] + closed: bool = False + domain: Interval = field(default_factory=Interval.unit_interval) + + @property + def length(self) -> float: + points = self.get_points() + total_length = 0.0 + for i in range(len(points) - 1): + total_length += points[i].distance_to(points[i + 1]) + if self.closed and points: + total_length += points[-1].distance_to(points[0]) + return total_length + + @property + def _domain(self) -> Interval: + """ + internal domain property for ICurve interface + """ + return self.domain + + def get_points(self) -> List[Point]: + """ + converts the raw coordinate list into Point objects + """ + if len(self.value) % 3 != 0: + raise ValueError( + "Polyline value list is malformed: expected length to be multiple of 3" + ) + + points = [] + for i in range(0, len(self.value), 3): + points.append( + Point( + x=self.value[i], + y=self.value[i + 1], + z=self.value[i + 2], + units=self.units, + ) + ) + return points + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, + is_closed, + domain_start, domain_end, + coords_count, + x1, y1, z1, x2, y2, z2, ...] + """ + result = [] + result.append(1 if self.closed else 0) # convert bool to int + result.extend([self.domain.start, self.domain.end]) + result.append(len(self.value)) # number of coordinates + result.extend(self.value) # all vertex coordinates + + # add header information + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) + return result + + @classmethod + def from_list(cls, coords: List[Any], units: str | Units) -> "Polyline": + """ + creates a Polyline from a list of format: + [total_length, speckle_type, units_encoding, + is_closed, + domain_start, domain_end, + coords_count, + x1, y1, z1, x2, y2, z2, ...] + """ + is_closed = bool(coords[3]) + domain = Interval(start=coords[4], end=coords[5]) + coords_count = int(coords[6]) + vertex_coords = coords[7:7 + coords_count] + + return cls( + value=vertex_coords, + closed=is_closed, + domain=domain, + units=units + ) + + def transform_to(self, transform) -> Tuple[bool, "Polyline"]: + """ + transform this polyline by transforming all its vertices + """ + if len(self.value) % 3 != 0: + return False, self + + # Transform each point in the value list + transformed_values = [] + for i in range(0, len(self.value), 3): + point = Point( + x=self.value[i], + y=self.value[i + 1], + z=self.value[i + 2], + units=self.units + ) + success, transformed_point = point.transform_to(transform) + if not success: + return False, self + + transformed_values.extend([ + transformed_point.x, + transformed_point.y, + transformed_point.z + ]) + + transformed = Polyline( + value=transformed_values, + closed=self.closed, + domain=self.domain, + units=self.units, + applicationId=self.applicationId + ) + return True, transformed diff --git a/src/specklepy/objects/geometry/vector.py b/src/specklepy/objects/geometry/vector.py new file mode 100644 index 0000000..2452e20 --- /dev/null +++ b/src/specklepy/objects/geometry/vector.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from typing import List, Any + +from specklepy.objects.base import Base +from specklepy.objects.interfaces import IHasUnits, ITransformable +from specklepy.objects.models.units import ( + Units, + get_encoding_from_units +) + + +@dataclass(kw_only=True) +class Vector(Base, IHasUnits, ITransformable, speckle_type="Objects.Geometry.Vector"): + """ + a 3-dimensional vector + """ + + x: float + y: float + z: float + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(x: {self.x}, y: {self.y}, z: {self.z}, units: {self.units})" + + @property + def length(self) -> float: + return (self.x ** 2 + self.y ** 2 + self.z ** 2) ** 0.5 + + def to_list(self) -> List[Any]: + """ + returns a serializable list of format: + [total_length, speckle_type, units_encoding, x, y, z] + """ + result = [self.x, self.y, self.z] + result.insert(0, get_encoding_from_units(self.units)) + result.insert(0, self.speckle_type) + result.insert(0, len(result) + 1) # +1 for the length we're adding + return result + + @classmethod + def from_list(cls, coords: List[Any], units: str | Units) -> "Vector": + """ + creates a Vector from a list of format: + [total_length, speckle_type, units_encoding, x, y, z] + """ + x, y, z = coords[3:6] # geometric data starts at index 3 + return cls(x=x, y=y, z=z, units=units) + + def transform_to(self, transform): + m = transform.matrix + tx = self.x * m[0] + self.y * m[1] + self.z * m[2] + ty = self.x * m[4] + self.y * m[5] + self.z * m[6] + tz = self.x * m[8] + self.y * m[9] + self.z * m[10] + transformed = Vector(x=tx, y=ty, z=tz, units=self.units, + applicationId=self.applicationId) + return True, transformed diff --git a/src/specklepy/objects/interfaces.py b/src/specklepy/objects/interfaces.py index 274173c..5ce2b98 100644 --- a/src/specklepy/objects/interfaces.py +++ b/src/specklepy/objects/interfaces.py @@ -35,6 +35,20 @@ def display_value(self) -> T: pass +class ITransformable(metaclass=ABCMeta): + """ + interface for objects that can be transformed + """ + + @abstractmethod + def transform_to(self, transform) -> tuple[bool, Base]: + """ + transform this object using the given transform + """ + pass + + +# field interfaces @dataclass(kw_only=True) class IHasUnits(metaclass=ABCMeta): diff --git a/src/specklepy/objects/other.py b/src/specklepy/objects/other.py new file mode 100644 index 0000000..8c92ed6 --- /dev/null +++ b/src/specklepy/objects/other.py @@ -0,0 +1,63 @@ +from specklepy.objects.base import Base +from specklepy.objects.models.units import get_scale_factor_from_string + + +class Transform(Base, speckle_type="Objects.Other.Transform"): + """ + generic transform class with a column-based 4x4 transform matrix + graphics based apps typically use column-based matrices, where the last column defines translation. + modelling apps may use row-based matrices, where the last row defines translation. transpose if so. + """ + + def __init__(self, matrix=None, units=None): + super().__init__() + self.matrix = matrix or [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ] + self.units = units + + def convert_to_units(self, new_units): + """ + converts this transform to different units + """ + if not new_units or not self.units: + return self.to_array() + + scale_factor = get_scale_factor_from_string(self.units, new_units) + + return [ + self.matrix[0], # M11 + self.matrix[1], # M12 + self.matrix[2], # M13 + self.matrix[3] * scale_factor, # M14 (translation) + self.matrix[4], # M21 + self.matrix[5], # M22 + self.matrix[6], # M23 + self.matrix[7] * scale_factor, # M24 (translation) + self.matrix[8], # M31 + self.matrix[9], # M32 + self.matrix[10], # M33 + self.matrix[11] * scale_factor, # M34 (translation) + self.matrix[12], # M41 + self.matrix[13], # M42 + self.matrix[14], # M43 + self.matrix[15], # M44 + ] + + @staticmethod + def create_matrix(values): + """ + creates a matrix from an array of values + """ + if len(values) != 16: + raise ValueError("Matrix requires exactly 16 values") + return [float(v) for v in values] + + def to_array(self): + """ + returns the transform matrix as an array + """ + return self.matrix.copy() diff --git a/src/specklepy/objects/tests/line_test.py b/src/specklepy/objects/tests/line_test.py deleted file mode 100644 index fefd596..0000000 --- a/src/specklepy/objects/tests/line_test.py +++ /dev/null @@ -1,26 +0,0 @@ -from devtools import debug - -from specklepy.core.api.operations import deserialize, serialize -from specklepy.objects.geometry import Line, Point -from specklepy.objects.models.units import Units -from specklepy.objects.primitive import Interval - -# points -p1 = Point(x=1.0, y=2.0, z=3.0, units=Units.m) -p2 = Point(x=4.0, y=6.0, z=8.0, units=Units.m, applicationId="asdf") - - -# test Line -line = Line(start=p1, end=p2, units=Units.m, domain=Interval(start=0.0, end=1.0)) - -print(f"\nLine length: {line.length}") - -ser_line = serialize(line) -line_again = deserialize(ser_line) - -print("\nOriginal line:") -debug(line) -print("\nSerialized line:") -debug(ser_line) -print("\nDeserialized line:") -debug(line_again) diff --git a/src/specklepy/objects/tests/mesh_test.py b/src/specklepy/objects/tests/mesh_test.py deleted file mode 100644 index 4857a87..0000000 --- a/src/specklepy/objects/tests/mesh_test.py +++ /dev/null @@ -1,183 +0,0 @@ -from devtools import debug - -from specklepy.core.api.operations import deserialize, serialize -from specklepy.objects.geometry import Mesh - -# create a speckle cube mesh (but more colorful) -vertices = [ - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 1.0, - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 1.0, - 1.0, -] - -# define faces (triangles) -faces = [ - 3, - 0, - 1, - 2, - 3, - 0, - 2, - 3, - 3, - 4, - 5, - 6, - 3, - 4, - 6, - 7, - 3, - 0, - 4, - 7, - 3, - 0, - 7, - 3, - 3, - 1, - 5, - 6, - 3, - 1, - 6, - 2, - 3, - 3, - 2, - 6, - 3, - 3, - 6, - 7, - 3, - 0, - 1, - 5, - 3, - 0, - 5, - 4, -] - -# create colors (one per vertex) -colors = [ - 255, - 0, - 0, - 255, - 0, - 255, - 0, - 255, - 0, - 0, - 255, - 255, - 255, - 255, - 0, - 255, - 255, - 0, - 255, - 255, - 0, - 255, - 255, - 255, - 255, - 255, - 255, - 255, - 0, - 0, - 0, - 255, -] - -texture_coordinates = [ - 0.0, - 0.0, - 1.0, - 0.0, - 1.0, - 1.0, - 0.0, - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 1.0, - 1.0, - 0.0, - 1.0, -] - -# create the mesh -cube_mesh = Mesh( - vertices=vertices, - faces=faces, - colors=colors, - textureCoordinates=texture_coordinates, - units="mm", - area=0.0, - volume=0.0, -) - -print(f"\nMesh Details:") -print(f"Number of vertices: {cube_mesh.vertices_count}") -print(f"Number of texture coordinates: {cube_mesh.texture_coordinates_count}") - -print("\nSome vertex points:") -for i in range(4): - point = cube_mesh.get_point(i) - print(f"Vertex {i}: ({point.x}, {point.y}, {point.z})") - -print("\nSome texture coordinates:") -for i in range(4): - u, v = cube_mesh.get_texture_coordinate(i) - print(f"Texture coordinate {i}: ({u}, {v})") - -print("\nTesting serialization...") -ser_mesh = serialize(cube_mesh) -mesh_again = deserialize(ser_mesh) - -print("\nOriginal mesh:") -debug(cube_mesh) -print("\nDeserialized mesh:") -debug(mesh_again) - -print("\nTesting vertex-texture coordinate alignment...") -cube_mesh.align_vertices_with_texcoords_by_index() -print("Alignment complete.") - -print(f"Vertices count after alignment: {cube_mesh.vertices_count}") -print( - f"Texture coordinates count after alignment: { - cube_mesh.texture_coordinates_count}" -) diff --git a/src/specklepy/objects/tests/point_test.py b/src/specklepy/objects/tests/point_test.py deleted file mode 100644 index 95cf6ba..0000000 --- a/src/specklepy/objects/tests/point_test.py +++ /dev/null @@ -1,21 +0,0 @@ -from devtools import debug - -from specklepy.core.api.operations import deserialize, serialize -from specklepy.objects.geometry import Point -from specklepy.objects.models.units import Units - -# test points -p1 = Point(x=1346.0, y=2304.0, z=3000.0, units=Units.mm) -p2 = Point(x=4.0, y=6.0, z=8.0, units=Units.m, applicationId="asdf") - -print("Distance between points:", p2.distance_to(p1)) - -ser_p1 = serialize(p1) -p1_again = deserialize(ser_p1) - -print("\nOriginal point:") -debug(p1) -print("\nSerialized point:") -debug(ser_p1) -print("\nDeserialized point:") -debug(p1_again) diff --git a/src/specklepy/objects/tests/polyline_test.py b/src/specklepy/objects/tests/polyline_test.py deleted file mode 100644 index dabded0..0000000 --- a/src/specklepy/objects/tests/polyline_test.py +++ /dev/null @@ -1,51 +0,0 @@ -from devtools import debug - -from specklepy.core.api.operations import deserialize, serialize -from specklepy.objects.geometry import Polyline -from specklepy.objects.models.units import Units -from specklepy.objects.primitive import Interval - -# create points for first polyline - not closed, in meters -points1_coords = [1.0, 1.0, 0.0, 2.0, 1.0, 0.0, 2.0, 2.0, 0.0, 1.0, 2.0, 0.0] - -# Create points for second polyline - closed, in ft -points2_coords = [0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 3.0, 3.0, 0.0, 0.0, 3.0, 0.0] - -# create polylines -polyline1 = Polyline( - value=points1_coords, - closed=False, - units=Units.m, - domain=Interval(start=0.0, end=1.0), -) - -polyline2 = Polyline( - value=points2_coords, - closed=True, - units=Units.feet, - domain=Interval(start=0.0, end=1.0), - applicationId="polyllllineeee", -) - -print("Polyline 1 length (meters):", polyline1.length) -print("Polyline 2 length (feet):", polyline2.length) - -ser_poly1 = serialize(polyline1) -poly1_again = deserialize(ser_poly1) - -print("\nOriginal polyline 1:") -debug(polyline1) -print("\nSerialized polyline 1:") -debug(ser_poly1) -print("\nDeserialized polyline 1:") -debug(poly1_again) - -ser_poly2 = serialize(polyline2) -poly2_again = deserialize(ser_poly2) - -print("\nOriginal polyline 2:") -debug(polyline2) -print("\nSerialized polyline 2:") -debug(ser_poly2) -print("\nDeserialized polyline 2:") -debug(poly2_again) diff --git a/src/specklepy/objects/tests/test_arc.py b/src/specklepy/objects/tests/test_arc.py new file mode 100644 index 0000000..21b7b57 --- /dev/null +++ b/src/specklepy/objects/tests/test_arc.py @@ -0,0 +1,151 @@ +import pytest +from specklepy.objects.geometry.arc import Arc +from specklepy.objects.geometry.plane import Plane +from specklepy.objects.geometry.point import Point +from specklepy.objects.geometry.vector import Vector +from specklepy.objects.other import Transform +from specklepy.objects.primitive import Interval +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def sample_arc(): + plane = Plane( + origin=Point(x=0, y=0, z=0, units="m"), + normal=Vector(x=0, y=0, z=1, units="m"), + xdir=Vector(x=1, y=0, z=0, units="m"), + ydir=Vector(x=0, y=1, z=0, units="m"), + units="m" + ) + + return Arc( + plane=plane, + startPoint=Point(x=1, y=0, z=0, units="m"), + midPoint=Point(x=0.7071, y=0.7071, z=0, units="m"), + endPoint=Point(x=0, y=1, z=0, units="m"), + domain=Interval.unit_interval(), + units="m" + ) + + +def test_arc_basic_properties(sample_arc): + assert pytest.approx(sample_arc.radius, 0.001) == 1.0 + assert sample_arc.units == "m" + + +def test_arc_transform(sample_arc): + transform = Transform(matrix=[ + 2, 0, 0, 1, + 0, 2, 0, 1, + 0, 0, 2, 1, + 0, 0, 0, 1 + ], units="m") + + success, transformed = sample_arc.transform_to(transform) + assert success is True + assert pytest.approx(transformed.radius, 0.001) == 2.0 + + +def test_arc_serialization(sample_arc): + serialized = serialize(sample_arc) + deserialized = deserialize(serialized) + + assert deserialized.units == sample_arc.units + assert pytest.approx(deserialized.radius, 0.001) == sample_arc.radius + + assert pytest.approx(deserialized.startPoint.x, + 0.001) == sample_arc.startPoint.x + assert pytest.approx(deserialized.startPoint.y, + 0.001) == sample_arc.startPoint.y + assert pytest.approx(deserialized.startPoint.z, + 0.001) == sample_arc.startPoint.z + + assert pytest.approx(deserialized.midPoint.x, + 0.001) == sample_arc.midPoint.x + assert pytest.approx(deserialized.midPoint.y, + 0.001) == sample_arc.midPoint.y + assert pytest.approx(deserialized.midPoint.z, + 0.001) == sample_arc.midPoint.z + + assert pytest.approx(deserialized.endPoint.x, + 0.001) == sample_arc.endPoint.x + assert pytest.approx(deserialized.endPoint.y, + 0.001) == sample_arc.endPoint.y + assert pytest.approx(deserialized.endPoint.z, + 0.001) == sample_arc.endPoint.z + + +def test_arc_measure(sample_arc): + assert pytest.approx(sample_arc.measure, 0.001) == 1.5708 + + +def test_arc_length(sample_arc): + assert pytest.approx(sample_arc.length, 0.001) == 1.5708 + + +def test_arc_domain(sample_arc): + assert sample_arc.domain.start == 0.0 + assert sample_arc.domain.end == 1.0 + assert sample_arc._domain == sample_arc.domain + + +def test_arc_to_list(): + plane = Plane( + origin=Point(x=0.0, y=0.0, z=0.0, units="m"), + normal=Vector(x=0.0, y=0.0, z=1.0, units="m"), + xdir=Vector(x=1.0, y=0.0, z=0.0, units="m"), + ydir=Vector(x=0.0, y=1.0, z=0.0, units="m"), + units="m" + ) + arc = Arc( + plane=plane, + startPoint=Point(x=1.0, y=0.0, z=0.0, units="m"), + midPoint=Point(x=0.7071, y=0.7071, z=0.0, units="m"), + endPoint=Point(x=0.0, y=1.0, z=0.0, units="m"), + domain=Interval(start=0.0, end=1.0), + units="m" + ) + + coords = arc.to_list() + assert len(coords) == 28 # total_length, type, units_encoding + data + assert coords[0] == 28 # total length + assert coords[1] == "Objects.Geometry.Arc" # speckle type + assert coords[5:7] == [0.0, 1.0] # domain + # Check plane coordinates + assert coords[7:10] == [0.0, 0.0, 0.0] # plane origin + assert coords[10:13] == [0.0, 0.0, 1.0] # plane normal + assert coords[13:16] == [1.0, 0.0, 0.0] # plane xdir + assert coords[16:19] == [0.0, 1.0, 0.0] # plane ydir + # Check point coordinates + assert coords[19:22] == [1.0, 0.0, 0.0] # start point + assert coords[22:25] == [0.7071, 0.7071, 0.0] # mid point + assert coords[25:28] == [0.0, 1.0, 0.0] # end point + + +def test_arc_from_list(): + coords = [ + 28, "Objects.Geometry.Arc", 3, # header + 1.0, 1.5708, # radius, measure + 0.0, 1.0, # domain + 0.0, 0.0, 0.0, # plane origin + 0.0, 0.0, 1.0, # plane normal + 1.0, 0.0, 0.0, # plane xdir + 0.0, 1.0, 0.0, # plane ydir + 1.0, 0.0, 0.0, # start point + 0.7071, 0.7071, 0.0, # mid point + 0.0, 1.0, 0.0 # end point + ] + + arc = Arc.from_list(coords) + assert arc.units == "m" + assert arc.domain.start == 0.0 + assert arc.domain.end == 1.0 + assert arc.startPoint.x == 1.0 + assert arc.startPoint.y == 0.0 + assert arc.startPoint.z == 0.0 + assert abs(arc.midPoint.x - 0.7071) < 0.0001 + assert abs(arc.midPoint.y - 0.7071) < 0.0001 + assert arc.midPoint.z == 0.0 + assert arc.endPoint.x == 0.0 + assert arc.endPoint.y == 1.0 + assert arc.endPoint.z == 0.0 diff --git a/src/specklepy/objects/tests/test_line.py b/src/specklepy/objects/tests/test_line.py new file mode 100644 index 0000000..c27e343 --- /dev/null +++ b/src/specklepy/objects/tests/test_line.py @@ -0,0 +1,70 @@ +import pytest +from specklepy.objects.geometry.line import Line +from specklepy.objects.geometry.point import Point +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.objects.primitive import Interval +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def sample_line(): + p1 = Point(x=0.0, y=0.0, z=0.0, units=Units.m) + p2 = Point(x=3.0, y=4.0, z=0.0, units=Units.m) + return Line(start=p1, end=p2, units=Units.m, domain=Interval(start=0.0, end=1.0)) + + +def test_line_creation(sample_line): + assert sample_line.start.x == 0.0 + assert sample_line.end.x == 3.0 + assert sample_line.units == Units.m.value + + +def test_line_length(sample_line): + assert sample_line.length == 5.0 + + +def test_line_transformation(sample_line): + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = sample_line.transform_to(transform) + assert success is True + assert transformed.start.x == 1.0 + assert transformed.end.x == 7.0 + assert transformed.units == sample_line.units + + +def test_line_serialization(sample_line): + serialized = serialize(sample_line) + deserialized = deserialize(serialized) + + assert deserialized.start.x == sample_line.start.x + assert deserialized.end.x == sample_line.end.x + assert deserialized.units == sample_line.units + + +def test_line_from_list(): + coords = [11, "Objects.Geometry.Line", 3, + 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 1.0] + line = Line.from_list(coords, "m") + assert line.start.x == 1.0 + assert line.start.y == 2.0 + assert line.start.z == 3.0 + assert line.end.x == 4.0 + assert line.end.y == 5.0 + assert line.end.z == 6.0 + assert line.domain.start == 0.0 + assert line.domain.end == 1.0 + assert line.units == "m" + + +def test_line_from_coords(): + line = Line.from_coords(0.0, 0.0, 0.0, 3.0, 4.0, 0.0, Units.m.value) + assert line.start.x == 0.0 + assert line.end.x == 3.0 + assert line.units == Units.m.value diff --git a/src/specklepy/objects/tests/test_mesh.py b/src/specklepy/objects/tests/test_mesh.py new file mode 100644 index 0000000..93cc571 --- /dev/null +++ b/src/specklepy/objects/tests/test_mesh.py @@ -0,0 +1,222 @@ +import pytest +from specklepy.objects.geometry.mesh import Mesh +from specklepy.objects.geometry.point import Point +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def sample_mesh(): + vertices = [ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0 + ] + faces = [3, 0, 1, 2, 3, 0, 2, 3] # Two triangles forming a square + colors = [255, 0, 0, 255] * 4 + + return Mesh( + vertices=vertices, + faces=faces, + colors=colors, + units=Units.m + ) + + +@pytest.fixture +def cube_mesh(): + vertices = [ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0, + 1.0, 0.0, 1.0, + 1.0, 1.0, 1.0, + 0.0, 1.0, 1.0 + ] + faces = [ + 3, 0, 1, 2, # bottom + 3, 0, 2, 3, + 3, 4, 5, 6, # top + 3, 4, 6, 7, + 3, 0, 1, 5, # front + 3, 0, 5, 4, + 3, 2, 3, 7, # back + 3, 2, 7, 6, + 3, 0, 3, 7, # left + 3, 0, 7, 4, + 3, 1, 2, 6, # right + 3, 1, 6, 5 + ] + return Mesh(vertices=vertices, faces=faces, units=Units.m) + + +def test_mesh_creation(sample_mesh): + assert sample_mesh.vertices_count == 4 + assert sample_mesh.faces_count == 2 + assert sample_mesh.units == Units.m.value + assert pytest.approx(sample_mesh.area, 0.001) == 1.0 + assert sample_mesh.volume == 0.0 + + +def test_mesh_get_point(sample_mesh): + point = sample_mesh.get_point(1) + assert point.x == 1.0 + assert point.y == 0.0 + assert point.z == 0.0 + assert point.units == Units.m.value + + +def test_mesh_get_points(sample_mesh): + points = sample_mesh.get_points() + assert len(points) == 4 + assert all(isinstance(p, Point) for p in points) + assert points[0].x == 0.0 + assert points[1].x == 1.0 + + +def test_mesh_get_face_vertices(sample_mesh): + face_vertices = sample_mesh.get_face_vertices(0) + assert len(face_vertices) == 3 + assert face_vertices[0].x == 0.0 + assert face_vertices[1].x == 1.0 + assert face_vertices[0].units == Units.m.value + + +def test_mesh_transform(sample_mesh): + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = sample_mesh.transform_to(transform) + assert success is True + + point = transformed.get_point(0) + assert point.x == 1.0 # 0*2 + 1 + assert point.y == 1.0 # 0*2 + 1 + + assert pytest.approx(transformed.area, 0.001) == sample_mesh.area * 4 + assert transformed.volume == 0.0 + + +def test_mesh_is_closed(sample_mesh, cube_mesh): + assert sample_mesh.is_closed() is False + assert cube_mesh.is_closed() is True + + +def test_mesh_area_and_volume(sample_mesh, cube_mesh): + # Test flat square + assert pytest.approx(sample_mesh.area, 0.001) == 1.0 + assert sample_mesh.volume == 0.0 + + # Test cube + # 1x1x1 cube has 6 faces + assert pytest.approx(cube_mesh.area, 0.001) == 6.0 + # 1x1x1 cube has volume 1 + assert pytest.approx(cube_mesh.volume, 0.001) == 1.0 + + +def test_mesh_serialization(sample_mesh): + serialized = serialize(sample_mesh) + deserialized = deserialize(serialized) + + assert deserialized.vertices == sample_mesh.vertices + assert deserialized.faces == sample_mesh.faces + assert deserialized.colors == sample_mesh.colors + assert deserialized.units == sample_mesh.units + assert pytest.approx(deserialized.area, 0.001) == sample_mesh.area + assert pytest.approx(deserialized.volume, 0.001) == sample_mesh.volume + + +def test_mesh_convert_units(sample_mesh): + original_area = sample_mesh.area + sample_mesh.convert_units(Units.mm) + assert sample_mesh.units == Units.mm.value + + point = sample_mesh.get_point(1) + assert point.x == 1000.0 + assert point.units == Units.mm.value + + assert pytest.approx( + sample_mesh.area, 0.001) == original_area * (1000 ** 2) + assert sample_mesh.volume == 0.0 + + +def test_mesh_to_list(): + mesh = Mesh( + vertices=[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + faces=[3, 0, 1, 2], + colors=[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255], + textureCoordinates=[0.0, 0.0, 1.0, 0.0, 0.0, 1.0], + units="m" + ) + + coords = mesh.to_list() + assert len(coords) > 3 + assert coords[0] > 3 + assert coords[1] == "Objects.Geometry.Mesh" + + index = 3 + vertices_count = coords[index] + assert vertices_count == 9 # 3 vertices * 3 coordinates + index += 1 + assert coords[index:index + vertices_count] == [0.0, + 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] + + index += vertices_count + faces_count = coords[index] + assert faces_count == 4 # 1 face with 3 vertices + count + index += 1 + assert coords[index:index + faces_count] == [3, 0, 1, 2] + + index += faces_count + colors_count = coords[index] + assert colors_count == 12 # 3 vertices * 4 rgba values + index += 1 + assert coords[index:index + colors_count] == [255, + 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255] + + index += colors_count + texture_coords_count = coords[index] + assert texture_coords_count == 6 # 3 vertices * 2 uv coordinates + index += 1 + assert coords[index:index + texture_coords_count] == [0.0, + 0.0, 1.0, 0.0, 0.0, 1.0] + + index += texture_coords_count + assert pytest.approx(coords[index], 0.001) == 0.5 + assert coords[index + 1] == 0.0 # volume + + +def test_mesh_from_list(): + coords = [ + 26, "Objects.Geometry.Mesh", 3, # header + 9, # vertices count + 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, # vertices + 4, # faces count + 3, 0, 1, 2, # faces + 12, # colors count + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, # colors + 6, # texture coordinates count + 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, # texture coordinates + 0.5, 0.0 # area, volume will be calculated + ] + + mesh = Mesh.from_list(coords) + assert mesh.units == "m" + assert len(mesh.vertices) == 9 + assert mesh.vertices == [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] + assert len(mesh.faces) == 4 + assert mesh.faces == [3, 0, 1, 2] + assert len(mesh.colors) == 12 + assert mesh.colors == [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255] + assert len(mesh.textureCoordinates) == 6 + assert mesh.textureCoordinates == [0.0, 0.0, 1.0, 0.0, 0.0, 1.0] + assert pytest.approx(mesh.area, 0.001) == 0.5 + assert mesh.volume == 0.0 diff --git a/src/specklepy/objects/tests/test_plane.py b/src/specklepy/objects/tests/test_plane.py new file mode 100644 index 0000000..2649de3 --- /dev/null +++ b/src/specklepy/objects/tests/test_plane.py @@ -0,0 +1,89 @@ +import pytest +from specklepy.objects.geometry.plane import Plane +from specklepy.objects.geometry.point import Point +from specklepy.objects.geometry.vector import Vector +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def sample_plane(): + return Plane( + origin=Point(x=0.0, y=0.0, z=0.0, units=Units.m), + normal=Vector(x=0.0, y=0.0, z=1.0, units=Units.m), + xdir=Vector(x=1.0, y=0.0, z=0.0, units=Units.m), + ydir=Vector(x=0.0, y=1.0, z=0.0, units=Units.m), + units=Units.m + ) + + +def test_plane_creation(sample_plane): + assert sample_plane.origin.x == 0.0 + assert sample_plane.normal.z == 1.0 + assert sample_plane.xdir.x == 1.0 + assert sample_plane.ydir.y == 1.0 + assert sample_plane.units == Units.m.value + + +def test_plane_transformation(sample_plane): + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = sample_plane.transform_to(transform) + assert success is True + assert transformed.origin.x == 1.0 + assert transformed.xdir.x == 2.0 + assert transformed.units == sample_plane.units + + +def test_plane_serialization(sample_plane): + serialized = serialize(sample_plane) + deserialized = deserialize(serialized) + + assert deserialized.origin.x == sample_plane.origin.x + assert deserialized.normal.z == sample_plane.normal.z + assert deserialized.units == sample_plane.units + + +def test_plane_to_list(): + plane = Plane( + origin=Point(x=1.0, y=2.0, z=3.0, units="m"), + normal=Vector(x=0.0, y=0.0, z=1.0, units="m"), + xdir=Vector(x=1.0, y=0.0, z=0.0, units="m"), + ydir=Vector(x=0.0, y=1.0, z=0.0, units="m"), + units="m" + ) + coords = plane.to_list() + # total_length, type, units_encoding + 12 coordinates + assert len(coords) == 15 + assert coords[3:6] == [1.0, 2.0, 3.0] # origin + assert coords[6:9] == [0.0, 0.0, 1.0] # normal + assert coords[9:12] == [1.0, 0.0, 0.0] # xdir + assert coords[12:15] == [0.0, 1.0, 0.0] # ydir + + +def test_plane_from_list(): + coords = [15, "Objects.Geometry.Plane", 3, # header + 1.0, 2.0, 3.0, # origin + 0.0, 0.0, 1.0, # normal + 1.0, 0.0, 0.0, # xdir + 0.0, 1.0, 0.0] # ydir + plane = Plane.from_list(coords) + assert plane.origin.x == 1.0 + assert plane.origin.y == 2.0 + assert plane.origin.z == 3.0 + assert plane.normal.x == 0.0 + assert plane.normal.y == 0.0 + assert plane.normal.z == 1.0 + assert plane.xdir.x == 1.0 + assert plane.xdir.y == 0.0 + assert plane.xdir.z == 0.0 + assert plane.ydir.x == 0.0 + assert plane.ydir.y == 1.0 + assert plane.ydir.z == 0.0 + assert plane.units == "m" diff --git a/src/specklepy/objects/tests/test_point.py b/src/specklepy/objects/tests/test_point.py new file mode 100644 index 0000000..4476df8 --- /dev/null +++ b/src/specklepy/objects/tests/test_point.py @@ -0,0 +1,72 @@ +import pytest +from specklepy.objects.geometry.point import Point +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.core.api.operations import serialize, deserialize + + +def test_point_creation(): + p1 = Point(x=1.0, y=2.0, z=3.0, units=Units.m) + assert p1.x == 1.0 + assert p1.y == 2.0 + assert p1.z == 3.0 + assert p1.units == Units.m.value + + +def test_point_distance_calculation(): + p1 = Point(x=1.0, y=2.0, z=3.0, units=Units.m) + p2 = Point(x=4.0, y=6.0, z=8.0, units=Units.m) + p3 = Point(x=1000.0, y=2000.0, z=3000.0, units=Units.mm) + + distance = p1.distance_to(p2) + expected = ((3.0**2 + 4.0**2 + 5.0**2) ** 0.5) + assert distance == pytest.approx(expected) + + distance = p1.distance_to(p3) + assert distance == pytest.approx(0.0) + + +def test_point_transformation(): + p1 = Point(x=1.0, y=2.0, z=3.0, units=Units.m) + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = p1.transform_to(transform) + assert success is True + assert transformed.x == 3.0 + assert transformed.y == 5.0 + assert transformed.z == 7.0 + + +def test_point_serialization(): + p1 = Point(x=1.0, y=2.0, z=3.0, units=Units.m) + serialized = serialize(p1) + deserialized = deserialize(serialized) + + assert deserialized.x == p1.x + assert deserialized.y == p1.y + assert deserialized.z == p1.z + assert deserialized.units == p1.units + + +def test_point_to_list(): + point = Point(x=1.0, y=2.0, z=3.0, units="m") + coords = point.to_list() + # total_length, type, units_encoding + 3 coordinates + assert len(coords) == 6 + assert coords[0] == 6 # total length + assert coords[1] == "Objects.Geometry.Point" # speckle type + assert coords[3:] == [1.0, 2.0, 3.0] # coordinates + + +def test_point_from_list(): + coords = [6, "Objects.Geometry.Point", 3, 1.0, 2.0, 3.0] + point = Point.from_list(coords, "m") + assert point.x == 1.0 + assert point.y == 2.0 + assert point.z == 3.0 + assert point.units == "m" diff --git a/src/specklepy/objects/tests/test_polyline.py b/src/specklepy/objects/tests/test_polyline.py new file mode 100644 index 0000000..b408039 --- /dev/null +++ b/src/specklepy/objects/tests/test_polyline.py @@ -0,0 +1,127 @@ +import pytest +from specklepy.objects.geometry.polyline import Polyline +from specklepy.objects.geometry.point import Point +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.objects.primitive import Interval +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def square_vertices(): + return [ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0 + ] + + +@pytest.fixture +def sample_polyline(square_vertices): + return Polyline( + value=square_vertices, + closed=True, + units=Units.m, + domain=Interval(start=0.0, end=1.0) + ) + + +def test_polyline_creation(square_vertices): + polyline = Polyline( + value=square_vertices, + closed=True, + units=Units.m, + domain=Interval(start=0.0, end=1.0) + ) + + assert len(polyline.value) == 12 + assert polyline.closed is True + assert polyline.units == Units.m.value + assert isinstance(polyline.domain, Interval) + assert polyline.domain.start == 0.0 + assert polyline.domain.end == 1.0 + + +def test_polyline_get_points(sample_polyline): + points = sample_polyline.get_points() + + assert len(points) == 4 + + assert all(isinstance(p, Point) for p in points) + + assert points[0].x == 0.0 and points[0].y == 0.0 and points[0].z == 0.0 + assert points[1].x == 1.0 and points[1].y == 0.0 and points[1].z == 0.0 + + assert all(p.units == Units.m.value for p in points) + + +def test_polyline_length(sample_polyline): + + assert pytest.approx(sample_polyline.length) == 4.0 + + sample_polyline.closed = False + assert pytest.approx(sample_polyline.length) == 3.0 + + +def test_polyline_transformation(sample_polyline): + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = sample_polyline.transform_to(transform) + assert success is True + + points = transformed.get_points() + + assert points[0].x == 1.0 + assert points[0].y == 1.0 + assert points[0].z == 1.0 + + assert points[1].x == 3.0 + assert points[1].y == 1.0 + assert points[1].z == 1.0 + + assert transformed.units == sample_polyline.units + + +def test_polyline_serialization(sample_polyline): + serialized = serialize(sample_polyline) + deserialized = deserialize(serialized) + + assert deserialized.value == sample_polyline.value + assert deserialized.closed == sample_polyline.closed + assert deserialized.units == sample_polyline.units + assert deserialized.domain.start == sample_polyline.domain.start + assert deserialized.domain.end == sample_polyline.domain.end + + +def test_polyline_to_list(): + polyline = Polyline( + value=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + closed=True, + domain=Interval(start=0.0, end=1.0), + units="m" + ) + coords = polyline.to_list() + assert coords[3] == 1 # is_closed (True = 1) + assert coords[4:6] == [0.0, 1.0] # domain + assert coords[6] == 6 # coords_count + assert coords[7:] == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] # coordinates + + +def test_polyline_from_list(): + coords = [11, "Objects.Geometry.Polyline", 3, # header + 1, # closed + 0.0, 1.0, # domain + 6, # coords_count + 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] # coordinates + polyline = Polyline.from_list(coords, "m") + assert polyline.closed is True + assert polyline.domain.start == 0.0 + assert polyline.domain.end == 1.0 + assert polyline.value == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + assert polyline.units == "m" diff --git a/src/specklepy/objects/tests/test_vector.py b/src/specklepy/objects/tests/test_vector.py new file mode 100644 index 0000000..49476be --- /dev/null +++ b/src/specklepy/objects/tests/test_vector.py @@ -0,0 +1,71 @@ +import pytest +from specklepy.objects.geometry.vector import Vector +from specklepy.objects.models.units import Units +from specklepy.objects.other import Transform +from specklepy.core.api.operations import serialize, deserialize + + +@pytest.fixture +def sample_vectors(): + return ( + Vector(x=1.0, y=2.0, z=3.0, units=Units.m), + Vector(x=4.0, y=5.0, z=6.0, units=Units.m) + ) + + +def test_vector_creation(sample_vectors): + v1, _ = sample_vectors + assert v1.x == 1.0 + assert v1.y == 2.0 + assert v1.z == 3.0 + assert v1.units == Units.m.value + + +def test_vector_length(sample_vectors): + v1, _ = sample_vectors + expected = (1.0**2 + 2.0**2 + 3.0**2) ** 0.5 + assert v1.length == pytest.approx(expected) + + +def test_vector_transformation(sample_vectors): + v1, _ = sample_vectors + transform = Transform(matrix=[ + 2.0, 0.0, 0.0, 1.0, + 0.0, 2.0, 0.0, 1.0, + 0.0, 0.0, 2.0, 1.0, + 0.0, 0.0, 0.0, 1.0 + ], units=Units.m) + + success, transformed = v1.transform_to(transform) + assert success is True + assert transformed.x == 2.0 + assert transformed.y == 4.0 + assert transformed.z == 6.0 + assert transformed.units == v1.units + + +def test_vector_serialization(sample_vectors): + v1, _ = sample_vectors + serialized = serialize(v1) + deserialized = deserialize(serialized) + + assert deserialized.x == v1.x + assert deserialized.y == v1.y + assert deserialized.z == v1.z + assert deserialized.units == v1.units + + +def test_vector_from_list(): + coords = [6, "Objects.Geometry.Vector", 3, 1.0, 2.0, 3.0] + vector = Vector.from_list(coords, "m") + assert vector.x == 1.0 + assert vector.y == 2.0 + assert vector.z == 3.0 + assert vector.units == "m" + + +def test_vector_to_list(): + vector = Vector(x=1.0, y=2.0, z=3.0, units="m") + coords = vector.to_list() + assert len(coords) == 6 + assert coords[3:] == [1.0, 2.0, 3.0]