diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ecff164d..f40e9888 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -111,7 +111,7 @@ Edge2Elem: ndarray[n_edges, 2] # Edge adjacent elements (-1 if boundary) - ✅ Skeletonization algorithm behavior - ✅ Public method signatures - ✅ Mixed-element (tri + quad) support -- ✅ Test pass rate (currently 57 tests) +- ✅ Test pass rate (the full pytest suite) ### Must Improve - 🚀 O(n²) edge building → O(n log n) or O(n) diff --git a/.domi-pin b/.domi-pin index 04b063b8..60f31b71 100644 --- a/.domi-pin +++ b/.domi-pin @@ -4,6 +4,6 @@ upstream: domattioli/DomI branch: main -sha: cfa7f02a4661ed0db8cce9eee7cf3c93b4f0e863 -manifest_sha256: 124153ce0aa71c06ee4836c51488d067032327312c36be5451ad6b0e02af7341 -pinned_at: 2026-07-10T15:13:24Z +sha: c430fc2d3711e70934d32578a90eb6fc409fdb71 +manifest_sha256: 48c018894b19176816e9d0fb66a62cf5862f401ed575ba83167a66f8b8324094 +pinned_at: 2026-07-15T14:12:54Z diff --git a/.gitattributes b/.gitattributes index d2945067..0e4557d2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,26 @@ # Exclude Claude/agent + dev-process files from source archives # (git archive, PyPI sdist, GitHub release tarball, Zenodo snapshot). # Strictly library-running code ships downstream; these stay in the repo. -.claude/ export-ignore -CLAUDE.md export-ignore -AGENTS.md export-ignore -.specify/ export-ignore -.planning/ export-ignore -specs/ export-ignore -docs/sessions/ export-ignore + +# >>> release-ignore:begin (generated by DomI release-integrity — do not edit inside; edit .release-ignore.toml + regenerate) +.claude/ export-ignore +.claude-plugin/ export-ignore +.introspect/ export-ignore +.githooks/ export-ignore +.specify/ export-ignore +.planning/ export-ignore +.domi-pin export-ignore +.zenodoignore export-ignore +.release-ignore.toml export-ignore +CLAUDE.md export-ignore +AGENTS.md export-ignore +claude_routine_instructions.md export-ignore +instructions_on_start.sh export-ignore +scripts/hooks/ export-ignore +docs/sessions/ export-ignore docs/introspections/ export-ignore -.domi-pin export-ignore +specs/ export-ignore +*introspect*.yml export-ignore +.github/ export-ignore +docs/LEXICON_PROPOSAL.md export-ignore +# <<< release-ignore:end diff --git a/.github/workflows/LOCAL.md b/.github/workflows/LOCAL.md index d84d95d5..3dd2defd 100644 --- a/.github/workflows/LOCAL.md +++ b/.github/workflows/LOCAL.md @@ -9,3 +9,4 @@ workflows fail the workflow-conformance gate. | `python-package.yml` | full cross-OS test matrix incl. macOS lanes (main-push gated) — macOS gating is repo-local by design (spec-010 v2.2 rule 8) | | `publish-pypi.yml` | PyPI release, tag-triggered — repo-specific release pipeline | | `build-cpp-wheels.yml` | build-only manylinux wheel validation for the chilmesh_cpp binary backend (workflow_dispatch, artifacts only, no publish) — repo-specific packaging (#229) | +| `publish-cpp-wheels.yml` | release/dispatch-gated PyPI publish of chilmesh_cpp wheels + sdist (no push trigger; `environment: pypi` protected; Trusted Publishing/OIDC) — repo-specific packaging (#256 Phase 2) | diff --git a/.github/workflows/build-cpp-wheels.yml b/.github/workflows/build-cpp-wheels.yml index 9cf57b27..034a4dd1 100644 --- a/.github/workflows/build-cpp-wheels.yml +++ b/.github/workflows/build-cpp-wheels.yml @@ -1,7 +1,9 @@ name: build-cpp-wheels -# Build-only validation of the chilmesh_cpp binary (manylinux) wheels. +# Build + functional-smoke validation of the chilmesh_cpp binary (manylinux) wheels. # Manual dispatch; uploads wheels as artifacts ONLY — does NOT publish to PyPI. +# The cibuildwheel test step RUNS full_init on a tiny mesh (not just a hasattr +# probe) so a broken/empty binary fails the build (#256 Phase 1a). # Linux manylinux runs on the free ubuntu runner (not gated by the macOS/Windows # runner-billing question in #225), so the binary path can be validated in the # real manylinux container before any publish job is wired up. Refs #229, #163, #234. @@ -31,7 +33,24 @@ jobs: CIBW_BUILD: "cp310-* cp311-* cp312-*" CIBW_ARCHS_LINUX: "x86_64" CIBW_SKIP: "*-musllinux*" - CIBW_TEST_COMMAND: "python -c \"import chilmesh_cpp; assert hasattr(chilmesh_cpp, 'full_init'), 'cpp extension missing full_init'\"" + CIBW_TEST_REQUIRES: "numpy" + # Real functional smoke (#256 Phase 1a): actually RUN full_init on a + # 2-triangle unit square and assert the built adjacency + skeleton, not + # just that the symbol exists. Topology invariants: 4 verts, 2 elems, + # 5 unique edges (4 boundary + 1 shared diagonal), adjacency built, + # >=1 peel layer, edge2vert shape (5, 2). + CIBW_TEST_COMMAND: >- + python -c "import numpy as np, chilmesh_cpp; + pts = np.array([[0.,0.],[1.,0.],[1.,1.],[0.,1.]], dtype=np.float64); + conn = np.array([[0,1,2],[0,2,3]], dtype=np.int32); + m = chilmesh_cpp.full_init(pts, conn); + assert m.n_verts == 4, m.n_verts; + assert m.n_elems == 2, m.n_elems; + assert m.n_edges == 5, m.n_edges; + assert m.adjacency_built, 'adjacency not built'; + assert m.n_layers >= 1, m.n_layers; + assert np.asarray(m.edge2vert).shape == (5, 2), np.asarray(m.edge2vert).shape; + print('chilmesh_cpp full_init smoke OK:', m.n_verts, 'v', m.n_elems, 'e', m.n_edges, 'edges', m.n_layers, 'layers')" - name: Upload wheels uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish-cpp-wheels.yml b/.github/workflows/publish-cpp-wheels.yml new file mode 100644 index 00000000..1d35b78e --- /dev/null +++ b/.github/workflows/publish-cpp-wheels.yml @@ -0,0 +1,104 @@ +name: publish-cpp-wheels + +# Release/tag-gated PyPI publish of the chilmesh_cpp binary wheels + sdist (#256 Phase 2). +# +# Triggers ONLY on a published GitHub release or a deliberate manual dispatch — +# there is NO push trigger, so nothing publishes from `development`. The build +# leg reuses the manylinux cibuildwheel path validated in build-cpp-wheels.yml +# (Phase 1a). macOS/Windows legs are intentionally absent here: they are Phase 1b, +# gated on the runner-billing decision in #225 — add them to the build matrix +# below once #225 resolves. +# +# Auth: PyPI Trusted Publishing (OIDC, id-token: write) is preferred (no +# long-lived token). A token fallback mirroring publish-pypi.yml is shown as a +# commented alternative on the publish step. Refs #256, #229, #225, #163, #234. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-manylinux: + name: cibuildwheel chilmesh_cpp (manylinux x86_64) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.21.3 + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse src/chilmesh_cpp + env: + CIBW_BUILD: "cp310-* cp311-* cp312-*" + CIBW_ARCHS_LINUX: "x86_64" + CIBW_SKIP: "*-musllinux*" + CIBW_TEST_REQUIRES: "numpy" + # Same functional smoke as build-cpp-wheels.yml: run full_init on a + # 2-triangle unit square and assert adjacency + skeleton so a broken + # binary cannot be published. + CIBW_TEST_COMMAND: >- + python -c "import numpy as np, chilmesh_cpp; + pts = np.array([[0.,0.],[1.,0.],[1.,1.],[0.,1.]], dtype=np.float64); + conn = np.array([[0,1,2],[0,2,3]], dtype=np.int32); + m = chilmesh_cpp.full_init(pts, conn); + assert m.n_verts == 4, m.n_verts; + assert m.n_elems == 2, m.n_elems; + assert m.n_edges == 5, m.n_edges; + assert m.adjacency_built, 'adjacency not built'; + assert m.n_layers >= 1, m.n_layers; + assert np.asarray(m.edge2vert).shape == (5, 2), np.asarray(m.edge2vert).shape; + print('chilmesh_cpp full_init smoke OK:', m.n_verts, 'v', m.n_elems, 'e', m.n_edges, 'edges', m.n_layers, 'layers')" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: chilmesh-cpp-manylinux-wheels + path: wheelhouse/*.whl + if-no-files-found: error + + sdist: + name: build chilmesh_cpp sdist + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - name: Build sdist + run: pipx run build --sdist --outdir dist src/chilmesh_cpp + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: chilmesh-cpp-sdist + path: dist/*.tar.gz + if-no-files-found: error + + publish: + name: publish chilmesh_cpp to PyPI + needs: [build-manylinux, sdist] + runs-on: ubuntu-latest + timeout-minutes: 15 + # Protect with required reviewers in repo settings (Settings → Environments → pypi). + environment: pypi + permissions: + id-token: write # PyPI Trusted Publishing (OIDC) — no long-lived secret + steps: + - name: Download all wheel + sdist artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + # Token fallback — if PyPI Trusted Publishing is NOT configured for + # chilmesh-cpp, drop the `id-token: write` permission above and uncomment: + # with: + # password: ${{ secrets.PYPI_API_TOKEN }} + with: + packages-dir: dist + skip-existing: true diff --git a/.planning/008-DECISION.md b/.planning/008-DECISION.md index e13bfffd..0dec4656 100644 --- a/.planning/008-DECISION.md +++ b/.planning/008-DECISION.md @@ -4,6 +4,15 @@ **Date:** 2026-05-22 **Status:** FINAL (pending benchmark data) +> **UPDATE 2026-07-14 — superseded outcome.** The Rust/quad-edge port described +> here was built (`src/chilmesh_core`) and has since been measured and **FROZEN**: +> it is output-equivalent to Python but ~2–5× slower than the C++ backend on full +> init, with no performance niche over it. The "pending benchmark data" is now +> filled — see [`docs/RUST_EVALUATION.md`](../docs/RUST_EVALUATION.md) and +> `src/chilmesh_core/STATUS.md`. C++ is the acceleration path; the forward plan is +> prebuilt C++ wheels ([`docs/dev/PREBUILT_WHEELS_PLAN.md`](../docs/dev/PREBUILT_WHEELS_PLAN.md)), +> not further Rust work. This decision record is retained as history. + --- ## Executive Summary diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index b686f9b3..f610bc99 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -2,6 +2,14 @@ **Analysis Date:** 2026-05-21 +> **UPDATE 2026-07-14.** The "Rust/C++ extension" optimization opportunity below +> has been resolved: the **C++** half-edge backend is built, bit-identical, and is +> the acceleration path (~5× faster than the Rust port). The **Rust** extension was +> also built, measured, and is now **FROZEN** (no perf niche over C++) — see +> [`docs/RUST_EVALUATION.md`](../../docs/RUST_EVALUATION.md). The remaining lever is +> distribution (prebuilt C++ wheels), not a new language port: +> [`docs/dev/PREBUILT_WHEELS_PLAN.md`](../../docs/dev/PREBUILT_WHEELS_PLAN.md). + ## Language & Performance Optimization Opportunities ### 1. Skeletonization Bottleneck (Medium Priority) diff --git a/.planning/project_plan.md b/.planning/project_plan.md index c91dc207..93a36ad9 100644 --- a/.planning/project_plan.md +++ b/.planning/project_plan.md @@ -1,5 +1,10 @@ # CHILmesh Project Plan & Roadmap +> ⚠️ **STALE — historical, not the live roadmap (banner added 2026-07-27).** +> This document describes the 2026-04-26 `v0.1.1 → v0.2.0` plan. The repo is now **v1.4.1** (stable; C++/Rust half-edge backends, full mutation API `#94`, fort.14/.2dm/fort.13 I/O, lazy `summary()`). The "You are here" / Phase-0 markers below are ~15 months out of date. +> **Live status & roadmap → [README §Status & Roadmap](../README.md#status--roadmap).** +> Disposition (retire this file vs. refresh it) is an open operator decision — see [#261](https://github.com/domattioli/CHILmesh/issues/261). Banner added pending that call; the content below is preserved unmodified as a historical record. + **Current Version:** 0.1.1 (Alpha) **Planning Date:** 2026-04-26 **Horizon:** 12 months (through Q1 2027) diff --git a/.release-ignore.toml b/.release-ignore.toml new file mode 100644 index 00000000..08be3625 --- /dev/null +++ b/.release-ignore.toml @@ -0,0 +1,10 @@ +# Repo-local extension of DomI's canonical release-ignore baseline +# (DomI skills/release-integrity/policy/release-ignore.toml). Additive only. +# Regenerate channel files: python release_ignore.py generate (release-integrity skill). + +[extend] +# docs/BENCHMARK.md and other computational docs ship (operator ruling +# 2026-07-14); only AI dev-workflow material is excluded. +exclude = [ + "docs/LEXICON_PROPOSAL.md", +] diff --git a/.specify/specs/003-skeletonize-medial-rule/research.md b/.specify/specs/003-skeletonize-medial-rule/research.md new file mode 100644 index 00000000..46b91b87 --- /dev/null +++ b/.specify/specs/003-skeletonize-medial-rule/research.md @@ -0,0 +1,174 @@ +# Research: the precise peel rule for `skeletonize()` + +**Issue**: [#223](https://github.com/domattioli/CHILmesh/issues/223) — Implement a real +`skeletonize()`, distinct from the layer peel (`_peel`, formerly `_layerize`/`_skeletonize`). +**Status**: research note (advance-only; no implementation until the operator ratifies a rule). +**Author of the decision**: the operator (this is the CHILmesh author's specialized algorithm; a +routine session may *specify* the options but must not *pick* the semantics). + +## Why this note exists + +#223 is blocked on a single decision — **which peel rule `skeletonize()` implements** — and has +been unanswered for 5+ weeks (flagged in the #261 zoom-out as a decision-limbo bottleneck). The +issue offers three candidate rules; the author leans toward option (1) but explicitly asked for +confirmation "before implementation so we don't ship a third wrong version." + +This note does three things so the decision becomes cheap: + +1. Surfaces a **contradiction inside `docs/CONCEPTS.md`** about what `skeletonize()` even *is* — the + real reason the decision is hard. +2. Specifies option (1) concretely against the *current* code (`_peel`, post-#187), so it is + ratify-or-redirect rather than re-derive. +3. States a validation plan and the naming consequences of each choice. + +It does **not** add code, and it does not flip any label on its own. + +## The blocking contradiction: `CONCEPTS.md` defines `skeletonize()` two incompatible ways + +`docs/CONCEPTS.md` is the repo's canonical explainer for the four constructs (distance field → +medial axis → skeleton → layers). It disagrees with itself on `skeletonize()`: + +- **Line 19** (the reserved-name table row) says: *"`skeletonize()` — reserved, **medial-axis only** + (#223, unimplemented)."* → this is **option (1)**, a *geometric* medial-axis extraction. +- **Lines 90–105** (`## The two mesh operations: _peel vs skeletonize`) describe `skeletonize()` as + *"remove only removable (connectivity-preserving) boundary elements ... homotopy-equivalent + thinning ... the medial spine remains"* → this is **option (2)**, a *topological* skeleton + (Zhang–Suen-style thinning on the face complex). + +The same document's own `## Skeleton` vs `## Medial axis` sections (lines 40–69) are explicit that +these are **different constructs produced by different procedures**: the medial axis is the exact +distance ridge (may branch to every corner; can be disconnected/spurious), while the skeleton is a +topology-preserving thinning (connected by construction, 1-wide). So the two `skeletonize()` +definitions in CONCEPTS.md are not two phrasings of one idea — they name two different outputs. + +**This is why the three prototypes in #223 each failed one way or another:** they were chasing a +target the docs never pinned down. The decision #223 needs is not "which of three algorithms is +least buggy" — it is **"which construct does the reserved name `skeletonize()` denote?"** Everything +else follows. + +## Current code the implementation would build on (post-#187) + +`_peel()` (`src/chilmesh/CHILmesh.py:1128`) is a vectorized inward ring removal. Per pass `iL` it +records, in `self.layers`: + +| key | contents | +|---|---| +| `OV[iL]` | outer-ring vertices bounding the layer's outer side | +| `OE[iL]` | elements adjacent to the layer's boundary edges | +| `IE[iL]` | active elements adjacent to any edge touching an `OV[iL]` vertex | +| `IV[iL]` | vertices of `OE ∪ IE` not in `OV[iL]` | +| `bEdgeIDs[iL]` | boundary edges defining this layer's outer frontier | + +`n_layers = max_e ℓ(e) + 1` is the graph-distance-to-boundary in elements (a quantized, +mesh-discrete analogue of the distance field). The public verb is `peel_layers()`; the medial-axis +name `skeletonize` is reserved and **not even a stub** today (the `_skeletonize` compat alias +referenced in the #223 body was dropped by the #187 rename). So there is no back-compat surface to +preserve — `skeletonize()` is a clean-slate addition. + +The important reuse fact: `_peel` already computes, for every element, the pass at which the +advancing front reached it. That *is* the grassfire arrival time. Option (1) below is essentially +"detect where fronts arriving from different boundaries meet," and all the arrival-time data it needs +is the layer decomposition `_peel` already produces. + +## Option (1) — front-collision medial extraction (grassfire), specified + +**Construct produced:** the medial axis (option (1) in #223; matches CONCEPTS.md **line 19**). + +**Intuition:** the medial axis is the grassfire wavefront-collision locus (CONCEPTS.md line 48) — +the points where fires lit simultaneously along the whole boundary and burning inward at unit speed +meet head-on. In the mesh-discrete setting the "fire" is exactly `_peel`'s advancing front, and the +"arrival time" of element `e` is its layer index `ℓ(e)`. + +**Rule.** An element is *medial* when the peel front reaches it from **two distinct boundary +directions at (nearly) the same time** — i.e. it is a local maximum of the graph-distance-to-boundary +field, or it sits on a ridge between two receding fronts. Concretely, a candidate definition to +ratify or amend: + +1. Compute `ℓ(e)` for every element via `_peel` (already done; it is the layer index). +2. Build the element dual graph (elements are nodes; share-an-edge is an adjacency — + `Edge2Elem` gives this directly). +3. Mark element `e` **medial** if it has no dual-neighbor with strictly greater `ℓ` — i.e. `e` is a + local maximum / ridge element of the discrete distance field. (Equivalently: the front cannot + advance past `e` into a deeper element, so two fronts collided at `e`.) +4. Record, per medial element, the peel pass `ℓ(e)` that produced it (the #223 output contract: + "skeleton elements + the peel order that produced them"). + +**Why this is the right shape for a *medial* target:** it is connected-by-front-collision on +simply-connected regions, branches toward genuine corners (as the true medial axis does), and reuses +the layer-collision data the issue itself points at (`IV`/`IE`). It is `O(n)` on top of the existing +`O(n)` peel. + +**Known caveats to decide in the plan, not now:** +- The medial axis is *allowed* to branch and to be locally >1 element wide at coarse resolution — it + is not required to be a clean 1-wide curve (that is the *skeleton*, option 2). Acceptance tests + must grade it as a medial axis (ridge coverage), not as a thinned skeleton (1-width). +- Multiply-connected domains (holes/islands): fronts also advance outward from inner boundaries, so + the ridge forms between outer and inner fronts. `_peel` already seeds from all boundary edges + (including inner rings), so `ℓ(e)` is the min distance to *any* boundary — correct for this. +- Local-max ties on flat plateaus (uniform `ℓ` bands) can thicken the ridge; a tie-break + (e.g. keep the plateau element nearest the centroid of its `ℓ`-band component) is a plan-level + refinement, not a semantic change. + +## Option (2) — topology-preserving thinning (skeleton), for contrast + +**Construct produced:** the topological skeleton (option (2) in #223; matches CONCEPTS.md +**lines 90–105**). + +**Rule.** Iteratively remove *simple* boundary elements (deletion preserves homotopy type) while +*retaining endpoints*, à la Zhang–Suen but defined on a 2-D tri/quad face complex. Terminates at a +1-wide connected spine. + +This is a materially larger piece of work: it needs precise definitions of "simple element" and +"endpoint element" for tri/quad faces, plus thinning-mask timing to avoid the two failure modes the +#223 prototypes already hit (local-thinning → 711 disconnected components; global +connectivity-preserving → collapse to 1 element). It is the *only* option that yields the +connectivity-preserving, homotopy-equivalent object the CONCEPTS.md peel-rule table describes. + +## Option (3) — innermost-layer derivation, for completeness + +`skeleton = elements in the deepest k layers`. The #223 prototype showed this is clean and connected +(34/2276 elems, 1 component on the L-shape) — but it is literally `_peel`'s *output sliced*, not a +distinct peel with its own rule. It cannot honor the issue's framing ("a distinct skeletonization +peel") and adds no construct the layer index doesn't already give. Include only as the trivial +fallback if the operator decides `skeletonize()` should not be a separate algorithm at all. + +## Recommendation (routine session — advisory only) + +Ratify **option (1)** and, in the same pass, **fix the CONCEPTS.md contradiction to match**: + +- It aligns with CONCEPTS.md **line 19**'s existing reserved-name definition ("medial-axis only"). +- It reuses the front-collision data the issue itself identifies, is `O(n)` on top of `_peel`, and is + connected-by-construction on simply-connected regions. +- It is far less code than option (2) and — unlike option (3) — is a genuine distinct rule. + +If instead the operator wants the **homotopy-preserving, 1-wide skeleton** described in CONCEPTS.md +lines 90–105, that is **option (2)**, a different construct, and this note's specification does not +apply — a separate thinning spec (simple-element + endpoint definitions) would be needed. Either way, +**CONCEPTS.md must be made self-consistent** as part of #223: the reserved-name row and the peel-rule +table currently promise two different objects under one name. + +## Validation plan (applies once a rule is ratified) + +- **Fixtures:** annulus, donut (has a hole), block_o, structured, plus the L-shape used in the #223 + prototype table — cover simply- and multiply-connected, tri and quad. +- **For option (1) (medial):** assert ridge elements are local maxima of `ℓ`; assert every interior + element is within one dual-hop of a medial element (ridge coverage); assert the medial set is + connected per connected component of the domain (allowing branches). Do **not** assert 1-width. +- **For option (2) (skeleton):** assert the result is 1-element-wide, connected, and + homotopy-equivalent (same component + hole count as the input via Euler characteristic). +- **Both:** `skeletonize()` must not mutate `self.layers` / `_peel` output; a `peel_layers()` call + before and after `skeletonize()` returns identical layers. + +## What the operator needs to decide (one line) + +Pick the construct the reserved name `skeletonize()` denotes: **(1) medial axis** (front-collision, +spec'd above — recommended), **(2) topological skeleton** (thinning; needs its own spec), or +**(3) innermost-layer slice** (not a distinct algorithm). The CONCEPTS.md contradiction is fixed to +match whichever is chosen. + +## References + +- #223 (issue), #221 (the `_layerize` rename), #187 (rename to `_peel`, dropped `_skeletonize` alias) +- `docs/CONCEPTS.md` — the four-construct explainer (and the contradiction this note flags) +- `src/chilmesh/CHILmesh.py:1128` (`_peel`), `:1251` (`peel_layers`) +- Blum (1967) grassfire/MAT; Zhang & Suen (1984) thinning diff --git a/.specify/specs/004-mesh-cartograms/research.md b/.specify/specs/004-mesh-cartograms/research.md new file mode 100644 index 00000000..9acaa69b --- /dev/null +++ b/.specify/specs/004-mesh-cartograms/research.md @@ -0,0 +1,132 @@ +# research: size-controlled mesh cartograms (#219) + +Advance-only research note for [#219](https://github.com/domattioli/CHILmesh/issues/219) +(`request: research` / `status: brainstorming` / `priority: someday`). Grounds the +proposed `mesh.cartogram(...)` API against the **current** CHILmesh plotting + layer +API so a later implementer starts from real call sites, not the issue's prose. No code +shipped; no label flip (advance only, per the brainstorming entitlement). + +--- + +## 1. Problem (restated, one line) + +An area-weighted `tripcolor` of a non-uniform mesh spends most of the canvas on the +few coarse elements and shrinks the many refined elements — where the scalar signal +concentrates — to sub-pixel threads. A cartogram equalizes *visual* weight per element +so a per-element (or per-vertex) scalar field is readable regardless of geometric size. + +## 2. Existing substrate to compose (verified in-tree) + +The cartogram is **additive** — it re-lays-out element geometry, then hands the new +polygons to the plotting path that already exists. No new render engine. + +| Need | Existing API (verified) | File | +|---|---|---| +| per-element polygons from `(points, connectivity)` | `build_polygons(points, connectivity, ...)` | `src/chilmesh/chilplotting.py:100` | +| scalar → colormap fill of a polygon set | `plot_filled(points, connectivity, *, values, cmap, vmin, vmax, ...)` | `chilplotting.py:205` | +| length-check `len(values) == n_elems` | already enforced in `plot_filled` | `chilplotting.py:227` | +| per-layer boundary vertex **path ordering** | `paths_on_outer_vertices(mesh, layer_idx) -> list[np.ndarray]` | `src/chilmesh/layer_paths.py:51` | +| layer decomposition (OE/IE/OV/IV per layer) | `mesh.layers` dict of lists | `CHILmesh.py:307`, docstring `:53-56` | +| axis config / limits | `configure_axes`, `_new_ax` | `chilplotting.py:72,162` | + +Consequence: `noncontig` and `hybrid` need only a **centroid + rescale** step, then +`PolyCollection` fill; `unrolled` is the only variant that consumes `mesh.layers` + +`paths_on_outer_vertices`, both of which already exist. + +## 3. Proposed API + +```python +fig, ax = mesh.cartogram( + values, # (n_elems,) or (n_verts,) scalar; per-vert reduced to per-elem by mean + kind="unrolled", # "noncontig" | "unrolled" | "hybrid" + reference="mean", # hybrid only: mean|median|mode|max|min → element extent ∝ |value-ref|+eps + cmap="inferno", + ax=None, +) -> tuple[Figure, Axes] +``` + +Thin instance method on `CHILmesh` delegating to a new `chilplotting.cartogram(mesh, values, kind, ...)` +free function (keeps the mixin thin, mirrors the `chilplotting.plot`/`axis_chilmesh(mesh)` pattern at +`chilplotting.py:299,304`). Returns Matplotlib handles like every other plot fn here. + +## 4. Algorithm per `kind` (grounded) + +- **`noncontig` — equal-area, geographic position preserved** + + I. compute each element centroid `c_e` from `points[connectivity[e]]` (mean of its verts). + + II. redraw every element as a fixed-area glyph about `c_e` (square or the element's own shape + scaled to a constant target area `A = bbox_area / n_elems`). Position = true centroid → the + map still reads geographically; only per-element *area* is equalized. + + III. hand the rescaled polygons + `values` to the `PolyCollection` fill (reuse `plot_filled`'s + norm/cmap branch, `chilplotting.py:225-233`). + + Known cost: equal-area glyphs at true centroids **overlap** in refined bands and **gap** in coarse + ones — acceptable for a first cut; the Dorling equal-circle / Gastner–Newman diffusion variants in + the backlog fix overlap but are materially more code (deferred, §7). + +- **`unrolled` — skeleton layers as equal-height bands** + + I. peel layers already available in `mesh.layers` (`_peel` output). Each layer → one horizontal band. + + II. within a band, order elements by the **OV path arc-length** from `paths_on_outer_vertices(mesh, i)` + (true boundary order) — falls back to centroid-angle if a layer has no clean path (islands / multi-loop). + + III. each element → an equal-width cell in its band; colour by `values`. Band height = equal-per-layer + (v1); count-weighted / log-count are backlog knobs (§7). + + This is the variant that exposes the #219 motivating result (deep small layers L21–L25 clustering) — + it deliberately discards geography for a layer×angle readout. + +- **`hybrid` — focus+context** + + element extent ∝ `|value - reference| + eps`, `reference ∈ {mean, median, mode, max, min}` of `values`. + Same centroid-anchored redraw as `noncontig` but the per-element target area is signal-driven, so + outliers vs the reference grow and on-reference elements shrink. Reuses the `noncontig` layout code + with a variable area vector. + +## 5. Dependencies / gating + +- **matplotlib only** for v1 — no new extra. `PolyCollection` is already the render path + (`plot_filled`), so cartograms inherit the same headless/`Agg` behavior and the same + ~11.6 µs/element raster ceiling measured in #167. Large-mesh (10⁶+) batching/decimation is a + backlog item, NOT a v1 blocker (the QuADMESH study meshes are ≤10⁵ elems). +- No GPU dependency — this is orthogonal to the #167 GPU-backend track (that issue is env-blocked; + this one is not, it composes the existing matplotlib path). + +## 6. Validation / test plan + +- **API/shape tests** (headless `MPLBACKEND=Agg`, mirrors existing plotting tests): each `kind` + returns `(Figure, Axes)`; `values` length mismatch raises (reuse the `plot_filled` guard); per-vertex + input is reduced to per-element without error. +- **Invariant tests**: `noncontig`/`hybrid` preserve element **count** (n polygons out == n elems in); + `unrolled` places every element in exactly one band and `sum(cells per band) == n_elems`. +- **Reference reproduction** (acceptance, #219): regenerate the QuADMESH pass-frequency cartograms + (PR #96 / #97 `experiments/mc_layer_pass/`) directly from a CHILmesh mesh + a per-element scalar, + asserting the fine-coastal-band structure is visible (non-background pixel fraction for the refined + 9.9%/4.9% element sets exceeds the plain-map baseline). Gallery doc page (MATLAB-help register docstrings). + +## 7. Backlog (from the issue, tiered so v1 stays small) + +Deferred past v1 (each is its own follow-up, not a v1 blocker): Dorling equal-circle + Gastner–Newman +contiguous diffusion (overlap/gap fix); count-weighted / log-count band heights; true OV arc-length vs +centroid-angle within-band ordering for multi-loop layers + islands; diverging colormaps + percentile-clip ++ colorblind-safe defaults; `PolyCollection` batching/decimation for 10⁶ elems; optional hover interactivity. + +## 8. Open questions for the operator (toward `ready`) + +1. **v1 scope** — ship all three `kind`s, or land `noncontig` + `unrolled` first and defer `hybrid`? + (`hybrid` reuses `noncontig` layout, so marginal cost is low — leaning all-three.) +2. **`noncontig` glyph** — fixed square vs the element's own shape scaled to constant area? (square is + simpler + reads as a cartogram; shape-preserving is prettier but adds per-element affine work.) +3. **`unrolled` band height** default — equal-per-layer (simplest, recommended v1) vs count-weighted? +4. **Priority** — #219 is `someday`; confirm it stays deferred, or promote if the QuADMESH pass-frequency + visualization is now a near-term need. + +A one-line answer to Q1–Q3 turns this into a `status: ready` spec (plan → tasks → implement). Until then +`mesh.cartogram()` is intentionally **not** added. + +--- + +_[model: claude-opus-4-8, repo: CHILmesh, session: 013NhKuhHJwC9wmmysELCufc, refs: #219, #167, QuADMESH#96/#97]_ diff --git a/.zenodoignore b/.zenodoignore new file mode 100644 index 00000000..b15861ed --- /dev/null +++ b/.zenodoignore @@ -0,0 +1,22 @@ +# >>> release-ignore:begin (generated by DomI release-integrity — do not edit inside; edit .release-ignore.toml + regenerate) +.claude/ +.claude-plugin/ +.introspect/ +.githooks/ +.specify/ +.planning/ +.domi-pin +.zenodoignore +.release-ignore.toml +CLAUDE.md +AGENTS.md +claude_routine_instructions.md +instructions_on_start.sh +scripts/hooks/ +docs/sessions/ +docs/introspections/ +specs/ +*introspect*.yml +.github/ +docs/LEXICON_PROPOSAL.md +# <<< release-ignore:end diff --git a/CHANGELOG.md b/CHANGELOG.md index 932a0381..2970762b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and the project adheres to [Semantic Versioning](https://semver.org/). _Nothing yet._ +## [1.4.1] — 2026-07-14 + +### Fixed +- **Rust backend `get_vertex_edges` was O(_n_) per call** — it rebuilt the full canonical edge list on every call (100–1800× slower than C++/Python, growing with mesh size). `build_adjacencies` now caches the vertex→edge index once (built from the same `to_edge2vert` source, so output is bit-identical); queries are O(1) (Block_O 954 μs → 0.32 μs). All 76 `test_backend_equivalence.py` cases still pass. + +### Changed +- **Rust backend (`chilmesh_core`) is now FROZEN** — kept and output-equivalent to Python (layer-peel parity reached — `n_layers`, layer members, edge ordering — #163 closed), but **not developed further** and not the recommended accelerator. A measured cross-backend evaluation found it ~2–5× slower than C++ on full init with no performance niche over it; C++ remains the acceleration path. Supersedes the earlier "Rust skeletonization is incomplete" limitation. Status banner: `src/chilmesh_core/STATUS.md`. + +### Docs +- **`docs/RUST_EVALUATION.md`** — first like-for-like measured comparison of the Python / C++ / Rust backends, the "could Rust replace Python as the default?" analysis, and the freeze recommendation. +- **`docs/dev/PREBUILT_WHEELS_PLAN.md`** — phased plan to publish prebuilt C++ binary wheels to PyPI (`pip install chilmesh[cpp]`) so the C++ speedup needs no user toolchain (#229). README "Backends" now documents the selection order (C++ → Rust → Python) and opt-in reality (both compiled backends are source builds; neither is lighter). + ## [1.4.0] — 2026-07-11 **Minor release.** The #187 lexicon ratification renames public API symbols with no compatibility aliases. These renames occur during the pre-adoption window with **no known downstream consumers**, so the release is versioned as a minor bump (1.3.0 → 1.4.0) rather than a major one; anyone pinning the old names should pin `chilmesh<1.4` and migrate at their convenience. Also consolidates the Valence→CHILmesh upstreaming (geometry + CFL gate) and fort.14 robustness fixes, and ships the reworked README hero animation. diff --git a/CITATION.cff b/CITATION.cff index 55ca7a83..db859baf 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -8,7 +8,7 @@ type: software authors: - family-names: Mattioli given-names: Dominik O. - affiliation: Pennsylvania State University + affiliation: Unaffiliated # orcid: https://orcid.org/0000-0000-0000-0000 # TODO: fill if available - family-names: Kubatko given-names: Ethan J. @@ -17,8 +17,8 @@ authors: repository-code: "https://github.com/domattioli/CHILmesh" url: "https://github.com/domattioli/CHILmesh" license: PolyForm-Noncommercial-1.0.0 -version: 1.4.0 -date-released: 2026-07-11 +version: 1.4.1 +date-released: 2026-07-14 keywords: - mesh-processing - mesh-smoothing @@ -30,7 +30,7 @@ keywords: - python identifiers: - type: doi - value: 10.5281/zenodo.20263854 + value: 10.5281/zenodo.21199161 description: Zenodo DOI (first archive — update with concept DOI once a second release lands) preferred-citation: type: thesis diff --git a/MANIFEST.in b/MANIFEST.in index 3d8e69b1..e22e3b66 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -44,3 +44,29 @@ include output/readme_pipeline_annulus.gif global-exclude __pycache__ global-exclude *.py[co] global-exclude .DS_Store + +# >>> release-ignore:begin (generated by DomI release-integrity — do not edit inside; edit .release-ignore.toml + regenerate) +prune .claude +prune .claude-plugin +prune .introspect +prune .githooks +prune .specify +prune .planning +global-exclude .domi-pin +global-exclude .zenodoignore +global-exclude .release-ignore.toml +global-exclude CLAUDE.md +global-exclude AGENTS.md +global-exclude claude_routine_instructions.md +global-exclude instructions_on_start.sh +prune scripts/hooks +prune docs/sessions +prune docs/introspections +prune specs +global-exclude *introspect*.yml +prune .github +prune tests +global-exclude .gitattributes +global-exclude .gitignore +exclude docs/LEXICON_PROPOSAL.md +# <<< release-ignore:end diff --git a/README.md b/README.md index eadd7284..c5f2c2c0 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,13 @@ Open issues - DOI + DOI License

