diff --git a/.gitignore b/.gitignore index 20843f5011bbd037f2202e47b85a1b9d20ae5e65..c2e6744ba73d9a990cc5b2d06d6d570e7fa44916 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ conan/profiles/host/user/** # CMake User Preset /CMakeUserPresets.json +dist/ + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dec5fb66eefc356e18d0cb59e6597f423bd1d84c..ae67a4978cda54fc28f4f38b280504cbed273d7d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,8 @@ stages: - precheck - build_test_x86-64_release - quality + - wheels + - deploy workflow: # only create the regular pipeline on a commit to default branch or the MR pipeline, but not both @@ -30,6 +32,11 @@ variables: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - when: never +.deploy_rules: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_MERGE_REQUEST_LABELS =~ /deploy/' + - when: never + format_check: stage: precheck extends: [.only_mr_rules] @@ -113,7 +120,7 @@ clang-17-Release: CONAN_HOST_PROFILE: ci-clang-17 CONAN_BUILD_PROFILE: linux BUILD_TYPE: Release - extends: [.test_report] + extends: [.only_mr_rules, .test_report] script: - !reference [.conan_install, script] - !reference [.build, script] @@ -125,7 +132,7 @@ gcc-11-Release: CONAN_HOST_PROFILE: ci-gcc-11 CONAN_BUILD_PROFILE: linux BUILD_TYPE: Release - extends: [.test_report] + extends: [.only_mr_rules, .test_report] script: - !reference [.conan_install, script] - !reference [.build, script] @@ -137,7 +144,7 @@ clang-17-Debug-ASAN: CONAN_HOST_PROFILE: ci-clang-17-asan CONAN_BUILD_PROFILE: linux BUILD_TYPE: Debug - extends: [.test_report] + extends: [.only_mr_rules, .test_report] script: - !reference [.conan_install, script] - !reference [.build, script] @@ -149,7 +156,7 @@ clang-17-Debug-UBSAN: CONAN_HOST_PROFILE: ci-clang-17-ubsan CONAN_BUILD_PROFILE: linux BUILD_TYPE: Debug - extends: [.test_report] + extends: [.only_mr_rules, .test_report] script: - !reference [.conan_install, script] - !reference [.build, script] @@ -161,8 +168,131 @@ gcc-11-Debug-gcov: CONAN_HOST_PROFILE: ci-gcc-11-gcov CONAN_BUILD_PROFILE: linux BUILD_TYPE: Debug - extends: [.gcov] + extends: [.only_mr_rules, .gcov] script: - !reference [.conan_install, script] - !reference [.build, script] - !reference [.gcov, script] + +bump-version: + stage: precheck + image: python:3.12 + script: + - pip install semver python-gitlab + - python ci/bump_version.py + artifacts: + paths: + - python/pymandos/_version.py + +.wheel-linux: + image: python:3.12 + # make a docker daemon available for cibuildwheel to use + services: + - name: docker:dind + entrypoint: ["env", "-u", "DOCKER_HOST"] + command: ["dockerd-entrypoint.sh"] + variables: + DOCKER_HOST: tcp://docker:2375/ + DOCKER_DRIVER: overlay2 + # See https://github.com/docker-library/docker/pull/166 + DOCKER_TLS_CERTDIR: "" + BUILD_TYPE: Release + CIBW_ARCHS: auto64 + before_script: + script: + - curl -sSL https://get.docker.com/ | sh + - python -m pip install conan cmake ninja cibuildwheel==2.22.0 + - git clone https://github.com/conan-io/cmake-conan.git -b develop2 + - cibuildwheel --output-dir dist + +wheel-linux-py38: + stage: wheels + extends: [.only_mr_rules, .wheel-linux] + variables: + CIBW_BUILD: cp38-* + +wheel-linux-py39: + stage: wheels + extends: [.only_mr_rules, .wheel-linux] + variables: + CIBW_BUILD: cp39-* + +wheel-linux-py310: + stage: wheels + extends: [.only_mr_rules, .wheel-linux] + variables: + CIBW_BUILD: cp310-* + +wheel-linux-py311: + stage: wheels + extends: [.only_mr_rules, .wheel-linux] + variables: + CIBW_BUILD: cp311-* + +wheel-linux-py312: + stage: wheels + extends: [.only_mr_rules, .wheel-linux] + variables: + CIBW_BUILD: cp312-* + +.deploy-linux-pypi: + extends: [.wheel-linux] + script: + - !reference [.wheel-linux, script] + - pip install twine + - twine upload dist/*.whl + +deploy-linux-py38: + stage: deploy + extends: [.deploy_rules, .deploy-linux-pypi] + variables: + CIBW_BUILD: cp38-* + needs: [bump-version] + +# .wheel-windows: +# stage: deploy +# image: mcr.microsoft.com/windows/servercore:1809 +# variables: +# CIBW_ARCHS: "auto64" +# before_script: +# - choco install python -y --version 3.12.4 +# - choco install git.install -y +# - git clone https://github.com/conan-io/cmake-conan.git -b develop2 +# - py -m pip install cibuildwheel==2.22.0 +# script: +# - py -m cibuildwheel --output-dir wheelhouse --platform windows +# artifacts: +# paths: +# - wheelhouse/ +# tags: +# - saas-windows-medium-amd64 + +# wheel-windows-py38: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp38-* + +# wheel-windows-py39: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp39-* + +# wheel-windows-py310: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp310-* + +# wheel-windows-py311: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp311-* + +# wheel-windows-py312: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp312-* + +# wheel-windows-py313: +# extends: [.wheel-windows] +# variables: +# CIBW_BUILD: cp313-* \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index e1fc95f5ff2135fc5ef7c40d1f92b0096159483a..fc9ebd032a8ce1c9a70d2258dd63c6f920da6acd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "cmake-modules"] path = cmake-modules url = git@gitlab.com:mslab-urjc/cmake-modules.git +[submodule "nanobind"] + path = nanobind + url = https://github.com/wjakob/nanobind diff --git a/CMakeLists.txt b/CMakeLists.txt index 8617c6284c6f71feec4f77619a0080fc0a5536d5..d7776bc8ebbc82ed8664d64fe33f01084a2307dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,10 @@ -cmake_minimum_required(VERSION 3.9) +cmake_minimum_required(VERSION 3.22) project(mandos LANGUAGES CXX) +if(SKBUILD) + set(CMAKE_PROJECT_VERSION ${SKBUILD_PROJECT_VERSION}) +endif() + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # CXX Standard @@ -22,7 +26,6 @@ local_options() include(GenerateExportHeader) -find_package(OpenMP REQUIRED) find_package(Eigen3 REQUIRED) find_package(spdlog REQUIRED) find_package(Tracy REQUIRED) @@ -40,6 +43,11 @@ add_subdirectory(src) if(MANDOS_BUILD_BINDINGS) find_package(pybind11 REQUIRED) add_subdirectory(bindings) + # Handling building the python wheel with scikit-build + if(SKBUILD) + include(cmake/ThirdParty.cmake) + copy_third_party_shared_libs("${CMAKE_INSTALL_PREFIX}/lib") + endif() endif() if(BUILD_TESTING) diff --git a/bindings/Mandos/python/CMakeLists.txt b/bindings/Mandos/python/CMakeLists.txt index 922c17ba87ccb0b6eb2d68e791ef35b8aa5ee56a..647c7ced4b3cdbe5b760395f71ef31e2289ecfc8 100644 --- a/bindings/Mandos/python/CMakeLists.txt +++ b/bindings/Mandos/python/CMakeLists.txt @@ -26,20 +26,23 @@ set(HEADERS Energies/ConstantForce.hpp ) -pybind11_add_module(pymandos ${SOURCES} ${HEADERS}) +pybind11_add_module(mandos_pylib ${SOURCES} ${HEADERS}) target_link_libraries( - pymandos + mandos_pylib PRIVATE Mandos::Core fmt::fmt project_options project_warnings pybind11::pybind11 - pybind11::embed + # pybind11::embed pybind11::module ) target_include_directories( - pymandos + mandos_pylib PUBLIC $ $ ) + +install(TARGETS mandos_pylib LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX}) +set_target_properties(mandos_pylib PROPERTIES INSTALL_RPATH "$ORIGIN/lib") diff --git a/bindings/Mandos/python/Mandos.cpp b/bindings/Mandos/python/Mandos.cpp index 6514a3961f26a2fb60f2b4ba859cc5c29b4b3074..dcf1b025c930cd3f49f50f1a72ab44b45787a64e 100644 --- a/bindings/Mandos/python/Mandos.cpp +++ b/bindings/Mandos/python/Mandos.cpp @@ -15,7 +15,7 @@ #include #include -PYBIND11_MODULE(pymandos, m) +PYBIND11_MODULE(mandos_pylib, m) { mandos::py::wrapModel(m); mandos::py::wrapDeformable3D(m); diff --git a/ci/bump_version.py b/ci/bump_version.py new file mode 100644 index 0000000000000000000000000000000000000000..f178696f020c3549974981aa0b0870df0e8dfee4 --- /dev/null +++ b/ci/bump_version.py @@ -0,0 +1,91 @@ +import os +import sys +import subprocess + +import semver +import gitlab + +def git(*args): + return subprocess.check_output(["git"] + list(args)) + +def extract_merge_request_id_from_commit(project): + try: + branch = os.getenv("CI_COMMIT_BRANCH") + if branch is not None: + print(f"Getting MR id from branch {branch}") + commits = project.commits.list(ref_name=branch, per_page=1) + last_commit = commits[0] + last_commit_mr = last_commit.merge_requests() + merge_id = last_commit_mr[-1]["iid"] + else: + merge_id = os.getenv("CI_MERGE_REQUEST_IID") + except gitlab.GitlabListError: + raise Exception(f"Unable to extract merge request from commit: {last_commit}") + return merge_id + + +def tag_repo_with_new_version(new_version): + os.system('git config user.email "${GITLAB_USER_EMAIL}"') + os.system('git config user.name "${GITLAB_USER_NAME}"') + os.system( + 'REMOTE_URL="https://oauth2:${MANDOS_ACCESS_TOKEN}@gitlab.com/${CI_PROJECT_PATH}" && git remote set-url tag-origin ${REMOTE_URL} || git remote add tag-origin ${REMOTE_URL}' + ) + os.system('git tag -a "{}" -m "Auto-Release"'.format(new_version)) + os.system('git push tag-origin "{}"'.format(new_version)) + + +def retrieve_labels_from_merge_request(project_object, merge_request_id): + print(f"Getting labels from MR id {merge_request_id}") + merge_request = project_object.mergerequests.get(merge_request_id) + return merge_request.labels + + +def bump(latest, project): + branch = os.getenv("CI_COMMIT_BRANCH") + + merge_request_id = extract_merge_request_id_from_commit(project) + labels = retrieve_labels_from_merge_request(project, merge_request_id) + + if "deploy" in labels: + if "bump-major" in labels: + new_version = semver.bump_major(latest) + elif "bump-minor" in labels: + new_version = semver.bump_minor(latest) + else: + new_version = semver.bump_patch(latest) + + return new_version + return None + + +def get_latest_remote_tag(project): + for each_tag in project.tags.list(get_all=True): + if semver.VersionInfo.is_valid(each_tag.name): + return each_tag.name + raise ValueError("ValueError exception thrown") + + +def main(): + project_id = os.environ["CI_PROJECT_ID"] + print(os.environ) + gitlab_oauth_token = os.environ["MANDOS_ACCESS_TOKEN"] + + gl = gitlab.Gitlab("https://gitlab.com", oauth_token=gitlab_oauth_token) + gl.auth() + project = gl.projects.get(project_id) + + latest = get_latest_remote_tag(project) + if os.getenv("CI_COMMIT_BRANCH") == "main": + version = bump(latest, project) + print(version) + if version is not None: + tag_repo_with_new_version(version) + + with open("python/pymandos/_version.py", "w") as output_file: + output_file.write("{}".format(version)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/cmake/ThirdParty.cmake b/cmake/ThirdParty.cmake new file mode 100644 index 0000000000000000000000000000000000000000..b7042bb3be52508895e751782b1e8a879b7dabb6 --- /dev/null +++ b/cmake/ThirdParty.cmake @@ -0,0 +1,41 @@ +include(CMakeDependentOption) + +# Copy third-party shared libs to the build directory for tests +function(copy_third_party_shared_libs target_dir) + message(STATUS "Copying third-party shared libraries to ${target_dir}...") + + if(WIN32) + set(pattern "*.dll") + elseif(APPLE) + set(pattern "*.dylib") + else() + set(pattern "*.so*") + endif() + + file(GLOB_RECURSE libs "${CMAKE_BINARY_DIR}/${pattern}") + set(copied_files) + foreach(path ${libs}) + message(STATUS "Copying shared libraries from ${path} to ${target_dir}") + file(GLOB libs "${path}/${pattern}") + file(COPY ${path} DESTINATION "${target_dir}") + list(APPEND copied_files ${path}) + endforeach() + message(STATUS ${copied_files}) + + # Set RPATH to $ORIGIN for the copied libraries + if(NOT WIN32) + find_program(PATCHELF_EXECUTABLE patchelf REQUIRED) + foreach(lib ${copied_files}) + get_filename_component(lib_name ${lib} NAME) + if(APPLE) + execute_process( + COMMAND install_name_tool -add_rpath @loader_path "${target_dir}/${lib_name}" COMMAND_ERROR_IS_FATAL ANY + ) + else() + execute_process( + COMMAND "${PATCHELF_EXECUTABLE}" --set-rpath \$ORIGIN "${target_dir}/${lib_name}" COMMAND_ERROR_IS_FATAL ANY + ) + endif() + endforeach() + endif() +endfunction() diff --git a/conanfile.py b/conanfile.py index 30afb9a49b1ae28827cfae7f4bbf42bb54e98e37..ab561d3775c6188275794b98b890d3633de44cff 100644 --- a/conanfile.py +++ b/conanfile.py @@ -55,11 +55,13 @@ class Mandos(ConanFile): if self.options.shared: self.options.rm_safe("fPIC") + def generate(self): cmake = CMakeDeps(self) cmake.generate() toolchain = CMakeToolchain(self) + toolchain.absolute_paths = True ## Needed because we are building with pip and it copies the project to another location toolchain.cache_variables["WARNINGS_AS_ERRORS"] = self.options.warnings_as_errors toolchain.cache_variables["ENABLE_COVERAGE"] = self.options.coverage toolchain.cache_variables["ENABLE_SANITIZER_UNDEFINED"] = self.options.ubsan diff --git a/nanobind b/nanobind new file mode 160000 index 0000000000000000000000000000000000000000..b7c4f1abfcab9cc5a8f0ef758926d92ff5eac3a3 --- /dev/null +++ b/nanobind @@ -0,0 +1 @@ +Subproject commit b7c4f1abfcab9cc5a8f0ef758926d92ff5eac3a3 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..4a073ee02b2572bb62a2fb6d2520e9626cda6270 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "pymandos" +dynamic = ["version"] + +description = "Mandos: A general purpose physically based simulation engine." +readme = "README.md" +license.file = "LICENSE" +authors = [ + { name = "Juan Jose Casafranca", email = "jjcasmar@gmail.com" }, +] +maintainers = [ + { name = "Juan Jose Casafranca", email = "jjcasmar@gmail.com" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] +requires-python = ">=3.7" + +dependencies = [ + "numpy", + "polyscope", + "scipy", +] + +[tool.setuptools_scm] # Section required +write_to = "python/pymandos/_version.py" + +[project.urls] +Homepage = "https://gitlab.com/mslab-urjc/mandos" + +[build-system] +requires = ["scikit-build-core", "conan", "patchelf"] +build-backend = "scikit_build_core.build" + +[tool.scikit-build] +build.verbose = true +logging.level = "INFO" +cmake.args = [ + "-DCMAKE_PROJECT_TOP_LEVEL_INCLUDES=cmake-conan/conan_provider.cmake", + "-DCONAN_INSTALL_ARGS=--build=missing;--options=&:shared=True;--options=*:shared=True;--deployer=full_deploy;--build=b2/5.2.1", + "-DMANDOS_BUILD_BINDINGS=True", + "-DBUILD_SHARED_LIBS=True" +] +wheel.install-dir = "pymandos" + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.regex" +input = "python/pymandos/_version.py" + +[tool.cibuildwheel] +manylinux-x86_64-image = "manylinux_2_28" diff --git a/python/pymandos/__init__.py b/python/pymandos/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9b054c0e110fb430ce4bb282d24b024e1dca3ee3 --- /dev/null +++ b/python/pymandos/__init__.py @@ -0,0 +1,5 @@ +from .mandos_pylib import * + +from ._version import __version__ as __version__ + +import parallel_transport diff --git a/python/pymandos/_version.py b/python/pymandos/_version.py new file mode 100644 index 0000000000000000000000000000000000000000..6c8e6b979c5f58121ac7ee2d9e024749da3a8ce1 --- /dev/null +++ b/python/pymandos/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/python/pymandos/parallel_transport.py b/python/pymandos/parallel_transport.py new file mode 100644 index 0000000000000000000000000000000000000000..d2c9ce40b9b9e20990d82127a7bac10243bae285 --- /dev/null +++ b/python/pymandos/parallel_transport.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import numpy as np +from scipy.spatial.transform import Rotation + +def parallel_transport(v1 : np.array, v2 : np.array) -> np.array: + """ + Compute the parallel transport rotation matrix + + The parallel transport matrix is the minimum rotation that aligns v1 and v2 + + Parameters: + v1 (np.array): The initial 3d vector + v2 (np.array): The final 3d vector + + Returns: + np.array: The 3x3 parallel transport rotation matrix + """ + cross = np.cross(v1, v2) + cross_norm = np.linalg.norm(cross) + if cross_norm < 1e-7: + return np.identity(3) + axis = cross / cross_norm + angle = np.atan2(cross_norm, np.dot(v1, v2)) + return Rotation.from_rotvec(axis * angle).as_matrix() + +def normalize(a : np.array) -> np.array: + return a / np.linalg.norm(a) + +def compute_rotation_matrix_from_direction(direction: np.array) -> np.array: + initial_dir = np.array((0.0, 0.0, 1.0)) + return parallel_transport(initial_dir, normalize(direction)) + +def compute_rotations_parallel_transport(positions : np.array) -> np.array: + """ + Compute the rotation vectors necessary so that the z director is following the centreline of the curve. + + Parameters: + positions (np.array): An Nx3 shaped array containing the positions of the centreline. + + Returns: + np.array: An array of equal shape as the positions array with the rotation vectors + """ + rotvecs = [(0.0, 0.0, 0.0)] * positions.size + + # Compute first rotation + direction0 = positions[1] - positions[0] + R0 = compute_rotation_matrix_from_direction(direction0) + + rotvecs[0] = Rotation.from_matrix(R0).as_rotvec() + + last_direction = normalize(direction0) + last_rotation = R0 + + for i in range(1,len(positions)): + vA = positions[i-1] + vB = positions[i] + + AB = vB - vA + + direction = normalize(AB) + rotation = parallel_transport(last_direction, direction) @ last_rotation + + rotvecs[i] = Rotation.from_matrix(rotation).as_rotvec(); + + last_rotation = rotation + last_direction = direction + + return np.array(rotvecs) diff --git a/src/Mandos/Core/CMakeLists.txt b/src/Mandos/Core/CMakeLists.txt index 3427ab8b99e614d43549e6ce62819e4211766a8b..ad6b40cedde7384e47f61add7694510f85c50e5f 100644 --- a/src/Mandos/Core/CMakeLists.txt +++ b/src/Mandos/Core/CMakeLists.txt @@ -128,3 +128,6 @@ target_link_libraries( add_library(Mandos::Core ALIAS Core) generate_export_header(Core PREFIX_NAME MANDOS_) + +install(TARGETS Core LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) +set_target_properties(Core PROPERTIES INSTALL_RPATH "$ORIGIN")