diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b6e42621..85e72809 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -4,7 +4,7 @@ name: pipeline # release.yml (v* tags, publish: true). Always the full chain: IPOPT compiled from # source, a compas_cra wheel for every platform and CPython with the solver built # into it, each smoke-tested, the full test suite run against one of those wheels, -# and the docs built. With publish: true it also deploys the docs. +# and the docs built. The docs are deployed from every push to main, and from a release. # # The release guard, the PyPI upload and the GitHub release are in release.yml, not # here: trusted publishing cannot match a job that lives in a reusable workflow, so it @@ -145,7 +145,7 @@ jobs: IPOPT_PREFIX='${{ github.workspace }}\build\ipopt\stage' EXTRA_LINK_DIRS='D:\msys64\ucrt64\lib' CMAKE_GENERATOR=Ninja - CMAKE_ARGS='-DCMAKE_C_COMPILER=D:/msys64/ucrt64/bin/gcc.exe -DCMAKE_CXX_COMPILER=D:/msys64/ucrt64/bin/g++.exe -DFORTRAN_COMPILER=D:/msys64/ucrt64/bin/gfortran.exe' + CMAKE_ARGS='-DCMAKE_C_COMPILER=D:/msys64/ucrt64/bin/gcc.exe -DCMAKE_CXX_COMPILER=D:/msys64/ucrt64/bin/g++.exe -DFORTRAN_COMPILER=D:/msys64/ucrt64/bin/gfortran.exe -DBUNDLE_RUNTIME_DLLS=OFF' run: | python -m pip install -q cibuildwheel python -m cibuildwheel --output-dir dist @@ -211,12 +211,19 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq libegl1 libgl1 python -m pip install -q --upgrade pip python -m pip install -q "$(ls wheelhouse/compas_cra-*-cp312-*.whl)[dev,docs]" + # `invoke docs`, not a second copy of the mkdocs command: the task in tasks.py is the + # one contributors run, and CI diverging from it is how a green local build turns red + # here (or worse, the reverse) - name: Build docs run: | - mkdocs build --strict --site-dir dist/docs + invoke docs --no-serve test -f dist/docs/index.html + # every push to main, not releases only: the site is the documentation of what is on + # main, and a docs fix that only ships with the next tag is a docs fix nobody reads. + # This costs no extra CI - build.yml already runs this whole pipeline on every push + # to main, so the wheel the docs are built against exists either way. - name: Deploy docs - if: inputs.publish + if: inputs.publish || github.ref == 'refs/heads/main' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 724fdcb1..30bf2641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Platform-logic tests for the invoke tasks (`tests/test_tasks.py`): the macOS and Linux environment construction of `invoke setup` is pure logic, pinned per-OS with mocks so a refactor cannot silently break a platform nobody is sitting at. Skipped where the dev tooling is not installed. + +* Rhino 8 ports of the three curved-interface examples (`scripts/rhino_cra_curve_3_blocks.py`, `rhino_cra_cube_curve_short.py`, `rhino_cra_cube_curve_tall.py`) with a shared drawing module (`scripts/rhino_cra_view.py`) that mirrors the desktop visualization - the same mesh arrows (cylinder shaft, conical head), colors and element grouping, on toggleable Rhino layers, drawn through the compas 2 Scene API. Open in the ScriptEditor and run; the `# r:` header installs compas_cra on first use. + +* A viewer smoke test (`tests/test_viewers.py`): the full `cra_view` scene is constructed headless against the installed `compas_viewer`, with only the blocking `show()` stubbed, so an API drift in the viewer fails in CI instead of on a user's screen. Skipped when the viz extra is absent. +* A regression test for the penalty solver on the arch, both variants of the `06_arch_penalty` example, pinning the penalty solution to `cra_solve` on the standard arch. + +* `invoke setup` builds the solver and installs the package in one command, on Windows, macOS and Linux. It installs the toolchain where that is possible without root (MSYS2 packages via pacman, Homebrew formulae including the `gfortran` symlink the `gcc` formula does not create; on Linux it prints the `apt`/`dnf` line, which needs root, and stops), stages IPOPT unless `build/ipopt/stage` already holds it, and installs editable with the right environment - on Windows that means running `build_ipopt.sh` inside the MSYS2 UCRT64 shell and pointing `CMAKE_ARGS` and `EXTRA_LINK_DIRS` at its toolchain, which was four exports to get right by hand. It is idempotent, and defaults to `JOBS=1` because MUMPS races under a parallel build. + * Local development instructions in the installation docs: the compilers and libraries `packaging/build_ipopt.sh` needs on Linux, macOS and Windows, and the Docker/cibuildwheel route for building a wheel without installing any of them. * `cra_solve`, `cra_penalty_solve` and `rbe_solve` appear in the API reference. They were assignments (`cra_solve = cra_solve_native`) rather than imports, so the documentation generator saw untyped attributes and rendered nothing for the three names that are the package's primary entry points. They are import aliases now, which changes nothing at runtime — each name is still the same function object as its `_native` counterpart. -* `invoke docs-serve` serves the documentation locally with live reload. +* `invoke docs` serves the documentation at `localhost:8000` with live reload - serving is the default because mkdocs links pages by directory, which only a web server resolves, so a site opened from disk shows folder listings on every click. `invoke docs --no-serve` builds into `dist/docs` instead, which is what CI deploys. ### Changed @@ -30,6 +39,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The documentation is built with mkdocs (mkdocs-material + mkdocstrings) instead of sphinx, following `compas_model`. Every page is markdown: the API reference is five one-line `::: compas_cra.` pages instead of six hand-maintained `.rst` files plus 80 checked-in `autosummary` stubs, and the changelog and licence pages include the repository's own files rather than duplicating them. The docs dependencies are a `docs` extra in `pyproject.toml`; `invoke docs` and the CI docs job both run `mkdocs build --strict`, so a broken link or a missing page now fails the build. * Two docstrings that documented parameters the functions do not take: `CRA_Assembly.add_to_interfaces` described a `type` argument it never had, and `Arch` documented `n` instead of `num_blocks` and omitted `extra_support`. * `packaging/build_ipopt.sh` no longer trusts the third-party source downloads. The `get.ASL` and `get.Mumps` scripts fetch their tarballs with a bare `curl -L -O`, with no integrity check and no retry, so a stalled transfer lands a short file that is then gunzipped anyway — which is how an aarch64 build failed with `gzip: MUMPS_5.8.2.tar.gz: not in gzip format` after receiving 54 kB of a 4.3 MB tarball. The script now points `CURL_HOME`/`WGETRC` at a config that abandons and retries a stalled transfer, and checks that `ThirdParty/ASL/solvers` and `ThirdParty/Mumps/MUMPS` actually exist, re-running the `get.*` script that came up empty (a second `coinbrew fetch` will not: it only runs `get.*` when the clone's revision changed). +* `invoke release` no longer deletes the staged IPOPT tree. It called `compas_invocations2.build.clean`, whose default removes `build/` - which in this repository is where `packaging/build_ipopt.sh` stages IPOPT, and what `CMakeLists.txt` defaults `IPOPT_PREFIX` to. Every release therefore threw away about fifteen minutes of build and left the next `pip install -e .` failing with `No IPOPT build at IPOPT_PREFIX=...`. It is called with `builds=False` now, so `dist/` and the egg-info still go and the stage tree stays. +* `pip install -e ".[dev]"` installs the documentation stack too, because `[dev]` now includes `[docs]`. Neither extra could run `invoke docs` on its own - `[dev]` has no mkdocs and `[docs]` has no invoke - so the command the installation instructions gave produced an environment the documented task did not run in. +* `pymdown-extensions` is declared in the `docs` extra. `mkdocs.yml` configures `pymdownx.*` directly, so it is a direct dependency; it arrived only as a transitive dependency of `mkdocs-material`. +* CI builds the documentation with `invoke docs` rather than its own copy of the `mkdocs build` command line, so the local task and the pipeline cannot drift apart. +* The development instructions start from a `uv` environment and state that `pip install -e ".[dev]"` is the only install step, list the `invoke` tasks and which of them need the compiled solver (only `test` and `release` - the documentation is generated from `src/`, not from an imported package), and note that `conda install ipopt` cannot stand in for `build_ipopt.sh` because conda-forge builds IPOPT shared against a separate `mumps-seq` while `CMakeLists.txt` links it statically. +* The documentation is deployed on every push to `main`, not only from a release tag. The deploy step was gated on `inputs.publish`, which only `release.yml` sets, so merging a documentation fix built the site and threw it away and the published site tracked the last tag rather than `main`. It costs no extra CI: `build.yml` already runs the whole pipeline on every push to `main`, so the wheel the docs are built against exists either way. +* Every URL points at `BlockResearchGroup/compas_cra`. `site_url`, `repo_url`, the `[project.urls]` block, the README badges, `CITATION.cff` and the links in the tutorial and contribution pages all named the `petrasvestartas` fork, so the published site advertised a canonical URL that is not where it is served from - GitHub Pages serves it at `blockresearchgroup.github.io/compas_cra`. +* `requirements.txt` installs the documentation and linting toolchain in one command, without building the package. An extra cannot do this: installing `compas_cra[docs]` builds `compas_cra`, which compiles the solver. The documentation never needs it. +* Two links in the README that were broken independently of the rename: the banner image pointed into `docs/_images`, which has not existed since the mkdocs migration moved it to `docs/assets/images`, and the examples link pointed at a `latest/examples.html` path on github.com rather than at the documentation site. +* An editable install is self-contained on Windows, the way a repaired wheel is. The extension links MSYS2's shared runtimes (libgfortran, libopenblas, libstdc++), and Python does not search PATH for extension DLLs, so importing `compas_cra._native` died with `DLL load failed` anywhere delvewheel had not vendored them - which is every editable install. The CMake install now copies the runtime DLL closure next to the module when `BUNDLE_RUNTIME_DLLS=ON`, which `invoke setup` passes; the package code does not know MSYS2 exists, and the CI wheels keep delvewheel's repair (the option defaults to OFF, so they never carry a second copy). +* `pytest` is part of the `dev` extra. `invoke test` was unusable from the documented install - CI happened to install pytest separately. +* After `invoke setup`, a plain `uv pip install -e .` rebuilds the extension with no special environment, and nothing is configured globally. `CMakeLists.txt` finds the MinGW toolchain itself on Windows (and refuses MSVC with an explanation - the GCC-built static archives cannot be linked by it, and CMake's Visual Studio default produced a LNK1181 ten minutes in), the venv's activation scripts carry the toolchain PATH and the Ninja generator (written once by `invoke setup`, venv-scoped), and the runtime DLL bundling defaults to on for Windows builds - the CI wheels pass `-DBUNDLE_RUNTIME_DLLS=OFF` and keep delvewheel's repair. Python edits need no reinstall at all: the editable install serves `src/` directly. +* The viewers work with compas_viewer 2.x again. `cra_view` passed plain Python lists to `viewer.scene.add`, which raised `SceneObjectNotRegisteredError: ` on the first example anyone ran with a viewer; every list is wrapped in `Collection` now, and two stale `size=`/`color=` kwargs became `pointsize=`/`pointcolor=`. +* Force and weight arrows are meshes again - cylinder shaft, conical head, widths scaling with length - restoring the compas_view2 `Arrow` shape and its `head_portion=0.2, head_width=0.07, body_width=0.02` parameters, verified against the documentation screenshots. The port had replaced them with compas_viewer's `VectorObject`, which draws a fixed pixel-width line with a four-sided pyramid head that degenerates to a sliver for axis-parallel directions - the weight arrows, drawn at the old world-unit width as a pixel width, rendered as disembodied heads. Arrows are added at opacity 0.999 on purpose: the transparency pass draws after the semi-transparent block faces, so an arrow inside a block stays bold as it did in compas_view2. +* The uneven interface resultants on the curved examples are the solver's true optimum, not an artifact: the fully converged solution (`tol=1e-8`, status optimal, 356 iterations) matches the fast acceptable-level solution to 1.7e-6 across all 72 sub-interface resultants, and their sum equals the supported weight. The visual difference against the old screenshots was the absolute-width arrow heads exaggerating small forces. +* The CRA solver's acceptable-level tolerances relaxed from 1e-8 to 1e-6. The wedge and curved-blocks examples converge to ~1e-8 violation and then cannot reach the hard tolerances - at 1e-8 acceptance they died a hair above the line (`Restoration_Failed` at 4.6e-8, `Maximum_Iterations_Exceeded` hovering at 1.6e-8). The hard tolerances are unchanged; relaxing acceptance only adds a stopping opportunity. +* The penalty solver uses the adaptive barrier update too - same degenerate complementarity constraints as the CRA solver, same crawl under the monotone update. +* The `06_arch_penalty` example passes `d_bnd=1e-2`: with the default 1e-3 the solve exhausts even a 9000-iteration cap under both barrier strategies, at 1e-2 it converges in ~65 iterations, and on the standard arch the penalty resultants then agree with `cra_solve` to 1e-3 - same physics, feasible bound. All 18 example scripts now run to completion, verified headless. +* Resultant force arrows are red only for interfaces in net tension (`sum_n < 0`) - the rule the published screenshots were made with (commit `15e5edc`, 2022-08-17). The per-vertex `<= -1e-5` check added later turned whole interfaces red over complementarity-relaxation noise (vertex normals reach -2e-3 on shelf interfaces that are in overall compression), which is why the shelf example showed red arrows where its screenshot shows green. Genuine tension still shows: net-tension interfaces render red, and per-vertex tension renders as red nodal arrows, unchanged. +* Block and support edges draw at the same width (1.5), block edges in dark gray, support edges in the support salmon - matching the reference screenshots instead of the 4-wide support outlines. +* `09_bridge` runs `cra_solve` again - the example had been switched to `cra_penalty_solve`, producing a visibly different solution from its screenshot. Verified against the 2022 code in an era-exact environment: identical resultants to five decimals, no tension anywhere. The view flags also now match the screenshot (`forcesdirect=True`): the image was made with flags that were never committed, and the image is the contract. +* The solver restores the published force distributions. The CRA system is square, and until IPOPT 3.14.11 square problems were special-cased: the convergence check ignored dual feasibility and bound complementarity, so IPOPT stopped at the first primal-feasible point of the barrier path - an interior point where every contact face carries force. Every figure in the paper (Kao et al. 2022, doi:10.1016/j.cad.2022.103216) and the docs is such a point. IPOPT 3.14.12 removed the special case, and the same problems then converge to a degenerate vertex - on the curved-interface examples the whole load lands on 8 of 72 sub-faces and the rest go to zero, which is what this package has shipped since the bundled IPOPT became newer than that. Root-caused by building IPOPT 3.14.9, 3.14.11 and 3.14.14 from source against the same MUMPS 5.9: 3.14.11 still spreads (identically to 3.14.9), 3.14.14 concentrates - the flip is exactly at 3.14.12, the release whose changelog documents the square-problem change; not MUMPS, and not this repository - every code generation from the screenshot-day commit to today produces both solutions depending only on the binary. The fix sets `mu_target=1e-5` (with tolerances to match and a raised iteration cap): the barrier stops at the interior point deliberately instead of by accident of an old binary. Calibrated against the published figures themselves rather than a rebuilt era binary - the old square-problem shortcut stopped wherever its check happened to trip, so a from-source 3.14.11 stops at a lower barrier level (force staircase of 4.5x) than the author's 2022 conda/macOS build, whose figures show near-uniform arrows. 1e-5 reproduces the figures: cube-curve-tall max/median 1.11, cube-curve-short 1.20, all 72 faces loaded. Equilibrium stays exact at 1e-8, and the arch reaches the same resultants (1.9571/0.8437) in 270 iterations. Pinning the old IPOPT was evaluated and rejected: correctly built it links and runs fine, but its stopping level is platform luck - the very thing that made the published results irreproducible in the first place. +* `13_curve-3-blocks` runs its original published parameters again (density=0.1, scale=5). They only solve in the barrier-interior regime; under strict optimization they exhaust the iteration cap on every IPOPT generation, which is why the author had switched to density=1 as a workaround days after the screenshot. +* `invoke setup` carries `MACOSX_DEPLOYMENT_TARGET=11.0` through the IPOPT build and the editable install on macOS - the same value CI pins, for Apple Silicon and Intel alike - so the static libraries and the extension agree on a target instead of failing to link against the Python build's older default. It also checks for the Xcode Command Line Tools up front (build_ipopt.sh needs xcrun for the Accelerate stub) rather than failing fifteen minutes into the build. +* The venv activation patch re-prepends the venv's own Scripts directory after the MSYS2 toolchain path: ucrt64in carries a python.exe of its own, and it was shadowing the venv's - a bare `python` in an activated shell ran the wrong interpreter. ### Removed diff --git a/CITATION.cff b/CITATION.cff index 4a57efe1..bafc1ddc 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,8 +4,8 @@ authors: - family-names: "Kao" given-names: "Gene Ting-Chun" orcid: https://orcid.org/0000-0002-4275-1246 -title: "petrasvestartas/compas_cra: Release v0.2.0" +title: "BlockResearchGroup/compas_cra: Release v0.2.0" version: v0.2.0 doi: 10.5281/zenodo.7043136 date-released: 2022-09-02 -url: "https://github.com/petrasvestartas/compas_cra" +url: "https://github.com/BlockResearchGroup/compas_cra" diff --git a/CMakeLists.txt b/CMakeLists.txt index 9badc560..1d0e52a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,30 @@ # ipopt executable, no DLLs, no separate solver distribution. cmake_minimum_required(VERSION 3.18...3.30) + +# Windows: this extension can only be built with MinGW gcc. It links libipopt.a and +# libcoinmumps.a, which are GCC-built C++/Fortran static archives - MSVC cannot link +# them (different C++ ABI), so letting CMake default to Visual Studio produces a +# LNK1181 ten minutes in. Find the MSYS2 toolchain and say so up front instead. An +# explicitly configured compiler (CI passes one) always wins. +if(WIN32 AND NOT DEFINED CMAKE_CXX_COMPILER AND NOT DEFINED ENV{CXX}) + set(_msys2_candidates "$ENV{MSYS2_ROOT}" "C:/msys64" "D:/msys64") + foreach(_root IN LISTS _msys2_candidates) + if(_root AND EXISTS "${_root}/ucrt64/bin/g++.exe") + set(CMAKE_C_COMPILER "${_root}/ucrt64/bin/gcc.exe" CACHE FILEPATH "") + set(CMAKE_CXX_COMPILER "${_root}/ucrt64/bin/g++.exe" CACHE FILEPATH "") + set(FORTRAN_COMPILER "${_root}/ucrt64/bin/gfortran.exe") + message(STATUS "MinGW toolchain: ${_root}/ucrt64") + break() + endif() + endforeach() + if(NOT DEFINED CMAKE_CXX_COMPILER) + message(FATAL_ERROR "No MinGW toolchain found (MSYS2 UCRT64 with gcc). " + "This extension links GCC-built static archives and cannot be built with MSVC. " + "Run `invoke setup`, or install MSYS2 and set MSYS2_ROOT.") + endif() +endif() + project(compas_cra LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) @@ -21,7 +45,7 @@ if(NOT DEFINED IPOPT_PREFIX) endif() endif() if(NOT EXISTS "${IPOPT_PREFIX}/include/coin-or/IpTNLP.hpp") - message(FATAL_ERROR "No IPOPT build at IPOPT_PREFIX=${IPOPT_PREFIX}; run packaging/build_ipopt.sh first") + message(FATAL_ERROR "No IPOPT build at IPOPT_PREFIX=${IPOPT_PREFIX}; run `invoke setup` (or packaging/build_ipopt.sh) first") endif() message(STATUS "IPOPT_PREFIX: ${IPOPT_PREFIX}") @@ -107,3 +131,34 @@ if(APPLE) endif() install(TARGETS _core LIBRARY DESTINATION compas_cra/_native) + +# Local (editable) installs on Windows: put the MinGW runtime DLLs next to the module, +# so the environment is self-contained - the same thing delvewheel does to the wheels +# in CI, done at install time. OFF by default: the CI wheels get delvewheel's repair +# and must not carry a second, unmangled copy. `invoke setup` turns it on. +# ON by default on Windows so that a plain `pip install -e .` produces a working +# environment; the CI wheel builds pass OFF explicitly because delvewheel repairs them +if(WIN32) + option(BUNDLE_RUNTIME_DLLS "Install the MinGW runtime DLLs next to the extension" ON) +else() + option(BUNDLE_RUNTIME_DLLS "Install the MinGW runtime DLLs next to the extension" OFF) +endif() +if(WIN32 AND BUNDLE_RUNTIME_DLLS) + get_filename_component(_toolchain_bin "${FORTRAN_COMPILER}" DIRECTORY) + # the dependency closure of _core outside the system DLLs, computed with objdump; + # openblas pulls in gomp, gfortran pulls in quadmath + set(_runtime_dlls + libgcc_s_seh-1.dll + libgfortran-5.dll + libgomp-1.dll + libopenblas.dll + libquadmath-0.dll + libstdc++-6.dll + libwinpthread-1.dll) + foreach(_dll IN LISTS _runtime_dlls) + if(NOT EXISTS "${_toolchain_bin}/${_dll}") + message(FATAL_ERROR "BUNDLE_RUNTIME_DLLS: ${_dll} not found in ${_toolchain_bin}") + endif() + install(FILES "${_toolchain_bin}/${_dll}" DESTINATION compas_cra/_native) + endforeach() +endif() diff --git a/README.md b/README.md index 7aa8f65a..19626ebe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # COMPAS CRA -![build](https://github.com/petrasvestartas/compas_cra/workflows/build/badge.svg) -[![GitHub - License](https://img.shields.io/github/license/petrasvestartas/compas_cra.svg)](./LICENSE) +![build](https://github.com/BlockResearchGroup/compas_cra/workflows/build/badge.svg) +[![GitHub - License](https://img.shields.io/github/license/BlockResearchGroup/compas_cra.svg)](./LICENSE) [![pip downloads](https://img.shields.io/pypi/dm/compas_cra)](https://pypi.python.org/project/compas_cra) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/compas_cra.svg)](https://pypi.python.org/project/compas_cra) [![PyPI - Latest Release](https://img.shields.io/pypi/v/compas_cra.svg)](https://pypi.python.org/project/compas_cra) @@ -32,20 +32,23 @@ The solver is compiled into the package, so an install from source builds it, an needs IPOPT staged first: ```bash -git clone https://github.com/petrasvestartas/compas_cra.git +git clone https://github.com/BlockResearchGroup/compas_cra.git cd compas_cra -packaging/build_ipopt.sh # ~15 minutes, once -pip install -e ".[dev]" + +uv venv --python 3.12 +source .venv/Scripts/activate # .venv/bin/activate on macOS and Linux + +uv pip install invoke compas_invocations2 +invoke setup # toolchain + IPOPT (~15 minutes, once) + editable install invoke test ``` -`build_ipopt.sh` needs a Fortran compiler and a static BLAS/LAPACK — on Debian/Ubuntu -`sudo apt install build-essential gfortran libopenblas-dev git curl make patch pkg-config`, -on macOS `brew install gcc bash`, on Windows an [MSYS2](https://www.msys2.org) UCRT64 -shell. Without a local toolchain, `CIBW_BUILD="cp312-*" cibuildwheel` builds the wheel -inside the manylinux container instead — the build is configured in `pyproject.toml`, -so that one command reproduces CI. Full instructions, per platform, are in -[the installation docs](./docs/installation.rst). +`invoke setup` handles the platform differences — MSYS2 packages on Windows, Homebrew +on macOS, and on Linux it prints the `apt`/`dnf` line to run first. Without a local +toolchain, `CIBW_BUILD="cp312-*" cibuildwheel` builds the wheel inside the manylinux +container instead — the build is configured in `pyproject.toml`, so that one command +reproduces CI. Full instructions, per platform, are in +[the installation docs](./docs/installation.md). ### Rhino 8 @@ -91,7 +94,7 @@ To find out more about CRA, please refer to our paper in the CAD Computer-Aided ## Coupled Rigid-Block Analysis: Stability-Aware Design of Complex Discrete-Element Assemblies -![image](https://github.com/petrasvestartas/compas_cra/blob/main/docs/_images/cra_bridge.png?raw=true) +![image](https://github.com/BlockResearchGroup/compas_cra/blob/main/docs/assets/images/cra_bridge.png?raw=true) ### Abstract @@ -147,8 +150,8 @@ by designing complex and scaffolding-free physical models. ### Read the docs -[https://github.com/petrasvestartas/compas_cra](https://github.com/petrasvestartas/compas_cra) +[https://github.com/BlockResearchGroup/compas_cra](https://github.com/BlockResearchGroup/compas_cra) ### Examples to reproduce our paper results -See examples in [docs](https://github.com/petrasvestartas/compas_cra/latest/examples.html) or try them in [docs/examples](https://github.com/petrasvestartas/compas_cra/blob/main/docs/examples). +See examples in [docs](https://blockresearchgroup.github.io/compas_cra/examples/) or try them in [docs/examples](https://github.com/BlockResearchGroup/compas_cra/blob/main/docs/examples). diff --git a/docs/contributions.md b/docs/contributions.md index b91af851..4b4e6c2b 100644 --- a/docs/contributions.md +++ b/docs/contributions.md @@ -7,39 +7,38 @@ Contributions are welcome and very much appreciated! We accept code contributions through pull requests. In short, this is how that works. -1. Fork [the repository](https://github.com/petrasvestartas/compas_cra) and clone the fork. -2. Create a virtual environment using your tool of choice (e.g. `virtualenv`, `conda`, etc). -3. Build the solver once — it is compiled into the package, so an install from source - needs a staged IPOPT tree. See [Installation](installation.md) for the compilers and - libraries this needs on your platform, and for the Docker route if you would rather - not install them: +1. Fork [the repository](https://github.com/BlockResearchGroup/compas_cra) and clone the fork. +2. Create an environment with [uv](https://docs.astral.sh/uv) and build the solver — + it is compiled into the package, so an install from source needs a staged IPOPT + tree, and `invoke setup` takes care of the whole sequence + (see [Installation](installation.md) for the details, and for the Docker route): ```bash - packaging/build_ipopt.sh + uv venv --python 3.12 + source .venv/Scripts/activate # .venv/bin/activate on macOS and Linux + uv pip install invoke compas_invocations2 + invoke setup ``` -4. Install the package and its development dependencies: + Only working on the documentation or on Python code? `uv pip install -r requirements.txt` + is enough for `invoke docs` and `invoke lint` — no solver build needed. - ```bash - pip install -e ".[dev]" - ``` -5. Make sure all tests pass: +3. Make sure all tests pass: ```bash invoke test ``` -6. Start making your changes to the **main** branch (or branch off of it). -7. Make sure all tests still pass: +4. Start making your changes to the **main** branch (or branch off of it). +5. Make sure all tests still pass: ```bash invoke test ``` -8. Add yourself to the **Contributors** section in `AUTHORS.md`. -9. Commit your changes and push your branch to GitHub. -10. Create a [pull request](https://help.github.com/articles/about-pull-requests/) through the GitHub website. +6. Commit your changes and push your branch to GitHub. +7. Create a [pull request](https://help.github.com/articles/about-pull-requests/) through the GitHub website. During development, use [pyinvoke](http://docs.pyinvoke.org/) tasks on the command line to ease recurring operations: @@ -47,13 +46,13 @@ command line to ease recurring operations: * `invoke clean`: Clean all generated artifacts. * `invoke check`: Run various code and documentation style checks. * `invoke docs`: Build the documentation. -* `invoke docs-serve`: Serve the documentation locally with live reload. +* `invoke docs`: Serve the documentation at `localhost:8000` with live reload. * `invoke test`: Run all tests and checks in one swift command. * `invoke`: Show available tasks. ## Bug reports -When [reporting a bug](https://github.com/petrasvestartas/compas_cra/issues) +When [reporting a bug](https://github.com/BlockResearchGroup/compas_cra/issues) please include: * Operating system name and version. @@ -63,7 +62,7 @@ please include: ## Feature requests and feedback The best way to send feedback is to file an issue on -[GitHub](https://github.com/petrasvestartas/compas_cra/issues). +[GitHub](https://github.com/BlockResearchGroup/compas_cra/issues). If you are proposing a feature: * Explain in detail how it would work. diff --git a/docs/examples/06_arch_penalty.py b/docs/examples/06_arch_penalty.py index dcd1679d..bf1aad4f 100644 --- a/docs/examples/06_arch_penalty.py +++ b/docs/examples/06_arch_penalty.py @@ -22,7 +22,10 @@ assembly_interfaces_numpy(assembly, nmax=10, amin=1e-2, tmax=1e-2) -cra_penalty_solve(assembly, mu=0.7, verbose=True, timer=True) +# d_bnd=1e-2: with the default 1e-3 displacement bound this solve exhausts the +# iteration cap; at 1e-2 it converges in ~65 iterations, on resultants that agree +# with cra_solve to 1e-3 on the standard arch +cra_penalty_solve(assembly, mu=0.7, d_bnd=1e-2, verbose=True, timer=True) cra_view( assembly, resultant=True, diff --git a/docs/examples/09_bridge.py b/docs/examples/09_bridge.py index edd3eb97..fd6ddce5 100644 --- a/docs/examples/09_bridge.py +++ b/docs/examples/09_bridge.py @@ -31,16 +31,21 @@ assembly_interfaces_numpy(assembly, amin=1e-6, tmax=1e-4) -# cra_solve(assembly, verbose=True, density=d, d_bnd=dispbnd, eps=overlap, mu=mu) -cra_penalty_solve(assembly, verbose=True, density=d, d_bnd=dispbnd, eps=overlap, mu=mu) +# the published screenshot was made with cra_solve; the penalty variant stays one +# uncomment away, as it was in 2022 +cra_solve(assembly, verbose=True, density=d, d_bnd=dispbnd, eps=overlap, mu=mu) +# cra_penalty_solve(assembly, verbose=True, density=d, d_bnd=dispbnd, eps=overlap, mu=mu) cra_view( assembly, resultant=True, nodal=False, grid=True, weights=True, - forcesdirect=False, - forcesline=True, + # the published screenshot shows cone-headed resultant arrows (forcesdirect), even + # though the 2022 file said forcesline - the image was made with uncommitted flags; + # the image is the contract + forcesdirect=True, + forcesline=False, displacements=True, dispscale=1, scale=0.5 / d, diff --git a/docs/examples/13_curve-3-blocks.py b/docs/examples/13_curve-3-blocks.py index 8c7348ea..36d4489e 100644 --- a/docs/examples/13_curve-3-blocks.py +++ b/docs/examples/13_curve-3-blocks.py @@ -9,7 +9,11 @@ from compas_cra.equilibrium import cra_solve from compas_cra.viewers import cra_view -density = 1 +# The published screenshot's original parameters. They only solve in the barrier- +# interior regime the solver now runs in (mu_target - see cra_native.py); under strict +# optimization they exhaust the iteration cap on every IPOPT generation, which is why +# the author had switched to density=1 as a workaround days after the screenshot. +density = 0.1 FILE_I = os.path.join(compas_cra.SAMPLE, "curve-3-blocks.json") @@ -25,6 +29,6 @@ grid=True, displacements=True, dispscale=0, - scale=1, + scale=5, density=density, ) diff --git a/docs/examples/index.md b/docs/examples/index.md index 1c0a48fb..8508c0d5 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,4 +1,4 @@ # Examples All example files can be found in -[docs/examples](https://github.com/petrasvestartas/compas_cra/blob/main/docs/examples). +[docs/examples](https://github.com/BlockResearchGroup/compas_cra/blob/main/docs/examples). diff --git a/docs/installation.md b/docs/installation.md index 8063569b..0dfcb355 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,32 +1,21 @@ # Installation -## Stable +## Install ```bash pip install compas_cra ``` -That is all that is needed, on Windows, macOS (Apple Silicon and Intel) and Linux. -The [IPOPT](https://coin-or.github.io/Ipopt/) solver is compiled into the package -itself as an extension module, so solving happens in-process: no conda environment, no -homebrew and no solver executables are involved. The wheels cover CPython 3.9 to 3.13. - -To also install the viewers: - -```bash -pip install compas_cra[viz] -``` - -Verify the solver is available with: +Windows, macOS and Linux, CPython 3.9-3.13. The [IPOPT](https://coin-or.github.io/Ipopt/) +solver is compiled into the package. ```bash -python -c "from compas_cra import _native; print(_native.IPOPT_VERSION)" +pip install compas_cra[viz] # with viewers +python -c "from compas_cra import _native; print(_native.IPOPT_VERSION)" # verify ``` -## Rhino 8 - -Start a Python 3 script in the ScriptEditor with this header and run it — Rhino -installs everything on the first run: +In Rhino 8, run a script with this header — Rhino installs everything on the first run +(examples in `scripts/`): ```python #! python3 @@ -34,89 +23,99 @@ installs everything on the first run: # r: compas_cra ``` -Ready-to-run examples are in the repository under `scripts/`. - ## Development -Working on `compas_cra` itself means building the solver, because the solver is part -of the package: `pip install .` and `pip install -e .` both compile the -`compas_cra._native` extension, and that links against a static IPOPT tree produced -by `packaging/build_ipopt.sh`. Without such a tree the install stops with -`No IPOPT build at IPOPT_PREFIX=...`. +### Windows -### Prerequisites - -The IPOPT build needs a Fortran compiler (its MUMPS linear solver is Fortran), a static -BLAS/LAPACK, a C/C++ toolchain, and `git`, `curl`, `make`, `patch` and -`pkg-config` for coinbrew. CMake and Ninja are *not* needed up front — the build -backend brings its own. +```bash +git clone https://github.com/BlockResearchGroup/compas_cra.git +cd compas_cra -Debian / Ubuntu: +uv venv --python 3.12 +source .venv/Scripts/activate -```bash -sudo apt install build-essential gfortran libopenblas-dev git curl make patch pkg-config +uv pip install invoke compas_invocations2 +invoke setup +invoke test ``` -Fedora / RHEL / AlmaLinux: +### macOS + +Nothing has to be installed first. If Homebrew is not on the machine, `invoke setup` +installs it, along with `bash` and `gcc` — macOS asks for your password once, because +the Homebrew installer creates its prefix as root. The account has to be an +administrator, which is the default for the first account on a Mac. + +Run it as yourself. **Not** `sudo invoke setup`: Homebrew refuses to install as root, +and the venv, `build/` and the staged IPOPT tree would all come out root-owned. The task +asks for the password at the single point it needs one. + +Set `COMPAS_CRA_NO_BREW_INSTALL=1` to refuse the bootstrap and be told to install +Homebrew yourself instead. + +A checkout path containing a space (`Desktop/untitled folder/…`) is fine: IPOPT is +autotools, which cannot build under such a path, so `invoke setup` builds and stages it +in `~/.compas_cra/ipopt` instead and points the extension build there. ```bash -sudo dnf install gcc gcc-c++ gcc-gfortran openblas-devel openblas-static \ - glibc-static libstdc++-static git curl make patch pkgconf +git clone https://github.com/BlockResearchGroup/compas_cra.git +cd compas_cra + +uv venv --python 3.12 +source .venv/bin/activate + +uv pip install invoke compas_invocations2 +invoke setup +invoke test ``` -macOS: +### Linux ```bash -brew install gcc bash -``` +sudo apt install build-essential gfortran libopenblas-dev git curl make patch pkg-config -`gfortran` comes with the `gcc` formula, and BLAS/LAPACK is the system Accelerate -framework, so nothing else is needed. The `bash` formula is: coinbrew refuses to run -under bash 3, which is still what macOS ships. +git clone https://github.com/BlockResearchGroup/compas_cra.git +cd compas_cra -Windows: the IPOPT build runs in an [MSYS2](https://www.msys2.org) UCRT64 shell. +uv venv --python 3.12 +source .venv/bin/activate -```bash -pacman -S git patch make diffutils pkgconf \ - mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gcc-fortran \ - mingw-w64-ucrt-x86_64-openblas +uv pip install invoke compas_invocations2 +invoke setup +invoke test ``` -Run `packaging/build_ipopt.sh` from that shell, then `pip` from your normal Python -with `IPOPT_PREFIX` pointed at the resulting `build/ipopt/stage`. +### After the first build -### Building +```bash +uv pip install -e . # rebuild after C++ changes; Python changes need no reinstall +``` + +### Documentation only ```bash -git clone https://github.com/petrasvestartas/compas_cra.git -cd compas_cra -packaging/build_ipopt.sh -pip install -e ".[dev]" -invoke test +uv venv --python 3.12 +source .venv/Scripts/activate +uv pip install -r requirements.txt +invoke docs ``` -`build_ipopt.sh` takes about fifteen minutes and is a one-off: it stages IPOPT into -`build/ipopt/stage`, which every later install reuses. Rebuild it only when the script -or the IPOPT version changes. Set `IPOPT_PREFIX` to use a stage tree from elsewhere, -`IPOPT_EXTRA_LINK` and `EXTRA_LINK_DIRS` for extra link flags and directories. +### Tasks -### Without a local toolchain +| Command | What it does | +| --- | --- | +| `invoke setup` | Build the solver and install the package, on any platform | +| `invoke docs` | Serve the site at `localhost:8000` with live reload; `--no-serve` builds `dist/docs` | +| `invoke lint` | `ruff check --fix src tests` | +| `invoke format` | `ruff format src tests` | +| `invoke test` | Run the test suite | +| `invoke release ` | Bump, tag and push, which triggers the release | -On Linux with Docker, `cibuildwheel` builds exactly what CI builds — IPOPT and all — -inside the manylinux container, so nothing has to be installed on the host: +### Without a local toolchain ```bash pip install cibuildwheel -CIBW_BUILD="cp312-*" cibuildwheel --output-dir dist +CIBW_BUILD="cp312-*" cibuildwheel --output-dir dist # Linux/Docker; exactly what CI runs ``` -Everything else — the manylinux image, the `dnf` line that installs gfortran and -OpenBLAS in the container, the IPOPT build itself and the smoke test — comes from -`[tool.cibuildwheel]` in `pyproject.toml`, so this is exactly what CI runs. -`CIBW_BUILD` is only there to limit the run to one CPython; drop it to build all five. - -That takes about five minutes and leaves an installable wheel in `dist/`. It is also -the quickest way to get a working solver into a local environment when you only want to -change Python code. - -See `native/README.md` and `packaging/README.md` for what is being built and why. +For building IPOPT by hand, see `packaging/README.md`. diff --git a/docs/tutorial.md b/docs/tutorial.md index 918e7997..db2735ab 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -77,7 +77,7 @@ cra_view(assembly, resultant=False, nodal=True, grid=True) ![Visualised result](assets/images/tutorial_cubes_4.png) The complete tutorial script can be downloaded from -[scripts/tutorial_cubes.py](https://github.com/petrasvestartas/compas_cra/blob/main/scripts/tutorial_cubes.py). +[scripts/tutorial_cubes.py](https://github.com/BlockResearchGroup/compas_cra/blob/main/scripts/tutorial_cubes.py). To reproduce our [paper](https://doi.org/10.1016/j.cad.2022.103216)'s examples or to see more how to construct assembly and solve equilibrium, @@ -90,7 +90,7 @@ from CAD software. Here we use Rhino as an example. ### Export mesh blocks as Assembly json file -Use this script at [scripts/mesh_to_assembly_json.py](https://github.com/petrasvestartas/compas_cra/blob/main/scripts/mesh_to_assembly_json.py) +Use this script at [scripts/mesh_to_assembly_json.py](https://github.com/BlockResearchGroup/compas_cra/blob/main/scripts/mesh_to_assembly_json.py) to select Rhino mesh blocks and export to assembly data structure as a `.json` file. ```python @@ -125,7 +125,7 @@ Currently, we do not implement automatic interface detection algorithm for block curve/free-form interfaces, so they have to be discretised manually as planar faces or triangles. -Use this script at [scripts/mesh_to_assembly_interfaces_json.py](https://github.com/petrasvestartas/compas_cra/blob/main/scripts/mesh_to_assembly_interfaces_json.py) +Use this script at [scripts/mesh_to_assembly_interfaces_json.py](https://github.com/BlockResearchGroup/compas_cra/blob/main/scripts/mesh_to_assembly_interfaces_json.py) to select mesh blocks with interfaces and export to assembly data structure and store it as json file. @@ -151,7 +151,7 @@ Figure 1 /// More Rhino files and precomputed `.json` files are located in the -[data](https://github.com/petrasvestartas/compas_cra/blob/main/data) folder. +[data](https://github.com/BlockResearchGroup/compas_cra/blob/main/data) folder. !!! note diff --git a/mkdocs.yml b/mkdocs.yml index d40d33eb..0a5e0595 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,14 +1,14 @@ site_name: COMPAS CRA site_description: Coupled Rigid-Block Analysis (CRA) for the COMPAS framework -site_url: https://petrasvestartas.github.io/compas_cra/ +site_url: https://blockresearchgroup.github.io/compas_cra/ -repo_name: petrasvestartas/compas_cra -repo_url: https://github.com/petrasvestartas/compas_cra/ +repo_name: BlockResearchGroup/compas_cra +repo_url: https://github.com/BlockResearchGroup/compas_cra/ copyright: Copyright © 2020 - 2026, ETH Zurich - Block Research Group extra: - homepage: https://petrasvestartas.github.io/compas_cra/ + homepage: https://blockresearchgroup.github.io/compas_cra/ extra_css: - css/extra.css diff --git a/packaging/build_ipopt.sh b/packaging/build_ipopt.sh index 71b84fc9..50983e1e 100755 --- a/packaging/build_ipopt.sh +++ b/packaging/build_ipopt.sh @@ -16,7 +16,7 @@ # platform repair tools (auditwheel / delocate / delvewheel). # # Usage: packaging/build_ipopt.sh -# Env: IPOPT_VERSION, WORK_DIR, JOBS +# Env: IPOPT_VERSION, WORK_DIR, JOBS, VERBOSITY (coinbrew 1-4, default 1) # set -euo pipefail @@ -278,6 +278,9 @@ fi COMMON_CONFIG=( --no-prompt --skip-update + # 1 = section headers only (the CI default); invoke setup passes 2 so an + # interactive build streams the compile lines instead of sitting silent + --verbosity="${VERBOSITY:-1}" --prefix="$STAGE_DIR" --parallel-jobs="$JOBS" --tests=none diff --git a/pyproject.toml b/pyproject.toml index 2a77df33..72473177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,27 +49,37 @@ dev = [ "black>=22.12.0", "build", "bump-my-version", + # the documentation stack too, so `pip install -e ".[dev]"` is the only install step a + # contributor needs: `invoke docs` is unusable without it, and `docs` alone has no invoke + "compas_cra[docs]", "compas_invocations2", "compas_viewer", "invoke>=0.14", + # invoke test is unusable without it; CI happened to install it separately + "pytest", "ruff", "twine", "wheel", ] # the docs are mkdocs-material + mkdocstrings, same stack as compas_model +# mirrored by requirements.txt, which installs this stack without building the +# package - the documentation never needs the compiled solver docs = [ "markdown-callouts>=0.4", - "mkdocs>=1.6", + "mkdocs>=1.6,<2", # Material is incompatible with the MkDocs 2.0 rewrite, per its own banner "mkdocs-autorefs>=1.4", "mkdocs-material>=9.5", "mkdocstrings[python]", + # mkdocs.yml configures pymdownx.* directly, so it is a direct dependency; it happens to + # arrive with mkdocs-material today, which is not something to rely on + "pymdown-extensions>=10", ] [project.urls] -Homepage = "https://petrasvestartas.github.io/compas_cra" -Documentation = "https://petrasvestartas.github.io/compas_cra" -Repository = "https://github.com/petrasvestartas/compas_cra.git" -Changelog = "https://github.com/petrasvestartas/compas_cra/blob/main/CHANGELOG.md" +Homepage = "https://blockresearchgroup.github.io/compas_cra" +Documentation = "https://blockresearchgroup.github.io/compas_cra" +Repository = "https://github.com/BlockResearchGroup/compas_cra.git" +Changelog = "https://github.com/BlockResearchGroup/compas_cra/blob/main/CHANGELOG.md" # ============================================================================ # scikit-build-core config diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..be3040a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# The one requirements file: the documentation and linting toolchain, installable +# without building the package. Runtime dependencies live in pyproject.toml and +# come with `pip install -e .`. +# +# `pip install -e ".[dev]"` is the normal route and covers everything in here, but it +# compiles the IPOPT extension, which needs a staged IPOPT tree and about fifteen +# minutes (see docs/installation.md). The documentation does not need the compiled +# solver at all - mkdocstrings reads the sources in src/ rather than an imported +# package - so this file exists for the docs-and-lint path, where that build would be +# pure overhead. +# +# Mirrors the `docs` extra in pyproject.toml, plus the tools tasks.py drives. + +compas +invoke >=0.14 +compas_invocations2 +ruff + +markdown-callouts >=0.4 +mkdocs >=1.6,<2 +mkdocs-autorefs >=1.4 +mkdocs-material >=9.5 +mkdocstrings[python] +pymdown-extensions >=10 diff --git a/scripts/rhino_cra_cube_curve_short.py b/scripts/rhino_cra_cube_curve_short.py new file mode 100644 index 00000000..17381c6e --- /dev/null +++ b/scripts/rhino_cra_cube_curve_short.py @@ -0,0 +1,44 @@ +#! python3 +# venv: compas-cra +# r: compas_cra>=0.7.2 +"""Short cube stack with curved interfaces: CRA equilibrium, drawn into the Rhino document. + +Rhino port of docs/examples/14_cube-curve-short.py. Keep rhino_cra_view.py next to this +script (and save the script) so the shared drawing module can be imported. +""" + +import os +import sys + +import compas + +import compas_cra +from compas_cra.datastructures import CRA_Assembly +from compas_cra.equilibrium import cra_solve + +try: + HERE = os.path.dirname(os.path.abspath(__file__)) +except NameError: # unsaved ScriptEditor document: assume the cwd holds the helper + HERE = os.getcwd() +if HERE not in sys.path: + sys.path.insert(0, HERE) + +from rhino_cra_view import cra_view_rhino # noqa: E402 + +density = 0.1 + +FILE_I = os.path.join(compas_cra.SAMPLE, "cube-curve-short.json") + +assembly = compas.json_load(FILE_I) +assembly = assembly.copy(cls=CRA_Assembly) +assembly.set_boundary_conditions([0]) + +cra_solve(assembly, verbose=True, timer=True, density=density) +cra_view_rhino( + assembly, + resultant=True, + nodal=False, + scale=50, + density=density, + layer_prefix="CRA-CubeCurveShort", +) diff --git a/scripts/rhino_cra_cube_curve_tall.py b/scripts/rhino_cra_cube_curve_tall.py new file mode 100644 index 00000000..ea7b8788 --- /dev/null +++ b/scripts/rhino_cra_cube_curve_tall.py @@ -0,0 +1,44 @@ +#! python3 +# venv: compas-cra +# r: compas_cra>=0.7.2 +"""Tall cube stack with curved interfaces: CRA equilibrium, drawn into the Rhino document. + +Rhino port of docs/examples/15_cube-curve-tall.py. Keep rhino_cra_view.py next to this +script (and save the script) so the shared drawing module can be imported. +""" + +import os +import sys + +import compas + +import compas_cra +from compas_cra.datastructures import CRA_Assembly +from compas_cra.equilibrium import cra_solve + +try: + HERE = os.path.dirname(os.path.abspath(__file__)) +except NameError: # unsaved ScriptEditor document: assume the cwd holds the helper + HERE = os.getcwd() +if HERE not in sys.path: + sys.path.insert(0, HERE) + +from rhino_cra_view import cra_view_rhino # noqa: E402 + +density = 0.1 + +FILE_I = os.path.join(compas_cra.SAMPLE, "cube-curve-tall.json") + +assembly = compas.json_load(FILE_I) +assembly = assembly.copy(cls=CRA_Assembly) +assembly.set_boundary_conditions([0]) + +cra_solve(assembly, verbose=True, timer=True, density=density) +cra_view_rhino( + assembly, + resultant=True, + nodal=False, + scale=40, + density=density, + layer_prefix="CRA-CubeCurveTall", +) diff --git a/scripts/rhino_cra_curve_3_blocks.py b/scripts/rhino_cra_curve_3_blocks.py new file mode 100644 index 00000000..f0cc8546 --- /dev/null +++ b/scripts/rhino_cra_curve_3_blocks.py @@ -0,0 +1,44 @@ +#! python3 +# venv: compas-cra +# r: compas_cra>=0.7.2 +"""Three blocks with curved interfaces: CRA equilibrium, drawn into the Rhino document. + +Rhino port of docs/examples/13_curve-3-blocks.py. Keep rhino_cra_view.py next to this +script (and save the script) so the shared drawing module can be imported. +""" + +import os +import sys + +import compas + +import compas_cra +from compas_cra.datastructures import CRA_Assembly +from compas_cra.equilibrium import cra_solve + +try: + HERE = os.path.dirname(os.path.abspath(__file__)) +except NameError: # unsaved ScriptEditor document: assume the cwd holds the helper + HERE = os.getcwd() +if HERE not in sys.path: + sys.path.insert(0, HERE) + +from rhino_cra_view import cra_view_rhino # noqa: E402 + +density = 1 + +FILE_I = os.path.join(compas_cra.SAMPLE, "curve-3-blocks.json") + +assembly = compas.json_load(FILE_I) +assembly = assembly.copy(cls=CRA_Assembly) +assembly.set_boundary_conditions([0]) + +cra_solve(assembly, verbose=True, timer=True, density=density) +cra_view_rhino( + assembly, + resultant=False, + nodal=True, + scale=1, + density=density, + layer_prefix="CRA-Curve3", +) diff --git a/scripts/rhino_cra_view.py b/scripts/rhino_cra_view.py new file mode 100644 index 00000000..76ea91c0 --- /dev/null +++ b/scripts/rhino_cra_view.py @@ -0,0 +1,322 @@ +#! python3 +# venv: compas-cra +# r: compas_cra>=0.7.2 +"""Rhino port of the compas_cra desktop viewer (compas_cra.viewers.cra_view). + +Draws a solved CRA assembly into the active Rhino document through the COMPAS scene API, +mirroring the desktop viewer: block and support meshes with their edge lines, interface +outlines, resultant/nodal force arrows (cylinder shaft + conical head, widths scaled by +arrow length), and self-weight arrows - all in the same colors. Objects are sorted onto +layers ("::Blocks", "::Forces::Resultant", ...) so each group can be +toggled in the Rhino layer panel. + +This module imports cleanly outside Rhino (only ``compas`` is needed), so it can be +syntax- and logic-checked headless; the actual drawing requires Rhino, where the COMPAS +scene resolves every item to its Rhino scene object. +""" + +from math import sqrt + +from compas.colors import Color +from compas.datastructures import Mesh +from compas.geometry import Cone +from compas.geometry import Cylinder +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Polygon +from compas.geometry import Polyline +from compas.geometry import Vector +from compas.geometry import is_coplanar +from compas.scene import Scene + +# desktop viewer colors (compas_cra.viewers.cra_view) +COLOR_BLOCK = Color(0.9, 0.9, 0.9) +COLOR_BLOCK_EDGE = Color(0.0, 0.0, 0.0) +COLOR_SUPPORT = Color.from_hex("#f79d84") +COLOR_INTERFACE_FACE = Color(0.8, 0.8, 0.8) +COLOR_INTERFACE_OUTLINE = Color.from_hex("#fac05e") +COLOR_INTERFACE_POINT = Color(0.0, 0.0, 0.0) +COLOR_COMPRESSION = Color.from_hex("#386641") +COLOR_TENSION = Color(0.8, 0.0, 0.0) +COLOR_NODAL_COMPRESSION = Color.from_hex("#00468b") +COLOR_NODAL_TENSION = Color(1.0, 0.0, 0.0) +COLOR_FRICTION = Color(1.0, 0.5, 0.0) +COLOR_WEIGHT = Color.from_hex("#59cd90") +COLOR_SUPPORT_NODE = Color.from_hex("#ee6352") +COLOR_BLOCK_NODE = Color.from_hex("#3284a0") + + +class Arrow: + """A force arrow as a mesh: cylinder shaft plus conical head. + + Same parameters as the desktop viewer's ``Arrow``: the head takes ``head_portion`` + of the total length, and both widths are scaled by the arrow length, so a short + force renders as a thin pin and a long one as a bold arrow. + """ + + def __init__(self, position=(0, 0, 0), direction=(0, 0, 1), head_portion=0.2, head_width=0.07, body_width=0.02): + self.position = Vector(*position) + self.direction = Vector(*direction) + self.head_portion = head_portion + self.head_width = head_width + self.body_width = body_width + + def mesh(self): + """Build the arrow mesh, or return None for a zero-length arrow.""" + length = self.direction.length + if length == 0: + return None + zaxis = self.direction.unitized() + body_length = length * (1 - self.head_portion) + shaft = Cylinder( + radius=self.body_width * length, + height=body_length, + frame=Frame.from_plane(Plane(self.position + zaxis * (body_length / 2), zaxis)), + ) + head = Cone( + radius=self.head_width * length, + height=length * self.head_portion, + frame=Frame.from_plane(Plane(self.position + zaxis * body_length, zaxis)), + ) + mesh = Mesh.from_shape(shaft, u=32) + mesh.join(Mesh.from_shape(head, u=32)) + return mesh + + +def _add_arrow(scene, arrow, color, layer): + mesh = arrow.mesh() + if mesh is None: + return + scene.add(mesh, color=color, layer=layer) + + +def _weighted_average(points, weights): + total = sum(weights) + if total == 0: # degenerate: fall back to the plain centroid + weights = [1.0] * len(points) + total = float(len(points)) + return Point( + sum(p[0] * w for p, w in zip(points, weights)) / total, + sum(p[1] * w for p, w in zip(points, weights)) / total, + sum(p[2] * w for p, w in zip(points, weights)) / total, + ) + + +def draw_blocks(assembly, scene, edge=True, tol=0.0, layer_prefix="CRA"): + """Add block and support meshes (plus their edge lines) to the scene.""" + for node in assembly.graph.nodes(): + block = assembly.graph.node_attribute(node, "block") + is_support = assembly.graph.node_attribute(node, "is_support") + if is_support: + scene.add(block, color=COLOR_SUPPORT, layer=layer_prefix + "::Supports") + else: + scene.add(block, color=COLOR_BLOCK, layer=layer_prefix + "::Blocks") + if not edge: + continue + for block_edge in block.edges(): + if tol != 0.0: + fkeys = block.edge_faces(block_edge) + ps = [ + block.face_center(fkeys[0]), + block.face_center(fkeys[1]), + *block.edge_coordinates(block_edge), + ] + if is_coplanar(ps, tol=tol): + continue + line = Line(*block.edge_coordinates(block_edge)) + if is_support: + scene.add(line, color=COLOR_SUPPORT, layer=layer_prefix + "::Supports") + else: + scene.add(line, color=COLOR_BLOCK_EDGE, layer=layer_prefix + "::Blocks") + + +def _draw_interface(scene, interface, layer, faces=True): + corners = [Point(*point) for point in interface.points] + if faces: + scene.add(Mesh.from_polygons([Polygon(interface.points)]), color=COLOR_INTERFACE_FACE, layer=layer) + scene.add(Polyline(corners + corners[:1]), color=COLOR_INTERFACE_OUTLINE, layer=layer) + for corner in corners: + scene.add(corner, color=COLOR_INTERFACE_POINT, layer=layer) + + +def draw_interfaces(assembly, scene, layer_prefix="CRA"): + """Add interface faces, outlines, and corner points to the scene.""" + layer = layer_prefix + "::Interfaces" + for edge in assembly.graph.edges(): + interface = assembly.graph.edge_attribute(edge, "interface") + if interface is not None: + support = assembly.graph.node_attribute(edge[0], "is_support") or assembly.graph.node_attribute( + edge[1], "is_support" + ) + _draw_interface(scene, interface, layer, faces=not support) + for subinterface in assembly.graph.edge_attribute(edge, "interfaces") or []: + _draw_interface(scene, subinterface, layer) + + +def draw_forcesdirect(assembly, scene, scale=1.0, resultant=True, nodal=False, layer_prefix="CRA"): + """Add resultant and/or nodal force arrows to the scene, as in the desktop viewer.""" + layer_resultant = layer_prefix + "::Forces::Resultant" + layer_nodal = layer_prefix + "::Forces::Nodal" + layer_friction = layer_prefix + "::Forces::Friction" + thres = 1e-6 + locs = [] + res_np = [] + res_nn = [] + fnp = [] + fnn = [] + ft = [] + for edge in assembly.graph.edges(): + support_0 = assembly.graph.node_attribute(edge[0], "is_support") + support_1 = assembly.graph.node_attribute(edge[1], "is_support") + flip = not (support_0 and not support_1) + interfaces = assembly.graph.edge_attribute(edge, "interfaces") + if interfaces is None: + continue + for interface in interfaces: + forces = interface.forces + if forces is None: + continue + corners = [Point(*point) for point in interface.points] + frame = interface.frame + w, u, v = frame.zaxis, frame.xaxis, frame.yaxis + if nodal: + for i, pt in enumerate(corners): + force = forces[i]["c_np"] - forces[i]["c_nn"] + fn = w * force * scale + if fn.length == 0: + continue + arrow = Arrow(pt, fn * -1 if flip else fn) + if force >= 0: + fnp.append(arrow) + else: + fnn.append(arrow) + ft_uv = (u * forces[i]["c_u"] + v * forces[i]["c_v"]) * scale + if ft_uv.length == 0: + continue + ft.append(Arrow(pt, ft_uv * -1 if flip else ft_uv)) + if resultant: + sum_n = sum(force["c_np"] - force["c_nn"] for force in forces) + # net tension decides the color - the rule the published screenshots used; + # per-vertex epsilon noise from the relaxed complementarity must not flip it + is_tension = sum_n < 0 + sum_u = sum(force["c_u"] for force in forces) + sum_v = sum(force["c_v"] for force in forces) + if abs(sum_n) <= thres: # pure friction interface + weights = [sqrt(force["c_u"] ** 2 + force["c_v"] ** 2) for force in forces] + friction = True + else: + weights = [force["c_np"] - force["c_nn"] for force in forces] + friction = False + pos = _weighted_average(corners, weights) + resultant_f = (w * sum_n + u * sum_u + v * sum_v) * scale + if resultant_f.length >= thres: + locs.append(pos) + arrow = Arrow(pos, resultant_f * -1 if flip else resultant_f) + if friction: + _add_arrow(scene, arrow, COLOR_FRICTION, layer_friction) + if not is_tension: + res_np.append(arrow) + else: + res_nn.append(arrow) + for loc in locs: + scene.add(loc, color=COLOR_COMPRESSION, layer=layer_resultant) + for arrow in res_np: + _add_arrow(scene, arrow, COLOR_COMPRESSION, layer_resultant) + for arrow in res_nn: + _add_arrow(scene, arrow, COLOR_TENSION, layer_resultant) + for arrow in fnp: + _add_arrow(scene, arrow, COLOR_NODAL_COMPRESSION, layer_nodal) + for arrow in fnn: + _add_arrow(scene, arrow, COLOR_NODAL_TENSION, layer_nodal) + for arrow in ft: + _add_arrow(scene, arrow, COLOR_FRICTION, layer_friction) + + +def draw_weights(assembly, scene, scale=1.0, density=1.0, layer_prefix="CRA"): + """Add self-weight arrows and block/support center points to the scene.""" + layer = layer_prefix + "::Weights" + for node in assembly.graph.nodes(): + block = assembly.graph.node_attribute(node, "block") + if assembly.graph.node_attribute(node, "is_support"): + scene.add(Point(*block.center()), color=COLOR_SUPPORT_NODE, layer=layer) + continue + d = block.attributes["density"] if "density" in block.attributes else density + _add_arrow(scene, Arrow(block.center(), [0, 0, -block.volume() * d * scale]), COLOR_WEIGHT, layer) + scene.add(Point(*block.center()), color=COLOR_BLOCK_NODE, layer=layer) + + +def _rhino_redraw(enable): + try: + import rhinoscriptsyntax as rs + except ImportError: # not running inside Rhino + return + rs.EnableRedraw(enable) + if enable: + rs.ZoomExtents(all=True) + + +def cra_view_rhino( + assembly, + scale=1.0, + density=1.0, + tol=1e-5, + resultant=True, + nodal=False, + edge=True, + blocks=True, + interfaces=True, + forcesdirect=True, + weights=True, + layer_prefix="CRA", +): + """Draw a solved CRA assembly into the Rhino document, like the desktop ``cra_view``. + + Parameters + ---------- + assembly : :class:`~compas_cra.datastructures.CRA_Assembly` + The solved rigid block assembly. + scale : float, optional + Force scale. + density : float, optional + Density of the block material (for the self-weight arrows). + tol : float, optional + Tolerance below which coplanar block edges are not drawn. + resultant : bool, optional + Plot resultant force arrows. + nodal : bool, optional + Plot nodal force arrows. + edge : bool, optional + Plot block edges. + blocks : bool, optional + Plot blocks and supports. + interfaces : bool, optional + Plot interfaces. + forcesdirect : bool, optional + Plot forces as arrows. + weights : bool, optional + Plot block self-weight as arrows. + layer_prefix : str, optional + Rhino layers are created as "::". + + Returns + ------- + :class:`compas.scene.Scene` + The scene that was drawn. + """ + scene = Scene() + if blocks: + draw_blocks(assembly, scene, edge=edge, tol=tol, layer_prefix=layer_prefix) + if interfaces: + draw_interfaces(assembly, scene, layer_prefix=layer_prefix) + if forcesdirect: + draw_forcesdirect(assembly, scene, scale=scale, resultant=resultant, nodal=nodal, layer_prefix=layer_prefix) + if weights: + draw_weights(assembly, scene, scale=scale, density=density, layer_prefix=layer_prefix) + _rhino_redraw(False) + try: + scene.draw() + finally: + _rhino_redraw(True) + return scene diff --git a/src/compas_cra/equilibrium/cra_native.py b/src/compas_cra/equilibrium/cra_native.py index 455dc9b6..03306ffe 100644 --- a/src/compas_cra/equilibrium/cra_native.py +++ b/src/compas_cra/equilibrium/cra_native.py @@ -22,23 +22,50 @@ # precision, and they are, but they are load bearing: the solve reaches its answer # through IPOPT's restoration phase, and relaxing them keeps it out of restoration # without getting it any closer to convergence. Measured on the 20-block arch, -# tol=1e-8 / constr_viol_tol=1e-9 turns a solve that stops at a feasible point into -# Maximum_Iterations_Exceeded at the 3000-iteration cap. +# The CRA system is square (as many equations as unknowns), and that made IPOPT's +# version the hidden variable of this solver's history. Until IPOPT 3.14.11, square +# problems were special-cased: the convergence check ignored dual feasibility and bound +# complementarity, so IPOPT stopped at the first primal-feasible point of the barrier +# path - an interior point, where every contact face still carries force. Every result +# in the CRA paper (Kao et al. 2022, doi:10.1016/j.cad.2022.103216) and every published +# example screenshot is such a point: on the curved-interface examples the load spreads +# over all 72 sub-faces. IPOPT 3.14.12 removed the special case, and from there the +# solver drives complementarity to zero and lands on a degenerate vertex instead - the +# same examples concentrate the whole load on 8 faces and zero the rest. Verified by +# building IPOPT 3.14.9, 3.14.11 and 3.14.14 from source against the same MUMPS 5.9: +# 3.14.11 still spreads, 3.14.14 concentrates - the flip is exactly at 3.14.12, per +# its changelog; not MUMPS, and not this repository - every code generation from the +# screenshot-day commit (15e5edc) to today produces both solutions depending only on +# the binary. +# +# mu_target restores the published behavior as an explicit setting rather than an +# accident of an old binary: the barrier stops at mu = 1e-5 instead of 0, an interior +# point where every contact face carries force. Calibrated against the published +# figures themselves, not against a rebuilt era binary: a from-source 3.14.11 stops at +# a lower effective mu than Gene's 2022 conda/macOS build did (the old square-problem +# shortcut stopped wherever the check happened to trip, so its level was platform +# luck), and the figures show near-uniform arrows - which 1e-5 reproduces +# (cube-curve-tall max/median 1.11, cube-curve-short 1.20, all 72 faces loaded). +# Equilibrium stays exact: constr_viol_tol holds the force balance at 1e-8; only the +# force *distribution* on statically indeterminate contacts is selected. The contact +# complementarity products sit at the mu level by construction, so solves legitimately +# end as acceptable-level or feasible-restoration points at ~1e-7 violation - +# acceptable_constr_viol_tol at 1e-6 is what admits them, and the degenerate examples +# are knife-edged in this option set (13_curve-3-blocks needs thousands of iterations +# and fails outright one small step away in mu_target or tol), so change these +# together or not at all. The arch keeps its resultants (1.9571/0.8437, vs 1.9570 +# under strict optimization) with iteration headroom intact (270 of 9000). _CRA_OPTIONS = { - "tol": 1e-10, - "constr_viol_tol": 1e-12, - "compl_inf_tol": 1e-12, - "acceptable_tol": 1e-8, - "acceptable_constr_viol_tol": 1e-8, - "acceptable_compl_inf_tol": 1e-8, - # CRA's complementarity constraints are degenerate, and the monotone barrier update - # crawls on them: the 20-block arch takes 1964 of its 3000 permitted iterations, - # which leaves almost no headroom before a wheel that rounds slightly differently - # runs out of iterations instead -- which is exactly what users on macOS hit, where - # the wheel links Accelerate rather than OpenBLAS. The adaptive update finishes the - # same solve in 668 iterations, on the same answer: interface resultants agree to - # 1e-10, and a 40-block arch goes from 2757 iterations to 307. + "tol": 1e-4, + "dual_inf_tol": 1e-4, + "compl_inf_tol": 1e-4, + "constr_viol_tol": 1e-8, + "acceptable_tol": 1e-3, + "acceptable_constr_viol_tol": 1e-6, + "acceptable_compl_inf_tol": 1e-3, "mu_strategy": "adaptive", + "mu_target": 1e-5, + "max_iter": 9000, } # the tolerances the CRA penalty solver has always used @@ -47,6 +74,10 @@ "constr_viol_tol": 1e-7, "acceptable_tol": 1e-6, "acceptable_constr_viol_tol": 1e-5, + # same degenerate complementarity constraints as the CRA solver, same crawl under + # the monotone update: the 20-block arch exhausted the 3000-iteration cap at 5e-2 + # violation. See _CRA_OPTIONS. + "mu_strategy": "adaptive", } diff --git a/src/compas_cra/viewers/cra_view.py b/src/compas_cra/viewers/cra_view.py index 924349d7..94e2234f 100644 --- a/src/compas_cra/viewers/cra_view.py +++ b/src/compas_cra/viewers/cra_view.py @@ -5,7 +5,11 @@ import numpy as np from compas.colors import Color from compas.datastructures import Mesh +from compas.geometry import Cone +from compas.geometry import Cylinder +from compas.geometry import Frame from compas.geometry import Line +from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Polygon from compas.geometry import Polyline @@ -15,25 +19,59 @@ from compas.geometry import is_coplanar from compas_viewer import Viewer from compas_viewer.config import Config +from compas_viewer.scene import Collection class Arrow: - def __init__(self, position=[0, 0, 0], direction=[0, 0, 1], linewidth=0.02): + """A force arrow as a mesh: cylinder shaft, conical head. + + This is the compas_view2 ``Arrow`` shape the original visualization used, with its + parameters: the widths are world units, the head takes ``head_portion`` of the + length. The compas_viewer ``VectorObject`` is no substitute - it draws a fixed + pixel-width line with a four-sided pyramid head that degenerates to a sliver for + axis-parallel directions. + """ + + def __init__(self, position=[0, 0, 0], direction=[0, 0, 1], head_portion=0.2, head_width=0.07, body_width=0.02): super().__init__() self.position = Vector(*position) self.direction = Vector(*direction) - self.linewidth = linewidth - - def add_to_scene(self, viewer, facecolor: Color, opacity=1): - viewer.scene.add( - Vector(*self.direction), - anchor=Point(*self.position), - facecolor=facecolor, - linecolor=facecolor, - linewidth=self.linewidth, - show_lines=True, - opacity=opacity, + self.head_portion = head_portion + self.head_width = head_width + self.body_width = body_width + + def mesh(self): + length = self.direction.length + if length == 0: + return None + zaxis = self.direction.unitized() + body_length = length * (1 - self.head_portion) + # widths scale with the length, exactly as the compas_view2 Arrow scaled its + # unit shape by the direction vector: a short force renders as a thin pin, a + # long one as a bold arrow, and relative proportions stay comparable + shaft = Cylinder( + radius=self.body_width * length, + height=body_length, + frame=Frame.from_plane(Plane(self.position + zaxis * (body_length / 2), zaxis)), ) + head = Cone( + radius=self.head_width * length, + height=length * self.head_portion, + frame=Frame.from_plane(Plane(self.position + zaxis * body_length, zaxis)), + ) + mesh = Mesh.from_shape(shaft, u=32) + mesh.join(Mesh.from_shape(head, u=32)) + return mesh + + def add_to_scene(self, viewer, facecolor: Color, opacity=0.999): + mesh = self.mesh() + if mesh is None: + return + # opacity just under 1 on purpose: it moves the arrow into the renderer's + # transparency pass, which draws after the semi-transparent block faces - so an + # arrow inside a block stays bold, as it was in compas_view2, instead of being + # washed out by the faces compositing over it + viewer.scene.add(mesh, facecolor=facecolor, show_lines=False, opacity=min(opacity, 0.999)) def draw_blocks(assembly, viewer, edge=True, tol=0.0): @@ -66,7 +104,7 @@ def draw_blocks(assembly, viewer, edge=True, tol=0.0): blockedges.append(Line(*block.edge_coordinates(edge))) if len(blocks) != 0: viewer.scene.add( - blocks, + Collection(blocks), show_faces=True, show_lines=False, opacity=0.6, @@ -74,16 +112,18 @@ def draw_blocks(assembly, viewer, edge=True, tol=0.0): ) if len(supports) != 0: viewer.scene.add( - supports, + Collection(supports), show_faces=True, show_lines=False, opacity=0.5, facecolor=Color.from_hex("#f79d84"), ) + # edges share one thickness; each set takes the color of the meshes it outlines: + # dark gray on the light gray blocks, salmon on the salmon supports if len(blockedges) != 0: - viewer.scene.add(blockedges, linewidth=1.5) + viewer.scene.add(Collection(blockedges), linecolor=Color(0.3, 0.3, 0.3), linewidth=1.5) if len(supportedges) != 0: - viewer.scene.add(supportedges, linecolor=Color.from_hex("#f79d84"), linewidth=4) + viewer.scene.add(Collection(supportedges), linecolor=Color.from_hex("#f79d84"), linewidth=1.5) def draw_interfaces(assembly, viewer): @@ -110,14 +150,14 @@ def draw_interfaces(assembly, viewer): if len(interfaces) != 0: viewer.scene.add( - interfaces, + Collection(interfaces), show_lines=False, show_points=False, facecolor=(0.8, 0.8, 0.8), ) if len(faces) != 0: viewer.scene.add( - faces, + Collection(faces), linecolor=Color.from_hex("#fac05e"), linewidth=10, pointsize=10, @@ -178,17 +218,17 @@ def draw_forces(assembly, viewer, scale=1.0, resultant=True, nodal=False): else: res_nn.append(Line(p1, p2)) if len(locs) != 0: - viewer.scene.add(locs, size=12, color=Color.from_hex("#386641")) + viewer.scene.add(Collection(locs), pointsize=12, pointcolor=Color.from_hex("#386641")) if len(res_np) != 0: - viewer.scene.add(res_np, linewidth=8, linecolor=Color(0, 0.3, 0)) + viewer.scene.add(Collection(res_np), linewidth=8, linecolor=Color(0, 0.3, 0)) if len(res_nn) != 0: - viewer.scene.add(res_nn, linewidth=8, linecolor=Color(0.8, 0, 0)) + viewer.scene.add(Collection(res_nn), linewidth=8, linecolor=Color(0.8, 0, 0)) if len(fnn) != 0: - viewer.scene.add(fnn, linewidth=5, linecolor=Color.from_hex("#00468b")) + viewer.scene.add(Collection(fnn), linewidth=5, linecolor=Color.from_hex("#00468b")) if len(fnp) != 0: - viewer.scene.add(fnp, linewidth=5, linecolor=Color(1, 0, 0)) + viewer.scene.add(Collection(fnp), linewidth=5, linecolor=Color(1, 0, 0)) if len(ft) != 0: - viewer.scene.add(ft, linewidth=5, linecolor=Color(1.0, 0.5, 0.0)) + viewer.scene.add(Collection(ft), linewidth=5, linecolor=Color(1.0, 0.5, 0.0)) def draw_forcesline(assembly, viewer, scale=1.0, resultant=True, nodal=False): @@ -222,12 +262,13 @@ def draw_forcesline(assembly, viewer, scale=1.0, resultant=True, nodal=False): p2 = pt - ft_uv ft.append(Line(p1, p2)) if resultant: - is_tension = False - for force in forces: - if force["c_np"] - force["c_nn"] <= -1e-5: - is_tension = True - sum_n = sum(force["c_np"] - force["c_nn"] for force in forces) + # tension when the NET normal force is tensile - see the comment in + # draw_forcesdirect: per-vertex tests false-positive on the solver's + # relaxed-complementarity noise (vertex forces negative by ~1e-3 on + # compressed interfaces), while the net sign colors the resultant by + # what it actually depicts + is_tension = sum_n < 0 sum_u = sum(force["c_u"] for force in forces) sum_v = sum(force["c_v"] for force in forces) if sum_n == 0: @@ -256,17 +297,17 @@ def draw_forcesline(assembly, viewer, scale=1.0, resultant=True, nodal=False): else: res_nn.append(Line(p1, p2)) if len(locs) != 0: - viewer.scene.add(locs, pointsize=12, pointcolor=Color.from_hex("#386641")) + viewer.scene.add(Collection(locs), pointsize=12, pointcolor=Color.from_hex("#386641")) if len(res_np) != 0: - viewer.scene.add(res_np, linewidth=8, linecolor=Color(0, 0.3, 0)) + viewer.scene.add(Collection(res_np), linewidth=8, linecolor=Color(0, 0.3, 0)) if len(res_nn) != 0: - viewer.scene.add(res_nn, linewidth=8, linecolor=Color(0.8, 0, 0)) + viewer.scene.add(Collection(res_nn), linewidth=8, linecolor=Color(0.8, 0, 0)) if len(fnn) != 0: - viewer.scene.add(fnn, linewidth=5, linecolor=Color.from_hex("#00468b")) + viewer.scene.add(Collection(fnn), linewidth=5, linecolor=Color.from_hex("#00468b")) if len(fnp) != 0: - viewer.scene.add(fnp, linewidth=5, linecolor=Color(1, 0, 0)) + viewer.scene.add(Collection(fnp), linewidth=5, linecolor=Color(1, 0, 0)) if len(ft) != 0: - viewer.scene.add(ft, linewidth=5, linecolor=Color(1.0, 0.5, 0.0)) + viewer.scene.add(Collection(ft), linewidth=5, linecolor=Color(1.0, 0.5, 0.0)) # print("total reaction: ", total_reaction) @@ -301,9 +342,9 @@ def draw_forcesdirect(assembly, viewer, scale=1.0, resultant=True, nodal=False): if (w * force * scale).length == 0: continue if flip: - f = Arrow(pt, w * force * scale * -1, linewidth=10) + f = Arrow(pt, w * force * scale * -1) else: - f = Arrow(pt, w * force * scale, linewidth=10) + f = Arrow(pt, w * force * scale) if force >= 0: fnp.append(f) else: @@ -312,26 +353,25 @@ def draw_forcesdirect(assembly, viewer, scale=1.0, resultant=True, nodal=False): if ft_uv.length == 0: continue if flip: - f = Arrow( - pt, - ft_uv * -1, - linewidth=10, - ) + f = Arrow(pt, ft_uv * -1) else: - f = Arrow( - pt, - ft_uv, - linewidth=10, - ) + f = Arrow(pt, ft_uv) ft.append(f) if resultant: - is_tension = False - - for force in forces: - if force["c_np"] - force["c_nn"] <= -1e-5: - is_tension = True - sum_n = sum(force["c_np"] - force["c_nn"] for force in forces) + # The resultant arrow depicts the NET interface force, so its color + # follows the sign of the net normal component: tensile (negative) + # renders red, compressive renders green - the same rule draw_forces + # uses. The old test flagged the interface red as soon as ANY vertex + # was more than 1e-5 in tension, but the solver relaxes + # complementarity (f_n (d_n + eps) = 0 with eps ~ 1e-4) and stops at + # IPOPT's "acceptable" tolerance, so vertex normal forces routinely + # come out negative by up to ~1e-3 on interfaces that are in overall + # compression - a false positive (the shelf example rendered red + # although its published screenshot is green). Genuine tension - a + # hanging or peeling interface - makes the net normal force itself + # negative and still renders red. + is_tension = sum_n < 0 sum_u = sum(force["c_u"] for force in forces) sum_v = sum(force["c_v"] for force in forces) if abs(sum_n) <= thres: @@ -352,9 +392,9 @@ def draw_forcesdirect(assembly, viewer, scale=1.0, resultant=True, nodal=False): if resultant_f.length >= thres: locs.append(Point(*resultant_pos)) if flip: - f = Arrow(resultant_pos, resultant_f * -1, linewidth=10) + f = Arrow(resultant_pos, resultant_f * -1) else: - f = Arrow(resultant_pos, resultant_f, linewidth=10) + f = Arrow(resultant_pos, resultant_f) if friction: f.add_to_scene(viewer, facecolor=(1.0, 0.5, 0.0)) if not is_tension: @@ -362,7 +402,7 @@ def draw_forcesdirect(assembly, viewer, scale=1.0, resultant=True, nodal=False): else: res_nn.append(f) if len(locs) != 0: - viewer.scene.add(locs, size=12, color=Color.from_hex("#386641")) + viewer.scene.add(Collection(locs), pointsize=12, pointcolor=Color.from_hex("#386641")) if len(res_np) != 0: for arrow in res_np: arrow.add_to_scene(viewer, facecolor=Color.from_hex("#386641")) @@ -412,9 +452,9 @@ def draw_displacements(assembly, viewer, dispscale=1.0, tol=0.0): continue blocks.append(Line(*new_block.edge_coordinates(edge))) if len(blocks) != 0: - viewer.scene.add(blocks, linewidth=1, linecolor=Color(0.7, 0.7, 0.7)) + viewer.scene.add(Collection(blocks), linewidth=1, linecolor=Color(0.7, 0.7, 0.7)) if len(nodes) != 0: - viewer.scene.add(nodes, pointcolor=Color(0.7, 0.7, 0.7)) + viewer.scene.add(Collection(nodes), pointcolor=Color(0.7, 0.7, 0.7)) def draw_weights(assembly, viewer, scale=1.0, density=1.0): @@ -432,7 +472,6 @@ def draw_weights(assembly, viewer, scale=1.0, density=1.0): Arrow( block.center(), [0, 0, -block.volume() * d * scale], - linewidth=0.02, ) ) # print("self-weight", -block.volume() * density) @@ -442,9 +481,9 @@ def draw_weights(assembly, viewer, scale=1.0, density=1.0): # print("total self-weight: ", total_weights) if len(supports) != 0: - viewer.scene.add(supports, pointsize=20, pointcolor=Color.from_hex("#ee6352")) + viewer.scene.add(Collection(supports), pointsize=20, pointcolor=Color.from_hex("#ee6352")) if len(blocks) != 0: - viewer.scene.add(blocks, pointsize=30, pointcolor=Color.from_hex("#3284a0")) + viewer.scene.add(Collection(blocks), pointsize=30, pointcolor=Color.from_hex("#3284a0")) if len(weights) != 0: for weight in weights: weight.add_to_scene(viewer, facecolor=Color.from_hex("#59cd90")) diff --git a/tasks.py b/tasks.py index faeb0403..aac0d6ce 100644 --- a/tasks.py +++ b/tasks.py @@ -1,11 +1,17 @@ from __future__ import print_function +import glob import os +import platform +import re +import shutil +import sysconfig import invoke from compas_invocations2 import build from compas_invocations2 import style from compas_invocations2 import tests +from compas_invocations2.console import chdir from compas_invocations2.console import confirm from invoke import Collection @@ -33,7 +39,11 @@ def release(ctx, release_type): ctx.run("bump-my-version bump %s --verbose" % release_type) build.prepare_changelog(ctx) - build.clean(ctx) + # builds=False: the default `clean` removes build/, which here holds the staged IPOPT + # tree packaging/build_ipopt.sh spends about fifteen minutes producing. CMakeLists.txt + # defaults IPOPT_PREFIX to build/ipopt/stage, so removing it makes the next + # `pip install -e .` fail with `No IPOPT build at IPOPT_PREFIX=...`. + build.clean(ctx, builds=False) if confirm( "Everything is ready. You are about to push to git, which will build every wheel " @@ -45,16 +55,509 @@ def release(ctx, release_type): raise invoke.Exit("You need to manually revert the tag/commits created.") -@invoke.task(help={"strict": "Fail the build on a broken reference or a missing page."}) -def docs(ctx, strict=True): - """Build the documentation with mkdocs into dist/docs.""" - ctx.run("mkdocs build --site-dir dist/docs" + (" --strict" if strict else "")) +# ============================================================================ +# building the solver +# ============================================================================ +# compas_cra is not pure Python: `pip install -e .` compiles compas_cra._native against +# a static IPOPT tree that packaging/build_ipopt.sh stages into build/ipopt/stage. The +# `setup` task below is that whole sequence - toolchain, IPOPT, editable install - as +# one command, on all three platforms. -@invoke.task(help={"port": "Port to serve on. Defaults to 8000."}) -def docs_serve(ctx, port=8000): - """Serve the documentation locally, rebuilding on every change.""" - ctx.run("mkdocs serve --dev-addr localhost:%s" % port) +def _ipopt_work_dir(base_folder): + """Where build_ipopt.sh builds and stages IPOPT: build/ipopt, space permitting. + + IPOPT is autotools, and autotools flatly refuses a source or prefix path containing + whitespace - configure stops at `unsafe srcdir value`, and coinbrew's own test + expressions fall apart the same way. A checkout under, say, "Desktop/untitled + folder" can never build IPOPT in-tree. The script takes WORK_DIR from the + environment and CMakeLists.txt takes IPOPT_PREFIX, so in that case the build goes + to ~/.compas_cra/ipopt instead - shared between such checkouts on purpose, the + staged tree is a function of the pinned IPOPT version, not of the clone. + """ + default = os.path.join(base_folder, "build", "ipopt") + if not re.search(r"\s", default): + return default + fallback = os.path.join(os.path.expanduser("~"), ".compas_cra", "ipopt") + if re.search(r"\s", fallback): + raise invoke.Exit( + "Both the repository path and %s contain whitespace, and the IPOPT build is " + "autotools, which cannot build under either. Move the repository to a path " + "without spaces, or set WORK_DIR to one." % fallback + ) + # the helper runs once for the staged-check and once for the build; say it once + if not getattr(_ipopt_work_dir, "noted", False): + _ipopt_work_dir.noted = True + print("NOTE: the repository path contains a space, which autotools cannot build under.") + print(" IPOPT will be built and staged in %s instead." % fallback) + return fallback + + +def _ipopt_stage_dir(base_folder): + return os.path.join(_ipopt_work_dir(base_folder), "stage") + + +def _ipopt_marker(base_folder): + """What CMakeLists.txt itself probes for, so "staged" means exactly what it means there.""" + return os.path.join(_ipopt_stage_dir(base_folder), "include", "coin-or", "IpTNLP.hpp") + + +def _ipopt_prefix_override(base_folder): + """The IPOPT_PREFIX the extension build needs, or None when the default is right. + + None whenever the stage tree is at build/ipopt/stage, where CMakeLists.txt looks by + itself; a path only when _ipopt_work_dir redirected the build away from a + whitespace repository path. + """ + stage = _ipopt_stage_dir(base_folder) + if stage == os.path.join(base_folder, "build", "ipopt", "stage"): + return None + return stage + + +MSYS2_PACKAGES = ( + "git patch make diffutils pkgconf mingw-w64-ucrt-x86_64-gcc " + "mingw-w64-ucrt-x86_64-gcc-fortran mingw-w64-ucrt-x86_64-openblas" +) + +# The official installer. Run with NONINTERACTIVE=1 it asks nothing, but it still needs +# sudo once to create the prefix, so macOS prompts for the login password a single time. +# Set COMPAS_CRA_NO_BREW_INSTALL to refuse the bootstrap and just fail with a hint. +HOMEBREW_INSTALL_URL = "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" +# where brew lands: Apple silicon first, then Intel. Checked directly because a brew that +# was installed moments ago - or by someone who never edited their shell profile - is on +# disk but not yet on PATH. +HOMEBREW_PREFIXES = ("/opt/homebrew", "/usr/local") + +LINUX_HINT = """No Fortran compiler found. The IPOPT build needs one (MUMPS is Fortran), +plus a static BLAS/LAPACK and a C/C++ toolchain: + + Debian / Ubuntu: + sudo apt install build-essential gfortran libopenblas-dev git curl make patch pkg-config + + Fedora / RHEL / AlmaLinux: + sudo dnf install gcc gcc-c++ gcc-gfortran openblas-devel openblas-static \ + glibc-static libstdc++-static git curl make patch pkgconf + +These need root, so they are not installed for you. Re-run `invoke setup` afterwards.""" + + +def _msys2_root(): + """Locate the MSYS2 installation the Windows IPOPT build runs inside.""" + candidates = [os.environ.get("MSYS2_ROOT"), r"C:\msys64", r"D:\msys64"] + for root in candidates: + if root and os.path.isfile(os.path.join(root, "usr", "bin", "bash.exe")): + return root + raise invoke.Exit( + "No MSYS2 installation found. The Windows IPOPT build runs in an MSYS2 UCRT64 " + "shell; install it from https://www.msys2.org, or set MSYS2_ROOT if it lives " + r"somewhere other than C:\msys64 or D:\msys64." + ) + + +def _brew(): + """Locate brew, on PATH or at either standard prefix. None if it is not installed.""" + found = shutil.which("brew") + if found: + return found + for prefix in HOMEBREW_PREFIXES: + # these are macOS paths by definition; os.path.join would backslash them on a + # Windows machine running the test suite + candidate = prefix + "/bin/brew" + if os.access(candidate, os.X_OK): + return candidate + return None + + +def _use_brew(brew): + """Put brew's bin directory on PATH for the rest of this process. + + A brew installed by _install_homebrew during this very run is not on the PATH we + inherited, and ctx.run passes os.environ down, so without this every later `brew` + call would fail until the contributor opened a new terminal. + """ + bin_dir = os.path.dirname(brew) + path = os.environ.get("PATH", "") + if bin_dir not in path.split(os.pathsep): + os.environ["PATH"] = bin_dir + os.pathsep + path + return brew + + +def _refuse_root(): + """Stop if the task is being run under sudo. + + `sudo invoke setup` is the obvious thing to try after the Homebrew installer asks for + administrator rights, and it is worse than the problem: Homebrew flatly refuses to + install as root, and were it not for that, build/, the staged IPOPT tree and the venv + would all come out owned by root and unwritable afterwards. Correct is to run as + yourself; the Homebrew install asks for the password itself. + """ + # no geteuid on Windows, where there is nothing equivalent to guard against + if hasattr(os, "geteuid") and os.geteuid() == 0: + raise invoke.Exit( + "Do not run `invoke setup` with sudo. Homebrew refuses to install as root, and " + "the IPOPT build, the venv and build/ would end up owned by root. Run it as " + "yourself - it asks for your password at the one point it needs it." + ) + + +def _install_homebrew(ctx): + """Install Homebrew, and return the path to the brew it produced. + + macOS ships no package manager and no Fortran compiler, so there is nothing to build + IPOPT with until this has run. It is the one step that needs root - the installer + creates /opt/homebrew and chowns it - which is a single password prompt, not a + separate thing to go and do by hand. pty=True so that prompt is actually visible. + """ + if os.environ.get("COMPAS_CRA_NO_BREW_INSTALL"): + raise invoke.Exit( + "Homebrew is required on macOS and COMPAS_CRA_NO_BREW_INSTALL is set. " + "Install it from https://brew.sh and re-run `invoke setup`." + ) + print("Homebrew not found. Installing it - macOS asks for your login password once.") + installer = '/bin/bash -c "$(curl -fsSL %s)"' % HOMEBREW_INSTALL_URL + # `sudo -v` has to share a pty with the installer, hence one ctx.run and not two. + # macOS sudo keys its credential cache to the controlling tty, and pty=True allocates + # a fresh pty per call - so a password entered in an earlier ctx.run is invisible to + # the installer's own `sudo -n` probe, which then reports the account is not an + # administrator when it is. One run, one tty, one password. + result = ctx.run("sudo -v && " + installer, env={"NONINTERACTIVE": "1"}, pty=True, warn=True) + if not result.ok and not _brew(): + # NONINTERACTIVE makes every prompt fatal, a timestamp that expired during a slow + # download included. Let the installer do its own asking; it wants a RETURN too. + print("\nNon-interactive install did not finish. Retrying - press RETURN when asked.") + ctx.run(installer, pty=True, warn=True) + brew = _brew() + if not brew: + raise invoke.Exit( + "The Homebrew installer finished but no brew turned up at %s. " + "Install it manually from https://brew.sh and re-run `invoke setup`." % " or ".join(HOMEBREW_PREFIXES) + ) + print("Homebrew installed at %s" % brew) + return brew + + +def _native_build_env(): + """Environment `pip install -e .` needs to find the compilers and the runtimes. + + Windows needs the most: the MSYS2 toolchain paths. macOS needs one thing - the + same deployment target CI builds IPOPT with (pipeline.yml pins 11.0), on arm64 and + intel alike; without it the static libs can refuse to link into an extension + targeting the Python build's older default. Linux needs nothing: the toolchain is + on PATH and CMakeLists.txt defaults IPOPT_PREFIX to the stage tree by itself. + """ + system = platform.system() + if system == "Darwin": + return {"MACOSX_DEPLOYMENT_TARGET": os.environ.get("MACOSX_DEPLOYMENT_TARGET", "11.0")} + if system != "Windows": + return {} + root = _msys2_root() + return { + # ucrt64\bin on PATH is not optional: g++ spawns cc1plus.exe from ucrt64\lib, + # and cc1plus finds its DLLs (gmp, mpfr, isl, zstd) in ucrt64\bin. Without it + # the compiler dies instantly with exit 1 and no output at all, and CMake + # reports the C++ compiler as "broken". + "PATH": os.path.join(root, "ucrt64", "bin") + os.pathsep + os.environ.get("PATH", ""), + "CMAKE_GENERATOR": "Ninja", + } + + +VENV_PATCH_MARK = "# compas_cra: MinGW toolchain for rebuilding the extension" +VENV_PATCH_MARK_MACOS = "# compas_cra: Homebrew toolchain for rebuilding the extension" +# both marks share this prefix, so one containment test keeps either patch from being +# appended twice - including to venvs patched by the Windows-only version of this task +VENV_PATCH_SENTINEL = "compas_cra:" + + +def _append_once(path, lines): + """Append lines to a shell script, unless this task has already written to it.""" + if not os.path.isfile(path): + return + with open(path, "r", encoding="utf-8") as f: + if VENV_PATCH_SENTINEL in f.read(): + return + with open(path, "a", encoding="utf-8", newline="") as f: + f.write("\n" + "\n".join(lines) + "\n") + print("patched %s" % path) + + +def _patch_venv_activation_macos(): + """Put Homebrew and the deployment target into the venv's activate script. + + The macOS mirror of the Windows patch below, and for the same reason: after it, a + plain `uv pip install -e .` in the activated venv rebuilds the extension with no + special environment. It matters more here than it looks, because `invoke setup` may + have installed Homebrew moments ago into a shell profile the current terminal never + read. Venv-scoped on purpose - no dotfile outside the repository is touched. + """ + activate = os.path.join(".venv", "bin", "activate") + if not os.path.isfile(activate): + print("no .venv to patch, skipping activation setup") + return + brew = _brew() + if not brew: + return + lines = [ + VENV_PATCH_MARK_MACOS, + 'eval "$(%s shellenv)"' % brew, + # brew shellenv prepends its own bin, which carries a python of its own; the + # venv must win, exactly as Scripts must on Windows + 'export PATH="$VIRTUAL_ENV/bin:$PATH"', + "export MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET:-11.0}", + ] + stage = _ipopt_prefix_override(os.getcwd()) + if stage: + lines.append('export IPOPT_PREFIX="%s"' % stage) + _append_once(activate, lines) + + +def _patch_venv_activation(): + """Write the build environment into the venv's activation scripts, once. + + Windows below, macOS in _patch_venv_activation_macos, nothing on Linux - there the + toolchain is already on PATH and needs no help. + + After this, a plain `uv pip install -e .` works in the activated venv with no + special environment: PATH provides the DLLs the compiler's own cc1plus needs, and + CMAKE_GENERATOR keeps CMake off the Visual Studio generator (MSVC cannot link the + GCC-built static archives; scikit-build-core also reads it to fetch ninja). The + compilers themselves are found by CMakeLists.txt. Venv-scoped on purpose - nothing + global is touched. + """ + system = platform.system() + if system == "Darwin": + _patch_venv_activation_macos() + return + if system != "Windows": + return + scripts = os.path.join(".venv", "Scripts") + if not os.path.isdir(scripts): + print("no .venv to patch, skipping activation setup") + return + root = _msys2_root() + ucrt_win = os.path.join(root, "ucrt64", "bin") + # bash cannot hold C:\...-style entries in PATH (the colon is its separator) + ucrt_posix = "/" + root.replace(":", "").replace("\\", "/").lower() + "/ucrt64/bin" + # the venv's own Scripts dir is re-prepended after the toolchain: ucrt64in + # carries a python.exe of its own, and it must never shadow the venv's + patches = { + "activate": [ + VENV_PATCH_MARK, + 'export PATH="%s:$PATH"' % ucrt_posix, + 'export PATH="$VIRTUAL_ENV/Scripts:$PATH"', + "export CMAKE_GENERATOR=Ninja", + ], + "activate.bat": [ + "rem " + VENV_PATCH_MARK[2:], + 'set "PATH=%s;%%PATH%%"' % ucrt_win, + 'set "PATH=%%VIRTUAL_ENV%%\\Scripts;%%PATH%%"', + 'set "CMAKE_GENERATOR=Ninja"', + ], + "activate.ps1": [ + VENV_PATCH_MARK, + '$env:PATH = "%s;" + $env:PATH' % ucrt_win, + '$env:PATH = "$env:VIRTUAL_ENV\\Scripts;" + $env:PATH', + '$env:CMAKE_GENERATOR = "Ninja"', + ], + } + stage = _ipopt_prefix_override(os.getcwd()) + if stage: + patches["activate"].append('export IPOPT_PREFIX="%s"' % _msys_path(stage)) + patches["activate.bat"].append('set "IPOPT_PREFIX=%s"' % stage) + patches["activate.ps1"].append('$env:IPOPT_PREFIX = "%s"' % stage) + for name, lines in patches.items(): + _append_once(os.path.join(scripts, name), lines) + + +def _install_toolchain(ctx): + """Install what build_ipopt.sh needs, per platform. + + Windows and macOS are both bootstrapped end to end - pacman packages into an existing + MSYS2 on the one, Homebrew and its formulae on the other, installing Homebrew itself + if it is missing. Linux is the exception: its package managers need root for every + install, not just the first, so it prints LINUX_HINT and stops. + """ + system = platform.system() + + if system == "Windows": + pacman = os.path.join(_msys2_root(), "usr", "bin", "pacman.exe") + # --needed makes this a no-op once installed, --noconfirm keeps it non-interactive + ctx.run('"%s" -S --needed --noconfirm %s' % (pacman, MSYS2_PACKAGES)) + + elif system == "Darwin": + # installed on demand: a stock macOS has no package manager, and requiring the + # contributor to go and get one first is the one manual step this task exists to + # remove. Addressed by absolute path throughout - a brew installed just now is + # not on the PATH this process started with. + brew = _use_brew(_brew() or _install_homebrew(ctx)) + # build_ipopt.sh resolves Accelerate through xcrun. The Homebrew installer pulls + # the Command Line Tools in itself, so by here this should hold; it is checked + # anyway rather than failing fifteen minutes into the build. + if not shutil.which("xcrun"): + raise invoke.Exit("Xcode Command Line Tools are required: xcode-select --install") + # coinbrew refuses to run under bash 3, which is what macOS still ships + ctx.run('"{0}" list bash >/dev/null 2>&1 || "{0}" install bash'.format(brew)) + ctx.run('"{0}" list gcc >/dev/null 2>&1 || "{0}" install gcc'.format(brew)) + # not strictly required, but coinbrew warns loudly without it, and configure + # resolves dependencies more reliably with it; MSYS2 and the Linux hint both + # already include their pkgconf + ctx.run('"{0}" list pkgconf >/dev/null 2>&1 || "{0}" install pkgconf'.format(brew)) + # gfortran arrives from the gcc formula as gfortran-14 or similar; build_ipopt.sh + # looks for the unsuffixed name + ctx.run( + 'test -x "$("{0}" --prefix)/bin/gfortran" || ' + 'ln -sf "$(ls "$("{0}" --prefix)"/bin/gfortran-* | tail -1)" "$("{0}" --prefix)/bin/gfortran"'.format(brew) + ) + + elif not shutil.which("gfortran"): + raise invoke.Exit(LINUX_HINT) + + +def _codesign_extension(ctx): + """Ad-hoc re-sign the freshly installed extension modules. Apple silicon only. + + CMake's install step edits the load commands of the .so after the linker ad-hoc + signed it, which invalidates the signature - and arm64 macOS answers an invalid + signature at import with SIGKILL: the process dies with exit 137 and no message at + all. The wheels CI publishes are re-signed by delocate; the local editable install + gets the same treatment here. Idempotent, codesign --force replaces cheerfully. + """ + if platform.system() != "Darwin": + return + pattern = os.path.join(sysconfig.get_path("platlib"), "compas_cra", "**", "*.so") + for so in sorted(glob.glob(pattern, recursive=True)): + ctx.run('codesign --force --sign - "%s"' % so) + + +def _verbosity(): + """coinbrew verbosity for interactive builds: stream the compile lines. + + An interactive fifteen-minute build with no output is indistinguishable from a hung + one, so `invoke setup` defaults to 3. Not 2: coinbrew demotes ThirdParty projects + one level (`invoke_make $((verbosity-1))`), and ASL and MUMPS - almost the whole + build - are ThirdParty, so at 2 their make output still goes to /dev/null. At 3 + they stream; only their short autoconf configures stay quiet (those need 4). CI + calls build_ipopt.sh directly and keeps the script's own quiet default. VERBOSITY + in the environment still wins, in either direction. + """ + return os.environ.get("VERBOSITY", "3") + + +def _msys_path(path): + """C:\\a\\b as MSYS2 bash needs it: /c/a/b. Colon and backslashes both break bash. + + By hand rather than os.path.splitdrive, which only understands drive letters when + running on Windows itself - this way the helper means the same thing everywhere. + """ + drive, colon, rest = path.partition(":") + if colon and len(drive) == 1: + return "/" + drive.lower() + rest.replace("\\", "/") + return path.replace("\\", "/") + + +def _build_ipopt(ctx, jobs): + """Stage IPOPT, in-tree or in the whitespace fallback, unless already staged.""" + if os.path.isfile(_ipopt_marker(ctx.base_folder)): + print("IPOPT already staged in %s, skipping." % _ipopt_stage_dir(ctx.base_folder)) + return + + work_dir = _ipopt_work_dir(ctx.base_folder) + in_tree = work_dir == os.path.join(ctx.base_folder, "build", "ipopt") + + print("Building IPOPT. This takes about fifteen minutes, and only has to happen once.") + + if platform.system() == "Windows": + bash = os.path.join(_msys2_root(), "usr", "bin", "bash.exe") + # No `cd`, no `&&`, no inline JOBS=: invoke runs this through cmd.exe, which + # splits on && before bash ever sees the line (single quotes do not protect in + # cmd). Everything those provided comes through the environment instead: + # MSYSTEM=UCRT64 with a login shell puts the ucrt64 toolchain on PATH, + # CHERE_INVOKING keeps the login shell in the invoking directory (we are in + # base_folder courtesy of the chdir in `setup`), and build_ipopt.sh reads JOBS. + env = {"MSYSTEM": "UCRT64", "CHERE_INVOKING": "1", "JOBS": str(jobs), "VERBOSITY": _verbosity()} + if not in_tree: + # WORK_DIR is consumed by bash, so it has to be a path bash can hold + env["WORK_DIR"] = _msys_path(work_dir) + ctx.run('"{0}" -lc "packaging/build_ipopt.sh"'.format(bash), env=env) + else: + env = {"JOBS": str(jobs), "VERBOSITY": _verbosity()} + if not in_tree: + env["WORK_DIR"] = work_dir + if platform.system() == "Darwin": + # same deployment target the CI IPOPT step pins, for arm64 and intel alike + env["MACOSX_DEPLOYMENT_TARGET"] = os.environ.get("MACOSX_DEPLOYMENT_TARGET", "11.0") + ctx.run("packaging/build_ipopt.sh", env=env) + + +@invoke.task( + help={ + "jobs": ( + "Parallel jobs for the IPOPT build. Defaults to 1, and should stay there: " + "MUMPS' makefiles are missing dependencies, so a parallel build races and " + "dies linking libcoinmumps, intermittently and with no error output." + ), + "toolchain": "False to skip the toolchain check and install.", + } +) +def setup(ctx, jobs=1, toolchain=True): + """Build the solver and install compas_cra into the active environment. + + Everything docs/installation.md spells out by hand, in one command, on Windows, + macOS and Linux: install the toolchain - Homebrew included, on a Mac that has + none - stage + IPOPT if it is not staged already, then install the package editable with the + compilers and link directories the extension needs. + + Idempotent - a second run reinstalls the package and leaves the IPOPT tree alone. + """ + _refuse_root() + + with chdir(ctx.base_folder): + if toolchain: + _install_toolchain(ctx) + + _build_ipopt(ctx, jobs) + + _patch_venv_activation() + + # uv where the contributor is using it, pip otherwise; both install into whatever + # environment is active + pip = "uv pip" if shutil.which("uv") else "python -m pip" + env = _native_build_env() + stage = _ipopt_prefix_override(ctx.base_folder) + if stage: + # the stage tree is not at build/ipopt/stage, so CMakeLists.txt has to be told + env["IPOPT_PREFIX"] = stage + ctx.run('%s install -e ".[dev]"' % pip, env=env) + + _codesign_extension(ctx) + + print("\nDone. `invoke test` should now pass.") + + +@invoke.task( + help={ + "serve": ( + "Serve at localhost with live reload (the default). --no-serve builds " + "into dist/docs instead - the artifact CI deploys." + ), + "port": "Port to serve on. Defaults to 8000.", + "strict": "Fail on a broken reference or a missing page.", + } +) +def docs(ctx, serve=True, port=8000, strict=True): + """Serve the documentation at http://localhost:8000, rebuilding on every edit. + + Serving is the default because it is the only way the site is browsable locally: + mkdocs links pages by directory (`examples/`), which only a web server resolves to + the page inside - opened from file:// every click shows a folder listing. CI runs + `invoke docs --no-serve` for the deployable build. + """ + flag = " --strict" if strict else "" + if serve: + ctx.run("mkdocs serve --dev-addr localhost:%s%s" % (port, flag)) + else: + ctx.run("mkdocs build --site-dir dist/docs" + flag) ns = Collection( @@ -62,11 +565,11 @@ def docs_serve(ctx, port=8000): style.lint, style.format, docs, - docs_serve, tests.test, tests.testdocs, build.prepare_changelog, build.clean, + setup, release, ) ns.configure({"base_folder": os.path.dirname(__file__)}) diff --git a/tests/test_native.py b/tests/test_native.py index ec731734..a7755ff2 100644 --- a/tests/test_native.py +++ b/tests/test_native.py @@ -104,10 +104,12 @@ def test_cra_snake(): assert max(resultants) > 0 -def arch_assembly(num_blocks=20): +def arch_assembly(num_blocks=20, extra_support=False): from compas_cra.geometry import Arch - assembly = Arch(height=5.0, span=10.0, thickness=0.5, depth=0.5, num_blocks=num_blocks).assembly() + assembly = Arch( + height=5.0, span=10.0, thickness=0.5, depth=0.5, num_blocks=num_blocks, extra_support=extra_support + ).assembly() assembly_interfaces_numpy(assembly, nmax=10, amin=1e-2, tmax=1e-2) return assembly @@ -142,3 +144,32 @@ def test_cra_arch_has_iteration_headroom(): result = solve_nlp(problem, backend="native", options=_CRA_OPTIONS) assert result.success, result.status_message assert result.iterations < 1500, "lost iteration headroom: {} iterations".format(result.iterations) + + +def test_cra_penalty_arch(): + """The penalty solver on the arch, both variants of docs/examples/06_arch_penalty.py. + + With the default d_bnd=1e-3 this problem exhausts a 9000-iteration cap under both + barrier strategies; at d_bnd=1e-2 it converges in ~65 iterations. On the standard + arch - all compression, penalties inactive - the penalty solution must agree with + cra_solve, which pins the parameter change to the same physics. The extra-support + arch is infeasible for cra_solve (that instability is what the penalty formulation + is for), so there it must simply converge with nonzero displacements.""" + from compas_cra.equilibrium import cra_penalty_solve + + standard = arch_assembly() + cra_penalty_solve(standard, mu=0.7, d_bnd=1e-2) + resultants = sorted(interface_resultants(standard)) + assert len(resultants) == 19 + assert resultants[-1] == pytest.approx(1.96, abs=0.05) + assert resultants[0] > 0 + + extra = arch_assembly(extra_support=True) + cra_penalty_solve(extra, mu=0.7, d_bnd=1e-2) + assert len(interface_resultants(extra)) == 21 + displacements = [ + extra.graph.node_attribute(n, "displacement") + for n in extra.graph.nodes() + if not extra.graph.node_attribute(n, "is_support") + ] + assert max(max(abs(x) for x in d) for d in displacements if d) > 1e-3 diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 00000000..602a06d7 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,205 @@ +"""The platform logic of the invoke tasks, mocked per-OS. + +The Windows path is exercised for real on Windows dev machines; the macOS and Linux +branches cannot run here, but their environment construction is pure logic - these +tests pin it so a refactor cannot silently break a platform nobody is sitting at. +Skipped wherever the dev tooling is not installed (the CI test job installs only the +wheel and pytest). +""" + +import os +import sys + +import pytest + +pytest.importorskip("invoke", reason="dev tooling not installed") +pytest.importorskip("compas_invocations2", reason="dev tooling not installed") + +# tasks.py lives at the repository root, which the bare `pytest` binary does not put +# on sys.path (python -m pytest would) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tasks # noqa: E402 + + +def test_native_build_env_macos(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Darwin") + monkeypatch.delenv("MACOSX_DEPLOYMENT_TARGET", raising=False) + env = tasks._native_build_env() + assert env == {"MACOSX_DEPLOYMENT_TARGET": "11.0"} + + +def test_native_build_env_macos_respects_override(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Darwin") + monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "12.0") + assert tasks._native_build_env()["MACOSX_DEPLOYMENT_TARGET"] == "12.0" + + +def test_native_build_env_linux_is_empty(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Linux") + assert tasks._native_build_env() == {} + + +def test_native_build_env_windows(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Windows") + monkeypatch.setattr(tasks, "_msys2_root", lambda: r"C:\msys64") + env = tasks._native_build_env() + assert env["CMAKE_GENERATOR"] == "Ninja" + assert env["PATH"].startswith(os.path.join(r"C:\msys64", "ucrt64", "bin")) + + +def test_verbosity_default_and_override(monkeypatch): + monkeypatch.delenv("VERBOSITY", raising=False) + assert tasks._verbosity() == "3" + monkeypatch.setenv("VERBOSITY", "1") + assert tasks._verbosity() == "1" + + +def test_brew_prefers_path(monkeypatch): + monkeypatch.setattr(tasks.shutil, "which", lambda name: "/opt/homebrew/bin/brew") + assert tasks._brew() == "/opt/homebrew/bin/brew" + + +def test_brew_falls_back_to_prefixes(monkeypatch): + """A brew installed moments ago is on disk before it is ever on PATH.""" + monkeypatch.setattr(tasks.shutil, "which", lambda name: None) + monkeypatch.setattr(tasks.os, "access", lambda path, mode: path == "/usr/local/bin/brew") + assert tasks._brew() == "/usr/local/bin/brew" + + +def test_brew_returns_none_when_absent(monkeypatch): + monkeypatch.setattr(tasks.shutil, "which", lambda name: None) + monkeypatch.setattr(tasks.os, "access", lambda path, mode: False) + assert tasks._brew() is None + + +def test_use_brew_prepends_once(monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin") + tasks._use_brew("/opt/homebrew/bin/brew") + assert os.environ["PATH"].split(os.pathsep)[0] == "/opt/homebrew/bin" + tasks._use_brew("/opt/homebrew/bin/brew") + assert os.environ["PATH"].count("/opt/homebrew/bin") == 1 + + +def test_install_homebrew_refused_by_env(monkeypatch): + """The escape hatch fails instead of installing anything.""" + monkeypatch.setenv("COMPAS_CRA_NO_BREW_INSTALL", "1") + with pytest.raises(tasks.invoke.Exit): + tasks._install_homebrew(None) + + +def test_patch_venv_activation_is_a_noop_on_linux(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Linux") + tasks._patch_venv_activation() # must not raise, must not look for a venv + + +def test_append_once_is_idempotent(tmp_path): + script = tmp_path / "activate" + script.write_text("# original\n", encoding="utf-8") + tasks._append_once(str(script), [tasks.VENV_PATCH_MARK_MACOS, "export FOO=1"]) + tasks._append_once(str(script), [tasks.VENV_PATCH_MARK_MACOS, "export FOO=1"]) + assert script.read_text(encoding="utf-8").count("export FOO=1") == 1 + + +def test_append_once_skips_a_missing_file(tmp_path): + tasks._append_once(str(tmp_path / "nope"), ["export FOO=1"]) + + +class _FakeResult: + def __init__(self, ok): + self.ok = ok + + +class _FakeCtx: + """Records the commands a task would run, and answers each with a fixed result.""" + + def __init__(self, results): + self.results = list(results) + self.commands = [] + + def run(self, command, **kwargs): + self.commands.append(command) + return _FakeResult(self.results.pop(0)) + + +def test_refuse_root_allows_a_normal_user(monkeypatch): + monkeypatch.setattr(tasks.os, "geteuid", lambda: 501, raising=False) + tasks._refuse_root() + + +def test_refuse_root_rejects_sudo(monkeypatch): + monkeypatch.setattr(tasks.os, "geteuid", lambda: 0, raising=False) + with pytest.raises(tasks.invoke.Exit): + tasks._refuse_root() + + +def test_install_homebrew_pairs_sudo_with_the_installer(monkeypatch): + """One ctx.run, so `sudo -v` and the installer share a pty - and a credential.""" + monkeypatch.delenv("COMPAS_CRA_NO_BREW_INSTALL", raising=False) + monkeypatch.setattr(tasks, "_brew", lambda: "/opt/homebrew/bin/brew") + ctx = _FakeCtx([True]) + assert tasks._install_homebrew(ctx) == "/opt/homebrew/bin/brew" + assert len(ctx.commands) == 1 + assert ctx.commands[0].startswith("sudo -v && ") + assert tasks.HOMEBREW_INSTALL_URL in ctx.commands[0] + + +def test_install_homebrew_retries_interactively(monkeypatch): + """A failed non-interactive run falls back to letting the installer prompt.""" + monkeypatch.delenv("COMPAS_CRA_NO_BREW_INSTALL", raising=False) + brews = iter([None, "/opt/homebrew/bin/brew"]) + monkeypatch.setattr(tasks, "_brew", lambda: next(brews)) + ctx = _FakeCtx([False, True]) + assert tasks._install_homebrew(ctx) == "/opt/homebrew/bin/brew" + assert len(ctx.commands) == 2 + assert not ctx.commands[1].startswith("sudo -v") + + +def test_install_homebrew_exits_when_no_brew_appears(monkeypatch): + monkeypatch.delenv("COMPAS_CRA_NO_BREW_INSTALL", raising=False) + monkeypatch.setattr(tasks, "_brew", lambda: None) + with pytest.raises(tasks.invoke.Exit): + tasks._install_homebrew(_FakeCtx([False, False])) + + +def test_ipopt_work_dir_stays_in_tree_without_spaces(): + base = os.path.join(os.sep, "home", "dev", "compas_cra") + assert tasks._ipopt_work_dir(base) == os.path.join(base, "build", "ipopt") + assert tasks._ipopt_prefix_override(base) is None + + +def test_ipopt_work_dir_redirects_a_spaced_path(monkeypatch, capsys): + """Autotools cannot build under whitespace, so the build leaves the tree.""" + monkeypatch.setattr(tasks.os.path, "expanduser", lambda p: p.replace("~", os.path.join(os.sep, "home", "dev"))) + base = os.path.join(os.sep, "home", "dev", "untitled folder", "compas_cra") + expected = os.path.join(os.sep, "home", "dev", ".compas_cra", "ipopt") + assert tasks._ipopt_work_dir(base) == expected + assert tasks._ipopt_prefix_override(base) == os.path.join(expected, "stage") + + +def test_ipopt_work_dir_exits_when_home_is_spaced_too(monkeypatch): + monkeypatch.setattr( + tasks.os.path, "expanduser", lambda p: p.replace("~", os.path.join(os.sep, "home", "spaced name")) + ) + base = os.path.join(os.sep, "srv", "untitled folder", "compas_cra") + with pytest.raises(tasks.invoke.Exit): + tasks._ipopt_work_dir(base) + + +def test_msys_path(): + assert tasks._msys_path(r"C:\Users\dev\.compas_cra\ipopt") == "/c/Users/dev/.compas_cra/ipopt" + + +def test_codesign_extension_signs_each_so(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Darwin") + monkeypatch.setattr(tasks.sysconfig, "get_path", lambda name: os.path.join(os.sep, "venv", "site-packages")) + sos = [os.path.join(os.sep, "venv", "site-packages", "compas_cra", "_native", "_core.so")] + monkeypatch.setattr(tasks.glob, "glob", lambda pattern, recursive: list(sos)) + ctx = _FakeCtx([True]) + tasks._codesign_extension(ctx) + assert ctx.commands == ['codesign --force --sign - "%s"' % sos[0]] + + +def test_codesign_extension_is_a_noop_off_macos(monkeypatch): + monkeypatch.setattr(tasks.platform, "system", lambda: "Windows") + tasks._codesign_extension(None) # must not touch ctx at all diff --git a/tests/test_viewers.py b/tests/test_viewers.py new file mode 100644 index 00000000..79348005 --- /dev/null +++ b/tests/test_viewers.py @@ -0,0 +1,48 @@ +"""The viewer module against the installed compas_viewer, without a window. + +The scene objects are fully constructed - only the blocking show() is stubbed - so an +API drift in compas_viewer (scene.add rejecting lists, renamed kwargs) fails here +instead of on a user's screen. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + +pytest.importorskip("compas_viewer", reason="the viz extra is not installed") + +from compas.datastructures import Mesh # noqa: E402 +from compas.geometry import Box # noqa: E402 +from compas.geometry import Frame # noqa: E402 +from compas.geometry import Translation # noqa: E402 +from compas_assembly.datastructures import Block # noqa: E402 +from compas_viewer import Viewer # noqa: E402 + +from compas_cra.datastructures import CRA_Assembly # noqa: E402 +from compas_cra.equilibrium import cra_solve # noqa: E402 +from compas_cra.viewers import cra_view # noqa: E402 + + +def solved_cubes(): + support = Box(1, 1, 1) + free = Box(1, 1, 1, frame=Frame.worldXY().transformed(Translation.from_vector([0, 0, 1]))) + assembly = CRA_Assembly() + assembly.add_block(Block.from_shape(support), node=0) + assembly.add_block(Block.from_shape(free), node=1) + assembly.set_boundary_conditions([0]) + interface = Mesh() + for i, c in enumerate([[0.5, 0.5, 0.5], [-0.5, 0.5, 0.5], [-0.5, -0.5, 0.5], [0.5, -0.5, 0.5]]): + interface.add_vertex(key=i, x=c[0], y=c[1], z=c[2]) + interface.add_face([0, 1, 2, 3]) + assembly.add_interfaces_from_meshes([interface], 0, 1) + cra_solve(assembly, density=1) + return assembly + + +def test_cra_view_builds_the_scene(monkeypatch): + seen = {} + monkeypatch.setattr(Viewer, "show", lambda self: seen.setdefault("objects", len(self.scene.objects))) + cra_view(solved_cubes(), forcesline=True, nodal=True) + assert seen["objects"] > 0