diff --git a/AGENT.md b/AGENT.md index 164aa4c5..538814be 100644 --- a/AGENT.md +++ b/AGENT.md @@ -35,7 +35,13 @@ BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks h Why this is written down: changing anything under `src/cpp` needs a rebuilt `_native` module, and the obvious command failed three times before it worked. Each failure had a cause that is not visible from the error text. -- Use the project's own path, `uv pip install --python .venv/Scripts/python.exe -e . --no-deps -v`. It reuses +- UPDATE (2026-11-10, later): none of the flags below are needed any more. `pyproject.toml` builds with + `tools/build_backend/scad_build.py`, which on Windows picks Ninja + the gcc on PATH when Visual Studio is absent, finds + OCCT (`SCAD_OCCT_PREFIX`, `C:/occt`, or `%LOCALAPPDATA%/simplecadapi/occt-`, building it there once if missing) and + limits the job count by RAM. A bare `uv sync` / `uv run` works; anything you set yourself (CMAKE_GENERATOR, CC, CXX, + CMAKE_ARGS, CMAKE_BUILD_PARALLEL_LEVEL) still wins. `[tool.uv] cache-keys` makes uv rebuild only when `src/cpp` changes, + not on a version bump. What remains true: a running editor locks the installed `.pyd` (rename it aside, see below). +- (Older note) Use the project's own path, `uv pip install --python .venv/Scripts/python.exe -e . --no-deps -v`. It reuses `src/cpp/build/wheel//` (incremental when the compiler flags did not change). Plain `ninja _native` in that directory fails: its CMakeCache points into a temporary isolated build environment (ninja, pybind11, python) that was deleted when the last build ended, so CMake cannot regenerate. diff --git a/pyproject.toml b/pyproject.toml index a4b6eff0..f8bdcd35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,10 @@ Issues = "https://github.com/PhySpace/SimpleCADAPI/issues" [build-system] # One distribution: the Python package and the compiled C++ module (``simplecadapi._cpp``, src/cpp/) it runs on. requires = ["scikit-build-core>=0.10", "pybind11>=2.13", "ninja>=1.11; platform_system == 'Windows'"] -build-backend = "scikit_build_core.build" +# scad_build (tools/build_backend) is scikit-build-core plus toolchain/OpenCASCADE detection on Windows, so that a plain +# `uv sync` builds without hand-set CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS. +build-backend = "scad_build" +backend-path = ["tools/build_backend"] [tool.scikit-build] minimum-version = "build-system.requires" @@ -92,6 +95,7 @@ build-dir = "src/cpp/build/wheel/{wheel_tag}" wheel.packages = ["src/simplecadapi"] wheel.exclude = ["**/__pycache__/**", "**/*.pyc"] sdist.include = [ + "/tools/build_backend", "/src/simplecadapi/editor/webui", "/skillproj.toml", "/docs/skill", @@ -162,6 +166,18 @@ dev = [ "ty>=0.0.18", ] +[tool.uv] +# `uv run` / `uv sync` rebuild the editable install (the C++ module, minutes of compiling, and a C++ toolchain + OCCT on the +# machine) whenever a cache key changed. uv's default key is pyproject.toml, so a version bump or a dependency edit forced +# a rebuild nobody needed. Rebuild only when the C++ module's inputs change. +cache-keys = [ + { file = "src/cpp/CMakeLists.txt" }, + { file = "src/cpp/cmake/**" }, + { file = "src/cpp/include/**" }, + { file = "src/cpp/src/**" }, + { file = "src/cpp/bindings/**" }, +] + [tool.ty.environment] python-version = "3.10" diff --git a/tools/build_backend/scad_build.py b/tools/build_backend/scad_build.py new file mode 100644 index 00000000..874adf8b --- /dev/null +++ b/tools/build_backend/scad_build.py @@ -0,0 +1,129 @@ +"""PEP 517 build backend: scikit-build-core, plus the toolchain/OCCT detection a plain ``uv sync`` needs. + +Why this exists: the package contains a C++ module (src/cpp) that needs a compiler, a build generator and an OpenCASCADE +install. scikit-build-core leaves all three to CMake's defaults, and on a Windows machine without Visual Studio the default +is the NMake generator, which then fails ("'nmake' -? failed"), and nothing tells CMake where OCCT is. Contributors had to +export CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS by hand. This wrapper does that detection itself and then delegates every hook +to scikit-build-core. Anything the user already set (CMAKE_GENERATOR, CC, CXX, SCAD_OCCT_PREFIX, CMAKE_ARGS with a generator +or an OCCT prefix, CMAKE_BUILD_PARALLEL_LEVEL) is left alone, so CI (cibuildwheel) and Linux/macOS builds behave as before. + +Detection runs only on Windows. Linux and macOS use the system OCCT (or CI's) through CMake's own search. +""" +from __future__ import annotations + +import ctypes +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from scikit_build_core import build as _skbuild + +# Everything scikit-build-core exposes (get_requires_for_*, prepare_metadata_*, build_sdist, ...) passes straight through. +from scikit_build_core.build import * # noqa: F401,F403 + +REPO = Path(__file__).resolve().parents[2] +OCCT_TAG = "V7_9_3" # tools/build_occt.py's DEFAULT_TAG: the kernel generation the SDK ships + + +def _log(msg: str) -> None: + print(f"[scad_build] {msg}", file=sys.stderr, flush=True) + + +def _has_msvc() -> bool: + """True when Visual Studio's C++ toolset is installed (CMake's Visual Studio generator finds it by itself).""" + if shutil.which("cl"): + return True + pf = os.environ.get("ProgramFiles(x86)") + vswhere = Path(pf, "Microsoft Visual Studio", "Installer", "vswhere.exe") if pf else None + if vswhere and vswhere.is_file(): + out = subprocess.run( + [str(vswhere), "-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], + capture_output=True, text=True, check=False, + ).stdout.strip() + return bool(out) + return False + + +def _total_memory_gb() -> float: + class _Status(ctypes.Structure): + _fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong), ("total_phys", ctypes.c_ulonglong), + ("avail_phys", ctypes.c_ulonglong), ("total_page", ctypes.c_ulonglong), + ("avail_page", ctypes.c_ulonglong), ("total_virt", ctypes.c_ulonglong), + ("avail_virt", ctypes.c_ulonglong), ("avail_ext", ctypes.c_ulonglong)] + + status = _Status() + status.length = ctypes.sizeof(_Status) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined] + return status.total_phys / 2**30 + + +def _find_occt() -> Path | None: + """An OCCT install prefix (the directory holding lib/cmake/opencascade), looked up in the usual places.""" + candidates = [os.environ.get("SCAD_OCCT_PREFIX"), "C:/occt", str(_occt_cache_dir())] + for c in candidates: + if c and (Path(c) / "lib" / "cmake" / "opencascade").is_dir(): + return Path(c) + return None + + +def _occt_cache_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / "simplecadapi" / f"occt-{OCCT_TAG}" + + +def _build_occt(prefix: Path) -> None: + _log(f"no OpenCASCADE found; building {OCCT_TAG} into {prefix} (one time, 20-40 minutes, cached for every later build)") + subprocess.run([sys.executable, str(REPO / "src" / "cpp" / "tools" / "build_occt.py"), "--prefix", str(prefix), "--tag", OCCT_TAG], check=True) + + +def _prepare_windows_environment() -> None: + env = os.environ + cmake_args = env.get("CMAKE_ARGS", "") + + # 1. generator + compiler + if not env.get("CMAKE_GENERATOR") and "-G" not in cmake_args and not _has_msvc(): + gxx, gcc, ninja = shutil.which("g++"), shutil.which("gcc"), shutil.which("ninja") + if not (gxx and gcc): + raise RuntimeError( + "Building simplecadapi needs a C++ compiler on Windows: install Visual Studio Build Tools (C++ workload) " + "or a MinGW-w64 gcc (e.g. `scoop install gcc`) and re-run." + ) + # ninja is injected by scikit-build-core from the build requirements when the generator is Ninja + env["CMAKE_GENERATOR"] = "Ninja" + env.setdefault("CC", gcc) + env.setdefault("CXX", gxx) + _log(f"Visual Studio not found; using Ninja + {gxx}" + ("" if ninja else " (ninja from the build requirements)")) + + # 2. OpenCASCADE + if "SCAD_OCCT_PREFIX" not in cmake_args: + prefix = _find_occt() + if prefix is None: + prefix = _occt_cache_dir() + _build_occt(prefix) + # OpenCASCADE_DIR too: CMake's package registry may otherwise offer a stale OCCT left in %TEMP% by an old build + cmake_args += f" -DSCAD_OCCT_PREFIX={prefix.as_posix()} -DOpenCASCADE_DIR={(prefix / 'lib' / 'cmake' / 'opencascade').as_posix()}" + env["CMAKE_ARGS"] = cmake_args.strip() + _log(f"OpenCASCADE: {prefix}") + + # 3. parallelism: one g++ translating OCCT-heavy bindings needs ~2 GB, so a many-core machine with little RAM ran out of + # memory ("cc1plus: out of memory") under ninja's default of cpu_count + 2 jobs + if not env.get("CMAKE_BUILD_PARALLEL_LEVEL"): + jobs = max(1, min(os.cpu_count() or 2, int(_total_memory_gb() // 2.5))) + env["CMAKE_BUILD_PARALLEL_LEVEL"] = str(jobs) + + +def _prepare() -> None: + if sys.platform == "win32": + _prepare_windows_environment() + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_editable(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_editable(wheel_directory, config_settings, metadata_directory)