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.
- peclet_geom-1.0.0/.github/workflows/ci.yml +67 -0
- peclet_geom-1.0.0/.github/workflows/release.yml +70 -0
- peclet_geom-1.0.0/.gitignore +6 -0
- peclet_geom-1.0.0/CITATION.cff +31 -0
- peclet_geom-1.0.0/LICENSE +21 -0
- peclet_geom-1.0.0/PKG-INFO +91 -0
- peclet_geom-1.0.0/README.md +61 -0
- peclet_geom-1.0.0/cmake/PecletDeps.cmake +50 -0
- peclet_geom-1.0.0/cmake/SuiteNanobind.cmake +51 -0
- peclet_geom-1.0.0/pyproject.toml +67 -0
- peclet_geom-1.0.0/python/CMakeLists.txt +52 -0
- peclet_geom-1.0.0/python/geom_bindings.cpp +362 -0
- peclet_geom-1.0.0/python/packaging/_geom.pyi +76 -0
- peclet_geom-1.0.0/python/packaging/geom_init.py +15 -0
- peclet_geom-1.0.0/python/state_hash.py +152 -0
- peclet_geom-1.0.0/python/state_hash_reference.json +8 -0
|
@@ -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,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
|
+
}
|