diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e0d0e2e..978f808 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,39 +1,24 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/python { - "name": "Python 3", + "name": "speckle-automate-pymesh-spatial-joins", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", - "features": { - "ghcr.io/devcontainers-contrib/features/poetry:2": {} - }, - - "remoteEnv": { - "SPECKLE_TOKEN": "foobar" - }, - "containerEnv": { - "SPECKLE_TOKEN": "asdfasdf" - }, - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], + "dockerFile": "../Dockerfile", // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "cp .env.example .env && POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-root", + // "postCreateCommand": "poetry install --no-root", // Configure tool-specific properties. "customizations": { "vscode": { // Add the IDs of extensions you want installed when the container is created. "extensions": [ - "ms-python.vscode-pylance", "ms-python.python", - "ms-python.black-formatter", + "mikestead.dotenv", "streetsidesoftware.code-spell-checker", - "mikestead.dotenv" + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "charliermarsh.ruff" ] } } diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cfac25b..c03e6a6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4.1.1 - uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.10' - name: Install and configure Poetry uses: snok/install-poetry@v1 with: diff --git a/Dockerfile b/Dockerfile index ff7ca25..bf8dfde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,237 @@ -# We use the official Python 3.11 image as our base image and will add our code to it. For more details, see https://hub.docker.com/_/python -FROM python:3.11-slim +FROM debian:bullseye-slim + +# Install basic packages +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + netbase \ + wget; \ + rm -rf /var/lib/apt/lists/* + +# Install version control systems and procps +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + mercurial \ + openssh-client \ + subversion \ + procps; \ + rm -rf /var/lib/apt/lists/* + +# Install various development tools and libraries +RUN set -ex; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + bzip2 \ + dpkg-dev \ + file \ + g++ \ + gcc \ + imagemagick \ + libbz2-dev \ + libc6-dev \ + libcurl4-openssl-dev \ + libdb-dev \ + libevent-dev \ + libffi-dev \ + libgdbm-dev \ + libglib2.0-dev \ + libgmp-dev \ + libjpeg-dev \ + libkrb5-dev \ + liblzma-dev \ + libmagickcore-dev \ + libmagickwand-dev \ + libmaxminddb-dev \ + libncurses5-dev \ + libncursesw5-dev \ + libpng-dev \ + libpq-dev \ + libreadline-dev \ + libsqlite3-dev \ + libssl-dev \ + libtool \ + libwebp-dev \ + libxml2-dev \ + libxslt-dev \ + libyaml-dev \ + make \ + patch \ + unzip \ + xz-utils \ + zlib1g-dev \ + $(if apt-cache show 'default-libmysqlclient-dev' 2>/dev/null | grep -q '^Version:'; then \ + echo 'default-libmysqlclient-dev'; \ + else \ + echo 'libmysqlclient-dev'; \ + fi); \ + rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ENV LANG=C.UTF-8 + +# Install additional libraries +RUN /bin/sh -c set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + libbluetooth-dev \ + tk-dev \ + uuid-dev; \ + rm -rf /var/lib/apt/lists/* + +# Set GPG key and Python version +ENV GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D +ENV PYTHON_VERSION=3.10.13 + +# Install Python from source +RUN /bin/sh -c set -eux; \ + wget -O python.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"; \ + wget -O python.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"; \ + GNUPGHOME="$(mktemp -d)"; export GNUPGHOME; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$GPG_KEY"; \ + gpg --batch --verify python.tar.xz.asc python.tar.xz; \ + gpgconf --kill all; \ + rm -rf "$GNUPGHOME" python.tar.xz.asc; \ + mkdir -p /usr/src/python; \ + tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz; \ + rm python.tar.xz; \ + cd /usr/src/python; \ + gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ + ./configure \ + --build="$gnuArch" \ + --enable-loadable-sqlite-extensions \ + --enable-optimizations \ + --enable-option-checking=fatal \ + --enable-shared \ + --with-lto \ + --with-system-expat \ + --without-ensurepip; \ + nproc="$(nproc)"; \ + EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)"; \ + LDFLAGS="$(dpkg-buildflags --get LDFLAGS)"; \ + make -j "$nproc" \ + "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ + "LDFLAGS=${LDFLAGS:-}" \ + "PROFILE_TASK=${PROFILE_TASK:-}"; \ + rm python; \ + make -j "$nproc" \ + "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ + "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" \ + "PROFILE_TASK=${PROFILE_TASK:-}" \ + python; \ + make install; \ + bin="$(readlink -ve /usr/local/bin/python3)"; \ + dir="$(dirname "$bin")"; \ + mkdir -p "/usr/share/gdb/auto-load/$dir"; \ + cp -vL Tools/gdb/libpython.py "/usr/share/gdb/auto-load/$bin-gdb.py"; \ + cd /; \ + rm -rf /usr/src/python; \ + find /usr/local -depth \ + \( \ + \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \ + -o \( -type f -a \( -name '*.pyc' -o -name '*.pyo' -o -name 'libpython*.a' \) \) \ + \) -exec rm -rf '{}' +; \ + ldconfig; \ + python3 --version + +# Create symlinks for Python tools +RUN /bin/sh -c set -eux; \ + for src in idle3 pydoc3 python3 python3-config; do \ + dst="$(echo "$src" | tr -d 3)"; \ + [ -s "/usr/local/bin/$src" ]; \ + [ ! -e "/usr/local/bin/$dst" ]; \ + ln -svT "$src" "/usr/local/bin/$dst"; \ + done + +# Set Python pip and setuptools versions and install pip +ENV PYTHON_PIP_VERSION=23.0.1 +ENV PYTHON_SETUPTOOLS_VERSION=65.5.1 +ENV PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/c6add47b0abf67511cdfb4734771cbab403af062/public/get-pip.py +ENV PYTHON_GET_PIP_SHA256=22b849a10f86f5ddf7ce148ca2a31214504ee6c83ef626840fde6e5dcd809d11 +RUN /bin/sh -c set -eux; \ + wget -O get-pip.py "$PYTHON_GET_PIP_URL"; \ + echo "$PYTHON_GET_PIP_SHA256 *get-pip.py" | sha256sum -c -; \ + export PYTHONDONTWRITEBYTECODE=1; \ + python get-pip.py \ + --disable-pip-version-check \ + --no-cache-dir \ + --no-compile \ + "pip==$PYTHON_PIP_VERSION" \ + "setuptools==$PYTHON_SETUPTOOLS_VERSION"; \ + rm -f get-pip.py; \ + pip --version + + +# Set working directory +WORKDIR /root/ + +# Set build arguments +ARG BRANCH=main +ARG NUM_CORES=10 + +# Clone a git repository +RUN /bin/sh -c "git clone --single-branch --depth 1 -b $BRANCH https://github.com/nuvolos-cloud/PyMesh.git" + +# Set environment variables +ENV PYMESH_PATH=/root/PyMesh +ENV NUM_CORES=10 + +# Install additional packages +RUN echo "deb http://ftp.us.debian.org/debian unstable main contrib non-free" >> /etc/apt/sources.list.d/unstable.list && \ + apt-get update && apt-get install -y \ + gcc-9 \ + g++-9 \ + git \ + cmake \ + libgmp-dev \ + libmpfr-dev \ + libgmpxx4ldbl \ + libboost-dev \ + libboost-thread-dev \ + zip unzip patchelf && \ + apt-get clean + +# Build and install PyMesh +WORKDIR /root/PyMesh + +RUN git clone --depth 1 https://github.com/PyMesh/cgal.git $PYMESH_PATH/third_party/cgal +RUN git clone --depth 1 https://github.com/PyMesh/libigl.git $PYMESH_PATH/third_party/libigl +RUN git clone --depth 1 https://github.com/PyMesh/carve.git $PYMESH_PATH/third_party/carve +RUN git clone --depth 1 https://github.com/PyMesh/cork.git $PYMESH_PATH/third_party/cork +RUN git clone --depth 1 https://github.com/PyMesh/tetgen.git $PYMESH_PATH/third_party/tetgen +RUN git clone --depth 1 https://github.com/PyMesh/qhull.git $PYMESH_PATH/third_party/qhull +RUN git clone --depth 1 https://github.com/PyMesh/Clipper.git $PYMESH_PATH/third_party/Clipper +RUN git clone --depth 1 https://github.com/PyMesh/eigen.git $PYMESH_PATH/third_party/eigen +RUN git clone --depth 1 https://github.com/PyMesh/pybind11.git $PYMESH_PATH/third_party/pybind11 +RUN git clone --depth 1 https://github.com/PyMesh/geogram.git $PYMESH_PATH/third_party/geogram +RUN git clone --depth 1 https://github.com/PyMesh/draco.git $PYMESH_PATH/third_party/draco +RUN git clone --depth 1 https://github.com/PyMesh/TetWild.git $PYMESH_PATH/third_party/TetWild +RUN git clone --depth 1 https://github.com/PyMesh/WindingNumber.git $PYMESH_PATH/third_party/WindingNumber +RUN git clone --depth 1 https://github.com/PyMesh/tbb.git $PYMESH_PATH/third_party/tbb +RUN git clone --depth 1 https://github.com/PyMesh/jigsaw.git $PYMESH_PATH/third_party/jigsaw +RUN git clone --depth 1 https://github.com/fmtlib/fmt.git $PYMESH_PATH/third_party/fmt +RUN git clone --depth 1 https://github.com/gabime/spdlog.git $PYMESH_PATH/third_party/spdlog + +RUN git submodule update --init third_party/triangle +RUN git submodule update --init third_party/quartet +RUN git submodule update --init third_party/mmg +RUN git submodule update --init third_party/json + +RUN pip install -r $PYMESH_PATH/python/requirements.txt +RUN ./setup.py bdist_wheel +RUN rm -rf build_3.7 third_party/build +RUN python $PYMESH_PATH/docker/patches/patch_wheel.py dist/pymesh2*.whl +RUN pip install --upgrade pip +RUN pip install dist/pymesh2*.whl + +# Build third-party libraries for PyMesh +WORKDIR /root/PyMesh/third_party +RUN /bin/sh -c "python ./build.py mmg && python ./build.py tetgen" # We install poetry to generate a list of dependencies which will be required by our application RUN pip install poetry @@ -13,4 +245,5 @@ WORKDIR /home/speckle COPY . /home/speckle # Using poetry, we generate a list of requirements, save them to requirements.txt, and then use pip to install them -RUN poetry export --format requirements.txt --output /home/speckle/requirements.txt && pip install --requirement /home/speckle/requirements.txt +RUN poetry export --format requirements.txt --output /home/speckle/requirements.txt --without-hashes && \ + pip install --requirement /home/speckle/requirements.txt \ No newline at end of file diff --git a/Geometry/__init__.py b/Geometry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Geometry/mesh.py b/Geometry/mesh.py new file mode 100644 index 0000000..cc11441 --- /dev/null +++ b/Geometry/mesh.py @@ -0,0 +1,107 @@ +from typing import Tuple, Optional + +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 + + +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_transform_to_trimesh_matrix(transform: Transform) -> np.ndarray: + """ + Convert the Speckle Transform matrix to a NumPy array format suitable for trimesh. + + Returns: + np.ndarray: 4x4 transformation matrix in NumPy array format. + """ + return np.array(transform.value).reshape(4, 4) + + +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) diff --git a/Geometry/p.py b/Geometry/p.py new file mode 100644 index 0000000..2fa9209 --- /dev/null +++ b/Geometry/p.py @@ -0,0 +1,16 @@ +import pymesh + +vertices = [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0] + +] + +faces = [ + [0, 1, 2], + [0, 2, 3] +] + +mesh = pymesh.form_mesh(vertices, faces) diff --git a/Rules/__init__.py b/Rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Rules/actions.py b/Rules/actions.py new file mode 100644 index 0000000..e69de29 diff --git a/Rules/checks.py b/Rules/checks.py new file mode 100644 index 0000000..0a97f78 --- /dev/null +++ b/Rules/checks.py @@ -0,0 +1,51 @@ +# Required imports +from typing import Callable, List, Union + +from specklepy.objects import Base + + +# We're going to define a set of rules that will allow us to filter and +# process parameters in our Speckle objects. These rules will be encapsulated +# in a class called `ParameterRules`. + + +class ElementCheckRules: + """A collection of rules for processing parameters in Speckle objects. + + This class provides static methods that return lambda functions. These + lambda functions serve as filters or conditions we can use in our main + processing logic. By encapsulating these rules, we can easily extend + or modify them in the future. + """ + + @staticmethod + def rule_combiner(*rules: Callable[[Base], bool]) -> Callable[[Base], bool]: + def combined(obj: Base) -> bool: + return all(rule(obj) for rule in rules) + + return combined + + @staticmethod + def is_displayable_rule() -> Callable[[Base], bool]: + """Rule: Check if a parameter is displayable.""" + return ( + lambda parameter: parameter.displayValue + and parameter.displayValue is not None + ) + + @staticmethod + def speckle_type_rule( + desired_type: Union[str, List[str]] + ) -> Callable[[Base], bool]: + """Rule: Check if a parameter's speckle_type matches the desired type.""" + + # Convert single string to list for consistent handling + if isinstance(desired_type, str): + desired_type = [desired_type] + + print(desired_type) + + return ( + lambda speckle_object: getattr(speckle_object, "speckle_type", None) + in desired_type + ) diff --git a/Rules/traversal.py b/Rules/traversal.py new file mode 100644 index 0000000..e69de29 diff --git a/Utilities/flatten.py b/Utilities/flatten.py new file mode 100644 index 0000000..b51b587 --- /dev/null +++ b/Utilities/flatten.py @@ -0,0 +1,71 @@ +"""Helper module for a simple speckle object tree flattening.""" +from typing import Tuple, Optional + +from specklepy.objects import Base +from specklepy.objects.other import Instance, Transform + + +# def flatten_base(base: Base) -> Iterable[Base]: +# """Take a base and flatten it to an iterable of bases.""" +# if hasattr(base, "elements") and base["elements"] is not None: +# for element in base["elements"]: +# yield from flatten_base(element) +# yield base + + +def extract_base_and_transform( + base: Base, + inherited_instance_id: Optional[str] = None, + transform_list: Optional[List[Transform]] = None, +) -> Tuple[Base, str, Optional[List[Transform]]]: + """ + Traverses Speckle object hierarchies to yield `Base` objects and their transformations. + Tailored to Speckle's AEC data structures, it covers the newer hierarchical structures + with Collections and also with patterns found in older Revit specific data. + + Parameters: + - base (Base): The starting point `Base` object for traversal. + - inherited_instance_id (str, optional): The inherited identifier for `Base` objects without a unique ID. + - transform_list (List[Transform], optional): Accumulated list of transformations from parent to child objects. + + Yields: + - tuple: A `Base` object, its identifier, and a list of applicable `Transform` objects or None. + + The id of the `Base` object is either the inherited identifier for a definition from an instance + or the one defined in the object. + """ + # Derive the identifier for the current `Base` object, defaulting to an inherited one if needed. + current_id = getattr(base, "id", inherited_instance_id) + transform_list = transform_list or [] + + if isinstance(base, Instance): + # Append transformation data and dive into the definition of `Instance` objects. + if base.transform: + transform_list.append(base.transform) + if base.definition: + yield from extract_base_and_transform( + base.definition, current_id, transform_list.copy() + ) + else: + # Initial yield for the current `Base` object. + yield base, current_id, transform_list + + # Process 'elements' and '@elements', typical containers for `Base` objects in AEC models. + elements_attr = getattr(base, "elements", []) or getattr(base, "@elements", []) + for element in elements_attr: + if isinstance(element, Base): + # Recurse into each `Base` object within 'elements' or '@elements'. + yield from extract_base_and_transform( + element, current_id, transform_list.copy() + ) + + # Recursively process '@'-prefixed properties that are Base objects with 'elements'. + # This is a common pattern in older Speckle data models, such as those used for Revit commits. + for attr_name in dir(base): + if attr_name.startswith("@"): + attr_value = getattr(base, attr_name) + # If the attribute is a Base object containing 'elements', recurse into it. + if isinstance(attr_value, Base) and hasattr(attr_value, "elements"): + yield from extract_base_and_transform( + attr_value, current_id, transform_list.copy() + ) diff --git a/flatten.py b/flatten.py deleted file mode 100644 index 4c2d5bb..0000000 --- a/flatten.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Helper module for a simple speckle object tree flattening.""" - -from collections.abc import Iterable - -from specklepy.objects import Base - - -def flatten_base(base: Base) -> Iterable[Base]: - """Take a base and flatten it to an iterable of bases.""" - if hasattr(base, "elements"): - for element in base["elements"]: - yield from flatten_base(element) - yield base diff --git a/main.py b/main.py index ea4b4b4..ea2cfb8 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from speckle_automate import ( execute_automate_function, ) -from flatten import flatten_base +from Utilities.flatten import flatten_base class FunctionInputs(AutomateBase): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29