diff --git a/src/specklepy/objects/geometry.py b/src/specklepy/objects/geometry.py index 4767d4e..dcf089d 100644 --- a/src/specklepy/objects/geometry.py +++ b/src/specklepy/objects/geometry.py @@ -1,9 +1,14 @@ from dataclasses import dataclass, field -from typing import List +from typing import List, Tuple from specklepy.objects.base import Base -from specklepy.objects.interfaces import ICurve, IHasUnits -from specklepy.objects.models.units import Units +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 @@ -33,11 +38,28 @@ def from_coords(cls, x: float, y: float, z: float, units: str | Units) -> "Point def distance_to(self, other: "Point") -> float: """ - calculates the distance between this point and another given point + calculates the distance between this point and another given point. """ - dx = other.x - self.x - dy = other.y - self.y - dz = other.z - self.z + 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 @@ -70,9 +92,10 @@ def to_list(self) -> List[float]: return result @classmethod - def from_list(cls, coords: List[float], units: str) -> "Line": + def from_list(cls, coords: List[float], units: str | Units) -> "Line": if len(coords) < 6: - raise ValueError("Line from coordinate array requires 6 coordinates.") + 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) @@ -93,3 +116,185 @@ def from_coords( 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/interfaces.py b/src/specklepy/objects/interfaces.py index 28f6f40..274173c 100644 --- a/src/specklepy/objects/interfaces.py +++ b/src/specklepy/objects/interfaces.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod from dataclasses import dataclass, field -from typing import Generic, TypeVar +from typing import Generic, List, TypeVar from specklepy.logging.exceptions import SpeckleInvalidUnitException from specklepy.objects.base import Base @@ -37,22 +37,16 @@ def display_value(self) -> T: @dataclass(kw_only=True) class IHasUnits(metaclass=ABCMeta): - """Interface for objects that have units.""" units: str | Units _units: str = field(repr=False, init=False) @property def units(self) -> str: - """Get the units of the object""" return self._units @units.setter def units(self, value: str | Units): - """ - While this property accepts any string value, geometry expects units - to be specific strings (see Units enum) - """ if isinstance(value, str): self._units = value elif isinstance(value, Units): @@ -63,6 +57,40 @@ def units(self, value: str | Units): ) +@dataclass(kw_only=True) +class IHasArea(metaclass=ABCMeta): + + area: float + _area: float = field(init=False, repr=False) + + @property + def area(self) -> float: + return self._area + + @area.setter + def area(self, value: float): + if not isinstance(value, (int, float)): + raise ValueError(f"Area must be a number, got {type(value)}") + self._area = float(value) + + +@dataclass(kw_only=True) +class IHasVolume(metaclass=ABCMeta): + + volume: float + _volume: float = field(init=False, repr=False) + + @property + def volume(self) -> float: + return self._volume + + @volume.setter + def volume(self, value: float): + if not isinstance(value, (int, float)): + raise ValueError(f"Volume must be a number, got {type(value)}") + self._volume = float(value) + + # data object interfaces class IProperties(metaclass=ABCMeta): @property @@ -71,7 +99,7 @@ def properties(self) -> dict[str, object]: pass -class IDataObject(IProperties, IDisplayValue[list[Base]], metaclass=ABCMeta): +class IDataObject(IProperties, IDisplayValue[List[Base]], metaclass=ABCMeta): @property @abstractmethod def name(self) -> str: diff --git a/src/specklepy/objects/primitive.py b/src/specklepy/objects/primitive.py index 267401e..1fcd87d 100644 --- a/src/specklepy/objects/primitive.py +++ b/src/specklepy/objects/primitive.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import List from specklepy.objects.base import Base @@ -19,9 +20,9 @@ def __str__(self) -> str: def unit_interval(cls) -> "Interval": return cls(start=0, end=1) - def to_list(self) -> list[float]: + def to_list(self) -> List[float]: return [self.start, self.end] @classmethod - def from_list(cls, args: list[float]) -> "Interval": + def from_list(cls, args: List[float]) -> "Interval": return cls(start=args[0], end=args[1]) diff --git a/src/specklepy/objects/proxies.py b/src/specklepy/objects/proxies.py new file mode 100644 index 0000000..478daf7 --- /dev/null +++ b/src/specklepy/objects/proxies.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from specklepy.objects.base import Base +from specklepy.objects.interfaces import IHasUnits + + +@dataclass(kw_only=True) +class ColorProxy( + Base, + speckle_type="Models.Proxies.ColorProxy", + detachable={"objects"}, +): + objects: List[str] + value: int + name: Optional[str] + + +@dataclass(kw_only=True) +class GroupProxy( + Base, + speckle_type="Models.Proxies.GroupProxy", + detachable={"objects"}, +): + + objects: List[str] + name: str + + +@dataclass(kw_only=True) +class InstanceProxy( + Base, + IHasUnits, + speckle_type="Models.Proxies.InstanceProxy", +): + + definition_id: str + transform: List[float] + max_depth: int + + +@dataclass(kw_only=True) +class InstanceDefinitionProxy( + Base, + speckle_type="Models.Proxies.InstanceDefinitionProxy", + detachable={"objects"}, +): + + objects: List[str] + max_depth: int + name: str diff --git a/src/specklepy/objects/v3_test.py b/src/specklepy/objects/tests/line_test.py similarity index 58% rename from src/specklepy/objects/v3_test.py rename to src/specklepy/objects/tests/line_test.py index 264b6b9..fefd596 100644 --- a/src/specklepy/objects/v3_test.py +++ b/src/specklepy/objects/tests/line_test.py @@ -1,32 +1,19 @@ from devtools import debug -from specklepy.api.operations import deserialize, serialize +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 -# test points +# 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") -p3 = Point(units="m", x=0, y=0, z=0) -print("Distance between points:", p1.distance_to(p2)) - -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) - -# # test Line +# 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}") +print(f"\nLine length: {line.length}") ser_line = serialize(line) line_again = deserialize(ser_line) diff --git a/src/specklepy/objects/tests/mesh_test.py b/src/specklepy/objects/tests/mesh_test.py new file mode 100644 index 0000000..4857a87 --- /dev/null +++ b/src/specklepy/objects/tests/mesh_test.py @@ -0,0 +1,183 @@ +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 new file mode 100644 index 0000000..95cf6ba --- /dev/null +++ b/src/specklepy/objects/tests/point_test.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..dabded0 --- /dev/null +++ b/src/specklepy/objects/tests/polyline_test.py @@ -0,0 +1,51 @@ +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)