-> **MATLAB users:** This Python library is the actively-developed successor to the original MATLAB codebase. The original (no longer maintained) is at [`src/@CHILmesh/CHILmesh.m`](src/@CHILmesh/CHILmesh.m) and on MATLAB File Exchange +> **MATLAB users:** This Python library is the actively-developed successor to the original MATLAB codebase. The original (no longer maintained) is at [`src/@CHILmesh/CHILmesh.m`](src/@CHILmesh/CHILmesh.m) and on MATLAB File Exchange Want the fast C++ backend from MATLAB? Call the Python API through MATLAB's `py.` bridge (e.g. `py.chilmesh.Mesh.read_from_fort14('ocean.14')`) after `pip install chilmesh` — no MEX build. --- @@ -48,10 +48,10 @@ ## Status & Roadmap -**Current status (June 2026): Stable and actively-maintained.** C++ half-edge backend (up to ~15× faster on full init); bit-identical output verified; cross-backend equivalence tests across C++ and Rust; fort.14 + .2dm + fort.13 I/O; mixed-element support; full mesh-mutation API (split/swap/merge/collapse, [#94](https://github.com/domattioli/CHILmesh/issues/94)); lazy header-only `summary()`. +**Current status (July 2026): Stable and actively-maintained.** C++ half-edge backend (up to ~15× faster on full init); bit-identical output verified; cross-backend equivalence tests across C++ and Rust; fort.14 + .2dm + fort.13 I/O; mixed-element support; full mesh-mutation API (split/swap/merge/collapse, [#94](https://github.com/domattioli/CHILmesh/issues/94)); lazy header-only `summary()`. -- **Now:** Pre-built binary wheels (cibuildwheel, manylinux/macOS/Windows); Rust layer-peel completion ([#163](https://github.com/domattioli/CHILmesh/issues/163)). -- **Next:** performance optimization; parallelization; conda-forge packaging; mkdocs API site; native `.chil` file format +- **Now:** Publish pre-built binary **C++** wheels to PyPI — `pip install chilmesh[cpp]`, no toolchain ([#256](https://github.com/domattioli/CHILmesh/issues/256)). +- **Next:** native `.chil` file format ([#201](https://github.com/domattioli/CHILmesh/issues/201)); documentation site. - **Future:** formal integration within a unified ecosystem including ADMESH PyPI version and QuADMESH PyPI version --- @@ -72,7 +72,6 @@ ```bash pip install chilmesh # PyPI uv pip install chilmesh # uv -conda install -c conda-forge chilmesh # conda-forge (pending) pip install -e . # from source ``` @@ -119,20 +118,22 @@ Reference workload: **EasternPacific_ENPAC2003** — 272,913 vertices · 531,680 | Stage | MATLAB (Octave) ‡ | Python | C++ | Rust | |---|---:|---:|---:|---:| -| Fast init (adj, no peel) | 2.738 s | 6.454 s | 0.769 s | tbd | -| Peel only | 12.771 s | 5.814 s | 0.669 s | tbd | -| Full init (adj + peel) | 16.677 s | 12.300 s | 1.438 s | tbd | -| Quality (signed area) | 75 ms | 51 ms | 7 ms | tbd | +| Fast init (adj, no peel) | 2.738 s | 6.454 s | 0.769 s | n/a § | +| Peel only | 12.771 s | 5.814 s | 0.669 s | n/a § | +| Full init (adj + peel) | 16.677 s | 12.300 s | 1.438 s | 11.98 s § | +| Quality (signed area) | 75 ms | 51 ms | 7 ms | 2 ms § | Like-for-like: every backend runs the same operation on the same in-memory arrays. No fort.14 parse, signed-area quality. All resolve `n_layers = 75`; Python↔C++ layers are bit-identical ([`test_backend_equivalence.py`](tests/test_backend_equivalence.py)). - **C++ leads every stage** — full init 8.6× over Python, 11.6× over Octave. - **Octave builds adjacency 2.4× faster than Python** — `sparse()`-accumulated, in compiled built-ins. - **Python peels 2.2× faster than Octave** — ~26% ahead on full init overall. -- **Rust** — the layer peel now matches Python on `n_layers`, layer-member sets (OE/IE/OV/IV), per-layer `bEdgeIDs` (full-mesh edge IDs, ascending), full-mesh `Edge2Vert`/`Vert2Edge` ordering, and signed areas — verified by the `rust-equivalence` CI job across all four fixtures incl. `block_o` ([#163](https://github.com/domattioli/CHILmesh/issues/163)); only perf timings (`tbd`) remain open. +- **Rust** — the layer peel matches Python on `n_layers`, layer-member sets (OE/IE/OV/IV), per-layer `bEdgeIDs` (full-mesh edge IDs, ascending), full-mesh `Edge2Vert`/`Vert2Edge` ordering, and signed areas — verified by the `rust-equivalence` CI job across all four fixtures incl. `block_o` ([#163](https://github.com/domattioli/CHILmesh/issues/163)). Perf is now measured at ENPAC scale too (the mesh is reachable in-environment from the [Valence](https://github.com/domattioli/Valence) sibling checkout — [#250](https://github.com/domattioli/CHILmesh/issues/250)): on the bundled fixtures **Rust full-inits ~3–5× faster than Python but ~2–5× slower than C++**, but at the 531,680-element ENPAC2003 workload **Rust full-init closes to ≈ Python (11.98 s vs 11.89 s same-machine) and runs ~15× slower than C++** — the small-mesh Rust-over-Python edge does not hold at scale. Its `get_vertex_edges` query path was O(_n_) per call (rebuilt the edge list each call); that defect is **now fixed** — the vertex→edge index is cached, so queries are O(1) (Block_O 954 μs → 0.32 μs, 76/76 equivalence tests still pass). Full data, methodology, the "should Rust replace Python anywhere?" analysis, and the default-backend/opt-in discussion: [`docs/RUST_EVALUATION.md`](docs/RUST_EVALUATION.md). Bottom line: **C++ remains the acceleration path; Rust earns no perf niche over it.** ‡ Octave 8.4, interpreter. Times are in-memory compute only — fort.14 parse and rendering excluded. Machine-dependent. Full method: [`docs/BENCHMARK.md`](docs/BENCHMARK.md). +§ Rust ENPAC2003 cells measured separately on the cloud reference machine (x86_64, Python 3.11, chilmesh 1.4.1) once the mesh became reachable in-environment from the [Valence](https://github.com/domattioli/Valence) sibling checkout ([#250](https://github.com/domattioli/CHILmesh/issues/250)); same-machine controls there were Python full-init 11.89 s / C++ 0.803 s (both within noise of the MATLAB-1.2.2 columns above), so the Rust column is machine-consistent against those controls. Rust doesn't expose `fast_init`/peel separately ([#163](https://github.com/domattioli/CHILmesh/issues/163)) → only full-init + quality are measurable; `n_layers = 75` matched all backends. +

EasternPacific_ENPAC2003 quality plot and distribution
@@ -165,7 +166,7 @@ Three algorithms — each preserves boundary nodes, leaves topology unchanged, a |---|---|---| | **Python** | Reference implementation — the default | `pip install chilmesh` | | **C++** | High-performance backend (half-edge) — bit-identical output | `pip install ./src/chilmesh_cpp` (or `bash scripts/build_cpp.sh`) | -| Rust | Experimental (quad-edge); the layer peel (backend `skeletonize()`) reaches full `n_layers`/layer-member/`bEdgeIDs`/edge-ordering parity with Python (`rust-equivalence` CI, all 4 fixtures incl. `block_o`), perf timings still open — see [#163](https://github.com/domattioli/CHILmesh/issues/163) | source build, not recommended yet | +| Rust | ❄️ **Frozen** (experimental quad-edge); output-equivalent to Python (`rust-equivalence` CI, all 4 fixtures incl. `block_o`) but **not developed further**. **Measured ~2–5× slower than C++ on full init** on bundled fixtures (and ~15× slower at the 531k-element ENPAC2003 scale, where it converges to ≈ Python; queries were O(_n_)/call, now cached to O(1)) — [`docs/RUST_EVALUATION.md`](docs/RUST_EVALUATION.md) concludes it earns no perf niche over C++ | source build, not recommended | | MATLAB | Original 2017 implementation, archived & unmaintained | [`src/@CHILmesh/CHILmesh.m`](src/@CHILmesh/CHILmesh.m) | ```python @@ -180,7 +181,9 @@ chilmesh.backend_info() > **PyPI installs are pure-Python.** The example above reflects a **source build** of the C++ extension. A plain `pip install chilmesh` from PyPI currently ships **no compiled extension**, so `backend_info()` reports `{'available': ['python'], 'selected': 'python'}` ([#229](https://github.com/domattioli/CHILmesh/issues/229)). Build from source (`pip install ./src/chilmesh_cpp`) for the C++ path until pre-built binary wheels land. -Force a specific backend with `CHILMESH_BACKEND` (`python` or `cpp`). When unset, the fastest available is picked. The cpp↔python bit-identity guarantee is gated in CI by the `cpp-equivalence` job (ubuntu), which builds the extension and runs [`tests/test_backend_equivalence.py`](tests/test_backend_equivalence.py). Pre-built binary wheels (`manylinux` / `macOS` / `Windows`) via `cibuildwheel` are planned — see [`docs/`](docs/) for build-from-source instructions. +**How the backend is chosen.** When `CHILMESH_BACKEND` is unset, CHILmesh auto-selects the fastest *available* backend, in order **C++ → Rust → Python** — so a build that has only the Rust extension will use Rust over Python automatically. Force one with `CHILMESH_BACKEND=python|cpp|rust`, and check what's active with `chilmesh.backend_info()`. + +**Opt-in reality — both compiled backends are source builds.** Neither C++ nor Rust ships in the PyPI wheel, and **Rust is not a lighter-weight opt-in than C++**: C++ needs a C++ toolchain + CMake (`pip install ./src/chilmesh_cpp`), Rust needs a Rust toolchain (`maturin build …`). Until pre-built binary wheels land ([#229](https://github.com/domattioli/CHILmesh/issues/229)), a plain `pip install chilmesh` runs pure-Python everywhere. **If you build one, build C++** — it is the recommended accelerator (~5× faster than Rust on full init, bit-identical output); the **Rust backend is frozen** (kept and output-equivalent, but not developed further — [`docs/RUST_EVALUATION.md`](docs/RUST_EVALUATION.md) explains why it earns no niche over C++ and should not replace Python). The path to making C++ the zero-opt-in default is **prebuilt binary wheels** ([`docs/dev/PREBUILT_WHEELS_PLAN.md`](docs/dev/PREBUILT_WHEELS_PLAN.md), #229), not switching languages. The cpp↔python bit-identity guarantee is gated in CI by the `cpp-equivalence` job and Rust output-parity by the `rust-equivalence` job, both of which build the extension and run [`tests/test_backend_equivalence.py`](tests/test_backend_equivalence.py). Pre-built binary wheels (`manylinux` / `macOS` / `Windows`) via `cibuildwheel` are planned — see [`docs/`](docs/) for build-from-source instructions. ### Engine @@ -221,8 +224,12 @@ Also available as `python -m chilmesh`. Each subcommand has `--help`. ## Citation -CHILmesh originated in MATLAB as the data structure backing a layer-peel-driven indirect tri-to-quad conversion heuristic (Mattioli, OSU MSc Thesis, 2017) - QuADMESH Thesis +

+ Cite via Zenodo DOI + QuADMESH Thesis +

+ +Cite the **software** via its Zenodo DOI — click the badge above, or use the BibTeX below. CHILmesh originated in MATLAB as the data structure backing a layer-peel-driven indirect tri-to-quad conversion heuristic (Mattioli, OSU MSc Thesis, 2017) — the **thesis** (badge above) is the original method reference. ```bibtex @software{mattioli_chilmesh, author = {Mattioli, Dominik O. and Kubatko, Ethan J.}, @@ -230,8 +237,8 @@ CHILmesh originated in MATLAB as the data structure backing a layer-peel-driven quadrilateral, and mixed-element grids}, year = {2026}, publisher = {Zenodo}, - version = {1.2.2}, - doi = {10.5281/zenodo.20263854}, + version = {1.4.1}, + doi = {10.5281/zenodo.21199161}, url = {https://github.com/domattioli/CHILmesh} } ``` diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md index 1e70d127..d65434d7 100644 --- a/docs/BENCHMARK.md +++ b/docs/BENCHMARK.md @@ -60,8 +60,25 @@ `n_layers = 30` on all three (parity ✅). MATLAB is the original `src/@CHILmesh` class under GNU Octave 8.4 (interpreter, not MATLAB JIT); Python's skeletonization now beats Octave, with adjacency build (pure-Python loops) the -remaining gap; C++ leads throughout. Rust is excluded — its skeletonization is -incomplete (#163). Absolute times are machine-dependent. +remaining gap; C++ leads throughout. Rust is excluded from this WNAT table +(measured separately on the bundled fixtures — see below). Absolute times are +machine-dependent. + +> **Rust backend (measured 2026-07-14).** #163 is closed and Rust is now +> output-equivalent to Python, so the earlier "skeletonization incomplete" +> exclusion is stale. On the bundled fixtures Rust full-inits ~3–5× faster than +> Python but **~2–5× slower than C++** (≈5× behind on `Block_O`). Its +> `get_vertex_edges` query path was O(_n_) per call (100–1800× slower than +> C++/Python — it rebuilt the edge list every call); that defect is **now fixed** +> — the vertex→edge index is cached in `build_adjacencies`, so queries are O(1) +> (Block_O 954 μs → 0.32 μs, on par with C++/Python, 76/76 equivalence tests +> still pass). C++ remains the acceleration path; even with queries fixed, Rust +> earns no perf niche over C++ on the hot path. The backend is now **frozen** +> (kept + output-equivalent, not developed further — `src/chilmesh_core/STATUS.md`). +> Full measured tables (incl. the before/after query column), methodology, the +> "could Rust replace Python anywhere?" analysis, and the default-backend / opt-in +> discussion: [`RUST_EVALUATION.md`](RUST_EVALUATION.md). The path to zero-opt-in +> speed is prebuilt **C++** wheels: [`dev/PREBUILT_WHEELS_PLAN.md`](dev/PREBUILT_WHEELS_PLAN.md). --- diff --git a/docs/DOWNSTREAM_MIGRATION_GUIDE.md b/docs/DOWNSTREAM_MIGRATION_GUIDE.md index 15daa766..58594e8e 100644 --- a/docs/DOWNSTREAM_MIGRATION_GUIDE.md +++ b/docs/DOWNSTREAM_MIGRATION_GUIDE.md @@ -1,7 +1,7 @@ # Downstream Project Integration Guide -**Version:** 2.0 (revised for CHILmesh v1.0.0) -**CHILmesh Version:** 1.0.0+ +**Version:** 2.1 (adds v1.4.0 #187 breaking-rename migration) +**CHILmesh Version:** 1.4.0+ **Target Projects:** MADMESHR, ADMESH, Valence --- @@ -12,6 +12,75 @@ Guide for developers of downstream research projects integrating with CHILmesh v **TL;DR:** Existing code keeps working — `CHILmesh` is still importable. The new `Mesh` alias is the v1.0.0 preferred idiom; adopt it when convenient. Optional C++ backend gives 46× speedup with bit-identical output. +> **⚠️ v1.4.0 is the exception to "existing code keeps working."** The #187 lexicon +> ratification renamed several public/semi-public symbols **without compatibility +> aliases**. If you allow `chilmesh` 1.4.x and still call an old name, you break on +> upgrade (silently at install, `AttributeError` at runtime). See +> [v1.4.0 Breaking Renames](#-v140-breaking-renames-187) below **before** widening a pin. + +--- + +## ⚠️ v1.4.0 Breaking Renames (#187) + +v1.4.0 (2026-07-11) ratified the CHILmesh skeleton/layer lexicon (#187) and renamed +the symbols below **with no compatibility aliases**. Because it shipped as a *minor* +bump, a `chilmesh>=1.x,<2` / caret pin does **not** exclude it — a consumer that (a) +admits 1.4.x and (b) still calls a renamed symbol breaks on upgrade. The +`smooth_mesh` signature change (#251) shipped in the same release and is a second +break vector. + +### Rename map + +| Old (≤1.3.x) | New (≥1.4.0) | Surface | +|---|---|---| +| `mesh.skeletonize()` | `mesh.peel_layers()` | `CHILmesh` public | +| `mesh._skeletonize()` | `mesh._peel()` | `CHILmesh` private (**removed** — no alias) | +| `_layerize` | `_peel` | `CHILmesh` private | +| `MutableMesh.reskeletonize_local(...)` | `MutableMesh.repeel_local(...)` | `chilmesh.mutations` | +| `MutableMesh.skeletonize_diff(...)` | `MutableMesh.layers_diff(...)` | `chilmesh.mutations` | +| `smooth_mesh(...)` positional splat | `smooth_mesh(method, acknowledge_change=False, *, sdf=None, size_fn=None, **kwargs)` | `CHILmesh` public (#251) | + +`skeletonize` / `_skeletonize` / `_layerize` / `reskeletonize_local` / `skeletonize_diff` +no longer exist under their old names — grep your tree for those five groups. + +### `smooth_mesh` signature (#251) + +`method` and `acknowledge_change` remain positional-or-keyword, so an existing +`mesh.smooth_mesh("laplacian", True)` or `mesh.smooth_mesh(method=..., acknowledge_change=True)` +call is safe. What changed: `sdf` and `size_fn` are now **keyword-only** — any call +that passed them positionally must switch to keyword form. + +```python +# Before (≤1.3.x) — positional sdf breaks on 1.4.x +mesh.smooth_mesh("laplacian", True, my_sdf) + +# After (≥1.4.0) — sdf/size_fn keyword-only +mesh.smooth_mesh("laplacian", True, sdf=my_sdf) +``` + +### Migrating a call site + +```python +# Before (≤1.3.x) +layers = mesh.skeletonize() + +# After (≥1.4.0) +layers = mesh.peel_layers() +``` + +To stay compatible across the boundary (support both `chilmesh<1.4` and `>=1.4`): + +```python +peel = getattr(mesh, "peel_layers", None) or mesh.skeletonize +layers = peel() +``` + +### Pin guidance + +- Pinning `chilmesh<1.4` → safe for now; plan the rename before widening the pin. +- Admitting 1.4.x **and** calling any renamed symbol → **broken on upgrade**; rename + the call sites (or add the `getattr` shim above) first. + --- ## What's New in v1.0.0? @@ -40,7 +109,12 @@ On WNAT_Hagen (52,774 verts · 98,365 elements): - C++ skeletonization in isolation: **0.033 s vs Python 2.20 s (66×)** Force a specific backend with the `CHILMESH_BACKEND` environment variable -(`python`, `cpp`, or `rust`). +(`python`, `cpp`, or `rust`). When unset, the fastest available is auto-selected +(order: C++ → Rust → Python). + +> **Downstream note:** the **Rust** backend is **frozen** — kept and +> output-equivalent, but not developed further; build **C++** for acceleration. +> See [`RUST_EVALUATION.md`](RUST_EVALUATION.md). ### New Public Surface (cumulative since v0.4.1) - `MeshAdapterForMADMESHR`, `MeshAdapterForADMESH`, `MeshAdapterForADMESHDomains` re-exported at package root. @@ -448,7 +522,8 @@ Report at: https://github.com/domattioli/CHILmesh/issues | 0.1.1 | ✅ Legacy | ✅ Legacy | ✅ Legacy | Old version, still works | | 0.2.0 | ✅ Recommended | ✅ Recommended | ✅ Recommended | Current, use this | | 0.2.x | ✅ Recommended | ✅ Recommended | ✅ Recommended | Bug fixes, recommended | -| 1.0.0 | TBD | TBD | TBD | Future, full stability | +| 1.0.0 | ✅ | ✅ | ✅ | `Mesh` alias, C++ backend | +| 1.4.0+ | ⚠️ | ⚠️ | ⚠️ | **#187 breaking renames — see [above](#-v140-breaking-renames-187)** | --- @@ -496,7 +571,7 @@ See `examples/` directory for complete working examples: --- -**Last Updated:** 2026-04-27 -**Guide Version:** 1.0 +**Last Updated:** 2026-07-21 +**Guide Version:** 2.1 For latest information, visit: https://github.com/domattioli/CHILmesh diff --git a/docs/RUST_EVALUATION.md b/docs/RUST_EVALUATION.md new file mode 100644 index 00000000..4e06ffc2 --- /dev/null +++ b/docs/RUST_EVALUATION.md @@ -0,0 +1,296 @@ +# Rust Backend Evaluation + +**Date:** 2026-07-14 +**Author:** Claude Code (routine session) +**Backend status:** ❄️ **FROZEN** (2026-07-14, operator-directed) — kept and +output-equivalent, but not developed further. See `src/chilmesh_core/STATUS.md`. +**Status:** Evaluation — evidence + recommendation; the O(1) query fix was applied, the +backend is otherwise frozen +**Question:** Does a Rust implementation make sense for CHILmesh over the C++ or +Python backends *in any regard*? And specifically: **could Rust replace Python in +any functionality to improve performance?** + +This document records the first *measured* head-to-head of all three backends +(the README/`BENCHMARK.md` Rust perf cells were `tbd` and Rust was excluded from +the cross-language tables — see [#163](https://github.com/domattioli/CHILmesh/issues/163)). +It complements the earlier design-level study +[`.planning/research/quad_edge_feasibility.md`](../.planning/research/quad_edge_feasibility.md) +(2026-05-09), which reasoned about quad-edge *before* the Rust crate existed; +this one measures the crate that shipped in PR #220. + +--- + +## 1. What already exists + +Rust is **not** greenfield here — it is a wired-in third backend: + +- **Crate:** `src/chilmesh_core/` (PyO3 + `ndarray` + `numpy`), exposing a + `RustMesh` class with I/O (`fort.14`/`.2dm`), `build_adjacencies`, + `skeletonize`, `compute_quality`, adjacency getters, vertex/element queries, + and `add_element`/`remove_element`. +- **Python wrapper:** `src/chilmesh/backends/rust_backend.py` (`RUST_AVAILABLE`, + `full_init`). +- **Selection:** `chilmesh.backend_info()` reports `rust` when the extension is + importable and exposes `RustMesh`. +- **CI:** the `rust-equivalence` job (`.github/workflows/python-package.yml`) + builds the crate with `maturin`, asserts the backend is active, and runs the + cross-backend equivalence suite. + +So the question is not "should we start Rust" but "given the shipped Rust +backend, does it earn its place over C++ or Python?" + +--- + +## 2. Methodology + +All three backends were built from source **in this session** and measured +like-for-like — every backend runs the same operation on the same in-memory +`(connectivity, points)` arrays (no fort.14 parse inside the timed region), +using the committed harness `scripts/benchmark.py`. + +*(Terminology: the layer decomposition is the **layer peel** / *onion peeling* — +per the #187 lexicon, "skeletonization" is deprecated for this operation and +reserved for a future medial-axis op (#223). The backend extension method is still +literally named `skeletonize()` pending a deferred cross-language backend rename, +so that symbol name persists in code while prose says "peel".)* + +- **Build:** `maturin build --release` (crate profile: `opt-level=3`, `lto=true`, + `codegen-units=1`) for Rust; `pip wheel ./src/chilmesh_cpp` (scikit-build-core + + pybind11, `Release`) for C++. +- **Correctness gate:** `tests/test_backend_equivalence.py` — **76/76 pass**, so + the Rust build under test is output-correct (layer counts, layer-member sets, + `bEdgeIDs`, edge ordering, and signed areas all match Python). Perf below is + therefore measured on a *correct* Rust backend, not a broken one. +- **Machine:** Intel Xeon @ 2.80 GHz (4 cores), 15 GiB RAM, Linux 6.18, + Python 3.11.15, rustc/cargo 1.94.1, g++ 13.3. Absolute times are + machine-dependent; the **ratios** are the portable result. +- **Meshes:** the repo's bundled fixtures (`donut`, `annulus`, `Block_O`), plus + the continental-scale **ENPAC2003** reference (531,680 elems), now reachable + in-environment from the [Valence](https://github.com/domattioli/Valence) + sibling checkout — its previously-`tbd` README Rust cells are filled (#250): + full-init **11.98 s** (≈ Python 11.89 s same-machine, ~15× slower than C++ + 0.803 s), quality **2 ms**, `n_layers = 75` matching all backends. Rust + `fast_init`/peel stay unmeasurable separately (#163), so those two cells read + `n/a`. The small-mesh Rust-over-Python edge does not survive at scale — the + ratios below hold, and ENPAC only sharpens the "no perf niche over C++" + conclusion. + +--- + +## 3. Measured results + +Medians of 5–7 runs. `full-init` = adjacency build + layer peel (the +apples-to-apples cross-backend operation). `vert-edge` = one `get_vertex_edges` +call, averaged over up to 2000 vertices. + +### Full init (adjacency + layer peel) + +| Mesh | Elements | C++ | Rust | Python | Rust vs C++ | Rust vs Python | +|---|---:|---:|---:|---:|---:|---:| +| donut | 276 | ~0 ms | 1 ms | 5 ms | slower | ~5× faster | +| annulus | 580 | 1 ms | 2 ms | 8 ms | ~2× slower | ~4× faster | +| Block_O | 5,214 | 5 ms | 24 ms | 68 ms | **~5× slower** | ~2.8× faster | + +`n_layers` parity ✅ across all three on every mesh. + +### Vertex-edge lookup (per call) + +| Mesh | Elements | C++ | Rust (before fix) | Rust (after fix) | Python | +|---|---:|---:|---:|---:|---:| +| donut | 276 | 0.64 μs | 50 μs | **0.53 μs** | 0.41 μs | +| annulus | 580 | 0.81 μs | 103 μs | **0.40 μs** | 0.44 μs | +| Block_O | 5,214 | 0.64 μs | 954 μs | **0.32 μs** | 0.51 μs | + +Before the fix (§4.2) the Rust query cost was **100–1800× worse** than C++/Python +and **grew with mesh size** — a scaling defect, not a constant overhead. After the +fix it is O(1) and on par with / slightly faster than C++ and Python +(Block_O: 954 μs → 0.32 μs, ~3000×). Equivalence still holds (76/76 tests pass). + +--- + +## 4. Findings + +1. **Full init: Rust loses to C++, beats Python.** Rust is ~3–5× faster than + pure Python but ~2–5× *slower* than C++, and the gap widens with mesh size + (~5× behind C++ on Block_O). C++ remains the performance backend. + +2. **Queries were catastrophically slow — now fixed.** Root cause was an + algorithmic defect in the shipped crate, not the language. + `RustMesh.get_vertex_edges` (`src/chilmesh_core/lib.rs`) called + `adjacency::to_edge2vert(&self.connectivity)` on **every call**, rebuilding + the entire canonical edge list (O(n_elems), with allocation), then linearly + scanning **all** edges (O(n_edges)). So each lookup was O(n_elems + n_edges); + C++ and Python cache a `Vert2Edge` adjacency and answer in O(1), which is why + the Rust query time tracked mesh size. + **Fixed in this session:** `build_adjacencies` now precomputes the + vertex→edge index once (from the same `to_edge2vert` source, so output stays + bit-identical), and `get_vertex_edges` returns the cached row in O(1); + `set_connectivity` invalidates the cache. Result: Block_O 954 μs → 0.32 μs + (~3000×), now on par with / faster than C++ and Python, with all 76 + equivalence tests still passing (see the "after fix" column in §3). + +3. **Correctness is not the issue.** All 76 equivalence tests pass. The problem + is purely that Rust is slower than the already-shipped C++ backend on the hot + path, and its query API was never given a cached adjacency. + +4. **Prior `tbd`/"excluded" doc state is now resolved.** README's Rust perf cells + were `tbd`; `BENCHMARK.md` said "Rust is excluded — its skeletonization is + incomplete (#163)". Both are stale: #163 is closed, the layer peel reaches + parity, and the missing perf evidence is the table above. + +5. **The backends differ in internal topology representation, not in output.** + Python is the reference — flat numpy arrays (`Edge2Vert`, `Elem2Edge`, + `Edge2Elem`) plus dict adjacencies (`Vert2Edge`, `Vert2Elem`) and an `EdgeMap` + hash. **C++** uses a **half-edge (DCEL)** structure (`src/chilmesh_cpp/src/halfedge.*`). + **Rust** uses a **quad-edge** structure (Guibas–Stolfi 1985 — four directed + edges per undirected edge). Despite three different internal representations, + all produce **bit-identical** layers, adjacency tables, and signed areas — that + equivalence is exactly what `tests/test_backend_equivalence.py` guards. The + quad-edge choice was an explicit experiment (`.planning/008-DECISION.md`) to + test whether a different topology structure would scale better than the flat / + half-edge representations. The measurements above show it does **not** beat the + C++ half-edge backend — the data-structure bet did not pay off on performance. + That, plus the doubled compiled-backend maintenance surface, is **why it is + frozen**: not because it is wrong (it is bit-exact), but because it is a slower + second way to compute the same thing C++ already computes faster. + +--- + +## 5. Could Rust *replace Python* in any functionality to improve performance? + +The pointed version of the question. A functionality is a candidate only if it is +(a) currently pure-Python with **no** C++ acceleration, (b) actually +perf-critical, and (c) something Rust would plausibly *win* at. + +| Functionality | Today | Perf-critical? | Would Rust replacing Python win? | Verdict | +|---|---|---|---|---| +| adjacency / layer-peel / signed-area / vertex queries | Python **+ C++** (+ Rust) | yes | C++ already replaces Python and beats Rust; Rust adds nothing | **No — C++ owns this** | +| FEM smoother (`method='fem'`, direct + iterative) | Python → `scipy.sparse` `spsolve` / MINRES | yes at scale | the solve is already in compiled SuiteSparse/LAPACK; Rust would reimplement a sparse solver — huge effort, unlikely to beat, high risk | **No** | +| angle-based smoother | Python, numpy-vectorized | moderate | numpy batch ops are already near-C; Rust gain marginal | **No** | +| ADMESH warm-start truss | Python, numpy-vectorized | at scale | force loop is vectorized; any win is marginal and better placed in the existing C++ backend | **Marginal — prefer C++** | +| fort.14/.13/.15 + gmsh I/O | Python | no (I/O-bound) | parse time is rarely the bottleneck; Rust I/O exists in the crate but shows no measured win | **No** | +| spatial indexing (point-location / NN — planned Phase 5) | not implemented | would be | greenfield, so no incumbent to beat — a *fair* Rust candidate on merit | **Only open niche, but see below** | +| topology mutations (split/swap/merge/collapse, #94) | Python | moderate | quad-edge `splice` gives O(1) local edits (the feasibility doc's strongest argument); the crate has `add_element`/`remove_element` stubs | **Compiled backend could help — but C++ half-edge is the vehicle** | + +**Answer: there is no functionality where replacing Python with Rust is the right +performance move.** Everywhere Rust could help, one of two things is already true: + +- **C++ already replaces Python there, faster, with bit-identical output, and is + the maintained acceleration path.** Adding the same work to the slower Rust + backend helps no one — you would never *select* Rust as the active backend to + get one accelerated function while paying a 5× penalty on the shared core. +- **The hot loop is already compiled** (numpy/scipy/BLAS/SuiteSparse). Rewriting + those mature, well-tested kernels in Rust is high-effort, high-risk, and + unlikely to beat them. + +The only genuinely open niches — Phase-5 spatial indexing and topology-mutation +`splice` — are *greenfield*, where Rust would compete on merit. But even there +the sensible vehicle is the **existing C++ half-edge backend** (already ahead on +the shared core, already bit-identical, already in CI), not a second compiled +backend that is slower and less maintained. The rule of thumb: **if a +pure-Python path ever needs acceleration, extend C++, not Rust.** + +### 5.1 Default backend & opt-in — can Rust replace Python as the default? + +A natural follow-up: users opt in to C++ today; could Rust become the *default* +so they get speed without opting in? The answer turns on distribution, not +language. + +- **Both C++ and Rust are opt-in source builds today; neither is lighter than the + other.** A plain `pip install chilmesh` from PyPI ships **pure-Python only** — + no compiled extension ([#229](https://github.com/domattioli/CHILmesh/issues/229)). + C++ needs a C++ toolchain + CMake (`pip install ./src/chilmesh_cpp`); Rust needs + a Rust toolchain (`maturin build …`). Rust is **not** a lighter-weight opt-in + than C++ — it is the same class of requirement (a compiler + a build step). +- **Auto-selection already prefers Rust over Python when Rust is built.** + `backend_info()` (`src/chilmesh/__init__.py`) picks the fastest *available* + backend, ordered C++ → Rust → Python, and honors `CHILMESH_BACKEND`. So "use + Rust instead of Python when the user hasn't opted into C++" **already happens** + — *if the Rust extension is present*. The only reason a non-opting user gets + pure Python is that nothing compiled ships in the wheel. +- **Making a compiled backend the zero-opt-in default = shipping prebuilt binary + wheels** (manylinux/macOS/Windows via `cibuildwheel`/`maturin`). That work is + already planned for **C++** ([#229](https://github.com/domattioli/CHILmesh/issues/229)). + Once you commit to shipping a prebuilt compiled wheel, ship the **faster** one — + C++ is ~5× faster than Rust on full init. Defaulting to Rust would trade that 5× + away. +- **The one real Rust distribution nicety:** maturin builds an `abi3` wheel, so a + *single* wheel per platform works across Python 3.8+, whereas the pybind11 C++ + extension needs a wheel per Python version. Fewer wheels to build/ship is a + genuine packaging convenience — but it does not outweigh C++ being 5× faster on + the hot path. It is at best a stopgap argument if the C++ wheel matrix stalls, + not a reason to make Rust the default. + +**Net:** keep **pure-Python as the universal default and fallback** (zero +dependencies, runs everywhere, the reference every backend is validated against); +pursue **prebuilt C++ wheels** as the auto-selected accelerator (existing plan). +Do not make Rust the default backend, and do not migrate any Python computation to +Rust for speed. + +--- + +## 6. Recommendation + +**Do not invest further in Rust as a competing backend, and do not migrate any +Python functionality to Rust for performance.** + +Rationale: + +- C++ already delivers the acceleration (≈8.6–15× over Python on full init on the + headline meshes; ~5× over Rust here), with bit-identical output, and is the + documented recommended backend. +- The Rust backend is a second compiled-backend maintenance surface (toolchain, + wheels, a dedicated CI job, equivalence tests) that is **slower than C++ on the + hot path and has a query-scaling defect** (§4.2). +- Rust's theoretical advantages (memory safety, `cargo`, fearless concurrency) + are real in general but **unrealized and unneeded** at CHILmesh's scale — a + 5,000-element mesh full-inits in 24 ms; there is no workload here that a + compiled, memory-safe, parallel core would rescue that C++ has not already + covered. + +**Concretely:** + +- **The Rust backend is now FROZEN** (operator-directed, 2026-07-14) — kept and + output-equivalent, but not developed further and not kept in lockstep as the + Python API grows. Status banner: `src/chilmesh_core/STATUS.md`. The crate, its + `rust-equivalence` CI job, the equivalence tests, and the benchmark data all + stay; no new feature work lands on it. This is the "freeze" option below, + chosen over outright deprecation so the reference/parity value is retained. +- **The acceleration path forward is prebuilt C++ binary wheels**, not a second + backend — so a plain `pip install` gives C++ speed with no toolchain. Plan: + [`docs/dev/PREBUILT_WHEELS_PLAN.md`](dev/PREBUILT_WHEELS_PLAN.md) (#229). +- The one worthwhile, well-scoped fix — **cache `Vert2Edge` in `RustMesh`** so + `get_vertex_edges` is O(1) instead of rebuilding the edge list per call (§4.2) + — **has been applied in this session.** Rust queries now match C++/Python. This + removes a real footgun: because `backend_info()` auto-selects Rust over Python + when Rust is built and C++ is not (`src/chilmesh/__init__.py`), the old defect + meant that a Rust-only build silently regressed query-heavy workloads far below + pure Python. It does **not** change the top-line conclusion — Rust full-init is + still ~5× behind C++, so C++ remains the acceleration path. + +--- + +## 7. Reproduce + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" maturin + +# C++ backend +pip wheel ./src/chilmesh_cpp -w wheelhouse --no-deps +pip install --force-reinstall --no-deps wheelhouse/chilmesh_cpp-*.whl + +# Rust backend +maturin build --release --manifest-path src/chilmesh_core/Cargo.toml --out wheelhouse +pip install --force-reinstall --no-deps wheelhouse/chilmesh_core-*.whl + +# confirm all three, then measure +python -c "import chilmesh; print(chilmesh.backend_info())" # -> ['cpp','rust','python'] +python -m pytest tests/test_backend_equivalence.py -q # 76 passed +CHILMESH_RUN_BENCH=1 python scripts/benchmark.py --mesh src/chilmesh/data/Block_O.14 --repeats 5 +``` + +> Run backend imports from a directory **outside** the repo (or install +> non-editable wheels) so `chilmesh_cpp`/`chilmesh_core` resolve to the built +> extensions rather than the `src/` source dirs on `sys.path` (the namespace-stub +> shadowing noted in #163). diff --git a/docs/dev/PREBUILT_WHEELS_PLAN.md b/docs/dev/PREBUILT_WHEELS_PLAN.md new file mode 100644 index 00000000..c477edf3 --- /dev/null +++ b/docs/dev/PREBUILT_WHEELS_PLAN.md @@ -0,0 +1,239 @@ +# Plan: Prebuilt C++ Binary Wheels on PyPI + +**Date:** 2026-07-14 +**Status:** Plan — not yet implemented +**Tracking:** [#229](https://github.com/domattioli/CHILmesh/issues/229) (PyPI ships pure-Python), [#225](https://github.com/domattioli/CHILmesh/issues/225) (macOS/Windows runner billing), [#234](https://github.com/domattioli/CHILmesh/issues/234) +**Related:** [`docs/RUST_EVALUATION.md`](../RUST_EVALUATION.md) (why C++, not Rust, is the wheel to ship) + +## Goal — the consumer outcome + +Make the C++ speedup the **default** for a plain install, with **no toolchain on the +user's side**: + +```bash +pip install chilmesh[cpp] # downloads a prebuilt binary; no compiler needed +``` + +Today `pip install chilmesh` is pure-Python (the compiled extension must be built +from source — a C++ compiler + CMake barrier that most users, especially on +managed/HPC/Windows machines, will not clear). Prebuilding the wheel once per +platform in CI and publishing it to PyPI removes that barrier entirely: `pip` +downloads the already-compiled binary matching the user's OS + Python. + +**Non-goal:** Rust wheels. The Rust backend is **frozen** (see +[`RUST_EVALUATION.md`](../RUST_EVALUATION.md)); it earns no perf niche over C++ and +is not part of the distribution story. + +## Current state (accurate as of this plan) + +| Piece | State | +|---|---| +| `chilmesh` (main pkg) | pure-Python, setuptools, `requires-python >=3.10`, PyPI = no compiled ext (#229) | +| `chilmesh-cpp` (`src/chilmesh_cpp`) | separate package, scikit-build-core + pybind11, version `0.6.0.dev0`, **not on PyPI** | +| `build-cpp-wheels.yml` | **build-only**: cibuildwheel manylinux **x86_64 only**, `cp310–312`, musllinux skipped, asserts `import chilmesh_cpp` — uploads **artifacts only, no publish**; manual dispatch | +| `publish-pypi.yml` | publishes the **main** pkg on `release`; `twine` + `PYPI_API_TOKEN` secret | +| Backend selection | `chilmesh.backend_info()` auto-detects `import chilmesh_cpp` and picks it — **no code change needed** once the wheel is installable | + +So the runtime detection already works; the entire gap is **packaging + distribution**. + +## Design decision — distribution model + +**Model A (recommended): publish `chilmesh-cpp` as its own binary-wheel package; add a +`chilmesh[cpp]` extra that depends on it.** + +- `chilmesh` stays pure-Python (universal default + fallback; sdist/uncovered + platforms still work). +- `chilmesh[cpp]` pulls the prebuilt `chilmesh-cpp` binary → auto-selected at import. +- Minimal churn: matches the existing two-package layout and the existing + `import chilmesh_cpp` detection. No change to `chilmesh`'s build backend. + +**Model B (rejected for now): bundle the C++ extension into the main `chilmesh` wheel.** +Would turn `chilmesh` itself into a per-platform binary package (change its build +backend to scikit-build-core, ship a pure-Python sdist fallback). More invasive, and +it couples the pure-Python reference release cadence to the compiled build matrix. +Revisit only if Model A's two-package version-sync proves painful. + +## Phased plan + +### Phase 0 — decide + pin (design, no CI yet) +- Ratify Model A. +- Define the **version-compatibility contract** between `chilmesh` and `chilmesh-cpp` + (they version independently today: `chilmesh` 1.4.x vs `chilmesh-cpp` `0.6.0.dev0`). Pin a compatible range + in the extra, e.g. `chilmesh[cpp]` → `chilmesh-cpp>=0.6,<0.7`, and bump them together + on any extension-API change. Record the contract in `CONTRIBUTING.md`. +- Graduate `chilmesh-cpp` off `.dev0` to a real release version (e.g. `0.6.0`). + +### Phase 1 — expand the build matrix (`build-cpp-wheels.yml`) +- Add **macOS** (`x86_64` + `arm64` / universal2) and **Windows** (`AMD64`) alongside + manylinux `x86_64`. Resolve the runner-billing question in **#225** first (macOS/ + Windows are paid GitHub runners). +- Keep `cp310 cp311 cp312`; add `cp313` once pybind11/deps support it. (pybind11 needs a + wheel **per Python version** — this matrix is inherently `platforms × versions`. This + is the one place Rust's maturin `abi3` single-wheel would be simpler — noted, but out + of scope since C++ wins on perf.) +- Decide musllinux (currently skipped) and linux `aarch64` (emulated builds are slow) — + defer both unless a consumer needs them. +- Strengthen `CIBW_TEST_COMMAND`: import **and** run one real `full_init` on a tiny mesh, + not just `hasattr(full_init)`. + +### Phase 2 — add a publish job +- New job (or new workflow `publish-cpp-wheels.yml`) that, on a `chilmesh-cpp` **release + tag**, builds the full matrix + sdist and `twine upload`s to PyPI. +- Auth: reuse the `PYPI_API_TOKEN` pattern from `publish-pypi.yml`, **or** migrate to + PyPI **Trusted Publishing** (OIDC, `id-token: write`) — preferred, no long-lived token. +- Guard strictly on the tag/release event so nothing publishes from `development`. + +### Phase 3 — wire the consumer-facing extra +- In `chilmesh`'s `pyproject.toml`: + ```toml + [project.optional-dependencies] + cpp = ["chilmesh-cpp>=0.6,<0.7"] + ``` +- No backend code change — `backend_info()` already imports `chilmesh_cpp`. +- README + docs: document `pip install chilmesh[cpp]`; update the "PyPI installs are + pure-Python" note (#229) to "plain install = pure-Python; `chilmesh[cpp]` = prebuilt + C++ where a wheel exists, pure-Python fallback elsewhere." + +### Phase 4 — validate + close out +- Post-publish smoke: in a **clean** environment on each platform, + `pip install chilmesh[cpp]` then assert `backend_info()['selected'] == 'cpp'` and run + `tests/test_backend_equivalence.py` against the **installed wheel** (not `src/` — avoid + the namespace-stub shadowing from #163: run from outside the repo tree). +- Update `docs/BENCHMARK.md` / README wheel-availability notes; close **#229**. + +## Risks & considerations +- **Runner cost (#225):** macOS/Windows runners are billed; Phase 1 is gated on that + decision. manylinux (Linux) is free on `ubuntu-latest` and can ship first. +- **Matrix size:** pybind11 → one wheel per (platform × Python version). Manageable at + 3–4 Python versions × 3 platforms, but it grows. +- **macOS arm64:** cross-build/`universal2` needs care in cibuildwheel; test on Apple + silicon if possible. +- **glibc target:** pick a manylinux baseline (e.g. `manylinux_2_28`) broad enough for + HPC/older distros. +- **Version sync:** the `chilmesh` ↔ `chilmesh-cpp` compatibility pin must be maintained + on every extension-API change (Phase 0 contract). +- **Test isolation:** always validate against the installed wheel, never the `src/` + source dir on `sys.path` (#163 false-positive `CPP_AVAILABLE`). + +## Sequencing +1. Phase 0 (design/pin) — cheap, unblocks everything. +2. Phase 1 Linux-only publish first (free runners) → real prebuilt wheel on PyPI for the + largest user base, fastest win. +3. Resolve #225, then add macOS/Windows (Phase 1 remainder) + Phase 2 publish job. +4. Phase 3 extra + docs, Phase 4 validation, close #229. + +--- + +## Appendix — ready-to-apply snippets + +> **PROPOSED — not yet applied.** These are copy-paste-ready but intentionally +> *inert* (they are documentation, not live workflow files). Apply them only after +> Model A is ratified (Phase 0) and the macOS/Windows runner-billing question +> (#225) is resolved. The `macos-latest` / `windows-latest` matrix legs and the +> publish job are the parts that incur runner cost or perform the **irreversible, +> outward** act of uploading to PyPI — those need operator sign-off before they go +> live. The Linux-only build leg is free and safe to enable first. + +### A1 — Phase 1: expand `build-cpp-wheels.yml` (matrix + real smoke test) + +Replace the single `build-manylinux` job with a matrixed build. macOS/Windows legs +are gated on #225 — drop them from `matrix.os` to ship Linux-only first. + +```yaml +jobs: + build-wheels: + name: cibuildwheel chilmesh_cpp (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] # macos/windows gated on #225 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.21.3 + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse src/chilmesh_cpp + env: + CIBW_BUILD: "cp310-* cp311-* cp312-*" + CIBW_ARCHS_LINUX: "x86_64" + CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_ARCHS_WINDOWS: "AMD64" + CIBW_SKIP: "*-musllinux*" + # Real smoke: build a mesh, not just check the symbol exists. + CIBW_TEST_REQUIRES: "numpy" + CIBW_TEST_COMMAND: > + python -c "import numpy as np, chilmesh_cpp; + pts=np.array([[0.,0.],[1.,0.],[0.,1.],[1.,1.]]); + conn=np.array([[0,1,2],[1,3,2]],dtype=np.int32); + m=chilmesh_cpp.full_init(pts, conn); + assert m.n_elems==2, m.n_elems; print('cpp full_init OK', m.n_verts, m.n_elems)" + - uses: actions/upload-artifact@v4 + with: + name: chilmesh-cpp-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + if-no-files-found: error +``` + +### A2 — Phase 2: `publish-cpp-wheels.yml` (tag/release-gated publish) + +New workflow. Prefer **Trusted Publishing** (OIDC, no long-lived token); the +token variant (mirroring `publish-pypi.yml`) is shown as a fallback comment. The +`build` job reuses A1; only `publish` is new. + +```yaml +name: publish-cpp-wheels +on: + release: + types: [published] # publishes ONLY on a real GitHub release; never on push + workflow_dispatch: +permissions: + contents: read +jobs: + build: + # ... reuse the A1 matrix build; uploads per-OS wheel artifacts ... + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pipx run build --sdist --outdir dist src/chilmesh_cpp + - uses: actions/upload-artifact@v4 + with: { name: chilmesh-cpp-sdist, path: dist/*.tar.gz } + publish: + needs: [build, sdist] + runs-on: ubuntu-latest + environment: pypi # protect with required reviewers in repo settings + permissions: + id-token: write # PyPI Trusted Publishing (OIDC) — no secret + steps: + - uses: actions/download-artifact@v4 + with: { path: dist, merge-multiple: true } + - uses: pypa/gh-action-pypi-publish@release/v1 + # Token fallback (if not using Trusted Publishing): + # with: { password: ${{ secrets.PYPI_API_TOKEN }} } +``` + +### A3 — Phase 3: consumer-facing extra + docs + +`chilmesh/pyproject.toml` (add alongside the existing `dev` extra) — **land this in +the same change that publishes `chilmesh-cpp`, never before**, or +`pip install chilmesh[cpp]` resolves to a package that isn't on PyPI yet: + +```toml +[project.optional-dependencies] +cpp = ["chilmesh-cpp>=0.6,<0.7"] # pin bumps with any extension-API change (Phase 0 contract) +``` + +README / docs one-liner: + +```bash +pip install chilmesh # pure-Python, runs everywhere +pip install chilmesh[cpp] # + prebuilt C++ acceleration where a wheel exists +``` + +No backend code changes — `chilmesh.backend_info()` already imports `chilmesh_cpp` +and auto-selects it. diff --git a/pyproject.toml b/pyproject.toml index 4297b14b..c01b9c29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "chilmesh" -version = "1.4.0" +version = "1.4.1" description = "Fast 2D mesh library for hydrodynamic domains — Python API with optional C++ acceleration" authors = [{name = "Dominik Mattioli"}] license = {text = "PolyForm Noncommercial License 1.0.0"} diff --git a/scripts/benchmark_all_backends.py b/scripts/benchmark_all_backends.py index 0f595370..96b07713 100644 --- a/scripts/benchmark_all_backends.py +++ b/scripts/benchmark_all_backends.py @@ -14,6 +14,7 @@ python scripts/benchmark_all_backends.py [MESH_PATH] """ import json +import os import platform import statistics import sys @@ -30,10 +31,11 @@ def _fmt(seconds: float, unit: str = "s") -> str: return str(seconds) def _locate_mesh(explicit: str | None) -> Path: + tmp_base = os.environ.get("VALENCE_DATA_DIR", os.path.expanduser("~/valence-domains")) candidates = [ explicit, - "/tmp/valence-domains/registry_data/meshes/WNAT_Hagen.14", - "/tmp/WNAT_Hagen.14", + str(Path(tmp_base) / "registry_data/meshes/WNAT_Hagen.14"), + str(Path(tmp_base) / "WNAT_Hagen.14"), str(Path.home() / "WNAT_Hagen.14"), ] for c in candidates: @@ -289,7 +291,9 @@ def main() -> int: "rust": rs_results, "cpp": cpp_results, } - json_path = Path("/tmp/wnat_all_backends.json") + out_dir = Path(os.environ.get("CHILMESH_OUT_DIR", Path(__file__).resolve().parents[1] / "output")) + out_dir.mkdir(parents=True, exist_ok=True) + json_path = out_dir / "wnat_all_backends.json" json_path.write_text(json.dumps(output, indent=2)) print(f"Results saved: {json_path}") diff --git a/scripts/benchmark_quadegg_variants.py b/scripts/benchmark_quadegg_variants.py index 2d0dfa91..9e11b736 100755 --- a/scripts/benchmark_quadegg_variants.py +++ b/scripts/benchmark_quadegg_variants.py @@ -13,6 +13,7 @@ """ import json +import os import time import sys import tracemalloc @@ -24,7 +25,8 @@ BACKENDS = ['edgemap', 'halfedge', 'quadegg'] OPERATIONS = ['fast_init', 'full_init', 'quality_analysis', 'query_latency'] -WNAT_HAGEN_PATH = "/tmp/valence-domains/registry_data/meshes/WNAT_Hagen.14" +_VALENCE_DATA_DIR = os.environ.get("VALENCE_DATA_DIR", os.path.expanduser("~/valence-domains")) +WNAT_HAGEN_PATH = str(Path(_VALENCE_DATA_DIR) / "registry_data/meshes/WNAT_Hagen.14") def measure_operation(op_name: str, op_fn, n_trials: int = 2) -> Tuple[float, float, float]: diff --git a/scripts/benchmark_wnat_hagen.py b/scripts/benchmark_wnat_hagen.py index aaa61a54..3e188177 100644 --- a/scripts/benchmark_wnat_hagen.py +++ b/scripts/benchmark_wnat_hagen.py @@ -14,6 +14,7 @@ import argparse import json +import os import platform import statistics import sys @@ -45,10 +46,11 @@ def _measure(label: str, fn, *, n: int = 1) -> tuple[str, float]: def _locate_mesh(explicit: str | None) -> Path: + valence_dir = os.environ.get("VALENCE_DATA_DIR", os.path.expanduser("~/valence-domains")) candidates = [ explicit, - "/tmp/valence-domains/registry_data/meshes/WNAT_Hagen.14", - "/tmp/valence-domains/WNAT_Hagen.14", + str(Path(valence_dir) / "registry_data/meshes/WNAT_Hagen.14"), + str(Path(valence_dir) / "WNAT_Hagen.14"), str(Path.home() / "WNAT_Hagen.14"), ] for c in candidates: @@ -56,8 +58,7 @@ def _locate_mesh(explicit: str | None) -> Path: return Path(c) raise FileNotFoundError( "WNAT_Hagen mesh not found. Clone Valence:\n" - " git clone https://github.com/domattioli/Valence " - "/tmp/valence-domains\n" + f" git clone https://github.com/domattioli/Valence {valence_dir}\n" "then re-run this script." ) diff --git a/scripts/generate_enpac_showcase.py b/scripts/generate_enpac_showcase.py index 8c4a57da..22eeff7b 100644 --- a/scripts/generate_enpac_showcase.py +++ b/scripts/generate_enpac_showcase.py @@ -30,7 +30,8 @@ def _candidate_paths() -> list[Path]: env = os.environ.get("ENPAC_PATH") if env: out.append(Path(env)) - out.append(Path("/tmp/valence-domains/registry_data/meshes/EasternPacific_ENPAC2003.14")) + valence_dir = os.environ.get("VALENCE_DATA_DIR", os.path.expanduser("~/valence-domains")) + out.append(Path(valence_dir) / "registry_data/meshes/EasternPacific_ENPAC2003.14") out.append(Path.home() / "valence-domains/registry_data/meshes/EasternPacific_ENPAC2003.14") return out diff --git a/scripts/scenes/pipeline_annulus_scene.py b/scripts/scenes/pipeline_annulus_scene.py index 3f54c176..f756cc28 100644 --- a/scripts/scenes/pipeline_annulus_scene.py +++ b/scripts/scenes/pipeline_annulus_scene.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import os from pathlib import Path import numpy as np @@ -48,7 +49,8 @@ config.frame_rate = 30 config.background_color = "#0e0e10" -DATA_PATH = Path("/tmp/readme_pipeline.json") +_OUT_DIR = Path(os.environ.get("CHILMESH_OUT_DIR", Path(__file__).resolve().parents[2] / "output")) +DATA_PATH = _OUT_DIR / "readme_pipeline.json" EDGE_COLOR = "#3a7fbf" FILL_COLOR = "#5fb0ff" diff --git a/scripts/scenes/quality_compare_scene.py b/scripts/scenes/quality_compare_scene.py index 318accf3..c11524e5 100644 --- a/scripts/scenes/quality_compare_scene.py +++ b/scripts/scenes/quality_compare_scene.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import os from pathlib import Path import numpy as np @@ -47,7 +48,8 @@ config.frame_rate = 30 config.background_color = "#0e0e10" -DATA_PATH = Path("/tmp/quality_compare.json") +_OUT_DIR = Path(os.environ.get("CHILMESH_OUT_DIR", Path(__file__).resolve().parents[2] / "output")) +DATA_PATH = _OUT_DIR / "quality_compare.json" # Per-fixture color palette. PALETTE = { diff --git a/scripts/scenes/skeletonization_annulus_scene.py b/scripts/scenes/skeletonization_annulus_scene.py index c5a7ac06..bc99f587 100644 --- a/scripts/scenes/skeletonization_annulus_scene.py +++ b/scripts/scenes/skeletonization_annulus_scene.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import os from pathlib import Path import numpy as np @@ -41,7 +42,8 @@ config.frame_rate = 30 config.background_color = "#0e0e10" -DATA_PATH = Path("/tmp/skeleton_annulus.json") +_OUT_DIR = Path(os.environ.get("CHILMESH_OUT_DIR", Path(__file__).resolve().parents[2] / "output")) +DATA_PATH = _OUT_DIR / "skeleton_annulus.json" OV_COLOR = "#5fb0ff" OE_COLOR = "#ff9f43" diff --git a/scripts/scenes/skeletonization_scene.py b/scripts/scenes/skeletonization_scene.py index 9b8b7c4e..cc157244 100644 --- a/scripts/scenes/skeletonization_scene.py +++ b/scripts/scenes/skeletonization_scene.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import os from pathlib import Path import numpy as np @@ -38,7 +39,8 @@ config.pixel_height = 720 config.background_color = "#0e0e10" -DATA_PATH = Path("/tmp/skeleton_data.json") +_OUT_DIR = Path(os.environ.get("CHILMESH_OUT_DIR", Path(__file__).resolve().parents[2] / "output")) +DATA_PATH = _OUT_DIR / "skeleton_data.json" OV_COLOR = "#5fb0ff" # outer vertices OE_COLOR = "#ff9f43" # outer elements diff --git a/src/chilmesh/CHILmesh.py b/src/chilmesh/CHILmesh.py index fde83b8b..ddf7f0f0 100644 --- a/src/chilmesh/CHILmesh.py +++ b/src/chilmesh/CHILmesh.py @@ -294,6 +294,9 @@ def __init__( self, connectivity: Opt[np.ndarray] = None, points: Opt[np.ndarray # node+element-only meshes. Each entry: {"kind": "open"|"flow", # "ibtype": Optional[int], "nodes": np.ndarray of 0-based node indices}. self.boundary_segments: List[Dict[str, Any]] = [] + # True iff a NOPE/NBOU boundary block was physically present in the + # source fort.14; distinguishes present-but-empty from absent (#259). + self.boundaries_present: bool = False # Hidden properties self.adjacencies: Dict[str, Any] = {} @@ -2692,10 +2695,12 @@ def read_from_fort14( # --- parse boundary segments (#129) --- boundary_segments = [] + boundaries_present = False try: # NOPE open boundaries nope = int(lines[i].split()[0]); i += 1 total_nope = int(lines[i].split()[0]); i += 1 + boundaries_present = True # NOPE/NBOU block physically present (#259) for _ in range(nope): n_seg = int(lines[i].split()[0]); i += 1 nodes = [] @@ -2728,6 +2733,7 @@ def read_from_fort14( ) mesh.boundary_segments = boundary_segments + mesh.boundaries_present = boundaries_present # Default compute_adjacencies follows compute_layers (mirrors __init__). if compute_adjacencies is None: diff --git a/src/chilmesh/__init__.py b/src/chilmesh/__init__.py index e14ce8b1..77ec815e 100644 --- a/src/chilmesh/__init__.py +++ b/src/chilmesh/__init__.py @@ -10,15 +10,21 @@ The legacy name ``CHILmesh`` is kept as an alias of ``Mesh`` for backward compatibility with code that imported the class directly. + +Lazy loading (#255): the heavy mesh/plotting/geometry surface (which pulls in +numpy + matplotlib) is loaded on first attribute access via PEP 562 +``__getattr__``. Only the stdlib-pure ``fort14_io`` names are imported eagerly, +so lightweight consumers can ``from chilmesh import read_fort14_raw`` without +dragging in numpy or matplotlib. """ from __future__ import annotations +import importlib from importlib import metadata -from .CHILmesh import CHILmesh, write_fort14 -from .gmsh_io import read_msh, write_msh, GmshParseError -from .fort13_io import Fort13, NodalAttribute, read_fort13, write_fort13, Fort13ParseError -from .fort15_io import Fort15, read_fort15, write_fort15, Fort15ParseError +# Eagerly export the stdlib-pure fort.14 raw I/O surface only. fort14_io imports +# nothing heavier than the standard library, so this path stays numpy/matplotlib +# free (#255 — keeps Valence's pure-stdlib fort.14 delegation contract intact). from .fort14_io import ( Fort14Raw, OpenBoundary, @@ -27,39 +33,6 @@ write_fort14_raw, Fort14ParseError, ) -from .summary_io import summary, SummaryError -from .mesh_topology import EdgeMap, quad_from_tri_pair, quads_from_tri_pairs -from .mutations import MutableMesh -from .quality import element_quality, courant_number, cfl_gate -from .geometry import ( - haversine_m, - edge_lengths, - EARTH_RADIUS_M, - convex_hull, - is_antimeridian_wrapping, - split_antimeridian_bbox, - bbox_iou, - hausdorff_distance, -) -from .node_match import ( - NodeMatch, - match_nodes, - derive_tolerance, - nodal_field_delta, -) -from . import examples -from . import bridge -from . import chilplotting -from . import layer_paths -from .layer_paths import paths_on_outer_vertices -from .admesh_warmstart import optimize_with_admesh_truss, optimize_with_admesh_truss_arrays -from .bridge import ( - MeshAdapterForMADMESHR, - MeshAdapterForADMESH, - MeshAdapterForADMESHDomains, -) - -Mesh = CHILmesh try: __version__ = metadata.version("chilmesh") @@ -67,6 +40,91 @@ __version__ = "0.0.0" +# --- Lazy attribute loading (PEP 562) -------------------------------------- +# name -> relative module that defines it. Accessing any of these triggers the +# heavy import chain (numpy / matplotlib) only when actually needed. +_LAZY_ATTRS = { + # .CHILmesh (the CHILmesh/Mesh class names are handled by the + # _ChilmeshModule __getattribute__ guard below, not here — see #255.) + "write_fort14": ".CHILmesh", + # .gmsh_io + "read_msh": ".gmsh_io", + "write_msh": ".gmsh_io", + "GmshParseError": ".gmsh_io", + # .fort13_io + "Fort13": ".fort13_io", + "NodalAttribute": ".fort13_io", + "read_fort13": ".fort13_io", + "write_fort13": ".fort13_io", + "Fort13ParseError": ".fort13_io", + # .fort15_io + "Fort15": ".fort15_io", + "read_fort15": ".fort15_io", + "write_fort15": ".fort15_io", + "Fort15ParseError": ".fort15_io", + # .summary_io + "summary": ".summary_io", + "SummaryError": ".summary_io", + # .mesh_topology + "EdgeMap": ".mesh_topology", + "quad_from_tri_pair": ".mesh_topology", + "quads_from_tri_pairs": ".mesh_topology", + # .mutations + "MutableMesh": ".mutations", + # .quality + "element_quality": ".quality", + "courant_number": ".quality", + "cfl_gate": ".quality", + # .geometry + "haversine_m": ".geometry", + "edge_lengths": ".geometry", + "EARTH_RADIUS_M": ".geometry", + "convex_hull": ".geometry", + "is_antimeridian_wrapping": ".geometry", + "split_antimeridian_bbox": ".geometry", + "bbox_iou": ".geometry", + "hausdorff_distance": ".geometry", + # .node_match + "NodeMatch": ".node_match", + "match_nodes": ".node_match", + "derive_tolerance": ".node_match", + "nodal_field_delta": ".node_match", + # .layer_paths + "paths_on_outer_vertices": ".layer_paths", + # .admesh_warmstart + "optimize_with_admesh_truss": ".admesh_warmstart", + "optimize_with_admesh_truss_arrays": ".admesh_warmstart", + # .bridge + "MeshAdapterForMADMESHR": ".bridge", + "MeshAdapterForADMESH": ".bridge", + "MeshAdapterForADMESHDomains": ".bridge", +} + +# Submodules re-exported lazily as attributes of the package. +_LAZY_SUBMODULES = ("examples", "bridge", "chilplotting", "layer_paths") + + +def __getattr__(name): # PEP 562 lazy attribute access + # ``Mesh`` / ``CHILmesh`` are resolved by the _ChilmeshModule + # __getattribute__ guard below (submodule name-collision, #255) and never + # reach here. + if name in _LAZY_SUBMODULES: + mod = importlib.import_module(f".{name}", __name__) + globals()[name] = mod + return mod + target = _LAZY_ATTRS.get(name) + if target is not None: + mod = importlib.import_module(target, __name__) + value = getattr(mod, name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) + + def backend_info() -> dict: """Return information about the available and selected mesh backends. @@ -174,3 +232,34 @@ def backend_info() -> dict: "MeshAdapterForADMESH", "MeshAdapterForADMESHDomains", ] + + +# --- Class/submodule name-collision guard (#255) --------------------------- +# ``CHILmesh`` names both the primary class and the ``chilmesh.CHILmesh`` +# submodule that defines it. Importing that submodule (directly, or via any +# ``from .CHILmesh import ...`` / ``from . import CHILmesh``) makes the import +# system bind the *package attribute* ``chilmesh.CHILmesh`` to the module, +# shadowing the class for ``chilmesh.CHILmesh`` and ``from chilmesh import +# CHILmesh``. A PEP 562 ``__getattr__`` can't override this (it only fires when +# the attribute is missing, and the submodule binding makes it present). A +# module ``__getattribute__`` override resolves both public names to the class +# unconditionally, independent of import order — while still importing the +# (numpy-heavy) class module only on first access, so the lightweight fort.14 +# path stays stdlib-only. +import sys as _sys +from types import ModuleType as _ModuleType + + +class _ChilmeshModule(_ModuleType): + def __getattribute__(self, name): + if name in ("CHILmesh", "Mesh"): + cache = super().__getattribute__("__dict__") + cls = cache.get("_mesh_cls") + if cls is None: + from chilmesh.CHILmesh import CHILmesh as cls + cache["_mesh_cls"] = cls + return cls + return super().__getattribute__(name) + + +_sys.modules[__name__].__class__ = _ChilmeshModule diff --git a/src/chilmesh/backends/rust_backend.py b/src/chilmesh/backends/rust_backend.py index 5054ce1f..8d4e4b8c 100644 --- a/src/chilmesh/backends/rust_backend.py +++ b/src/chilmesh/backends/rust_backend.py @@ -3,6 +3,12 @@ This module wraps the compiled chilmesh_core extension (PyO3/maturin). It provides the same logical interface as the C++ backend wrapper. +STATUS: FROZEN (2026-07-14). This backend is kept and output-equivalent to +Python, but is not developed further and is not the recommended accelerator — +it is measured ~2-5x slower than the C++ backend on full init with no +performance niche over it. Use the C++ backend for speed. See +``docs/RUST_EVALUATION.md`` and ``src/chilmesh_core/STATUS.md``. + Usage:: from chilmesh.backends.rust_backend import RUST_AVAILABLE, full_init diff --git a/src/chilmesh/fort14_io.py b/src/chilmesh/fort14_io.py index 1f5697ad..1c74dedf 100644 --- a/src/chilmesh/fort14_io.py +++ b/src/chilmesh/fort14_io.py @@ -85,6 +85,7 @@ class Fort14Raw: elements: dict # element id -> tuple of node ids (true arity, file winding) open_boundaries: list = field(default_factory=list) flow_boundaries: list = field(default_factory=list) + boundaries_present: bool = False # True iff a NOPE/NBOU block was physically parsed (#259) def read_fort14_raw(filename, parse_boundaries: bool = True) -> Fort14Raw: @@ -100,6 +101,12 @@ def read_fort14_raw(filename, parse_boundaries: bool = True) -> Fort14Raw: Fort14ParseError: on a malformed header, node/element block, or boundary block. A legacy mesh with no boundary block is accepted (empty ``open_boundaries`` / ``flow_boundaries``). + + Note: + ``boundaries_present`` is ``True`` only when a NOPE/NBOU block was + physically parsed, distinguishing a present-but-empty (0/0) boundary + section from an absent one (#259). It stays ``False`` when + ``parse_boundaries=False`` (the block was skipped, not inspected). """ path = Path(filename) with open(path) as fh: @@ -144,7 +151,9 @@ def read_fort14_raw(filename, parse_boundaries: bool = True) -> Fort14Raw: open_boundaries: list = [] flow_boundaries: list = [] + boundaries_present = False if parse_boundaries and i < len(lines) and lines[i].strip(): + boundaries_present = True try: nope = int(lines[i].split()[0]); i += 1 i += 1 # NETA (total open nodes) — recomputed on write @@ -209,6 +218,7 @@ def read_fort14_raw(filename, parse_boundaries: bool = True) -> Fort14Raw: grid_name=grid_name, n_nodes=n_nodes, n_elems=n_elems, node_ids=node_ids, coords=coords, elem_ids=elem_ids, elements=elements, open_boundaries=open_boundaries, flow_boundaries=flow_boundaries, + boundaries_present=boundaries_present, ) diff --git a/src/chilmesh/gmsh_io.py b/src/chilmesh/gmsh_io.py index b6b91e84..75eaefdd 100644 --- a/src/chilmesh/gmsh_io.py +++ b/src/chilmesh/gmsh_io.py @@ -13,6 +13,25 @@ class GmshParseError(Exception): pass +def _require_int(token: str, what: str) -> int: + """Parse and validate an integer token. + + Parameters: + token: String to parse as integer + what: Human-readable description of the field (for error message) + + Returns: + Parsed integer value + + Raises: + GmshParseError: If token cannot be parsed as integer + """ + try: + return int(token) + except ValueError as exc: + raise GmshParseError(f"malformed {what}: non-numeric {token!r}") from exc + + def read_msh(full_file_name: str) -> "CHILmesh": """ Load a mesh from a Gmsh ASCII .msh file. @@ -134,7 +153,7 @@ def _read_msh_v2_2(lines: list, filename: str) -> "CHILmesh": if len(parts) < 4: raise GmshParseError(f"Element line malformed: {lines[elem_idx]}") try: - elem_id = int(parts[0]) + _require_int(parts[0], "element ID") elem_type = int(parts[1]) n_tags = int(parts[2]) # Skip tag section, extract node list @@ -189,7 +208,7 @@ def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh": if len(header_parts) < 4: raise GmshParseError("$Nodes header malformed") num_blocks = int(header_parts[0]) - num_nodes = int(header_parts[1]) + _require_int(header_parts[1], "number of nodes") except ValueError: raise GmshParseError("$Nodes header has non-numeric values") @@ -201,9 +220,9 @@ def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh": if len(block_header) < 4: raise GmshParseError(f"Node block {block_idx} header malformed") try: - dim = int(block_header[0]) - tag = int(block_header[1]) - parametric = int(block_header[2]) + _require_int(block_header[0], f"node block {block_idx} dimension") + _require_int(block_header[1], f"node block {block_idx} tag") + _require_int(block_header[2], f"node block {block_idx} parametric") num_in_block = int(block_header[3]) except ValueError: raise GmshParseError(f"Node block {block_idx} header has non-numeric values") @@ -250,7 +269,7 @@ def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh": if len(elem_header) < 4: raise GmshParseError("$Elements header malformed") elem_blocks = int(elem_header[0]) - elem_total = int(elem_header[1]) + _require_int(elem_header[1], "total number of elements") except ValueError: raise GmshParseError("$Elements header has non-numeric values") @@ -262,8 +281,8 @@ def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh": if len(block_header) < 4: raise GmshParseError(f"Element block {block_idx} header malformed") try: - dim = int(block_header[0]) - tag = int(block_header[1]) + _require_int(block_header[0], f"element block {block_idx} dimension") + _require_int(block_header[1], f"element block {block_idx} tag") elem_type = int(block_header[2]) num_in_block = int(block_header[3]) except ValueError: @@ -276,7 +295,7 @@ def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh": raise GmshParseError(f"Element block {block_idx} incomplete") parts = lines[line_idx].split() try: - elem_tag = int(parts[0]) + int(parts[0]) # validation only: ValueError -> enclosing except keeps the "Element line malformed" contract if elem_type == 2: # Triangle if len(parts) < 4: raise GmshParseError(f"Triangle element line too short: {lines[line_idx]}") diff --git a/src/chilmesh/quality.py b/src/chilmesh/quality.py index 201cb04a..d922ff46 100644 --- a/src/chilmesh/quality.py +++ b/src/chilmesh/quality.py @@ -191,6 +191,24 @@ def _quad_quality( (90.0 - angle_min) / 90.0, ) return float(skewness) + elif metric in ("min_angle", "max_angle"): + # Quad angle metrics use the element's own four interior angles + # (radians), same convention as the skew/eas branches above. The + # prior triangle-split path returned min-of-sub-triangle-maxima, + # under-reporting a genuinely obtuse quad (#260). + verts = np.array([v0, v1, v2, v3]) + angles = np.zeros(4) + for j in range(4): + v_curr = verts[j] + v_next = verts[(j + 1) % 4] + v_prev = verts[(j - 1) % 4] + v_edge1 = v_next - v_curr + v_edge2 = v_prev - v_curr + n1 = np.linalg.norm(v_edge1) + 1e-12 + n2 = np.linalg.norm(v_edge2) + 1e-12 + dot = np.clip(np.dot(v_edge1 / n1, v_edge2 / n2), -1.0, 1.0) + angles[j] = np.arccos(dot) + return float(angles.min() if metric == "min_angle" else angles.max()) else: # For other metrics, split quad into two triangles and take min q1 = _triangle_quality(v0, v1, v2, metric) @@ -304,8 +322,9 @@ def element_quality( v2 = verts_array[elem_valid[2]] v3 = verts_array[elem_valid[3]] - if metric in ("skew", "skewness", "angular_skewness", "angular skewness", "equiangle_skewness", "equiangle skewness", "eas"): - # Use dedicated quad skew quality function + if metric in ("skew", "skewness", "angular_skewness", "angular skewness", "equiangle_skewness", "equiangle skewness", "eas", "min_angle", "max_angle"): + # Dedicated quad function: skew/eas + raw-interior-angle + # min/max (#260 — angle metrics must not triangle-split). qualities[i] = _quad_quality(v0, v1, v2, v3, metric) else: # For other metrics, split into two triangles and take minimum diff --git a/src/chilmesh/summary_io.py b/src/chilmesh/summary_io.py index aa483e85..ec72a060 100644 --- a/src/chilmesh/summary_io.py +++ b/src/chilmesh/summary_io.py @@ -1,12 +1,19 @@ """Lazy header-only mesh metadata reading for CHILmesh. -Supports fast metadata extraction from mesh files (fort.14, 2dm, fort.13, fort.15, .npy, .npz) without +Supports fast metadata extraction from mesh files (fort.14, 2dm, fort.13, fort.15, .msh, .npy, .npz, +generic fort.NNN sidecars) without loading full mesh data. For CHILmesh objects, returns mesh properties directly. """ from __future__ import annotations +import re from pathlib import Path +# Any file literally named fort. (fort.22/.24 forcing, fort.19/.20/.61/.63 +# output, …) with no dedicated reader. Specific suffix branches (.14/.13/.15) run +# first, so only unhandled fort.NNN sidecars fall through to the generic path. +_FORT_GENERIC_RE = re.compile(r'^fort\.\d+$') + class SummaryError(Exception): """Raised when summary extraction encounters an error.""" @@ -110,6 +117,10 @@ def _summary_from_file(path: Path, *, deep: bool = False) -> dict: fmt = 'npy' elif suffix == '.npz': fmt = 'npz' + elif suffix == '.msh': + fmt = 'gmsh' + elif _FORT_GENERIC_RE.match(path.name.lower()): + fmt = 'fort_generic' else: raise SummaryError(f"Unknown mesh format: {suffix}") @@ -138,6 +149,10 @@ def _summary_from_file(path: Path, *, deep: bool = False) -> dict: _read_npy_header(path, result) elif fmt == 'npz': _read_npz_header(path, result) + elif fmt == 'gmsh': + _read_msh_header(path, result) + elif fmt == 'fort_generic': + _read_fort_generic_header(path, result) # If deep=True, load the full mesh for element_type and bbox if deep: @@ -317,4 +332,64 @@ def _read_npz_header(path: Path, result: dict) -> None: raise SummaryError(f"Cannot read .npz header {path}: {e}") +def _read_msh_header(path: Path, result: dict) -> None: + """Read a Gmsh .msh header (version + node/element counts) by streaming. + + Scans the file line-by-line without allocating node/element arrays (same + streaming, no-array approach as the .2dm path). Supports Gmsh ASCII + versions 2.2 and 4.1. In v2.2 the count line after ``$Nodes`` / + ``$Elements`` is a single integer; in v4.1 it is + ``numBlocks numEntities minTag maxTag`` and the entity count is the second + token. The scan stops once the element count is read, so element bodies are + never traversed. + """ + version = None + try: + with open(path, 'r', encoding='utf-8') as f: + for line in f: + s = line.strip() + if s == '$MeshFormat': + fmt_line = f.readline().split() + if fmt_line: + version = fmt_line[0] + result['gmsh_version'] = version + elif s == '$Nodes': + toks = f.readline().split() + if toks: + idx = 1 if (version and version.startswith('4')) else 0 + result['n_nodes'] = int(toks[idx]) + elif s == '$Elements': + toks = f.readline().split() + if toks: + idx = 1 if (version and version.startswith('4')) else 0 + result['n_elems'] = int(toks[idx]) + break + except (IOError, ValueError, IndexError) as e: + raise SummaryError(f"Cannot read .msh header {path}: {e}") + if version is None: + raise SummaryError(f"gmsh .msh header malformed: no $MeshFormat in {path}") + + +def _read_fort_generic_header(path: Path, result: dict) -> None: + """Read a generic fort.NNN sidecar's first line only (fully lazy). + + ADCIRC emits many numbered fort.NNN files with no dedicated reader + (forcing: fort.22/.24; output: fort.19/.20/.61/.63; …). The + ``mesh_read_guard`` PreToolUse hook reroutes ALL ``fort.`` reads to + ``chilmesh summary``, so summary() must resolve every one to at least + minimal metadata rather than raising ``Unknown mesh format``. Reads only + the first line — never the (potentially multi-GB) body — mirroring the + fort.15 lazy approach; ``fort_number`` carries the numeric suffix so the + caller can tell which sidecar it is. + """ + result['fort_number'] = path.suffix.lstrip('.') + try: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + first_line = f.readline().strip() + if first_line: + result['description'] = first_line + except OSError as e: + raise SummaryError(f"Cannot read fort file {path}: {e}") + + __all__ = ["summary", "SummaryError"] diff --git a/src/chilmesh_core/STATUS.md b/src/chilmesh_core/STATUS.md new file mode 100644 index 00000000..df68a1fa --- /dev/null +++ b/src/chilmesh_core/STATUS.md @@ -0,0 +1,52 @@ +# chilmesh_core (Rust backend) — STATUS: FROZEN + +**Status:** ❄️ **FROZEN** (2026-07-14, operator-directed) +**Evaluation:** [`docs/RUST_EVALUATION.md`](../../docs/RUST_EVALUATION.md) + +## What "frozen" means here + +This Rust quad-edge backend is **kept but not developed further**: + +- **Kept** — the crate, its `rust-equivalence` CI job, the cross-backend equivalence + tests, and the measured benchmark data all stay. The backend remains buildable + (`maturin build --release`) and output-equivalent to Python (76/76 equivalence tests). +- **Not evolved** — no new features, and it is **not** kept in lockstep as the Python + API grows. Treat it as a reference/experimental artifact, not a maintained backend. + +## How this differs from the Python / C++ backends + +Same outputs, different internal topology structure: + +- **Python** (reference) — flat numpy arrays + dict adjacencies + an `EdgeMap` hash. +- **C++** — **half-edge (DCEL)** (`src/chilmesh_cpp/src/halfedge.*`); the accelerator. +- **Rust (this crate)** — **quad-edge** (Guibas–Stolfi 1985; four directed edges per + undirected edge). + +All three are **bit-identical on output** (layers, adjacency tables, signed areas — +guarded by `tests/test_backend_equivalence.py`). The quad-edge port was an experiment +(`.planning/008-DECISION.md`) to test whether a different topology structure would scale +better; it does not beat the C++ half-edge backend — the data-structure bet did not pay +off, which is the core reason for the freeze. + +## Why (summary; full detail + numbers in the evaluation) + +A measured, like-for-like comparison found that Rust: + +- full-inits ~3–5× faster than pure Python but **~2–5× slower than C++** (≈5× behind on + Block_O) — it earns **no performance niche over C++**, which is the recommended, + bit-identical accelerator; +- adds a second compiled-backend maintenance surface (a 2nd toolchain, a 2nd wheel + matrix, a 2nd equivalence lane, a 2nd binding set) for no speed C++ doesn't already + provide. + +The one real query-path defect (`get_vertex_edges` was O(_n_)/call) was fixed before the +freeze — queries are now O(1) — so the frozen state is *correct and fast on queries*, just +not the acceleration path. See the evaluation for the before/after numbers and the +"could Rust replace Python as the default?" analysis (answer: no — ship prebuilt **C++** +wheels instead; see [`docs/dev/PREBUILT_WHEELS_PLAN.md`](../../docs/dev/PREBUILT_WHEELS_PLAN.md)). + +## If you are tempted to revive it + +Only two greenfield niches could justify a compiled backend that C++ doesn't already +own — Phase-5 spatial indexing and topology-mutation `splice`. Even there, extend the +existing **C++ half-edge** backend first; unfreezing Rust needs a fresh, measured case. diff --git a/src/chilmesh_core/io.rs b/src/chilmesh_core/io.rs index 77843ea2..813640a5 100644 --- a/src/chilmesh_core/io.rs +++ b/src/chilmesh_core/io.rs @@ -185,6 +185,7 @@ pub fn parse_fort14(path: &str) -> Result { num_verts: n_verts, num_elems: n_elems, edges: None, + vert2edge: None, areas: None, layers: None, }) @@ -538,6 +539,7 @@ pub fn parse_2dm(path: &str) -> Result { num_verts: n_verts, num_elems: n_elems, edges: None, + vert2edge: None, areas: None, layers: None, }) diff --git a/src/chilmesh_core/lib.rs b/src/chilmesh_core/lib.rs index 36e3e5d7..3be95f5e 100644 --- a/src/chilmesh_core/lib.rs +++ b/src/chilmesh_core/lib.rs @@ -18,6 +18,7 @@ pub struct RustMesh { pub num_verts: usize, pub num_elems: usize, pub edges: Option>, // Quad-edge topology: [n_edges, 4] + pub vert2edge: Option>>, // cached vertex -> incident edge ids (built in build_adjacencies) pub areas: Option>, // Signed areas: [n_elems] (computed on demand) pub layers: Option>, // Skeletonization layers (computed on demand) } @@ -33,6 +34,7 @@ impl RustMesh { num_verts: 0, num_elems: 0, edges: None, + vert2edge: None, areas: None, layers: None, } @@ -86,6 +88,8 @@ impl RustMesh { let array = connectivity.as_ref(py).to_owned_array(); self.connectivity = array; self.num_elems = self.connectivity.shape()[0]; + self.edges = None; + self.vert2edge = None; Ok(()) } @@ -105,6 +109,26 @@ impl RustMesh { fn build_adjacencies(&mut self) -> PyResult<()> { let edges = adjacency::build_quadegg_from_connectivity(&self.connectivity, self.num_verts); self.edges = Some(edges); + // Cache vertex -> incident edge ids once (O(n_edges)) so get_vertex_edges is + // O(1) per call instead of rebuilding the edge list on every call. + let edge2vert = adjacency::to_edge2vert(&self.connectivity); + let n_edges = edge2vert.shape()[0]; + let mut v2e: Vec> = vec![Vec::new(); self.num_verts]; + for edge_idx in 0..n_edges { + let a = edge2vert[[edge_idx, 0]] as usize; + let b = edge2vert[[edge_idx, 1]] as usize; + if a < self.num_verts { + v2e[a].push(edge_idx); + } + if b < self.num_verts { + v2e[b].push(edge_idx); + } + } + for row in v2e.iter_mut() { + row.sort_unstable(); + row.dedup(); + } + self.vert2edge = Some(v2e); Ok(()) } @@ -173,14 +197,12 @@ impl RustMesh { } } - /// Get edges incident to a vertex (requires edges to be computed) + /// Get edges incident to a vertex (requires build_adjacencies to have run). + /// O(1): returns the cached vertex->edge row (empty for out-of-range v, matching + /// the prior scan-finds-nothing behavior). fn get_vertex_edges(&self, v: usize) -> PyResult> { - match &self.edges { - Some(_edges_array) => { - // Convert quad-edge format [n_edges, 4] to edge list [n_edges, 2] - let edge2vert = adjacency::to_edge2vert(&self.connectivity); - Ok(queries::get_vertex_edges(v, &edge2vert)) - } + match &self.vert2edge { + Some(v2e) => Ok(v2e.get(v).cloned().unwrap_or_default()), None => Err(pyo3::exceptions::PyRuntimeError::new_err( "Edges not computed. Call build_adjacencies() first.", )), diff --git a/tests/fixtures/gmsh/sample.msh b/tests/fixtures/gmsh/sample.msh new file mode 100644 index 00000000..40658032 --- /dev/null +++ b/tests/fixtures/gmsh/sample.msh @@ -0,0 +1,15 @@ +$MeshFormat +2.2 0 8 +$EndMeshFormat +$Nodes +4 +1 0 0 0 +2 1 0 0 +3 1 1 0 +4 0 1 0 +$EndNodes +$Elements +2 +1 2 2 0 1 1 2 3 +2 2 2 0 1 1 3 4 +$EndElements diff --git a/tests/test_fort14_io_raw.py b/tests/test_fort14_io_raw.py index 6362ade4..633f7475 100644 --- a/tests/test_fort14_io_raw.py +++ b/tests/test_fort14_io_raw.py @@ -205,3 +205,34 @@ def test_truncated_boundary_block(tmp_path): # Parser should return what it got without crashing. assert len(raw.open_boundaries) >= 0 assert len(raw.flow_boundaries) == 0 + + +def test_boundaries_present_when_block_physically_present(tmp_path): + """#259: a present-but-empty (0/0) NOPE/NBOU block sets boundaries_present.""" + # CW fixture has a physical 0/0/0/0 boundary block. + raw = read_fort14_raw(_write(tmp_path, CW)) + assert raw.boundaries_present is True + assert raw.open_boundaries == [] + assert raw.flow_boundaries == [] + + +def test_boundaries_absent_when_no_block(tmp_path): + """#259: a mesh with no boundary section at all keeps boundaries_present False.""" + no_block = """ + no-boundary mesh + 1 3 + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 0.0 1.0 0.0 + 1 3 1 2 3 + """ + raw = read_fort14_raw(_write(tmp_path, no_block)) + assert raw.boundaries_present is False + assert raw.open_boundaries == [] + assert raw.flow_boundaries == [] + + +def test_boundaries_present_false_when_parse_skipped(tmp_path): + """#259: parse_boundaries=False leaves boundaries_present False (not inspected).""" + raw = read_fort14_raw(_write(tmp_path, CW), parse_boundaries=False) + assert raw.boundaries_present is False diff --git a/tests/test_fort14_roundtrip.py b/tests/test_fort14_roundtrip.py index 8956f60e..0a551547 100644 --- a/tests/test_fort14_roundtrip.py +++ b/tests/test_fort14_roundtrip.py @@ -9,6 +9,8 @@ """ from __future__ import annotations +import textwrap + import numpy as np import pytest @@ -47,3 +49,38 @@ def test_fort14_roundtrip_identity(name, tmp_path): assert sorted(orig_sets) == sorted(rel_sets), ( f"{name}: connectivity vertex-sets differ after roundtrip" ) + + +def test_chilmesh_boundaries_present_distinguishes_empty_from_absent(tmp_path): + """#259: CHILmesh.read_from_fort14 distinguishes present-but-empty from absent.""" + # Minimal 1-element triangle mesh WITH physical 0/0/0/0 boundary block. + with_block = """ + test mesh with boundaries + 1 3 + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 0.0 1.0 0.0 + 1 3 1 2 3 + 0 + 0 + 0 + 0 + """ + p = tmp_path / "with_block.14" + p.write_text(textwrap.dedent(with_block).strip() + "\n", encoding="utf-8") + mesh_with = CHILmesh.read_from_fort14(p, compute_layers=False, compute_adjacencies=False) + assert mesh_with.boundaries_present is True + + # Same mesh WITHOUT any boundary block at all. + without_block = """ + test mesh no boundaries + 1 3 + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 0.0 1.0 0.0 + 1 3 1 2 3 + """ + p = tmp_path / "without_block.14" + p.write_text(textwrap.dedent(without_block).strip() + "\n", encoding="utf-8") + mesh_without = CHILmesh.read_from_fort14(p, compute_layers=False, compute_adjacencies=False) + assert mesh_without.boundaries_present is False diff --git a/tests/test_lightweight_import.py b/tests/test_lightweight_import.py new file mode 100644 index 00000000..a7e715db --- /dev/null +++ b/tests/test_lightweight_import.py @@ -0,0 +1,42 @@ +"""Regression: `import chilmesh` lightweight fort.14 path stays stdlib-only (#255). + +`from chilmesh import read_fort14_raw` must not require numpy or matplotlib, so +downstream pure-stdlib consumers (Valence's fort.14 delegation, #214) keep +working. The stdlib check runs in a subprocess with the heavy deps blocked, so +it is independent of whatever the parent pytest process already imported. +""" +import subprocess +import sys +import textwrap + + +def test_fort14_raw_import_needs_no_numpy_or_matplotlib(): + code = textwrap.dedent( + """ + import sys + # Block the heavy stack: any import attempt now raises ModuleNotFoundError. + for _m in ("numpy", "matplotlib", "matplotlib.pyplot", "matplotlib.cm", "scipy"): + sys.modules[_m] = None + from chilmesh import read_fort14_raw, write_fort14_raw, Fort14Raw + assert callable(read_fort14_raw) + assert callable(write_fort14_raw) + print("LIGHTWEIGHT_OK") + """ + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert result.returncode == 0, ( + "import chilmesh.read_fort14_raw pulled in a blocked heavy dep:\n" + + result.stderr + ) + assert "LIGHTWEIGHT_OK" in result.stdout + + +def test_heavy_surface_still_available(): + """The lazy names still resolve when the heavy deps ARE present.""" + import chilmesh + + assert chilmesh.Mesh is chilmesh.CHILmesh + assert callable(chilmesh.summary) + assert hasattr(chilmesh, "chilplotting") diff --git a/tests/test_quality_degenerate_branches.py b/tests/test_quality_degenerate_branches.py index 4b21cac3..4e86ff47 100644 --- a/tests/test_quality_degenerate_branches.py +++ b/tests/test_quality_degenerate_branches.py @@ -67,11 +67,11 @@ def test_public_api_collinear_angular_skewness_via_element_quality(self): class TestQuadQualityNonSkewSplit: """Test lines 147-149: _quad_quality else branch for non-skew metrics.""" - def test_unit_square_min_angle_split(self): - """Unit square split into two triangles, min_angle metric. + def test_unit_square_min_angle_raw(self): + """Unit square min_angle uses raw interior angles (#260). - Quad split as (v0,v1,v2) and (v0,v2,v3). - Result should equal min of the two triangle qualities. + Every interior angle of a unit square is 90 deg (pi/2 rad); the + prior triangle-split path returned pi/4 (a triangulation artifact). """ v0 = np.array([0.0, 0.0]) v1 = np.array([1.0, 0.0]) @@ -80,12 +80,7 @@ def test_unit_square_min_angle_split(self): q_quad = _quad_quality(v0, v1, v2, v3, metric="min_angle") - # Manually compute the two triangle qualities - q_tri_1 = _triangle_quality(v0, v1, v2, metric="min_angle") - q_tri_2 = _triangle_quality(v0, v2, v3, metric="min_angle") - expected = min(q_tri_1, q_tri_2) - - np.testing.assert_allclose(q_quad, expected) + np.testing.assert_allclose(q_quad, np.pi / 2) def test_unit_square_aspect_ratio_split(self): """Unit square split into two triangles, aspect_ratio metric.""" @@ -114,8 +109,11 @@ def test_quad_quality_returns_float(self): assert isinstance(q, float) - def test_unit_square_max_angle_split(self): - """Unit square with max_angle metric.""" + def test_unit_square_max_angle_raw(self): + """Unit square max_angle uses raw interior angles (#260). + + Every interior angle is 90 deg (pi/2 rad). + """ v0 = np.array([0.0, 0.0]) v1 = np.array([1.0, 0.0]) v2 = np.array([1.0, 1.0]) @@ -123,11 +121,7 @@ def test_unit_square_max_angle_split(self): q_quad = _quad_quality(v0, v1, v2, v3, metric="max_angle") - q_tri_1 = _triangle_quality(v0, v1, v2, metric="max_angle") - q_tri_2 = _triangle_quality(v0, v2, v3, metric="max_angle") - expected = min(q_tri_1, q_tri_2) - - np.testing.assert_allclose(q_quad, expected) + np.testing.assert_allclose(q_quad, np.pi / 2) class TestElementQualityDegenerateFewVertices: diff --git a/tests/test_standalone_quality.py b/tests/test_standalone_quality.py index 93409762..6a533865 100644 --- a/tests/test_standalone_quality.py +++ b/tests/test_standalone_quality.py @@ -143,6 +143,22 @@ def test_aspect_ratio_default(self): np.testing.assert_allclose(q1, q2) + def test_quad_max_angle_reports_obtuse_corner(self): + """Skewed quad's max_angle is its true obtuse corner, not a + triangle-split under-report (#260). + + Quad [[0,0],[3,0],[2.2,1],[0,1]] has interior angles + 90 / 51.34 / 128.66 / 90 deg; max_angle must read 128.66 deg. + """ + verts = np.array([[0.0, 0.0], [3.0, 0.0], [2.2, 1.0], [0.0, 1.0]]) + conn = [[0, 1, 2, 3]] + + mx = float(np.ravel(chilmesh.element_quality(verts, conn, metric="max_angle"))[0]) + mn = float(np.ravel(chilmesh.element_quality(verts, conn, metric="min_angle"))[0]) + + np.testing.assert_allclose(np.degrees(mx), 128.66, atol=0.05) + np.testing.assert_allclose(np.degrees(mn), 51.34, atol=0.05) + def test_invalid_metric_raises(self): """Invalid metric should raise ValueError.""" verts = np.array([[0.0, 0.0], [1.0, 0.0], [0.5, 0.5]]) diff --git a/tests/test_summary_io.py b/tests/test_summary_io.py index 8c5bb64a..d5ec86e8 100644 --- a/tests/test_summary_io.py +++ b/tests/test_summary_io.py @@ -141,6 +141,45 @@ def test_2dm_roundtrip(self, tmp_path): assert result['file_bytes'] > 0, f"Expected file_bytes>0, got {result['file_bytes']}" +class TestSummaryFortGeneric: + """Generic fort.NNN sidecars (#201): guard reroutes every fort. + to `chilmesh summary`, so summary() must resolve them, not raise.""" + + def test_fort_forcing_sidecar(self, tmp_path): + """fort.22 (forcing) resolves to fort_generic metadata, not Unknown-format.""" + f = tmp_path / "fort.22" + f.write_text("test forcing file\n100 3\n1 0.5 0.5\n") + result = summary(f) + assert result['format'] == 'fort_generic' + assert result['fort_number'] == '22' + assert result['description'] == 'test forcing file' + assert result['file_bytes'] > 0 + + def test_fort_output_sidecar(self, tmp_path): + """fort.63 (output) also resolves via the generic path.""" + f = tmp_path / "fort.63" + f.write_text("! elevation output\n") + result = summary(f) + assert result['format'] == 'fort_generic' + assert result['fort_number'] == '63' + + def test_specific_fort_reader_wins(self, tmp_path): + """A file named fort.14 still routes to the fort14 reader, not generic.""" + f = tmp_path / "fort.14" + f.write_text("grid\n2 4\n") + result = summary(f) + assert result['format'] == 'fort14' + assert result['n_elems'] == 2 and result['n_nodes'] == 4 + + def test_empty_fort_generic_no_description(self, tmp_path): + """Empty first line → no description key, still resolves.""" + f = tmp_path / "fort.19" + f.write_text("") + result = summary(f) + assert result['format'] == 'fort_generic' + assert 'description' not in result + + class TestSummaryErrors: """Test error cases for summary().""" @@ -199,6 +238,29 @@ def test_fort14_suffix_with_fort14_extension(self, tmp_path): assert result['n_nodes'] == 9, f"Expected n_nodes=9, got {result['n_nodes']}" +class TestSummaryGrd: + """Test .grd suffix detection (ADCIRC grid file, fort.14 format; #201). + + The mesh_read_guard hook reroutes .grd reads to `chilmesh summary ` + and the summary() docstring lists .grd as supported, but no test asserted + the reroute target actually resolves. Regression guard for that path. + """ + + def test_grd_suffix_detects_as_fort14(self, tmp_path): + """File with .grd suffix detects as fort14 format (same 2-line header).""" + mesh_file = tmp_path / "mesh.grd" + mesh_file.write_text("ADCIRC grid\n4 9\n") + + result = summary(mesh_file) + + assert result['format'] == 'fort14', ( + f"Expected format='fort14' for .grd suffix, got {result['format']}" + ) + assert result['n_elems'] == 4, f"Expected n_elems=4, got {result['n_elems']}" + assert result['n_nodes'] == 9, f"Expected n_nodes=9, got {result['n_nodes']}" + assert result['grid_name'] == 'ADCIRC grid' + + class TestSummaryFileStatErrors: """Test file stat errors (lines 104-105).""" @@ -604,3 +666,25 @@ def test_npz_corrupted_archive_error(self, tmp_path): assert "cannot read .npz header" in str(exc_info.value).lower(), ( f"SummaryError should mention 'cannot read .npz header', got: {exc_info.value}" ) + + +class TestSummaryGmsh: + """Test summary() on Gmsh .msh format files.""" + + def test_msh_sample_shallow(self): + """Gmsh .msh shallow summary of sample.msh: streaming header read.""" + path = Path(__file__).parent / "fixtures" / "gmsh" / "sample.msh" + result = summary(path) + assert result['format'] == 'gmsh', f"Expected format='gmsh', got {result['format']}" + assert result['gmsh_version'] == '2.2', ( + f"Expected gmsh_version='2.2', got {result.get('gmsh_version')}" + ) + assert result['n_nodes'] == 4, f"Expected n_nodes=4, got {result.get('n_nodes')}" + assert result['n_elems'] == 2, f"Expected n_elems=2, got {result.get('n_elems')}" + + def test_msh_missing_meshformat_error(self, tmp_path): + """A .msh with no $MeshFormat section raises SummaryError.""" + bad = tmp_path / "bad.msh" + bad.write_text("$Nodes\n1\n1 0 0 0\n$EndNodes\n") + with pytest.raises(SummaryError): + summary(bad)