diff --git a/.gitignore b/.gitignore index d1d32c8..5425a15 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .mypy_cache/ .tools/ +.tmp-blender-user/ *.blend1 *.blend2 *.log @@ -12,6 +13,7 @@ __pycache__/ core_build/ build/ dist/ +/artifacts/ *.pyd *.so *.dylib diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c755684 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +## 0.4.0 hardening + +- Normalize vertex-only bow-ties after QEM by deterministically duplicating + disconnected incident fans without moving geometry or changing face-corner + attributes; true edge-non-manifolds remain rejected. +- Make shrinkwrap projection transactional. Failed or gate-breaking projection + restores the validated candidate and records the rollback reason. +- Add adversarial topology, standalone engine, and Blender projection rollback + regressions grounded in multi-shell photogrammetry parts. + +## 0.4.0 - 2026-07-16 + +- Added Atlas-Q scan analysis and solver portfolio. +- Added target-scale native QEM proxies and deterministic candidate ensembles. +- Added exact conforming PairClosure with UV-seam/material propagation. +- Added area/spatial/component-stratified symmetric RMS, p95, and maximum source + error with preset-specific hard gates. +- Added vertex-fan manifold, true boundary-cycle, component, duplicate-face, + winding, concave/crossed-quad, Jacobian, aspect, and target-count validation. +- Added exact-source versus exact-proxy topology proof status in output and UI. +- Moved area, diagonal, aspect, Jacobian, and fidelity gates into world space so + non-uniform object scale cannot hide stretched quads or large error. +- Added a 60-degree local quad-warpage ceiling and per-component Euler checks. +- Added target-scale preprocessing to direct PairClosure and mode-specific + field/pairing weights; unsafe concave triangle pairs now remain unpaired. +- Added ParityPath sparse quad transitions and deterministic target-aware + selection against the conservative global corner closure. +- Added a topology-gated edge-rotation/QEM unlock for irreducible low-budget + scan proxies; every round is transactional and signature preserving. +- Fixed the old collinear per-triangle pure-quad workaround. +- Fixed duplicate candidate recomputation in TRIAD-Q. +- Fixed boundary edges being reported as non-manifold. +- Fixed orphaned mesh data in Replace mode. +- Preserved material-slot order and isolated baked target materials. +- Made baking transactional: all settings and datablocks roll back on failure. +- Added real isolated install/enable smoke tests for both release zip layouts. +- Added algorithm and photogrammetry workflow documentation, unit coverage, and + release packaging. + +## 0.3.0 + +- Added TRIAD-Q Lite, standalone OBJ I/O, seed-swept pairing, and Blender smoke + coverage. diff --git a/README.md b/README.md index ce47080..83df2ef 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,281 @@ -# CurioMesh Quad Remesher - -CurioMesh is a Blender add-on for practical automatic quad retopology. Version -0.3 includes the production QuadriFlow pipeline from v0.2 and adds TRIAD-Q Lite, -an experimental standalone quad-dominant remeshing engine that can run inside or -outside Blender. - -## What It Does - -- Runs QuadriFlow in face-count mode with deterministic seed control. -- Provides an experimental `TRIAD-Q Lite` engine: feature extraction, field - estimation, seed-swept triangle pairing, material/UV seam awareness, optional - pure-quad repair, and standalone OBJ input/output. -- Keeps an evaluated source snapshot alive through transfer, projection, and - optional baking, so `REPLACE` mode does not project from the newly remeshed - mesh back onto itself. -- Cleans duplicate/degenerate mesh data and recalculates normals before - remeshing. -- Retries through Blender voxel remesh when QuadriFlow rejects messy input. -- Projects the output back to the source with Shrinkwrap. -- Preserves material slots, maps material indices from the nearest source face, - and transfers or projects UVs. -- Reports face count, quad ratio, target error, extraordinary vertex ratio, - non-manifold edges, boundary edges, UV validity, material preservation, - elapsed time, and the engine path used. +# CurioMesh Atlas-Q Quad Retopology + +CurioMesh 0.4 is a Blender 4.3+ add-on for turning dense meshes and +photogrammetry scans into lower-density, validated quad retopology. Atlas-Q is +not presented as a universal miracle solver. It is a scan-aware controller that +builds a target-scale proxy, chooses an appropriate solver path, compares +multiple candidates against the preserved source, and rejects results that do +not pass the requested topology and quad-quality gates. + +## What Changed In 0.4 + +- **Scan Intelligence** classifies organic, hard-surface, open, dirty, and very + large inputs without changing the source object. The report explains whether + topology counts are exact or sampled and warns about destructive repair. +- **Atlas-Q Auto** is the new default. It combines a QEM proxy, multi-seed + QuadriFlow candidates, an optional dirty-scan repair candidate, + area/spatial/component-stratified source-error measurement, and hard + validation. +- **PairClosure** replaces the old per-triangle "pure quad" workaround. It + performs feature-aware triangle pairing, compares a sparse ParityPath + transition closure with the conservative global corner closure, and selects + the valid result closest to the requested budget. Before closure it can + normalize vertex-only bow-ties by + duplicating disconnected incident fans without moving geometry, changing + face order, or changing face-corner UVs. It deliberately refuses to present + this as a repair for edges used by more than two faces. Every accepted face + has exactly four distinct corners. +- **Topology and shape gates** reject edge and vertex non-manifolds, branched + boundaries, duplicate faces, inconsistent winding, concave/crossed quads, + excessive target error, poor Jacobians/aspect, and excessive symmetric RMS, + p95, or maximum sampled source error. +- **Large-scan proxy budgets** keep Python-side work off the full-resolution + scan. Blender's native collapse decimator creates a target-scale QEM proxy + before candidate remeshing. +- `Replace` mode now releases the old orphaned mesh datablock. Material-slot + ordering is preserved, and baking makes target materials single-user before + editing their node trees. + +The immutable evaluated source snapshot remains alive through remeshing, +projection, material mapping, UV transfer, and optional baking. A failed +candidate never replaces the selected source object. Projection is +transactional: if shrinkwrap makes an accepted candidate degenerate, +non-manifold, topologically different, or too inaccurate, CurioMesh restores +the validated pre-projection mesh and records the rollback on the output. + +## Solver Portfolio + +| Strategy | Use it for | Implementation and expectation | +|---|---|---| +| `Auto` | Default for most assets | Measures the scan and routes to the strongest applicable path. | +| `Organic / General` | Statues, rocks, terrain pieces, creatures, clean scans | Target-scale QEM proxy plus a deterministic multi-seed QuadriFlow ensemble. | +| `Mechanical / Feature` | Props, architecture, machinery, crisp archaeology | Preserves sharp, seam, material, and boundary evidence while comparing flow candidates. | +| `Dirty Closed Scan` | Noisy closed photogrammetry with broken connectivity | May voxel-repair one candidate. It can close holes and erase thin sheets, so it requires `Repair` topology policy. | +| `Open Fragment` | Intentional sheets, excavation fragments, partial scans | Preserves boundary loops and avoids automatic voxel closure. | +| `Exact PairClosure` | Robust fallback or simple/open triangulated surfaces | Guarantees conforming quads after validation, but can create more poles and may miss an aggressive face target. | + +Atlas-Q accepts a candidate only after measured checks. `Require Locally Valid +Quads` is enabled by default. A solver process finishing successfully is not +enough. “Locally valid” deliberately does not claim a global face-to-face +self-intersection proof. ## Installation -1. Download or clone this repository. -2. Zip the folder that contains `__init__.py`. -3. In Blender, open `Edit > Preferences > Add-ons > Install`, select the zip, - and enable **CurioMesh Quad Remesher**. +1. Download a release archive or clone this repository. +2. In Blender, use `Edit > Preferences > Add-ons > Install from Disk`. +3. Select the CurioMesh add-on or extension zip and enable **CurioMesh Quad + Remesher**. 4. Select a mesh and open `3D View > N-panel > CurioMesh`. -CurioMesh targets Blender 4.3+ and is validated against Blender 4.5 LTS. +CurioMesh targets Blender 4.3+ and the test runner prefers Blender 4.5 LTS. -## Workflow +## Recommended Photogrammetry Workflow -1. Select a mesh object. -2. Choose a target face count and quality preset. -3. Leave `Output` on `New Object` while evaluating results. -4. Use `Replace` when you want the selected object updated in place. -5. Keep `Project UVs` enabled for textured assets. It first tries Blender data - transfer, then falls back to BVH barycentric UV projection. -6. Use `Bake Color` or `Bake If UVs Fail` for assets where projection cannot - preserve acceptable texture detail. +1. Keep `Output` set to `New Object`. +2. Set the desired **Target Faces**. This is a soft geometric budget: topology, + features, and valid quads take precedence over an impossible exact count. +3. Click **Analyze Without Changing Mesh**. +4. Leave `Scan Strategy` on `Auto` unless the report misclassifies known intent. +5. Use `Preserve` topology for open fragments, intentional holes, thin shells, + mechanical parts, and most clean meshes. +6. Use `Repair` only for a closed scan whose holes and broken connectivity are + known defects. +7. Run Atlas-Q and inspect local quad-manifold status, topology proof, p95/max + source error, minimum scaled Jacobian, p95 aspect ratio, boundary loops, and + silhouette. +8. Keep UV projection or bake base color. Bake normal/displacement detail in a + dedicated external baking step; CurioMesh 0.4 does not expose those maps. + A low-poly mesh cannot encode scan-frequency detail in geometry alone. -## Controls +For multi-million-face scans, Atlas-Q samples the full source, then performs +exact topology accounting on the reduced proxy. The result explicitly says +`PROXY_PRESERVED_SOURCE_UNVERIFIED`; it does not present proxy preservation as +proof about every full-resolution source edge. See +[docs/PHOTOGRAMMETRY_WORKFLOW.md](docs/PHOTOGRAMMETRY_WORKFLOW.md) for recovery +steps and expectations by scan type. -- `Target Faces`: approximate number of output quads. -- `Engine`: `QuadriFlow` for production, `TRIAD-Q Lite` for the experimental - standalone CurioMesh engine, or `Auto` for the production default. -- `Quality`: Draft, Balanced, or Hero defaults for cleanup and preservation. -- `Preserve Sharp`, `Preserve Boundary`, `Treat UV Seams As Sharp`: feature - preservation hints for QuadriFlow and preprocessing. -- `Use Mesh Symmetry`: forwards mesh symmetry settings to QuadriFlow. -- `Cleanup Strength`: duplicate/degenerate cleanup before remeshing. -- `Voxel Repair Fallback`: retries through voxel remesh if QuadriFlow rejects - the source mesh. -- `Project Details`: applies a Shrinkwrap pass onto the preserved source - snapshot. -- `Texture Preservation`: `Project UVs`, `Transfer Only`, `Bake Color`, or - `None`. -- `Apply Source Modifiers`: in `Replace` mode, remove original modifiers after - baking their evaluated result into the remeshed mesh. -- `TRIAD-Q Seeds`, `TRIAD-Q Feature Angle`, `TRIAD-Q Pure Quads`, and - `TRIAD-Q Flow`: experimental controls shown when the TRIAD-Q Lite engine is - selected. - -## TRIAD-Q Lite CLI - -TRIAD-Q Lite can run without Blender on OBJ files: +## Mathematical Design -```powershell -python -m triadq input.obj output.obj --target-faces 4000 --mode balanced --seed-count 8 -``` +Atlas-Q uses an error-budgeted solver selection rather than claiming one +algorithm wins on every mesh. -It emits a JSON report with mode classification, selected seed, quad ratio, -target-face error, extraordinary vertex ratio, feature-edge count, aspect -penalty, and score. The current implementation is a practical prototype: it is -best at converting triangulated surfaces into feature-aware quad-dominant -meshes, not at replacing QuadriFlow as a full simplification/remeshing solver. +The initial target edge scale is -## Development And Tests +```text +h0 = sqrt(A / Nq) +``` -Python syntax check: +where `A` is sampled surface area and `Nq` is the desired quad count. The +Balanced QEM proxy budget is approximately -```powershell -python -m compileall -q __init__.py bridge.py metrics.py operators.py textures.py ui.py triadq tests +```text +Fproxy = min(Fsource, clamp(12 Nq, 2,000, 400,000)). ``` -Ruff check: +Each valid candidate is scored from measured symmetric source distance, scaled +Jacobian, aspect ratio, target-count error, pole ratio, and quad ratio. Topology +violations are rejection conditions rather than small score penalties. -```powershell -ruff check . -``` +PairClosure starts with feature-aware triangle pairing. It then processes every +residual triangle with shared edge midpoints. ParityPath keeps unaffected valid +quads unchanged and solves odd quad transitions as a T-join on the quad-only +face-dual graph. Affected faces use locally validated two-, three-, or +four-quad templates with shared geometric midpoints and face-local UVs. -Standalone TRIAD-Q Lite smoke tests: +The conservative target closure processes every base face +`f = (v0 ... vk-1)` using a globally shared edge midpoint `mi` and a face +center `cf`: -```powershell -python tests\triadq_smoke.py +```text +qi = (vi, mi, cf, m(i-1)). ``` -Headless Blender smoke tests: +This produces `Fglobal = 3T + 4Q`. ParityPath instead has +`Fparity = 3T + Q0 + 2Q2d + 3Q2c + 4Q4`, where the `Q` terms classify +unaffected, direct two-split, center-filled two-split, and four-split quads. +Both paths are conforming, preserve Euler characteristic when the base is +manifold, guarantee four-corner faces, and preserve face-local UV seams. -```powershell -tests\run_blender_tests.ps1 +If Blender's collapse decimator stalls above a closure budget, PairClosure can +rotate a deterministic face-independent set of interior triangle diagonals and +retry QEM. Every round is transactional: it is retained only when manifold, +degeneracy, component, Euler, and per-component boundary-signature gates remain +exact. The final quad count and source-error gates still decide acceptance. + +For a vertex-only bow-tie, incident faces around vertex `v` are partitioned by +edge adjacency into fans `F0 ... Fk`. `F0` keeps `v`; each later fan receives a +coincident duplicate `v_i`. This is an exact combinatorial normalization: +coordinates, faces, materials, and corner UV indices are unchanged. True +edge-non-manifolds remain rejected. + +For a closed all-quad manifold, poles are mathematically necessary: + +```text +sum_v (4 - valence(v)) = 4 chi. ``` -If Blender is not installed, the runner can download the official Blender 4.5.1 -LTS portable build into `.tools/`: +A sphere therefore has total defect charge eight; "no extraordinary vertices" +is not a valid universal requirement. CurioMesh measures and minimizes excess +poles instead. + +The detailed implemented objective, gates, and roadmap are in +[docs/ALGORITHM.md](docs/ALGORITHM.md). + +## Controls + +- `Target Faces`: approximate final quad budget. +- `Engine`: Atlas-Q Auto, direct QuadriFlow, PairClosure, or the v0.3 Auto alias. +- `Quality`: Draft uses one candidate; Balanced uses the configured budget; + Hero uses at least five candidates and stricter acceptance gates. +- `Scan Strategy`: solver-family override. +- `Topology`: `Preserve` rejects changed Euler characteristic, boundary-cycle + count, connected-component count, or open/closed state against an exact + source or proxy reference. It also compares a sorted `(Euler, boundary + cycles)` signature per component, so cycles cannot silently move between + equal-Euler components; + `Repair` permits change. Proxy-only proof is prominently labeled. +- `Require Locally Valid Quads`: rejects non-quads, repeated/zero-area faces, + concave/crossed/inverted quads, edge/vertex non-manifolds, branched + boundaries, duplicate faces, inconsistent winding, and quads warped more + than 60 degrees across either diagonal. Gates are measured in world space, + including non-uniform object scale. Global self-intersection remains + unverified. +- `Candidate Budget`: one to eight deterministic candidates. +- `Preserve Sharp`, `Preserve Boundary`, `Treat UV Seams As Sharp`: feature + constraints passed into proxy/remeshing stages. +- `Voxel Repair Fallback`: available for direct QuadriFlow and controlled by + Atlas-Q's dirty-scan strategy. +- `Project Details`: projects the accepted topology back to the preserved + evaluated source. +- `Texture Preservation`: UV projection/transfer, base-color bake, or none. +- `Output`: create a new object or replace the selected object's mesh after all + mandatory checks pass. + +## Standalone PairClosure CLI + +The dependency-light TRIAD-Q/PairClosure path can still process OBJ files +outside Blender: ```powershell -tests\run_blender_tests.ps1 -InstallBlender +python -m triadq input.obj output.obj --target-faces 4000 --mode auto --seed-count 8 --pure-quads ``` -The smoke test loads the add-on directly from this checkout, registers it, -and runs remesh cases for a UV/material sphere, torus, open grid, and -bad-normal repair mesh, plus TRIAD-Q Lite cube and grid cases. +The Blender PairClosure engine now builds a target-scale QEM proxy before the +Python pairing stage. The standalone CLI performs mode-tuned field pairing and +exact quad closure but does **not** have Blender's QEM or QuadriFlow backend, so +`target-faces` remains a scoring target outside Blender. + +## What Is Implemented Versus Research + +Implemented now: + +- Bounded scan classification and explicit uncertainty. +- Target-scale native QEM proxy construction. +- Multi-candidate QuadriFlow, feature-flow, open-flow, and dirty-flow routing. +- Exact conforming PairClosure. +- Sparse ParityPath transitions plus topology-gated edge-rotation/QEM unlock. +- Area-, spatial-, and component-stratified symmetric distance scoring with + RMS, p95, and maximum-error gates. +- Immutable-source projection, UV/material transfer, and baking. + +Not claimed or implemented: + +- A proof that Atlas-Q is globally novel or universally better. +- Full mixed-integer global patch-layout optimization comparable to MIQ, + Integer-Grid Maps, or QuadWild. +- Semantic animation loops around eyes, mouths, joints, handles, or cloth. +- Guaranteed exact face count, minimum pole count, or excellent quad shape for + every topology and budget. +- A global triangle/quad self-intersection proof or semantic deformation loops. +- Out-of-core cancellation during Blender's synchronous QuadriFlow operation. -## Notes +The new contribution is a clean-room CurioMesh synthesis: confidence-aware scan +routing, error-budgeted proxy construction, measured candidate selection, and +an exact closure/validation contract. Benchmark superiority remains unproven +until real scan comparisons are published. + +## Research Basis + +CurioMesh's implementation and roadmap are grounded in primary sources: + +- [Instant Field-Aligned Meshes](https://igl.ethz.ch/projects/instant-meshes/) + for scalable local orientation/position fields. +- [QuadriFlow](https://onlinelibrary.wiley.com/doi/10.1111/cgf.13498) for + scalable global consistency and lower singularity counts. +- [Mixed-Integer Quadrangulation](https://www.graphics.rwth-aachen.de/publication/0344/) + and [Integer-Grid Maps](https://www.graphics.rwth-aachen.de/publication/03197/) + for seamless parameterization and reliable quad extraction. +- [Reliable Feature-Line Driven Quad Remeshing](https://www.quadmesh.cloud/ReliableQuad.pdf) + for feature-aware patch layouts and global quantization. +- [Pattern-Based Quadrangulation](https://igl.ethz.ch/projects/patch-quad/) for + valid patch filling. +- [Garland-Heckbert QEM](https://www.cs.princeton.edu/courses/archive/fall03/cs526/papers/garland97.pdf) + for target-scale proxy simplification. +- [Quasi-structured quad meshing with midpoint subdivision](https://arxiv.org/abs/2103.04652) + for the robust all-quad closure pattern. +- [Blossom-Quad](https://orbi.uliege.be/handle/2268/113152) for graph matching + formulations of indirect quadrangulation. +- [Practical Quad Mesh Simplification](https://vcg-legacy.isti.cnr.it/Publications/2010/TPCPP10/Tarini%20Pietroni%20Cignoni%20Panozzo%20Puppo%20-%20Practical%20Quad%20Semplification%20-%20EG%202010.pdf) + and [Quadrilateral Mesh Simplification](https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Daniels08.pdf) + for topology-preserving local connectivity operations and quad LOD design. + +CurioMesh is MIT licensed. No GPL QuadWild/BiMDF source is copied or vendored. + +## Development, Tests, And Packaging + +```powershell +python -m unittest discover -s tests -v +python tests\triadq_smoke.py +python -m compileall -q __init__.py atlasq.py bridge.py metrics.py operators.py textures.py ui.py triadq tests scripts +ruff check . +tests\run_blender_tests.ps1 +python scripts\build_release.py +``` -- QuadriFlow is high quality but not magic. It is useful for subdivision-ready - meshes and sculpt-friendly retopology, but it is not a replacement for manual - animation topology on characters. -- TRIAD-Q Lite is intentionally marked experimental. It is the first standalone - CurioMesh engine path and is useful for iteration, tests, and triangulated - mesh conversion, while QuadriFlow remains the default production engine. -- Voxel repair can save messy input but may erase thin or open details. Disable - it when boundary fidelity matters more than robustness. -- The old broken `core/` C++ prototype was removed from the shipped add-on. - CurioMesh now advertises only implemented behavior. +The release builder creates a legacy add-on zip and a Blender extension-layout +zip in `dist/`, then validates every archive entry. Generated caches and build +artifacts are excluded from Git. ## License -MIT. See `LICENSE`. +MIT. See [LICENSE](LICENSE). diff --git a/__init__.py b/__init__.py index 315f054..d3cc535 100644 --- a/__init__.py +++ b/__init__.py @@ -3,11 +3,11 @@ bl_info = { "name": "CurioMesh Quad Remesher", "author": "CurioCrafter", - "version": (0, 3, 0), + "version": (0, 4, 0), "blender": (4, 3, 0), "location": "3D View > N-panel > CurioMesh", "category": "Mesh", - "description": "QuadriFlow remeshing plus experimental TRIAD-Q Lite quad-dominant retopology", + "description": "Atlas-Q scan-aware quad retopology with explicit validation", "doc_url": "https://github.com/CurioCrafter/CurioMesh", } @@ -15,6 +15,7 @@ from .operators import ( CURIOMESH_OT_apply_preset, + CURIOMESH_OT_analyze, CURIOMESH_OT_compute_metrics, CURIOMESH_OT_remesh, ) @@ -23,6 +24,7 @@ classes = ( CURIOMESH_PG_settings, + CURIOMESH_OT_analyze, CURIOMESH_OT_remesh, CURIOMESH_OT_apply_preset, CURIOMESH_OT_compute_metrics, diff --git a/atlasq.py b/atlasq.py new file mode 100644 index 0000000..a75a597 --- /dev/null +++ b/atlasq.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +from collections import Counter, defaultdict +from dataclasses import asdict, dataclass +import math +from typing import Any + +import bpy +from mathutils import Vector + +from .metrics import MeshDiagnostics, mesh_diagnostics +from .triadq import component_topology_preserved + + +@dataclass(slots=True) +class ScanProfile: + category: str + recommended_strategy: str + face_count: int + vertex_count: int + sampled_faces: int + exact_topology: bool + topology_basis: str + boundary_edges: int + boundary_loops: int + connected_components: int + component_euler: tuple[int, ...] + component_signature: tuple[tuple[int, int], ...] + non_manifold_edges: int + non_manifold_vertices: int + branched_boundary_vertices: int + inconsistent_edges: int + duplicate_faces: int + euler_characteristic: int | None + degenerate_ratio: float + sharp_ratio: float + triangle_ratio: float + target_edge_length: float + diagonal: float + texture_critical: bool + warnings: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @property + def summary(self) -> str: + qualifier = { + "SOURCE_EXACT": "exact source", + "PROXY_EXACT": "exact proxy (source sampled)", + }.get(self.topology_basis, "sampled") + return ( + f"{self.category}: {self.face_count:,} faces; {qualifier} analysis; " + f"recommended {self.recommended_strategy}." + ) + + +@dataclass(frozen=True, slots=True) +class CandidateSpec: + strategy: str + label: str + seed: int + repair: bool = False + preserve_sharp: bool = True + preserve_boundary: bool = True + pair_closure: bool = False + + +@dataclass(slots=True) +class CandidateEvaluation: + strategy: str + label: str + valid: bool + score: float + reason: str + faces: int + quad_ratio: float + target_error: float + geometry_rms: float + geometry_p95: float + geometry_max: float + min_scaled_jacobian: float + p95_aspect_ratio: float + extraordinary_ratio: float + elapsed_ms: float + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + for key, value in payload.items(): + if isinstance(value, float) and not math.isfinite(value): + payload[key] = None + return payload + + +@dataclass(frozen=True, slots=True) +class QualityGates: + max_target_error: float + max_rms_edge_units: float + max_p95_edge_units: float + max_error_edge_units: float + min_p05_scaled_jacobian: float + max_p95_aspect_ratio: float + + +def quality_gates(quality: str) -> QualityGates: + return { + "DRAFT": QualityGates(0.75, 0.60, 1.50, 4.00, 0.01, 12.0), + "HERO": QualityGates(0.25, 0.25, 0.75, 2.00, 0.05, 6.0), + }.get(str(quality).upper(), QualityGates(0.35, 0.40, 1.00, 3.00, 0.03, 8.0)) + + +def candidate_gate_failures( + diagnostics: MeshDiagnostics, + *, + target_faces: int, + geometry_rms: float, + geometry_p95: float, + geometry_max: float, + target_edge_normalized: float, + require_quads: bool, + topology_policy: str, + profile: ScanProfile | None, + quality: str, +) -> list[str]: + failures: list[str] = [] + if diagnostics.faces <= 0: + failures.append("empty output") + if require_quads and not diagnostics.quad_only: + failures.append("local quad/manifold gate failed") + for count, label in ( + (diagnostics.degenerate_faces, "degenerate faces"), + (diagnostics.invalid_quads, "invalid quads"), + (diagnostics.non_manifold_edges, "non-manifold edges"), + (diagnostics.non_manifold_vertices, "non-manifold vertices"), + (diagnostics.branched_boundary_vertices, "branched boundary vertices"), + (diagnostics.inconsistent_edges, "inconsistently wound edges"), + (diagnostics.duplicate_faces, "duplicate faces"), + ): + if count: + failures.append(f"{count} {label}") + if diagnostics.quads and diagnostics.min_scaled_jacobian <= 0.0: + failures.append("inverted, crossed, or concave quad") + if not (math.isfinite(geometry_rms) and math.isfinite(geometry_p95) and math.isfinite(geometry_max)): + failures.append("surface-error measurement failed") + + gates = quality_gates(quality) + target_error = abs(diagnostics.faces - target_faces) / max(1, target_faces) + edge_scale = max(target_edge_normalized, 1e-9) + rms_edge_units = geometry_rms / edge_scale + p95_edge_units = geometry_p95 / edge_scale + max_edge_units = geometry_max / edge_scale + if target_error > gates.max_target_error: + failures.append( + f"target error {target_error:.1%} exceeds {gates.max_target_error:.0%} {quality.lower()} gate" + ) + if math.isfinite(rms_edge_units) and rms_edge_units > gates.max_rms_edge_units: + failures.append( + f"RMS error {rms_edge_units:.2f} target edges exceeds {gates.max_rms_edge_units:.2f} gate" + ) + if math.isfinite(p95_edge_units) and p95_edge_units > gates.max_p95_edge_units: + failures.append( + f"p95 error {p95_edge_units:.2f} target edges exceeds {gates.max_p95_edge_units:.2f} gate" + ) + if math.isfinite(max_edge_units) and max_edge_units > gates.max_error_edge_units: + failures.append( + f"max sampled error {max_edge_units:.2f} target edges exceeds " + f"{gates.max_error_edge_units:.2f} gate" + ) + if diagnostics.quads and diagnostics.p05_scaled_jacobian < gates.min_p05_scaled_jacobian: + failures.append( + f"p05 Jacobian {diagnostics.p05_scaled_jacobian:.3f} is below " + f"{gates.min_p05_scaled_jacobian:.3f} gate" + ) + if diagnostics.quads and diagnostics.p95_aspect_ratio > gates.max_p95_aspect_ratio: + failures.append( + f"p95 aspect {diagnostics.p95_aspect_ratio:.2f} exceeds " + f"{gates.max_p95_aspect_ratio:.2f} gate" + ) + + if topology_policy == "PRESERVE" and profile is not None and profile.topology_basis != "SAMPLED": + if ( + profile.non_manifold_edges > 0 + or profile.non_manifold_vertices > 0 + or profile.branched_boundary_vertices > 0 + or profile.duplicate_faces > 0 + ): + failures.append("source topology is not a certifiable manifold; choose Repair topology") + if profile.euler_characteristic is not None and diagnostics.euler_characteristic != profile.euler_characteristic: + failures.append("Euler characteristic changed") + if diagnostics.boundary_loops != profile.boundary_loops: + failures.append("boundary-loop count changed") + if diagnostics.connected_components != profile.connected_components: + failures.append("connected-component count changed") + if diagnostics.component_euler != profile.component_euler: + failures.append("per-component Euler characteristics changed") + if not component_topology_preserved(profile.component_signature, diagnostics.component_signature): + failures.append("per-component Euler/boundary signatures changed") + if (diagnostics.boundary_edges == 0) != (profile.boundary_edges == 0): + failures.append("open/closed topology changed") + return failures + + +def _percentile(values: list[float], fraction: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + position = max(0.0, min(1.0, fraction)) * (len(ordered) - 1) + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return ordered[lower] + weight = position - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def _boundary_components(boundary_edges: list[tuple[int, int]]) -> int: + adjacency: dict[int, set[int]] = defaultdict(set) + for a, b in boundary_edges: + adjacency[a].add(b) + adjacency[b].add(a) + remaining = set(adjacency) + components = 0 + while remaining: + components += 1 + stack = [remaining.pop()] + while stack: + current = stack.pop() + linked = adjacency[current] & remaining + remaining.difference_update(linked) + stack.extend(linked) + return components + + +def analyze_object( + obj: bpy.types.Object, + target_faces: int, + *, + feature_angle_deg: float = 35.0, + max_exact_faces: int = 500_000, + max_sample_faces: int = 120_000, +) -> ScanProfile: + mesh = obj.data + face_count = len(mesh.polygons) + vertex_count = len(mesh.vertices) + exact = face_count <= max(1, int(max_exact_faces)) + stride = 1 if exact else max(1, math.ceil(face_count / max(1, int(max_sample_faces)))) + sample_indices = range(0, face_count, stride) + + bounds = [obj.matrix_world @ Vector(corner) for corner in (getattr(obj, "bound_box", ()) or ())] + if bounds: + xs = [corner[0] for corner in bounds] + ys = [corner[1] for corner in bounds] + zs = [corner[2] for corner in bounds] + diagonal = math.sqrt( + (max(xs) - min(xs)) ** 2 + (max(ys) - min(ys)) ** 2 + (max(zs) - min(zs)) ** 2 + ) + else: + diagonal = 0.0 + area_epsilon = max(diagonal * diagonal * 1e-14, 1e-30) + + edge_use: Counter[tuple[int, int]] = Counter() + first_normal: dict[tuple[int, int], tuple[float, float, float]] = {} + sharp_edges: set[tuple[int, int]] = set() + sampled = 0 + triangles = 0 + degenerates = 0 + sampled_area = 0.0 + threshold = math.cos(math.radians(max(0.0, min(180.0, feature_angle_deg)))) + normal_matrix = obj.matrix_world.to_3x3().inverted_safe().transposed() + + for index in sample_indices: + poly = mesh.polygons[index] + sampled += 1 + triangles += int(len(poly.vertices) == 3) + coordinates = [obj.matrix_world @ mesh.vertices[vertex].co for vertex in poly.vertices] + origin = coordinates[0] if coordinates else None + area = ( + 0.5 + * sum( + (coordinates[corner] - origin).cross(coordinates[corner + 1] - origin).length + for corner in range(1, len(coordinates) - 1) + ) + if origin is not None + else 0.0 + ) + sampled_area += max(0.0, area) + degenerates += int(not math.isfinite(area) or area <= area_epsilon) + if not exact: + continue + world_normal = normal_matrix @ poly.normal + world_normal.normalize() + normal = (float(world_normal.x), float(world_normal.y), float(world_normal.z)) + for raw_edge in poly.edge_keys: + edge = tuple(sorted(raw_edge)) + edge_use[edge] += 1 + previous = first_normal.get(edge) + if previous is None: + first_normal[edge] = normal + elif edge_use[edge] == 2: + dot = previous[0] * normal[0] + previous[1] * normal[1] + previous[2] * normal[2] + if dot < threshold: + sharp_edges.add(edge) + + scale = face_count / max(1, sampled) + estimated_area = sampled_area * scale + target_edge = math.sqrt(max(estimated_area, 1e-20) / max(1, int(target_faces))) + + exact_diagnostics = mesh_diagnostics(obj, target_faces) if exact else None + boundary_count = exact_diagnostics.boundary_edges if exact_diagnostics is not None else -1 + boundary_loops = exact_diagnostics.boundary_loops if exact_diagnostics is not None else -1 + connected_components = exact_diagnostics.connected_components if exact_diagnostics is not None else -1 + component_euler = exact_diagnostics.component_euler if exact_diagnostics is not None else () + component_signature = exact_diagnostics.component_signature if exact_diagnostics is not None else () + non_manifold = exact_diagnostics.non_manifold_edges if exact_diagnostics is not None else -1 + non_manifold_vertices = exact_diagnostics.non_manifold_vertices if exact_diagnostics is not None else -1 + branched_boundary_vertices = ( + exact_diagnostics.branched_boundary_vertices if exact_diagnostics is not None else -1 + ) + inconsistent_edges = exact_diagnostics.inconsistent_edges if exact_diagnostics is not None else -1 + duplicate_faces = exact_diagnostics.duplicate_faces if exact_diagnostics is not None else -1 + euler = exact_diagnostics.euler_characteristic if exact_diagnostics is not None else None + sharp_ratio = len(sharp_edges) / max(1, len(edge_use)) if exact else 0.0 + degenerate_ratio = degenerates / max(1, sampled) + triangle_ratio = triangles / max(1, sampled) + texture_critical = bool(mesh.uv_layers) or len(mesh.materials) > 1 + + if exact and ( + non_manifold / max(1, len(edge_use)) > 0.001 + or non_manifold_vertices > 0 + or branched_boundary_vertices > 0 + or inconsistent_edges > 0 + or duplicate_faces > 0 + or degenerate_ratio > 0.005 + ): + category = "DIRTY_SCAN" + strategy = "DIRTY_FLOW" + elif exact and boundary_count > 0: + category = "OPEN_SURFACE" + strategy = "OPEN_FLOW" + elif sharp_ratio > 0.05: + category = "HARD_SURFACE" + strategy = "FEATURE_FLOW" + elif not exact: + category = "LARGE_SCAN" + strategy = "FLOW_ENSEMBLE" + else: + category = "ORGANIC" + strategy = "FLOW_ENSEMBLE" + + warnings: list[str] = [] + if not exact: + warnings.append("Topology counts are sampled because the source exceeds the exact-analysis limit.") + if category == "DIRTY_SCAN": + warnings.append("Repair may close intentional holes and erase sheets thinner than the voxel size.") + if category == "OPEN_SURFACE": + warnings.append("Boundary preservation is enabled; voxel repair is unsafe for an intentional open fragment.") + if texture_critical: + warnings.append("Low-poly geometry cannot retain scan-frequency detail without UV transfer or baking.") + if face_count / max(1, target_faces) > 250: + warnings.append("The requested reduction is extreme; inspect silhouette and p95 projection error.") + scales = [abs(float(value)) for value in obj.scale if abs(float(value)) > 1e-12] + if scales and max(scales) / min(scales) > 1.01: + warnings.append( + "Metrics are evaluated in world space, but applying non-uniform scale can improve solver flow." + ) + + return ScanProfile( + category=category, + recommended_strategy=strategy, + face_count=face_count, + vertex_count=vertex_count, + sampled_faces=sampled, + exact_topology=exact, + topology_basis="SOURCE_EXACT" if exact else "SAMPLED", + boundary_edges=boundary_count, + boundary_loops=boundary_loops, + connected_components=connected_components, + component_euler=component_euler, + component_signature=component_signature, + non_manifold_edges=non_manifold, + non_manifold_vertices=non_manifold_vertices, + branched_boundary_vertices=branched_boundary_vertices, + inconsistent_edges=inconsistent_edges, + duplicate_faces=duplicate_faces, + euler_characteristic=euler, + degenerate_ratio=degenerate_ratio, + sharp_ratio=sharp_ratio, + triangle_ratio=triangle_ratio, + target_edge_length=target_edge, + diagonal=max(diagonal, 1e-12), + texture_critical=texture_critical, + warnings=tuple(warnings), + ) + + +def build_candidate_specs( + profile: ScanProfile, + *, + strategy: str, + quality: str, + seed: int, + candidate_count: int, + topology_policy: str, + voxel_repair: bool = True, +) -> list[CandidateSpec]: + requested = (strategy or "AUTO").upper() + selected = profile.recommended_strategy if requested == "AUTO" else requested + count = max(1, min(int(candidate_count), 8)) + if quality == "DRAFT": + count = 1 + elif quality == "HERO": + count = max(count, 5) + + if selected == "PAIR_CLOSURE": + return [CandidateSpec("PAIR_CLOSURE", "PairClosure exact all-quad fallback", seed, pair_closure=True)] + + repair_allowed = voxel_repair and topology_policy == "REPAIR" and selected == "DIRTY_FLOW" + preserve_sharp = selected in {"FEATURE_FLOW", "OPEN_FLOW"} or profile.category == "HARD_SURFACE" + preserve_boundary = selected != "DIRTY_FLOW" or topology_policy == "PRESERVE" + specs = [] + for index in range(count): + repair = repair_allowed and index == 0 + label = "RepairFlow" if repair else ("FeatureFlow" if preserve_sharp else "FlowEnsemble") + specs.append( + CandidateSpec( + strategy="DIRTY_FLOW" if repair else ("FEATURE_FLOW" if preserve_sharp else "FLOW_ENSEMBLE"), + label=f"{label} candidate {index + 1}", + seed=max(0, int(seed)) + index * 7919, + repair=repair, + preserve_sharp=preserve_sharp, + preserve_boundary=preserve_boundary, + ) + ) + return specs + + +def evaluate_candidate( + spec: CandidateSpec, + diagnostics: MeshDiagnostics, + *, + target_faces: int, + geometry_rms: float, + geometry_p95: float, + geometry_max: float, + target_edge_normalized: float, + require_quads: bool, + topology_policy: str, + profile: ScanProfile, + quality: str, + elapsed_ms: float, +) -> CandidateEvaluation: + failures = candidate_gate_failures( + diagnostics, + target_faces=target_faces, + geometry_rms=geometry_rms, + geometry_p95=geometry_p95, + geometry_max=geometry_max, + target_edge_normalized=target_edge_normalized, + require_quads=require_quads, + topology_policy=topology_policy, + profile=profile, + quality=quality, + ) + + edge_units = geometry_p95 / max(target_edge_normalized, 1e-9) + geometry_quality = math.exp(-edge_units / 0.35) if math.isfinite(edge_units) else 0.0 + jacobian_quality = max(0.0, min(1.0, diagnostics.p05_scaled_jacobian)) + aspect_quality = math.exp(-max(0.0, diagnostics.p95_aspect_ratio - 1.0) / 3.0) + count_quality = math.exp(-diagnostics.face_count_error) + pole_quality = math.exp(-diagnostics.extraordinary_ratio * 4.0) + score = ( + 0.35 * geometry_quality + + 0.25 * jacobian_quality + + 0.15 * aspect_quality + + 0.10 * count_quality + + 0.10 * pole_quality + + 0.05 * diagnostics.quad_ratio + ) + return CandidateEvaluation( + strategy=spec.strategy, + label=spec.label, + valid=not failures, + score=score if not failures else -1.0, + reason=( + "accepted against exact reduced proxy; full source topology remains unverified" + if not failures and topology_policy == "PRESERVE" and profile.topology_basis == "PROXY_EXACT" + else ("accepted" if not failures else "; ".join(failures)) + ), + faces=diagnostics.faces, + quad_ratio=diagnostics.quad_ratio, + target_error=abs(diagnostics.faces - target_faces) / max(1, target_faces), + geometry_rms=geometry_rms, + geometry_p95=geometry_p95, + geometry_max=geometry_max, + min_scaled_jacobian=diagnostics.min_scaled_jacobian, + p95_aspect_ratio=diagnostics.p95_aspect_ratio, + extraordinary_ratio=diagnostics.extraordinary_ratio, + elapsed_ms=elapsed_ms, + ) diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..e693f50 --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,13 @@ +schema_version = "1.0.0" + +id = "curiomesh" +version = "0.4.0" +name = "CurioMesh Atlas-Q Quad Retopology" +tagline = "Scan-aware quad retopology with explicit validation" +maintainer = "CurioCrafter" +type = "add-on" + +website = "https://github.com/CurioCrafter/CurioMesh" +tags = ["Mesh"] +blender_version_min = "4.3.0" +license = ["SPDX:MIT"] diff --git a/bridge.py b/bridge.py index f536c3d..d0ae12b 100644 --- a/bridge.py +++ b/bridge.py @@ -1,7 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace +import json import math +import random import time import traceback from typing import Any @@ -10,10 +12,29 @@ import bpy from mathutils import Vector from mathutils.bvhtree import BVHTree - +import numpy as np + +from .atlasq import ( + CandidateEvaluation, + CandidateSpec, + ScanProfile, + analyze_object, + build_candidate_specs, + candidate_gate_failures, + evaluate_candidate, +) from .metrics import MeshDiagnostics, mesh_diagnostics, uv_mapping_is_valid from .textures import bake_selected_to_active, ensure_target_uvs -from .triadq import MeshData, RemeshOptions as TriadRemeshOptions, remesh_mesh as triad_remesh_mesh +from .triadq import ( + MeshData, + RemeshOptions as TriadRemeshOptions, + remesh_mesh as triad_remesh_mesh, + split_non_manifold_vertex_fans, +) + + +MAX_FIDELITY_REFERENCE_TRIANGLES = 450_000 +MAX_EVALUATED_SNAPSHOT_ESTIMATED_BYTES = 6 * 1024**3 @dataclass(slots=True) @@ -25,7 +46,14 @@ class RemeshConfig: triad_seed_count: int = 6 triad_feature_angle: float = 35.0 triad_force_quads: bool = False + pair_closure_base_faces: int = 0 + pair_closure_output_cap: int = 0 triad_flow_mode: str = "AUTO" + atlas_strategy: str = "AUTO" + atlas_candidate_count: int = 3 + topology_policy: str = "PRESERVE" + require_quads: bool = True + analysis_sample_faces: int = 120000 preserve_sharp: bool = True preserve_boundary: bool = True preserve_seams: bool = True @@ -55,6 +83,43 @@ class RemeshResult: message: str = "" materials_preserved: bool = False uv_status: str = "NONE" + strategy: str = "" + profile: str = "" + candidate_count: int = 0 + geometry_rms: float = 0.0 + geometry_p95: float = 0.0 + geometry_max: float = 0.0 + sampling_status: str = "NOT_MEASURED" + fidelity_status: str = "NOT_MEASURED" + quad_only_verified: bool = False + topology_status: str = "NOT_CHECKED" + candidates: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(slots=True) +class _SurfaceReference: + bvh: BVHTree + sample_points: list[Vector] + diagonal: float + component_sampling: str = "NOT_REQUESTED" + fidelity_status: str = "SOURCE_FULL" + + +@dataclass(slots=True) +class _BoundedSourceEvidence: + sample_points: list[Vector] + component_sampling: str + diagonal: float + source_triangle_floor: int + + +@dataclass(frozen=True, slots=True) +class _SurfaceError: + rms: float + p95: float + maximum: float + component_sampling: str + fidelity_status: str class _ContextSnapshot: @@ -136,11 +201,9 @@ def _remove_object(obj: bpy.types.Object | None, *, remove_mesh: bool = True) -> def _object_diagonal(obj: bpy.types.Object) -> float: - if not getattr(obj.data, "vertices", None): + if not getattr(obj.data, "vertices", None) or not obj.data.vertices: return 1.0 - if not obj.data.vertices: - return 1.0 - points = [obj.matrix_world @ vert.co for vert in obj.data.vertices] + points = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] minv = Vector((min(p.x for p in points), min(p.y for p in points), min(p.z for p in points))) maxv = Vector((max(p.x for p in points), max(p.y for p in points), max(p.z for p in points))) return max((maxv - minv).length, 1e-6) @@ -148,25 +211,39 @@ def _object_diagonal(obj: bpy.types.Object) -> float: def _copy_material_slots(source: bpy.types.Object, target: bpy.types.Object) -> int: target.data.materials.clear() - materials: list[bpy.types.Material] = [] - - for slot in getattr(source, "material_slots", []): - if slot.material and slot.material not in materials: - materials.append(slot.material) + copied = 0 for material in getattr(source.data, "materials", []): - if material and material not in materials: - materials.append(material) + try: + target.data.materials.append(material) + copied += 1 + except Exception: + pass + return copied - for material in materials: - target.data.materials.append(material) - if materials: - target.active_material = materials[0] - return len(materials) + +def _estimated_mesh_copy_bytes(mesh: bpy.types.Mesh) -> int: + """Conservative custom-data-aware estimate for one evaluated mesh copy.""" + + return ( + len(mesh.vertices) * 96 + + len(mesh.edges) * 48 + + len(mesh.loops) * 32 + + len(mesh.polygons) * 96 + ) def _make_evaluated_snapshot(source: bpy.types.Object, config: RemeshConfig) -> bpy.types.Object: depsgraph = bpy.context.evaluated_depsgraph_get() evaluated = source.evaluated_get(depsgraph) + estimated_bytes = _estimated_mesh_copy_bytes(evaluated.data) + if estimated_bytes > MAX_EVALUATED_SNAPSHOT_ESTIMATED_BYTES: + gib = estimated_bytes / float(1024**3) + limit_gib = MAX_EVALUATED_SNAPSHOT_ESTIMATED_BYTES / float(1024**3) + raise RuntimeError( + f"The evaluated scan needs an estimated {gib:.1f} GiB copy, above CurioMesh's " + f"{limit_gib:.0f} GiB safety guard. Split or pre-decimate the scan before retopology." + ) + _log(config, f"Evaluated mesh copy estimate: {estimated_bytes / float(1024**2):.1f} MiB") try: mesh = bpy.data.meshes.new_from_object( evaluated, @@ -261,16 +338,13 @@ def _voxel_repair(obj: bpy.types.Object, config: RemeshConfig) -> bool: _set_active_only(obj) voxel_size = _auto_voxel_size(obj, config) try: - bpy.ops.object.voxel_remesh(voxel_size=voxel_size, adaptivity=0.0) + if hasattr(obj.data, "remesh_voxel_size"): + obj.data.remesh_voxel_size = voxel_size + if hasattr(obj.data, "remesh_voxel_adaptivity"): + obj.data.remesh_voxel_adaptivity = 0.0 + bpy.ops.object.voxel_remesh() _log(config, f"Voxel repair succeeded at voxel_size={voxel_size:.5f}") return True - except TypeError: - try: - bpy.ops.object.voxel_remesh(voxel_size=voxel_size) - _log(config, f"Voxel repair succeeded at voxel_size={voxel_size:.5f}") - return True - except Exception: - traceback.print_exc() except Exception: traceback.print_exc() return False @@ -357,6 +431,21 @@ def _apply_meshdata_to_object(obj: bpy.types.Object, data: MeshData) -> None: mesh.polygons[index].material_index = max(0, int(material)) except Exception: mesh.polygons[index].material_index = 0 + if data.uvs is not None and data.face_uvs is not None: + try: + uv_layer = mesh.uv_layers.new(name="UVMap") + for poly_index, poly in enumerate(mesh.polygons): + if poly_index >= len(data.face_uvs): + break + uv_face = data.face_uvs[poly_index] + for corner, loop_index in enumerate(poly.loop_indices): + if corner >= len(uv_face): + continue + uv_index = uv_face[corner] + if 0 <= uv_index < len(data.uvs): + uv_layer.data[loop_index].uv = data.uvs[uv_index][:2] + except Exception: + traceback.print_exc() obj.data = mesh try: if old_mesh.users == 0: @@ -386,16 +475,340 @@ def _run_triadq_lite(obj: bpy.types.Object, config: RemeshConfig) -> bool: obj["curiomesh_triadq_seed"] = int(report.selected_seed) obj["curiomesh_triadq_score"] = float(report.score) obj["curiomesh_triadq_feature_edges"] = int(report.feature_edges) + obj["curiomesh_vertex_fan_splits"] = int(report.vertex_fan_splits) _log( config, ( f"TRIAD-Q Lite: mode={report.mode} seed={report.selected_seed} " - f"faces={report.output_faces} quad_ratio={report.quad_ratio:.3f}" + f"faces={report.output_faces} quad_ratio={report.quad_ratio:.3f} " + f"vertex_fan_splits={report.vertex_fan_splits}" ), ) return True +def _apply_decimate_proxy( + obj: bpy.types.Object, + target_faces: int, + config: RemeshConfig, + *, + preserve_features: bool, +) -> bool: + current_faces = len(obj.data.polygons) + current_triangles = sum(max(1, len(poly.vertices) - 2) for poly in obj.data.polygons) + target = max(4, int(target_faces)) + if current_triangles <= target: + return False + _set_active_only(obj) + modifier = obj.modifiers.new("CurioMesh target-scale QEM proxy", "DECIMATE") + modifier.decimate_type = "COLLAPSE" + modifier.ratio = max(0.00001, min(1.0, target / max(1.0, float(current_triangles)))) + try: + modifier.use_collapse_triangulate = True + except Exception: + pass + if preserve_features: + try: + modifier.delimit = {"SHARP", "SEAM", "MATERIAL"} + except Exception: + pass + try: + bpy.ops.object.modifier_apply(modifier=modifier.name) + obj["curiomesh_proxy_input_faces"] = int(current_faces) + obj["curiomesh_proxy_faces"] = int(len(obj.data.polygons)) + _log(config, f"Built QEM proxy: {current_faces} -> {len(obj.data.polygons)} faces") + return True + except Exception: + traceback.print_exc() + try: + obj.modifiers.remove(modifier) + except Exception: + pass + return False + + +def _triangle_count(obj: bpy.types.Object) -> int: + return sum(max(1, len(poly.vertices) - 2) for poly in obj.data.polygons) + + +def _rotate_independent_triangle_edges( + obj: bpy.types.Object, + seed: int, + *, + fraction: float = 0.18, +) -> tuple[int, int]: + """Rotate a deterministic independent set of interior triangle edges. + + Blender's collapse decimator can stop on an irreducible triangulation even + when a much smaller triangulation of the same surface topology exists. + Edge rotations change only the diagonalization. Keeping selected edges + face-disjoint makes the operation deterministic and avoids interacting + rotations within a round. + """ + + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + bm.faces.ensure_lookup_table() + candidates = [ + edge + for edge in bm.edges + if len(edge.link_faces) == 2 + and all(len(face.verts) == 3 for face in edge.link_faces) + ] + candidates.sort(key=lambda edge: tuple(sorted(vertex.index for vertex in edge.verts))) + random.Random(int(seed)).shuffle(candidates) + used_faces: set[bmesh.types.BMFace] = set() + selected: list[bmesh.types.BMEdge] = [] + limit = max(1, int(round(len(bm.faces) * max(0.01, min(0.45, fraction))))) + for edge in candidates: + if any(face in used_faces for face in edge.link_faces): + continue + selected.append(edge) + used_faces.update(edge.link_faces) + if len(selected) >= limit: + break + if not selected: + return 0, 0 + result = bmesh.ops.rotate_edges( + bm, + edges=selected, + use_ccw=bool(int(seed) & 1), + ) + rotated = len(result.get("edges", ())) + if rotated: + bm.to_mesh(obj.data) + obj.data.update(calc_edges=True) + return len(selected), rotated + finally: + bm.free() + + +def _proxy_topology_certifiable(diagnostics: MeshDiagnostics) -> bool: + return bool( + diagnostics.faces > 0 + and diagnostics.component_signature + and diagnostics.non_manifold_edges == 0 + and diagnostics.non_manifold_vertices == 0 + and diagnostics.branched_boundary_vertices == 0 + and diagnostics.inconsistent_edges == 0 + and diagnostics.duplicate_faces == 0 + and diagnostics.degenerate_faces == 0 + ) + + +def _proxy_topology_matches( + reference: MeshDiagnostics, + candidate: MeshDiagnostics, +) -> bool: + return bool( + _proxy_topology_certifiable(candidate) + and candidate.connected_components == reference.connected_components + and candidate.euler_characteristic == reference.euler_characteristic + and candidate.component_signature == reference.component_signature + and candidate.ngons == 0 + ) + + +def _normalize_proxy_vertex_fans( + obj: bpy.types.Object, + output_cap: int, + config: RemeshConfig, +) -> int: + """Split coordinate-identical bow-tie fans created by QEM contractions.""" + + diagnostics = mesh_diagnostics(obj, output_cap) + if diagnostics.non_manifold_vertices <= 0: + return 0 + if ( + diagnostics.non_manifold_edges > 0 + or diagnostics.branched_boundary_vertices > 0 + or diagnostics.inconsistent_edges > 0 + or diagnostics.duplicate_faces > 0 + or diagnostics.degenerate_faces > 0 + ): + return 0 + normalized, splits = split_non_manifold_vertex_fans(_meshdata_from_object(obj)) + if splits <= 0: + return 0 + _apply_meshdata_to_object(obj, normalized) + obj["curiomesh_pair_unlock_vertex_fan_splits"] = int( + obj.get("curiomesh_pair_unlock_vertex_fan_splits", 0) + ) + int(splits) + _log(config, f"Normalized {splits} QEM-created disconnected vertex fan(s)") + return int(splits) + + +def _unlock_pair_closure_proxy( + obj: bpy.types.Object, + base_target: int, + output_cap: int, + config: RemeshConfig, + *, + max_rounds: int = 112, +) -> int: + """Unlock topology-preserving QEM reductions with gated edge rotations. + + Each round is transactional. A rotated/QEM candidate is retained only + when it remains a certifiable manifold with the exact same per-component + Euler/boundary signature. Invalid or topology-changing rounds restore the + previous mesh. The 2.8 closure estimate is conservative for the observed + PairClosure pairing range; final candidate gates still enforce the actual + quad count and topology. + """ + + cap = max(4, int(output_cap)) + target = max(4, int(base_target)) + _normalize_proxy_vertex_fans(obj, cap, config) + baseline = mesh_diagnostics(obj, cap) + if not _proxy_topology_certifiable(baseline): + _log(config, "Skipped QEM unlock because the proxy topology is not certifiable") + return 0 + + initial_triangles = _triangle_count(obj) + if math.ceil(initial_triangles * 2.8) <= cap: + return 0 + + accepted_rounds = 0 + rejected_rounds = 0 + rotated_edges = 0 + no_reduction_rounds = 0 + rounds = max(0, min(128, int(max_rounds))) + for round_index in range(1, rounds + 1): + before_triangles = _triangle_count(obj) + previous_mesh = obj.data.copy() + previous_mesh.name = f"{obj.data.name}_PreUnlock{round_index:03d}" + try: + _selected, rotated = _rotate_independent_triangle_edges( + obj, + int(config.seed) + 1000 + round_index, + ) + if rotated <= 0: + if previous_mesh.users == 0: + bpy.data.meshes.remove(previous_mesh) + break + _apply_decimate_proxy( + obj, + target, + config, + preserve_features=False, + ) + _normalize_proxy_vertex_fans(obj, cap, config) + candidate = mesh_diagnostics(obj, cap) + candidate_triangles = _triangle_count(obj) + accepted = bool( + candidate_triangles <= before_triangles + and _proxy_topology_matches(baseline, candidate) + ) + if accepted: + if previous_mesh.users == 0: + bpy.data.meshes.remove(previous_mesh) + accepted_rounds += 1 + rotated_edges += rotated + baseline = candidate + if candidate_triangles < before_triangles: + no_reduction_rounds = 0 + else: + no_reduction_rounds += 1 + else: + rejected_mesh = obj.data + obj.data = previous_mesh + if rejected_mesh.users == 0: + bpy.data.meshes.remove(rejected_mesh) + rejected_rounds += 1 + no_reduction_rounds += 1 + + if math.ceil(_triangle_count(obj) * 2.8) <= cap: + break + if no_reduction_rounds >= 12: + break + except Exception: + traceback.print_exc() + rejected_mesh = obj.data + obj.data = previous_mesh + if rejected_mesh.users == 0: + bpy.data.meshes.remove(rejected_mesh) + rejected_rounds += 1 + break + + final_triangles = _triangle_count(obj) + obj["curiomesh_pair_unlock_initial_triangles"] = int(initial_triangles) + obj["curiomesh_pair_unlock_final_triangles"] = int(final_triangles) + obj["curiomesh_pair_unlock_rounds"] = int(accepted_rounds) + obj["curiomesh_pair_unlock_rejected_rounds"] = int(rejected_rounds) + obj["curiomesh_pair_unlock_rotated_edges"] = int(rotated_edges) + obj["curiomesh_pair_unlock_output_estimate"] = int(math.ceil(final_triangles * 2.8)) + _log( + config, + ( + "Topology-gated QEM unlock: " + f"triangles={initial_triangles}->{final_triangles}, " + f"rounds={accepted_rounds}, rejected={rejected_rounds}, " + f"estimated_closure={math.ceil(final_triangles * 2.8)}/{cap}" + ), + ) + return accepted_rounds + + +def _prepare_flow_proxy(obj: bpy.types.Object, config: RemeshConfig) -> None: + factor, cap = { + "DRAFT": (6, 150_000), + "BALANCED": (12, 400_000), + "HERO": (20, 400_000), + }.get(config.quality, (12, 400_000)) + budget = min(cap, max(2_000, int(config.target_faces) * factor)) + _apply_decimate_proxy(obj, budget, config, preserve_features=config.preserve_sharp or config.preserve_seams) + + +def _prepare_pair_closure_proxy(obj: bpy.types.Object, config: RemeshConfig) -> None: + closure_upper_bound = sum(len(poly.vertices) for poly in obj.data.polygons) + if closure_upper_bound <= max(4, int(config.target_faces)) * 1.10: + return + # With pairing fraction p, face-corner closure emits approximately + # N_base * (3 - p) quads. p=0.8 is a conservative first estimate. + configured_base = int(config.pair_closure_base_faces) + base_target = ( + max(4, configured_base) + if configured_base > 0 + else max(4, int(round(max(4, config.target_faces) / 2.2))) + ) + obj["curiomesh_pair_closure_base_target"] = int(base_target) + _apply_decimate_proxy( + obj, + base_target, + config, + preserve_features=bool(config.preserve_sharp or config.preserve_seams), + ) + configured_cap = int(config.pair_closure_output_cap) + output_cap = ( + max(4, configured_cap) + if configured_cap > 0 + else max(4, int(math.ceil(config.target_faces * (1.75 if config.quality == "DRAFT" else 1.35)))) + ) + obj["curiomesh_pair_closure_output_cap"] = int(output_cap) + if configured_base > 0 or not (config.preserve_sharp or config.preserve_seams): + _unlock_pair_closure_proxy( + obj, + base_target, + output_cap, + config, + ) + + +def _clone_candidate(base: bpy.types.Object, index: int) -> bpy.types.Object: + mesh = base.data.copy() + mesh.name = f"{base.data.name}_AtlasCandidate{index}" + candidate = bpy.data.objects.new(f"{base.name}_AtlasCandidate{index}", mesh) + candidate.matrix_world = base.matrix_world.copy() + _link_object_like(base, candidate) + _copy_material_slots(base, candidate) + for key in base.keys(): + candidate[key] = base[key] + return candidate + + def _apply_shrinkwrap(target: bpy.types.Object, source: bpy.types.Object, config: RemeshConfig) -> bool: _set_active_only(target) try: @@ -424,9 +837,587 @@ def _build_source_bvh(source: bpy.types.Object) -> tuple[BVHTree, list[tuple[int return BVHTree.FromPolygons(verts_world, triangles, all_triangles=True), triangles, polygon_indices -def _map_material_indices(source: bpy.types.Object, target: bpy.types.Object) -> bool: +def _distance_bvh(obj: bpy.types.Object) -> BVHTree: + # All surface-error queries are in world space. Blender's FromObject BVH + # stores object-local coordinates, so using it here silently corrupts + # scores for translated, rotated, or scaled objects. + return _build_source_bvh(obj)[0] + + +def _bounded_triangle_count(obj: bpy.types.Object, limit: int) -> tuple[int, bool]: + """Count tessellated faces without allocating a loop-triangle collection. + + The returned count stops immediately after ``limit``. This makes the + fidelity-reference decision proportional to the cap instead of the full + scan size and, importantly, avoids materializing Python triangle tuples. + """ + + cap = max(1, int(limit)) + count = 0 + for polygon in obj.data.polygons: + count += max(1, len(polygon.vertices) - 2) + if count > cap: + return count, True + return count, False + + +def _bounded_source_sample_points( + obj: bpy.types.Object, + limit: int, +) -> tuple[list[Vector], str]: + """Sample a huge source in bounded memory without loop triangulation. + + Face-center stride samples retain source-to-candidate fidelity evidence, + while a small world-space grid keeps broad spatial coverage. This is not an + exhaustive component proof, so the status deliberately says ``BOUNDED``. + """ + + face_count = len(obj.data.polygons) + budget = max(0, int(limit)) + if face_count == 0 or budget == 0: + return [], "BOUNDED_SOURCE_EMPTY" + + world_bounds = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + minimum = Vector(tuple(min(point[axis] for point in world_bounds) for axis in range(3))) + maximum = Vector(tuple(max(point[axis] for point in world_bounds) for axis in range(3))) + span = maximum - minimum + grid_resolution = max(2, min(8, int(round(budget ** (1.0 / 3.0))))) + grid_budget = min(budget // 4, grid_resolution**3) + stride_budget = max(1, budget - grid_budget) + stride = max(1, math.ceil(face_count / stride_budget)) + matrix = obj.matrix_world + grid_points: dict[tuple[int, int, int], Vector] = {} + stride_points: list[Vector] = [] + + for index, polygon in enumerate(obj.data.polygons): + center = matrix @ polygon.center + if len(grid_points) < grid_budget: + cell = tuple( + min( + grid_resolution - 1, + max(0, int(((center[axis] - minimum[axis]) / max(span[axis], 1e-20)) * grid_resolution)), + ) + for axis in range(3) + ) + grid_points.setdefault(cell, center) + if index % stride == stride // 2 and len(stride_points) < stride_budget: + stride_points.append(center) + + points = (list(grid_points.values()) + stride_points)[:budget] + return points, f"BOUNDED_SOURCE_FACES_{len(points)}_OF_{face_count}" + + +def _capture_bounded_source_evidence( + source: bpy.types.Object, + sample_limit: int, + source_triangle_floor: int, +) -> _BoundedSourceEvidence: + points, component_sampling = _bounded_source_sample_points(source, sample_limit) + return _BoundedSourceEvidence( + sample_points=points, + component_sampling=component_sampling, + diagonal=_object_diagonal(source), + source_triangle_floor=max(1, int(source_triangle_floor)), + ) + + +def _percentile(values: list[float], fraction: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + position = max(0.0, min(1.0, fraction)) * (len(ordered) - 1) + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return ordered[lower] + weight = position - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def _component_sample_points( + obj: bpy.types.Object, + limit: int, + *, + max_vertices: int = 2_000_000, + max_edges: int = 6_000_000, +) -> tuple[list[Vector], str]: + """Return representatives biased toward small connected components. + + NumPy-backed union-find avoids Python adjacency sets on scan-scale meshes. + Above the explicit memory guard, spatial and area strata remain active and + the returned status records that component coverage was not exhaustive. + """ + + mesh = obj.data + vertex_count = len(mesh.vertices) + edge_count = len(mesh.edges) + if limit <= 0 or vertex_count == 0 or edge_count == 0: + return [], "NO_SURFACE_COMPONENTS" + if vertex_count > max_vertices or edge_count > max_edges: + return [], f"SKIPPED_LIMIT_{vertex_count}_VERTICES_{edge_count}_EDGES" + + dtype = np.int32 if vertex_count < np.iinfo(np.int32).max else np.int64 + parents = np.arange(vertex_count, dtype=dtype) + sizes = np.ones(vertex_count, dtype=np.int32) + edge_vertices = np.empty(edge_count * 2, dtype=dtype) + mesh.edges.foreach_get("vertices", edge_vertices) + + def root(raw: int) -> int: + current = int(raw) + while int(parents[current]) != current: + parents[current] = parents[int(parents[current])] + current = int(parents[current]) + return current + + for raw_a, raw_b in edge_vertices.reshape((-1, 2)): + a = root(int(raw_a)) + b = root(int(raw_b)) + if a == b: + continue + if sizes[a] < sizes[b]: + a, b = b, a + parents[b] = a + sizes[a] += sizes[b] + + while True: + compressed = parents[parents] + if np.array_equal(compressed, parents): + break + parents = compressed + roots, first_indices, counts = np.unique(parents, return_index=True, return_counts=True) + del roots, edge_vertices + surface_mask = counts >= 2 + first_indices = first_indices[surface_mask] + counts = counts[surface_mask] + component_count = len(first_indices) + if component_count == 0: + return [], "NO_SURFACE_COMPONENTS" + + if component_count > limit: + # The area strata already favor large islands. Spend the component + # quota on the small pieces most likely to vanish during QEM collapse. + selected = np.argsort(counts, kind="stable")[:limit] + first_indices = first_indices[selected] + status = f"CAPPED_{len(first_indices)}_OF_{component_count}" + else: + status = f"ALL_{component_count}" + matrix = obj.matrix_world + return [matrix @ mesh.vertices[int(index)].co for index in first_indices], status + + +def _radical_inverse(index: int, base: int) -> float: + value = 0.0 + denominator = 1.0 + current = max(1, int(index)) + while current: + current, digit = divmod(current, base) + denominator *= base + value += digit / denominator + return value + + +def _triangle_point(a: Vector, b: Vector, c: Vector, index: int) -> Vector: + root_u = math.sqrt(_radical_inverse(index + 1, 2)) + v = _radical_inverse(index + 1, 3) + return (1.0 - root_u) * a + root_u * (1.0 - v) * b + root_u * v * c + + +def _stratified_surface_points( + obj: bpy.types.Object, + limit: int, + *, + include_components: bool, +) -> tuple[list[Vector], str]: + mesh = obj.data + if limit <= 0 or not mesh.polygons: + return [], "EMPTY" + mesh.calc_loop_triangles() + if not mesh.loop_triangles: + return [], "EMPTY" + + component_points: list[Vector] = [] + component_status = "NOT_REQUESTED" + if include_components: + component_points, component_status = _component_sample_points( + obj, + min(1024, max(1, limit // 4)), + ) + + world_bounds = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + minimum = Vector( + ( + min(point.x for point in world_bounds), + min(point.y for point in world_bounds), + min(point.z for point in world_bounds), + ) + ) + maximum = Vector( + ( + max(point.x for point in world_bounds), + max(point.y for point in world_bounds), + max(point.z for point in world_bounds), + ) + ) + span = maximum - minimum + grid_resolution = 8 + grid_points: dict[tuple[int, int, int], Vector] = {} + total_area = 0.0 + matrix = obj.matrix_world + for triangle in mesh.loop_triangles: + a, b, c = (matrix @ mesh.vertices[index].co for index in triangle.vertices) + area = 0.5 * (b - a).cross(c - a).length + if not math.isfinite(area) or area <= 0.0: + continue + total_area += area + center = (a + b + c) / 3.0 + cell = tuple( + min( + grid_resolution - 1, + max(0, int(((center[axis] - minimum[axis]) / max(span[axis], 1e-20)) * grid_resolution)), + ) + for axis in range(3) + ) + grid_points.setdefault(cell, center) + + points = component_points + list(grid_points.values()) + if len(points) >= limit: + return points[:limit], component_status + area_budget = limit - len(points) + if total_area <= 0.0: + return points, component_status + + interval = total_area / max(1, area_budget) + threshold = interval * 0.5 + cumulative = 0.0 + sample_index = 0 + for triangle in mesh.loop_triangles: + a, b, c = (matrix @ mesh.vertices[index].co for index in triangle.vertices) + area = 0.5 * (b - a).cross(c - a).length + if not math.isfinite(area) or area <= 0.0: + continue + upper = cumulative + area + while threshold <= upper and sample_index < area_budget: + points.append(_triangle_point(a, b, c, sample_index)) + sample_index += 1 + threshold += interval + cumulative = upper + if sample_index >= area_budget: + break + return points[:limit], component_status + + +def _make_surface_reference( + source: bpy.types.Object, + sample_limit: int = 5000, + *, + reference_proxy: bpy.types.Object | None = None, + max_reference_triangles: int = MAX_FIDELITY_REFERENCE_TRIANGLES, + bounded_source_evidence: _BoundedSourceEvidence | None = None, +) -> _SurfaceReference: + cap = max(1, int(max_reference_triangles)) + if bounded_source_evidence is None: + source_triangles, source_exceeds_cap = _bounded_triangle_count(source, cap) + else: + source_triangles = bounded_source_evidence.source_triangle_floor + source_exceeds_cap = True + reference_object = source + + if source_exceeds_cap: + if reference_proxy is None: + raise RuntimeError( + "The source exceeds the bounded fidelity-reference budget and no reduced proxy was supplied." + ) + proxy_triangles, proxy_exceeds_cap = _bounded_triangle_count(reference_proxy, cap) + if proxy_exceeds_cap: + raise RuntimeError( + f"The fidelity proxy exceeds the {cap:,}-triangle memory budget; lower the target face count." + ) + reference_object = reference_proxy + if bounded_source_evidence is None: + points, component_status = _bounded_source_sample_points(source, sample_limit) + diagonal = _object_diagonal(source) + else: + points = bounded_source_evidence.sample_points + component_status = bounded_source_evidence.component_sampling + diagonal = bounded_source_evidence.diagonal + fidelity_status = ( + f"BOUNDED_PROXY_BVH_{proxy_triangles}_TRIS_SOURCE_GE_{source_triangles}_TRIS" + ) + else: + points, component_status = _stratified_surface_points( + source, + sample_limit, + include_components=True, + ) + fidelity_status = f"SOURCE_FULL_BVH_{source_triangles}_TRIS" + diagonal = _object_diagonal(source) + + return _SurfaceReference( + bvh=_distance_bvh(reference_object), + sample_points=points, + diagonal=diagonal, + component_sampling=component_status, + fidelity_status=fidelity_status, + ) + + +def _sampled_surface_error_stats( + source: bpy.types.Object, + target: bpy.types.Object, + *, + sample_limit: int = 5000, + reference: _SurfaceReference | None = None, +) -> _SurfaceError: + if not target.data.polygons or (reference is None and not source.data.polygons): + return _SurfaceError(float("inf"), float("inf"), float("inf"), "EMPTY", "EMPTY") + if reference is None: + reference = _make_surface_reference(source, sample_limit) + target_bvh = _distance_bvh(target) + distances: list[float] = [] + target_points, _component_status = _stratified_surface_points( + target, + sample_limit, + include_components=False, + ) + for point in target_points: + hit = reference.bvh.find_nearest(point) + if hit is not None and hit[3] is not None and math.isfinite(hit[3]): + distances.append(float(hit[3])) + for point in reference.sample_points: + hit = target_bvh.find_nearest(point) + if hit is not None and hit[3] is not None and math.isfinite(hit[3]): + distances.append(float(hit[3])) + if not distances: + return _SurfaceError( + float("inf"), + float("inf"), + float("inf"), + reference.component_sampling, + reference.fidelity_status, + ) + rms = math.sqrt(sum(distance * distance for distance in distances) / len(distances)) / reference.diagonal + p95 = _percentile(distances, 0.95) / reference.diagonal + maximum = max(distances) / reference.diagonal + return _SurfaceError( + rms, + p95, + maximum, + reference.component_sampling, + reference.fidelity_status, + ) + + +def _sampled_surface_error( + source: bpy.types.Object, + target: bpy.types.Object, + *, + sample_limit: int = 5000, + reference: _SurfaceReference | None = None, +) -> tuple[float, float]: + """Compatibility wrapper returning RMS and p95 normalized error.""" + + stats = _sampled_surface_error_stats( + source, + target, + sample_limit=sample_limit, + reference=reference, + ) + return stats.rms, stats.p95 + + +def _run_atlas_candidate( + candidate: bpy.types.Object, + source: bpy.types.Object, + config: RemeshConfig, + profile: ScanProfile, + spec: CandidateSpec, + reference: _SurfaceReference, +) -> CandidateEvaluation: + started = time.perf_counter() + candidate_config = replace( + config, + seed=spec.seed, + preserve_sharp=config.preserve_sharp and spec.preserve_sharp, + preserve_boundary=config.preserve_boundary and spec.preserve_boundary, + triad_force_quads=True if spec.pair_closure else config.triad_force_quads, + ) + if spec.pair_closure: + _prepare_pair_closure_proxy(candidate, candidate_config) + ok = _run_triadq_lite(candidate, candidate_config) + else: + if spec.repair and candidate_config.voxel_repair: + ok = _voxel_repair(candidate, candidate_config) + else: + ok = True + if ok: + ok = _run_quadriflow(candidate, candidate_config) + + elapsed_ms = (time.perf_counter() - started) * 1000.0 + if not ok: + return CandidateEvaluation( + strategy=spec.strategy, + label=spec.label, + valid=False, + score=-1.0, + reason="solver failed", + faces=0, + quad_ratio=0.0, + target_error=1.0, + geometry_rms=float("inf"), + geometry_p95=float("inf"), + geometry_max=float("inf"), + min_scaled_jacobian=0.0, + p95_aspect_ratio=0.0, + extraordinary_ratio=1.0, + elapsed_ms=elapsed_ms, + ) + + diagnostics = mesh_diagnostics(candidate, config.target_faces) + geometry = _sampled_surface_error_stats(source, candidate, reference=reference) + return evaluate_candidate( + spec, + diagnostics, + target_faces=config.target_faces, + geometry_rms=geometry.rms, + geometry_p95=geometry.p95, + geometry_max=geometry.maximum, + target_edge_normalized=max(profile.target_edge_length / profile.diagonal, 1e-9), + require_quads=config.require_quads, + topology_policy=config.topology_policy, + profile=profile, + quality=config.quality, + elapsed_ms=elapsed_ms, + ) + + +def _run_atlasq( + base_obj: bpy.types.Object, + source: bpy.types.Object, + config: RemeshConfig, + profile: ScanProfile, + reference: _SurfaceReference | None = None, +) -> tuple[bpy.types.Object | None, CandidateEvaluation | None, list[CandidateEvaluation]]: + pair_closure_only = str(config.atlas_strategy or "AUTO").upper() == "PAIR_CLOSURE" + if not pair_closure_only: + _prepare_flow_proxy(base_obj, config) + pristine_mesh = base_obj.data.copy() + if reference is None: + reference = _make_surface_reference( + source, + 5000, + reference_proxy=base_obj, + ) + specs = build_candidate_specs( + profile, + strategy=config.atlas_strategy, + quality=config.quality, + seed=config.seed, + candidate_count=config.atlas_candidate_count, + topology_policy=config.topology_policy, + voxel_repair=config.voxel_repair, + ) + evaluations: list[CandidateEvaluation] = [] + candidate_objects: list[tuple[bpy.types.Object, CandidateEvaluation]] = [] + + def run_spec(spec: CandidateSpec, index: int) -> None: + candidate = base_obj if index == 0 else _clone_candidate(base_obj, index) + if index > 0: + replacement = pristine_mesh.copy() + old_mesh = candidate.data + candidate.data = replacement + if old_mesh.users == 0: + bpy.data.meshes.remove(old_mesh) + try: + evaluation = _run_atlas_candidate(candidate, source, config, profile, spec, reference) + except Exception as exc: + traceback.print_exc() + evaluation = CandidateEvaluation( + strategy=spec.strategy, + label=spec.label, + valid=False, + score=-1.0, + reason=f"candidate exception: {type(exc).__name__}: {exc}"[:240], + faces=0, + quad_ratio=0.0, + target_error=1.0, + geometry_rms=float("inf"), + geometry_p95=float("inf"), + geometry_max=float("inf"), + min_scaled_jacobian=0.0, + p95_aspect_ratio=0.0, + extraordinary_ratio=1.0, + elapsed_ms=0.0, + ) + evaluations.append(evaluation) + candidate_objects.append((candidate, evaluation)) + _log( + config, + ( + f"Atlas-Q {evaluation.label}: valid={evaluation.valid} score={evaluation.score:.4f} " + f"faces={evaluation.faces} p95={evaluation.geometry_p95:.6f} reason={evaluation.reason}" + ), + ) + + for index, spec in enumerate(specs): + run_spec(spec, index) + + valid = [(obj, evaluation) for obj, evaluation in candidate_objects if evaluation.valid] + pair_attempted = any(spec.pair_closure for spec in specs) + if not valid and config.require_quads and not pair_attempted: + fallback = CandidateSpec( + strategy="PAIR_CLOSURE", + label="PairClosure exact all-quad fallback", + seed=config.seed + 104729, + pair_closure=True, + ) + run_spec(fallback, len(specs)) + valid = [(obj, evaluation) for obj, evaluation in candidate_objects if evaluation.valid] + + selected_obj = None + selected_evaluation = None + if valid: + selected_obj, selected_evaluation = max(valid, key=lambda item: item[1].score) + + for candidate, _evaluation in candidate_objects: + if candidate is not selected_obj: + _remove_object(candidate) + if pristine_mesh.users == 0: + bpy.data.meshes.remove(pristine_mesh) + return selected_obj, selected_evaluation, evaluations + + +def _transfer_material_indices_with_modifier( + source: bpy.types.Object, + target: bpy.types.Object, +) -> bool: + _set_active_only(target) + modifier = target.modifiers.new("CurioMesh material transfer", "DATA_TRANSFER") + modifier.object = source + try: + modifier.use_poly_data = True + modifier.data_types_polys = {"MATERIAL_INDEX"} + modifier.poly_mapping = "NEAREST" + bpy.ops.object.modifier_apply(modifier=modifier.name) + return True + except Exception: + traceback.print_exc() + try: + target.modifiers.remove(modifier) + except Exception: + pass + return False + + +def _map_material_indices( + source: bpy.types.Object, + target: bpy.types.Object, + *, + allow_python_bvh: bool = True, +) -> bool: if not source.data.materials or not target.data.materials or not source.data.polygons: return False + if not allow_python_bvh: + return _transfer_material_indices_with_modifier(source, target) bvh, _triangles, polygon_indices = _build_source_bvh(source) assigned = 0 @@ -542,7 +1533,13 @@ def _project_uvs_bvh(source: bpy.types.Object, target: bpy.types.Object) -> bool return assigned > 0 and uv_mapping_is_valid(target) -def _preserve_textures(source: bpy.types.Object, target: bpy.types.Object, config: RemeshConfig) -> str: +def _preserve_textures( + source: bpy.types.Object, + target: bpy.types.Object, + config: RemeshConfig, + *, + allow_python_bvh: bool = True, +) -> str: mode = str(config.texture_mode or "PROJECT").upper() if mode == "NONE": return "SKIPPED" @@ -555,7 +1552,7 @@ def _preserve_textures(source: bpy.types.Object, target: bpy.types.Object, confi if mode in {"TRANSFER", "PROJECT"}: transferred = _transfer_uvs_with_modifier(source, target) - if mode == "PROJECT" and not uv_mapping_is_valid(target): + if mode == "PROJECT" and allow_python_bvh and not uv_mapping_is_valid(target): projected = _project_uvs_bvh(source, target) if mode == "BAKE" or (config.bake_fallback and not uv_mapping_is_valid(target)): try: @@ -609,8 +1606,14 @@ def _finalize_output( result_mesh.name = f"{source_obj.name}_CurioMesh" if config.apply_source_modifiers: _strip_source_modifiers(source_obj) + old_source_mesh = source_obj.data source_obj.data = result_mesh _remove_object(work_obj) + if old_source_mesh.users == 0: + try: + bpy.data.meshes.remove(old_source_mesh) + except Exception: + pass output_obj = source_obj work_obj = None else: @@ -632,17 +1635,85 @@ def _annotate_output(output_obj: bpy.types.Object, result: RemeshResult) -> None output_obj["curiomesh_face_count_error"] = round(result.diagnostics.face_count_error, 4) output_obj["curiomesh_uv_status"] = result.uv_status output_obj["curiomesh_materials_preserved"] = bool(result.materials_preserved) + output_obj["curiomesh_strategy"] = result.strategy + output_obj["curiomesh_scan_profile"] = result.profile + output_obj["curiomesh_candidate_count"] = int(result.candidate_count) + output_obj["curiomesh_geometry_rms"] = round(result.geometry_rms, 8) + output_obj["curiomesh_geometry_p95"] = round(result.geometry_p95, 8) + output_obj["curiomesh_geometry_max"] = round(result.geometry_max, 8) + output_obj["curiomesh_component_sampling"] = result.sampling_status + output_obj["curiomesh_fidelity_reference"] = result.fidelity_status + output_obj["curiomesh_quad_only_verified"] = bool(result.quad_only_verified) + output_obj["curiomesh_topology_status"] = result.topology_status + if result.candidates: + output_obj["curiomesh_candidates_json"] = json.dumps( + result.candidates, + separators=(",", ":"), + allow_nan=False, + ) + + +def _merge_proxy_topology_profile(source: ScanProfile, proxy: ScanProfile) -> ScanProfile: + """Route and validate from an exact reduced proxy without overstating source proof.""" + + if not proxy.exact_topology: + return source + return replace( + source, + category=proxy.category, + recommended_strategy=proxy.recommended_strategy, + topology_basis="PROXY_EXACT", + boundary_edges=proxy.boundary_edges, + boundary_loops=proxy.boundary_loops, + connected_components=proxy.connected_components, + component_euler=proxy.component_euler, + component_signature=proxy.component_signature, + non_manifold_edges=proxy.non_manifold_edges, + non_manifold_vertices=proxy.non_manifold_vertices, + branched_boundary_vertices=proxy.branched_boundary_vertices, + inconsistent_edges=proxy.inconsistent_edges, + duplicate_faces=proxy.duplicate_faces, + euler_characteristic=proxy.euler_characteristic, + warnings=source.warnings + + ( + "Topology gates use the exact reduced proxy; full-resolution source topology remains unverified.", + ), + ) + + +def _topology_status(profile: ScanProfile | None, topology_policy: str) -> str: + if topology_policy == "REPAIR": + return "REPAIR_ALLOWED" + if profile is None: + return "NOT_CHECKED" + if profile.topology_basis == "SOURCE_EXACT": + return "SOURCE_EXACT_PRESERVED" + if profile.topology_basis == "PROXY_EXACT": + return "PROXY_PRESERVED_SOURCE_UNVERIFIED" + return "SOURCE_UNVERIFIED" def run_curiomesh(obj: bpy.types.Object, config: RemeshConfig) -> RemeshResult: start = time.perf_counter() source_snapshot: bpy.types.Object | None = None + source_surface: bpy.types.Object | None = None work_obj: bpy.types.Object | None = None engine = str(config.engine or "QUADRIFLOW").upper() if engine == "AUTO": - engine = "QUADRIFLOW" + engine = "ATLAS_Q" uv_status = "NONE" materials_preserved = False + profile: ScanProfile | None = None + selected_evaluation: CandidateEvaluation | None = None + candidate_evaluations: list[CandidateEvaluation] = [] + strategy = "" + geometry_rms = 0.0 + geometry_p95 = 0.0 + geometry_max = 0.0 + sampling_status = "NOT_MEASURED" + fidelity_status = "NOT_MEASURED" + fidelity_reference: _SurfaceReference | None = None + bounded_source_evidence: _BoundedSourceEvidence | None = None if obj is None or obj.type != "MESH": return RemeshResult(False, message="Select a mesh object.") @@ -650,22 +1721,130 @@ def run_curiomesh(obj: bpy.types.Object, config: RemeshConfig) -> RemeshResult: with _ContextSnapshot() as context_state: try: source_snapshot = _make_evaluated_snapshot(obj, config) - work_obj = _make_working_copy(source_snapshot, obj) + source_surface = source_snapshot + source_triangle_probe, source_exceeds_reference_cap = _bounded_triangle_count( + source_snapshot, + MAX_FIDELITY_REFERENCE_TRIANGLES, + ) + if source_exceeds_reference_cap: + bounded_source_evidence = _capture_bounded_source_evidence( + source_snapshot, + 5000, + source_triangle_probe, + ) + if engine == "ATLAS_Q": + profile = analyze_object( + source_snapshot, + config.target_faces, + feature_angle_deg=config.triad_feature_angle, + max_sample_faces=config.analysis_sample_faces, + ) + # Reuse the sole evaluated mesh as the native QEM work object. + # Candidate copies are created only after this object is below + # the reference cap, avoiding a second unreduced scan copy. + work_obj = source_snapshot + source_snapshot = None + source_surface = obj + work_obj.name = f"{obj.name}_CurioMeshWork" + work_obj.data.name = f"{obj.name}_CurioMeshWorkMesh" + work_obj.hide_select = False + work_obj.hide_render = False + work_obj.hide_set(False) + else: + work_obj = _make_working_copy(source_snapshot, obj) + pair_closure_only = ( + engine == "ATLAS_Q" + and str(config.atlas_strategy or "AUTO").upper() == "PAIR_CLOSURE" + ) + if engine == "ATLAS_Q" and not pair_closure_only: + # Keep BMesh cleanup and Python adjacency off the full-resolution + # scan whenever Blender's native QEM proxy can reduce it first. + _prepare_flow_proxy(work_obj, config) + elif engine == "TRIAD_Q_LITE": + # PairClosure is an exact closure, not a simplifier. Reduce to + # its expected output scale before Python pairing/adjacency so a + # dense scan cannot expand by roughly three times. + _prepare_pair_closure_proxy(work_obj, config) + elif source_exceeds_reference_cap: + # Direct QuadriFlow remains available for compatibility, but a + # scan-scale input still needs a bounded fidelity reference. + # Reusing its native QEM proxy avoids another full mesh copy. + _prepare_flow_proxy(work_obj, config) _cleanup_mesh(work_obj.data, config) + fidelity_reference = _make_surface_reference( + source_surface, + 5000, + reference_proxy=work_obj, + bounded_source_evidence=bounded_source_evidence, + ) - if engine == "TRIAD_Q_LITE": - ok = _run_triadq_lite(work_obj, config) + if engine == "ATLAS_Q": + if profile is None: + profile = analyze_object( + source_surface, + config.target_faces, + feature_angle_deg=config.triad_feature_angle, + max_sample_faces=config.analysis_sample_faces, + ) + if not profile.exact_topology: + proxy_profile = analyze_object( + work_obj, + config.target_faces, + feature_angle_deg=config.triad_feature_angle, + max_exact_faces=450_000, + max_sample_faces=config.analysis_sample_faces, + ) + profile = _merge_proxy_topology_profile(profile, proxy_profile) + work_obj, selected_evaluation, candidate_evaluations = _run_atlasq( + work_obj, + source_surface, + config, + profile, + fidelity_reference, + ) + ok = work_obj is not None and selected_evaluation is not None + strategy = selected_evaluation.strategy if selected_evaluation is not None else "NONE" + elif engine == "TRIAD_Q_LITE": + triad_config = replace( + config, + triad_force_quads=bool(config.require_quads), + ) + ok = _run_triadq_lite(work_obj, triad_config) + strategy = "PAIR_CLOSURE" if triad_config.triad_force_quads else "TRIAD_Q_LITE" else: ok = _run_quadriflow(work_obj, config) - - if not ok and config.voxel_repair and engine == "QUADRIFLOW": + strategy = "QUADRIFLOW" + if ok and config.require_quads: + ok = mesh_diagnostics(work_obj, config.target_faces).quad_only + + if ( + not ok + and config.voxel_repair + and config.topology_policy == "REPAIR" + and engine == "QUADRIFLOW" + ): _log(config, "QuadriFlow failed; retrying after voxel repair") _remove_object(work_obj) - work_obj = _make_working_copy(source_snapshot, obj) + if source_snapshot is not None: + work_obj = _make_working_copy(source_snapshot, obj) + else: + work_obj = _make_evaluated_snapshot(obj, config) + work_obj.name = f"{obj.name}_CurioMeshWork" + work_obj.hide_select = False + work_obj.hide_render = False + work_obj.hide_set(False) + if bounded_source_evidence is not None: + # The retry snapshot is full resolution again. Keep its + # Python BMesh cleanup behind the same native QEM cap as + # the first scan-scale attempt. + _prepare_flow_proxy(work_obj, config) _cleanup_mesh(work_obj.data, config) - _voxel_repair(work_obj, config) - ok = _run_quadriflow(work_obj, config) + repaired = _voxel_repair(work_obj, config) + ok = repaired and _run_quadriflow(work_obj, config) engine = "VOXEL_REPAIR_QUADRIFLOW" + strategy = "DIRTY_FLOW" + if ok and config.require_quads: + ok = mesh_diagnostics(work_obj, config.target_faces).quad_only if not ok: elapsed = (time.perf_counter() - start) * 1000.0 @@ -673,31 +1852,168 @@ def run_curiomesh(obj: bpy.types.Object, config: RemeshConfig) -> RemeshResult: False, engine=engine, elapsed_ms=elapsed, - message="QuadriFlow failed on this mesh.", + message=( + f"{engine} produced no candidate that passed the topology and quad-quality gates. " + "Try Repair topology for a closed dirty scan, preserve topology for an open fragment, " + "or raise the target face count." + ), + strategy=strategy, + profile=profile.category if profile else "", + candidate_count=len(candidate_evaluations), + candidates=[evaluation.to_dict() for evaluation in candidate_evaluations], ) + projection_snapshot: bpy.types.Mesh | None = None + projection_status = "NOT_REQUESTED" if config.shrinkwrap_project: - _apply_shrinkwrap(work_obj, source_snapshot, config) + projection_snapshot = work_obj.data.copy() + projection_status = "APPLIED" if _apply_shrinkwrap(work_obj, source_surface, config) else "FAILED" + + if projection_snapshot is not None and projection_status == "FAILED": + rejected_mesh = work_obj.data + work_obj.data = projection_snapshot + projection_snapshot = None + if rejected_mesh.users == 0: + bpy.data.meshes.remove(rejected_mesh) + projection_status = "ROLLED_BACK_APPLY_FAILED" + _log(config, "Shrinkwrap projection failed to apply; restored validated candidate") + + def evaluate_final_mesh() -> tuple[MeshDiagnostics, _SurfaceError, list[str]]: + diagnostics = mesh_diagnostics(work_obj, config.target_faces) + measured = _sampled_surface_error_stats( + source_surface, + work_obj, + reference=fidelity_reference, + ) + failures: list[str] = [] + if engine == "ATLAS_Q" and profile is not None: + failures = candidate_gate_failures( + diagnostics, + target_faces=config.target_faces, + geometry_rms=measured.rms, + geometry_p95=measured.p95, + geometry_max=measured.maximum, + target_edge_normalized=max(profile.target_edge_length / profile.diagonal, 1e-9), + require_quads=config.require_quads, + topology_policy=config.topology_policy, + profile=profile, + quality=config.quality, + ) + elif config.require_quads and not diagnostics.quad_only: + failures = ["local quad/manifold gate failed"] + return diagnostics, measured, failures + + post_projection, geometry, post_failures = evaluate_final_mesh() + if post_failures and projection_snapshot is not None and projection_status == "APPLIED": + rejected_mesh = work_obj.data + work_obj.data = projection_snapshot + projection_snapshot = None + if rejected_mesh.users == 0: + bpy.data.meshes.remove(rejected_mesh) + post_projection, geometry, post_failures = evaluate_final_mesh() + if not post_failures: + projection_status = "ROLLED_BACK_FAILED_GATES" + _log(config, "Shrinkwrap projection failed release gates; restored validated candidate") + + if projection_snapshot is not None: + if projection_snapshot.users == 0: + bpy.data.meshes.remove(projection_snapshot) + projection_snapshot = None + + geometry_rms = geometry.rms + geometry_p95 = geometry.p95 + geometry_max = geometry.maximum + sampling_status = geometry.component_sampling + fidelity_status = geometry.fidelity_status + if post_failures: + elapsed = (time.perf_counter() - start) * 1000.0 + return RemeshResult( + False, + engine=engine, + elapsed_ms=elapsed, + message=( + "Final projection violated the release gates: " + + "; ".join(post_failures[:5]) + ), + strategy=strategy, + profile=profile.category if profile else "", + candidate_count=len(candidate_evaluations), + candidates=[evaluation.to_dict() for evaluation in candidate_evaluations], + ) - material_count = _copy_material_slots(source_snapshot, work_obj) + work_obj["curiomesh_projection_status"] = projection_status + + material_count = _copy_material_slots(source_surface, work_obj) materials_preserved = material_count > 0 if materials_preserved: - _map_material_indices(source_snapshot, work_obj) + _map_material_indices( + source_surface, + work_obj, + allow_python_bvh=bounded_source_evidence is None, + ) - uv_status = _preserve_textures(source_snapshot, work_obj, config) + uv_status = _preserve_textures( + source_surface, + work_obj, + config, + allow_python_bvh=bounded_source_evidence is None, + ) output_obj, work_obj = _finalize_output(obj, work_obj, config) diagnostics = mesh_diagnostics(output_obj, config.target_faces) elapsed = (time.perf_counter() - start) * 1000.0 + topology_status = _topology_status(profile, config.topology_policy) + topology_note = ( + " Full-resolution source topology was not exhaustively verified." + if "UNVERIFIED" in topology_status + else "" + ) + sampling_note = ( + " Exhaustive connected-component sampling was skipped at the scan-scale memory guard." + if sampling_status.startswith(("SKIPPED_LIMIT", "BOUNDED_")) + else "" + ) + fidelity_note = ( + " Fidelity error used bounded source samples and a target-scale proxy BVH." + if fidelity_status.startswith("BOUNDED_PROXY_BVH_") + else "" + ) + projection_note = ( + { + "ROLLED_BACK_FAILED_GATES": ( + " Shrinkwrap projection was rolled back because it violated quad-quality gates." + ), + "ROLLED_BACK_APPLY_FAILED": ( + " Shrinkwrap projection could not be applied; the validated candidate was retained." + ), + }.get(projection_status, "") + ) result = RemeshResult( True, output_object=output_obj, diagnostics=diagnostics, engine=engine, elapsed_ms=elapsed, - message="Remesh completed.", + message=( + f"{strategy} completed: {diagnostics.faces:,} locally validated manifold quads; " + f"p95 scan error {geometry_p95:.4%} of the source diagonal." + f"{topology_note}{sampling_note}{fidelity_note}{projection_note}" + if diagnostics.quad_only + else f"{strategy} remesh completed." + ), materials_preserved=materials_preserved, uv_status=uv_status, + strategy=strategy, + profile=profile.category if profile else "", + candidate_count=max(1, len(candidate_evaluations)), + geometry_rms=geometry_rms, + geometry_p95=geometry_p95, + geometry_max=geometry_max, + sampling_status=sampling_status, + fidelity_status=fidelity_status, + quad_only_verified=diagnostics.quad_only, + topology_status=topology_status, + candidates=[evaluation.to_dict() for evaluation in candidate_evaluations], ) _annotate_output(output_obj, result) context_state.final_active = output_obj diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..124585e --- /dev/null +++ b/docs/ALGORITHM.md @@ -0,0 +1,312 @@ +# Atlas-Q Algorithm And Validation Contract + +This document separates shipped behavior from research directions. The code is +a clean-room CurioMesh implementation under MIT; it does not copy GPL solver +sources. + +## Problem Contract + +Input can be a clean authored mesh, an open fragment, or a noisy photogrammetry +scan. The requested output is a lower-density surface whose accepted faces are +all valid quads. Four different goals can conflict: + +1. geometric fidelity to the scan, +2. low face count, +3. clean quad shape and few poles, +4. preservation or intentional repair of topology. + +Atlas-Q makes these conflicts visible. Topology and validity are hard gates; +face count is a soft target. + +## Implemented Pipeline + +### 1. Immutable source + +The evaluated object is copied once and retained as the reference for source +distance, projection, material assignment, UV transfer, and baking. Work is +performed on separate objects. `Replace` happens only after validation. + +### 2. Bounded scan intelligence + +For meshes up to the exact-analysis limit, CurioMesh measures edge incidence, +true boundary cycles, connected face components, Euler characteristic, +edge/vertex manifoldness, duplicate faces, winding consistency, degeneracy, +triangle ratio, and dihedral feature coverage. Above the limit it samples the +full source, reduces it natively, and performs exact connectivity checks on the +proxy. The basis is carried as `SOURCE_EXACT`, `PROXY_EXACT`, or `SAMPLED`. + +Auto routing begins with these empirical thresholds: + +- dirty: non-manifold edge ratio above `0.1%`, sampled degenerates above + `0.5%`, or a vertex non-manifold, branched boundary, or duplicate face; +- open: one or more measured boundary components; +- hard surface: sharp-edge coverage above `5%`; +- large scan: source exceeds exact-analysis budget; +- organic: otherwise. + +UVs or multiple material slots add a texture-critical warning without changing +the geometric class. + +### 3. Target-scale QEM proxy + +For target count `Nq`, estimated target edge length is + +```text +h0 = sqrt(A / Nq). +``` + +CurioMesh applies Blender's native collapse decimator before Python adjacency +or standalone conversion. Proxy budgets are: + +| Quality | Multiplier | Cap | Candidate count | +|---|---:|---:|---:| +| Draft | `6 Nq` | 150k | 1 | +| Balanced | `12 Nq` | 400k | configured, default 3 | +| Hero | `20 Nq` | 400k | at least 5 | + +Sharp, seam, and material delimiters are requested where Blender exposes them. +The source reference is never decimated. + +On explicitly budgeted PairClosure runs, Blender QEM can stall on an +irreducible triangulation even though a smaller triangulation of the same +surface exists. Atlas-Q then rotates a deterministic face-independent set of +interior triangle diagonals and retries QEM for a bounded number of rounds. +Each round snapshots the mesh and is committed only when it does not increase +triangle count and preserves all manifold/degeneracy gates plus the exact +sorted per-component `(Euler, boundary cycles)` signature. Otherwise it is +rolled back. The closure estimate used to stop early is heuristic; the actual +quad count and fidelity gates remain authoritative. + +### 4. Solver candidates + +Flow candidates run Blender's QuadriFlow with deterministic seeds separated by +7919. Feature/Open variants alter sharp and boundary constraints. DirtyFlow may +voxel-repair the first candidate only when topology repair is explicitly +allowed. + +If no valid flow candidate remains, Atlas-Q runs PairClosure. Direct Blender +PairClosure uses the same target-scale preprocessing. It further +decimates toward a base triangle budget + +```text +Nbase ~= Nq / (3 - p), p = 0.8 initially, +``` + +then performs feature-aware greedy triangle pairing. Organic, Patch, Dirty, +and Texture modes use different field-smoothing, feature-angle, and pairing +weight profiles. A proposed pair must itself be a finite, convex, non-crossed +quad with no more than 60 degrees of triangle-normal divergence across either +diagonal; unsafe pairs remain triangles for exact closure. + +When PairClosure is explicitly requested, Atlas-Q bypasses the larger +flow-candidate proxy and builds only this closure-sized QEM proxy. Avoiding two +serial decimation passes matters for sub-1,000-quad assets: it reduces topology +drift and makes the requested final budget correspond to the closure model. +For an explicitly low-poly fallback, callers may also release sharp/UV seam +delimiters on the closure proxy. Exact topology, local quad validity, and +surface-error gates still decide acceptance; the relaxed candidate cannot +silently remove a handle, boundary cycle, or component. + +Batch controllers can set an explicit closure-base budget after observing a +failed candidate's expansion ratio. If a base request `B0` produced `Q0` quads +for desired output `Qt`, the next deterministic estimate is proportional to +`B1 = B0 Qt / Q0`, with a small safety margin. The final target remains `Qt`; +only proxy construction changes, and all topology/fidelity gates are rerun. + +Immediately after the QEM proxy and before triangle pairing, PairClosure checks +the local fan of every used vertex. If the incident faces split into `k > 1` +groups when adjacency is restricted to edges containing that vertex, each fan +after the first receives an exact coordinate duplicate. This removes +vertex-only bow-ties even when those fans connect elsewhere in the same global +component. Face order, material IDs, and corner UV indices do not change. The +normalizer is deterministic and refuses inputs containing an edge incident to +more than two faces; that defect needs a different, explicit repair policy. + +### 5. ParityPath and conservative exact closure + +After feature-aware pairing, ParityPath marks all three edges of every residual +triangle. A neighboring quad incident to an odd number of marked edges becomes +a terminal in the quad-only dual graph. Deterministic shortest paths pair those +terminals or route them to a mesh boundary; path edges are combined by +symmetric difference. This is the parity equation + +```text +sum(edge splits around face) = face arity (mod 2). +``` + +Unchanged quads remain one face. Two-split quad cells use a validated direct +two-quad partition when possible or a three-quad center template; four-split +cells use four quads. Residual triangles use the robust three-quad midpoint and +center template. Every local template is checked for finite, convex, +non-crossed geometry before it is committed, and the complete component +signature is checked again afterward. + +For transition classes `Q0`, `Q2d`, `Q2c`, and `Q4`, the sparse count is + +```text +Fparity = 3T + Q0 + 2 Q2d + 3 Q2c + 4 Q4. +``` + +Atlas-Q also constructs the conservative global candidate. For every base +polygon `f=(v0,...,vk-1)`, construct one globally shared geometric midpoint +for each undirected edge, one face center, and + +```text +qi = (vi, midpoint(vi,v(i+1)), center(f), midpoint(v(i-1),vi)). +``` + +Applying this to the entire base—not only leftover triangles—is what makes the +result conforming. Shared geometric edges receive one midpoint. UV midpoints +remain face-local so seams survive. Material IDs are repeated per generated +corner quad. + +For `T` unpaired triangles and `Q` paired quads, that candidate has: + +```text +Fglobal = 3T + 4Q. +``` + +The engine returns the valid closure whose face count is closest to the target, +using the lower count as the deterministic tie-breaker. ParityPath is therefore +an optimization, not a replacement for the proven conservative construction. + +If the base is a valid consistently wound manifold, the operation preserves its +Euler characteristic and manifoldness. Acceptance additionally checks vertex +fans, boundary degree, duplicates, winding, local crossing, and corner +Jacobians. It does not prove that spatially separate faces never intersect. + +### 6. Symmetric source error + +CurioMesh builds world-space BVHs for source and candidate and samples both +directions. Samples combine: + +- systematic triangle-area strata, +- occupied cells of an 8x8x8 world-space grid, +- representatives biased toward small connected source components. + +NumPy union-find provides exact component representatives up to two million +vertices/six million edges; above that explicit guard, area and spatial strata +remain active and exhaustive component coverage is not claimed. RMS, p95, and +maximum sampled distances are normalized by source bounding-box diagonal. +This catches off-surface output and omitted regions more reliably than one-way +shrinkwrap distance or index-strided vertices. + +### 7. Candidate score + +Only candidates passing hard gates receive a positive score. The current +measured score is + +```text +S = 0.35 G + 0.25 J + 0.15 A + 0.10 C + 0.10 P + 0.05 Q, +``` + +where: + +- `G = exp(-(p95/h0)/0.35)` is geometric fidelity, +- `J` is clamped p05 scaled Jacobian, +- `A = exp(-max(aspect95-1,0)/3)` is aspect quality, +- `C = exp(-target_error)` is count fidelity, +- `P = exp(-4 extraordinary_ratio)` penalizes excess poles, +- `Q` is quad ratio. + +This is a practical controller objective, not a claim of a globally optimal +quadrangulation functional. + +## Hard Acceptance Gates + +With `Require Locally Valid Quads`, an output is rejected if it has: + +- any face whose arity is not four, +- repeated-corner, zero-area, concave, crossed, inverted, or more-than-60-degree + warped faces, +- non-positive corner scaled Jacobian, +- an edge incident to more than two faces or a disconnected vertex fan, +- a branched boundary, duplicate face, or inconsistent shared-edge winding, +- changed global Euler characteristic, connected-component count, open/closed + state, or the sorted per-component `(Euler, boundary cycles)` signature under + exact source/proxy `Preserve` analysis, +- quality-preset violations in target count, p05 Jacobian, p95 aspect, or + symmetric RMS/p95/maximum sampled surface error. + +Current hard thresholds are: + +| Quality | Target error | RMS / target edge | p95 / target edge | max / target edge | p05 Jacobian | p95 aspect | +|---|---:|---:|---:|---:|---:|---:| +| Draft | 75% | 0.60 | 1.50 | 4.00 | 0.01 | 12 | +| Balanced | 35% | 0.40 | 1.00 | 3.00 | 0.03 | 8 | +| Hero | 25% | 0.25 | 0.75 | 2.00 | 0.05 | 6 | + +Validation runs before and after projection. Shrinkwrap succeeding does not +override a failed quality gate. The accepted candidate mesh is snapshotted +before projection; if projection violates a gate, CurioMesh restores that mesh +and records `curiomesh_projection_status=ROLLED_BACK_FAILED_GATES`. If applying +the modifier itself fails, it records `ROLLED_BACK_APPLY_FAILED`. A rollback is +preferred over silently shipping folded or degenerate quads. “Locally valid” is not an embedded-surface +self-intersection certificate; visual/intersection inspection is still +required for a shipping asset. + +Area, bounding diagonal, aspect, Jacobian, and BVH distances are evaluated in +world space. Non-uniform object scale therefore cannot make a visibly stretched +quad pass by looking square in local coordinates; applying scale can still help +the underlying solver produce better flow. + +For a closed orientable all-quad mesh: + +```text +E = 2F +V - F = chi +sum_interior (4 - valence) = 4 chi. +``` + +With boundary: + +```text +sum_interior (4-valence) + sum_boundary (3-valence) = 4 chi. +``` + +Regression tests exercise both disk and sphere-topology examples. + +## Research Roadmap, Not Shipped Claims + +The strongest longer-term path is a global patch solver: + +1. robust multi-scale normal/curvature confidence, +2. confidence-weighted 4-RoSy field, +3. singularity simplification at target scale, +4. feature/separatrix patch layout, +5. integer arc quantization with parity and no T-junctions, +6. injective parameterization using symmetric Dirichlet energy, +7. robust extraction and local cavity templates. + +A representative field objective is + +```text +sum_ij w_ij rho(|q_i - exp(i4 phi_ij) q_j|^2) + + lambda sum_i c_i |q_i - exp(i4 theta_i)|^2. +``` + +An adaptive curvature metric can start from the sagitta estimate + +```text +h_j(x) ~= sqrt(8 epsilon / (|k_j(x)| + k_floor)). +``` + +These ideas derive from established MIQ, Integer-Grid Map, field-aligned, +feature-patch, and injective-parameterization research. They are not silently +represented as implemented by the current Python add-on. + +## Primary Sources + +- [Instant Field-Aligned Meshes](https://igl.ethz.ch/projects/instant-meshes/) +- [QuadriFlow](https://onlinelibrary.wiley.com/doi/10.1111/cgf.13498) +- [Mixed-Integer Quadrangulation](https://www.graphics.rwth-aachen.de/publication/0344/) +- [Integer-Grid Maps](https://www.graphics.rwth-aachen.de/publication/03197/) +- [QEx robust quad extraction](https://www.graphics.rwth-aachen.de/publication/03204/) +- [Reliable Feature-Line Driven Quad Remeshing](https://www.quadmesh.cloud/ReliableQuad.pdf) +- [Pattern-Based Quadrangulation](https://igl.ethz.ch/projects/patch-quad/) +- [Garland-Heckbert QEM](https://www.cs.princeton.edu/courses/archive/fall03/cs526/papers/garland97.pdf) +- [Quasi-structured quad meshing](https://arxiv.org/abs/2103.04652) +- [SLIM locally injective maps](https://igl.ethz.ch/projects/slim/SLIM2017.pdf) +- [Blossom-Quad](https://orbi.uliege.be/handle/2268/113152) +- [Practical Quad Mesh Simplification](https://vcg-legacy.isti.cnr.it/Publications/2010/TPCPP10/Tarini%20Pietroni%20Cignoni%20Panozzo%20Puppo%20-%20Practical%20Quad%20Semplification%20-%20EG%202010.pdf) +- [Quadrilateral Mesh Simplification](https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Daniels08.pdf) diff --git a/docs/PHOTOGRAMMETRY_WORKFLOW.md b/docs/PHOTOGRAMMETRY_WORKFLOW.md new file mode 100644 index 0000000..fdd6464 --- /dev/null +++ b/docs/PHOTOGRAMMETRY_WORKFLOW.md @@ -0,0 +1,89 @@ +# Photogrammetry Retopology Workflow + +## Before You Run CurioMesh + +- Keep the original scan in the file and use `New Object` output. +- Apply known unit scale and inspect disconnected debris, intentional openings, + thin sheets, and flipped regions. +- Decide whether holes are evidence or defects. This determines `Preserve` + versus `Repair`; CurioMesh cannot infer archaeological intent from geometry. +- Decide what high-frequency detail will move into color, normal, or + displacement textures. + +## Strategy By Asset + +### Organic statue, rock, terrain, or creature + +Use `Auto` or `Organic / General`, `Preserve`, and Balanced. Raise target count +when the p95 error or silhouette is poor. Automatic topology is suitable for +static/game/sculpt bases, not guaranteed deformation loops. + +### Mechanical, architectural, or ship scan + +Use `Mechanical / Feature` and `Preserve`. Keep seam/material feature options +on. Remove scan-noise edges before forcing feature preservation; a solver that +faithfully preserves false features produces faithfully bad topology. + +### Open archaeological fragment + +Use `Open Fragment` and `Preserve`. Do not enable dirty closed-scan repair. +Compare reported boundary-loop count with the source. Projection across nearby +opposing sheets remains a risk. + +### Dirty closed photogrammetry + +Analyze first. If holes and disconnected sheets are known defects, use `Dirty +Closed Scan` with `Repair`. Voxel repair can change genus, close intentional +openings, and remove features thinner than a few voxels. The output report +records the selected path and topology metrics. + +### Multi-million-face scan + +Atlas-Q area/spatial/component-samples the full source and builds a bounded +native QEM proxy. Connectivity gates are exact on that proxy, while the result +is prominently marked `PROXY_PRESERVED_SOURCE_UNVERIFIED`. The original remains +the projection/texture reference. Do not blindly split the model into +independent remeshing tiles: unrelated fields and singularities can create +visible seams. + +## Reading The Result + +- `Local Quad Manifold Valid`: mandatory for an exact-quad delivery. This + checks vertex fans, boundary degree, winding, duplicates, and local quad + geometry; it is not a global self-intersection proof. +- `Topology Proof`: distinguishes exact source preservation from exact proxy + preservation and unverified full-source topology. +- `p95 Error / Diagonal`: symmetric sampled geometric deviation. Compare assets + at the same unit scale and target count. +- `Max Sample Error / Diagonal`: catches isolated omitted pieces that averages + and p95 can hide. Small connected source components receive sample priority + within the documented two-million-vertex connectivity budget. +- `Min Scaled Jacobian`: must stay positive; values nearer one are squarer. +- `p95 Aspect Ratio`: identifies stretched cells even when average quality looks + good. Both are world-space metrics, so unapplied non-uniform scale is included. +- `Extraordinary Verts`: poles are necessary on most topologies; look for + clustering in visible or deforming areas. +- `Boundary Loops`, `Connected Components`, and `Euler Characteristic`: detect + unintended topology change against the exact source or reduced proxy. +- `Target Error`: a large value means the requested count conflicted with the + solver/closure. Raise the target or use a flow strategy. +- `curiomesh_vertex_fan_splits`: the number of coordinate-preserving + vertex-only bow-tie splits applied after proxy reduction. A nonzero value is + expected on some scan fragments; edge-non-manifold input is still rejected. +- `curiomesh_projection_status`: `ROLLED_BACK_FAILED_GATES` means shrinkwrap + folded or otherwise invalidated the candidate, so the validated + pre-projection topology was retained instead. + +## If Atlas-Q Rejects Every Candidate + +1. Read the result message and candidate JSON custom property. +2. Raise target faces; extremely coarse integer layouts can be invalid. +3. For an intentional open mesh, force `Open Fragment` + `Preserve`. +4. For a truly dirty closed mesh, use `Dirty Closed Scan` + `Repair`. +5. Use PairClosure for an exact-quad fallback and accept its pole/count tradeoff. +6. If semantic loops matter, add manual guide topology after automatic + retopology. Geometry-only automation cannot know joints, eyelids, handles, or + archaeological interpretation. + +CurioMesh 0.4 can bake base color. Use a dedicated baker for normal and +displacement maps after approving the final topology and UVs. diff --git a/metrics.py b/metrics.py index 2f69092..fb4738c 100644 --- a/metrics.py +++ b/metrics.py @@ -1,10 +1,13 @@ from __future__ import annotations -from collections import Counter from dataclasses import dataclass import math import bpy +from mathutils import Vector +import numpy as np + +from .triadq import MeshData, topology_summary @dataclass(slots=True) @@ -18,10 +21,133 @@ class MeshDiagnostics: extraordinary: int = 0 extraordinary_ratio: float = 0.0 non_manifold_edges: int = 0 + non_manifold_vertices: int = 0 + branched_boundary_vertices: int = 0 + inconsistent_edges: int = 0 + duplicate_faces: int = 0 boundary_edges: int = 0 + boundary_loops: int = 0 + connected_components: int = 0 + component_euler: tuple[int, ...] = () + component_signature: tuple[tuple[int, int], ...] = () + euler_characteristic: int = 0 + degenerate_faces: int = 0 + invalid_quads: int = 0 + min_scaled_jacobian: float = 0.0 + p05_scaled_jacobian: float = 0.0 + p95_aspect_ratio: float = 0.0 + quad_only: bool = False uv_valid: bool = False +def _percentile(values: list[float], fraction: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + position = max(0.0, min(1.0, fraction)) * (len(ordered) - 1) + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return ordered[lower] + weight = position - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def _quad_quality(obj: bpy.types.Object, max_percentile_samples: int = 200_000) -> tuple[int, float, float, float]: + mesh = obj.data + if not mesh.vertices: + return 0, 0.0, 0.0, 0.0 + bounds = [obj.matrix_world @ Vector(corner) for corner in (getattr(obj, "bound_box", ()) or ())] + if bounds: + xs = [corner[0] for corner in bounds] + ys = [corner[1] for corner in bounds] + zs = [corner[2] for corner in bounds] + diagonal_squared = ( + (max(xs) - min(xs)) ** 2 + + (max(ys) - min(ys)) ** 2 + + (max(zs) - min(zs)) ** 2 + ) + else: + diagonal_squared = 0.0 + area_epsilon = max(diagonal_squared * 1e-14, 1e-20) + jacobians: list[float] = [] + aspects: list[float] = [] + degenerate = 0 + quad_count = sum(1 for poly in mesh.polygons if len(poly.vertices) == 4) + corner_stride = max(1, math.ceil(max(1, quad_count * 4) / max(1, max_percentile_samples))) + aspect_stride = max(1, math.ceil(max(1, quad_count) / max(1, max_percentile_samples))) + corner_number = 0 + quad_number = 0 + minimum_jacobian = float("inf") + matrix = obj.matrix_world + + for poly in mesh.polygons: + coords = [matrix @ mesh.vertices[index].co for index in poly.vertices] + origin = coords[0] if coords else None + world_area = ( + 0.5 + * sum( + (coords[corner] - origin).cross(coords[corner + 1] - origin).length + for corner in range(1, len(coords) - 1) + ) + if origin is not None + else 0.0 + ) + poly_degenerate = ( + not math.isfinite(world_area) + or world_area <= area_epsilon + or len(set(poly.vertices)) != len(poly.vertices) + ) + if poly_degenerate: + degenerate += 1 + if len(poly.vertices) != 4: + continue + lengths = [(coords[(index + 1) % 4] - coords[index]).length for index in range(4)] + shortest = min(lengths) + if shortest <= 1e-12: + if not poly_degenerate: + degenerate += 1 + minimum_jacobian = -1.0 + if corner_number % corner_stride == 0: + jacobians.append(-1.0) + corner_number += 4 + quad_number += 1 + continue + aspect = max(lengths) / shortest + if quad_number % aspect_stride == 0: + aspects.append(aspect) + reference_normal = Vector((0.0, 0.0, 0.0)) + center = sum(coords, Vector((0.0, 0.0, 0.0))) / 4.0 + for index in range(4): + reference_normal += (coords[index] - center).cross(coords[(index + 1) % 4] - center) + if reference_normal.length <= 1e-20: + reference_normal = Vector((0.0, 0.0, 0.0)) + else: + reference_normal.normalize() + for index in range(4): + current = coords[index] + following = coords[(index + 1) % 4] - current + previous = coords[(index - 1) % 4] - current + denominator = following.length * previous.length + if denominator <= 1e-20: + jacobian = -1.0 + else: + jacobian = following.cross(previous).dot(reference_normal) / denominator + minimum_jacobian = min(minimum_jacobian, jacobian) + if corner_number % corner_stride == 0: + jacobians.append(jacobian) + corner_number += 1 + quad_number += 1 + + finite_aspects = [value for value in aspects if math.isfinite(value)] + return ( + degenerate, + minimum_jacobian if math.isfinite(minimum_jacobian) else 0.0, + _percentile(jacobians, 0.05), + _percentile(finite_aspects, 0.95) if finite_aspects else 0.0, + ) + + def mesh_diagnostics(obj: bpy.types.Object, target_faces: int = 0) -> MeshDiagnostics: mesh = obj.data face_count = len(mesh.polygons) @@ -29,20 +155,15 @@ def mesh_diagnostics(obj: bpy.types.Object, target_faces: int = 0) -> MeshDiagno tris = sum(1 for poly in mesh.polygons if len(poly.vertices) == 3) ngons = max(0, face_count - quads - tris) - valence = [0] * len(mesh.vertices) - for edge in mesh.edges: - a, b = edge.vertices - valence[a] += 1 - valence[b] += 1 - - extraordinary = sum(1 for count in valence if count != 4) - edge_use = Counter() - for poly in mesh.polygons: - for key in poly.edge_keys: - edge_use[tuple(sorted(key))] += 1 - - boundary_edges = sum(1 for count in edge_use.values() if count == 1) - non_manifold_edges = sum(1 for count in edge_use.values() if count != 2) + coordinates = np.empty(len(mesh.vertices) * 3, dtype=np.float64) + if len(mesh.vertices): + mesh.vertices.foreach_get("co", coordinates) + coordinates = coordinates.reshape((-1, 3)) + world_matrix = np.asarray(obj.matrix_world, dtype=np.float64) + coordinates = coordinates @ world_matrix[:3, :3].T + world_matrix[:3, 3] + faces = [tuple(poly.vertices) for poly in mesh.polygons] + topology = topology_summary(MeshData(vertices=coordinates, faces=faces)) + degenerate, min_jacobian, p05_jacobian, p95_aspect = _quad_quality(obj) face_error = 0.0 if target_faces > 0: face_error = abs(face_count - int(target_faces)) / float(max(1, int(target_faces))) @@ -54,10 +175,32 @@ def mesh_diagnostics(obj: bpy.types.Object, target_faces: int = 0) -> MeshDiagno ngons=ngons, quad_ratio=(quads / float(face_count)) if face_count else 0.0, face_count_error=face_error, - extraordinary=extraordinary, - extraordinary_ratio=(extraordinary / float(len(mesh.vertices))) if mesh.vertices else 0.0, - non_manifold_edges=non_manifold_edges, - boundary_edges=boundary_edges, + extraordinary=topology.extraordinary_vertices, + extraordinary_ratio=( + topology.extraordinary_vertices / float(topology.used_vertices) + if topology.used_vertices + else 0.0 + ), + non_manifold_edges=topology.non_manifold_edges, + non_manifold_vertices=topology.non_manifold_vertices, + branched_boundary_vertices=topology.branched_boundary_vertices, + inconsistent_edges=topology.inconsistent_edges, + duplicate_faces=topology.duplicate_faces, + boundary_edges=topology.boundary_edges, + boundary_loops=topology.boundary_components, + connected_components=topology.connected_components, + component_euler=topology.component_euler, + component_signature=topology.component_signature, + euler_characteristic=topology.euler_characteristic, + degenerate_faces=degenerate, + invalid_quads=topology.invalid_quads, + min_scaled_jacobian=min_jacobian, + p05_scaled_jacobian=p05_jacobian, + p95_aspect_ratio=p95_aspect, + quad_only=( + topology.all_quads + and min_jacobian > 0.0 + ), uv_valid=uv_mapping_is_valid(obj), ) diff --git a/operators.py b/operators.py index 609298d..9c63bac 100644 --- a/operators.py +++ b/operators.py @@ -2,6 +2,7 @@ import bpy +from .atlasq import analyze_object from .bridge import RemeshConfig, RemeshResult, run_curiomesh from .metrics import mesh_diagnostics @@ -13,9 +14,18 @@ ] ENGINE_ITEMS = [ + ( + "ATLAS_Q", + "Atlas-Q Auto (Recommended)", + "Analyze the scan, build a target-scale proxy, compare solver candidates, and require validated quads", + ), ("QUADRIFLOW", "QuadriFlow", "Production path using Blender's built-in QuadriFlow remesher"), - ("TRIAD_Q_LITE", "TRIAD-Q Lite", "Experimental standalone CurioMesh quad-dominant engine"), - ("AUTO", "Auto", "Use the strongest available production engine"), + ( + "TRIAD_Q_LITE", + "PairClosure", + "Feature-aware pairing, safe vertex-fan normalization, and conforming exact all-quad closure", + ), + ("AUTO", "Auto (v0.3 compatibility)", "Alias for Atlas-Q Auto"), ] FLOW_ITEMS = [ @@ -27,6 +37,28 @@ ("TEXTURE", "TextureFlow", "Favor material and UV seam preservation"), ] +ATLAS_STRATEGY_ITEMS = [ + ("AUTO", "Auto", "Choose from measured topology, boundaries, features, scale, and texture state"), + ("FLOW_ENSEMBLE", "Organic / General", "Target-scale QEM proxy plus multiple QuadriFlow candidates"), + ("FEATURE_FLOW", "Mechanical / Feature", "Preserve persistent sharp, seam, and material boundaries"), + ("DIRTY_FLOW", "Dirty Closed Scan", "Permit voxel repair before field-aligned remeshing"), + ("OPEN_FLOW", "Open Fragment", "Preserve intentional boundary loops and avoid voxel closure"), + ( + "PAIR_CLOSURE", + "Exact PairClosure", + "Deterministic pure-quad fallback with safe bow-tie fan splitting; robust but creates more poles", + ), +] + +TOPOLOGY_ITEMS = [ + ( + "PRESERVE", + "Preserve", + "Reject changes to Euler characteristic, boundary cycles, component count, and open/closed state", + ), + ("REPAIR", "Repair", "Allow reconstruction of dirty closed scans; holes and thin sheets may change"), +] + TEXTURE_ITEMS = [ ("PROJECT", "Project UVs", "Transfer UVs, then use BVH projection if needed"), ("TRANSFER", "Transfer Only", "Use Blender data transfer only"), @@ -50,6 +82,11 @@ def config_from_owner(owner: object) -> RemeshConfig: triad_feature_angle=float(owner.triad_feature_angle), triad_force_quads=bool(owner.triad_force_quads), triad_flow_mode=str(owner.triad_flow_mode), + atlas_strategy=str(owner.atlas_strategy), + atlas_candidate_count=int(owner.atlas_candidate_count), + topology_policy=str(owner.topology_policy), + require_quads=bool(owner.require_quads), + analysis_sample_faces=int(owner.analysis_sample_faces), preserve_sharp=bool(owner.preserve_sharp), preserve_boundary=bool(owner.preserve_boundary), preserve_seams=bool(owner.preserve_seams), @@ -81,11 +118,30 @@ def apply_result_to_settings(settings: bpy.types.PropertyGroup, result: RemeshRe settings.metrics_extraordinary = int(diagnostics.extraordinary) settings.metrics_extraordinary_ratio = float(diagnostics.extraordinary_ratio) settings.metrics_non_manifold_edges = int(diagnostics.non_manifold_edges) + settings.metrics_non_manifold_vertices = int(diagnostics.non_manifold_vertices) + settings.metrics_duplicate_faces = int(diagnostics.duplicate_faces) + settings.metrics_invalid_quads = int(diagnostics.invalid_quads) + settings.metrics_components = int(diagnostics.connected_components) + settings.metrics_component_euler = ", ".join(str(value) for value in diagnostics.component_euler) settings.metrics_boundary_edges = int(diagnostics.boundary_edges) + settings.metrics_boundary_loops = int(diagnostics.boundary_loops) + settings.metrics_euler = int(diagnostics.euler_characteristic) + settings.metrics_degenerate = int(diagnostics.degenerate_faces) + settings.metrics_min_jacobian = float(diagnostics.min_scaled_jacobian) + settings.metrics_p95_aspect = float(diagnostics.p95_aspect_ratio) + settings.metrics_quad_only = bool(result.quad_only_verified) settings.metrics_uv_valid = bool(diagnostics.uv_valid) settings.metrics_materials_preserved = bool(result.materials_preserved) settings.metrics_elapsed_ms = float(result.elapsed_ms) settings.metrics_engine = str(result.engine) + settings.metrics_strategy = str(result.strategy) + settings.metrics_profile = str(result.profile) + settings.metrics_candidates = int(result.candidate_count) + settings.metrics_geometry_rms = float(result.geometry_rms) + settings.metrics_geometry_p95 = float(result.geometry_p95) + settings.metrics_geometry_max = float(result.geometry_max) + settings.metrics_sampling_status = str(result.sampling_status) + settings.metrics_topology_status = str(result.topology_status) settings.metrics_status = "OK" if result.success else "FAILED" settings.metrics_uv_status = str(result.uv_status) settings.metrics_message = str(result.message) @@ -94,17 +150,27 @@ def apply_result_to_settings(settings: bpy.types.PropertyGroup, result: RemeshRe class CURIOMESH_OT_remesh(bpy.types.Operator): bl_idname = "curiomesh.remesh" bl_label = "CurioMesh Remesh" - bl_description = "Run CurioMesh's QuadriFlow retopology pipeline" + bl_description = "Run the Atlas-Q scan-aware quad retopology pipeline" bl_options = {"REGISTER", "UNDO"} target_faces: bpy.props.IntProperty(name="Target Faces", default=8000, min=4, soft_max=250000) - engine: bpy.props.EnumProperty(name="Engine", items=ENGINE_ITEMS, default="QUADRIFLOW") + engine: bpy.props.EnumProperty(name="Engine", items=ENGINE_ITEMS, default="ATLAS_Q") quality: bpy.props.EnumProperty(name="Quality", items=QUALITY_ITEMS, default="BALANCED") seed: bpy.props.IntProperty(name="Seed", default=0, min=0, soft_max=100000) triad_seed_count: bpy.props.IntProperty(name="TRIAD-Q Seeds", default=6, min=1, max=32) triad_feature_angle: bpy.props.FloatProperty(name="TRIAD-Q Feature Angle", default=35.0, min=0.0, max=180.0) triad_force_quads: bpy.props.BoolProperty(name="TRIAD-Q Pure Quads", default=False) triad_flow_mode: bpy.props.EnumProperty(name="TRIAD-Q Flow", items=FLOW_ITEMS, default="AUTO") + atlas_strategy: bpy.props.EnumProperty(name="Scan Strategy", items=ATLAS_STRATEGY_ITEMS, default="AUTO") + atlas_candidate_count: bpy.props.IntProperty(name="Candidate Budget", default=3, min=1, max=8) + topology_policy: bpy.props.EnumProperty(name="Topology", items=TOPOLOGY_ITEMS, default="PRESERVE") + require_quads: bpy.props.BoolProperty(name="Require Locally Valid Quads", default=True) + analysis_sample_faces: bpy.props.IntProperty( + name="Analysis Sample Faces", + default=120000, + min=1000, + max=500000, + ) preserve_sharp: bpy.props.BoolProperty(name="Preserve Sharp", default=True) preserve_boundary: bpy.props.BoolProperty(name="Preserve Boundary", default=True) preserve_seams: bpy.props.BoolProperty(name="Treat UV Seams As Sharp", default=True) @@ -139,10 +205,7 @@ def execute(self, context: bpy.types.Context): self.report( {"INFO"}, - ( - f"CurioMesh: {result.diagnostics.faces} faces, " - f"{result.diagnostics.quad_ratio:.1%} quads, UV {result.uv_status.lower()}" - ), + result.message, ) return {"FINISHED"} @@ -166,6 +229,7 @@ def execute(self, context: bpy.types.Context): settings.texture_mode = "TRANSFER" settings.bake_fallback = False settings.smooth_normals = True + settings.atlas_candidate_count = 1 elif preset == "HERO": settings.cleanup_strength = 0.35 settings.shrinkwrap_project = True @@ -175,6 +239,7 @@ def execute(self, context: bpy.types.Context): settings.preserve_boundary = True settings.preserve_attributes = True settings.smooth_normals = True + settings.atlas_candidate_count = 5 else: settings.cleanup_strength = 0.25 settings.shrinkwrap_project = True @@ -184,10 +249,43 @@ def execute(self, context: bpy.types.Context): settings.preserve_boundary = True settings.preserve_attributes = True settings.smooth_normals = True + settings.atlas_candidate_count = 3 return {"FINISHED"} +class CURIOMESH_OT_analyze(bpy.types.Operator): + bl_idname = "curiomesh.analyze" + bl_label = "Analyze Scan" + bl_description = "Inspect topology and target-scale evidence without changing the source mesh" + bl_options = {"INTERNAL"} + + def execute(self, context: bpy.types.Context): + obj = context.object + if obj is None or obj.type != "MESH": + self.report({"ERROR"}, "Select a mesh object.") + return {"CANCELLED"} + settings = context.scene.curiomesh_settings + profile = analyze_object( + obj, + settings.target_faces, + feature_angle_deg=settings.triad_feature_angle, + max_sample_faces=settings.analysis_sample_faces, + ) + settings.analysis_profile = profile.category + settings.analysis_strategy = profile.recommended_strategy + settings.analysis_faces = profile.face_count + settings.analysis_sampled_faces = profile.sampled_faces + settings.analysis_exact = profile.exact_topology + settings.analysis_boundary_loops = profile.boundary_loops + settings.analysis_non_manifold = profile.non_manifold_edges + settings.analysis_sharp_ratio = profile.sharp_ratio + settings.analysis_target_edge = profile.target_edge_length + settings.analysis_warning = " | ".join(profile.warnings) + self.report({"INFO"}, profile.summary) + return {"FINISHED"} + + class CURIOMESH_OT_compute_metrics(bpy.types.Operator): bl_idname = "curiomesh.compute_metrics" bl_label = "Compute CurioMesh Metrics" @@ -210,6 +308,7 @@ def execute(self, context: bpy.types.Context): message="Metrics computed.", materials_preserved=bool(obj.data.materials), uv_status="VALID" if diagnostics.uv_valid else "INVALID", + quad_only_verified=diagnostics.quad_only, ) apply_result_to_settings(settings, result) return {"FINISHED"} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dbc3875 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "curiomesh-triadq" +version = "0.4.0" +description = "Standalone TRIAD-Q PairClosure engine from the CurioMesh Blender add-on" +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +authors = [{ name = "CurioCrafter" }] +dependencies = ["numpy>=1.23"] + +[project.urls] +Repository = "https://github.com/CurioCrafter/CurioMesh" + +[project.scripts] +triadq = "triadq.__main__:main" + +[tool.setuptools] +packages = ["triadq"] + +[tool.ruff] +line-length = 120 +target-version = "py310" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..c15e998 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""CurioMesh development and release helpers.""" diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..fae2571 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path, PurePosixPath +import tomllib +import zipfile + + +PACKAGE_NAME = "curiomesh" +ROOT_FILES = { + "__init__.py", + "atlasq.py", + "bridge.py", + "metrics.py", + "operators.py", + "textures.py", + "ui.py", + "blender_manifest.toml", + "LICENSE", + "README.md", + "CHANGELOG.md", +} + + +def _bl_info_version(source: str, *, origin: str) -> str: + tree = ast.parse(source, filename=origin) + for statement in tree.body: + if not isinstance(statement, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == "bl_info" for target in statement.targets): + continue + bl_info = ast.literal_eval(statement.value) + version = bl_info.get("version") if isinstance(bl_info, dict) else None + if not isinstance(version, tuple) or not version or not all(isinstance(part, int) for part in version): + raise ValueError(f"{origin}: bl_info.version must be a non-empty integer tuple") + return ".".join(str(part) for part in version) + raise ValueError(f"{origin}: bl_info assignment was not found") + + +def _validated_metadata(manifest_source: str, init_source: str, *, origin: str) -> tuple[str, str]: + manifest = tomllib.loads(manifest_source) + package_id = manifest.get("id") + version = manifest.get("version") + if package_id != PACKAGE_NAME: + raise ValueError(f"{origin}: manifest id must be {PACKAGE_NAME!r}, got {package_id!r}") + if manifest.get("type") != "add-on": + raise ValueError(f"{origin}: manifest type must be 'add-on'") + if not isinstance(version, str) or not version: + raise ValueError(f"{origin}: manifest version must be a non-empty string") + bl_info_version = _bl_info_version(init_source, origin=f"{origin}/__init__.py") + if bl_info_version != version: + raise ValueError( + f"{origin}: blender_manifest.toml version {version!r} does not match bl_info {bl_info_version!r}" + ) + return package_id, version + + +def release_metadata(root: Path) -> tuple[str, str]: + package_id, version = _validated_metadata( + (root / "blender_manifest.toml").read_text(encoding="utf-8"), + (root / "__init__.py").read_text(encoding="utf-8"), + origin=str(root), + ) + project_file = root / "pyproject.toml" + if project_file.is_file(): + project_version = tomllib.loads(project_file.read_text(encoding="utf-8")).get("project", {}).get("version") + if project_version != version: + raise ValueError( + f"{project_file}: project version {project_version!r} does not match add-on version {version!r}" + ) + return package_id, version + + +def source_files(root: Path) -> list[Path]: + files = [root / name for name in sorted(ROOT_FILES)] + files.extend(sorted(path for path in (root / "triadq").rglob("*.py") if path.is_file())) + files.extend(sorted(path for path in (root / "docs").rglob("*.md") if path.is_file())) + missing = [str(path) for path in files if not path.is_file()] + if missing: + raise FileNotFoundError(f"Release inputs missing: {missing}") + return files + + +def _write_archive(root: Path, output: Path, *, prefix: str) -> None: + files = source_files(root) + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path in files: + relative = PurePosixPath(path.relative_to(root).as_posix()) + archive.write(path, str(PurePosixPath(prefix) / relative) if prefix else str(relative)) + verify_archive(output, prefix=prefix) + + +def verify_archive(path: Path, *, prefix: str) -> None: + with zipfile.ZipFile(path) as archive: + entries = archive.namelist() + required_prefix = f"{prefix}/" if prefix else "" + package_id, version = _validated_metadata( + archive.read(f"{required_prefix}blender_manifest.toml").decode("utf-8"), + archive.read(f"{required_prefix}__init__.py").decode("utf-8"), + origin=str(path), + ) + if len(entries) != len(set(entries)): + raise ValueError(f"Archive contains duplicate entries: {path}") + unsafe = [ + entry + for entry in entries + if PurePosixPath(entry).is_absolute() or ".." in PurePosixPath(entry).parts or "\\" in entry + ] + if unsafe: + raise ValueError(f"Unsafe archive entries: {unsafe}") + forbidden = ("__pycache__", ".pyc", ".pyo", ".git/", ".tools/", "dist/") + bad = [entry for entry in entries if any(token in entry for token in forbidden)] + if bad: + raise ValueError(f"Forbidden archive entries: {bad}") + required = { + f"{required_prefix}__init__.py", + f"{required_prefix}blender_manifest.toml", + f"{required_prefix}triadq/engine.py", + f"{required_prefix}triadq/topology.py", + } + missing = required - set(entries) + if missing: + raise ValueError(f"Archive is incomplete: {sorted(missing)}") + if prefix and any(not entry.startswith(required_prefix) for entry in entries): + raise ValueError(f"Legacy archive entries must all be rooted at {required_prefix!r}") + layout = "addon" if prefix else "extension" + expected_name = f"{package_id}-{version}-{layout}.zip" + if path.name != expected_name: + raise ValueError(f"Archive filename must be {expected_name!r}, got {path.name!r}") + + +def build_archives(root: Path, output_dir: Path) -> tuple[Path, Path]: + package_name, version = release_metadata(root) + legacy = output_dir / f"{package_name}-{version}-addon.zip" + extension = output_dir / f"{package_name}-{version}-extension.zip" + _write_archive(root, legacy, prefix=package_name) + _write_archive(root, extension, prefix="") + return legacy, extension + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build validated CurioMesh release archives.") + parser.add_argument("--output", type=Path, default=None, help="Output directory (default: repository dist)") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + output = args.output.resolve() if args.output else root / "dist" + for archive in build_archives(root, output): + print(archive) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_slashfn_batch.py b/scripts/validate_slashfn_batch.py new file mode 100644 index 0000000..943bce5 --- /dev/null +++ b/scripts/validate_slashfn_batch.py @@ -0,0 +1,942 @@ +"""Independent, deterministic validation for the SlashFn Atlas-Q batch. + +Run this script with the same Blender release used to save the production file. +The baseline pass fingerprints the untouched SlashFn source meshes. The final +pass rechecks those fingerprints, then validates every retopology output using +CurioMesh's production diagnostics rather than trusting batch-report claims. + +Examples:: + + blender pristine.blend --background --python-exit-code 1 \ + --python scripts/validate_slashfn_batch.py -- \ + --mode baseline --report artifacts/slashfn-baseline.json + + blender finished.blend --background --python-exit-code 1 \ + --python scripts/validate_slashfn_batch.py -- \ + --mode final --baseline-report artifacts/slashfn-baseline.json \ + --report artifacts/slashfn-final-validation.json +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict +from datetime import datetime, timezone +import hashlib +import importlib +import importlib.util +import json +from pathlib import Path +import struct +import sys +import traceback +from typing import Any + +import bpy +import numpy as np + + +DEFAULT_SOURCE_COLLECTION = "SlashFn" +DEFAULT_OUTPUT_COLLECTION = "SlashFn_AtlasQ_Retopology" +DEFAULT_WORK_COLLECTION = "_CurioMesh_SlashFn_Component_Work" +DEFAULT_EXPECTED_SOURCES = 22 +SCHEMA_VERSION = 1 + + +def parse_args() -> argparse.Namespace: + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + parser = argparse.ArgumentParser( + description="Fingerprint SlashFn sources or independently validate an Atlas-Q batch." + ) + parser.add_argument("--mode", choices=("baseline", "final"), required=True) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--baseline-report", type=Path) + parser.add_argument("--source-collection", default=DEFAULT_SOURCE_COLLECTION) + parser.add_argument("--output-collection", default=DEFAULT_OUTPUT_COLLECTION) + parser.add_argument("--work-collection", default=DEFAULT_WORK_COLLECTION) + parser.add_argument("--expected-sources", type=int, default=DEFAULT_EXPECTED_SOURCES) + parser.add_argument( + "--max-output-quads", + type=int, + default=999, + help="Maximum allowed quad faces per output; may not exceed 999.", + ) + parser.add_argument( + "--expected-texture-resolution", + type=int, + default=512, + help="Required square packed base-color texture resolution; must be below 1000.", + ) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="CurioMesh package root containing __init__.py.", + ) + args = parser.parse_args(argv) + if args.mode == "final" and args.baseline_report is None: + parser.error("--baseline-report is required in final mode") + if args.expected_sources <= 0: + parser.error("--expected-sources must be positive") + if not 1 <= args.max_output_quads <= 999: + parser.error("--max-output-quads must be between 1 and 999") + if not 1 <= args.expected_texture_resolution < 1000: + parser.error("--expected-texture-resolution must be between 1 and 999") + return args + + +def canonical_json(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def sha256_array(values: np.ndarray, dtype: str) -> str: + """Hash an array with an explicit little-endian dtype and shape header.""" + + array = np.ascontiguousarray(np.asarray(values, dtype=np.dtype(dtype))) + header = canonical_json({"dtype": array.dtype.str, "shape": list(array.shape)}) + digest = hashlib.sha256() + digest.update(struct.pack(" bpy.types.Collection | None: + direct = bpy.data.collections.get(name) + if direct is not None: + return direct + folded = name.casefold() + matches = [item for item in bpy.data.collections if item.name.casefold() == folded] + if len(matches) == 1: + return matches[0] + return None + + +def recursive_objects(collection: bpy.types.Collection) -> list[bpy.types.Object]: + found: dict[int, bpy.types.Object] = {} + + def visit(current: bpy.types.Collection) -> None: + for obj in current.objects: + found[obj.as_pointer()] = obj + for child in current.children: + visit(child) + + visit(collection) + return sorted(found.values(), key=lambda obj: (obj.name.casefold(), obj.name)) + + +def recursive_meshes(collection: bpy.types.Collection) -> list[bpy.types.Object]: + return [ + obj + for obj in recursive_objects(collection) + if obj.type == "MESH" and obj.data is not None and len(obj.data.polygons) > 0 + ] + + +def foreach_array( + data: Any, + attribute: str, + count: int, + width: int, + dtype: str, +) -> np.ndarray: + values = np.empty(count * width, dtype=np.dtype(dtype)) + if values.size: + data.foreach_get(attribute, values) + return values.reshape((count, width)) if width > 1 else values + + +def uv_layer_record(mesh: bpy.types.Mesh, layer: Any, index: int) -> dict[str, Any]: + count = len(layer.data) + try: + values = foreach_array(layer.data, "uv", count, 2, " int | None: + return { + "POINT": len(mesh.vertices), + "EDGE": len(mesh.edges), + "FACE": len(mesh.polygons), + "CORNER": len(mesh.loops), + }.get(domain) + + +def color_layer_record(mesh: bpy.types.Mesh, attribute: Any, index: int) -> dict[str, Any]: + count = len(attribute.data) + try: + values = foreach_array(attribute.data, "color", count, 4, " dict[str, Any]: + """Return stable field digests for one raw Blender mesh object.""" + + mesh = obj.data + vertices = foreach_array(mesh.vertices, "co", len(mesh.vertices), 3, " dict[str, Any]: + meshes = recursive_meshes(collection) + records = [source_mesh_record(obj) for obj in meshes] + aggregate = { + "objects": len(records), + "vertices": sum(item["vertices"] for item in records), + "edges": sum(item["edges"] for item in records), + "loops": sum(item["loops"] for item in records), + "polygons": sum(item["polygons"] for item in records), + } + aggregate["manifest_sha256"] = sha256_bytes( + canonical_json([(item["object"], item["content_sha256"]) for item in records]) + ) + return {"aggregate": aggregate, "objects": records} + + +def named_records(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {record["object"]: record for record in records} + + +def compare_source_manifest( + baseline: dict[str, Any], + current: dict[str, Any], +) -> list[str]: + errors: list[str] = [] + baseline_records = named_records(baseline.get("objects", [])) + current_records = named_records(current.get("objects", [])) + baseline_names = set(baseline_records) + current_names = set(current_records) + if baseline_names != current_names: + errors.append( + "Source object names differ from baseline: " + f"missing={sorted(baseline_names - current_names)}, " + f"unexpected={sorted(current_names - baseline_names)}" + ) + for name in sorted(baseline_names & current_names, key=str.casefold): + expected = baseline_records[name] + actual = current_records[name] + if expected.get("content_sha256") == actual.get("content_sha256"): + continue + differing_fields = [] + expected_fingerprints = expected.get("fingerprints", {}) + actual_fingerprints = actual.get("fingerprints", {}) + for field in sorted(set(expected_fingerprints) | set(actual_fingerprints)): + if expected_fingerprints.get(field) != actual_fingerprints.get(field): + differing_fields.append(field) + for field in ("mesh", "vertices", "edges", "loops", "polygons"): + if expected.get(field) != actual.get(field): + differing_fields.append(field) + errors.append( + f"Source {name!r} differs from baseline in: " + + (", ".join(differing_fields) if differing_fields else "stable metadata") + ) + expected_digest = baseline.get("aggregate", {}).get("manifest_sha256") + current_digest = current.get("aggregate", {}).get("manifest_sha256") + if expected_digest != current_digest and not errors: + errors.append("Source manifest digest differs from baseline") + return errors + + +def load_curiomesh(repo_root: Path) -> tuple[Any, Any]: + root = repo_root.resolve() + initializer = root / "__init__.py" + if not initializer.is_file(): + raise RuntimeError(f"CurioMesh package initializer not found: {initializer}") + for module_name in list(sys.modules): + if module_name == "curiomesh" or module_name.startswith("curiomesh."): + del sys.modules[module_name] + spec = importlib.util.spec_from_file_location( + "curiomesh", + initializer, + submodule_search_locations=[str(root)], + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not construct CurioMesh import spec for {root}") + package = importlib.util.module_from_spec(spec) + sys.modules["curiomesh"] = package + spec.loader.exec_module(package) + metrics = importlib.import_module("curiomesh.metrics") + triadq = importlib.import_module("curiomesh.triadq") + return metrics, triadq + + +def normalized_topology(obj: bpy.types.Object, triadq: Any) -> dict[str, Any]: + mesh = obj.data + coordinates = foreach_array(mesh.vertices, "co", len(mesh.vertices), 3, " dict[str, Any]: + mesh = obj.data + active = mesh.uv_layers.active.name if mesh.uv_layers.active is not None else None + return { + "names": sorted(layer.name for layer in mesh.uv_layers), + "active": active, + "layers": [ + { + "name": layer.name, + "values": len(layer.data), + "expected_values": len(mesh.loops), + "finite": uv_layer_record(mesh, layer, index)["finite"], + } + for index, layer in enumerate(mesh.uv_layers) + ], + } + + +def color_structure(obj: bpy.types.Object) -> list[dict[str, Any]]: + mesh = obj.data + return [ + { + "name": attribute.name, + "domain": attribute.domain, + "data_type": attribute.data_type, + "values": len(attribute.data), + "expected_values": color_domain_size(mesh, attribute.domain), + "finite": color_layer_record(mesh, attribute, index)["finite"], + } + for index, attribute in enumerate(mesh.color_attributes) + ] + + +def material_structure(obj: bpy.types.Object) -> dict[str, Any]: + names = [material.name for material in obj.data.materials if material is not None] + invalid_polygons = sum( + 1 + for polygon in obj.data.polygons + if polygon.material_index < 0 + or polygon.material_index >= len(obj.data.materials) + or obj.data.materials[polygon.material_index] is None + ) + return {"names": sorted(set(names)), "invalid_polygon_assignments": invalid_polygons} + + +def id_property_string_list(value: Any) -> list[str]: + """Normalize JSON strings, ID-property arrays, and plain sequences.""" + + decoded = value + if isinstance(value, str): + try: + decoded = json.loads(value) + except json.JSONDecodeError: + decoded = [value] + if isinstance(decoded, str): + return [decoded] + if isinstance(decoded, (list, tuple)): + return [str(item) for item in decoded] + try: + return [str(item) for item in decoded.to_list()] + except (AttributeError, TypeError): + return [] + + +def image_pixel_record(image: bpy.types.Image) -> dict[str, Any]: + width = int(image.size[0]) if len(image.size) >= 1 else 0 + height = int(image.size[1]) if len(image.size) >= 2 else 0 + pixel_count = len(image.pixels) + pixels = np.empty(pixel_count, dtype=" 0 and np.isfinite(pixels).all()) + rgb = pixels.reshape((-1, 4))[:, :3] if pixel_count and pixel_count % 4 == 0 else None + return { + "name": image.name, + "width": width, + "height": height, + "channels": int(image.channels), + "pixel_values": pixel_count, + "expected_rgba_values": width * height * 4, + "packed": image.packed_file is not None, + "finite": finite, + "pixels_sha256": sha256_array(pixels, " 1e-4, axis=1))) if rgb is not None and len(rgb) else 0.0 + ), + "dynamic_range": ( + float(np.max(rgb) - np.min(rgb)) if rgb is not None and len(rgb) else 0.0 + ), + } + + +def validate_base_color_bake( + output: bpy.types.Object, + source_material_names: list[str], + expected_resolution: int, +) -> tuple[dict[str, Any], list[str]]: + errors: list[str] = [] + labeled_nodes: list[tuple[bpy.types.Material, Any]] = [] + seen_materials: set[int] = set() + for material in output.data.materials: + if material is None or material.as_pointer() in seen_materials: + continue + seen_materials.add(material.as_pointer()) + if not material.use_nodes or material.node_tree is None: + continue + for node in material.node_tree.nodes: + if node.type == "TEX_IMAGE" and node.label == "CurioMesh_BaseColor": + labeled_nodes.append((material, node)) + + if len(labeled_nodes) != 1: + errors.append( + "expected exactly one TEX_IMAGE node labeled 'CurioMesh_BaseColor', " + f"found {len(labeled_nodes)}" + ) + + linked_nodes = [] + referenced_images: dict[int, bpy.types.Image] = {} + node_records = [] + for material, node in labeled_nodes: + image = node.image + if image is not None: + referenced_images[image.as_pointer()] = image + linked = any( + link.from_node == node + and link.from_socket.name == "Color" + and link.to_node.type == "BSDF_PRINCIPLED" + and link.to_socket.name == "Base Color" + for link in material.node_tree.links + ) + if linked: + linked_nodes.append(node) + else: + errors.append( + f"base-color node {node.name!r} in material {material.name!r} is not " + "linked directly to a Principled Base Color input" + ) + if image is None: + errors.append( + f"base-color node {node.name!r} in material {material.name!r} has no image" + ) + node_records.append( + { + "material": material.name, + "node": node.name, + "label": node.label, + "image": image.name if image is not None else None, + "linked_to_principled_base_color": linked, + } + ) + + images = list(referenced_images.values()) + if len(images) != 1: + errors.append(f"expected exactly one referenced baked image, found {len(images)}") + image_records = [image_pixel_record(image) for image in images] + for image_record in image_records: + dimensions = (image_record["width"], image_record["height"]) + if dimensions != (expected_resolution, expected_resolution): + errors.append( + f"baked image {image_record['name']!r} is {dimensions[0]}x{dimensions[1]}, " + f"expected {expected_resolution}x{expected_resolution}" + ) + if max(dimensions) >= 1000: + errors.append( + f"baked image {image_record['name']!r} violates the sub-1000 dimension limit" + ) + if not image_record["packed"]: + errors.append(f"baked image {image_record['name']!r} is not packed") + if not image_record["finite"]: + errors.append(f"baked image {image_record['name']!r} has non-finite or empty pixels") + if image_record["pixel_values"] != image_record["expected_rgba_values"]: + errors.append( + f"baked image {image_record['name']!r} does not contain one RGBA value per pixel" + ) + + source_provenance = id_property_string_list( + output.get("curiomesh_bake_source_materials") + ) + image_provenance = id_property_string_list(output.get("curiomesh_bake_images")) + try: + resolution_provenance = int(output.get("curiomesh_bake_resolution", -1)) + except (TypeError, ValueError): + resolution_provenance = -1 + if "Atlantis_400k" not in source_provenance: + errors.append("curiomesh_bake_source_materials does not contain 'Atlantis_400k'") + missing_source_materials = sorted(set(source_material_names) - set(source_provenance)) + if missing_source_materials: + errors.append( + f"bake source-material provenance is missing {missing_source_materials}" + ) + if resolution_provenance != expected_resolution: + errors.append( + f"curiomesh_bake_resolution is {resolution_provenance}, expected {expected_resolution}" + ) + referenced_names = sorted(image.name for image in images) + if sorted(image_provenance) != referenced_names: + errors.append( + f"curiomesh_bake_images {sorted(image_provenance)} does not match " + f"the shader image {referenced_names}" + ) + + record = { + "nodes": node_records, + "images": image_records, + "provenance": { + "source_materials": source_provenance, + "resolution": resolution_provenance, + "images": image_provenance, + }, + "linked_node_count": len(linked_nodes), + "valid": not errors, + "errors": errors, + } + return record, errors + + +def structure_keys(structure: list[dict[str, Any]]) -> list[tuple[str, str, str]]: + return sorted( + (item["name"], item["domain"], item["data_type"]) + for item in structure + ) + + +def validate_one_output( + source: bpy.types.Object, + output: bpy.types.Object, + metrics: Any, + triadq: Any, + max_output_quads: int, + expected_texture_resolution: int, +) -> tuple[dict[str, Any], list[str]]: + errors: list[str] = [] + source_topology = normalized_topology(source, triadq) + diagnostics = metrics.mesh_diagnostics(output, 0) + diagnostic_record = asdict(diagnostics) + diagnostic_record["component_signature"] = [list(item) for item in diagnostics.component_signature] + diagnostic_record["component_euler"] = list(diagnostics.component_euler) + + zero_defect_fields = ( + "non_manifold_edges", + "non_manifold_vertices", + "branched_boundary_vertices", + "inconsistent_edges", + "duplicate_faces", + "degenerate_faces", + "invalid_quads", + ) + nonzero_defects = { + field: int(getattr(diagnostics, field)) + for field in zero_defect_fields + if int(getattr(diagnostics, field)) != 0 + } + if not diagnostics.quad_only: + errors.append("output is not certified quad-only") + if not 1 <= diagnostics.faces <= max_output_quads: + errors.append( + f"output has {diagnostics.faces} quad faces; required range is " + f"1..{max_output_quads}" + ) + if nonzero_defects: + errors.append(f"output has topology/quality defects {nonzero_defects}") + if not diagnostics.uv_valid: + errors.append("output UV mapping is invalid") + expected_signature = source_topology["component_signature"] + actual_signature = [list(item) for item in diagnostics.component_signature] + if actual_signature != expected_signature: + errors.append( + f"normalized component signature mismatch: expected {expected_signature}, " + f"got {actual_signature}" + ) + if source_topology["non_manifold_edges"] or source_topology["non_manifold_vertices"]: + errors.append( + "source vertex-fan normalization did not produce manifold topology: " + f"edges={source_topology['non_manifold_edges']}, " + f"vertices={source_topology['non_manifold_vertices']}" + ) + + mapped_source = output.get("curiomesh_batch_source") + if mapped_source != source.name: + errors.append(f"curiomesh_batch_source is {mapped_source!r}, expected {source.name!r}") + if int(output.get("curiomesh_batch_source_faces", -1)) != len(source.data.polygons): + errors.append("curiomesh_batch_source_faces does not match the source") + if int(output.get("curiomesh_batch_component_count", -1)) != source_topology["connected_components"]: + errors.append("curiomesh_batch_component_count does not match normalized topology") + if output.get("curiomesh_batch_method") != "FACE_EDGE_COMPONENT_ATLAS_Q": + errors.append("curiomesh_batch_method is missing or unexpected") + + source_materials = material_structure(source) + output_materials = material_structure(output) + if "Atlantis_400k" not in source_materials["names"]: + errors.append("source object does not reference the required Atlantis_400k material") + if not output_materials["names"]: + errors.append("output has no material for the baked base color") + if output_materials["invalid_polygon_assignments"]: + errors.append("one or more output polygons have invalid material assignments") + source_material_pointers = { + material.as_pointer() for material in source.data.materials if material is not None + } + output_material_pointers = { + material.as_pointer() for material in output.data.materials if material is not None + } + if source_material_pointers & output_material_pointers: + errors.append("baked output still shares a material datablock with its source") + + bake, bake_errors = validate_base_color_bake( + output, + source_materials["names"], + expected_texture_resolution, + ) + errors.extend(bake_errors) + + source_uv = uv_structure(source) + output_uv = uv_structure(output) + if source_uv["names"] != output_uv["names"]: + errors.append( + f"UV layer names differ: expected {source_uv['names']}, got {output_uv['names']}" + ) + if source_uv["active"] != output_uv["active"]: + errors.append( + f"active UV layer differs: expected {source_uv['active']!r}, got {output_uv['active']!r}" + ) + for layer in output_uv["layers"]: + if layer["values"] != layer["expected_values"] or not layer["finite"]: + errors.append(f"output UV layer {layer['name']!r} has invalid structure or values") + + source_colors = color_structure(source) + output_colors = color_structure(output) + if structure_keys(source_colors) != structure_keys(output_colors): + errors.append( + "color attribute structure differs: " + f"expected {structure_keys(source_colors)}, got {structure_keys(output_colors)}" + ) + for attribute in output_colors: + if attribute["values"] != attribute["expected_values"] or not attribute["finite"]: + errors.append( + f"output color attribute {attribute['name']!r} has invalid structure or values" + ) + + record = { + "source": source.name, + "source_faces": len(source.data.polygons), + "source_normalized_topology": source_topology, + "output": output.name, + "output_faces": len(output.data.polygons), + "source_mapping": mapped_source, + "diagnostics": diagnostic_record, + "materials": output_materials, + "bake": bake, + "uv": output_uv, + "color_attributes": output_colors, + "valid": not errors, + "errors": errors, + } + return record, errors + + +def work_collections(name: str) -> list[str]: + folded = name.casefold() + dotted_prefix = folded + "." + return sorted( + collection.name + for collection in bpy.data.collections + if collection.name.casefold() == folded + or collection.name.casefold().startswith(dotted_prefix) + ) + + +def validate_baseline(args: argparse.Namespace, report: dict[str, Any]) -> list[str]: + errors: list[str] = [] + source_collection = find_collection(args.source_collection) + if source_collection is None: + return [f"Source collection {args.source_collection!r} was not found"] + manifest = source_manifest(source_collection) + report["source"] = manifest + count = manifest["aggregate"]["objects"] + if count != args.expected_sources: + errors.append(f"Expected {args.expected_sources} source meshes, found {count}") + object_names = [item["object"] for item in manifest["objects"]] + if len(object_names) != len(set(object_names)): + errors.append("Source object names are not unique") + if find_collection(args.output_collection) is not None: + errors.append( + f"Baseline is not pristine: output collection {args.output_collection!r} exists" + ) + stale_work = work_collections(args.work_collection) + if stale_work: + errors.append(f"Baseline is not pristine: work collections exist {stale_work}") + return errors + + +def validate_final(args: argparse.Namespace, report: dict[str, Any]) -> list[str]: + errors: list[str] = [] + baseline_path = args.baseline_report.resolve() + with baseline_path.open("r", encoding="utf-8") as handle: + baseline_report = json.load(handle) + report["baseline_report"] = str(baseline_path) + if baseline_report.get("schema_version") != SCHEMA_VERSION: + errors.append("Baseline report schema is unsupported") + if baseline_report.get("mode") != "baseline" or not baseline_report.get("valid"): + errors.append("Baseline report is not a successful baseline validation") + baseline_source = baseline_report.get("source") + if not isinstance(baseline_source, dict): + raise RuntimeError("Baseline report does not contain a source manifest") + + source_collection = find_collection(args.source_collection) + if source_collection is None: + raise RuntimeError(f"Source collection {args.source_collection!r} was not found") + current_source = source_manifest(source_collection) + report["source"] = current_source + if current_source["aggregate"]["objects"] != args.expected_sources: + errors.append( + f"Expected {args.expected_sources} current source meshes, " + f"found {current_source['aggregate']['objects']}" + ) + errors.extend(compare_source_manifest(baseline_source, current_source)) + + stale_work = work_collections(args.work_collection) + report["work_collections"] = stale_work + if stale_work: + errors.append(f"Temporary component work collections remain: {stale_work}") + + output_collection = find_collection(args.output_collection) + if output_collection is None: + raise RuntimeError(f"Output collection {args.output_collection!r} was not found") + output_objects = recursive_objects(output_collection) + output_meshes = recursive_meshes(output_collection) + report["output_object_count"] = len(output_objects) + report["output_mesh_count"] = len(output_meshes) + if len(output_objects) != args.expected_sources: + errors.append( + f"Expected exactly {args.expected_sources} total output objects, found {len(output_objects)}" + ) + if len(output_meshes) != args.expected_sources: + errors.append( + f"Expected exactly {args.expected_sources} non-empty output meshes, found {len(output_meshes)}" + ) + non_mesh_outputs = [obj.name for obj in output_objects if obj not in output_meshes] + if non_mesh_outputs: + errors.append(f"Output collection contains non-mesh or empty objects: {non_mesh_outputs}") + + outputs_by_source: dict[str, list[bpy.types.Object]] = {} + for output in output_meshes: + mapped = output.get("curiomesh_batch_source") + key = str(mapped) if mapped is not None else "" + outputs_by_source.setdefault(key, []).append(output) + sources = {obj.name: obj for obj in recursive_meshes(source_collection)} + for mapped, outputs in sorted(outputs_by_source.items()): + if mapped not in sources: + errors.append( + f"Output mapping {mapped!r} has no source: {[item.name for item in outputs]}" + ) + metrics, triadq = load_curiomesh(args.repo_root) + output_records = [] + for source_name in sorted(sources, key=str.casefold): + matches = outputs_by_source.get(source_name, []) + if len(matches) != 1: + errors.append( + f"Source {source_name!r} has {len(matches)} mapped outputs; expected exactly one" + ) + continue + output = matches[0] + if output.as_pointer() == sources[source_name].as_pointer(): + errors.append(f"Output for {source_name!r} is the source object itself") + continue + record, object_errors = validate_one_output( + sources[source_name], + output, + metrics, + triadq, + args.max_output_quads, + args.expected_texture_resolution, + ) + output_records.append(record) + errors.extend(f"{source_name}: {message}" for message in object_errors) + report["outputs"] = output_records + + image_owners: dict[str, list[str]] = {} + for record in output_records: + for image_record in record.get("bake", {}).get("images", []): + image_owners.setdefault(image_record["name"], []).append(record["source"]) + shared_bakes = { + image_name: owners + for image_name, owners in sorted(image_owners.items()) + if len(owners) != 1 + } + report["baked_image_owners"] = image_owners + if shared_bakes: + errors.append(f"Baked base-color images are shared across outputs: {shared_bakes}") + + source_faces = current_source["aggregate"]["polygons"] + output_faces = sum(item["output_faces"] for item in output_records) + report["aggregate"] = { + "source_faces": source_faces, + "output_faces": output_faces, + "face_reduction": source_faces - output_faces, + "output_to_source_ratio": output_faces / max(1, source_faces), + } + if len(output_records) == args.expected_sources and output_faces >= source_faces: + errors.append( + f"Aggregate output faces ({output_faces}) are not lower than source faces ({source_faces})" + ) + return errors + + +def write_report(path: Path, report: dict[str, Any]) -> None: + path = path.resolve() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n", + encoding="utf-8", + ) + + +def main() -> None: + args = parse_args() + report: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "mode": args.mode, + "generated_utc": datetime.now(timezone.utc).isoformat(), + "blender_version": bpy.app.version_string, + "blend_file": bpy.data.filepath, + "source_collection": args.source_collection, + "output_collection": args.output_collection, + "expected_sources": args.expected_sources, + "validation_contract": { + "max_output_quads": args.max_output_quads, + "expected_texture_resolution": args.expected_texture_resolution, + "maximum_texture_dimension_exclusive": 1000, + "required_base_color_node_label": "CurioMesh_BaseColor", + "required_source_material": "Atlantis_400k", + }, + "valid": False, + "errors": [], + } + caught: Exception | None = None + try: + if args.mode == "baseline": + errors = validate_baseline(args, report) + else: + errors = validate_final(args, report) + report["errors"] = errors + report["valid"] = not errors + except Exception as exc: + caught = exc + report["errors"] = [f"{type(exc).__name__}: {exc}"] + report["traceback"] = traceback.format_exc() + report["valid"] = False + write_report(args.report, report) + print( + f"CURIO_SLASHFN_VALIDATION mode={args.mode} valid={report['valid']} " + f"report={args.report.resolve()}" + ) + if not report["valid"]: + detail = "; ".join(report["errors"][:10]) + if caught is not None: + raise RuntimeError(f"SlashFn validation failed: {detail}") from caught + raise RuntimeError(f"SlashFn validation failed: {detail}") + + +if __name__ == "__main__": + main() diff --git a/tests/blender_package_smoke.py b/tests/blender_package_smoke.py new file mode 100644 index 0000000..e67b556 --- /dev/null +++ b/tests/blender_package_smoke.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import addon_utils +import argparse +import ast +import importlib +import os +from pathlib import Path, PurePosixPath +import sys +import tomllib +import traceback +import zipfile + +import bpy + + +def arguments_after_separator() -> argparse.Namespace: + if "--" not in sys.argv: + raise RuntimeError("Pass --layout and the CurioMesh archive after --") + parser = argparse.ArgumentParser(description="Smoke an installed CurioMesh release archive") + parser.add_argument("--layout", choices=("legacy", "extension"), required=True) + parser.add_argument("archive", type=Path) + return parser.parse_args(sys.argv[sys.argv.index("--") + 1 :]) + + +def _bl_info_version(source: str, origin: str) -> str: + tree = ast.parse(source, filename=origin) + for statement in tree.body: + if not isinstance(statement, ast.Assign): + continue + if any(isinstance(target, ast.Name) and target.id == "bl_info" for target in statement.targets): + bl_info = ast.literal_eval(statement.value) + version = bl_info.get("version") if isinstance(bl_info, dict) else None + if not isinstance(version, tuple) or not all(isinstance(part, int) for part in version): + raise AssertionError(f"{origin}: invalid bl_info.version") + return ".".join(str(part) for part in version) + raise AssertionError(f"{origin}: bl_info was not found") + + +def validate_archive(archive: Path, layout: str) -> tuple[str, str]: + prefix = "curiomesh/" if layout == "legacy" else "" + with zipfile.ZipFile(archive) as package: + entries = package.namelist() + if len(entries) != len(set(entries)): + raise AssertionError(f"{archive}: duplicate ZIP entries") + unsafe = [ + entry + for entry in entries + if PurePosixPath(entry).is_absolute() or ".." in PurePosixPath(entry).parts or "\\" in entry + ] + if unsafe: + raise AssertionError(f"{archive}: unsafe ZIP entries: {unsafe}") + if layout == "legacy" and any(not entry.startswith(prefix) for entry in entries): + raise AssertionError(f"{archive}: legacy entries must all be rooted at {prefix!r}") + required = {f"{prefix}__init__.py", f"{prefix}blender_manifest.toml", f"{prefix}triadq/engine.py"} + missing = required - set(entries) + if missing: + raise AssertionError(f"{archive}: missing entries: {sorted(missing)}") + manifest = tomllib.loads(package.read(f"{prefix}blender_manifest.toml").decode("utf-8")) + init_source = package.read(f"{prefix}__init__.py").decode("utf-8") + + package_id = manifest.get("id") + version = manifest.get("version") + if package_id != "curiomesh" or manifest.get("type") != "add-on": + raise AssertionError(f"{archive}: invalid CurioMesh add-on manifest identity") + if not isinstance(version, str) or not version: + raise AssertionError(f"{archive}: invalid manifest version") + info_version = _bl_info_version(init_source, f"{archive}!/{prefix}__init__.py") + if info_version != version: + raise AssertionError(f"{archive}: manifest version {version!r} != bl_info version {info_version!r}") + archive_kind = "addon" if layout == "legacy" else "extension" + expected_name = f"{package_id}-{version}-{archive_kind}.zip" + if archive.name != expected_name: + raise AssertionError(f"{archive}: expected filename {expected_name!r}") + return package_id, version + + +def require_finished(result: set[str], operation: str) -> None: + if "FINISHED" not in result: + raise AssertionError(f"{operation} returned {sorted(result)}") + + +def isolated_path(variable: str) -> Path: + raw = os.environ.get(variable) + if not raw: + raise RuntimeError(f"{variable} must point to an isolated test root") + path = Path(raw).resolve() + path.mkdir(parents=True, exist_ok=True) + return path + + +def install_legacy(archive: Path, package_id: str) -> tuple[str, Path]: + scripts_root = isolated_path("BLENDER_USER_SCRIPTS") + result = bpy.ops.preferences.addon_install(filepath=str(archive), overwrite=True, target="DEFAULT") + require_finished(result, "legacy add-on install") + addon_utils.modules_refresh() + result = bpy.ops.preferences.addon_enable(module=package_id) + require_finished(result, "legacy add-on enable") + if package_id not in bpy.context.preferences.addons: + raise AssertionError("Legacy CurioMesh add-on is not enabled") + module = importlib.import_module(package_id) + installed_root = Path(module.__file__).resolve().parent + if not installed_root.is_relative_to(scripts_root): + raise AssertionError(f"Legacy add-on escaped isolated scripts root: {installed_root}") + return package_id, installed_root + + +def _isolated_extension_repo() -> tuple[object, Path]: + extensions_root = isolated_path("BLENDER_USER_EXTENSIONS") + repo_root = extensions_root / "curiomesh_package_smoke" + repo_root.mkdir(parents=True, exist_ok=True) + repos = bpy.context.preferences.extensions.repos + repo = next((candidate for candidate in repos if candidate.source != "SYSTEM"), None) + if repo is None: + result = bpy.ops.preferences.extension_repo_add(type="LOCAL") + require_finished(result, "extension repository creation") + repo = next((candidate for candidate in reversed(repos) if candidate.source != "SYSTEM"), None) + if repo is None: + raise AssertionError("Blender has no writable extension repository") + repo.enabled = True + repo.use_custom_directory = True + repo.custom_directory = str(repo_root) + if Path(repo.directory).resolve() != repo_root.resolve(): + raise AssertionError(f"Extension repository did not adopt isolated directory: {repo.directory}") + return repo, repo_root + + +def install_extension(archive: Path, package_id: str) -> tuple[str, Path]: + repo, repo_root = _isolated_extension_repo() + result = bpy.ops.extensions.package_install_files( + filepath=str(archive), + repo=repo.module, + enable_on_install=True, + ) + require_finished(result, "extension package install") + module_name = f"bl_ext.{repo.module}.{package_id}" + if module_name not in bpy.context.preferences.addons: + raise AssertionError(f"Extension installer did not enable {module_name}") + module = importlib.import_module(module_name) + installed_root = Path(module.__file__).resolve().parent + if not installed_root.is_relative_to(repo_root): + raise AssertionError(f"Extension escaped isolated repository: {installed_root}") + return module_name, installed_root + + +def assert_installed_runtime( + module_name: str, + installed_root: Path, + package_id: str, + version: str, +) -> None: + module = importlib.import_module(module_name) + manifest_path = installed_root / "blender_manifest.toml" + if not manifest_path.is_file(): + raise AssertionError(f"Installed package is missing its manifest: {manifest_path}") + installed_manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + if installed_manifest.get("id") != package_id or installed_manifest.get("version") != version: + raise AssertionError( + f"Installed manifest identity/version mismatch: " + f"id={installed_manifest.get('id')!r} version={installed_manifest.get('version')!r}" + ) + if hasattr(module, "bl_info"): + installed_version = ".".join(str(part) for part in module.bl_info["version"]) + if installed_version != version: + raise AssertionError(f"Installed module version {installed_version!r} != archive version {version!r}") + + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete() + bpy.ops.mesh.primitive_cube_add(size=2.0) + result = bpy.ops.curiomesh.analyze() + require_finished(result, "installed analyze operator") + settings = bpy.context.scene.curiomesh_settings + if settings.analysis_faces != 6 or not settings.analysis_profile: + raise AssertionError( + f"Installed analysis did not populate expected state: faces={settings.analysis_faces} " + f"profile={settings.analysis_profile!r}" + ) + + +def main() -> None: + args = arguments_after_separator() + archive = args.archive.resolve() + if not archive.is_file(): + raise FileNotFoundError(archive) + package_id, version = validate_archive(archive, args.layout) + if args.layout == "legacy": + module_name, installed_root = install_legacy(archive, package_id) + else: + module_name, installed_root = install_extension(archive, package_id) + + assert_installed_runtime(module_name, installed_root, package_id, version) + result = bpy.ops.preferences.addon_disable(module=module_name) + require_finished(result, f"{args.layout} add-on disable") + if module_name in bpy.context.preferences.addons: + raise AssertionError(f"{module_name} remained enabled after disable") + print( + f"CurioMesh {args.layout} installed-package smoke passed: " + f"archive={archive.name} module={module_name} root={installed_root}" + ) + + +if __name__ == "__main__": + try: + main() + except Exception: + traceback.print_exc() + raise SystemExit(1) diff --git a/tests/blender_smoke.py b/tests/blender_smoke.py index b86da43..a8e5cb8 100644 --- a/tests/blender_smoke.py +++ b/tests/blender_smoke.py @@ -40,6 +40,20 @@ def reset_scene(): bpy.data.materials.remove(material) +def bake_settings_state(scene): + bake = scene.render.bake + return ( + scene.render.engine, + scene.cycles.samples, + bake.use_selected_to_active, + bake.cage_extrusion, + bake.margin, + bake.use_pass_direct, + bake.use_pass_indirect, + bake.use_pass_color, + ) + + def add_material_and_uv(obj, color=(0.7, 0.35, 0.1, 1.0)): material = bpy.data.materials.new(f"{obj.name}_Material") material.diffuse_color = color @@ -85,12 +99,26 @@ def make_torus(name: str): return obj -def make_open_grid(name: str, size=8): +def make_large_triangulated_grid(name: str, subdivisions: int = 513): + bpy.ops.mesh.primitive_grid_add( + x_subdivisions=subdivisions, + y_subdivisions=subdivisions, + size=2.0, + ) + obj = bpy.context.object + obj.name = name + modifier = obj.modifiers.new("CurioSmoke triangulate", "TRIANGULATE") + bpy.context.view_layer.objects.active = obj + bpy.ops.object.modifier_apply(modifier=modifier.name) + return obj + + +def make_open_grid(name: str, size=8, height=0.08): verts = [] faces = [] for y in range(size + 1): for x in range(size + 1): - verts.append((x / size - 0.5, y / size - 0.5, 0.08 * ((x + y) % 2),)) + verts.append((x / size - 0.5, y / size - 0.5, height * ((x + y) % 2),)) for y in range(size): for x in range(size): a = y * (size + 1) + x @@ -118,6 +146,35 @@ def make_bad_normals_sphere(name: str): return obj +def make_non_manifold_cube(name: str): + verts = [ + (-1, -1, -1), + (1, -1, -1), + (1, 1, -1), + (-1, 1, -1), + (-1, -1, 1), + (1, -1, 1), + (1, 1, 1), + (-1, 1, 1), + ] + faces = [ + (0, 3, 2, 1), + (4, 5, 6, 7), + (0, 1, 5, 4), + (1, 2, 6, 5), + (2, 3, 7, 6), + (3, 0, 4, 7), + (0, 3, 2, 1), + ] + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(verts, [], faces) + mesh.update(calc_edges=True) + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + add_material_and_uv(obj, color=(0.55, 0.42, 0.28, 1.0)) + return obj + + def run_remesh_case( name: str, obj, @@ -125,7 +182,9 @@ def run_remesh_case( *, min_quad_ratio=0.55, require_uv=True, + require_material=True, max_face_error=None, + require_quad_only=False, ): reset_selection(obj) result = bpy.ops.curiomesh.remesh(**kwargs) @@ -137,7 +196,7 @@ def run_remesh_case( raise AssertionError(f"{name}: no active mesh output") if len(output.data.polygons) == 0: raise AssertionError(f"{name}: empty output mesh") - if len(output.data.materials) == 0: + if require_material and len(output.data.materials) == 0: raise AssertionError(f"{name}: materials were not preserved") if require_uv and not output.data.uv_layers: raise AssertionError(f"{name}: UV layer missing") @@ -151,6 +210,8 @@ def run_remesh_case( ) if settings.metrics_status != "OK": raise AssertionError(f"{name}: status {settings.metrics_status}") + if require_quad_only and not settings.metrics_quad_only: + raise AssertionError(f"{name}: exact quad-only validation did not pass") if require_uv and settings.metrics_uv_status == "FAILED": raise AssertionError(f"{name}: UV preservation failed") if max_face_error is not None and settings.metrics_face_error > max_face_error: @@ -172,15 +233,489 @@ def reset_selection(obj): bpy.context.view_layer.objects.active = obj +def run_bake_rollback_case(): + source = make_cube("CurioSmoke_BakeRollbackSource") + target = make_cube("CurioSmoke_BakeRollbackTarget") + reset_selection(source) + + scene = bpy.context.scene + expected_settings = bake_settings_state(scene) + original_materials = list(target.data.materials) + original_material_names = set(bpy.data.materials.keys()) + original_image_names = set(bpy.data.images.keys()) + original_nodes = [ + (node.name, node.label, node.type) + for material in original_materials + if material is not None and material.use_nodes + for node in material.node_tree.nodes + ] + + textures = sys.modules["curiomesh.textures"] + execute_bake = textures._execute_bake + textures._execute_bake = lambda _bake_type: {"CANCELLED"} + try: + try: + textures.bake_selected_to_active( + source, + target, + maps=("COLOR",), + resolution=64, + margin=3, + ) + except RuntimeError as exc: + if "did not finish" not in str(exc): + raise AssertionError(f"bake_rollback: unexpected failure: {exc}") from exc + else: + raise AssertionError("bake_rollback: CANCELLED bake was accepted") + finally: + textures._execute_bake = execute_bake + + if len(target.data.materials) != len(original_materials) or any( + restored is not original for restored, original in zip(target.data.materials, original_materials) + ): + raise AssertionError("bake_rollback: target material slots were not restored") + if set(bpy.data.materials.keys()) != original_material_names: + raise AssertionError("bake_rollback: a private bake material leaked") + if set(bpy.data.images.keys()) != original_image_names: + raise AssertionError("bake_rollback: a bake image leaked") + restored_nodes = [ + (node.name, node.label, node.type) + for material in target.data.materials + if material is not None and material.use_nodes + for node in material.node_tree.nodes + ] + if restored_nodes != original_nodes: + raise AssertionError("bake_rollback: original material nodes were mutated") + if bake_settings_state(scene) != expected_settings: + raise AssertionError("bake_rollback: render settings were not restored") + if bpy.context.view_layer.objects.active is not source or [obj.name for obj in bpy.context.selected_objects] != [ + source.name + ]: + raise AssertionError("bake_rollback: active object or selection was not restored") + print("PASS bake_rollback: cancelled bake left no image, material, node, or settings state") + + +def run_transformed_surface_error_case(): + source = make_cube("CurioSmoke_TransformSource") + source.location = (17.0, -4.5, 3.25) + source.rotation_euler = (0.37, -0.21, 1.13) + source.scale = (2.0, 0.75, 1.4) + bpy.context.view_layer.update() + target = bpy.data.objects.new("CurioSmoke_TransformTarget", source.data.copy()) + bpy.context.collection.objects.link(target) + target.matrix_world = source.matrix_world.copy() + bpy.context.view_layer.update() + + bridge = sys.modules["curiomesh.bridge"] + rms, p95 = bridge._sampled_surface_error(source, target) + if rms > 1e-6 or p95 > 1e-6: + raise AssertionError( + f"transformed_surface_error: identical world surfaces scored rms={rms:.12f} p95={p95:.12f}" + ) + print("PASS transformed_surface_error: world-space BVH scoring is transform invariant") + + +def run_bounded_fidelity_reference_case(): + source = make_open_grid("CurioSmoke_BoundedReferenceSource", size=8, height=0.0) + source.location = (9.0, -3.0, 2.0) + source.rotation_euler = (0.21, -0.33, 0.71) + source.scale = (2.5, 0.8, 1.25) + bpy.context.view_layer.update() + + proxy_mesh = bpy.data.meshes.new("CurioSmoke_BoundedReferenceProxyMesh") + proxy_mesh.from_pydata( + [(-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)], + [], + [(0, 1, 2, 3)], + ) + proxy_mesh.update(calc_edges=True) + proxy = bpy.data.objects.new("CurioSmoke_BoundedReferenceProxy", proxy_mesh) + bpy.context.collection.objects.link(proxy) + proxy.matrix_world = source.matrix_world.copy() + target = bpy.data.objects.new("CurioSmoke_BoundedReferenceTarget", proxy_mesh.copy()) + bpy.context.collection.objects.link(target) + target.matrix_world = source.matrix_world.copy() + bpy.context.view_layer.update() + + bridge = sys.modules["curiomesh.bridge"] + original_builder = bridge._build_source_bvh + + def reject_full_source_bvh(obj): + if obj is source: + raise AssertionError("bounded_fidelity_reference: attempted a full-source Python BVH") + return original_builder(obj) + + bridge._build_source_bvh = reject_full_source_bvh + try: + reference = bridge._make_surface_reference( + source, + sample_limit=64, + reference_proxy=proxy, + max_reference_triangles=8, + ) + stats = bridge._sampled_surface_error_stats(source, target, reference=reference) + finally: + bridge._build_source_bvh = original_builder + + if not reference.fidelity_status.startswith("BOUNDED_PROXY_BVH_2_TRIS_SOURCE_GE_"): + raise AssertionError(f"bounded_fidelity_reference: wrong status {reference.fidelity_status!r}") + if not reference.component_sampling.startswith("BOUNDED_SOURCE_FACES_"): + raise AssertionError( + f"bounded_fidelity_reference: source sampling was not bounded: {reference.component_sampling!r}" + ) + if len(reference.sample_points) > 64: + raise AssertionError("bounded_fidelity_reference: sample budget was exceeded") + if stats.rms > 1e-6 or stats.p95 > 1e-6: + raise AssertionError( + f"bounded_fidelity_reference: planar world-space proxy scored rms={stats.rms} p95={stats.p95}" + ) + if stats.fidelity_status != reference.fidelity_status: + raise AssertionError("bounded_fidelity_reference: status was lost during scoring") + print("PASS bounded_fidelity_reference: forced cap used bounded source samples and proxy BVH") + + +def run_large_retry_proxy_order_case(): + bpy.ops.mesh.primitive_cube_add(size=2.0) + source = bpy.context.object + source.name = "CurioSmoke_LargeRetryOrder" + bridge = sys.modules["curiomesh.bridge"] + events = [] + quadriflow_calls = 0 + + original_count = bridge._bounded_triangle_count + original_prepare = bridge._prepare_flow_proxy + original_cleanup = bridge._cleanup_mesh + original_quadriflow = bridge._run_quadriflow + original_voxel = bridge._voxel_repair + + def force_large_source(obj, limit): + if obj.name.startswith(f"{source.name}_CurioMeshSource"): + return limit + 1, True + return original_count(obj, limit) + + def record_prepare(obj, config): + events.append("prepare") + return original_prepare(obj, config) + + def record_cleanup(mesh, config): + events.append("cleanup") + return original_cleanup(mesh, config) + + def fail_then_succeed(_obj, _config): + nonlocal quadriflow_calls + quadriflow_calls += 1 + events.append(f"quadriflow_{quadriflow_calls}") + return quadriflow_calls == 2 + + def record_voxel(_obj, _config): + events.append("voxel") + return True + + bridge._bounded_triangle_count = force_large_source + bridge._prepare_flow_proxy = record_prepare + bridge._cleanup_mesh = record_cleanup + bridge._run_quadriflow = fail_then_succeed + bridge._voxel_repair = record_voxel + try: + result = bridge.run_curiomesh( + source, + bridge.RemeshConfig( + engine="QUADRIFLOW", + target_faces=24, + topology_policy="REPAIR", + require_quads=False, + shrinkwrap_project=False, + texture_mode="NONE", + output_mode="NEW", + ), + ) + finally: + bridge._bounded_triangle_count = original_count + bridge._prepare_flow_proxy = original_prepare + bridge._cleanup_mesh = original_cleanup + bridge._run_quadriflow = original_quadriflow + bridge._voxel_repair = original_voxel + + expected = [ + "prepare", + "cleanup", + "quadriflow_1", + "prepare", + "cleanup", + "voxel", + "quadriflow_2", + ] + if events != expected: + raise AssertionError(f"large_retry_proxy_order: expected {expected}, got {events}") + if not result.success: + raise AssertionError(f"large_retry_proxy_order: retry did not complete: {result.message}") + print("PASS large_retry_proxy_order: retry QEM reduction precedes BMesh cleanup") + + +def run_pairclosure_skips_flow_proxy_case(): + reset_scene() + bpy.ops.mesh.primitive_cube_add() + source = bpy.context.object + source.name = "CurioSmoke_PairClosureDirectProxy" + bridge = sys.modules["curiomesh.bridge"] + original_prepare = bridge._prepare_flow_proxy + calls = [] + + def fail_if_called(*_args, **_kwargs): + calls.append(True) + raise AssertionError("explicit PairClosure entered the flow-proxy path") + + bridge._prepare_flow_proxy = fail_if_called + try: + result = bridge.run_curiomesh( + source, + bridge.RemeshConfig( + engine="ATLAS_Q", + atlas_strategy="PAIR_CLOSURE", + target_faces=24, + atlas_candidate_count=1, + voxel_repair=False, + shrinkwrap_project=False, + texture_mode="NONE", + output_mode="NEW", + ), + ) + finally: + bridge._prepare_flow_proxy = original_prepare + + if calls: + raise AssertionError("pairclosure_direct_proxy: flow preprocessing was called") + if not result.success or result.output_object is None: + raise AssertionError(f"pairclosure_direct_proxy: {result.message}") + print("PASS pairclosure_direct_proxy: explicit PairClosure bypassed redundant flow QEM") + + +def run_pairclosure_unlock_topology_gate_case(): + bridge = sys.modules["curiomesh.bridge"] + + reset_scene() + bpy.ops.mesh.primitive_torus_add(major_segments=12, minor_segments=6) + rotated_source = bpy.context.object + rotated_source.name = "CurioSmoke_PairUnlockRotation" + triangulate = rotated_source.modifiers.new("Triangulate", "TRIANGULATE") + bpy.ops.object.modifier_apply(modifier=triangulate.name) + before = bridge.mesh_diagnostics(rotated_source, 128) + selected, rotated = bridge._rotate_independent_triangle_edges(rotated_source, 1001) + after = bridge.mesh_diagnostics(rotated_source, 128) + if selected <= 0 or rotated <= 0: + raise AssertionError("pair_unlock_gate: deterministic rotation selected no usable edges") + if after.component_signature != before.component_signature: + raise AssertionError("pair_unlock_gate: edge rotation changed the component topology signature") + if not bridge._proxy_topology_certifiable(after): + raise AssertionError("pair_unlock_gate: edge rotation introduced a topology defect") + + reset_scene() + bpy.ops.mesh.primitive_cube_add() + rollback_source = bpy.context.object + rollback_source.name = "CurioSmoke_PairUnlockRollback" + triangulate = rollback_source.modifiers.new("Triangulate", "TRIANGULATE") + bpy.ops.object.modifier_apply(modifier=triangulate.name) + rollback_before = bridge.mesh_diagnostics(rollback_source, 24) + original_rotate = bridge._rotate_independent_triangle_edges + original_decimate = bridge._apply_decimate_proxy + + def damage_topology(obj, _seed, *, fraction=0.18): + del fraction + bm = bridge.bmesh.new() + try: + bm.from_mesh(obj.data) + bm.faces.ensure_lookup_table() + bridge.bmesh.ops.delete(bm, geom=[bm.faces[0]], context="FACES") + bm.to_mesh(obj.data) + obj.data.update(calc_edges=True) + finally: + bm.free() + return 1, 1 + + def keep_damaged_proxy(_obj, _target, _config, *, preserve_features): + del preserve_features + return True + + bridge._rotate_independent_triangle_edges = damage_topology + bridge._apply_decimate_proxy = keep_damaged_proxy + try: + accepted = bridge._unlock_pair_closure_proxy( + rollback_source, + 4, + 4, + bridge.RemeshConfig(seed=9, debug_logs=True), + max_rounds=1, + ) + finally: + bridge._rotate_independent_triangle_edges = original_rotate + bridge._apply_decimate_proxy = original_decimate + + rollback_after = bridge.mesh_diagnostics(rollback_source, 24) + if accepted != 0: + raise AssertionError("pair_unlock_gate: topology-changing round was accepted") + if rollback_after.component_signature != rollback_before.component_signature: + raise AssertionError("pair_unlock_gate: rejected round did not restore exact topology") + if rollback_after.faces != rollback_before.faces: + raise AssertionError("pair_unlock_gate: rejected round did not restore the prior mesh") + print("PASS pair_unlock_gate: deterministic rotations preserve topology and invalid rounds roll back") + + +def run_projection_rollback_case(): + bridge = sys.modules["curiomesh.bridge"] + original_projection = bridge._apply_shrinkwrap + cases = ( + (True, "ROLLED_BACK_FAILED_GATES"), + (False, "ROLLED_BACK_APPLY_FAILED"), + ) + for reports_applied, expected_status in cases: + reset_scene() + bpy.ops.mesh.primitive_cube_add() + source = bpy.context.object + source.name = f"CurioSmoke_ProjectionRollback_{expected_status}" + + def collapse_projection(target, _source, _config): + origin = target.data.vertices[0].co.copy() + for vertex in target.data.vertices: + vertex.co = origin + target.data.update() + return reports_applied + + bridge._apply_shrinkwrap = collapse_projection + try: + result = bridge.run_curiomesh( + source, + bridge.RemeshConfig( + engine="TRIAD_Q_LITE", + target_faces=24, + triad_force_quads=True, + shrinkwrap_project=True, + texture_mode="NONE", + output_mode="NEW", + ), + ) + finally: + bridge._apply_shrinkwrap = original_projection + + if not result.success or result.output_object is None: + raise AssertionError(f"projection_rollback: validated pre-projection mesh was lost: {result.message}") + if result.output_object.get("curiomesh_projection_status") != expected_status: + raise AssertionError( + f"projection_rollback: expected {expected_status}, got " + f"{result.output_object.get('curiomesh_projection_status')}" + ) + diagnostics = bridge.mesh_diagnostics(result.output_object, 24) + if not diagnostics.quad_only: + raise AssertionError("projection_rollback: restored output no longer passes quad validation") + print("PASS projection_rollback: invalid and failed shrinkwrap results restored the validated candidate") + + +def run_quality_gate_case(): + atlasq = sys.modules["curiomesh.atlasq"] + metrics = sys.modules["curiomesh.metrics"] + diagnostics = metrics.MeshDiagnostics( + faces=100, + quads=100, + quad_ratio=1.0, + min_scaled_jacobian=0.8, + p05_scaled_jacobian=0.7, + p95_aspect_ratio=1.4, + quad_only=True, + connected_components=1, + ) + failures = atlasq.candidate_gate_failures( + diagnostics, + target_faces=100, + geometry_rms=0.05, + geometry_p95=0.10, + geometry_max=0.12, + target_edge_normalized=0.01, + require_quads=True, + topology_policy="REPAIR", + profile=None, + quality="BALANCED", + ) + if not any("p95 error" in failure for failure in failures): + raise AssertionError(f"quality_gate: catastrophic finite p95 error was accepted: {failures}") + max_failures = atlasq.candidate_gate_failures( + diagnostics, + target_faces=100, + geometry_rms=0.002, + geometry_p95=0.005, + geometry_max=0.05, + target_edge_normalized=0.01, + require_quads=True, + topology_policy="REPAIR", + profile=None, + quality="BALANCED", + ) + if not any("max sampled error" in failure for failure in max_failures): + raise AssertionError(f"quality_gate: isolated maximum error was accepted: {max_failures}") + print("PASS quality_gate: finite but excessive geometry error is rejected") + + +def run_component_sampling_case(): + source_mesh = bpy.data.meshes.new("CurioSmoke_ComponentSourceMesh") + main_vertices = [(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)] + tiny_vertices = [(6.0, 0.0, 0.0), (6.05, 0.0, 0.0), (6.0, 0.05, 0.0)] + source_mesh.from_pydata(main_vertices + tiny_vertices, [], [(0, 1, 2, 3), (4, 5, 6)]) + source_mesh.update(calc_edges=True) + source = bpy.data.objects.new("CurioSmoke_ComponentSource", source_mesh) + bpy.context.collection.objects.link(source) + + target_mesh = bpy.data.meshes.new("CurioSmoke_ComponentTargetMesh") + target_mesh.from_pydata(main_vertices, [], [(0, 1, 2, 3)]) + target_mesh.update(calc_edges=True) + target = bpy.data.objects.new("CurioSmoke_ComponentTarget", target_mesh) + bpy.context.collection.objects.link(target) + + bridge = sys.modules["curiomesh.bridge"] + stats = bridge._sampled_surface_error_stats(source, target, sample_limit=32) + if stats.component_sampling != "ALL_2": + raise AssertionError(f"component_sampling: expected both islands, got {stats.component_sampling}") + if stats.maximum < 0.5: + raise AssertionError(f"component_sampling: deleted small island was missed, max={stats.maximum}") + print("PASS component_sampling: omitted tiny disconnected island contributes to max error") + + +def run_world_scale_metrics_case(): + mesh = bpy.data.meshes.new("CurioSmoke_ScaledMetricsMesh") + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], [], [(0, 1, 2, 3)]) + mesh.update(calc_edges=True) + obj = bpy.data.objects.new("CurioSmoke_ScaledMetrics", mesh) + bpy.context.collection.objects.link(obj) + obj.scale = (100.0, 0.01, 1.0) + bpy.context.view_layer.update() + + metrics = sys.modules["curiomesh.metrics"].mesh_diagnostics(obj, 100) + if metrics.p95_aspect_ratio < 5_000.0: + raise AssertionError(f"world_scale_metrics: rendered aspect was underreported: {metrics.p95_aspect_ratio}") + profile = sys.modules["curiomesh.atlasq"].analyze_object(obj, 100) + normalized_edge = profile.target_edge_length / profile.diagonal + if not 0.0009 <= normalized_edge <= 0.0011: + raise AssertionError(f"world_scale_metrics: target-edge normalization is not world-space: {normalized_edge}") + print("PASS world_scale_metrics: non-uniform scale uses world-space area, diagonal, and quad quality") + + def main(): module = load_addon() reset_scene() + run_transformed_surface_error_case() + run_bounded_fidelity_reference_case() + run_large_retry_proxy_order_case() + run_pairclosure_skips_flow_proxy_case() + run_pairclosure_unlock_topology_gate_case() + run_projection_rollback_case() + run_quality_gate_case() + run_component_sampling_case() + run_world_scale_metrics_case() + reset_scene() sphere = make_uv_sphere("CurioSmoke_Sphere") out = run_remesh_case( "sphere_new", sphere, { + "engine": "QUADRIFLOW", "target_faces": 260, "quality": "BALANCED", "output_mode": "NEW", @@ -198,6 +733,7 @@ def main(): "torus_replace", torus, { + "engine": "QUADRIFLOW", "target_faces": 220, "quality": "BALANCED", "output_mode": "REPLACE", @@ -213,11 +749,13 @@ def main(): "open_grid_boundary", grid, { + "engine": "QUADRIFLOW", "target_faces": 90, "quality": "DRAFT", "output_mode": "NEW", "texture_mode": "PROJECT", "preserve_boundary": True, + "require_quads": False, "seed": 3, }, min_quad_ratio=0.50, @@ -228,6 +766,7 @@ def main(): "bad_normals_repair", bad_normals, { + "engine": "QUADRIFLOW", "target_faces": 180, "quality": "BALANCED", "output_mode": "REPLACE", @@ -240,10 +779,22 @@ def main(): ) bake_source = make_uv_sphere("CurioSmoke_Bake", segments=20, rings=10) - run_remesh_case( + source_material = bake_source.data.materials[0] + scene = bpy.context.scene + scene.render.engine = "BLENDER_EEVEE_NEXT" + scene.cycles.samples = 7 + scene.render.bake.use_selected_to_active = False + scene.render.bake.cage_extrusion = 0.123 + scene.render.bake.margin = 19 + scene.render.bake.use_pass_direct = True + scene.render.bake.use_pass_indirect = True + scene.render.bake.use_pass_color = False + expected_bake_settings = bake_settings_state(scene) + bake_output = run_remesh_case( "bake_color", bake_source, { + "engine": "QUADRIFLOW", "target_faces": 96, "quality": "DRAFT", "output_mode": "NEW", @@ -255,6 +806,25 @@ def main(): min_quad_ratio=0.60, max_face_error=0.60, ) + if bake_settings_state(scene) != expected_bake_settings: + raise AssertionError( + f"bake_color: render settings leaked; expected={expected_bake_settings!r} " + f"actual={bake_settings_state(scene)!r}" + ) + if bake_output.data.materials[0] == source_material: + raise AssertionError("bake_color: bake output still shares its source material") + if any(node.label.startswith("CurioMesh_") for node in source_material.node_tree.nodes): + raise AssertionError("bake_color: source material nodes were mutated") + baked_nodes = [ + node + for material in bake_output.data.materials + if material is not None and material.use_nodes + for node in material.node_tree.nodes + if node.label == "CurioMesh_BaseColor" and node.image is not None + ] + if not baked_nodes: + raise AssertionError("bake_color: baked image node was not retained on the output") + run_bake_rollback_case() triad_cube = make_cube("CurioSmoke_TriadCube") run_remesh_case( @@ -262,33 +832,131 @@ def main(): triad_cube, { "engine": "TRIAD_Q_LITE", - "target_faces": 6, + "target_faces": 24, "quality": "BALANCED", "output_mode": "NEW", "texture_mode": "PROJECT", "triad_seed_count": 4, "triad_flow_mode": "PATCH", "triad_feature_angle": 35.0, + "debug_logs": True, }, min_quad_ratio=0.85, - max_face_error=0.50, + max_face_error=0.20, + require_quad_only=True, ) - triad_grid = make_open_grid("CurioSmoke_TriadGrid", size=7) + dirty_source = make_non_manifold_cube("CurioSmoke_Dirty") run_remesh_case( - "triad_grid_replace", + "atlas_dirty_repair", + dirty_source, + { + "engine": "ATLAS_Q", + "target_faces": 48, + "quality": "DRAFT", + "atlas_strategy": "DIRTY_FLOW", + "atlas_candidate_count": 1, + "topology_policy": "REPAIR", + "require_quads": True, + "output_mode": "NEW", + "texture_mode": "NONE", + "seed": 29, + }, + min_quad_ratio=1.0, + require_uv=False, + max_face_error=0.75, + require_quad_only=True, + ) + + large_source = make_large_triangulated_grid("CurioSmoke_LargeProxy") + large_input_faces = len(large_source.data.polygons) + if large_input_faces <= 500_000: + raise AssertionError(f"atlas_target_scale_proxy: test did not enter sampled branch ({large_input_faces})") + large_output = run_remesh_case( + "atlas_target_scale_proxy", + large_source, + { + "engine": "ATLAS_Q", + "target_faces": 128, + "quality": "DRAFT", + "atlas_strategy": "FLOW_ENSEMBLE", + "atlas_candidate_count": 1, + "topology_policy": "PRESERVE", + "require_quads": True, + "output_mode": "NEW", + "texture_mode": "NONE", + "seed": 31, + }, + min_quad_ratio=1.0, + require_uv=False, + require_material=False, + max_face_error=0.45, + require_quad_only=True, + ) + proxy_faces = int(large_output.get("curiomesh_proxy_faces", 0)) + if proxy_faces <= 0 or proxy_faces >= large_input_faces: + raise AssertionError( + f"atlas_target_scale_proxy: expected reduced proxy, input={large_input_faces} proxy={proxy_faces}" + ) + topology_status = str(large_output.get("curiomesh_topology_status", "")) + if topology_status != "PROXY_PRESERVED_SOURCE_UNVERIFIED": + raise AssertionError(f"atlas_target_scale_proxy: unexpected topology status {topology_status!r}") + if bpy.context.scene.curiomesh_settings.metrics_topology_status != topology_status: + raise AssertionError("atlas_target_scale_proxy: proxy-only topology status was not surfaced in UI metrics") + reset_scene() + + triad_grid = make_open_grid("CurioSmoke_TriadGrid", size=10, height=0.0) + triad_input_faces = len(triad_grid.data.polygons) + triad_grid_output = run_remesh_case( + "triad_grid_proxy", triad_grid, { "engine": "TRIAD_Q_LITE", - "target_faces": 49, + "target_faces": 196, "quality": "DRAFT", - "output_mode": "REPLACE", + "output_mode": "NEW", "texture_mode": "PROJECT", "triad_seed_count": 4, "triad_flow_mode": "AUTO", "triad_feature_angle": 35.0, + "debug_logs": True, }, min_quad_ratio=0.70, + require_quad_only=True, + ) + triad_proxy_faces = int(triad_grid_output.get("curiomesh_proxy_faces", 0)) + if triad_proxy_faces <= 0 or triad_proxy_faces >= triad_input_faces: + raise AssertionError( + f"triad_grid_proxy: expected target-scale QEM proxy before closure, got {triad_proxy_faces}" + ) + + atlas_source = make_uv_sphere("CurioSmoke_Atlas", segments=20, rings=10) + reset_selection(atlas_source) + analysis_result = bpy.ops.curiomesh.analyze() + if "FINISHED" not in analysis_result: + raise AssertionError(f"atlas_analyze: operator returned {analysis_result}") + settings = bpy.context.scene.curiomesh_settings + if not settings.analysis_profile or settings.analysis_faces <= 0: + raise AssertionError("atlas_analyze: scan profile was not populated") + run_remesh_case( + "atlas_auto", + atlas_source, + { + "engine": "ATLAS_Q", + "target_faces": 96, + "quality": "BALANCED", + "atlas_strategy": "AUTO", + "atlas_candidate_count": 2, + "topology_policy": "PRESERVE", + "require_quads": True, + "output_mode": "NEW", + "texture_mode": "NONE", + "seed": 23, + }, + min_quad_ratio=1.0, + require_uv=False, + max_face_error=0.40, + require_quad_only=True, ) module.unregister() diff --git a/tests/run_blender_tests.ps1 b/tests/run_blender_tests.ps1 index 4290df0..638ee65 100644 --- a/tests/run_blender_tests.ps1 +++ b/tests/run_blender_tests.ps1 @@ -6,11 +6,17 @@ param( $ErrorActionPreference = "Stop" $RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) $PortableRoot = Join-Path $RepoRoot ".tools" -$PortableExe = Join-Path $PortableRoot "blender-4.5.1-windows-x64\blender.exe" +$PortableVersion = "4.5.10" +$PortableFolder = "blender-$PortableVersion-windows-x64" +$PortableExe = Join-Path $PortableRoot "$PortableFolder\blender.exe" if (-not $BlenderExe) { + $RunningBlenderExe = Get-Process -Name blender -ErrorAction SilentlyContinue | + Where-Object { $_.Path } | + Select-Object -First 1 -ExpandProperty Path $candidates = @( $PortableExe, + $RunningBlenderExe, "C:\Program Files\Blender Foundation\Blender 4.5\blender.exe", "C:\Program Files\Blender Foundation\Blender 4.4\blender.exe", "C:\Program Files\Blender Foundation\Blender 4.3\blender.exe" @@ -25,12 +31,26 @@ if (-not $BlenderExe) { if (-not $BlenderExe -and $InstallBlender) { New-Item -ItemType Directory -Force -Path $PortableRoot | Out-Null - $zip = Join-Path $PortableRoot "blender-4.5.1-windows-x64.zip" + $zip = Join-Path $PortableRoot "$PortableFolder.zip" + $checksumFile = Join-Path $PortableRoot "blender-$PortableVersion.sha256" + $releaseBase = "https://mirror.clarkson.edu/blender/release/Blender4.5" if (-not (Test-Path $zip)) { Invoke-WebRequest ` - -Uri "https://download.blender.org/release/Blender4.5/blender-4.5.1-windows-x64.zip" ` + -Uri "$releaseBase/$PortableFolder.zip" ` -OutFile $zip } + Invoke-WebRequest ` + -Uri "$releaseBase/blender-$PortableVersion.sha256" ` + -OutFile $checksumFile + $expectedLine = Get-Content $checksumFile | Where-Object { $_ -match [regex]::Escape("$PortableFolder.zip") } | Select-Object -First 1 + if (-not $expectedLine) { + throw "No SHA-256 entry found for $PortableFolder.zip" + } + $expectedHash = ($expectedLine -split "\s+")[0].ToLowerInvariant() + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Blender archive checksum mismatch: expected $expectedHash, got $actualHash" + } if (-not (Test-Path $PortableExe)) { Expand-Archive -Path $zip -DestinationPath $PortableRoot -Force } @@ -42,4 +62,84 @@ if (-not $BlenderExe -or -not (Test-Path $BlenderExe)) { } & $BlenderExe --background --factory-startup --python (Join-Path $RepoRoot "tests\blender_smoke.py") -exit $LASTEXITCODE +if ($LASTEXITCODE -ne 0) { + throw "CurioMesh source Blender smoke failed with exit code $LASTEXITCODE" +} + +$PythonExe = Get-Command python -ErrorAction Stop | Select-Object -ExpandProperty Source +$TempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$TempRoot = [IO.Path]::GetFullPath((Join-Path $TempBase "curiomesh-release-smoke-$([guid]::NewGuid().ToString('N'))")) +$TempPrefix = $TempBase.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + + [IO.Path]::DirectorySeparatorChar +if (-not $TempRoot.StartsWith($TempPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use package-smoke directory outside the system temp root: $TempRoot" +} + +$EnvironmentNames = @( + "BLENDER_USER_CONFIG", + "BLENDER_USER_DATAFILES", + "BLENDER_USER_EXTENSIONS", + "BLENDER_USER_SCRIPTS" +) +$PreviousEnvironment = @{} +foreach ($Name in $EnvironmentNames) { + $PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") +} + +try { + $ReleaseRoot = Join-Path $TempRoot "release" + New-Item -ItemType Directory -Force -Path $ReleaseRoot | Out-Null + & $PythonExe (Join-Path $RepoRoot "scripts\build_release.py") --output $ReleaseRoot + if ($LASTEXITCODE -ne 0) { + throw "CurioMesh release build failed with exit code $LASTEXITCODE" + } + + $LegacyArchives = @(Get-ChildItem -LiteralPath $ReleaseRoot -Filter "*-addon.zip") + $ExtensionArchives = @(Get-ChildItem -LiteralPath $ReleaseRoot -Filter "*-extension.zip") + if ($LegacyArchives.Count -ne 1 -or $ExtensionArchives.Count -ne 1) { + throw "Expected one legacy and one extension archive in $ReleaseRoot" + } + + $PackageSmokeScript = Join-Path $RepoRoot "tests\blender_package_smoke.py" + $Packages = @( + @{ Layout = "legacy"; Archive = $LegacyArchives[0] }, + @{ Layout = "extension"; Archive = $ExtensionArchives[0] } + ) + foreach ($Package in $Packages) { + $LayoutRoot = Join-Path $TempRoot $Package.Layout + $env:BLENDER_USER_CONFIG = Join-Path $LayoutRoot "config" + $env:BLENDER_USER_DATAFILES = Join-Path $LayoutRoot "datafiles" + $env:BLENDER_USER_EXTENSIONS = Join-Path $LayoutRoot "extensions" + $env:BLENDER_USER_SCRIPTS = Join-Path $LayoutRoot "scripts" + foreach ($Name in $EnvironmentNames) { + New-Item -ItemType Directory -Force -Path ([Environment]::GetEnvironmentVariable($Name, "Process")) | + Out-Null + } + + & $BlenderExe --background --factory-startup --python $PackageSmokeScript -- ` + --layout $Package.Layout $Package.Archive.FullName + if ($LASTEXITCODE -ne 0) { + throw "CurioMesh $($Package.Layout) installed-package smoke failed with exit code $LASTEXITCODE" + } + } +} +finally { + foreach ($Name in $EnvironmentNames) { + $PreviousValue = $PreviousEnvironment[$Name] + if ($null -eq $PreviousValue) { + Remove-Item -Path "Env:$Name" -ErrorAction SilentlyContinue + } + else { + [Environment]::SetEnvironmentVariable($Name, $PreviousValue, "Process") + } + } + if (Test-Path -LiteralPath $TempRoot) { + $ResolvedTempRoot = (Resolve-Path -LiteralPath $TempRoot).Path + if (-not $ResolvedTempRoot.StartsWith($TempPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove package-smoke directory outside the system temp root: $ResolvedTempRoot" + } + Remove-Item -LiteralPath $ResolvedTempRoot -Recurse -Force + } +} + +Write-Host "CurioMesh Blender release ladder passed (source, legacy archive, extension archive)." diff --git a/tests/test_release_package.py b/tests/test_release_package.py new file mode 100644 index 0000000..e59cf3b --- /dev/null +++ b/tests/test_release_package.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import ast +from pathlib import Path +import sys +import tempfile +import tomllib +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.build_release import build_archives, release_metadata # noqa: E402 + + +def bl_info_version(source: str) -> str: + tree = ast.parse(source) + for statement in tree.body: + if not isinstance(statement, ast.Assign): + continue + if any(isinstance(target, ast.Name) and target.id == "bl_info" for target in statement.targets): + bl_info = ast.literal_eval(statement.value) + return ".".join(str(part) for part in bl_info["version"]) + raise AssertionError("bl_info was not found") + + +def archive_payload(path: Path, prefix: str) -> dict[str, bytes]: + root = f"{prefix}/" if prefix else "" + with zipfile.ZipFile(path) as archive: + return {name.removeprefix(root): archive.read(name) for name in archive.namelist()} + + +class ReleasePackageTests(unittest.TestCase): + def test_both_layouts_are_complete_and_clean(self) -> None: + package_id, version = release_metadata(ROOT) + with tempfile.TemporaryDirectory() as temporary: + legacy, extension = build_archives(ROOT, Path(temporary)) + self.assertTrue(legacy.is_file()) + self.assertTrue(extension.is_file()) + self.assertEqual(legacy.name, f"{package_id}-{version}-addon.zip") + self.assertEqual(extension.name, f"{package_id}-{version}-extension.zip") + with zipfile.ZipFile(legacy) as archive: + self.assertIn("curiomesh/__init__.py", archive.namelist()) + self.assertIn("curiomesh/triadq/topology.py", archive.namelist()) + self.assertTrue(all(name.startswith("curiomesh/") for name in archive.namelist())) + with zipfile.ZipFile(extension) as archive: + self.assertIn("blender_manifest.toml", archive.namelist()) + self.assertIn("triadq/topology.py", archive.namelist()) + self.assertNotIn("curiomesh/__init__.py", archive.namelist()) + + legacy_payload = archive_payload(legacy, "curiomesh") + extension_payload = archive_payload(extension, "") + self.assertEqual(legacy_payload, extension_payload) + for payload in (legacy_payload, extension_payload): + manifest = tomllib.loads(payload["blender_manifest.toml"].decode("utf-8")) + self.assertEqual(manifest["id"], package_id) + self.assertEqual(manifest["version"], version) + self.assertEqual(bl_info_version(payload["__init__.py"].decode("utf-8")), version) + + def test_source_version_drift_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "blender_manifest.toml").write_text( + 'id = "curiomesh"\nversion = "1.2.3"\ntype = "add-on"\n', + encoding="utf-8", + ) + (root / "__init__.py").write_text('bl_info = {"version": (1, 2, 4)}\n', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match bl_info"): + release_metadata(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_topology_adversarial.py b/tests/test_topology_adversarial.py new file mode 100644 index 0000000..0e7e383 --- /dev/null +++ b/tests/test_topology_adversarial.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import unittest + +import numpy as np + +from triadq import ( + MeshData, + component_topology_preserved, + parity_path_quad_closure, + split_non_manifold_vertex_fans, + topology_summary, +) + + +def _mesh(vertices: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> MeshData: + return MeshData( + vertices=np.asarray(vertices, dtype=float), + faces=faces, + face_materials=["test"] * len(faces), + name="adversarial", + ) + + +class AdversarialTopologyTests(unittest.TestCase): + def test_parity_path_rejects_invalid_source_without_mutating_it(self) -> None: + mesh = _mesh( + [(0, 0, 0), (1, 1, 0), (0, 1, 0), (1, 0, 0)], + [(0, 1, 2, 3)], + ) + original_vertices = mesh.vertices.copy() + original_faces = list(mesh.faces) + + with self.assertRaisesRegex(ValueError, "invalid_quads=1"): + parity_path_quad_closure(mesh) + + np.testing.assert_array_equal(mesh.vertices, original_vertices) + self.assertEqual(mesh.faces, original_faces) + + def test_parity_path_preserves_each_disconnected_component_signature(self) -> None: + mesh = _mesh( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (3, 0, 0), + (5, 0, 0), + (3, 2, 0), + ], + [(0, 1, 2, 3), (4, 5, 6)], + ) + + output = parity_path_quad_closure(mesh) + + self.assertTrue(topology_summary(output).all_quads) + self.assertEqual( + topology_summary(output).component_signature, + topology_summary(mesh).component_signature, + ) + + def test_vertex_only_bow_tie_is_not_manifold(self) -> None: + mesh = _mesh( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (-1, 0, 0), + (-1, -1, 0), + (0, -1, 0), + ], + [(0, 1, 2, 3), (0, 4, 5, 6)], + ) + summary = topology_summary(mesh) + self.assertEqual(summary.non_manifold_edges, 0) + self.assertEqual(summary.non_manifold_vertices, 1) + self.assertEqual(summary.connected_components, 2) + self.assertEqual(summary.component_euler, (1, 1)) + self.assertFalse(summary.all_quads) + + def test_vertex_only_bow_tie_split_preserves_geometry_and_face_data(self) -> None: + mesh = MeshData( + vertices=np.asarray( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (-1, 0, 0), + (-1, -1, 0), + (0, -1, 0), + ], + dtype=float, + ), + faces=[(0, 1, 2, 3), (0, 4, 5, 6)], + face_materials=["first", "second"], + uvs=np.asarray( + [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0), (1, 0), (1, 1), (0, 1)], + dtype=float, + ), + face_uvs=[(0, 1, 2, 3), (4, 5, 6, 7)], + name="bow_tie", + ) + + repaired, split_count = split_non_manifold_vertex_fans(mesh) + repaired_summary = topology_summary(repaired) + + self.assertEqual(split_count, 1) + self.assertEqual(repaired.vertex_count, mesh.vertex_count + 1) + np.testing.assert_array_equal(repaired.vertices[:-1], mesh.vertices) + np.testing.assert_array_equal(repaired.vertices[-1], mesh.vertices[0]) + self.assertEqual(repaired.faces[0], mesh.faces[0]) + self.assertEqual(repaired.faces[1][0], mesh.vertex_count) + self.assertEqual(repaired.face_materials, mesh.face_materials) + self.assertEqual(repaired.face_uvs, mesh.face_uvs) + np.testing.assert_array_equal(repaired.uvs, mesh.uvs) + self.assertEqual(repaired_summary.non_manifold_vertices, 0) + self.assertTrue(repaired_summary.all_quads) + self.assertEqual(topology_summary(mesh).non_manifold_vertices, 1) + + def test_vertex_fan_split_handles_a_globally_connected_bow_tie(self) -> None: + mesh = _mesh( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (0, -1, 0), + (-1, -1, 0), + (-1, 0, 0), + ], + [(0, 1, 2, 3), (0, 6, 5, 4), (2, 1, 4, 5)], + ) + before = topology_summary(mesh) + repaired, split_count = split_non_manifold_vertex_fans(mesh) + after = topology_summary(repaired) + + self.assertEqual(before.connected_components, 1) + self.assertEqual(before.non_manifold_vertices, 1) + self.assertEqual(split_count, 1) + self.assertEqual(after.connected_components, 1) + self.assertEqual(after.non_manifold_vertices, 0) + + def test_vertex_fan_split_is_deterministic_for_three_fans(self) -> None: + mesh = _mesh( + [ + (0, 0, 0), + (1, 0, 0), + (0, 1, 0), + (-1, 0, 0), + (0, -1, 0), + (0, 0, 1), + (0, 1, 1), + ], + [(0, 1, 2), (0, 3, 4), (0, 5, 6)], + ) + repaired, split_count = split_non_manifold_vertex_fans(mesh) + + self.assertEqual(split_count, 2) + self.assertEqual(repaired.faces[0][0], 0) + self.assertEqual(repaired.faces[1][0], mesh.vertex_count) + self.assertEqual(repaired.faces[2][0], mesh.vertex_count + 1) + self.assertEqual(topology_summary(repaired).non_manifold_vertices, 0) + + def test_vertex_fan_split_refuses_edge_non_manifold_input(self) -> None: + mesh = _mesh( + [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1)], + [(0, 1, 2), (1, 0, 3), (0, 1, 4)], + ) + repaired, split_count = split_non_manifold_vertex_fans(mesh) + + self.assertIs(repaired, mesh) + self.assertEqual(split_count, 0) + self.assertEqual(topology_summary(repaired).non_manifold_edges, 1) + + def test_duplicate_face_is_rejected_independent_of_winding(self) -> None: + mesh = _mesh( + [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + [(0, 1, 2, 3), (3, 2, 1, 0)], + ) + summary = topology_summary(mesh) + self.assertEqual(summary.duplicate_faces, 1) + self.assertFalse(summary.all_quads) + + def test_inconsistent_shared_edge_winding_is_rejected(self) -> None: + mesh = _mesh( + [(0, 0, 0), (1, 0, 0), (2, 0, 0), (0, 1, 0), (1, 1, 0), (2, 1, 0)], + [(0, 1, 4, 3), (1, 4, 5, 2)], + ) + summary = topology_summary(mesh) + self.assertEqual(summary.inconsistent_edges, 1) + self.assertFalse(summary.all_quads) + + def test_concave_and_crossed_quads_are_rejected(self) -> None: + concave = _mesh( + [(0, 0, 0), (1, 0, 0), (0.25, 0.25, 0), (0, 1, 0)], + [(0, 1, 2, 3)], + ) + crossed = _mesh( + [(0, 0, 0), (1, 1, 0), (0, 1, 0), (1, 0, 0)], + [(0, 1, 2, 3)], + ) + self.assertEqual(topology_summary(concave).invalid_quads, 1) + self.assertEqual(topology_summary(crossed).invalid_quads, 1) + self.assertFalse(topology_summary(concave).all_quads) + self.assertFalse(topology_summary(crossed).all_quads) + + def test_extreme_saddle_warpage_is_rejected(self) -> None: + warped = _mesh( + [(-1, -1, 100), (1, -1, -100), (1, 1, 100), (-1, 1, -100)], + [(0, 1, 2, 3)], + ) + summary = topology_summary(warped) + self.assertEqual(summary.invalid_quads, 1) + self.assertFalse(summary.all_quads) + + def test_disconnected_components_are_counted(self) -> None: + mesh = _mesh( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (3, 0, 0), + (4, 0, 0), + (4, 1, 0), + (3, 1, 0), + ], + [(0, 1, 2, 3), (4, 5, 6, 7)], + ) + summary = topology_summary(mesh) + self.assertEqual(summary.connected_components, 2) + self.assertEqual(summary.boundary_components, 2) + self.assertEqual(summary.component_signature, ((1, 1), (1, 1))) + self.assertTrue(summary.all_quads) + + def test_boundary_cycles_cannot_redistribute_between_equal_euler_components(self) -> None: + source_signature = ((-2, 0), (-2, 4)) + redistributed_signature = ((-2, 2), (-2, 2)) + + # These are exactly the legacy preservation invariants: they cannot + # distinguish a closed genus-two component plus a four-boundary sphere + # from two genus-one components with two boundaries each. + self.assertEqual( + tuple(component[0] for component in source_signature), + tuple(component[0] for component in redistributed_signature), + ) + self.assertEqual( + sum(component[1] for component in source_signature), + sum(component[1] for component in redistributed_signature), + ) + + self.assertFalse(component_topology_preserved(source_signature, redistributed_signature)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_triadq_topology.py b/tests/test_triadq_topology.py new file mode 100644 index 0000000..eb8c717 --- /dev/null +++ b/tests/test_triadq_topology.py @@ -0,0 +1,541 @@ +from __future__ import annotations + +import gc +import json +from pathlib import Path +import sys +import unittest +import weakref +from unittest.mock import patch + +import numpy as np + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from triadq import ( # noqa: E402 + MeshData, + RemeshOptions, + parity_path_quad_closure, + quad_only_closure, + remesh_mesh, + topology_summary, +) +import triadq.engine as triad_engine # noqa: E402 + + +class QuadClosureTests(unittest.TestCase): + @staticmethod + def _transition_patch(selected_sides: tuple[int, ...], *, with_uvs: bool = False) -> MeshData: + """Build a square quad with outward triangle residuals on chosen sides.""" + + vertices = [ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.5, -1.0, 0.0), + (2.0, 0.5, 0.0), + (0.5, 2.0, 0.0), + (-1.0, 0.5, 0.0), + ] + side_faces = { + 0: (1, 0, 4), + 1: (2, 1, 5), + 2: (3, 2, 6), + 3: (0, 3, 7), + } + faces = [(0, 1, 2, 3), *(side_faces[side] for side in selected_sides)] + materials = ["center", *(f"flap_{side}" for side in selected_sides)] + if not with_uvs: + return MeshData( + vertices=np.asarray(vertices, dtype=float), + faces=faces, + face_materials=materials, + name="transition_patch", + ) + + # Every face owns its UV corners. In particular, the bottom triangle + # deliberately disagrees with the center along their shared edge. + uv_values: list[tuple[float, float]] = [ + (0.0, 0.0), + (1.0, 0.0), + (1.0, 1.0), + (0.0, 1.0), + ] + face_uvs: list[tuple[int, ...]] = [(0, 1, 2, 3)] + for side in selected_sides: + start = len(uv_values) + base = 10.0 * (side + 1) + uv_values.extend( + [ + (base + 2.0, base), + (base, base), + (base + 1.0, base - 2.0), + ] + ) + face_uvs.append((start, start + 1, start + 2)) + return MeshData( + vertices=np.asarray(vertices, dtype=float), + faces=faces, + face_materials=materials, + uvs=np.asarray(uv_values, dtype=float), + face_uvs=face_uvs, + name="transition_patch_uv", + ) + + def test_parity_path_leaves_an_unaffected_quad_exactly_untouched(self) -> None: + source = MeshData( + vertices=np.asarray( + [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + dtype=float, + ), + faces=[(0, 1, 2, 3)], + face_materials=["untouched"], + uvs=np.asarray([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float), + face_uvs=[(0, 1, 2, 3)], + name="untouched_quad", + ) + + output = parity_path_quad_closure(source) + + np.testing.assert_array_equal(output.vertices, source.vertices) + np.testing.assert_array_equal(output.uvs, source.uvs) + self.assertEqual(output.faces, source.faces) + self.assertEqual(output.face_materials, source.face_materials) + self.assertEqual(output.face_uvs, source.face_uvs) + self.assertEqual( + topology_summary(output).component_signature, + topology_summary(source).component_signature, + ) + + def test_parity_path_closes_a_triangle_residual_deterministically(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (2, 0, 0), (0, 2, 0)], dtype=float), + faces=[(0, 1, 2)], + face_materials=["residual"], + uvs=np.asarray([(0, 0), (1, 0), (0, 1)], dtype=float), + face_uvs=[(0, 1, 2)], + name="triangle_residual", + ) + + first = parity_path_quad_closure(source) + second = parity_path_quad_closure(source) + summary = topology_summary(first) + + self.assertTrue(summary.all_quads) + self.assertEqual(first.face_count, 3) + self.assertEqual(first.vertex_count, 7) + self.assertEqual(first.face_materials, ["residual"] * 3) + self.assertEqual(summary.component_signature, topology_summary(source).component_signature) + self.assertIsNotNone(first.face_uvs) + self.assertTrue(all(len(face_uv) == 4 for face_uv in first.face_uvs or [])) + np.testing.assert_array_equal(first.vertices, second.vertices) + np.testing.assert_array_equal(first.uvs, second.uvs) + self.assertEqual(first.faces, second.faces) + self.assertEqual(first.face_uvs, second.face_uvs) + + def test_parity_path_uses_three_quad_template_for_adjacent_splits(self) -> None: + source = self._transition_patch((0, 1)) + output = parity_path_quad_closure(source) + summary = topology_summary(output) + + self.assertTrue(summary.all_quads) + self.assertEqual(output.face_count, 9) + self.assertEqual(output.face_materials.count("center"), 3) + self.assertEqual(output.face_materials.count("flap_0"), 3) + self.assertEqual(output.face_materials.count("flap_1"), 3) + self.assertEqual(summary.component_signature, topology_summary(source).component_signature) + + def test_parity_path_uses_two_quad_template_for_opposite_splits(self) -> None: + source = self._transition_patch((0, 2)) + output = parity_path_quad_closure(source) + summary = topology_summary(output) + + self.assertTrue(summary.all_quads) + self.assertEqual(output.face_count, 8) + self.assertEqual(output.face_materials.count("center"), 2) + self.assertEqual(output.face_materials.count("flap_0"), 3) + self.assertEqual(output.face_materials.count("flap_2"), 3) + self.assertEqual(summary.component_signature, topology_summary(source).component_signature) + + def test_parity_path_k4_template_preserves_uv_seams_materials_and_topology(self) -> None: + source = self._transition_patch((0, 1, 2, 3), with_uvs=True) + first = parity_path_quad_closure(source) + second = parity_path_quad_closure(source) + summary = topology_summary(first) + + self.assertTrue(summary.all_quads) + self.assertEqual(first.face_count, 16) + self.assertEqual(first.face_materials.count("center"), 4) + for side in range(4): + self.assertEqual(first.face_materials.count(f"flap_{side}"), 3) + self.assertEqual(summary.component_signature, topology_summary(source).component_signature) + self.assertIsNotNone(first.uvs) + self.assertIsNotNone(first.face_uvs) + np.testing.assert_array_equal(first.uvs[: len(source.uvs)], source.uvs) + self.assertTrue(all(len(face_uv) == 4 for face_uv in first.face_uvs or [])) + + midpoint = next( + index + for index, vertex in enumerate(first.vertices) + if np.allclose(vertex, np.asarray((0.5, 0.0, 0.0))) + ) + seam_uvs = { + tuple(first.uvs[uv_index]) + for face, face_uv in zip(first.faces, first.face_uvs or [], strict=True) + for vertex, uv_index in zip(face, face_uv, strict=True) + if vertex == midpoint + } + self.assertIn((0.5, 0.0), seam_uvs) + self.assertIn((11.0, 10.0), seam_uvs) + np.testing.assert_array_equal(first.vertices, second.vertices) + np.testing.assert_array_equal(first.uvs, second.uvs) + self.assertEqual(first.faces, second.faces) + self.assertEqual(first.face_uvs, second.face_uvs) + + def test_flow_modes_change_field_and_pairing_parameters(self) -> None: + base = RemeshOptions(feature_angle_deg=35.0, smooth_field_iters=4) + organic = triad_engine._options_for_mode(base, "OrganicFlow") + patch = triad_engine._options_for_mode(base, "PatchFlow") + dirty = triad_engine._options_for_mode(base, "DirtyFlow") + self.assertGreater(organic.smooth_field_iters, patch.smooth_field_iters) + self.assertGreater(organic.feature_angle_deg, patch.feature_angle_deg) + self.assertEqual(dirty.smooth_field_iters, 1) + self.assertNotEqual( + triad_engine._pair_weights("OrganicFlow"), + triad_engine._pair_weights("TextureFlow"), + ) + + def test_open_square_is_conforming_and_preserves_euler(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], dtype=float), + faces=[(0, 1, 2), (0, 2, 3)], + face_materials=["a", "b"], + name="square", + ) + output = quad_only_closure(source) + source_summary = topology_summary(source) + output_summary = topology_summary(output) + + self.assertTrue(output_summary.all_quads) + self.assertEqual(output_summary.faces, 6) + self.assertEqual(output_summary.euler_characteristic, source_summary.euler_characteristic) + self.assertEqual(output_summary.boundary_components, 1) + self.assertEqual(output_summary.non_manifold_edges, 0) + self.assertEqual(output.face_materials.count("a"), 3) + self.assertEqual(output.face_materials.count("b"), 3) + + diagonal_midpoints = [ + index + for index, vertex in enumerate(output.vertices) + if np.allclose(vertex, np.asarray((0.5, 0.5, 0.0))) + ] + self.assertEqual(len(diagonal_midpoints), 1) + shared = diagonal_midpoints[0] + self.assertEqual(sum(shared in face for face in output.faces), 4) + + def test_closed_tetrahedron_obeys_quad_defect_charge(self) -> None: + source = MeshData( + vertices=np.asarray( + [(1, 1, 1), (-1, -1, 1), (-1, 1, -1), (1, -1, -1)], + dtype=float, + ), + faces=[(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)], + face_materials=["stone"] * 4, + name="tetra", + ) + output = quad_only_closure(source) + summary = topology_summary(output) + self.assertTrue(summary.all_quads) + self.assertEqual(summary.boundary_edges, 0) + self.assertEqual(summary.euler_characteristic, 2) + self.assertEqual(summary.defect_charge, 8) + + def test_uv_seams_remain_face_local(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], dtype=float), + faces=[(0, 1, 2), (0, 2, 3)], + face_materials=["a", "b"], + uvs=np.asarray([(0, 0), (1, 0), (1, 1), (0, 0), (1, 1), (0, 1)], dtype=float), + face_uvs=[(0, 1, 2), (3, 4, 5)], + name="uv_square", + ) + output = quad_only_closure(source) + self.assertIsNotNone(output.uvs) + self.assertIsNotNone(output.face_uvs) + self.assertEqual(len(output.face_uvs or []), len(output.faces)) + self.assertTrue(all(len(face) == 4 for face in output.face_uvs or [])) + + def test_engine_force_quads_uses_parity_path_closure(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], dtype=float), + faces=[(0, 1, 2), (0, 2, 3)], + face_materials=["scan", "scan"], + name="paired_square", + ) + output, report = remesh_mesh(source, RemeshOptions(target_faces=1, seed_count=2, force_quads=True)) + self.assertTrue(report.success) + self.assertTrue(report.closure_applied) + self.assertEqual(report.closure_method, "PARITY_PATH") + self.assertTrue(report.quad_only_verified) + self.assertTrue(topology_summary(output).all_quads) + self.assertEqual(report.euler_characteristic, 1) + self.assertIn("TRIAD-Q ParityPath completed", report.message) + self.assertTrue( + all( + candidate["closure_method"] == "PARITY_PATH" + for candidate in report.candidates + ) + ) + + def test_engine_reports_global_target_closure_when_parity_path_rejects(self) -> None: + source = MeshData( + vertices=np.asarray( + [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + dtype=float, + ), + faces=[(0, 1, 2), (0, 2, 3)], + face_materials=["scan", "scan"], + name="forced_fallback", + ) + + with patch.object( + triad_engine, + "parity_path_quad_closure", + side_effect=ValueError("forced parity rejection"), + ): + output, report = remesh_mesh( + source, + RemeshOptions(target_faces=4, seed_count=1, force_quads=True), + ) + + self.assertTrue(report.success) + self.assertTrue(report.quad_only_verified) + self.assertEqual(report.closure_method, "GLOBAL_CORNER_TARGET") + self.assertIn("global corner target closure", report.message) + self.assertTrue(topology_summary(output).all_quads) + + def test_engine_force_quads_normalizes_vertex_only_bow_tie(self) -> None: + source = MeshData( + vertices=np.asarray( + [ + (0, 0, 0), + (1, 0, 0), + (1, 1, 0), + (0, 1, 0), + (-1, 0, 0), + (-1, -1, 0), + (0, -1, 0), + ], + dtype=float, + ), + faces=[(0, 1, 2, 3), (0, 4, 5, 6)], + face_materials=["first", "second"], + name="engine_bow_tie", + ) + output, report = remesh_mesh( + source, + RemeshOptions(target_faces=2, seed_count=2, force_quads=True), + ) + + self.assertTrue(report.success) + self.assertEqual(report.vertex_fan_splits, 1) + self.assertTrue(topology_summary(output).all_quads) + + def test_pairing_skips_concave_quad_and_closes_triangles_safely(self) -> None: + source = MeshData( + vertices=np.asarray( + [(0, 0, 0), (1, 0, 0), (0.25, 0.25, 0), (0, 1, 0)], + dtype=float, + ), + faces=[(0, 1, 2), (0, 2, 3)], + face_materials=["scan", "scan"], + name="concave_pair", + ) + output, report = remesh_mesh( + source, + RemeshOptions(target_faces=6, seed_count=3, force_quads=True), + ) + self.assertTrue(report.success) + self.assertTrue(report.quad_only_verified) + self.assertEqual(len(output.faces), 6) + self.assertTrue(topology_summary(output).all_quads) + + def test_engine_force_quads_preserves_paired_and_seamed_uv_material_faces(self) -> None: + source = MeshData( + vertices=np.asarray( + [ + (0, 0, 0), + (1, 0, 0), + (2, 0, 0), + (0, 1, 0), + (1, 1, 0), + (2, 1, 0), + ], + dtype=float, + ), + faces=[ + (0, 1, 4), + (0, 4, 3), + (1, 2, 5), + (1, 5, 4), + ], + face_materials=["paired", "paired", "seam_a", "seam_b"], + uvs=np.asarray( + [ + (0, 0), + (1, 0), + (1, 1), + (0, 1), + (0, 0), + (1, 0), + (1, 1), + (0.25, 0.25), + (0.75, 0.75), + (0.25, 0.75), + ], + dtype=float, + ), + face_uvs=[ + (0, 1, 2), + (0, 2, 3), + (4, 5, 6), + (7, 8, 9), + ], + name="paired_and_seamed_strip", + ) + + output, report = remesh_mesh( + source, + RemeshOptions(target_faces=10, seed_count=3, force_quads=True), + ) + + self.assertTrue(report.success) + self.assertTrue(report.quad_only_verified) + self.assertEqual(output.face_count, 10) + self.assertEqual(output.face_materials.count("paired"), 4) + self.assertEqual(output.face_materials.count("seam_a"), 3) + self.assertEqual(output.face_materials.count("seam_b"), 3) + self.assertEqual(report.closure_method, "GLOBAL_CORNER_TARGET") + self.assertIn("global corner target closure", report.message) + self.assertIsNotNone(output.uvs) + self.assertIsNotNone(output.face_uvs) + self.assertEqual(len(output.face_uvs or []), output.face_count) + self.assertTrue(all(len(face_uvs) == 4 for face_uvs in output.face_uvs or [])) + np.testing.assert_allclose(output.uvs[: len(source.uvs)], source.uvs) + + used_uvs: dict[str, set[int]] = {"paired": set(), "seam_a": set(), "seam_b": set()} + for material, face_uvs in zip(output.face_materials, output.face_uvs or [], strict=True): + used_uvs[material].update(face_uvs) + self.assertTrue({0, 1, 2, 3}.issubset(used_uvs["paired"])) + self.assertTrue({4, 5, 6}.issubset(used_uvs["seam_a"])) + self.assertTrue({7, 8, 9}.issubset(used_uvs["seam_b"])) + + def test_seed_sweep_releases_non_best_candidate_meshes_and_keeps_reports(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (1, 0, 0), (0, 1, 0)], dtype=float), + faces=[(0, 1, 2)], + face_materials=["scan"], + name="candidate_memory", + ) + vertex_refs: list[weakref.ReferenceType[np.ndarray]] = [] + live_before_each_candidate: list[int] = [] + + def fake_candidate( + _mesh: object, + _options: object, + mode: str, + seed: int, + ) -> triad_engine._Candidate: + gc.collect() + live_before_each_candidate.append(sum(reference() is not None for reference in vertex_refs)) + vertices = np.asarray( + [(seed, 0, 0), (seed + 1, 0, 0), (seed, 1, 0)], + dtype=float, + ) + vertex_refs.append(weakref.ref(vertices)) + mesh = MeshData(vertices=vertices, faces=[(0, 1, 2)], face_materials=["scan"]) + report = triad_engine.RemeshReport( + success=True, + mode=mode, + selected_seed=seed, + output_faces=1, + score=-float(seed), + ) + return triad_engine._Candidate(mesh, report) + + with patch.object(triad_engine, "_run_candidate", side_effect=fake_candidate): + output, report = remesh_mesh(source, RemeshOptions(seed_count=6)) + + gc.collect() + self.assertEqual(output.vertices[0, 0], 0.0) + self.assertLessEqual(max(live_before_each_candidate), 2) + self.assertEqual(sum(reference() is not None for reference in vertex_refs), 1) + self.assertEqual([candidate["selected_seed"] for candidate in report.candidates], list(range(6))) + json.dumps(report.to_dict(), allow_nan=False) + + def test_seed_sweep_prefers_success_over_higher_scoring_failure(self) -> None: + source = MeshData( + vertices=np.asarray([(0, 0, 0), (1, 0, 0), (0, 1, 0)], dtype=float), + faces=[(0, 1, 2)], + face_materials=["scan"], + name="candidate_success", + ) + + def fake_candidate( + _mesh: object, + _options: object, + mode: str, + seed: int, + ) -> triad_engine._Candidate: + mesh = MeshData( + vertices=np.asarray( + [(seed, 0, 0), (seed + 1, 0, 0), (seed, 1, 0)], + dtype=float, + ), + faces=[(0, 1, 2)], + face_materials=["scan"], + ) + report = triad_engine.RemeshReport( + success=seed != 0, + mode=mode, + selected_seed=seed, + output_faces=1, + score=100.0 if seed == 0 else 10.0 - seed, + message="closure failed" if seed == 0 else "", + ) + return triad_engine._Candidate(mesh, report) + + with patch.object(triad_engine, "_run_candidate", side_effect=fake_candidate): + output, report = remesh_mesh(source, RemeshOptions(seed_count=3)) + + self.assertTrue(report.success) + self.assertEqual(report.selected_seed, 1) + self.assertEqual(output.vertices[0, 0], 1.0) + self.assertEqual([candidate["success"] for candidate in report.candidates], [False, True, True]) + + def test_non_manifold_closure_fails_without_throwing(self) -> None: + source = MeshData( + vertices=np.asarray( + [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1)], + dtype=float, + ), + faces=[(0, 1, 2), (1, 0, 3), (0, 1, 4)], + face_materials=["a", "b", "c"], + name="non_manifold", + ) + _output, report = remesh_mesh( + source, + RemeshOptions(target_faces=12, seed_count=2, force_quads=True), + ) + self.assertFalse(report.success) + self.assertFalse(report.quad_only_verified) + self.assertEqual(report.vertex_fan_splits, 0) + self.assertIn("rejected", report.message.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/textures.py b/textures.py index 64c06a8..74c6cfd 100644 --- a/textures.py +++ b/textures.py @@ -58,6 +58,11 @@ def assign_image_to_material(obj: bpy.types.Object, image: bpy.types.Image, labe if not mesh.materials: mesh.materials.append(bpy.data.materials.new(name=f"{obj.name}_Material")) + # Baking must not mutate a material shared by the immutable source object. + for index, material in enumerate(list(mesh.materials)): + if material is not None and material.users > 1: + mesh.materials[index] = material.copy() + for material in mesh.materials: if material is None: continue @@ -72,6 +77,121 @@ def assign_image_to_material(obj: bpy.types.Object, image: bpy.types.Image, labe nodes.active = tex +def connect_baked_image(obj: bpy.types.Object, image: bpy.types.Image, label: str) -> None: + if label != "CurioMesh_BaseColor": + return + for material in obj.data.materials: + if material is None or not material.use_nodes: + continue + nodes = material.node_tree.nodes + tex = next( + ( + node + for node in nodes + if node.type == "TEX_IMAGE" and node.label == label and node.image == image + ), + None, + ) + principled = nodes.get("Principled BSDF") + if tex is None or principled is None or "Base Color" not in principled.inputs: + continue + color_input = principled.inputs["Base Color"] + for link in list(color_input.links): + material.node_tree.links.remove(link) + material.node_tree.links.new(tex.outputs["Color"], color_input) + + +def _bake_jobs(maps: Iterable[str]) -> list[tuple[str, str, bool]]: + jobs: list[tuple[str, str, bool]] = [] + for map_name in maps: + pass_name = str(map_name).upper() + if pass_name == "COLOR": + jobs.append(("DIFFUSE", "CurioMesh_BaseColor", True)) + elif pass_name in {"ROUGHNESS", "METALLIC", "NORMAL", "EMIT"}: + jobs.append((pass_name, f"CurioMesh_{pass_name.title()}", False)) + return jobs + + +def _execute_bake(bake_type: str) -> set[str]: + return bpy.ops.object.bake(type=bake_type) + + +def _snapshot_bake_settings(scene: bpy.types.Scene) -> list[tuple[object, str, object]]: + bake = scene.render.bake + return [ + (scene.render, "engine", scene.render.engine), + (scene.cycles, "samples", scene.cycles.samples), + (bake, "use_selected_to_active", bake.use_selected_to_active), + (bake, "cage_extrusion", bake.cage_extrusion), + (bake, "margin", bake.margin), + (bake, "use_pass_direct", bake.use_pass_direct), + (bake, "use_pass_indirect", bake.use_pass_indirect), + (bake, "use_pass_color", bake.use_pass_color), + ] + + +def _restore_properties(snapshot: list[tuple[object, str, object]]) -> None: + for owner, name, value in snapshot: + setattr(owner, name, value) + + +def _make_target_materials_private( + target_obj: bpy.types.Object, + created_materials: list[bpy.types.Material], +) -> list[bpy.types.Material | None]: + """Isolate all bake node edits so a failed bake can be rolled back exactly.""" + + mesh = target_obj.data + original_materials = list(mesh.materials) + if not original_materials: + material = bpy.data.materials.new(name=f"{target_obj.name}_Material") + created_materials.append(material) + mesh.materials.append(material) + return original_materials + + for index, material in enumerate(original_materials): + if material is None: + private_material = bpy.data.materials.new(name=f"{target_obj.name}_Material_{index + 1}") + else: + private_material = material.copy() + created_materials.append(private_material) + mesh.materials[index] = private_material + return original_materials + + +def _rollback_bake_data( + target_obj: bpy.types.Object, + original_materials: list[bpy.types.Material | None], + created_materials: list[bpy.types.Material], + created_images: list[bpy.types.Image], +) -> list[str]: + errors: list[str] = [] + mesh = target_obj.data + + try: + for index, material in enumerate(original_materials): + mesh.materials[index] = material + while len(mesh.materials) > len(original_materials): + mesh.materials.pop(index=len(mesh.materials) - 1) + except Exception as exc: + errors.append(f"material slots: {exc}") + + for material in reversed(created_materials): + try: + if bpy.data.materials.get(material.name) is material: + bpy.data.materials.remove(material, do_unlink=True) + except Exception as exc: + errors.append(f"material {getattr(material, 'name', '')}: {exc}") + + for image in reversed(created_images): + try: + if bpy.data.images.get(image.name) is image: + bpy.data.images.remove(image, do_unlink=True) + except Exception as exc: + errors.append(f"image {getattr(image, 'name', '')}: {exc}") + return errors + + def bake_selected_to_active( source_obj: bpy.types.Object, target_obj: bpy.types.Object, @@ -81,13 +201,20 @@ def bake_selected_to_active( margin: int = 8, uv_method: str = "SMART", ) -> list[bpy.types.Image]: + jobs = _bake_jobs(maps) + if not jobs: + return [] + ensure_target_uvs(target_obj, uv_method) baked: list[bpy.types.Image] = [] + created_images: list[bpy.types.Image] = [] + created_materials: list[bpy.types.Material] = [] + original_materials: list[bpy.types.Material | None] = list(target_obj.data.materials) previous_active = bpy.context.view_layer.objects.active previous_selection = list(bpy.context.selected_objects) scene = bpy.context.scene - previous_engine = scene.render.engine + bake_settings = _snapshot_bake_settings(scene) visibility_state = {} try: @@ -115,35 +242,46 @@ def bake_selected_to_active( bpy.context.view_layer.objects.active = target_obj scene.render.engine = "CYCLES" - scene.cycles.samples = max(16, int(scene.cycles.samples)) + # A selected-to-active diffuse color bake has no lighting pass and does + # not benefit from inheriting a potentially huge production render + # sample count. Keep the asset pipeline deterministic and bounded. + scene.cycles.samples = 16 scene.render.bake.use_selected_to_active = True scene.render.bake.cage_extrusion = 0.04 scene.render.bake.margin = max(0, int(margin)) + _make_target_materials_private(target_obj, created_materials) - for map_name in maps: - pass_name = str(map_name).upper() - if pass_name == "COLOR": - bake_type = "DIFFUSE" - label = "CurioMesh_BaseColor" + for bake_type, label, is_color in jobs: + if is_color: scene.render.bake.use_pass_direct = False scene.render.bake.use_pass_indirect = False scene.render.bake.use_pass_color = True - elif pass_name in {"ROUGHNESS", "METALLIC", "NORMAL", "EMIT"}: - bake_type = pass_name - label = f"CurioMesh_{pass_name.title()}" - else: - continue image = create_bake_image(f"{target_obj.name}_{label}", int(resolution)) + created_images.append(image) assign_image_to_material(target_obj, image, label) - bpy.ops.object.bake(type=bake_type) + result = _execute_bake(bake_type) + if "FINISHED" not in result: + raise RuntimeError(f"{bake_type} bake did not finish: {sorted(result)}") + connect_baked_image(target_obj, image, label) try: image.pack() except Exception: pass baked.append(image) + except Exception as bake_error: + cleanup_errors = _rollback_bake_data( + target_obj, + original_materials, + created_materials, + created_images, + ) + if cleanup_errors: + details = "; ".join(cleanup_errors) + raise RuntimeError(f"Bake failed and rollback was incomplete ({details})") from bake_error + raise finally: - scene.render.engine = previous_engine + _restore_properties(bake_settings) for obj in (source_obj, target_obj): state = visibility_state.get(obj.name) if state is None or obj.name not in bpy.data.objects: diff --git a/triadq/__init__.py b/triadq/__init__.py index fbb8b0f..42a3456 100644 --- a/triadq/__init__.py +++ b/triadq/__init__.py @@ -1,12 +1,28 @@ from .engine import RemeshOptions, RemeshReport, remesh_mesh from .io import read_obj, write_obj +from .topology import ( + TopologySummary, + component_topology_preserved, + parity_path_quad_closure, + quad_is_locally_valid, + quad_only_closure, + split_non_manifold_vertex_fans, + topology_summary, +) from .types import MeshData __all__ = [ "MeshData", "RemeshOptions", "RemeshReport", + "TopologySummary", + "component_topology_preserved", + "parity_path_quad_closure", + "quad_only_closure", + "quad_is_locally_valid", + "split_non_manifold_vertex_fans", "read_obj", "remesh_mesh", + "topology_summary", "write_obj", ] diff --git a/triadq/engine.py b/triadq/engine.py index eaa6d2a..cb66068 100644 --- a/triadq/engine.py +++ b/triadq/engine.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import Counter, defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace import math import random import time @@ -9,6 +9,13 @@ import numpy as np +from .topology import ( + parity_path_quad_closure, + quad_is_locally_valid, + quad_only_closure, + split_non_manifold_vertex_fans, + topology_summary, +) from .types import Face, MeshData @@ -42,6 +49,13 @@ class RemeshReport: feature_edges: int = 0 feature_breaks: int = 0 aspect_penalty: float = 0.0 + closure_applied: bool = False + closure_method: str = "" + vertex_fan_splits: int = 0 + quad_only_verified: bool = False + degenerate_faces: int = 0 + euler_characteristic: int = 0 + defect_charge: int = 0 score: float = 0.0 elapsed_ms: float = 0.0 message: str = "" @@ -65,6 +79,13 @@ def to_dict(self) -> dict[str, Any]: "feature_edges": self.feature_edges, "feature_breaks": self.feature_breaks, "aspect_penalty": self.aspect_penalty, + "closure_applied": self.closure_applied, + "closure_method": self.closure_method, + "vertex_fan_splits": self.vertex_fan_splits, + "quad_only_verified": self.quad_only_verified, + "degenerate_faces": self.degenerate_faces, + "euler_characteristic": self.euler_characteristic, + "defect_charge": self.defect_charge, "score": self.score, "elapsed_ms": self.elapsed_ms, "message": self.message, @@ -88,33 +109,71 @@ class _Candidate: report: RemeshReport +@dataclass(frozen=True, slots=True) +class _PairWeights: + normal: float + field: float + material: float + aspect: float + diagonal: float + minimum_score: float + + def remesh_mesh(mesh: MeshData, options: RemeshOptions | None = None) -> tuple[MeshData, RemeshReport]: start = time.perf_counter() options = options or RemeshOptions() - tri_mesh = _triangulate(mesh) + normalized_mesh = mesh + vertex_fan_splits = 0 + if options.force_quads: + normalized_mesh, vertex_fan_splits = split_non_manifold_vertex_fans(mesh) + tri_mesh = _triangulate(normalized_mesh) if not tri_mesh.faces: - return mesh, RemeshReport(False, mode="EMPTY", message="Input mesh has no faces.") + return mesh, RemeshReport( + False, + mode="EMPTY", + vertex_fan_splits=vertex_fan_splits, + message="Input mesh has no faces.", + ) mode = _classify_mesh(tri_mesh, options) - best: _Candidate | None = None + best_mesh: MeshData | None = None + best_report: RemeshReport | None = None + candidate_reports: list[dict[str, Any]] = [] seed_count = max(1, int(options.seed_count)) for seed in range(seed_count): candidate = _run_candidate(tri_mesh, options, mode, seed) + candidate.report.vertex_fan_splits = vertex_fan_splits candidate.report.candidates = [] - if best is None or candidate.report.score > best.report.score: - best = candidate - - assert best is not None + candidate_reports.append(candidate.report.to_dict()) + candidate_is_better = ( + best_report is None + or (candidate.report.success and not best_report.success) + or ( + candidate.report.success == best_report.success + and candidate.report.score > best_report.score + ) + ) + if candidate_is_better: + best_mesh = candidate.mesh + best_report = candidate.report + + assert best_mesh is not None and best_report is not None elapsed = (time.perf_counter() - start) * 1000.0 - report = best.report + report = best_report report.elapsed_ms = elapsed - report.candidates = [ - _run_candidate(tri_mesh, options, mode, seed).report.to_dict() - for seed in range(min(seed_count, 8)) - ] - report.message = "TRIAD-Q Lite remesh completed." - return best.mesh, report + report.candidates = candidate_reports[:8] + if report.success: + closure_label = { + "PARITY_PATH": "ParityPath", + "GLOBAL_CORNER_TARGET": "global corner target closure", + }.get(report.closure_method, "PairClosure") + report.message = ( + f"TRIAD-Q {closure_label} completed with a locally validated all-quad manifold." + if report.quad_only_verified + else "TRIAD-Q Lite remesh completed." + ) + return best_mesh, report def _triangulate(mesh: MeshData) -> _TriMesh: @@ -180,21 +239,120 @@ def _classify_mesh(mesh: _TriMesh, options: RemeshOptions) -> str: def _run_candidate(mesh: _TriMesh, options: RemeshOptions, mode: str, seed: int) -> _Candidate: + tuned_options = _options_for_mode(options, mode) edge_faces = _edge_faces(mesh.faces) normals, areas = _face_normals(mesh.vertices, mesh.faces) - feature_edges = _feature_edges(mesh, options, edge_faces, normals) - field = _estimate_field(mesh, edge_faces, feature_edges, normals, options) - output_mesh, feature_breaks = _pair_triangles(mesh, options, edge_faces, feature_edges, normals, field, seed) + feature_edges = _feature_edges(mesh, tuned_options, edge_faces, normals) + field = _estimate_field(mesh, edge_faces, feature_edges, normals, tuned_options) + output_mesh, feature_breaks = _pair_triangles( + mesh, + edge_faces, + feature_edges, + normals, + field, + seed, + mode, + ) + closure_applied = bool(options.force_quads) + closure_method = "" + closure_error = "" + if closure_applied: + parity_mesh: MeshData | None = None + global_mesh: MeshData | None = None + parity_error = "" + global_error = "" + try: + parity_mesh = parity_path_quad_closure( + output_mesh, + name=f"{mesh.name}_paritypath", + ) + except ValueError as exc: + parity_error = str(exc) + try: + global_mesh = quad_only_closure( + output_mesh, + name=f"{mesh.name}_pairclosure_global", + ) + except ValueError as exc: + global_error = str(exc) + + closure_candidates = [ + (candidate, method) + for candidate, method in ( + (parity_mesh, "PARITY_PATH"), + (global_mesh, "GLOBAL_CORNER_TARGET"), + ) + if candidate is not None + ] + if closure_candidates: + output_mesh, closure_method = min( + closure_candidates, + key=lambda item: ( + abs(item[0].face_count - int(options.target_faces)), + item[0].face_count, + item[1], + ), + ) + else: + closure_error = ( + f"ParityPath: {parity_error or 'no candidate'}; " + f"global fallback: {global_error or 'no candidate'}" + ) report = _score_output(output_mesh, mesh, options, mode, seed, feature_edges, feature_breaks) report.input_faces = len(mesh.faces) report.success = bool(output_mesh.faces) report.feature_edges = len(feature_edges) + report.closure_applied = closure_applied + report.closure_method = closure_method + if closure_error: + report.success = False + report.message = f"PairClosure rejected the base topology: {closure_error}" if areas.size == 0: report.success = False report.message = "No valid face areas." return _Candidate(output_mesh, report) +def _options_for_mode(options: RemeshOptions, mode: str) -> RemeshOptions: + key = mode.upper() + if key == "ORGANICFLOW": + return replace( + options, + smooth_field_iters=max(6, int(options.smooth_field_iters) + 3), + feature_angle_deg=min(175.0, float(options.feature_angle_deg) + 12.0), + ) + if key == "PATCHFLOW": + return replace( + options, + smooth_field_iters=min(2, max(0, int(options.smooth_field_iters))), + feature_angle_deg=max(5.0, float(options.feature_angle_deg) - 10.0), + ) + if key == "DIRTYFLOW": + return replace( + options, + smooth_field_iters=1, + feature_angle_deg=min(175.0, float(options.feature_angle_deg) + 15.0), + ) + if key == "TEXTUREFLOW": + return replace( + options, + smooth_field_iters=max(3, int(options.smooth_field_iters)), + feature_angle_deg=max(5.0, float(options.feature_angle_deg) - 5.0), + preserve_material_boundaries=True, + preserve_uv_seams=True, + ) + return options + + +def _pair_weights(mode: str) -> _PairWeights: + return { + "ORGANICFLOW": _PairWeights(2.6, 1.35, 0.15, 0.70, 0.25, -10.0), + "PATCHFLOW": _PairWeights(2.1, 0.70, 0.35, 2.00, 0.05, -10.0), + "DIRTYFLOW": _PairWeights(3.0, 0.35, 0.10, 1.35, 0.60, -3.0), + "TEXTUREFLOW": _PairWeights(2.2, 0.80, 0.80, 0.95, 0.35, -10.0), + }.get(mode.upper(), _PairWeights(2.2, 0.90, 0.15, 0.90, 0.35, -10.0)) + + def _edge_faces(faces: list[tuple[int, int, int]]) -> dict[tuple[int, int], list[int]]: edge_faces: dict[tuple[int, int], list[int]] = defaultdict(list) for face_index, face in enumerate(faces): @@ -281,6 +439,46 @@ def _uv_seam_edges(mesh: _TriMesh, edge_faces: dict[tuple[int, int], list[int]]) return seams +def _paired_face_uvs( + mesh: _TriMesh, + face_a: int, + face_b: int, + quad: tuple[int, ...], +) -> Face | None: + if mesh.uvs is None or mesh.face_uvs is None: + return None + + maps: list[dict[int, int]] = [] + for face_index in (face_a, face_b): + face = mesh.faces[face_index] + uv_face = mesh.face_uvs[face_index] + if len(uv_face) != len(face): + return None + maps.append(dict(zip(face, uv_face, strict=True))) + + quad_uvs: list[int] = [] + for vertex in quad: + values = [mapping[vertex] for mapping in maps if vertex in mapping] + if not values or any(value < 0 or value >= len(mesh.uvs) for value in values): + return None + if any(value != values[0] for value in values[1:]): + return None + quad_uvs.append(values[0]) + return tuple(quad_uvs) + + +def _source_face_uvs(mesh: _TriMesh, face_index: int) -> Face | None: + if mesh.uvs is None or mesh.face_uvs is None: + return None + face = mesh.faces[face_index] + uv_face = mesh.face_uvs[face_index] + if len(uv_face) != len(face): + return None + if any(value < 0 or value >= len(mesh.uvs) for value in uv_face): + return None + return tuple(uv_face) + + def _estimate_field( mesh: _TriMesh, edge_faces: dict[tuple[int, int], list[int]], @@ -348,13 +546,14 @@ def _safe_normalize(vec: np.ndarray, *, fallback: np.ndarray) -> np.ndarray: def _pair_triangles( mesh: _TriMesh, - options: RemeshOptions, edge_faces: dict[tuple[int, int], list[int]], feature_edges: set[tuple[int, int]], normals: np.ndarray, field: np.ndarray, seed: int, + mode: str, ) -> tuple[MeshData, int]: + weights = _pair_weights(mode) candidates: dict[int, list[tuple[float, int, tuple[int, ...]]]] = defaultdict(list) feature_breaks = 0 for edge, linked_faces in edge_faces.items(): @@ -366,7 +565,9 @@ def _pair_triangles( quad = _quad_from_pair(mesh.faces[a], mesh.faces[b], edge) if quad is None: continue - score = _pair_score(mesh, quad, edge, a, b, normals, field) + if not quad_is_locally_valid(mesh.vertices, quad): + continue + score = _pair_score(mesh, quad, edge, a, b, normals, field, weights) candidates[a].append((score, b, quad)) candidates[b].append((score, a, quad)) @@ -375,13 +576,14 @@ def _pair_triangles( used: set[int] = set() output_faces: list[Face] = [] output_materials: list[str] = [] + output_face_uvs: list[Face] | None = [] if mesh.uvs is not None and mesh.face_uvs is not None else None for face_index in order: if face_index in used: continue options_for_face = sorted(candidates.get(face_index, []), key=lambda item: item[0], reverse=True) selected = None for score, other, quad in options_for_face: - if other not in used and score > -10.0: + if other not in used and score > weights.minimum_score: selected = (other, quad) break if selected is None: @@ -391,22 +593,32 @@ def _pair_triangles( used.add(other) output_faces.append(tuple(quad)) output_materials.append(_majority_material(mesh.face_materials[face_index], mesh.face_materials[other])) + if output_face_uvs is not None: + paired_uvs = _paired_face_uvs(mesh, face_index, other, quad) + if paired_uvs is None: + output_face_uvs = None + else: + output_face_uvs.append(paired_uvs) vertices = mesh.vertices.tolist() for face_index, tri in enumerate(mesh.faces): if face_index in used: continue - if options.force_quads: - quad, vertices = _split_triangle_to_quad(tri, np.asarray(vertices, dtype=np.float64)) - output_faces.append(tuple(quad)) - else: - output_faces.append(tuple(tri)) + output_faces.append(tuple(tri)) output_materials.append(mesh.face_materials[face_index]) + if output_face_uvs is not None: + source_uvs = _source_face_uvs(mesh, face_index) + if source_uvs is None: + output_face_uvs = None + else: + output_face_uvs.append(source_uvs) out = MeshData( vertices=np.asarray(vertices, dtype=np.float64), faces=output_faces, face_materials=output_materials, + uvs=mesh.uvs, + face_uvs=output_face_uvs, name=f"{mesh.name}_triadq", ) return out, feature_breaks @@ -422,7 +634,29 @@ def _quad_from_pair( opp_b = [v for v in face_b if v not in shared] if len(opp_a) != 1 or len(opp_b) != 1: return None - a, b = shared_edge + + # The shared edge key is sorted for adjacency lookup. Recover its directed + # order in each face so the merged quad keeps the source winding. + directed_a = next( + ( + (face_a[index], face_a[(index + 1) % 3]) + for index in range(3) + if {face_a[index], face_a[(index + 1) % 3]} == shared + ), + None, + ) + directed_b = next( + ( + (face_b[index], face_b[(index + 1) % 3]) + for index in range(3) + if {face_b[index], face_b[(index + 1) % 3]} == shared + ), + None, + ) + if directed_a is None or directed_b != (directed_a[1], directed_a[0]): + return None + + a, b = directed_a quad = (opp_a[0], a, opp_b[0], b) if len(set(quad)) != 4: return None @@ -437,6 +671,7 @@ def _pair_score( face_b: int, normals: np.ndarray, field: np.ndarray, + weights: _PairWeights, ) -> float: normal_alignment = max(-1.0, min(1.0, float(np.dot(normals[face_a], normals[face_b])))) aspect = _quad_aspect(mesh.vertices, quad) @@ -445,17 +680,39 @@ def _pair_score( shared_len = float(np.linalg.norm(mesh.vertices[shared_edge[1]] - mesh.vertices[shared_edge[0]])) perimeter = _quad_perimeter(mesh.vertices, quad) diagonal_penalty = shared_len / max(perimeter * 0.25, 1e-9) - material_bonus = 0.15 if mesh.face_materials[face_a] == mesh.face_materials[face_b] else -0.75 + material_bonus = weights.material if mesh.face_materials[face_a] == mesh.face_materials[face_b] else -1.5 field_score = _field_alignment(mesh.vertices, quad, field[face_a], normals[face_a]) + jacobian = _quad_min_scaled_jacobian(mesh.vertices, quad) return ( - normal_alignment * 2.2 - + field_score * 0.9 + normal_alignment * weights.normal + + field_score * weights.field + + jacobian * 3.0 + material_bonus - - math.log(max(aspect, 1.0)) * 0.9 - - diagonal_penalty * 0.35 + - math.log(max(aspect, 1.0)) * weights.aspect + - diagonal_penalty * weights.diagonal ) +def _quad_min_scaled_jacobian(vertices: np.ndarray, quad: tuple[int, int, int, int]) -> float: + points = vertices[list(quad)] + normal = np.zeros(3, dtype=np.float64) + for index in range(4): + normal += np.cross(points[index], points[(index + 1) % 4]) + normal_length = float(np.linalg.norm(normal)) + if normal_length <= 1e-20: + return -1.0 + normal /= normal_length + minimum = 1.0 + for index in range(4): + following = points[(index + 1) % 4] - points[index] + previous = points[(index - 1) % 4] - points[index] + denominator = float(np.linalg.norm(following) * np.linalg.norm(previous)) + if denominator <= 1e-20: + return -1.0 + minimum = min(minimum, float(np.dot(np.cross(following, previous), normal)) / denominator) + return minimum + + def _quad_aspect(vertices: np.ndarray, quad: tuple[int, int, int, int]) -> float: lengths = [] for i in range(4): @@ -488,20 +745,6 @@ def _majority_material(a: str, b: str) -> str: return a if a == b else a -def _split_triangle_to_quad(tri: tuple[int, int, int], vertices: np.ndarray) -> tuple[tuple[int, int, int, int], list[list[float]]]: - a, b, c = tri - pairs = [((a, b), c), ((b, c), a), ((c, a), b)] - edge, opposite = max( - pairs, - key=lambda item: float(np.linalg.norm(vertices[item[0][1]] - vertices[item[0][0]])), - ) - midpoint = (vertices[edge[0]] + vertices[edge[1]]) * 0.5 - new_vertices = vertices.tolist() - new_index = len(new_vertices) - new_vertices.append(midpoint.tolist()) - return (opposite, edge[0], new_index, edge[1]), new_vertices - - def _score_output( output: MeshData, source: _TriMesh, @@ -515,12 +758,11 @@ def _score_output( quads = sum(1 for face in output.faces if len(face) == 4) tris = sum(1 for face in output.faces if len(face) == 3) quad_ratio = quads / max(1, face_count) - edge_counts = _poly_edge_counts(output.faces) - boundary = sum(1 for count in edge_counts.values() if count == 1) - non_manifold = sum(1 for count in edge_counts.values() if count > 2) - valence = Counter(v for face in output.faces for v in face) - extraordinary = sum(1 for count in valence.values() if count != 4) - extraordinary_ratio = extraordinary / max(1, output.vertex_count) + summary = topology_summary(output) + boundary = summary.boundary_edges + non_manifold = summary.non_manifold_edges + extraordinary = summary.extraordinary_vertices + extraordinary_ratio = extraordinary / max(1, summary.used_vertices) aspect_penalty = _aspect_penalty(output) target_error = abs(face_count - int(options.target_faces)) / max(1.0, float(options.target_faces)) score = ( @@ -547,6 +789,10 @@ def _score_output( feature_edges=len(feature_edges), feature_breaks=feature_breaks, aspect_penalty=aspect_penalty, + quad_only_verified=summary.all_quads, + degenerate_faces=summary.degenerate_faces, + euler_characteristic=summary.euler_characteristic, + defect_charge=summary.defect_charge, score=score, input_faces=len(source.faces), ) diff --git a/triadq/topology.py b/triadq/topology.py new file mode 100644 index 0000000..4bdfbc6 --- /dev/null +++ b/triadq/topology.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +from collections import Counter, defaultdict, deque +from dataclasses import asdict, dataclass +import math +from typing import Any + +import numpy as np + +from .types import Face, MeshData + + +MAX_QUAD_WARP_DEGREES = 60.0 + + +@dataclass(slots=True) +class TopologySummary: + used_vertices: int = 0 + edges: int = 0 + faces: int = 0 + quads: int = 0 + boundary_edges: int = 0 + boundary_components: int = 0 + connected_components: int = 0 + component_euler: tuple[int, ...] = () + component_signature: tuple[tuple[int, int], ...] = () + non_manifold_edges: int = 0 + non_manifold_vertices: int = 0 + branched_boundary_vertices: int = 0 + inconsistent_edges: int = 0 + duplicate_faces: int = 0 + euler_characteristic: int = 0 + extraordinary_vertices: int = 0 + defect_charge: int = 0 + degenerate_faces: int = 0 + invalid_quads: int = 0 + all_quads: bool = False + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _edge_occurrences( + faces: list[Face], +) -> dict[tuple[int, int], list[tuple[int, int, int]]]: + occurrences: dict[tuple[int, int], list[tuple[int, int, int]]] = defaultdict(list) + for face_index, face in enumerate(faces): + for index in range(len(face)): + a = face[index] + b = face[(index + 1) % len(face)] + occurrences[tuple(sorted((a, b)))].append((face_index, a, b)) + return occurrences + + +def _boundary_components(boundary_edges: list[tuple[int, int]]) -> tuple[int, int]: + if not boundary_edges: + return 0, 0 + adjacency: dict[int, set[int]] = defaultdict(set) + for a, b in boundary_edges: + adjacency[a].add(b) + adjacency[b].add(a) + remaining = set(adjacency) + components = 0 + while remaining: + components += 1 + stack = [remaining.pop()] + while stack: + vertex = stack.pop() + linked = adjacency[vertex] & remaining + remaining.difference_update(linked) + stack.extend(linked) + branched_vertices = sum(1 for neighbors in adjacency.values() if len(neighbors) != 2) + return components, branched_vertices + + +def _canonical_face(face: Face) -> Face: + """Return a rotation- and winding-independent face key.""" + + rotations = [face[index:] + face[:index] for index in range(len(face))] + reverse = tuple(reversed(face)) + rotations.extend(reverse[index:] + reverse[:index] for index in range(len(reverse))) + return min(rotations) + + +def _component_topology_signatures( + faces: list[Face], + edge_occurrences: dict[tuple[int, int], list[tuple[int, int, int]]], +) -> tuple[tuple[int, int], ...]: + face_count = len(faces) + if face_count == 0: + return () + adjacency: list[set[int]] = [set() for _ in range(face_count)] + for occurrences in edge_occurrences.values(): + face_indices = {item[0] for item in occurrences} + for face_index in face_indices: + adjacency[face_index].update(face_indices - {face_index}) + remaining = set(range(face_count)) + signatures: list[tuple[int, int]] = [] + while remaining: + seed = remaining.pop() + component_faces = {seed} + stack = [seed] + while stack: + linked = adjacency[stack.pop()] & remaining + remaining.difference_update(linked) + stack.extend(linked) + component_faces.update(linked) + vertices = {vertex for face_index in component_faces for vertex in faces[face_index]} + edges: set[tuple[int, int]] = set() + for face_index in component_faces: + face = faces[face_index] + edges.update( + tuple(sorted((face[index], face[(index + 1) % len(face)]))) + for index in range(len(face)) + ) + boundary_edges = [ + edge + for edge in edges + if len(edge_occurrences[edge]) == 1 + ] + boundary_cycles, _branched_vertices = _boundary_components(boundary_edges) + euler_characteristic = len(vertices) - len(edges) + len(component_faces) + signatures.append((euler_characteristic, boundary_cycles)) + return tuple(sorted(signatures)) + + +def component_topology_preserved( + source: tuple[tuple[int, int], ...], + candidate: tuple[tuple[int, int], ...], +) -> bool: + """Return whether component Euler/boundary signatures match exactly. + + The signatures are sorted, so component ordering and mesh indices do not + affect the comparison. Pairing both invariants prevents boundary cycles + from moving between equal-Euler components without detection. + """ + + return tuple(source) == tuple(candidate) + + +def _non_manifold_vertex_count( + faces: list[Face], + edge_occurrences: dict[tuple[int, int], list[tuple[int, int, int]]], +) -> int: + """Count vertices whose incident faces form more than one edge-connected fan.""" + + incident: dict[int, set[int]] = defaultdict(set) + fan_adjacency: dict[int, dict[int, set[int]]] = defaultdict(lambda: defaultdict(set)) + for face_index, face in enumerate(faces): + for vertex in set(face): + incident[vertex].add(face_index) + for edge, occurrences in edge_occurrences.items(): + face_indices = {item[0] for item in occurrences} + if len(face_indices) != 2: + continue + first, second = tuple(face_indices) + for vertex in edge: + fan_adjacency[vertex][first].add(second) + fan_adjacency[vertex][second].add(first) + + non_manifold = 0 + for vertex, incident_faces in incident.items(): + if not incident_faces: + continue + seen = {next(iter(incident_faces))} + queue = deque(seen) + while queue: + for linked in fan_adjacency[vertex][queue.popleft()]: + if linked in incident_faces and linked not in seen: + seen.add(linked) + queue.append(linked) + if seen != incident_faces: + non_manifold += 1 + return non_manifold + + +def _face_area(vertices: np.ndarray, face: Face) -> float: + if len(face) < 3 or len(set(face)) != len(face): + return 0.0 + origin = vertices[face[0]] + area_vector = np.zeros(3, dtype=np.float64) + for index in range(1, len(face) - 1): + area_vector += np.cross(vertices[face[index]] - origin, vertices[face[index + 1]] - origin) + return 0.5 * float(np.linalg.norm(area_vector)) + + +def _segments_cross(a: np.ndarray, b: np.ndarray, c: np.ndarray, d: np.ndarray, epsilon: float) -> bool: + def orientation(p: np.ndarray, q: np.ndarray, r: np.ndarray) -> float: + return float((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])) + + first = orientation(a, b, c) + second = orientation(a, b, d) + third = orientation(c, d, a) + fourth = orientation(c, d, b) + return ( + ((first > epsilon and second < -epsilon) or (first < -epsilon and second > epsilon)) + and ((third > epsilon and fourth < -epsilon) or (third < -epsilon and fourth > epsilon)) + ) + + +def _quad_is_locally_valid(vertices: np.ndarray, face: Face, epsilon: float) -> bool: + if len(face) != 4 or len(set(face)) != 4: + return False + points = vertices[list(face)] + if not np.all(np.isfinite(points)): + return False + + normal = np.zeros(3, dtype=np.float64) + for index in range(4): + current = points[index] + following = points[(index + 1) % 4] + normal += np.cross(current, following) + normal_length = float(np.linalg.norm(normal)) + if normal_length <= epsilon: + return False + normal /= normal_length + + def triangle_normal(first: int, second: int, third: int) -> np.ndarray | None: + candidate = np.cross(points[second] - points[first], points[third] - points[first]) + length = float(np.linalg.norm(candidate)) + return None if length <= epsilon else candidate / length + + warp_cosine = math.cos(math.radians(MAX_QUAD_WARP_DEGREES)) + for first_triangle, second_triangle in ( + ((0, 1, 2), (0, 2, 3)), + ((1, 2, 3), (1, 3, 0)), + ): + first_normal = triangle_normal(*first_triangle) + second_normal = triangle_normal(*second_triangle) + if first_normal is None or second_normal is None: + return False + if float(np.dot(first_normal, second_normal)) < warp_cosine: + return False + + for index in range(4): + current = points[index] + following = points[(index + 1) % 4] - current + previous = points[(index - 1) % 4] - current + denominator = float(np.linalg.norm(following) * np.linalg.norm(previous)) + if denominator <= epsilon: + return False + scaled_jacobian = float(np.dot(np.cross(following, previous), normal)) / denominator + if not math.isfinite(scaled_jacobian) or scaled_jacobian <= 1e-8: + return False + + projection_axis = int(np.argmax(np.abs(normal))) + projected = np.delete(points, projection_axis, axis=1) + if _segments_cross(projected[0], projected[1], projected[2], projected[3], epsilon): + return False + if _segments_cross(projected[1], projected[2], projected[3], projected[0], epsilon): + return False + return True + + +def quad_is_locally_valid(vertices: np.ndarray, face: Face) -> bool: + """Validate one quad's finite, convex, non-crossed local geometry.""" + + coordinates = np.asarray(vertices, dtype=np.float64) + if not len(coordinates): + return False + span = np.ptp(coordinates, axis=0) + epsilon = max(float(np.dot(span, span)) * 1e-14, 1e-20) + return _quad_is_locally_valid(coordinates, face, epsilon) + + +def topology_summary(mesh: MeshData) -> TopologySummary: + edge_occurrences = _edge_occurrences(mesh.faces) + edge_counts = Counter({edge: len(items) for edge, items in edge_occurrences.items()}) + used_vertices = {vertex for face in mesh.faces for vertex in face} + adjacency: dict[int, set[int]] = defaultdict(set) + for a, b in edge_counts: + adjacency[a].add(b) + adjacency[b].add(a) + + boundary_edges = [edge for edge, count in edge_counts.items() if count == 1] + boundary_components, branched_boundary_vertices = _boundary_components(boundary_edges) + non_manifold_edges = sum(1 for count in edge_counts.values() if count > 2) + inconsistent_edges = sum( + 1 + for occurrences in edge_occurrences.values() + if len(occurrences) == 2 + and occurrences[0][1:] == occurrences[1][1:] + ) + duplicate_faces = len(mesh.faces) - len({_canonical_face(face) for face in mesh.faces}) + non_manifold_vertices = _non_manifold_vertex_count(mesh.faces, edge_occurrences) + boundary_vertices = {vertex for edge in boundary_edges for vertex in edge} + extraordinary = 0 + defect_charge = 0 + for vertex in used_vertices: + valence = len(adjacency[vertex]) + ideal = 3 if vertex in boundary_vertices else 4 + if valence != ideal: + extraordinary += 1 + defect_charge += ideal - valence + + if len(mesh.vertices): + span = np.ptp(np.asarray(mesh.vertices, dtype=np.float64), axis=0) + area_epsilon = max(float(np.dot(span, span)) * 1e-14, 1e-20) + else: + area_epsilon = 1e-20 + degenerate = sum(1 for face in mesh.faces if _face_area(mesh.vertices, face) <= area_epsilon) + quads = sum(1 for face in mesh.faces if len(face) == 4) + invalid_quads = sum( + 1 + for face in mesh.faces + if len(face) == 4 and not _quad_is_locally_valid(mesh.vertices, face, area_epsilon) + ) + component_signature = _component_topology_signatures(mesh.faces, edge_occurrences) + component_euler = tuple(signature[0] for signature in component_signature) + return TopologySummary( + used_vertices=len(used_vertices), + edges=len(edge_counts), + faces=len(mesh.faces), + quads=quads, + boundary_edges=len(boundary_edges), + boundary_components=boundary_components, + connected_components=len(component_euler), + component_euler=component_euler, + component_signature=component_signature, + non_manifold_edges=non_manifold_edges, + non_manifold_vertices=non_manifold_vertices, + branched_boundary_vertices=branched_boundary_vertices, + inconsistent_edges=inconsistent_edges, + duplicate_faces=duplicate_faces, + euler_characteristic=len(used_vertices) - len(edge_counts) + len(mesh.faces), + extraordinary_vertices=extraordinary, + defect_charge=defect_charge, + degenerate_faces=degenerate, + invalid_quads=invalid_quads, + all_quads=( + bool(mesh.faces) + and quads == len(mesh.faces) + and degenerate == 0 + and non_manifold_edges == 0 + and non_manifold_vertices == 0 + and branched_boundary_vertices == 0 + and inconsistent_edges == 0 + and duplicate_faces == 0 + and invalid_quads == 0 + ), + ) + + +def split_non_manifold_vertex_fans(mesh: MeshData) -> tuple[MeshData, int]: + """Duplicate vertices whose incident faces form disconnected edge fans. + + A vertex-only bow-tie is not a manifold surface even when every edge is + used by at most two faces. The repair is deliberately coordinate + preserving: each additional fan receives a duplicate of the original + vertex, while face order, materials, and face-corner UV indices remain + unchanged. The returned count is the number of duplicate vertices added. + + This function does not attempt to repair non-manifold edges, duplicate + faces, or geometric degeneracy. Callers should gate those conditions + separately before treating the result as a certifiable manifold. + """ + + if not mesh.faces: + return mesh, 0 + + edge_occurrences = _edge_occurrences(mesh.faces) + if any(len(occurrences) > 2 for occurrences in edge_occurrences.values()): + return mesh, 0 + + incident_faces: dict[int, set[int]] = defaultdict(set) + vertex_edge_faces: dict[int, dict[int, set[int]]] = defaultdict( + lambda: defaultdict(set) + ) + for face_index, face in enumerate(mesh.faces): + for vertex in set(face): + incident_faces[vertex].add(face_index) + for corner, vertex in enumerate(face): + following = face[(corner + 1) % len(face)] + vertex_edge_faces[vertex][following].add(face_index) + vertex_edge_faces[following][vertex].add(face_index) + + duplicate_coordinates: list[np.ndarray] = [] + remapped_corners: dict[tuple[int, int], int] = {} + + for vertex in sorted(incident_faces): + local_faces = incident_faces[vertex] + if len(local_faces) <= 1: + continue + + adjacency: dict[int, set[int]] = {face_index: set() for face_index in local_faces} + for sharing_faces in vertex_edge_faces[vertex].values(): + linked = sorted(sharing_faces & local_faces) + if len(linked) < 2: + continue + anchor = linked[0] + for other in linked[1:]: + adjacency[anchor].add(other) + adjacency[other].add(anchor) + + remaining = set(local_faces) + fans: list[list[int]] = [] + while remaining: + seed = min(remaining) + remaining.remove(seed) + fan = [seed] + queue = deque([seed]) + while queue: + linked = adjacency[queue.popleft()] & remaining + for face_index in sorted(linked): + remaining.remove(face_index) + queue.append(face_index) + fan.append(face_index) + fans.append(sorted(fan)) + + fans.sort(key=lambda fan: fan[0]) + for fan in fans[1:]: + duplicate = mesh.vertex_count + len(duplicate_coordinates) + duplicate_coordinates.append(np.array(mesh.vertices[vertex], dtype=np.float64, copy=True)) + for face_index in fan: + remapped_corners[(face_index, vertex)] = duplicate + + if not duplicate_coordinates: + return mesh, 0 + output_vertices = np.vstack( + (mesh.vertices, np.asarray(duplicate_coordinates, dtype=np.float64)) + ) + output_faces = [ + tuple(remapped_corners.get((face_index, vertex), vertex) for vertex in face) + for face_index, face in enumerate(mesh.faces) + ] + return ( + mesh.copy_with( + vertices=output_vertices, + faces=output_faces, + ), + len(duplicate_coordinates), + ) + + +def _toggle_edge(edges: set[tuple[int, int]], edge: tuple[int, int]) -> None: + """Toggle an edge in a GF(2) edge set.""" + + if edge in edges: + edges.remove(edge) + else: + edges.add(edge) + + +def _parity_path_edges(mesh: MeshData) -> set[tuple[int, int]]: + """Solve sparse transition parity on the quadrilateral dual graph. + + Every triangle first marks its three edges. That gives each residual + triangle the robust six-corner template used by global corner closure, + while allowing already-valid quads to remain untouched. Quads incident to + an odd number of marked edges are then terminals of a T-join over the + quad-only dual, with mesh boundaries acting as exterior terminals. Paths + are combined by symmetric difference, so overlaps cancel exactly. + """ + + occurrences = _edge_occurrences(mesh.faces) + selected: set[tuple[int, int]] = { + tuple(sorted((face[corner], face[(corner + 1) % len(face)]))) + for face in mesh.faces + if len(face) == 3 + for corner in range(3) + } + adjacency: list[list[tuple[int, tuple[int, int]]]] = [[] for _face in mesh.faces] + boundary_by_face: dict[int, list[tuple[int, int]]] = defaultdict(list) + for edge, uses in occurrences.items(): + if len(uses) > 2: + raise ValueError(f"Edge {edge} has {len(uses)} incident faces.") + if len(uses) == 1: + face_index = uses[0][0] + if len(mesh.faces[face_index]) == 4: + boundary_by_face[face_index].append(edge) + elif len(uses) == 2: + first, second = uses[0][0], uses[1][0] + if len(mesh.faces[first]) == 4 and len(mesh.faces[second]) == 4: + adjacency[first].append((second, edge)) + adjacency[second].append((first, edge)) + + for linked in adjacency: + linked.sort(key=lambda item: (item[0], item[1])) + for edges in boundary_by_face.values(): + edges.sort() + + terminals = { + face_index + for face_index, face in enumerate(mesh.faces) + if len(face) == 4 + and sum( + tuple(sorted((face[corner], face[(corner + 1) % len(face)]))) in selected + for corner in range(4) + ) + % 2 + } + while terminals: + start = min(terminals) + distances = {start: 0} + parents: dict[int, tuple[int, tuple[int, int]]] = {} + queue = deque([start]) + while queue: + current = queue.popleft() + for linked, edge in adjacency[current]: + if linked in distances: + continue + distances[linked] = distances[current] + 1 + parents[linked] = (current, edge) + queue.append(linked) + + # Pairing to another transition quad costs one edge per dual step. + # Reaching the exterior additionally costs one boundary edge. On an + # equal cost, prefer face-to-face pairing because it discharges two + # terminals. + candidates: list[tuple[int, int, int, tuple[int, int], int | None]] = [] + for target in sorted(terminals - {start}): + if target in distances: + candidates.append((distances[target], 0, target, (-1, -1), target)) + for target, boundary_edges in sorted(boundary_by_face.items()): + if target not in distances: + continue + for edge in boundary_edges: + candidates.append((distances[target] + 1, 1, target, edge, None)) + + if not candidates: + raise ValueError( + "Transition parity cannot be discharged through quad-only " + "adjacency or the mesh boundary." + ) + + _cost, _kind, target, exterior_edge, paired_terminal = min(candidates) + current = target + while current != start: + parent, edge = parents[current] + _toggle_edge(selected, edge) + current = parent + if paired_terminal is None: + _toggle_edge(selected, exterior_edge) + terminals.remove(start) + else: + terminals.remove(start) + terminals.remove(paired_terminal) + + for face_index, face in enumerate(mesh.faces): + split_count = sum( + tuple(sorted((face[corner], face[(corner + 1) % len(face)]))) in selected + for corner in range(len(face)) + ) + if (len(face) + split_count) % 2: + raise ValueError(f"Parity solve failed on face {face_index}.") + return selected + + +def parity_path_quad_closure(mesh: MeshData, *, name: str | None = None) -> MeshData: + """Convert a triangle/quad mesh with a sparse parity-path closure. + + Valid unaffected quads remain one face. Odd triangle cells are connected + in the dual graph or routed to a boundary; only edges on those paths are + split. Shared geometry midpoints prevent T-junctions while face-local UV + interpolation preserves seams. Every local patch and the final component + Euler/boundary signature are validated transactionally. + """ + + if not mesh.faces: + raise ValueError("ParityPath closure requires at least one face.") + if any(len(face) not in {3, 4} for face in mesh.faces): + raise ValueError("ParityPath closure accepts triangle/quad meshes only.") + + source_summary = topology_summary(mesh) + source_errors = { + "degenerate_faces": source_summary.degenerate_faces, + "invalid_quads": source_summary.invalid_quads, + "non_manifold_edges": source_summary.non_manifold_edges, + "non_manifold_vertices": source_summary.non_manifold_vertices, + "branched_boundary_vertices": source_summary.branched_boundary_vertices, + "inconsistent_edges": source_summary.inconsistent_edges, + "duplicate_faces": source_summary.duplicate_faces, + } + failed = {key: value for key, value in source_errors.items() if value} + if failed: + details = ", ".join(f"{key}={value}" for key, value in failed.items()) + raise ValueError(f"ParityPath source topology is not certifiable: {details}.") + + selected_edges = _parity_path_edges(mesh) + vertices = np.asarray(mesh.vertices, dtype=np.float64) + output_vertices = vertices.tolist() + midpoint_indices: dict[tuple[int, int], int] = {} + for edge in sorted(selected_edges): + midpoint_indices[edge] = len(output_vertices) + output_vertices.append(((vertices[edge[0]] + vertices[edge[1]]) * 0.5).tolist()) + + output_faces: list[Face] = [] + output_materials: list[str] = [] + output_uvs = None if mesh.uvs is None else np.asarray(mesh.uvs, dtype=np.float64).tolist() + output_face_uvs: list[Face] | None = ( + [] if output_uvs is not None and mesh.face_uvs is not None else None + ) + + def patch_is_valid(patch: list[Face]) -> bool: + # Validate each cell in local indexing. Rebuilding a dense array from + # every accumulated output vertex for every face is quadratic on large + # scans and provides no additional geometric information. + return all( + quad_is_locally_valid( + np.asarray([output_vertices[index] for index in quad], dtype=np.float64), + (0, 1, 2, 3), + ) + for quad in patch + ) + + for face_index, face in enumerate(mesh.faces): + material = ( + mesh.face_materials[face_index] + if face_index < len(mesh.face_materials) + else "default" + ) + augmented: list[int] = [] + face_uv: Face | None = None + augmented_uv: list[int] = [] + if output_face_uvs is not None and output_uvs is not None and mesh.face_uvs is not None: + candidate_uv = mesh.face_uvs[face_index] + if ( + len(candidate_uv) == len(face) + and mesh.uvs is not None + and all(0 <= uv < len(mesh.uvs) for uv in candidate_uv) + ): + face_uv = candidate_uv + else: + output_face_uvs = None + + split_count = 0 + for corner, vertex in enumerate(face): + following = face[(corner + 1) % len(face)] + edge = tuple(sorted((vertex, following))) + augmented.append(vertex) + if face_uv is not None: + augmented_uv.append(face_uv[corner]) + if edge not in selected_edges: + continue + split_count += 1 + augmented.append(midpoint_indices[edge]) + if face_uv is not None and output_uvs is not None and mesh.uvs is not None: + following_uv = face_uv[(corner + 1) % len(face_uv)] + uv_midpoint = len(output_uvs) + output_uvs.append( + ((mesh.uvs[face_uv[corner]] + mesh.uvs[following_uv]) * 0.5).tolist() + ) + augmented_uv.append(uv_midpoint) + + patch: list[Face] | None = None + patch_uvs: list[Face] | None = None + if len(face) == 4 and split_count == 0: + candidate_patch = [tuple(face)] + if patch_is_valid(candidate_patch): + patch = candidate_patch + if face_uv is not None: + patch_uvs = [tuple(face_uv)] + + # A six-corner transition cell can sometimes be cut directly into two + # valid quads. Try each opposite boundary diagonal before spending a + # center vertex and a third quad. + if patch is None and len(face) == 4 and split_count == 2: + for offset in range(3): + candidate_patch = [ + tuple(augmented[(offset + step) % 6] for step in range(4)), + tuple(augmented[(offset + 3 + step) % 6] for step in range(4)), + ] + if not patch_is_valid(candidate_patch): + continue + patch = candidate_patch + if face_uv is not None: + patch_uvs = [ + tuple(augmented_uv[(offset + step) % 6] for step in range(4)), + tuple(augmented_uv[(offset + 3 + step) % 6] for step in range(4)), + ] + break + + if patch is None: + if len(augmented) % 2 or len(augmented) < 4: + raise ValueError( + f"Face {face_index} has no even transition boundary " + f"(corners={len(augmented)})." + ) + center = len(output_vertices) + output_vertices.append(np.mean(vertices[list(face)], axis=0).tolist()) + center_uv = -1 + if face_uv is not None and output_uvs is not None and mesh.uvs is not None: + center_uv = len(output_uvs) + output_uvs.append(np.mean(mesh.uvs[list(face_uv)], axis=0).tolist()) + + cell_count = len(augmented) // 2 + for offset in (0, 1): + candidate_patch = [ + ( + center, + augmented[(offset + 2 * cell) % len(augmented)], + augmented[(offset + 2 * cell + 1) % len(augmented)], + augmented[(offset + 2 * cell + 2) % len(augmented)], + ) + for cell in range(cell_count) + ] + if not patch_is_valid(candidate_patch): + continue + patch = candidate_patch + if face_uv is not None: + patch_uvs = [ + ( + center_uv, + augmented_uv[(offset + 2 * cell) % len(augmented_uv)], + augmented_uv[(offset + 2 * cell + 1) % len(augmented_uv)], + augmented_uv[(offset + 2 * cell + 2) % len(augmented_uv)], + ) + for cell in range(cell_count) + ] + break + + if patch is None: + raise ValueError( + f"Face {face_index} has no locally valid ParityPath quad template." + ) + output_faces.extend(patch) + output_materials.extend([material] * len(patch)) + if output_face_uvs is not None: + if patch_uvs is None: + output_face_uvs = None + else: + output_face_uvs.extend(patch_uvs) + + result = MeshData( + vertices=np.asarray(output_vertices, dtype=np.float64), + faces=output_faces, + face_materials=output_materials, + uvs=None if output_uvs is None else np.asarray(output_uvs, dtype=np.float64), + face_uvs=output_face_uvs, + name=name or f"{mesh.name}_parity_path_closure", + ) + summary = topology_summary(result) + if not summary.all_quads: + raise ValueError( + "ParityPath closure failed validation: " + f"quads={summary.quads}/{summary.faces}, degenerate={summary.degenerate_faces}, " + f"invalid_quads={summary.invalid_quads}, non_manifold_edges={summary.non_manifold_edges}, " + f"non_manifold_vertices={summary.non_manifold_vertices}, " + f"branched_boundary_vertices={summary.branched_boundary_vertices}, " + f"inconsistent_edges={summary.inconsistent_edges}, duplicate_faces={summary.duplicate_faces}." + ) + if not component_topology_preserved( + source_summary.component_signature, + summary.component_signature, + ): + raise ValueError( + "ParityPath closure changed the component Euler/boundary signature: " + f"{source_summary.component_signature} -> {summary.component_signature}." + ) + return result + + +def quad_only_closure(mesh: MeshData, *, name: str | None = None) -> MeshData: + """Conformingly split every polygon corner into a quad. + + Edge midpoints are shared globally, so adjacent faces receive matching + topology. UV midpoints remain face-local, which preserves UV seams. + """ + + vertices = np.asarray(mesh.vertices, dtype=np.float64) + output_vertices = vertices.tolist() + edge_midpoints: dict[tuple[int, int], int] = {} + output_faces: list[Face] = [] + output_materials: list[str] = [] + + output_uvs = None if mesh.uvs is None else np.asarray(mesh.uvs, dtype=np.float64).tolist() + output_face_uvs: list[Face] | None = [] if output_uvs is not None and mesh.face_uvs is not None else None + + for face_index, face in enumerate(mesh.faces): + if len(face) < 3: + raise ValueError(f"Face {face_index} has fewer than three corners.") + if len(set(face)) != len(face): + raise ValueError(f"Face {face_index} repeats a vertex and cannot be closed safely.") + if any(vertex < 0 or vertex >= len(vertices) for vertex in face): + raise ValueError(f"Face {face_index} has an out-of-range vertex index.") + + mids: list[int] = [] + for corner, vertex in enumerate(face): + following = face[(corner + 1) % len(face)] + edge = tuple(sorted((vertex, following))) + midpoint = edge_midpoints.get(edge) + if midpoint is None: + midpoint = len(output_vertices) + edge_midpoints[edge] = midpoint + output_vertices.append(((vertices[vertex] + vertices[following]) * 0.5).tolist()) + mids.append(midpoint) + + center = len(output_vertices) + output_vertices.append(np.mean(vertices[list(face)], axis=0).tolist()) + material = mesh.face_materials[face_index] if face_index < len(mesh.face_materials) else "default" + + face_uv = None + uv_mids: list[int] = [] + uv_center = -1 + if output_face_uvs is not None and mesh.face_uvs is not None and output_uvs is not None: + candidate = mesh.face_uvs[face_index] if face_index < len(mesh.face_uvs) else () + if len(candidate) == len(face) and all(0 <= uv < len(mesh.uvs) for uv in candidate): + face_uv = candidate + for corner, uv_index in enumerate(face_uv): + following_uv = face_uv[(corner + 1) % len(face_uv)] + uv_mids.append(len(output_uvs)) + output_uvs.append(((mesh.uvs[uv_index] + mesh.uvs[following_uv]) * 0.5).tolist()) + uv_center = len(output_uvs) + output_uvs.append(np.mean(mesh.uvs[list(face_uv)], axis=0).tolist()) + + for corner, vertex in enumerate(face): + output_faces.append((vertex, mids[corner], center, mids[(corner - 1) % len(face)])) + output_materials.append(material) + if output_face_uvs is not None: + if face_uv is None: + output_face_uvs = None + else: + output_face_uvs.append( + (face_uv[corner], uv_mids[corner], uv_center, uv_mids[(corner - 1) % len(face)]) + ) + + result = MeshData( + vertices=np.asarray(output_vertices, dtype=np.float64), + faces=output_faces, + face_materials=output_materials, + uvs=None if output_uvs is None else np.asarray(output_uvs, dtype=np.float64), + face_uvs=output_face_uvs, + name=name or f"{mesh.name}_quad_closure", + ) + summary = topology_summary(result) + if not summary.all_quads: + raise ValueError( + "Quad closure failed validation: " + f"quads={summary.quads}/{summary.faces}, degenerate={summary.degenerate_faces}, " + f"invalid_quads={summary.invalid_quads}, non_manifold_edges={summary.non_manifold_edges}, " + f"non_manifold_vertices={summary.non_manifold_vertices}, " + f"branched_boundary_vertices={summary.branched_boundary_vertices}, " + f"inconsistent_edges={summary.inconsistent_edges}, duplicate_faces={summary.duplicate_faces}." + ) + return result diff --git a/ui.py b/ui.py index d91cff8..2b361c3 100644 --- a/ui.py +++ b/ui.py @@ -2,18 +2,36 @@ import bpy -from .operators import ENGINE_ITEMS, FLOW_ITEMS, OUTPUT_ITEMS, QUALITY_ITEMS, TEXTURE_ITEMS +from .operators import ( + ATLAS_STRATEGY_ITEMS, + ENGINE_ITEMS, + FLOW_ITEMS, + OUTPUT_ITEMS, + QUALITY_ITEMS, + TEXTURE_ITEMS, + TOPOLOGY_ITEMS, +) class CURIOMESH_PG_settings(bpy.types.PropertyGroup): target_faces: bpy.props.IntProperty(name="Target Faces", default=8000, min=4, soft_max=250000) - engine: bpy.props.EnumProperty(name="Engine", items=ENGINE_ITEMS, default="QUADRIFLOW") + engine: bpy.props.EnumProperty(name="Engine", items=ENGINE_ITEMS, default="ATLAS_Q") quality: bpy.props.EnumProperty(name="Quality", items=QUALITY_ITEMS, default="BALANCED") seed: bpy.props.IntProperty(name="Seed", default=0, min=0, soft_max=100000) triad_seed_count: bpy.props.IntProperty(name="TRIAD-Q Seeds", default=6, min=1, max=32) triad_feature_angle: bpy.props.FloatProperty(name="TRIAD-Q Feature Angle", default=35.0, min=0.0, max=180.0) triad_force_quads: bpy.props.BoolProperty(name="TRIAD-Q Pure Quads", default=False) triad_flow_mode: bpy.props.EnumProperty(name="TRIAD-Q Flow", items=FLOW_ITEMS, default="AUTO") + atlas_strategy: bpy.props.EnumProperty(name="Scan Strategy", items=ATLAS_STRATEGY_ITEMS, default="AUTO") + atlas_candidate_count: bpy.props.IntProperty(name="Candidate Budget", default=3, min=1, max=8) + topology_policy: bpy.props.EnumProperty(name="Topology", items=TOPOLOGY_ITEMS, default="PRESERVE") + require_quads: bpy.props.BoolProperty(name="Require Locally Valid Quads", default=True) + analysis_sample_faces: bpy.props.IntProperty( + name="Analysis Sample Faces", + default=120000, + min=1000, + max=500000, + ) preserve_sharp: bpy.props.BoolProperty(name="Preserve Sharp", default=True) preserve_boundary: bpy.props.BoolProperty(name="Preserve Boundary", default=True) preserve_seams: bpy.props.BoolProperty(name="Treat UV Seams As Sharp", default=True) @@ -41,15 +59,45 @@ class CURIOMESH_PG_settings(bpy.types.PropertyGroup): metrics_extraordinary: bpy.props.IntProperty(name="Extraordinary Verts", default=0) metrics_extraordinary_ratio: bpy.props.FloatProperty(name="Extraordinary Ratio", default=0.0, precision=3) metrics_non_manifold_edges: bpy.props.IntProperty(name="Non-Manifold Edges", default=0) + metrics_non_manifold_vertices: bpy.props.IntProperty(name="Non-Manifold Vertices", default=0) + metrics_duplicate_faces: bpy.props.IntProperty(name="Duplicate Faces", default=0) + metrics_invalid_quads: bpy.props.IntProperty(name="Invalid Quads", default=0) + metrics_components: bpy.props.IntProperty(name="Connected Components", default=0) + metrics_component_euler: bpy.props.StringProperty(name="Component Euler Values", default="") metrics_boundary_edges: bpy.props.IntProperty(name="Boundary Edges", default=0) + metrics_boundary_loops: bpy.props.IntProperty(name="Boundary Loops", default=0) + metrics_euler: bpy.props.IntProperty(name="Euler Characteristic", default=0) + metrics_degenerate: bpy.props.IntProperty(name="Degenerate Faces", default=0) + metrics_min_jacobian: bpy.props.FloatProperty(name="Min Scaled Jacobian", default=0.0, precision=4) + metrics_p95_aspect: bpy.props.FloatProperty(name="p95 Aspect Ratio", default=0.0, precision=3) + metrics_quad_only: bpy.props.BoolProperty(name="Local Quad Manifold Valid", default=False) metrics_uv_valid: bpy.props.BoolProperty(name="UV Valid", default=False) metrics_materials_preserved: bpy.props.BoolProperty(name="Materials Preserved", default=False) metrics_elapsed_ms: bpy.props.FloatProperty(name="Elapsed ms", default=0.0, precision=1) metrics_engine: bpy.props.StringProperty(name="Engine", default="") + metrics_strategy: bpy.props.StringProperty(name="Strategy", default="") + metrics_profile: bpy.props.StringProperty(name="Scan Profile", default="") + metrics_candidates: bpy.props.IntProperty(name="Candidates", default=0) + metrics_geometry_rms: bpy.props.FloatProperty(name="RMS Error / Diagonal", default=0.0, precision=6) + metrics_geometry_p95: bpy.props.FloatProperty(name="p95 Error / Diagonal", default=0.0, precision=6) + metrics_geometry_max: bpy.props.FloatProperty(name="Max Sample Error / Diagonal", default=0.0, precision=6) + metrics_sampling_status: bpy.props.StringProperty(name="Component Sampling", default="NOT_MEASURED") + metrics_topology_status: bpy.props.StringProperty(name="Topology Proof", default="NOT_CHECKED") metrics_status: bpy.props.StringProperty(name="Status", default="") metrics_uv_status: bpy.props.StringProperty(name="UV Status", default="") metrics_message: bpy.props.StringProperty(name="Message", default="") + analysis_profile: bpy.props.StringProperty(name="Profile", default="Not analyzed") + analysis_strategy: bpy.props.StringProperty(name="Recommended", default="") + analysis_faces: bpy.props.IntProperty(name="Input Faces", default=0) + analysis_sampled_faces: bpy.props.IntProperty(name="Sampled Faces", default=0) + analysis_exact: bpy.props.BoolProperty(name="Exact Topology", default=False) + analysis_boundary_loops: bpy.props.IntProperty(name="Boundary Loops", default=-1) + analysis_non_manifold: bpy.props.IntProperty(name="Non-Manifold Edges", default=-1) + analysis_sharp_ratio: bpy.props.FloatProperty(name="Sharp Coverage", default=0.0, subtype="FACTOR") + analysis_target_edge: bpy.props.FloatProperty(name="Target Edge Scale", default=0.0, precision=6) + analysis_warning: bpy.props.StringProperty(name="Warning", default="") + class CURIOMESH_PT_panel(bpy.types.Panel): bl_label = "CurioMesh" @@ -76,6 +124,32 @@ def draw(self, context: bpy.types.Context): row.operator("curiomesh.apply_preset", text="Balanced").preset = "BALANCED" row.operator("curiomesh.apply_preset", text="Hero").preset = "HERO" + scan = layout.box() + scan.label(text="1. Scan Intelligence") + scan.operator("curiomesh.analyze", text="Analyze Without Changing Mesh", icon="VIEWZOOM") + if settings.analysis_faces: + scan.label(text=f"{settings.analysis_profile}: {settings.analysis_faces:,} input faces") + scan.label(text=f"Recommended: {settings.analysis_strategy}") + qualifier = "exact topology" if settings.analysis_exact else f"sampled {settings.analysis_sampled_faces:,} faces" + scan.label(text=qualifier) + if settings.analysis_warning: + scan.label(text=settings.analysis_warning[:100], icon="ERROR") + + if settings.engine in {"ATLAS_Q", "AUTO"}: + atlas = layout.box() + atlas.label(text="2. Atlas-Q Solver Portfolio") + atlas.prop(settings, "atlas_strategy") + atlas.prop(settings, "topology_policy") + atlas.prop(settings, "require_quads") + atlas.prop(settings, "atlas_candidate_count") + atlas.prop(settings, "triad_feature_angle", text="Feature Angle") + if settings.topology_policy == "REPAIR": + atlas.label(text="Repair may close holes or erase thin sheets.", icon="ERROR") + if settings.atlas_strategy == "PAIR_CLOSURE": + atlas.label(text="Exact quads; expect more poles and a looser face target.") + else: + atlas.label(text="Candidates are scored by error, Jacobian, aspect, count, and poles.") + preserve = layout.box() preserve.label(text="Shape Preservation") preserve.prop(settings, "preserve_sharp") @@ -96,11 +170,13 @@ def draw(self, context: bpy.types.Context): if settings.engine == "TRIAD_Q_LITE": triad = layout.box() - triad.label(text="TRIAD-Q Lite") + triad.label(text="PairClosure Standalone") triad.prop(settings, "triad_flow_mode") triad.prop(settings, "triad_seed_count") triad.prop(settings, "triad_feature_angle") - triad.prop(settings, "triad_force_quads") + triad.prop(settings, "require_quads") + if not settings.require_quads: + triad.label(text="Debug mode may retain triangles.", icon="ERROR") textures = layout.box() textures.label(text="Materials And UVs") @@ -125,6 +201,11 @@ def draw(self, context: bpy.types.Context): op.triad_feature_angle = settings.triad_feature_angle op.triad_force_quads = settings.triad_force_quads op.triad_flow_mode = settings.triad_flow_mode + op.atlas_strategy = settings.atlas_strategy + op.atlas_candidate_count = settings.atlas_candidate_count + op.topology_policy = settings.topology_policy + op.require_quads = settings.require_quads + op.analysis_sample_faces = settings.analysis_sample_faces op.preserve_sharp = settings.preserve_sharp op.preserve_boundary = settings.preserve_boundary op.preserve_seams = settings.preserve_seams @@ -149,12 +230,33 @@ def draw(self, context: bpy.types.Context): col = metrics.column(align=True) col.prop(settings, "metrics_status") col.prop(settings, "metrics_engine") + col.prop(settings, "metrics_strategy") + col.prop(settings, "metrics_profile") + topology_row = col.row() + topology_row.alert = "UNVERIFIED" in settings.metrics_topology_status + topology_row.prop(settings, "metrics_topology_status") + col.prop(settings, "metrics_candidates") col.prop(settings, "metrics_faces") col.prop(settings, "metrics_quad_ratio") + col.prop(settings, "metrics_quad_only") col.prop(settings, "metrics_face_error") + col.prop(settings, "metrics_geometry_rms") + col.prop(settings, "metrics_geometry_p95") + col.prop(settings, "metrics_geometry_max") + col.prop(settings, "metrics_sampling_status") + col.prop(settings, "metrics_min_jacobian") + col.prop(settings, "metrics_p95_aspect") col.prop(settings, "metrics_extraordinary") col.prop(settings, "metrics_non_manifold_edges") + col.prop(settings, "metrics_non_manifold_vertices") + col.prop(settings, "metrics_duplicate_faces") + col.prop(settings, "metrics_invalid_quads") + col.prop(settings, "metrics_components") + col.prop(settings, "metrics_component_euler") col.prop(settings, "metrics_boundary_edges") + col.prop(settings, "metrics_boundary_loops") + col.prop(settings, "metrics_euler") + col.prop(settings, "metrics_degenerate") col.prop(settings, "metrics_uv_valid") col.prop(settings, "metrics_materials_preserved") col.prop(settings, "metrics_uv_status")