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 @@
-
+
-> **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 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
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
and
---
@@ -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.
+
@@ -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)
-
+
+
+
+
+
+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/audit_hero_gif.py b/scripts/audit_hero_gif.py
index e945f5f0..497cdada 100644
--- a/scripts/audit_hero_gif.py
+++ b/scripts/audit_hero_gif.py
@@ -15,7 +15,7 @@
import argparse
import sys
import numpy as np
-from typing import Optional, Tuple, Callable
+from typing import Tuple, Callable
# Module-level constants (no generator imports at module level)
HBINS = 40
diff --git a/scripts/benchmark.py b/scripts/benchmark.py
index 6f135231..b79a5c3f 100644
--- a/scripts/benchmark.py
+++ b/scripts/benchmark.py
@@ -398,7 +398,6 @@ def main() -> int:
file=sys.stderr)
# Still run for interactive use; the gate is advisory.
- from chilmesh import CHILmesh
conn, pts, n_elems_raw, n_verts_raw, grid_name = _load_fort14_arrays(Path(args.mesh))
n_layers_ref = None
diff --git a/scripts/benchmark_all_backends.py b/scripts/benchmark_all_backends.py
index 0f595370..40b849b8 100644
--- a/scripts/benchmark_all_backends.py
+++ b/scripts/benchmark_all_backends.py
@@ -15,7 +15,6 @@
"""
import json
import platform
-import statistics
import sys
import time
from pathlib import Path
diff --git a/scripts/benchmark_quadegg_variants.py b/scripts/benchmark_quadegg_variants.py
index 2d0dfa91..342b5f2b 100755
--- a/scripts/benchmark_quadegg_variants.py
+++ b/scripts/benchmark_quadegg_variants.py
@@ -108,7 +108,7 @@ def query_latency():
median, std, peak_mem = measure_operation('query_latency', query_latency, n_trials=2)
results['query_latency'] = (median, std, peak_mem)
- print(f"✓", file=sys.stderr)
+ print("✓", file=sys.stderr)
return results
@@ -133,7 +133,7 @@ def main():
traceback.print_exc()
continue
- print(f"", file=sys.stderr) # newline
+ print("", file=sys.stderr) # newline
# Write JSON
output_data = {
diff --git a/scripts/benchmark_wnat_hagen.py b/scripts/benchmark_wnat_hagen.py
index aaa61a54..ca31a9f3 100644
--- a/scripts/benchmark_wnat_hagen.py
+++ b/scripts/benchmark_wnat_hagen.py
@@ -15,7 +15,6 @@
import argparse
import json
import platform
-import statistics
import sys
import time
from pathlib import Path
diff --git a/scripts/generate_3row_admesh.py b/scripts/generate_3row_admesh.py
index 2b744fdb..4c6e4153 100644
--- a/scripts/generate_3row_admesh.py
+++ b/scripts/generate_3row_admesh.py
@@ -397,7 +397,7 @@ def count_poor_elements(quality):
if len(set(counts)) == 1:
print(f" ✓ All rows have {counts[0]} elements")
else:
- print(f" ✗ WARNING: Element counts differ!")
+ print(" ✗ WARNING: Element counts differ!")
print(f" Unique values: {set(counts)}")
# ========================================================================
diff --git a/scripts/github_release.py b/scripts/github_release.py
index 68762231..b70e851f 100755
--- a/scripts/github_release.py
+++ b/scripts/github_release.py
@@ -80,7 +80,7 @@ def extract_version(arg_version: Optional[str]) -> Optional[str]:
with open("pyproject.toml") as f:
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', f.read())
return match.group(1) if match else None
- except:
+ except Exception:
return None
@@ -112,7 +112,7 @@ def extract_changelog_section(version: str) -> str:
return lines[1].strip() if len(lines) > 1 else f"Release {version}"
return f"Release {version}"
- except:
+ except Exception:
return f"Release {version}"
diff --git a/scripts/illustrate_truss_convergence.py b/scripts/illustrate_truss_convergence.py
index e600ea95..88ef63c6 100644
--- a/scripts/illustrate_truss_convergence.py
+++ b/scripts/illustrate_truss_convergence.py
@@ -27,7 +27,6 @@
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
-import chilmesh
from chilmesh import examples
from chilmesh._vendor_admesh_truss import distmesh2d_warmstart
diff --git a/scripts/pypi_publish.py b/scripts/pypi_publish.py
index 702ccd1d..1fda88fa 100755
--- a/scripts/pypi_publish.py
+++ b/scripts/pypi_publish.py
@@ -65,7 +65,7 @@ def detect_pypi_credentials() -> Tuple[bool, Optional[str]]:
content = pypirc.read_text()
if "password" in content and "pypi-" in content:
return True, "~/.pypirc"
- except:
+ except Exception:
pass
# Try twine check (validates tokens)
@@ -90,7 +90,7 @@ def extract_pyproject_data(project_root: str) -> Tuple[Optional[str], Optional[s
version = version_match.group(1) if version_match else None
return name, version
- except:
+ except Exception:
return None, None
diff --git a/scripts/scenes/quality_compare_scene.py b/scripts/scenes/quality_compare_scene.py
index 318accf3..f82e10f8 100644
--- a/scripts/scenes/quality_compare_scene.py
+++ b/scripts/scenes/quality_compare_scene.py
@@ -23,8 +23,6 @@
DOWN,
UP,
LEFT,
- RIGHT,
- Polygon,
Rectangle,
Scene,
Text,
@@ -32,8 +30,6 @@
Write,
Create,
FadeIn,
- FadeOut,
- Transform,
Line,
Dot,
config,
diff --git a/scripts/scenes/skeletonization_annulus_scene.py b/scripts/scenes/skeletonization_annulus_scene.py
index c5a7ac06..2d524430 100644
--- a/scripts/scenes/skeletonization_annulus_scene.py
+++ b/scripts/scenes/skeletonization_annulus_scene.py
@@ -17,7 +17,6 @@
from manim import (
DOWN,
UP,
- LEFT,
RIGHT,
Polygon,
Dot,
@@ -28,7 +27,6 @@
Write,
Create,
FadeIn,
- FadeOut,
Transform,
config,
)
diff --git a/scripts/scenes/skeletonization_scene.py b/scripts/scenes/skeletonization_scene.py
index 9b8b7c4e..defe2976 100644
--- a/scripts/scenes/skeletonization_scene.py
+++ b/scripts/scenes/skeletonization_scene.py
@@ -26,7 +26,6 @@
Write,
Create,
FadeIn,
- FadeOut,
Transform,
config,
)
diff --git a/src/chilmesh/CHILmesh.py b/src/chilmesh/CHILmesh.py
index fde83b8b..0763237a 100644
--- a/src/chilmesh/CHILmesh.py
+++ b/src/chilmesh/CHILmesh.py
@@ -6,12 +6,8 @@
from .utils.plot_utils import CHILmeshPlotMixin
import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.cm as cm
from scipy.spatial import Delaunay, cKDTree
from typing import List, Tuple, Optional as Opt, Dict, Set, Union, Any
-from scipy.sparse import lil_matrix
-from scipy.sparse.linalg import spsolve
from copy import deepcopy
__all__ = ['CHILmesh', 'write_fort14']
@@ -294,6 +290,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] = {}
@@ -685,7 +684,6 @@ def _validate_adjacencies(self) -> None:
v2e = self.adjacencies['Vert2Edge']
v2m = self.adjacencies['Vert2Elem']
e2v = self.adjacencies['Edge2Vert']
- e2m = self.adjacencies['Edge2Elem']
# Check: All vertices have entries
assert len(v2e) == self.n_verts, f"Vert2Edge has {len(v2e)} entries, expected {self.n_verts}"
@@ -2692,10 +2690,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
+ _ = int(lines[i].split()[0]); i += 1 # total open-boundary nodes (unused)
+ boundaries_present = True # NOPE/NBOU block physically present (#259)
for _ in range(nope):
n_seg = int(lines[i].split()[0]); i += 1
nodes = []
@@ -2707,7 +2707,7 @@ def read_from_fort14(
)
# NBOU flow boundaries
nbou = int(lines[i].split()[0]); i += 1
- total_nbou = int(lines[i].split()[0]); i += 1
+ _ = int(lines[i].split()[0]); i += 1 # total flow-boundary nodes (unused)
for _ in range(nbou):
hdr = lines[i].split(); i += 1
n_seg = int(hdr[0])
@@ -2728,6 +2728,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/bridge.py b/src/chilmesh/bridge.py
index 7b33066e..4a077731 100644
--- a/src/chilmesh/bridge.py
+++ b/src/chilmesh/bridge.py
@@ -8,7 +8,7 @@
from __future__ import annotations
-from typing import Dict, Set, Tuple, List, Optional
+from typing import Dict, Set, List, Optional
import numpy as np
diff --git a/src/chilmesh/cli.py b/src/chilmesh/cli.py
index cce2adf2..48fc1d70 100644
--- a/src/chilmesh/cli.py
+++ b/src/chilmesh/cli.py
@@ -191,7 +191,7 @@ def cmd_summary(args: argparse.Namespace) -> int:
if file_bytes is not None:
print(f" File bytes: {file_bytes}")
else:
- print(f" File bytes: N/A")
+ print(" File bytes: N/A")
print(f" Nodes: {data.get('n_nodes', 'N/A')}")
print(f" Elements: {data.get('n_elems', 'N/A')}")
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..6f94b1e7 100644
--- a/src/chilmesh/gmsh_io.py
+++ b/src/chilmesh/gmsh_io.py
@@ -5,7 +5,10 @@
from __future__ import annotations
import numpy as np
-from typing import Optional as Opt
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .CHILmesh import CHILmesh
class GmshParseError(Exception):
@@ -33,7 +36,6 @@ def read_msh(full_file_name: str) -> "CHILmesh":
GmshParseError: If format is unsupported, required sections missing,
or file is malformed.
"""
- from .CHILmesh import CHILmesh
with open(full_file_name, 'r', encoding='utf-8') as f:
lines = [line.strip() for line in f]
@@ -70,7 +72,6 @@ def read_msh(full_file_name: str) -> "CHILmesh":
def _read_msh_v2_2(lines: list, filename: str) -> "CHILmesh":
"""Parse Gmsh format 2.2."""
- from .CHILmesh import CHILmesh
nodes = {}
elements = []
@@ -161,7 +162,6 @@ def _read_msh_v2_2(lines: list, filename: str) -> "CHILmesh":
def _read_msh_v4_1(lines: list, filename: str) -> "CHILmesh":
"""Parse Gmsh format 4.1."""
- from .CHILmesh import CHILmesh
nodes = {}
elements = []
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/conftest.py b/tests/conftest.py
index 61b2837a..dc7fc526 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,6 @@
import pytest
-import chilmesh
from chilmesh import examples as _examples
FIXTURE_NAMES = ["annulus", "donut", "block_o", "structured", "quad_2x2"]
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_2dm_roundtrip.py b/tests/test_2dm_roundtrip.py
index bbdedb07..30fc12c6 100644
--- a/tests/test_2dm_roundtrip.py
+++ b/tests/test_2dm_roundtrip.py
@@ -8,10 +8,8 @@
boundary-segment metadata, so the round-trip is lossless for geometry/topology
but does not preserve boundary records.
"""
-from pathlib import Path
import numpy as np
-import pytest
import chilmesh
diff --git a/tests/test_admesh_warmstart.py b/tests/test_admesh_warmstart.py
index a6463942..b0104ca5 100644
--- a/tests/test_admesh_warmstart.py
+++ b/tests/test_admesh_warmstart.py
@@ -20,7 +20,6 @@
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from chilmesh import (
- CHILmesh,
optimize_with_admesh_truss,
optimize_with_admesh_truss_arrays,
examples,
@@ -140,7 +139,7 @@ def test_vbnd_annulus_chilmesh_form(self, annulus_mesh, annulus_sdf):
# Check bit-exact preservation
output_boundary = mesh_opt.points[boundary_indices, :2]
assert np.array_equal(output_boundary, input_boundary), \
- f"Boundary not preserved in CHILmesh form"
+ "Boundary not preserved in CHILmesh form"
def test_vbnd_donut_domain_agnostic(self, donut_mesh, donut_sdf):
"""V_BND on donut: proves domain-agnosticism."""
diff --git a/tests/test_advancing_front.py b/tests/test_advancing_front.py
index 656d714e..8628602b 100644
--- a/tests/test_advancing_front.py
+++ b/tests/test_advancing_front.py
@@ -5,7 +5,6 @@
"""
import pytest
-import numpy as np
from pathlib import Path
from chilmesh import CHILmesh
diff --git a/tests/test_backend_equivalence.py b/tests/test_backend_equivalence.py
index c0143608..ad46267a 100644
--- a/tests/test_backend_equivalence.py
+++ b/tests/test_backend_equivalence.py
@@ -10,7 +10,6 @@
"""
from __future__ import annotations
-import os
from pathlib import Path
import numpy as np
diff --git a/tests/test_backend_info.py b/tests/test_backend_info.py
index 4f639dca..01c86515 100644
--- a/tests/test_backend_info.py
+++ b/tests/test_backend_info.py
@@ -158,7 +158,6 @@ def test_slow_path_warning_when_pure_python(monkeypatch):
"""#202: pure-Python layer peel of a large mesh warns once (non-silent)."""
if CPP_AVAILABLE or RUST_AVAILABLE:
pytest.skip("compiled fast backend present; slow-path warning N/A")
- import chilmesh.CHILmesh # ensure submodule imported
cm = sys.modules["chilmesh.CHILmesh"]
monkeypatch.setattr(cm, "_SLOW_PATH_ELEM_THRESHOLD", 1)
monkeypatch.setattr(cm, "_SLOW_PATH_WARNED", False)
@@ -172,7 +171,6 @@ def test_no_slow_path_warning_below_threshold(monkeypatch, recwarn):
"""Small meshes never trigger the slow-path warning."""
if CPP_AVAILABLE or RUST_AVAILABLE:
pytest.skip("compiled fast backend present; slow-path warning N/A")
- import chilmesh.CHILmesh # ensure submodule imported
cm = sys.modules["chilmesh.CHILmesh"]
monkeypatch.setattr(cm, "_SLOW_PATH_ELEM_THRESHOLD", 10_000)
monkeypatch.setattr(cm, "_SLOW_PATH_WARNED", False)
diff --git a/tests/test_backend_wrappers.py b/tests/test_backend_wrappers.py
index 4a077cb9..f0a3b0b0 100644
--- a/tests/test_backend_wrappers.py
+++ b/tests/test_backend_wrappers.py
@@ -8,7 +8,6 @@
import importlib
import sys
import types
-from typing import Any
import numpy as np
import pytest
diff --git a/tests/test_chilmesh_error_paths.py b/tests/test_chilmesh_error_paths.py
index 21a60122..9990dc53 100644
--- a/tests/test_chilmesh_error_paths.py
+++ b/tests/test_chilmesh_error_paths.py
@@ -12,8 +12,6 @@
import pytest
import numpy as np
-import tempfile
-from pathlib import Path
import chilmesh
from chilmesh import examples
diff --git a/tests/test_chilmesh_uncovered_branches.py b/tests/test_chilmesh_uncovered_branches.py
index 216267f3..c5b55b7b 100644
--- a/tests/test_chilmesh_uncovered_branches.py
+++ b/tests/test_chilmesh_uncovered_branches.py
@@ -10,7 +10,6 @@
import numpy as np
import pytest
-from pathlib import Path
from chilmesh import CHILmesh, examples
from chilmesh.CHILmesh import _check_fort14
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4318c393..b78082fa 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -9,7 +9,6 @@
import subprocess
import sys
-from pathlib import Path
import pytest
diff --git a/tests/test_compute_adjacencies_flag.py b/tests/test_compute_adjacencies_flag.py
index 9a2ade45..fa919078 100644
--- a/tests/test_compute_adjacencies_flag.py
+++ b/tests/test_compute_adjacencies_flag.py
@@ -9,7 +9,6 @@
from __future__ import annotations
import numpy as np
-import pytest
import chilmesh
diff --git a/tests/test_ctor_skip_flags.py b/tests/test_ctor_skip_flags.py
index b13aec3f..d167de46 100644
--- a/tests/test_ctor_skip_flags.py
+++ b/tests/test_ctor_skip_flags.py
@@ -1,6 +1,5 @@
"""Tests for CHILmesh constructor skip flags (#204)."""
import numpy as np
-import pytest
from chilmesh.CHILmesh import CHILmesh
diff --git a/tests/test_edge_map.py b/tests/test_edge_map.py
index 43ba09cf..e1414989 100644
--- a/tests/test_edge_map.py
+++ b/tests/test_edge_map.py
@@ -1,7 +1,5 @@
"""Unit tests for EdgeMap class."""
-import pytest
-import numpy as np
from chilmesh.mesh_topology import EdgeMap
diff --git a/tests/test_fem_smoother_square_preservation.py b/tests/test_fem_smoother_square_preservation.py
index 079941c7..170c9fa1 100644
--- a/tests/test_fem_smoother_square_preservation.py
+++ b/tests/test_fem_smoother_square_preservation.py
@@ -24,7 +24,6 @@
from __future__ import annotations
import numpy as np
-import pytest
from chilmesh import CHILmesh
diff --git a/tests/test_fort13_errors.py b/tests/test_fort13_errors.py
index 703f793c..7c450cd8 100644
--- a/tests/test_fort13_errors.py
+++ b/tests/test_fort13_errors.py
@@ -5,7 +5,6 @@
"""
import pytest
import numpy as np
-from pathlib import Path
from chilmesh import read_fort13, write_fort13, Fort13, NodalAttribute
from chilmesh.fort13_io import Fort13ParseError
diff --git a/tests/test_fort13_roundtrip.py b/tests/test_fort13_roundtrip.py
index 122cbc50..07547246 100644
--- a/tests/test_fort13_roundtrip.py
+++ b/tests/test_fort13_roundtrip.py
@@ -100,8 +100,6 @@ def test_fort13_roundtrip_identity(tmp_path):
def test_public_exports():
"""Smoke test that all public names are exported from chilmesh."""
from chilmesh import (
- Fort13,
- NodalAttribute,
read_fort13 as rf13,
write_fort13 as wf13,
Fort13ParseError,
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_from_admesh_domain.py b/tests/test_from_admesh_domain.py
index 12089cc8..1ee23bb4 100644
--- a/tests/test_from_admesh_domain.py
+++ b/tests/test_from_admesh_domain.py
@@ -1,5 +1,4 @@
"""Tests for from_admesh_domain entry point and metadata (Issues #42–43)."""
-from pathlib import Path
from types import SimpleNamespace
import pytest
diff --git a/tests/test_invariants.py b/tests/test_invariants.py
index bd40ce22..b3b7055d 100644
--- a/tests/test_invariants.py
+++ b/tests/test_invariants.py
@@ -5,7 +5,6 @@
"""
from __future__ import annotations
-from itertools import chain
import numpy as np
import pytest
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_main_module.py b/tests/test_main_module.py
index 0959cac5..47dbec62 100644
--- a/tests/test_main_module.py
+++ b/tests/test_main_module.py
@@ -8,7 +8,6 @@
import subprocess
import sys
-import pytest
class TestMainModuleInvocation:
diff --git a/tests/test_metadata_validation.py b/tests/test_metadata_validation.py
index 09948a87..e86a330b 100644
--- a/tests/test_metadata_validation.py
+++ b/tests/test_metadata_validation.py
@@ -1,6 +1,4 @@
"""Tests for metadata validation and contributor workflows (Issue #44)."""
-import tempfile
-from pathlib import Path
from types import SimpleNamespace
import pytest
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_readme_quickstart.py b/tests/test_readme_quickstart.py
index bd58be56..d7bd0ad6 100644
--- a/tests/test_readme_quickstart.py
+++ b/tests/test_readme_quickstart.py
@@ -23,7 +23,6 @@
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
-import pytest
import warnings
import chilmesh
diff --git a/tests/test_smooth_laplacian.py b/tests/test_smooth_laplacian.py
index c0713d3d..072d1cad 100644
--- a/tests/test_smooth_laplacian.py
+++ b/tests/test_smooth_laplacian.py
@@ -22,7 +22,7 @@
import numpy as np
import pytest
-from chilmesh import CHILmesh, examples
+from chilmesh import examples
class TestSmoothValidation:
diff --git a/tests/test_split_edge.py b/tests/test_split_edge.py
index 4c7b8c19..e1f6f861 100644
--- a/tests/test_split_edge.py
+++ b/tests/test_split_edge.py
@@ -4,7 +4,7 @@
import numpy as np
import pytest
-from chilmesh import CHILmesh, MutableMesh
+from chilmesh import MutableMesh
from chilmesh import examples
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.py b/tests/test_summary.py
index 1c20368c..339daa89 100644
--- a/tests/test_summary.py
+++ b/tests/test_summary.py
@@ -7,7 +7,6 @@
import subprocess
import sys
-from pathlib import Path
import pytest
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)
diff --git a/tests/test_unification_api_contract.py b/tests/test_unification_api_contract.py
index b3ccd57b..828733a0 100644
--- a/tests/test_unification_api_contract.py
+++ b/tests/test_unification_api_contract.py
@@ -10,7 +10,6 @@
from __future__ import annotations
-import dataclasses
import inspect
import tempfile
from pathlib import Path