peclet-geom 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ name: CI
2
+
3
+ # The gate that proves the split did what it claims: peclet-geom must build and pass WITHOUT an MPI
4
+ # toolchain and WITHOUT Kokkos on the runner. If either ever creeps back into the dependency
5
+ # closure, this job is where it shows up — a plain ubuntu-latest image has neither.
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ no-mpi-no-kokkos:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ # Stated, not assumed: fail loudly if the image turns out to carry MPI, because then this job
23
+ # would prove nothing about the property it exists to protect.
24
+ - name: The runner has no MPI and no Kokkos
25
+ run: |
26
+ if command -v mpicxx >/dev/null || dpkg -l | grep -q libopenmpi-dev; then
27
+ echo "::error::this runner HAS an MPI toolchain — the no-MPI gate is vacuous here"; exit 1
28
+ fi
29
+ echo "no mpicxx, no libopenmpi-dev — the gate is meaningful"
30
+
31
+ - run: pip install numpy pytest nanobind scikit-build-core cmake
32
+ - name: Build and install (vendored core headers, no siblings present)
33
+ run: PECLET_VENDOR_DEPS=ON pip install -v .
34
+
35
+ - name: Import and exercise SceneBuilder
36
+ run: |
37
+ python - <<'PY'
38
+ from peclet import geom
39
+ s = geom.SceneBuilder()
40
+ for n in ("add_leaf", "add_union", "add_difference", "add_intersection",
41
+ "principal_frame", "body_properties", "bake", "encode",
42
+ "eval", "eval_root", "eval_root_grad"):
43
+ assert hasattr(s, n), n
44
+ print("peclet.geom OK:", geom.build_toolchain)
45
+ PY
46
+
47
+ # The byte gate. Its reference values are byte-identical to what the RELEASED peclet-core
48
+ # 1.0.2 produced for peclet.core.geom before the split, so a changed digit means the move was
49
+ # not a move.
50
+ #
51
+ # It is TOOLCHAIN-SPECIFIC and exits 77 ("skipped") against a reference recorded elsewhere —
52
+ # floating-point results are not comparable across compilers. The reference is recorded on the
53
+ # author's toolchain, so on a GitHub runner this normally SKIPS, and 77 must not read as a
54
+ # pass or as a failure. Core expresses the same thing through ctest's SKIP_RETURN_CODE 77;
55
+ # here it has to be spelled out, because a bare step treats 77 as failure.
56
+ - name: State-hash byte gate (skips across toolchains, by design)
57
+ run: |
58
+ set +e
59
+ OMP_NUM_THREADS=1 python python/state_hash.py --check python/state_hash_reference.json
60
+ rc=$?
61
+ set -e
62
+ case $rc in
63
+ 0) echo "::notice::byte gate RAN and matched — same toolchain as the reference" ;;
64
+ 77) echo "::notice::byte gate SKIPPED: this runner's toolchain differs from the reference's."
65
+ echo "::notice::That is expected here. The gate is enforced locally before a release." ;;
66
+ *) echo "::error::byte gate FAILED (exit $rc) — a state hash changed"; exit $rc ;;
67
+ esac
@@ -0,0 +1,70 @@
1
+ name: Release
2
+
3
+ # Publish peclet-geom to PyPI on a version tag via Trusted Publishing (OIDC).
4
+ #
5
+ # WHEELS, unlike its former housemate. peclet.geom links no MPI and no Kokkos — its closure is six
6
+ # peclet/core/geom/*.hpp headers plus common/{types,portable}.hpp — so a portable binary wheel IS
7
+ # possible, which is the entire point of the split (suite/docs/CORE_BOUNDARY.md). While this code
8
+ # lived in peclet-core it inherited that package's MPI build requirement and could only ship an
9
+ # sdist, which is what made it unobtainable on Colab.
10
+ #
11
+ # The core headers are vendored at PECLET_CORE_TAG (cmake/PecletDeps.cmake) by PECLET_VENDOR_DEPS=ON,
12
+ # exactly as peclet-amr does: the suite siblings are absent on a runner.
13
+
14
+ on:
15
+ push:
16
+ tags: ["v*"]
17
+ workflow_dispatch:
18
+
19
+ jobs:
20
+ wheels:
21
+ name: wheels · ${{ matrix.os }}
22
+ runs-on: ${{ matrix.os }}
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-14]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: pypa/cibuildwheel@v3.2.0
30
+ env:
31
+ CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*"
32
+ CIBW_SKIP: "*-musllinux_*"
33
+ CIBW_ARCHS_MACOS: arm64
34
+ # No suite checkout on a runner: fetch the core headers at the pinned tag.
35
+ CIBW_ENVIRONMENT: "PECLET_VENDOR_DEPS=ON"
36
+ CIBW_BEFORE_ALL_LINUX: "cmake --version || true"
37
+ # A wheel that imports is the only wheel worth publishing.
38
+ CIBW_TEST_COMMAND: 'python -c "from peclet import geom; s = geom.SceneBuilder(); print(geom.build_toolchain)"'
39
+ - uses: actions/upload-artifact@v7
40
+ with:
41
+ name: wheels-${{ matrix.os }}
42
+ path: wheelhouse/*.whl
43
+
44
+ sdist:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: actions/setup-python@v6
49
+ with:
50
+ python-version: "3.12"
51
+ - run: pip install build
52
+ - run: python -m build --sdist
53
+ - uses: actions/upload-artifact@v7
54
+ with:
55
+ name: sdist
56
+ path: dist/*.tar.gz
57
+
58
+ publish:
59
+ needs: [wheels, sdist]
60
+ runs-on: ubuntu-latest
61
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
62
+ environment: pypi
63
+ permissions:
64
+ id-token: write # required for trusted publishing
65
+ steps:
66
+ - uses: actions/download-artifact@v8
67
+ with:
68
+ path: dist
69
+ merge-multiple: true
70
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ build/
2
+ build_*/
3
+ *.so
4
+ __pycache__/
5
+ *.egg-info/
6
+ dist/
@@ -0,0 +1,31 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it using the metadata below."
3
+ title: "peclet-geom: analytic-SDF scene authoring for the peclet suite"
4
+ abstract: >-
5
+ Header-only C++20 + MPI infrastructure shared across the peclet suite: ORB block domain decomposition,
6
+ asynchronous ghost-layer exchange (NBX + persistent neighborhood-collective engines, GPU-resident
7
+ variant), Lagrangian particle migration and ghosts, SDF geometry with VTI I/O, an AMR octree, and
8
+ dynamic (weighted-ORB) load balancing. Optional Kokkos GPU backends and Python bindings.
9
+ type: software
10
+ authors:
11
+ - family-names: Peters
12
+ given-names: "E.A.J.F."
13
+ alias: Frank
14
+ orcid: "https://orcid.org/0000-0001-6099-3583"
15
+ email: e.a.j.f.peters@tue.nl
16
+ affiliation: "Eindhoven University of Technology"
17
+ version: 1.0.0
18
+ date-released: 2026-09-21
19
+ license: MIT
20
+ # No Zenodo record yet — peclet-geom is new at 1.0.0 (split from peclet-core,
21
+ # suite/docs/CORE_BOUNDARY.md). Add the concept DOI here once the record exists; do NOT
22
+ # inherit peclet-core's, which belongs to a different artifact.
23
+ repository-code: "https://github.com/computational-chemical-engineering/peclet-geom"
24
+ keywords:
25
+ - signed distance field
26
+ - constructive solid geometry
27
+ - computational geometry
28
+ - rigid body dynamics
29
+ - inertia tensor
30
+ - scene authoring
31
+ - transport phenomena
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Peters
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: peclet-geom
3
+ Version: 1.0.0
4
+ Summary: peclet.geom — analytic-SDF scene authoring: CSG trees, batch evaluation, lattice baking, rigid-body mass properties
5
+ Keywords: signed distance field,SDF,CSG,constructive solid geometry,computational geometry,rigid body,inertia tensor,mesh-free,transport phenomena
6
+ Author-Email: "E.A.J.F. Peters" <e.a.j.f.peters@tue.nl>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Programming Language :: C++
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
21
+ Classifier: Topic :: Scientific/Engineering :: Physics
22
+ Project-URL: Homepage, https://github.com/computational-chemical-engineering/peclet
23
+ Project-URL: Documentation, https://computational-chemical-engineering.github.io/peclet/
24
+ Project-URL: Source, https://github.com/computational-chemical-engineering/peclet-geom
25
+ Project-URL: Examples gallery, https://computational-chemical-engineering.github.io/peclet-examples/
26
+ Project-URL: Issues, https://github.com/computational-chemical-engineering/peclet-geom/issues
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: numpy>=1.20
29
+ Description-Content-Type: text/markdown
30
+
31
+ # peclet-geom
32
+
33
+ `peclet.geom` — analytic-SDF scene authoring for the [peclet](https://github.com/computational-chemical-engineering/peclet)
34
+ suite: CSG trees over signed-distance primitives, batch evaluation, lattice baking, and rigid-body
35
+ mass properties (mass, centre of mass, the full inertia tensor, and the principal frame as three
36
+ moments plus a quaternion).
37
+
38
+ ```bash
39
+ pip install peclet-geom # or just `pip install peclet` — it is part of the family
40
+ ```
41
+
42
+ ```python
43
+ from peclet import geom
44
+
45
+ s = geom.SceneBuilder()
46
+ head = s.add_leaf("torus", ...)
47
+ handle = s.add_leaf("capsule", ...)
48
+ body = s.add_union([head, handle])
49
+ m, com, inertia, quat = s.body_properties(body, lo, hi)
50
+ racket = s.principal_frame(body, lo, hi) # re-expressed exactly, no resampling
51
+ ```
52
+
53
+ ## Why this is its own package
54
+
55
+ It is **host-only**: no MPI, no Kokkos, no GPU — its closure is six `peclet/core/geom/*.hpp`
56
+ headers plus two common ones, and it depends on nothing but numpy at runtime. That is what lets it
57
+ ship as wheels and be part of a plain `pip install peclet`.
58
+
59
+ Until peclet 1.2.0 this code was `peclet.core.geom` inside the `peclet-core` distribution, whose
60
+ build requires an MPI toolchain because it also builds the halo bindings. A pure-geometry API was
61
+ therefore unobtainable without MPI — which broke eleven gallery pages and `peclet.dem`'s
62
+ `scene_particle.build()`. The boundary rule and the migration ladder are in
63
+ [suite/docs/CORE_BOUNDARY.md](https://github.com/computational-chemical-engineering/peclet/blob/main/docs/CORE_BOUNDARY.md).
64
+
65
+ `peclet.core.geom` still works and is the *same object* — `peclet.core.geom.SceneBuilder is
66
+ peclet.geom.SceneBuilder` — it gains a `DeprecationWarning` in 1.3.0 and is removed in 2.0.0.
67
+
68
+ ## Build from source
69
+
70
+ ```bash
71
+ cmake -S python -B python/build -DCMAKE_BUILD_TYPE=Release
72
+ cmake --build python/build -j
73
+ PYTHONPATH=python/build python -c "from peclet import geom; print(geom.SceneBuilder())"
74
+ ```
75
+
76
+ The core headers come from a sibling `../core` checkout if present, else are fetched at
77
+ `PECLET_CORE_TAG` (`cmake/PecletDeps.cmake`). Point at a core worktree with
78
+ `-DPECLET_SIBLING_PECLET_CORE=/path/to/core-worktree`.
79
+
80
+ ## Faithfulness gate
81
+
82
+ `python/state_hash.py` hashes every public entry path. The recorded reference is byte-identical to
83
+ what `peclet-core` 1.0.2 produced before the split — a changed digit is a bug in the move:
84
+
85
+ ```bash
86
+ OMP_NUM_THREADS=1 PYTHONPATH=python/build python python/state_hash.py --check python/state_hash_reference.json
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,61 @@
1
+ # peclet-geom
2
+
3
+ `peclet.geom` — analytic-SDF scene authoring for the [peclet](https://github.com/computational-chemical-engineering/peclet)
4
+ suite: CSG trees over signed-distance primitives, batch evaluation, lattice baking, and rigid-body
5
+ mass properties (mass, centre of mass, the full inertia tensor, and the principal frame as three
6
+ moments plus a quaternion).
7
+
8
+ ```bash
9
+ pip install peclet-geom # or just `pip install peclet` — it is part of the family
10
+ ```
11
+
12
+ ```python
13
+ from peclet import geom
14
+
15
+ s = geom.SceneBuilder()
16
+ head = s.add_leaf("torus", ...)
17
+ handle = s.add_leaf("capsule", ...)
18
+ body = s.add_union([head, handle])
19
+ m, com, inertia, quat = s.body_properties(body, lo, hi)
20
+ racket = s.principal_frame(body, lo, hi) # re-expressed exactly, no resampling
21
+ ```
22
+
23
+ ## Why this is its own package
24
+
25
+ It is **host-only**: no MPI, no Kokkos, no GPU — its closure is six `peclet/core/geom/*.hpp`
26
+ headers plus two common ones, and it depends on nothing but numpy at runtime. That is what lets it
27
+ ship as wheels and be part of a plain `pip install peclet`.
28
+
29
+ Until peclet 1.2.0 this code was `peclet.core.geom` inside the `peclet-core` distribution, whose
30
+ build requires an MPI toolchain because it also builds the halo bindings. A pure-geometry API was
31
+ therefore unobtainable without MPI — which broke eleven gallery pages and `peclet.dem`'s
32
+ `scene_particle.build()`. The boundary rule and the migration ladder are in
33
+ [suite/docs/CORE_BOUNDARY.md](https://github.com/computational-chemical-engineering/peclet/blob/main/docs/CORE_BOUNDARY.md).
34
+
35
+ `peclet.core.geom` still works and is the *same object* — `peclet.core.geom.SceneBuilder is
36
+ peclet.geom.SceneBuilder` — it gains a `DeprecationWarning` in 1.3.0 and is removed in 2.0.0.
37
+
38
+ ## Build from source
39
+
40
+ ```bash
41
+ cmake -S python -B python/build -DCMAKE_BUILD_TYPE=Release
42
+ cmake --build python/build -j
43
+ PYTHONPATH=python/build python -c "from peclet import geom; print(geom.SceneBuilder())"
44
+ ```
45
+
46
+ The core headers come from a sibling `../core` checkout if present, else are fetched at
47
+ `PECLET_CORE_TAG` (`cmake/PecletDeps.cmake`). Point at a core worktree with
48
+ `-DPECLET_SIBLING_PECLET_CORE=/path/to/core-worktree`.
49
+
50
+ ## Faithfulness gate
51
+
52
+ `python/state_hash.py` hashes every public entry path. The recorded reference is byte-identical to
53
+ what `peclet-core` 1.0.2 produced before the split — a changed digit is a bug in the move:
54
+
55
+ ```bash
56
+ OMP_NUM_THREADS=1 PYTHONPATH=python/build python python/state_hash.py --check python/state_hash_reference.json
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,50 @@
1
+ # PecletDeps.cmake — dependency provisioning for peclet-geom.
2
+ #
3
+ # peclet-geom has exactly ONE dependency: the header-only peclet-core geom headers. No Kokkos, no
4
+ # MPI, no morton (suite/docs/CORE_BOUNDARY.md §1.2) — which is what lets this package ship wheels
5
+ # while peclet-halo stays an sdist.
6
+ #
7
+ # Two ways to build, selected automatically:
8
+ # * DEV / suite build — the sibling ../../core/include is used directly.
9
+ # * SELF-CONTAINED sdist (pip install peclet-geom) — the siblings are ABSENT, so the core headers
10
+ # are FetchContent-fetched at PECLET_CORE_TAG below.
11
+ # Force the fetch with -DPECLET_VENDOR_DEPS=ON. Keep PECLET_CORE_TAG in lockstep with the other
12
+ # packages' PecletDeps.cmake; the release pre-flight (tools/release/check_release_state.sh) checks it.
13
+ #
14
+ # The PECLET_SIBLING_PECLET_CORE override below is how concurrent work stays out of each other's
15
+ # way: point a build at a core WORKTREE without touching the shared ../../core checkout.
16
+ include_guard(GLOBAL)
17
+ include(FetchContent)
18
+
19
+ set(PECLET_CORE_TAG "v1.0.2" CACHE STRING "Vendored core git tag (headers)")
20
+ option(PECLET_VENDOR_DEPS "Force FetchContent-fetch of the core headers (self-contained sdist)" OFF)
21
+
22
+ function(peclet_sibling_include repo tag sibling_reldir outvar)
23
+ # Dev override for a sibling checked out somewhere else than `../<repo>` -- in particular a git
24
+ # WORKTREE, which is how the suite runs concurrent agents (`git worktree add ../core-w0`). Pass
25
+ # the repo ROOT (`/include` is appended):
26
+ # -DPECLET_SIBLING_PECLET_CORE=/path/to/suite/core-w0
27
+ # Unset (the default) it resolves exactly as before, so every existing build is unchanged.
28
+ string(TOUPPER "${repo}" _ovr)
29
+ string(REPLACE "-" "_" _ovr "${_ovr}")
30
+ if(PECLET_SIBLING_${_ovr})
31
+ set(${outvar} "${PECLET_SIBLING_${_ovr}}/include" PARENT_SCOPE)
32
+ message(STATUS "[peclet] ${repo} headers from PECLET_SIBLING_${_ovr} -> ${PECLET_SIBLING_${_ovr}}/include")
33
+ return()
34
+ endif()
35
+ set(_local "${CMAKE_CURRENT_SOURCE_DIR}/${sibling_reldir}/include")
36
+ if(EXISTS "${_local}" AND NOT PECLET_VENDOR_DEPS)
37
+ set(${outvar} "${_local}" PARENT_SCOPE)
38
+ return()
39
+ endif()
40
+ string(TOLOWER "peclet_sib_${repo}" _name)
41
+ FetchContent_Declare(${_name}
42
+ GIT_REPOSITORY "https://github.com/computational-chemical-engineering/${repo}.git"
43
+ GIT_TAG ${tag} GIT_SHALLOW TRUE)
44
+ FetchContent_GetProperties(${_name})
45
+ if(NOT ${_name}_POPULATED)
46
+ FetchContent_Populate(${_name})
47
+ endif()
48
+ set(${outvar} "${${_name}_SOURCE_DIR}/include" PARENT_SCOPE)
49
+ message(STATUS "[peclet] vendored ${repo} headers -> ${${_name}_SOURCE_DIR}/include")
50
+ endfunction()
@@ -0,0 +1,51 @@
1
+ # SuiteNanobind.cmake — shared nanobind provisioning for the peclet suite.
2
+ #
3
+ # Policy: the suite's Python bindings use nanobind (replacing pybind11), built through
4
+ # scikit-build-core for wheels and through plain CMake for the developer `cmake -S . -B build`
5
+ # workflow. nanobind ships a CMake config inside its Python package; we locate it the same way
6
+ # scikit-build-core does — by asking the active interpreter — so a single mechanism covers both
7
+ # `pip install .` and a venv-driven dev build.
8
+ #
9
+ # Usage in a consumer CMakeLists:
10
+ # include(${SUITE_CMAKE_DIR}/SuiteNanobind.cmake) # or list-append to CMAKE_MODULE_PATH
11
+ # suite_require_nanobind()
12
+ # nanobind_add_module(mymod NB_STATIC src/bindings.cpp)
13
+ # target_link_libraries(mymod PRIVATE Kokkos::kokkos)
14
+ #
15
+ # The Kokkos View <-> nanobind ndarray zero-copy bridge consumed by every binding lives in
16
+ # core/include/peclet/core/python/ndarray_interop.hpp (peclet-core).
17
+
18
+ include_guard(GLOBAL)
19
+
20
+ # A macro (not a function): find_package(Python) sets plain variables like Python_INCLUDE_DIRS that
21
+ # nanobind reads at module-creation time. A function would scope those locally, so nanobind-static
22
+ # would later be compiled at directory scope without the Python headers. A macro runs in the
23
+ # caller's scope, so the variables are visible where nanobind_add_module() is invoked.
24
+ macro(suite_require_nanobind)
25
+ if(NOT COMMAND nanobind_add_module)
26
+ # Need the Development.Module component so nanobind can build extension modules. Honor an already
27
+ # chosen interpreter (scikit-build-core / an activated venv set Python_EXECUTABLE).
28
+ find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module)
29
+
30
+ # Ask the interpreter where nanobind's CMake config lives (works for both pip-installed and
31
+ # venv-resident nanobind, and matches how scikit-build-core resolves it).
32
+ if(NOT nanobind_DIR)
33
+ execute_process(
34
+ COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
35
+ OUTPUT_VARIABLE _suite_nb_cmake_dir
36
+ OUTPUT_STRIP_TRAILING_WHITESPACE
37
+ RESULT_VARIABLE _suite_nb_result)
38
+ if(NOT _suite_nb_result EQUAL 0 OR NOT EXISTS "${_suite_nb_cmake_dir}")
39
+ message(FATAL_ERROR
40
+ "[suite] nanobind not found via '${Python_EXECUTABLE} -m nanobind --cmake_dir'. "
41
+ "Install it into the active environment:\n"
42
+ " pip install nanobind\n"
43
+ "(scikit-build-core adds it automatically when building a wheel via pyproject.toml).")
44
+ endif()
45
+ set(nanobind_DIR "${_suite_nb_cmake_dir}")
46
+ endif()
47
+
48
+ find_package(nanobind CONFIG REQUIRED)
49
+ message(STATUS "[suite] Using nanobind from ${nanobind_DIR}")
50
+ endif()
51
+ endmacro()
@@ -0,0 +1,67 @@
1
+ # peclet-geom — `peclet.geom`, analytic-SDF scene authoring.
2
+ #
3
+ # The family's first HOST-ONLY wheel. It exists because this code was unobtainable without an MPI
4
+ # toolchain while it shared the `peclet-core` distribution with the halo bindings: the sdist builds
5
+ # core/python/CMakeLists.txt, whose `find_package(MPI REQUIRED)` covered both targets even though
6
+ # geom links no MPI. That cost eleven gallery pages and peclet.dem's scene_particle.build().
7
+ # See suite/docs/CORE_BOUNDARY.md for the boundary rule and the migration ladder.
8
+ #
9
+ # Depends on nothing but numpy at runtime, and on the header-only peclet-core geom headers at build
10
+ # time (cmake/PecletDeps.cmake, PECLET_CORE_TAG). No Kokkos, no MPI, no morton.
11
+
12
+ [build-system]
13
+ requires = ["scikit-build-core>=0.8", "nanobind>=2.0"]
14
+ build-backend = "scikit_build_core.build"
15
+
16
+ [project]
17
+ name = "peclet-geom"
18
+ version = "1.0.0"
19
+ description = "peclet.geom — analytic-SDF scene authoring: CSG trees, batch evaluation, lattice baking, rigid-body mass properties"
20
+ readme = "README.md"
21
+ requires-python = ">=3.10"
22
+ license = "MIT"
23
+ license-files = ["LICENSE"]
24
+ authors = [{ name = "E.A.J.F. Peters", email = "e.a.j.f.peters@tue.nl" }]
25
+ keywords = ["signed distance field", "SDF", "CSG", "constructive solid geometry", "computational geometry", "rigid body", "inertia tensor", "mesh-free", "transport phenomena"]
26
+ classifiers = [
27
+ "Development Status :: 5 - Production/Stable",
28
+ "Intended Audience :: Science/Research",
29
+ "Intended Audience :: Education",
30
+ "Programming Language :: C++",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
37
+ "Topic :: Scientific/Engineering",
38
+ "Topic :: Scientific/Engineering :: Mathematics",
39
+ "Topic :: Scientific/Engineering :: Physics",
40
+ ]
41
+
42
+ dependencies = ["numpy>=1.20"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/computational-chemical-engineering/peclet"
46
+ Documentation = "https://computational-chemical-engineering.github.io/peclet/"
47
+ Source = "https://github.com/computational-chemical-engineering/peclet-geom"
48
+ "Examples gallery" = "https://computational-chemical-engineering.github.io/peclet-examples/"
49
+ Issues = "https://github.com/computational-chemical-engineering/peclet-geom/issues"
50
+
51
+ [tool.scikit-build]
52
+ minimum-version = "0.8"
53
+ cmake.version = ">=3.24"
54
+ # The module lives in python/ (a standalone project); its install rules place peclet/geom/.
55
+ cmake.source-dir = "python"
56
+ wheel.install-dir = "."
57
+ # The wheel's contents come entirely from the CMake install() rules; no auto-discovery.
58
+ wheel.packages = []
59
+ # Self-contained sdist: cmake/ carries the vendored SuiteNanobind and PecletDeps so `pip install
60
+ # peclet-geom` builds with no suite checkout present (the failure core hit from 0.1.0 to 0.6.0).
61
+ # NO sdist.include here, deliberately. scikit-build-core takes the sdist from the GIT file list by
62
+ # default, which already excludes build trees via .gitignore — whereas `sdist.include = ["python"]`
63
+ # FORCE-includes, overriding .gitignore, and a local `cmake -B python/build` then rides along:
64
+ # measured 476 KB of CMakeCache and objects carrying absolute paths from the author's machine.
65
+ # docs/RELEASE.md §A3 records this trap for the submodule sdists; the force-include is how it bites.
66
+ # Everything this package needs (python/, cmake/, README, LICENSE, CITATION.cff) is tracked.
67
+ sdist.exclude = ["python/build", "python/build_*", "**/__pycache__", "**/*.so", "dist"]
@@ -0,0 +1,52 @@
1
+ # Python bindings for the analytic-SDF scene authoring layer — `peclet.geom`.
2
+ #
3
+ # THE POINT OF THIS FILE IS WHAT IT DOES NOT DO. It calls no find_package(MPI) and no
4
+ # find_package(Kokkos), because geom_bindings.cpp needs neither: its closure is six
5
+ # peclet/core/geom/*.hpp headers plus common/{types,portable}.hpp, none of which include
6
+ # peclet/core/common/mpi.hpp (suite/docs/CORE_BOUNDARY.md §2.1 makes that the boundary test).
7
+ #
8
+ # That is the defect this package exists to fix. While these bindings shared a distribution with
9
+ # the halo bindings, core/python/CMakeLists.txt's `find_package(MPI REQUIRED)` covered both, so
10
+ # `pip install` of a pure-geometry API needed an MPI toolchain — and eleven gallery pages plus
11
+ # peclet.dem's scene_particle.build() were unobtainable without one.
12
+ #
13
+ # cmake -S python -B python/build && cmake --build python/build -j
14
+ # PYTHONPATH=python/build python -c "from peclet.geom import SceneBuilder"
15
+ cmake_minimum_required(VERSION 3.24)
16
+ project(peclet_geom_python LANGUAGES CXX)
17
+
18
+ set(CMAKE_CXX_STANDARD 20)
19
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
20
+
21
+ # SuiteNanobind: the umbrella's copy in a suite checkout, the vendored copy otherwise. Same
22
+ # arrangement as core, for the same reason (DECISIONS.md: the sdist must be self-contained).
23
+ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake" "${CMAKE_CURRENT_SOURCE_DIR}/../cmake")
24
+ include(SuiteNanobind)
25
+ suite_require_nanobind()
26
+
27
+ # The core headers, from the sibling checkout or vendored at PECLET_CORE_TAG (cmake/PecletDeps.cmake).
28
+ include(${CMAKE_CURRENT_SOURCE_DIR}/../cmake/PecletDeps.cmake)
29
+ peclet_sibling_include(peclet-core "${PECLET_CORE_TAG}" "../../core" PECLET_CORE_INCLUDE)
30
+
31
+ nanobind_add_module(geom_bindings NB_STATIC geom_bindings.cpp)
32
+ set_target_properties(geom_bindings PROPERTIES OUTPUT_NAME _geom
33
+ LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/peclet/geom")
34
+ target_include_directories(geom_bindings PRIVATE ${PECLET_CORE_INCLUDE})
35
+
36
+ # state_hash.py refuses to compare hashes across toolchains, so the module must SAY which one built
37
+ # it — otherwise --check skips (exit 77) and the byte gate silently proves nothing.
38
+ target_compile_definitions(geom_bindings PRIVATE
39
+ "PECLET_GEOM_BUILD_TOOLCHAIN=\"${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION} ${CMAKE_BUILD_TYPE} ${CMAKE_SYSTEM_PROCESSOR}\"")
40
+
41
+ # The importable package: peclet/geom/{__init__.py, _geom<abi>.so, _geom.pyi, py.typed}. `peclet`
42
+ # itself is a PEP-420 namespace owned by the member packages — it gets no __init__.py here.
43
+ set(_pkg "${CMAKE_BINARY_DIR}/peclet/geom")
44
+ file(MAKE_DIRECTORY "${_pkg}")
45
+ configure_file(packaging/geom_init.py "${_pkg}/__init__.py" COPYONLY)
46
+ configure_file(packaging/_geom.pyi "${_pkg}/_geom.pyi" COPYONLY)
47
+ file(TOUCH "${_pkg}/py.typed")
48
+
49
+ install(TARGETS geom_bindings LIBRARY DESTINATION peclet/geom)
50
+ install(FILES packaging/geom_init.py DESTINATION peclet/geom RENAME __init__.py)
51
+ install(FILES packaging/_geom.pyi DESTINATION peclet/geom)
52
+ install(FILES "${_pkg}/py.typed" DESTINATION peclet/geom)
@@ -0,0 +1,362 @@
1
+ // peclet.geom — Python authoring for the shared analytic-SDF scene layer.
2
+ //
3
+ // Was peclet.core.geom (package peclet-core) until the boundary split of suite/docs/CORE_BOUNDARY.md:
4
+ // this is host-only SDF authoring with no MPI in it, and it was unobtainable without an MPI
5
+ // toolchain while it shared a distribution with the halo bindings. Now its own package, with wheels.
6
+ //
7
+ // The missing ergonomic half of suite/docs/archive/ANALYTIC_SDF_GEOMETRY.md: SceneBuilder has been
8
+ // C++ only, so every Python consumer (flow's set_scene, dem's add_analytic_wall / add_scene_shape,
9
+ // the campaign gates) hand-assembled the flat (node_ints, node_reals, inst_ints, inst_reals)
10
+ // arrays. This module binds the builder itself, batch evaluation, lattice baking (the dem
11
+ // grid-particle path), and geom::bodyProperties — mass, COM, full inertia tensor, and the
12
+ // principal decomposition as three moments plus a QUATERNION — with addReframed closing the
13
+ // non-principal-reference-frame question exactly (one composed transform, no resampling).
14
+ //
15
+ // Host-only: no Kokkos, no MPI. Preprocessing tooling by design.
16
+ #include <nanobind/nanobind.h>
17
+ #include <nanobind/ndarray.h>
18
+ #include <nanobind/stl/array.h>
19
+ #include <nanobind/stl/pair.h>
20
+ #include <nanobind/stl/string.h>
21
+ #include <nanobind/stl/vector.h>
22
+
23
+ #include <cstring>
24
+ #include <limits>
25
+ #include <string>
26
+ #include <vector>
27
+
28
+ #include "peclet/core/geom/body_properties.hpp"
29
+ #include "peclet/core/geom/scene_builder.hpp"
30
+ #include "peclet/core/geom/scene_query.hpp"
31
+
32
+ namespace nb = nanobind;
33
+ namespace g = peclet::core::geom;
34
+ using peclet::core::Quat;
35
+ using peclet::core::Vec3;
36
+
37
+ namespace {
38
+
39
+ int kindFromName(const std::string& k) {
40
+ if (k == "sphere")
41
+ return g::kSphere;
42
+ if (k == "box")
43
+ return g::kBox;
44
+ if (k == "hollow_cylinder")
45
+ return g::kHollowCylinder;
46
+ if (k == "hollow_cylinder_shell")
47
+ return g::kHollowCylinderShell;
48
+ if (k == "capsule")
49
+ return g::kCapsule;
50
+ if (k == "torus")
51
+ return g::kTorus;
52
+ if (k == "cone")
53
+ return g::kCone;
54
+ if (k == "ellipsoid")
55
+ return g::kEllipsoid;
56
+ if (k == "superquadric")
57
+ return g::kSuperquadric;
58
+ throw std::invalid_argument(
59
+ "unknown leaf kind '" + k +
60
+ "' (sphere, box, hollow_cylinder, hollow_cylinder_shell, capsule, torus, cone, ellipsoid, "
61
+ "superquadric; grids go through add_grid)");
62
+ }
63
+
64
+ g::Transform<double> makeTransform(std::array<double, 3> t, std::array<double, 4> q, double s) {
65
+ g::Transform<double> tr;
66
+ tr.translation = Vec3<double>{t[0], t[1], t[2]};
67
+ tr.rotation = Quat<double>{q[0], q[1], q[2], q[3]};
68
+ tr.scale = s;
69
+ return tr;
70
+ }
71
+
72
+ template <class T>
73
+ nb::ndarray<nb::numpy, T> toArray(std::vector<T>&& v, std::initializer_list<std::size_t> shape) {
74
+ auto* held = new std::vector<T>(std::move(v));
75
+ nb::capsule owner(held, [](void* p) noexcept { delete static_cast<std::vector<T>*>(p); });
76
+ std::vector<std::size_t> sh(shape);
77
+ return nb::ndarray<nb::numpy, T>(held->data(), sh.size(), sh.data(), owner);
78
+ }
79
+
80
+ /// A thin owner: the builder plus a cached view for evaluation.
81
+ struct PyScene {
82
+ g::SceneBuilder<double> b;
83
+
84
+ auto rootEval(int root) const {
85
+ const g::SceneView<double> sv = b.view();
86
+ if (root < 0 || root >= sv.nodeCount)
87
+ throw std::out_of_range("root node index out of range");
88
+ return [sv, root](Vec3<double> p) {
89
+ return g::evalTree<double>(g::TablePtr<g::ShapeNode<double>>{sv.nodes}, sv.nodeCount, root, p,
90
+ g::TablePtr<g::GridDesc<double>>{sv.grids},
91
+ g::PoolPtr<float>{sv.samples});
92
+ };
93
+ }
94
+ };
95
+
96
+ } // namespace
97
+
98
+ NB_MODULE(_geom, m) {
99
+ m.attr("build_toolchain") = PECLET_GEOM_BUILD_TOOLCHAIN; // state_hash.py compares like with like
100
+ m.doc() =
101
+ "Analytic-SDF scene authoring: SceneBuilder (leaves + CSG + transforms + instancing), batch "
102
+ "evaluation, lattice baking, and rigid-body mass properties (mass, COM, inertia tensor, "
103
+ "principal moments + quaternion) by implicit quadrature.";
104
+
105
+ nb::class_<PyScene>(
106
+ m, "SceneBuilder",
107
+ "Authors an analytic constructive-solid-geometry (CSG) scene as two arrays: a forest of "
108
+ "NODES -- leaves (add_leaf), the union/intersection/difference combinators and "
109
+ "add_reframed copies, each addressed by the index its add_* call returns -- and a list of "
110
+ "INSTANCES (add_instance) that place one node's subtree in the world with a rigid-body "
111
+ "transform, optional linear/angular velocity and a material id. A node is a shape "
112
+ "DEFINITION; an instance is a PLACEMENT of one (see num_nodes vs num_instances). encode() "
113
+ "flattens both to the (node_ints, node_reals, inst_ints, inst_reals) arrays that "
114
+ "flow.set_scene, dem.add_analytic_wall and dem.add_scene_shape consume; eval()/eval_root() "
115
+ "evaluate the scene directly, for authoring and debugging (solvers evaluate on device from "
116
+ "the encoded arrays, not through this class).")
117
+ .def(nb::init<>(),
118
+ "SceneBuilder(): takes no arguments. Starts with an empty node forest and no "
119
+ "instances; build a tree bottom-up with add_leaf and the CSG combinators, place it "
120
+ "with add_instance, then encode() (or eval()/eval_root() to check it directly).")
121
+ .def(
122
+ "add_leaf",
123
+ [](PyScene& s, const std::string& kind, std::vector<double> params,
124
+ std::array<double, 3> translation, std::array<double, 4> rotation, double scale) {
125
+ std::initializer_list<double> il{}; // addLeaf takes an initializer_list; go via node
126
+ g::ShapeNode<double> nd;
127
+ nd.kind = kindFromName(kind);
128
+ for (std::size_t i = 0; i < params.size() && i < 8; ++i)
129
+ nd.params[i] = params[i];
130
+ nd.transform = makeTransform(translation, rotation, scale);
131
+ (void)il;
132
+ return s.b.addNode(nd);
133
+ },
134
+ nb::arg("kind"), nb::arg("params"),
135
+ nb::arg("translation") = std::array<double, 3>{0, 0, 0},
136
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
137
+ "Add a leaf primitive; returns its node index. kind: sphere [r], box [hx,hy,hz], "
138
+ "hollow_cylinder [rOuter,height,thickness] (y axis, distance-exact), "
139
+ "hollow_cylinder_shell [rOuter,rInner,height] (z axis, sign-exact), capsule "
140
+ "[r,halfLength] (y), torus [R,r] (y), cone [rBottom,rTop,halfHeight] (y), ellipsoid "
141
+ "[rx,ry,rz] (BOUND), superquadric [rx,ry,rz,e] (BOUND). rotation is a quaternion "
142
+ "(x, y, z, w).")
143
+ .def(
144
+ "add_union",
145
+ [](PyScene& s, int a, int b2, std::array<double, 3> t, std::array<double, 4> q,
146
+ double sc) { return s.b.addUnion(a, b2, makeTransform(t, q, sc)); },
147
+ nb::arg("a"), nb::arg("b"), nb::arg("translation") = std::array<double, 3>{0, 0, 0},
148
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
149
+ "SDF min of subtrees `a` and `b` (their node indices) -- the shape occupied by either "
150
+ "one; returns the new combinator's node index. `translation`/`rotation` (quaternion x, "
151
+ "y, z, w)/`scale` transform the frame BOTH children are evaluated in, so the union can "
152
+ "be placed, rotated or scaled as one rigid piece without touching a or b.")
153
+ .def(
154
+ "add_intersection",
155
+ [](PyScene& s, int a, int b2, std::array<double, 3> t, std::array<double, 4> q,
156
+ double sc) { return s.b.addIntersection(a, b2, makeTransform(t, q, sc)); },
157
+ nb::arg("a"), nb::arg("b"), nb::arg("translation") = std::array<double, 3>{0, 0, 0},
158
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
159
+ "SDF max of subtrees `a` and `b` (their node indices) -- the shape occupied by both; "
160
+ "returns the new combinator's node index. `translation`/`rotation` (quaternion x, y, "
161
+ "z, w)/`scale` transform the frame both children are evaluated in, exactly as in "
162
+ "add_union.")
163
+ .def(
164
+ "add_difference",
165
+ [](PyScene& s, int a, int b2, std::array<double, 3> t, std::array<double, 4> q,
166
+ double sc) { return s.b.addDifference(a, b2, makeTransform(t, q, sc)); },
167
+ nb::arg("a"), nb::arg("b"), nb::arg("translation") = std::array<double, 3>{0, 0, 0},
168
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
169
+ "a minus b.")
170
+ .def(
171
+ "add_reframed",
172
+ [](PyScene& s, int root, std::array<double, 3> t, std::array<double, 4> q, double sc) {
173
+ return s.b.addReframed(root, makeTransform(t, q, sc));
174
+ },
175
+ nb::arg("root"), nb::arg("translation") = std::array<double, 3>{0, 0, 0},
176
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
177
+ "Deep-copy the subtree and pre-compose this transform onto the copied root, so "
178
+ "eval_new(p) = eval_old(toLocal(W, p)) -- i.e. this PLACES the copy at W. With the "
179
+ "inverse principal transform from body_properties (rotation = conjugate of its quat, "
180
+ "translation = -com rotated by the conjugate... see principal_frame()), the copy's "
181
+ "canonical frame IS the principal body frame, exactly, with no resampling.")
182
+ .def(
183
+ "principal_frame",
184
+ [](PyScene& s, int root, std::array<double, 3> lo, std::array<double, 3> hi, int n,
185
+ int order, int nseg) {
186
+ const auto bp =
187
+ g::bodyProperties<double>(s.rootEval(root), Vec3<double>{lo[0], lo[1], lo[2]},
188
+ Vec3<double>{hi[0], hi[1], hi[2]}, n, order, nseg);
189
+ // W with toLocal(W, p_body) = com + R p_body: q_W = conj(q_R), t_W = invR(q_R, -com)
190
+ const Quat<double> qc{-bp.quat.x, -bp.quat.y, -bp.quat.z, bp.quat.w};
191
+ const Vec3<double> tw =
192
+ peclet::core::invRotate(bp.quat, Vec3<double>{-bp.com.x, -bp.com.y, -bp.com.z});
193
+ g::Transform<double> W;
194
+ W.rotation = qc;
195
+ W.translation = tw;
196
+ return s.b.addReframed(root, W);
197
+ },
198
+ nb::arg("root"), nb::arg("lo"), nb::arg("hi"), nb::arg("n") = 32, nb::arg("order") = 5,
199
+ nb::arg("nseg") = 8,
200
+ "Measure the subtree's mass properties and return a NEW root whose canonical frame is "
201
+ "the principal body frame (COM at the origin, axes principal) -- the one-call answer to "
202
+ "'my shape's reference frame is not its principal frame'. The original subtree is "
203
+ "untouched.")
204
+ .def(
205
+ "add_instance",
206
+ [](PyScene& s, int root, std::array<double, 3> t, std::array<double, 4> q, double sc,
207
+ std::array<double, 3> lin, std::array<double, 3> ang, std::array<double, 3> cen,
208
+ int material) {
209
+ return s.b.addInstance(root, makeTransform(t, q, sc),
210
+ Vec3<double>{lin[0], lin[1], lin[2]},
211
+ Vec3<double>{ang[0], ang[1], ang[2]},
212
+ Vec3<double>{cen[0], cen[1], cen[2]}, material);
213
+ },
214
+ nb::arg("root"), nb::arg("translation") = std::array<double, 3>{0, 0, 0},
215
+ nb::arg("rotation") = std::array<double, 4>{0, 0, 0, 1}, nb::arg("scale") = 1.0,
216
+ nb::arg("lin_vel") = std::array<double, 3>{0, 0, 0},
217
+ nb::arg("ang_vel") = std::array<double, 3>{0, 0, 0},
218
+ nb::arg("center") = std::array<double, 3>{std::numeric_limits<double>::quiet_NaN(),
219
+ std::numeric_limits<double>::quiet_NaN(),
220
+ std::numeric_limits<double>::quiet_NaN()},
221
+ nb::arg("material") = -1,
222
+ "Place a tree in the world; returns the instance index. What flow's set_scene and the "
223
+ "resolved coupling consume. `center` is the centre of rotation for ang_vel: leave it NaN "
224
+ "(the default) and it FOLLOWS the body (the translation, re-anchored on every "
225
+ "set_instance_transform); give any finite point and it is PINNED there in world "
226
+ "coordinates -- (0, 0, 0) included. (Raw instance arrays keep the legacy reading of an "
227
+ "all-zero centre as 'follows the body'; pin a world-origin centre from a raw array "
228
+ "through "
229
+ "flow's set_instance_motion(center=...).)")
230
+ .def(
231
+ "encode",
232
+ [](PyScene& s) {
233
+ std::vector<int> ni, ii;
234
+ std::vector<double> nr, ir;
235
+ s.b.encode(ni, nr, ii, ir);
236
+ return nb::make_tuple(
237
+ toArray(std::move(ni), {ni.size()}), toArray(std::move(nr), {nr.size()}),
238
+ toArray(std::move(ii), {ii.size()}), toArray(std::move(ir), {ir.size()}));
239
+ },
240
+ "The flat (node_ints, node_reals, inst_ints, inst_reals) arrays -- exactly what "
241
+ "flow.set_scene, dem.add_analytic_wall and dem.add_scene_shape take.")
242
+ .def(
243
+ "eval",
244
+ [](PyScene& s, nb::ndarray<double, nb::shape<-1, 3>, nb::c_contig> pts) {
245
+ const g::SceneView<double> sv = s.b.view();
246
+ const std::size_t n = pts.shape(0);
247
+ std::vector<double> out(n);
248
+ const double* p = pts.data();
249
+ for (std::size_t i = 0; i < n; ++i)
250
+ out[i] = g::evalScene<double>(sv, Vec3<double>{p[3 * i], p[3 * i + 1], p[3 * i + 2]});
251
+ return toArray(std::move(out), {n});
252
+ },
253
+ nb::arg("points"),
254
+ "Signed distance of the whole scene (min over instances) at (N,3) world points. "
255
+ "Authoring/debug; solvers evaluate on device.")
256
+ .def(
257
+ "eval_root",
258
+ [](PyScene& s, int root, nb::ndarray<double, nb::shape<-1, 3>, nb::c_contig> pts) {
259
+ auto f = s.rootEval(root);
260
+ const std::size_t n = pts.shape(0);
261
+ std::vector<double> out(n);
262
+ const double* p = pts.data();
263
+ for (std::size_t i = 0; i < n; ++i)
264
+ out[i] = f(Vec3<double>{p[3 * i], p[3 * i + 1], p[3 * i + 2]});
265
+ return toArray(std::move(out), {n});
266
+ },
267
+ nb::arg("root"), nb::arg("points"),
268
+ "Signed distance of ONE subtree, in its own canonical frame, at (N,3) points.")
269
+ .def(
270
+ "eval_root_grad",
271
+ [](PyScene& s, int root, nb::ndarray<double, nb::shape<-1, 3>, nb::c_contig> pts) {
272
+ const g::SceneView<double> sv = s.b.view();
273
+ if (root < 0 || root >= sv.nodeCount)
274
+ throw std::out_of_range("root node index out of range");
275
+ const std::size_t n = pts.shape(0);
276
+ std::vector<double> val(n), grd(3 * n);
277
+ const double* p = pts.data();
278
+ for (std::size_t i = 0; i < n; ++i) {
279
+ Vec3<double> gv;
280
+ val[i] = g::evalTreeGrad<double>(
281
+ g::TablePtr<g::ShapeNode<double>>{sv.nodes}, sv.nodeCount, root,
282
+ Vec3<double>{p[3 * i], p[3 * i + 1], p[3 * i + 2]},
283
+ g::TablePtr<g::GridDesc<double>>{sv.grids}, g::PoolPtr<float>{sv.samples}, gv);
284
+ grd[3 * i] = gv.x;
285
+ grd[3 * i + 1] = gv.y;
286
+ grd[3 * i + 2] = gv.z;
287
+ }
288
+ return nb::make_tuple(toArray(std::move(val), {n}), toArray(std::move(grd), {n, 3}));
289
+ },
290
+ nb::arg("root"), nb::arg("points"),
291
+ "Value AND analytic gradient of one subtree at (N,3) canonical points, one traversal: "
292
+ "(values (N,), gradients (N,3), unnormalised). At CSG ridges the gradient is the ACTIVE "
293
+ "branch's exact normal (deterministic left tie-break), not a finite-difference smear.")
294
+ .def(
295
+ "bake",
296
+ [](PyScene& s, int root, std::array<double, 3> origin, std::array<double, 3> spacing,
297
+ std::array<int, 3> dims) {
298
+ auto f = s.rootEval(root);
299
+ const int nx = dims[0], ny = dims[1], nz = dims[2];
300
+ if (nx < 2 || ny < 2 || nz < 2)
301
+ throw std::invalid_argument("bake: dims must be >= 2 per axis");
302
+ std::vector<float> out((std::size_t)nx * ny * nz);
303
+ for (int k = 0; k < nz; ++k)
304
+ for (int j = 0; j < ny; ++j)
305
+ for (int i = 0; i < nx; ++i)
306
+ out[(std::size_t)i + (std::size_t)j * nx + (std::size_t)k * nx * ny] =
307
+ (float)f(Vec3<double>{origin[0] + i * spacing[0], origin[1] + j * spacing[1],
308
+ origin[2] + k * spacing[2]});
309
+ return toArray(std::move(out), {(std::size_t)nx * ny * nz});
310
+ },
311
+ nb::arg("root"), nb::arg("origin"), nb::arg("spacing"), nb::arg("dims"),
312
+ "Sample a subtree on a lattice: flat float32, x-fastest (idx = i + j*nx + k*nx*ny), at "
313
+ "nodes origin + (i,j,k)*spacing -- the layout dem's grid-SDF particles and shell "
314
+ "generation consume.")
315
+ .def(
316
+ "body_properties",
317
+ [](PyScene& s, int root, std::array<double, 3> lo, std::array<double, 3> hi, int n,
318
+ int order, int nseg, double density) {
319
+ const auto bp = g::bodyProperties<double>(
320
+ s.rootEval(root), Vec3<double>{lo[0], lo[1], lo[2]},
321
+ Vec3<double>{hi[0], hi[1], hi[2]}, n, order, nseg, density);
322
+ nb::dict d;
323
+ d["volume"] = bp.volume;
324
+ d["mass"] = bp.mass;
325
+ d["com"] = std::array<double, 3>{bp.com.x, bp.com.y, bp.com.z};
326
+ std::vector<double> I(9), R(9);
327
+ for (int r = 0; r < 3; ++r)
328
+ for (int c = 0; c < 3; ++c) {
329
+ I[(std::size_t)(3 * r + c)] = bp.inertia[r][c];
330
+ R[(std::size_t)(3 * r + c)] = bp.rotation[r][c];
331
+ }
332
+ d["inertia_tensor"] = toArray(std::move(I), {3, 3});
333
+ d["principal"] =
334
+ std::array<double, 3>{bp.principal[0], bp.principal[1], bp.principal[2]};
335
+ d["rotation"] = toArray(std::move(R), {3, 3});
336
+ d["quat"] = std::array<double, 4>{bp.quat.x, bp.quat.y, bp.quat.z, bp.quat.w};
337
+ return d;
338
+ },
339
+ nb::arg("root"), nb::arg("lo"), nb::arg("hi"), nb::arg("n") = 32, nb::arg("order") = 5,
340
+ nb::arg("nseg") = 8, nb::arg("density") = 1.0,
341
+ "Mass properties of {subtree < 0} over [lo, hi] (which MUST contain the solid), constant "
342
+ "density, by implicit quadrature: dict with volume, mass, com, inertia_tensor (3,3 about "
343
+ "the COM), principal (3, ascending), rotation (3,3; columns = principal axes, "
344
+ "p_input = com + R p_body) and quat (x,y,z,w). Sign-exact bracketing means bound-only "
345
+ "leaves (ellipsoid, superquadric, CSG) carry NO systematic bias; measured ~4e-6 relative "
346
+ "at n=32 (ctest geom_body).")
347
+ .def(
348
+ "num_nodes", [](PyScene& s) { return (int)s.b.nodes().size(); },
349
+ "Number of NODES in the shape forest -- every leaf (add_leaf), CSG combinator "
350
+ "(add_union/add_intersection/add_difference) and add_reframed copy adds exactly one, "
351
+ "whether or not it has ever been placed with add_instance. A node is a shape "
352
+ "DEFINITION addressed by the index its add_* call returned, not something a solver "
353
+ "sees directly.")
354
+ .def(
355
+ "num_instances", [](PyScene& s) { return (int)s.b.instances().size(); },
356
+ "Number of INSTANCES -- entries created by add_instance, i.e. shape trees actually "
357
+ "PLACED in the world with a transform and optional rigid-body velocity/material id. "
358
+ "This is what solvers iterate over. Distinct from num_nodes: one node (say a stirrer "
359
+ "built as a union of two leaves) can be instanced zero, one, or many times at "
360
+ "different places, so num_instances can be smaller, equal to, or larger than "
361
+ "num_nodes.");
362
+ }
@@ -0,0 +1,76 @@
1
+ # Type stub for peclet.core.geom — GENERATED by `python -m nanobind.stubgen -m peclet.core.geom -o packaging/core_geom.pyi`
2
+ # (PYTHONPATH at the python build tree). Do not hand-edit; regenerate after changing geom_bindings.cpp.
3
+ """
4
+ Analytic-SDF scene authoring: SceneBuilder (leaves + CSG + transforms + instancing), batch evaluation, lattice baking, and rigid-body mass properties (mass, COM, inertia tensor, principal moments + quaternion) by implicit quadrature.
5
+ """
6
+
7
+ from collections.abc import Sequence
8
+ from typing import Annotated
9
+
10
+ import numpy
11
+ from numpy.typing import NDArray
12
+
13
+
14
+ class SceneBuilder:
15
+ def __init__(self) -> None: ...
16
+
17
+ def add_leaf(self, kind: str, params: Sequence[float], translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0) -> int:
18
+ """
19
+ Add a leaf primitive; returns its node index. kind: sphere [r], box [hx,hy,hz], hollow_cylinder [rOuter,height,thickness] (y axis, distance-exact), hollow_cylinder_shell [rOuter,rInner,height] (z axis, sign-exact), capsule [r,halfLength] (y), torus [R,r] (y), cone [rBottom,rTop,halfHeight] (y), ellipsoid [rx,ry,rz] (BOUND), superquadric [rx,ry,rz,e] (BOUND). rotation is a quaternion (x, y, z, w).
20
+ """
21
+
22
+ def add_union(self, a: int, b: int, translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0) -> int: ...
23
+
24
+ def add_intersection(self, a: int, b: int, translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0) -> int: ...
25
+
26
+ def add_difference(self, a: int, b: int, translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0) -> int:
27
+ """a minus b."""
28
+
29
+ def add_reframed(self, root: int, translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0) -> int:
30
+ """
31
+ Deep-copy the subtree and pre-compose this transform onto the copied root, so eval_new(p) = eval_old(toLocal(W, p)) -- i.e. this PLACES the copy at W. With the inverse principal transform from body_properties (rotation = conjugate of its quat, translation = -com rotated by the conjugate... see principal_frame()), the copy's canonical frame IS the principal body frame, exactly, with no resampling.
32
+ """
33
+
34
+ def principal_frame(self, root: int, lo: Sequence[float], hi: Sequence[float], n: int = 32, order: int = 5, nseg: int = 8) -> int:
35
+ """
36
+ Measure the subtree's mass properties and return a NEW root whose canonical frame is the principal body frame (COM at the origin, axes principal) -- the one-call answer to 'my shape's reference frame is not its principal frame'. The original subtree is untouched.
37
+ """
38
+
39
+ def add_instance(self, root: int, translation: Sequence[float] = [0.0, 0.0, 0.0], rotation: Sequence[float] = [0.0, 0.0, 0.0, 1.0], scale: float = 1.0, lin_vel: Sequence[float] = [0.0, 0.0, 0.0], ang_vel: Sequence[float] = [0.0, 0.0, 0.0], center: Sequence[float] = [float('nan'), float('nan'), float('nan')], material: int = -1) -> int:
40
+ """
41
+ Place a tree in the world; returns the instance index. What flow's set_scene and the resolved coupling consume. `center` is the centre of rotation for ang_vel: leave it NaN (the default) and it FOLLOWS the body (the translation, re-anchored on every set_instance_transform); give any finite point and it is PINNED there in world coordinates -- (0, 0, 0) included. (Raw instance arrays keep the legacy reading of an all-zero centre as 'follows the body'; pin a world-origin centre from a raw array through flow's set_instance_motion(center=...).)
42
+ """
43
+
44
+ def encode(self) -> tuple:
45
+ """
46
+ The flat (node_ints, node_reals, inst_ints, inst_reals) arrays -- exactly what flow.set_scene, dem.add_analytic_wall and dem.add_scene_shape take.
47
+ """
48
+
49
+ def eval(self, points: Annotated[NDArray[numpy.float64], dict(shape=(None, 3), order='C')]) -> NDArray[numpy.float64]:
50
+ """
51
+ Signed distance of the whole scene (min over instances) at (N,3) world points. Authoring/debug; solvers evaluate on device.
52
+ """
53
+
54
+ def eval_root(self, root: int, points: Annotated[NDArray[numpy.float64], dict(shape=(None, 3), order='C')]) -> NDArray[numpy.float64]:
55
+ """
56
+ Signed distance of ONE subtree, in its own canonical frame, at (N,3) points.
57
+ """
58
+
59
+ def eval_root_grad(self, root: int, points: Annotated[NDArray[numpy.float64], dict(shape=(None, 3), order='C')]) -> tuple:
60
+ """
61
+ Value AND analytic gradient of one subtree at (N,3) canonical points, one traversal: (values (N,), gradients (N,3), unnormalised). At CSG ridges the gradient is the ACTIVE branch's exact normal (deterministic left tie-break), not a finite-difference smear.
62
+ """
63
+
64
+ def bake(self, root: int, origin: Sequence[float], spacing: Sequence[float], dims: Sequence[int]) -> NDArray[numpy.float32]:
65
+ """
66
+ Sample a subtree on a lattice: flat float32, x-fastest (idx = i + j*nx + k*nx*ny), at nodes origin + (i,j,k)*spacing -- the layout dem's grid-SDF particles and shell generation consume.
67
+ """
68
+
69
+ def body_properties(self, root: int, lo: Sequence[float], hi: Sequence[float], n: int = 32, order: int = 5, nseg: int = 8, density: float = 1.0) -> dict:
70
+ """
71
+ Mass properties of {subtree < 0} over [lo, hi] (which MUST contain the solid), constant density, by implicit quadrature: dict with volume, mass, com, inertia_tensor (3,3 about the COM), principal (3, ascending), rotation (3,3; columns = principal axes, p_input = com + R p_body) and quat (x,y,z,w). Sign-exact bracketing means bound-only leaves (ellipsoid, superquadric, CSG) carry NO systematic bias; measured ~4e-6 relative at n=32 (ctest geom_body).
72
+ """
73
+
74
+ def num_nodes(self) -> int: ...
75
+
76
+ def num_instances(self) -> int: ...
@@ -0,0 +1,15 @@
1
+ """peclet.geom — analytic-SDF scene authoring: CSG trees, batch evaluation, lattice baking,
2
+ and rigid-body mass properties (mass, centre of mass, inertia tensor, principal frame).
3
+
4
+ Host-only and dependency-light by design: no MPI, no Kokkos, no Kokkos-backed solver. That is
5
+ why it ships as wheels and is part of a plain ``pip install peclet``.
6
+
7
+ Until peclet 1.2.0 this was ``peclet.core.geom`` in the ``peclet-core`` distribution, which could
8
+ not be installed without an MPI toolchain. ``peclet.core.geom`` still works and is the same object
9
+ (``peclet.core.geom.SceneBuilder is peclet.geom.SceneBuilder``); it warns from 1.3.0 and is removed
10
+ in 2.0.0. See suite/docs/CORE_BOUNDARY.md.
11
+ """
12
+ from ._geom import * # noqa: F401,F403
13
+ from . import _geom as _ext
14
+
15
+ __all__ = [n for n in dir(_ext) if not n.startswith("_")]
@@ -0,0 +1,152 @@
1
+ """Fixed-seed reference runs of every public entry path of peclet.geom, hashed.
2
+
3
+ The structural gate of suite/docs/QUALITY_PLAN.md §3.G: a refactor that moves code verbatim must
4
+ leave every final state BYTE-IDENTICAL. This script runs one deterministic scenario per public
5
+ entry path, hashes the final arrays (SHA-256 of the raw float64/int bytes) and prints them; with
6
+ ``--save FILE`` it records them as JSON and with ``--check FILE`` it compares against a recording
7
+ and exits non-zero on any difference.
8
+
9
+ PYTHONPATH=<python build tree> OMP_NUM_THREADS=1 python python/state_hash.py --save pre.json
10
+ PYTHONPATH=<python build tree> OMP_NUM_THREADS=1 mpirun -np 2 python python/state_hash.py --check pre.json
11
+
12
+ Under ``mpirun -np N`` (N > 1) the distributed paths (ParticleMigrator / ParticleHalo across ranks)
13
+ run too; every per-rank array is gathered to rank 0 in
14
+ rank order before hashing, so the hash names carry the rank count (``.np2``). Run at
15
+ OMP_NUM_THREADS=1: the device reductions are order-dependent at more than one thread.
16
+
17
+ Only ``geom`` here; the halo entry paths have the same script in peclet-halo (the `core` repo),
18
+ and the AMR ones in peclet-amr. Split out of peclet-core 2026-09-21 (suite/docs/CORE_BOUNDARY.md);
19
+ the geom hashes must be BYTE-IDENTICAL across that move, which is what proves it was a move.
20
+ """
21
+ import argparse
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+
27
+ import numpy as np
28
+
29
+
30
+ def sha(*arrays):
31
+ h = hashlib.sha256()
32
+ flat = []
33
+ for a in arrays: # a binding may return a tuple of arrays: hash each member in order
34
+ flat += list(a) if isinstance(a, (tuple, list)) and not np.isscalar(a[0]) else [a]
35
+ for a in flat:
36
+ a = np.ascontiguousarray(a)
37
+ h.update(str(a.dtype).encode())
38
+ h.update(str(a.shape).encode())
39
+ h.update(a.tobytes())
40
+ return h.hexdigest()
41
+
42
+
43
+ def gather_rows(comm, a):
44
+ """Concatenate a per-rank (n, k) array over ranks in rank order (rank 0 gets the result)."""
45
+ if comm is None or comm.size == 1:
46
+ return np.ascontiguousarray(a)
47
+ parts = comm.gather(np.ascontiguousarray(a), root=0)
48
+ return np.concatenate(parts, axis=0) if comm.rank == 0 else None
49
+
50
+
51
+ # ---------------------------------------------------------------------------------------------
52
+ # peclet.core.geom — a CSG scene evaluated on a grid, baked, and its mass properties.
53
+ # ---------------------------------------------------------------------------------------------
54
+ def run_geom(out, comm):
55
+ from peclet import geom
56
+ if comm is not None and comm.rank != 0:
57
+ return
58
+ s = geom.SceneBuilder()
59
+ sph = s.add_leaf("sphere", [0.3])
60
+ box = s.add_leaf("box", [0.25, 0.15, 0.2], translation=[0.2, 0.1, 0.0],
61
+ rotation=[0.0, 0.0, 0.3826834323650898, 0.9238795325112867])
62
+ tor = s.add_leaf("torus", [0.35, 0.08], translation=[-0.1, 0.0, 0.2])
63
+ u = s.add_union(sph, box)
64
+ d = s.add_difference(u, tor)
65
+ s.add_instance(d, translation=[0.5, 0.5, 0.5])
66
+ s.add_instance(sph, translation=[0.15, 0.8, 0.3], scale=0.5)
67
+ n = 24
68
+ g = (np.arange(n) + 0.5) / n
69
+ pts = np.stack(np.meshgrid(g, g, g, indexing="ij"), axis=-1).reshape(-1, 3)
70
+ pts = np.ascontiguousarray(pts, dtype=np.float64)
71
+ out["geom.eval"] = sha(s.eval(pts))
72
+ out["geom.eval_root"] = sha(s.eval_root(d, pts))
73
+ out["geom.eval_root_grad"] = sha(s.eval_root_grad(d, pts))
74
+ baked = s.bake(d, [-0.6, -0.6, -0.6], [0.05, 0.05, 0.05], [24, 24, 24])
75
+ out["geom.bake"] = sha(np.asarray(baked))
76
+ bp = s.body_properties(d, [-0.8, -0.8, -0.8], [0.8, 0.8, 0.8], n=24)
77
+ out["geom.body_properties"] = sha(
78
+ np.array([bp["volume"], bp["mass"]]), np.asarray(bp["com"]), np.asarray(bp["inertia_tensor"]),
79
+ np.asarray(bp["principal"]), np.asarray(bp["rotation"]), np.asarray(bp["quat"]))
80
+
81
+
82
+ # ---------------------------------------------------------------------------------------------
83
+ RUNNERS = {"geom": run_geom}
84
+
85
+
86
+ def toolchain():
87
+ """The module's compiler / version / build type. Hashes are only comparable within one."""
88
+ from peclet import geom
89
+ return getattr(geom, "build_toolchain", "unknown")
90
+
91
+
92
+ def main():
93
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
94
+ ap.add_argument("--modules", default="geom", help="comma-separated subset of: geom (mpi moved to peclet-halo)")
95
+ ap.add_argument("--save", metavar="FILE", help="write the hashes as JSON")
96
+ ap.add_argument("--check", metavar="FILE", help="compare against a JSON recording")
97
+ args = ap.parse_args()
98
+ if os.environ.get("OMP_NUM_THREADS") != "1":
99
+ sys.stderr.write("state_hash: run with OMP_NUM_THREADS=1 (device reductions are order-dependent)\n")
100
+ comm = None
101
+ try:
102
+ from mpi4py import MPI
103
+ comm = MPI.COMM_WORLD
104
+ except ImportError:
105
+ pass
106
+ rank = comm.rank if comm is not None else 0
107
+ out = {}
108
+ for name in args.modules.split(","):
109
+ name = name.strip()
110
+ if not name:
111
+ continue
112
+ try:
113
+ RUNNERS[name](out, comm)
114
+ except ImportError as e:
115
+ if rank == 0:
116
+ print(f"# {name}: not importable ({e}); skipped")
117
+ if rank != 0:
118
+ return 0
119
+ for k in sorted(out):
120
+ print(f"{k} {out[k]}")
121
+ rc = 0
122
+ if args.check:
123
+ ref = json.load(open(args.check))
124
+ want = ref.pop("toolchain", None)
125
+ have = toolchain()
126
+ if want is not None and want != have:
127
+ print(f"state_hash: reference recorded with toolchain '{want}', this build is '{have}' — "
128
+ "not comparable; SKIPPED (exit 77). Re-record with --save on this toolchain to gate it.")
129
+ return 77
130
+ # A recording may merge several rank counts; compare only the keys this run can produce
131
+ # (no `.npN` suffix, or the suffix of the current communicator size).
132
+ size = comm.size if comm is not None else 1
133
+ ref = {k: v for k, v in ref.items() if ".np" not in k or k.endswith(f".np{size}")}
134
+ for k in sorted(set(ref) | set(out)):
135
+ if k not in out:
136
+ print(f"MISSING {k}")
137
+ rc = 1
138
+ elif k not in ref:
139
+ print(f"NEW {k}")
140
+ elif ref[k] != out[k]:
141
+ print(f"DIFFER {k}: {ref[k][:16]}... -> {out[k][:16]}...")
142
+ rc = 1
143
+ print("state_hash: " + ("IDENTICAL" if rc == 0 else "DIFFERENCES FOUND"))
144
+ if args.save:
145
+ out["toolchain"] = toolchain()
146
+ with open(args.save, "w") as f:
147
+ json.dump(out, f, indent=1, sort_keys=True)
148
+ return rc
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ {
2
+ "geom.bake": "3d0033f786f20cf8a6caf8b05838a025ad591636bd1bf3ad4b36a89eabf8f5f3",
3
+ "geom.body_properties": "afb2cdfebdba03adfa66bd559024978f4092ee6ed857dc7dd727f003c4ab6529",
4
+ "geom.eval": "89e4051fed436b4e650739323ad49d3cb99d9996f513de92e55fd3a25bd69de5",
5
+ "geom.eval_root": "2567a7dac50a9f7d09cf5d0697587ba3730b0fc57ef481c231c396accb10e9b4",
6
+ "geom.eval_root_grad": "bf92ae4d692ce2187697e43c5ea4328b55460acf4a2e9d2a31868ff4c15c9234",
7
+ "toolchain": "GNU 14.2.0 Release x86_64"
8
+ }