diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000000..b91616d2c0 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,3 @@ +.env +*.pyc +__pycache__ diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000000..b91616d2c0 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,3 @@ +.env +*.pyc +__pycache__ diff --git a/docker/.ifcos_env b/docker/.ifcos_env new file mode 100644 index 0000000000..2603df959e --- /dev/null +++ b/docker/.ifcos_env @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# .ifcos_env +# register autocompletes. just source the file in your shell, i.e. +# source .ifcos_env + +.ifcos_env() { + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + opts="create update up down restart build attach logs ps config remove help" + + # Basic static completion + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + + return 0 +} + +# Register the completion for the command "ifcos_env" +complete -F .ifcos_env ./ifcos_env diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..fbc4c23baf --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,56 @@ +FROM rockylinux:9 + +# Update system, enable CRB (needed by some EPEL packages) and install EPEL, +# then install required packages + some common tools for a bit of command +# line comfort. Combined into one layer so a later `create` always installs +# against packages from the same dnf update, rather than layering fresh +# installs on top of a stale cached "update" layer. +RUN dnf update -y && \ + dnf install -y epel-release && \ + dnf config-manager --set-enabled crb && \ + dnf install -y --allowerasing --setopt=install_weak_deps=False --setopt=tsflags=nodocs \ + bash-completion vim git curl wget which tree htop sudo \ + gcc gcc-c++ autoconf automake bison make zip cmake \ + python3 python3-pip \ + bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \ + sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \ + readline-devel ncurses-devel libuuid-devel git-lfs \ + findutils xz byacc ccache && \ + git lfs install --system && \ + dnf clean all && \ + rm -rf /var/cache/dnf + +# Trust bind-mounted repos regardless of which user (root or builder) or host +# UID owns them, rather than a per-user config that only one of them sees. +RUN git config --system --add safe.directory '*' + +# Configure ccache. CCACHE_MAXSIZE (not `ccache -M`) because /ccache is a +# volume mount point at runtime - anything `ccache -M` writes to a config +# file under it during this build gets shadowed once the real volume is +# mounted, so the size cap only actually takes effect via the env var. +ENV CCACHE_DIR=/ccache +ENV CCACHE_MAXSIZE=5G +ENV PATH="/usr/lib/ccache:$PATH" + +# Non-root user matching the host UID/GID that bind-mounts the repo (default +# 1000:1000, the common single-user-Linux-box case), so files the build +# creates under the mount keep sane, non-root ownership on the host side. +# Override with --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) +# if your host user has a different UID/GID. +ARG USER_UID=1000 +ARG USER_GID=1000 +RUN groupadd -g "${USER_GID}" builder \ + && useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash builder \ + && echo "builder ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/builder + +# Copied while still root: /bin is not writable by the builder user. +COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /uvx /bin/ + +USER builder +WORKDIR /__w/IfcOpenShell/IfcOpenShell + +# Installed as builder so managed Python interpreters land under builder's +# $HOME, matching the user that actually runs the build. +RUN uv python install + +CMD ["sleep", "infinity"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..8674c89685 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,78 @@ +Docker build environment +======================== + +This is a small utility to make it easy to compile a perfect `_ifcopenshell_wrapper.cpython-*-x86_64-linux-gnu.so` +files. + +The reason for this tool is that I was trying to follow the web page directions, and my build was behaving differently +to the release builds. Eventually I concluded that the differences between toolchains on the RHEL based rocky9 image +and Ubuntu were just too great. Getting the build setup was already a lot of trial and error, so I thought I'd spend +more time trying to reuse the github actions that perform the build, using a utility called `act`. I learnt a lot, in +particular how much time, energy, and bandwidth Github waste. I also realised I was most of the way to a regular docker +setup anyway, so I might as well just do that. So I've deconstructed all the github action steps, and turned it into +a local docker build environment that uses the exact same base, tools, libraries, and build command/flags etc. + +Right now a Github action will: +- launch the rocky9 base +- upgrade all the packages +- install a bunch of extra tools +- do a recursive checkout of your repo +- checkout the build repository +- unpack dependencies +- run the build script, making all python versions (5? right now I think) +- create the .zip release files + +And it does _all_ of that _every_ time. This is not a fault of the action writers - it's just how Github seems to work. + +These dockers tools do the following differently, and it's actually a bit more powerful too: +- build the base image once. +- update the packages once. +- install the extra tools once. +- the repository is the one on your host, that gets bind mounted in the container as the working directory. +- by adding an environment variable to .env, restricts to compiling for just a single python version. +- when the build is finished the created files are right there under your local repositry (but not added to git) for + ease of access +- each repository can have it's own build environment container. +- the image is shared between those environments. +- the containers share the ccache, so additional envs should get a helping hand. +- it has a simple set of user friendly commands to drive it all. + +For example: +``` bash +# To see the commands (a superset of docker compose commands) +./ifcos_env + +# Enable autocomplete of commands +source .ifcos_env + +# First time commands +./ifcos_env create +./ifcos_env up +./ifcos_env build + +# install and test library +# find an issue +# edit code +./ifcos_env build + +# and so on. When done stop and optionally delete the container +./ifcos_env stop +./ifcos_env remove +``` + +To limit the build to one python version just add +``` bash +PY_TGT=py-311 +``` +or whichever version your Blender requires. + +You might see UNIQUE_ID in the .env file too. This keeps containers for separate folders, separate. + +System requirements +1. Linux-x64 only at this time. +2. Docker and docker-compose need to be installed. +3. Have a good amount of disk space. (image is in /var (typically the root partition) and will be about 1.7 GB) +4. The build action will create about 10GB in your repository folder. Make sure this partition is spacious + particularly if you intent on having multiple clones building. +5. ... I think that covers most of it. + diff --git a/docker/SKILL.md b/docker/SKILL.md new file mode 100644 index 0000000000..c9df338a56 --- /dev/null +++ b/docker/SKILL.md @@ -0,0 +1,186 @@ +--- +name: ifcopenshell-docker-build +description: >- + Build a real ifcopenshell_wrapper (.so + .py) and IfcConvert locally via + the docker/ifcos_env toolchain, then wire them into a checkout for + running C++-dependent parts of the test suite (geometry, the SWIG + wrapper stub, the C++ parser). Use whenever a task needs to compile + IfcOpenShell's C++ core rather than just read/patch source - e.g. + reproducing or fixing a bug in src/ifcgeom, src/ifcparse, src/ifcwrap, + or validating util/scripts/validate_stub.py against the actual + generated wrapper. +--- + +# Building IfcOpenShell locally with docker/ifcos_env + +`docker/` mirrors the project's GitHub Actions build environment locally, +in a persistent, non-root container with ccache so repeat builds are fast. +See `docker/README.md` for the design rationale. Pure-Python changes don't +need any of this - only reach for it when you need a real compiled +`_ifcopenshell_wrapper*.so` or `IfcConvert` binary. + +## Placement + +This `docker/` folder must live as a direct child of the repo root you want +to build (sibling of `src/`, `cmake/`, etc.) - `compose.yaml` and +`ifcos_env` resolve the repo via `../` relative to wherever `docker/` +itself sits, and bind-mount it into the container. If you're setting this +up in a fresh clone, copy the whole `docker/` directory there first. + +## Setup + +```bash +cd docker +./ifcos_env create # build the image (shared by name across all your clones/checkouts, so usually instant after the first time anywhere) +./ifcos_env up # create + start the container, clone/unpack the third-party dependency cache (~10GB, one-time per container) +./ifcos_env build # full build: all deps + IfcParse + IfcGeom + IfcConvert + the Python wrapper, for one Python version +``` + +`PY_TGT` and `UNIQUE_ID` live in `docker/.env` - `PY_TGT` (e.g. `py-311`) +restricts the build to one Python version instead of building five; +`UNIQUE_ID` is a hash of the folder path, recalculated on every `up`, so +each checkout gets its own container/volumes automatically. + +A full first build takes ~1.5 hours (mostly compiling IfcOpenShell's own +C++, not the cached third-party deps). After that, ccache makes incremental +rebuilds of a couple of touched `.cpp` files **under a minute**. + +## Container lifecycle + +The container is long-lived (`sleep infinity`) so exec'd commands and +ccache state persist between builds. Commands map directly onto Docker +Compose's own container-vs-image distinction: + +```bash +./ifcos_env up # create the container if it doesn't exist, then start it (runs ready_repo too) +./ifcos_env stop # stop the container, keep it around +./ifcos_env start # start it back up (same container, same filesystem layer) +./ifcos_env restart # stop, then start +./ifcos_env down # remove the container (and its network) entirely +./ifcos_env recreate # down, then up - a fresh container +``` + +Named volumes (`ccache`) and the bind-mounted repo/`build/` are unaffected +by `down`/`recreate` - only the container itself goes away, and `up` +recreates it from the image. + +## Fast iteration + +Pass a target to `build` to skip the parts you don't need: + +```bash +./ifcos_env build IfcConvert # only the executables (IfcConvert, IfcGeomServer) - skips the Python wrapper entirely +./ifcos_env build IfcOpenShell-Python # only the SWIG Python wrapper - skips executables entirely +./ifcos_env build # no target = everything (needed the first time, or after touching shared headers) +``` + +Use this to keep the edit -> rebuild -> test loop fast when debugging: if +you're only touching `src/ifcgeom/`, build `IfcConvert`; if you're only +exercising the Python API, build `IfcOpenShell-Python`. + +## Where the artifacts land + +Build output goes to `/build/Linux/x86_64/install/` on the host +(bind-mounted, not just inside the container), owned by you (see +"Container user" below): + +- `ifcopenshell/bin/IfcConvert` - the CLI binary +- `python-/lib/python/site-packages/ifcopenshell/_ifcopenshell_wrapper*.so` + and `ifcopenshell_wrapper.py` - the compiled wrapper + its generated + Python glue + +## Testing against a checkout (automated / AI-driven) + +`_ifcopenshell_wrapper*.so` and `ifcopenshell_wrapper.py` are already +gitignored under `src/ifcopenshell-python/ifcopenshell/`, which is exactly +where a normal in-tree build would put them - copy the two files there: + +```bash +SRC=build/Linux/x86_64/install/python-3.11.8/lib/python3.11/site-packages/ifcopenshell +cp "$SRC/_ifcopenshell_wrapper.cpython-311-x86_64-linux-gnu.so" src/ifcopenshell-python/ifcopenshell/ +cp "$SRC/ifcopenshell_wrapper.py" src/ifcopenshell-python/ifcopenshell/ +``` + +Then, to run the test suite against it: + +```bash +export PATH="$PWD/build/Linux/x86_64/install/ifcopenshell/bin:$PATH" # for IfcConvert-dependent tests +cd src/ifcopenshell-python/test +PYTHONPATH="$PWD/.." python3.11 -m pytest -p no:pytest-blender . +``` + +(`-p no:pytest-blender` avoids the pytest-blender plugin trying to find a +`blender` executable and failing collection entirely, even for non-Blender +tests.) You'll need the matching Python version's `pip install`s too +(numpy, shapely, isodate, lark, tabulate, pytest, ... - whatever the +modules under test import) since this is a bare interpreter, not the +project's pixi env. + +**This is the pattern to use for automated or AI-driven verification.** +Don't use `try` (below) for that - it overwrites files in a real, live +Blender installation, which isn't something an automated/AI workflow +should ever do without the human explicitly asking for it in the moment. + +## Testing in Blender itself (human only) + +`try` copies the built wrapper straight into your actual Blender/Bonsai +extension install, for manual in-Blender testing: + +```bash +./ifcos_env try +``` + +It reads `BLENDER_USER_RESOURCE` from `.env` - set this to wherever +Blender's user resource folder for the Bonsai extension actually lives on +your system, which depends on your own Blender setup: + +```bash +# in docker/.env +BLENDER_USER_RESOURCE=~/.config/blender/bonsai/ +``` + +`try` figures out the built Python version from `build/.../install/` +(disambiguating with `PY_TGT` if more than one version was built) and +copies the wrapper to +`$BLENDER_USER_RESOURCE/extensions/.local/lib/python/site-packages/ifcopenshell/`. + +## Container user + +The image runs as a non-root `builder` user, UID/GID matching your host +account (passed as `--build-arg` by `create` from `id -u`/`id -g`, so it +adjusts automatically - no manual flag needed even if you're not 1000:1000). +Files the build creates under the bind mount come out owned by you, not +root. Passwordless `sudo` is available inside the container (e.g. via +`attach`) for the rare case you need root for something ad hoc. + +If you're picking up an existing checkout that was previously built with +an older, root-based image, you may hit `Permission denied` the first time +you run `up`/`build` under the new image - `build/`, `.git/modules/`, the +`ccache` volume, `output/`, and `build.log` can all be left root-owned from +before. Fix it once via the container's own root (no host `sudo` needed): + +```bash +docker exec -u root -w /__w/IfcOpenShell/IfcOpenShell \ + chown -R "$(id -u)":"$(id -g)" .git/modules build output build.log /ccache +``` + +(`` is `ifcopenshell-` - see `docker ps -a`.) + +## Other things worth knowing + +- **Linux x64 only.** `compose.yaml` pins `platform: linux/amd64`; on an + ARM host (e.g. Apple Silicon) this build isn't available. +- **The final "Package .zip archives" step of `build()` has a pre-existing + bash syntax error**, unrelated to compilation - the actual build already + succeeded by that point (look for `Built IfcOpenShell...` in the output), + so this is safe to ignore if you only need the raw artifacts under + `build/.../install/`, not packaged release zips. +- **`test_mmaped_stream` and similar `USE_MMAP`-dependent tests will fail** + against this build - `nix/build-all.py` is invoked with `USE_MMAP=OFF` + here. Not a bug in your code if you see it fail. +- Only the bind-mounted `/build` lives on the host filesystem your + repo is checked out on. Anything the container writes *outside* that + mount lives in the container's own writable layer under Docker's data + root (commonly `/var/lib/docker`, i.e. usually your root partition) - + keep an eye on `df -h /` if you're running several of these containers + at once. diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 0000000000..9bc79234d1 --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,14 @@ +name: ifcopenshell-${UNIQUE_ID} +services: + ifcopenshell: + container_name: ifcopenshell-${UNIQUE_ID} + image: ifcopenshell-build-env:updated + platform: linux/amd64 + volumes: + - type: bind + source: ../ + target: /__w/IfcOpenShell/IfcOpenShell + - ccache:/ccache + +volumes: + ccache: diff --git a/docker/ifcos_env b/docker/ifcos_env new file mode 100755 index 0000000000..4286421572 --- /dev/null +++ b/docker/ifcos_env @@ -0,0 +1,330 @@ +#!/bin/bash + +# ================== CONFIG ================== +SCRIPT_NAME=$(basename "$0") +ENV_FILE=".env" +WORKDIR="/__w/IfcOpenShell/IfcOpenShell" +NAMEPREFIX=ifcopenshell + +function set_env() { + # Load .env file if it exists + if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a + echo "✅ Loaded environment variables from $ENV_FILE" + else + echo "⚠️ No $ENV_FILE found, proceeding without it." + fi +} + +set_env + +# ================ FUNCTIONS ================= + +function create() { + echo "⭐ Creating image: ifcopenshell-build-env" + docker build -f Dockerfile \ + --build-arg USER_UID="$(id -u)" --build-arg USER_GID="$(id -g)" \ + -t ifcopenshell-build-env:updated . +} + +function update() { + # The Dockerfile always builds FROM a clean rockylinux:9 and does + # `dnf update -y` as its first step, so re-running create() is enough + # to get fresh packages. + echo "⚡ Updating image: ifcopenshell-build-env" + create +} + +function up() { + # Creates the container if it doesn't exist yet (and starts it either + # way) - this is the one that needs ready_repo, since a freshly created + # container has no submodules/dependency cache in place yet. + echo "🚀 Creating/starting stack: ifcopenshell-${UNIQUE_ID}" + unique # Update UNIQUE_ID first + docker compose up -d "$@" # Container must exist before ready_repo can exec into it. + ready_repo # Ensure repo is recursive, and the build repo is in place. +} + +function down() { + # Removes the container (and its network) entirely. Named volumes + # (ccache) and the bind-mounted repo/build/ survive; up() will recreate + # the container from scratch next time. + echo "🔥 Removing stack: ifcopenshell-${UNIQUE_ID}" + docker compose down "$@" +} + +function stop() { + # Stops the existing container without removing it - the container, + # its filesystem layer, and its exec history all remain intact. + echo "🛑 Stopping stack: ifcopenshell-${UNIQUE_ID}" + docker compose stop "$@" +} + +function start() { + # Starts a previously-stopped container back up. Does nothing (and + # won't create anything) if the container doesn't exist - use up() for + # that. + echo "▶️ Starting stack: ifcopenshell-${UNIQUE_ID}" + docker compose start "$@" +} + +function restart() { + echo "🔄 Restarting stack (stop, then start)..." + stop + start +} + +function recreate() { + echo "♻️ Recreating stack (down, then up)..." + down + up +} + +function logs() { + echo "📜 Showing logs..." + docker compose logs -f "$@" +} + +function ps() { + docker compose ps +} + +function config() { + echo "🔍 Validated compose configuration:" + docker compose config +} + +function remove() { + # Lower-level than down(): removes already-stopped containers without + # touching the compose network. Mostly useful after a plain stop(). + echo "🗑️ Removing stopped containers: ifcopenshell-${UNIQUE_ID}" + docker compose rm "$@" +} + +function unique() { + echo "🔧 Making stack name folder specific..." + + REGEX="^UNIQUE_ID=" + + if [[ ! -f "$ENV_FILE" ]] || ! grep -qE "$REGEX" "$ENV_FILE"; then + echo -e "\nUNIQUE_ID=dummy\n" >> "$ENV_FILE" + fi + + export UNIQUE_ID="$(pwd | sha256sum | cut -c -8)" && sed -si "s/^UNIQUE_ID=.*$/UNIQUE_ID=${UNIQUE_ID}/" "$ENV_FILE" + + set_env +} + +function ready_repo() { + echo "👍 Getting the repo ready to build..." + docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c ' + set -euo pipefail # Recommended for robustness + + git submodule update --init --recursive + + if [[ ! -d "build" ]]; then + git clone -b rockylinux9-x64 https://github.com/IfcOpenShell/build-outputs.git build + else + cd build + git pull + cd .. + fi + + if [[ ! -d "build/Linux/x86_64/install/boost-1.86.0/" ]]; then + cd build + uv run ../nix/cache_dependencies.py unpack + cd .. + fi + ' +} + +function build() { + echo "☕ Execute the build, go make yourself a cuppa... I'll be a while" + local BUILD_TARGET="$1" + + docker exec -i -w "${WORKDIR}" -e PY_TGT="${PY_TGT}" -e BUILD_TARGET="${BUILD_TARGET}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c ' + set -o pipefail + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v ${PY_TGT:+-$PY_TGT} --diskcleanup ${BUILD_TARGET} 2>&1 | tee build.log + ' + echo "🎒 Pack Dependencies" + docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c ' + cd build + uv run ../nix/cache_dependencies.py pack + ' + + echo "🎁 Package .zip archives" + docker exec -i -w "${WORKDIR}" -e GITHUB_SHA="$(git rev-parse HEAD)" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c ' + OUTPUT_DIR=${PWD}/output + VERSION=v`cat VERSION` + mkdir -p ${OUTPUT_DIR} + cd ./build/`uname`/*/install/ifcopenshell + + ls -d python-* | while read py_version; do + postfix=`echo ${py_version: -1} | sed s/[0-9]//` + numbers=`echo $py_version | grep -oE "[0-9]+\.[0-9]+" | tr -d "."` + py_version_major=python-${numbers}$postfix + pushd . > /dev/null + cd $py_version + if [ ! -d ifcopenshell ]; then + mkdir ../ifcopenshell_ + mv * ../ifcopenshell_ + mv ../ifcopenshell_ ifcopenshell + fi + [ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__ + find ifcopenshell -name "*.pyc" -delete + zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell/* + mv *.zip ${OUTPUT_DIR}/ + popd > /dev/null + done + + cd bin + if compgen -G "./*.zip" > /dev/null; then + rm *.zip 2>&1 >/dev/null || true + ls | while read exe; do + zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip $exe + done + mv *.zip ${OUTPUT_DIR}/ + cd .. + ' +} + +function attach() { + echo "🔦 Connect to interactive shell" + docker exec -it -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" /bin/bash +} + +function try() { + # Copies the freshly built wrapper into your actual Blender/Bonsai + # installation for manual, in-Blender testing. This is a human-only + # convenience: it overwrites files in your live Blender setup, so it's + # not something that should run unattended as part of an automated or + # AI-driven build/test loop (which should instead copy the wrapper into + # the repo's own src/ifcopenshell-python/ifcopenshell/ - see SKILL.md). + echo "🚴 Copying build artifacts into your Blender resource folder for testing" + + if [[ -z "${BLENDER_USER_RESOURCE:-}" ]]; then + echo "❌ BLENDER_USER_RESOURCE is not set in .env." + echo " Add a line pointing at wherever Blender's user resource folder for" + echo " the Bonsai extension actually is on your system, e.g.:" + echo " BLENDER_USER_RESOURCE=~/.config/blender/bonsai/" + return 1 + fi + + # Normalise: expand a leading ~ (in case it was quoted in .env and so + # never went through shell tilde-expansion when set_env sourced it), + # then resolve to an absolute, symlink-free path. + local resource="${BLENDER_USER_RESOURCE/#\~/$HOME}" + resource="$(realpath -m "$resource")" + + local install_dir="../build/Linux/x86_64/install" + local py_dirs=("$install_dir"/python-*) + if [[ ${#py_dirs[@]} -gt 1 && -n "${PY_TGT:-}" ]]; then + # PY_TGT is compact (py-311); the install dirs are dotted + # (python-3.11.8) - reinsert the dot (assumes a single-digit major + # version, true for the Python 3.x line) before matching. + local py_tgt_digits="${PY_TGT#py-}" + local py_tgt_dotted="${py_tgt_digits:0:1}.${py_tgt_digits:1}" + local filtered=() d + for d in "${py_dirs[@]}"; do + [[ "$(basename "$d")" == "python-${py_tgt_dotted}."* ]] && filtered+=("$d") + done + [[ ${#filtered[@]} -gt 0 ]] && py_dirs=("${filtered[@]}") + fi + if [[ ${#py_dirs[@]} -ne 1 || ! -d "${py_dirs[0]}" ]]; then + echo "❌ Expected exactly one built python-* dir under $install_dir, found ${#py_dirs[@]}." + echo " Run 'build' first, or set PY_TGT in .env to disambiguate a multi-version build." + return 1 + fi + + local py_minor + py_minor="$(basename "${py_dirs[0]}" | grep -oE '[0-9]+\.[0-9]+')" + local wrapper_dir="${py_dirs[0]}/lib/python${py_minor}/site-packages/ifcopenshell" + if [[ ! -f "$wrapper_dir/ifcopenshell_wrapper.py" ]]; then + echo "❌ Built wrapper not found at $wrapper_dir - run 'build' first." + return 1 + fi + + local target="$resource/extensions/.local/lib/python${py_minor}/site-packages/ifcopenshell" + mkdir -p "$target" + cp "$wrapper_dir"/_ifcopenshell_wrapper*.so "$target/" + cp "$wrapper_dir"/ifcopenshell_wrapper.py "$target/" + echo "✅ Copied wrapper into $target" +} + +function clean() { + # Host-side only - doesn't touch the container, image, or ccache volume. + echo "💎 Clean the build and output folder up" + if [[ -d "../build" ]]; then + rm -rf ../build + fi + if [[ -d "../output" ]]; then + rm -rf ../output + fi +} + + +function help() { + cat < + +Available commands: + create Build the rocky9-based image + update Rebuild the image fresh, picking up OS package updates + up Create the container if it doesn't exist yet, and start it + down Remove the container entirely (docker compose down) + stop Stop the container without removing it + start Start a previously-stopped container + restart stop, then start (same container, no recreation) + recreate down, then up (fresh container) + build Execute the IfcOpenShell build + attach Connect to an interactive shell in the container + try Copy the built wrapper into your Blender resource folder + (human-only - see BLENDER_USER_RESOURCE below, and SKILL.md + for the AI/automated-testing equivalent) + clean Remove the build and output folders + logs Follow container logs + ps Show running containers + config Validate and show compose config + remove Remove stopped containers (docker compose rm) + help Show this help + +Environment variables from .env are automatically loaded, including: + PY_TGT Restrict the build to one Python version, e.g. py-311 + UNIQUE_ID Recalculated automatically on every 'up', don't set by hand + BLENDER_USER_RESOURCE Where 'try' copies the wrapper for manual testing, e.g. + ~/.config/blender/bonsai/ +EOF +} + +# ================= MAIN ================= + +case "$1" in + create) create ;; + update) update ;; + up) up "${@:2}" ;; + down) down "${@:2}" ;; + stop) stop "${@:2}" ;; + start) start "${@:2}" ;; + restart) restart ;; + recreate) recreate ;; + build) build "${@:2}" ;; + attach) attach ;; + try) try ;; + clean) clean ;; + logs) logs "${@:2}" ;; + ps) ps ;; + config) config ;; + remove) remove ;; + help|-h|--help) help ;; + "") + echo "❌ No command provided." + help + ;; + *) + echo "❌ Unknown command: $1" + echo "Type './$SCRIPT_NAME help' for available commands." + exit 1 + ;; +esac