7 Commits

Author SHA1 Message Date
Jonathon Broughton 85a73cb8eb try import pymesh to stop schema check failing
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2023-11-13 02:43:30 +00:00
Jonathon Broughton 64c7fa7d48 gitignore
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2023-11-13 02:18:19 +00:00
Jonathon Broughton 380a2ee844 Real Clash Detection 2023-11-13 02:16:30 +00:00
Iain Sproat 7ba4467217 Docker Caching Enabled
* chore(github action): update composite action version to configure buildx

* chore(github action): use tagged release for speckle automate action
2023-11-12 19:32:15 +00:00
Iain Sproat 463b53f8c8 chore(github action): update composite action version to configure buildx (#2)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2023-11-12 18:26:40 +00:00
Jonathon Broughton 1f908aa8d2 Merge pull request #1 from specklesystems/iain/test-github-action-with-caching
chore(github action): test action with cache
2023-11-12 18:16:07 +00:00
Iain Sproat d40821dfa6 chore(github action): test action with cache 2023-11-12 18:13:03 +00:00
9 changed files with 498 additions and 192 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
run: |
python main.py generate_schema ${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}
- name: Speckle Automate Function - Build and Publish
uses: specklesystems/speckle-automate-github-composite-action@0.7.2
uses: specklesystems/speckle-automate-github-composite-action@0.7.4
with:
speckle_automate_url: ${{ env.SPECKLE_AUTOMATE_URL || 'https://automate.speckle.dev' }}
speckle_token: ${{ secrets.SPECKLE_FUNCTION_TOKEN }}
+11
View File
@@ -311,3 +311,14 @@ pyrightconfig.json
.ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,pycharm
Dockerfile copy
Dockerfile copy 2
.idea/git_toolbox_prj.xml
.gitignore
.idea/misc.xml
.idea/inspectionProfiles/Project_Default.xml
.gitignore
.idea/vcs.xml
.idea/speckle-automate-basic-clash-demo.iml
.idea/modules.xml
.idea/inspectionProfiles/profiles_settings.xml
+151
View File
@@ -0,0 +1,151 @@
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import List, Tuple, Any, Optional
try:
import pymesh
except ImportError:
pymesh = None # Or handle it in another appropriate way
from speckle_automate import AutomationContext
from Geometry.element import Element
from Geometry.mesh import cast
def detect_clashes_old(
reference_elements: List[Element], latest_elements: List[Element], _tolerance: float
) -> list[tuple[str, str, float]]:
"""
Detect clashes between two sets of mesh elements using Pymesh.
Args:
reference_elements (List[Element]): Elements from the reference model.
latest_elements (List[Element]): Elements from the latest model.
_tolerance (float): Tolerance value for clash detection. TODO: how to implement this?
Returns:
List[Tuple[str, str]]: List of tuples indicating clashes, with each tuple
containing the IDs of the clashing elements.
"""
# TODO: Spatial partitioning to reduce number of comparisons
# TODO: Tolerance
# TODO: parallel processing
clashes = []
for ref_element in reference_elements:
for latest_element in latest_elements:
for ref_mesh in ref_element.meshes:
for latest_mesh in latest_element.meshes:
# Convert Trimesh meshes to Pymesh if necessary
ref_pymesh: pymesh.Mesh = cast(ref_mesh, pymesh.Mesh)
latest_pymesh: pymesh.Mesh = cast(latest_mesh, pymesh.Mesh)
if not ref_pymesh or not latest_pymesh:
continue
intersection = pymesh.boolean(
ref_pymesh, latest_pymesh, operation="intersection"
)
if (
intersection and intersection.volume > 0
): # TODO: could tolerance relate to this?
severity = intersection.volume / min(
ref_pymesh.volume, latest_pymesh.volume
)
clashes.append((ref_element.id, latest_element.id, severity))
break
return clashes
def check_for_clash(
ref_element: Element, latest_element: Element
) -> Optional[tuple[Any, Any, Any]]:
"""
Check for a clash between two elements and calculate the severity of the clash.
Args:
ref_element (Element): An element from the reference model.
latest_element (Element): An element from the latest model.
Returns:
Tuple[str, str, float]: A tuple containing the IDs of the clashing elements and the severity, if a clash is found.
"""
for ref_mesh in ref_element.meshes:
for latest_mesh in latest_element.meshes:
ref_pymesh = cast(ref_mesh, pymesh.Mesh)
latest_pymesh = cast(latest_mesh, pymesh.Mesh)
if not ref_pymesh or not latest_pymesh:
continue
intersection = pymesh.boolean(
ref_pymesh, latest_pymesh, operation="intersection"
)
if intersection and intersection.volume > 0:
severity = intersection.volume / min(
ref_pymesh.volume, latest_pymesh.volume
)
return ref_element.id, latest_element.id, severity
return None
def detect_clashes(
reference_elements: List[Element], latest_elements: List[Element], _tolerance: float
) -> List[Tuple[str, str, float]]:
"""
Detect clashes between two sets of mesh elements using parallel processing.
Args:
reference_elements (List[Element]): Elements from the reference model.
latest_elements (List[Element]): Elements from the latest model.
_tolerance (float): Tolerance value for clash detection. TODO: how to implement this?
Returns:
List[Tuple[str, str, float]]: A list of tuples indicating clashes.
"""
clashes = []
with ProcessPoolExecutor() as executor:
future_clash = {
executor.submit(check_for_clash, ref, latest): (ref, latest)
for ref in reference_elements
for latest in latest_elements
}
for future in as_completed(future_clash):
result = future.result()
if result:
clashes.append(result)
return clashes
def detect_and_report_clashes(
reference_elements: list[Element],
latest_elements: list[Element],
tolerance: float,
automate_context: AutomationContext,
) -> list[tuple[str, str, float]]:
clashes = detect_clashes(reference_elements, latest_elements, tolerance)
total_clashes = len(clashes)
padding_length = len(str(total_clashes))
for i, (ref_id, latest_id, severity) in enumerate(clashes, start=1):
clash_number = str(i).zfill(padding_length)
combined_message = f"Clash {clash_number}: between {ref_id} and {latest_id} with severity {severity:.2f}"
object_ids = [ref_id, latest_id]
# Assuming severity levels: Low (<0.25), Medium (0.25-0.75), High (>0.75) TODO: Determine severity levels
if severity > 0.75:
category = "High"
elif severity > 0.25:
category = "Medium"
else:
category = "Low"
automate_context.attach_error_to_objects(
category=category, object_ids=object_ids, message=combined_message
)
return clashes
+64
View File
@@ -0,0 +1,64 @@
from typing import Tuple, Optional, List
import numpy as np
import trimesh
from specklepy.objects import Base
from specklepy.objects.geometry import Mesh as SpeckleMesh
from specklepy.objects.other import Transform
from Geometry.helpers import combine_transform_matrices
from Geometry.mesh import speckle_mesh_to_trimesh
class Element:
def __init__(self, id, meshes):
"""
Initialize an Element object with an ID and a list of meshes.
Args:
id (str): The ID of the Element.
meshes (List[Trimesh]): List of trimesh Mesh objects.
"""
self.id = id
self.meshes = meshes
def speckle_to_element(
base_id_transforms: Tuple[Base, str, Optional[List[Transform]]]
) -> Element:
"""
Convert a SpecklePy Base object, its identifier, and an optional list of transforms
to an Element object.
Args:
base_id_transforms (tuple): Contains a SpecklePy Base object, its identifier,
and an optional list of Transform objects.
Returns:
Element: The resulting Element object.
"""
base, speckle_id, transforms = base_id_transforms
display_value = base.displayValue
if isinstance(display_value, SpeckleMesh):
display_value = [display_value]
element = Element(speckle_id, meshes=[])
# Combine all transforms into a single matrix
combined_transform = (
combine_transform_matrices(transforms) if transforms else np.identity(4)
)
if isinstance(display_value, list):
for mesh in display_value:
if mesh:
t_mesh = speckle_mesh_to_trimesh(mesh)
if not isinstance(t_mesh, trimesh.Trimesh):
continue
# Apply the combined transformation matrix
t_mesh.apply_transform(combined_transform)
element.meshes.append(t_mesh)
return element
+143
View File
@@ -0,0 +1,143 @@
from typing import List
import numpy as np
from specklepy.objects.geometry import Vector
from specklepy.objects.other import Transform as SpeckleTransform
def calculate_polygon_normal(vertices: List[Vector]) -> Vector:
"""
Calculate the normal vector for a polygon represented by a list of vertices.
Args:
vertices (List[Vector]): A list of vertices representing the polygon.
Returns:
Vector: The normal vector of the polygon.
"""
normal = Vector.from_list([0.0, 0.0, 0.0])
num_vertices = len(vertices)
for i in range(num_vertices):
curr, nxt = vertices[i], vertices[(i + 1) % num_vertices]
# Cross product components are accumulated to find the normal.
normal.x += (curr.y - nxt.y) * (curr.z + nxt.z)
normal.y += (curr.z - nxt.z) * (curr.x + nxt.x)
normal.z += (curr.x - nxt.x) * (curr.y + nxt.y)
# Normalize the calculated normal vector.
length = np.sqrt(normal.x**2 + normal.y**2 + normal.z**2)
normal.x, normal.y, normal.z = (
normal.x / length,
normal.y / length,
normal.z / length,
)
return normal
def is_point_within_triangle(pt: Vector, v1: Vector, v2: Vector, v3: Vector) -> bool:
"""
Check if a point is inside a given triangle.
Args:
pt (Vector): The point to check.
v1, v2, v3 (Vector): The vertices of the triangle.
Returns:
bool: True if the point is inside the triangle, False otherwise.
"""
def sign(p1, p2, p3):
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
b1 = sign(pt, v1, v2) < 0.0
b2 = sign(pt, v2, v3) < 0.0
b3 = sign(pt, v3, v1) < 0.0
return (b1 == b2) and (b2 == b3)
def triangulate_face(vertices: List[Vector]) -> List[List[int]]:
"""
Triangulate a polygon defined by a list of vertices.
Args:
vertices (List[Vector]): The vertices of the polygon.
Returns:
List[List[int]]: A list of triangles, each represented as a list of vertex indices.
"""
triangles = []
indices = list(range(len(vertices)))
normal = calculate_polygon_normal(vertices)
# The ear clipping algorithm is used for triangulation.
while len(indices) > 2:
for i in range(len(indices)):
prev, curr, nxt = (
indices[i - 1],
indices[i],
indices[(i + 1) % len(indices)],
)
if is_convex(vertices[prev], vertices[curr], vertices[nxt], normal):
triangles.append([prev, curr, nxt])
del indices[i]
break
return triangles
def is_convex(a: Vector, b: Vector, c: Vector, normal: Vector) -> bool:
"""
Check if a triangle formed by three vertices (a, b, c) is convex with respect to a given normal.
Args:
a (Vector): The first vertex of the triangle.
b (Vector): The second vertex of the triangle.
c (Vector): The third vertex of the triangle.
normal (Vector): The normal vector with respect to which convexity is checked.
Returns:
bool: True if the triangle is convex with respect to the normal, False otherwise.
"""
ab = Vector.from_list([b.x - a.x, b.y - a.y, b.z - a.z])
bc = Vector.from_list([c.x - b.x, c.y - b.y, c.z - b.z])
cross = Vector.from_list(
[
ab.y * bc.z - ab.z * bc.y,
ab.z * bc.x - ab.x * bc.z,
ab.x * bc.y - ab.y * bc.x,
]
)
# Dot product to compare with the face normal
return cross.x * normal.x + cross.y * normal.y + cross.z * normal.z > 0
def combine_transform_matrices(transforms: List[SpeckleTransform]) -> np.ndarray:
"""
Combine multiple transformation matrices into a single matrix.
Args:
transforms (List[SpeckleTransform]): A list of Speckle Transform objects.
Returns:
np.ndarray: A combined 4x4 transformation matrix.
"""
combined_matrix = np.identity(4)
for transform in transforms:
matrix = convert_speckle_transform_to_matrix(transform)
combined_matrix = np.dot(combined_matrix, matrix)
return combined_matrix
def convert_speckle_transform_to_matrix(transform: SpeckleTransform) -> np.ndarray:
"""
Convert a Speckle Transform object to a 4x4 NumPy matrix.
Args:
transform (SpeckleTransform): The Speckle Transform object.
Returns:
np.ndarray: A 4x4 transformation matrix.
"""
return np.array(transform.value).reshape(4, 4)
+74 -96
View File
@@ -1,107 +1,85 @@
from typing import Tuple, Optional
from typing import Union, Type
try:
import pymesh
except ImportError:
pymesh = None
import trimesh
def trimesh_to_pymesh(mesh: trimesh.Trimesh) -> pymesh.Mesh:
"""
Convert a Trimesh object to a Pymesh object.
Args:
mesh (Trimesh): The Trimesh object to convert.
Returns:
pymesh.Mesh: The resulting Pymesh object.
"""
return pymesh.form_mesh(mesh.vertices, mesh.faces)
def pymesh_to_trimesh(mesh: pymesh.Mesh) -> trimesh.Trimesh:
"""
Convert a Pymesh object to a Trimesh object.
Args:
mesh (pymesh.Mesh): The Pymesh object to convert.
Returns:
trimesh.Trimesh: The resulting Trimesh object.
"""
return trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces)
def cast(
mesh: Union[trimesh.Trimesh, pymesh.Mesh], target_type: Type
) -> Union[trimesh.Trimesh, pymesh.Mesh]:
"""
Casts a mesh object to a specified type.
Args:
mesh (Union[trimesh.Trimesh, pymesh.Mesh]): The mesh object to cast.
target_type (Type): The type to cast the mesh to.
Returns:
Union[trimesh.Trimesh, pymesh.Mesh]: The cast mesh object.
"""
if isinstance(mesh, trimesh.Trimesh) and target_type is pymesh.Mesh:
return trimesh_to_pymesh(mesh)
elif isinstance(mesh, pymesh.Mesh) and target_type is trimesh.Trimesh:
return pymesh_to_trimesh(mesh)
else:
raise TypeError("Unsupported mesh type or target type.")
import numpy as np
import trimesh
from specklepy.objects import Base
from specklepy.objects.geometry import Mesh as SpeckleMesh
from specklepy.objects.other import Transform
from trimesh import Trimesh
from specklepy.objects.geometry import Mesh as SpeckleMesh, Vector
class Element:
def __init__(self, id, meshes):
"""
Initialize an Element object with an ID and a list of meshes.
def speckle_mesh_to_trimesh(input_mesh: SpeckleMesh) -> trimesh.Trimesh:
vertices = np.array(input_mesh.vertices).reshape((-1, 3))
faces = []
Args:
id (str): The ID of the Element.
meshes (List[Trimesh]): List of trimesh Mesh objects.
"""
self.id = id
self.meshes = meshes
i = 0
while i < len(input_mesh.faces):
face_vertex_count = input_mesh.faces[i]
i += 1 # Skip the vertex count
face_vertex_indices = input_mesh.faces[i : i + face_vertex_count]
def speckle_transform_to_trimesh_matrix(transform: Transform) -> np.ndarray:
"""
Convert the Speckle Transform matrix to a NumPy array format suitable for trimesh.
face_vertices = [
Vector.from_list(vertices[idx].tolist()) for idx in face_vertex_indices
]
Returns:
np.ndarray: 4x4 transformation matrix in NumPy array format.
"""
return np.array(transform.value).reshape(4, 4)
if face_vertex_count == 3:
faces.append(face_vertex_indices)
else:
triangulated = triangulate_face(face_vertices)
faces.extend(
[[face_vertex_indices[idx] for idx in tri] for tri in triangulated]
)
i += face_vertex_count
def speckle_to_element(
base_with_transforms: Tuple[Base, str, Optional[Transform]]
) -> Element:
"""
Convert a SpecklePy Base object and its associated Transform to an Element object.
Args:
base_with_transforms (tuple): Contains a SpecklePy Base object and its
associated Transform object.
Returns:
Element: The resulting Element object.
"""
# Unpack the tuple to get the base, speckle ID, and transform.
base, speckle_id, transform = base_with_transforms
# To convert the Base object to a trimesh Mesh, use the displayValue property.
# This property provides the display mesh, expected to be an iterable of
# SpecklePy Mesh objects. However, legacy objects might be a single mesh.
display_value = base.displayValue
if isinstance(display_value, SpeckleMesh):
display_value = [display_value]
if isinstance(display_value, list):
# Initialize an Element with an empty list of meshes.
element = Element(speckle_id, meshes=[])
for mesh in display_value:
if mesh:
# Convert the SpecklePy Mesh to a trimesh Mesh.
t_mesh = speckle_to_trimesh(mesh)
if not isinstance(t_mesh, Trimesh):
continue
# If there's a transform, apply it to the trimesh Mesh.
if transform is not None:
trimesh_matrix = speckle_transform_to_trimesh_matrix(transform)
t_mesh.apply_transform(trimesh_matrix)
# Append the trimesh Mesh to the Element's list of meshes.
element.meshes.append(t_mesh)
return element
def speckle_to_trimesh(speckle_mesh: SpeckleMesh) -> Trimesh:
"""
Convert a SpecklePy Mesh to a trimesh Mesh object.
Args:
speckle_mesh: The SpecklePy Mesh to convert.
Returns:
trimesh.Trimesh: The resulting trimesh Mesh object.
"""
# Convert the list of vertices to a numpy array. Reshape it to
# (num_vertices, 3) to fit the trimesh format.
vertices_array = np.array(speckle_mesh.vertices).reshape((-1, 3))
# Faces are expected to be triangular. Reshape the faces list accordingly.
# Convert the faces list to a numpy array
faces_array_raw = np.array(speckle_mesh.faces)
# Remove the leading 3s by skipping every 4th value
faces_cleaned = np.delete(faces_array_raw, np.arange(0, faces_array_raw.size, 4))
# Reshape the array into (-1, 3) shape
faces_array = faces_cleaned.reshape((-1, 3))
# Return a new trimesh object using the reshaped vertices and faces.
return trimesh.Trimesh(vertices=vertices_array, faces=faces_array)
return trimesh.Trimesh(vertices=vertices, faces=np.array(faces))
-16
View File
@@ -1,16 +0,0 @@
import pymesh
import numpy as np
vertices = [
[0, 0, 0],
[1, 0, 0],
[1, 1, 0],
[0, 1, 0]
]
faces = [
[0, 1, 2],
[0, 2, 3]
]
mesh = pymesh.form_mesh(np.array(vertices), faces)
+48 -79
View File
@@ -2,7 +2,7 @@
use the automation_context module to wrap your function in an Automate context helper
"""
from typing import List, Optional, Tuple
from typing import Optional
from pydantic import Field
from speckle_automate import (
@@ -16,9 +16,9 @@ from specklepy.objects import Base
from specklepy.objects.other import Transform
from specklepy.objects.units import Units
from specklepy.transports.server import ServerTransport
from trimesh import Trimesh
from Geometry.mesh import speckle_to_element, Element
from Geometry.clash import detect_and_report_clashes
from Geometry.element import speckle_to_element
from Rules.checks import ElementCheckRules
from Utilities.flatten import extract_base_and_transform
@@ -31,18 +31,6 @@ class FunctionInputs(AutomateBase):
https://docs.pydantic.dev/latest/usage/models/
"""
tolerance: float = Field(
default=25.0,
title="Tolerance",
description="Specify the tolerance value for the analysis. \
Negative values relaxes the test, positive values make it more strict.",
)
tolerance_unit: str = Field( # Using the SpecklePy Units enum here
default=Units.mm,
json_schema_extra={"examples": ["mm", "cm", "m"]},
title="Tolerance Unit",
description="Unit of the tolerance value.",
)
static_model_name: str = Field(
...,
title="Static Model Name",
@@ -50,6 +38,22 @@ class FunctionInputs(AutomateBase):
)
tolerance: float = Field(
default=25.0,
title="Tolerance",
description="Specify the tolerance value for the analysis. \
Negative values relaxes the test, positive values make it more strict.",
readonly=True,
)
tolerance_unit: str = Field( # Using the SpecklePy Units enum here
default=Units.mm,
json_schema_extra={"examples": ["mm", "cm", "m"]},
title="Tolerance Unit",
description="Unit of the tolerance value.",
readonly=True,
)
def automate_function(
automate_context: AutomationContext,
function_inputs: FunctionInputs,
@@ -128,76 +132,41 @@ def automate_function(
# using trimesh library process all these meshes in the form of A vs B
# and get the clashes
clashes = detect_clashes(
reference_mesh_elements, latest_mesh_elements, function_inputs.tolerance
clashes = detect_and_report_clashes(
reference_mesh_elements, latest_mesh_elements, tolerance, automate_context
)
print(len(clashes))
# all object count
# all reference objects count
# all latest objects count
automate_context.mark_run_success(status_message="Clash detection completed.")
percentage_reference_objects_clashing = (
len(set([ref_id for ref_id, latest_id, severity in clashes]))
/ len(reference_mesh_elements)
* 100
)
percentage_latest_objects_clashing = (
len(set([latest_id for ref_id, latest_id, severity in clashes]))
/ len(latest_mesh_elements)
* 100
)
# all clashes count
all_objects_count = len(reference_mesh_elements) + len(latest_mesh_elements)
all_clashes_count = len(clashes)
clash_report_message = (
f"Clash detection report: {all_clashes_count} clashes found "
f"between {all_objects_count} objects. "
f"Percentage of reference objects clashing: "
f"{percentage_reference_objects_clashing}%. "
f"Percentage of latest objects clashing: "
f"{percentage_latest_objects_clashing}%."
)
def detect_clashes(
elements_a: List[Element], elements_b: List[Element], length_tolerance: float
) -> List[Tuple[Element, Element]]:
"""
Detects clashes between two sets of elements with a specified tolerance.
This function checks each combination of elements from `elements_a` and `elements_b`
to see if any of their respective meshes intersect within the specified tolerance.
If a clash is detected between any mesh from an element in `elements_a` and any mesh
from an element in `elements_b`, the pair of elements is added to the results.
Args:
- elements_a (List[Element]): A list of `Element` objects to be checked for clashes.
- elements_b (List[Element]): A second list of `Element` objects to be checked for clashes against `elements_a`.
- length_tolerance (float): The distance to offset mesh vertices for intersection check.
Returns:
- List[Tuple[Element, Element]]: A list of tuples where each tuple contains a pair of `Element` objects that clash.
"""
# Use list comprehension to get pairs of elements that have clashing meshes
clashes = [
(element_a, element_b)
for element_a in elements_a
for element_b in elements_b
if any(
check_intersection_with_tolerance(mesh_a, mesh_b, length_tolerance)
for mesh_a in element_a.meshes
for mesh_b in element_b.meshes
)
]
return clashes
def check_intersection_with_tolerance(
mesh_a: Trimesh, mesh_b: Trimesh, tolerance: float
) -> bool:
"""
Checks for intersections between two meshes within a specified tolerance.
Args:
- mesh_a: The first mesh to check.
- mesh_b: The second mesh to check.
- tolerance (float): The distance to offset mesh vertices for intersection check.
Positive values expand the mesh, negative values contract it.
Returns:
- bool: True if the meshes intersect within the specified tolerance, otherwise False.
"""
half_tolerance = tolerance / 2.0 # TODO: how to shrink bloat mesh?
offset_mesh_a: Trimesh = mesh_a # mesh_a.offset_mesh(half_tolerance)
offset_mesh_b: Trimesh = mesh_b # mesh_b.offset_mesh(half_tolerance)
# return offset_mesh_a.intersection(offset_mesh_b).volume > 0 TODO: Install Blender as the engine
# return a random boolean for testing - significantly favouring false
import random
return random.random() < 0.05
automate_context.mark_run_success(
status_message="Clash detection completed. " + clash_report_message
)
def get_reference_model(