Skip to content
KrateianPublic

About

Watertight STL/3MF/OBJ mesh repair for 3D printing. Sutura Triage Engine with 17 ranked repair methods — Tamponade (inside-out hole plug with Stent), Flap, Graft shell wrap, fTetWild and more — on a Rust + PyMeshLab core, with a reload-honest watertight verdict. CLI + Qt GUI for Linux and macOS.

Topics

Resources

Contributing

Security policy

Stars

9 stars

Watchers

1 watching

Forks

Latest commit

 

History

284 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sutura

Sutura

CI AppImage build macOS build CodeQL Latest release License Commercial license Platforms Rust core Python 3.11+ GitHub stars Last commit Downloads Contributors Top language Repo size Commit activity

Sutura Triage Engine: a staged PyMeshLab + manifold3d mesh-repair pipeline with 17 ranked repair methods for STL, OBJ and 3MF files, built for Linux with full macOS support.

Linux has no direct equivalent of Windows' right-click "Fix model" (3D Builder, Netfabb) or Bambu Studio's broken-on-Linux "Fix model" button. Sutura provides that workflow: pick a mesh, repair it, keep the original untouched.

Sutura is continuously hardened against real-world inputs — Thingi10K models, malformed files, adversarial inputs and torture scenarios (huge meshes, thin walls, multi-part assemblies) — and every change is verified automatically by CI on each push and pull request.

What you can rely on. The staged STL repair pipeline (Stage 1 VCG + Stage 2 manifold3d) is validated on a 115-mesh real-world scan corpus with a strict defects.detect() closed-loop check: 103/115 (~90%) genuinely watertight, 0 crashes, every pipeline claim confirmed 1:1, and an independent manifold3d re-check agrees on all 115 meshes. Defect detection is a single stdlib+numpy source of truth, unit-tested on clean and broken meshes. The mesh-type classifier (default engine) is calibrated on a 71-mesh labeled set and measurably beats the classic heuristic. These are the features to trust first; the experimental/opt-in items are clearly labelled as such below.

A note from the maintainer

Sutura is developed by a single maintainer alongside a full-time aircraft maintenance role at a major airline. Releases may come less frequently for a while going forward — the project is not abandoned, only slower-paced.

Screenshot

Sutura GUI

Options → Repair tab

Tamponade and Flap are on by default in Options → Repair.

How Sutura repairs a mesh, in plain words

A broken 3D model is like damaged skin. A scanner leaves holes where it saw nothing, triangles get glued together wrongly (non-manifold edges), and parts of the surface pass through each other (self-intersections). Slicers either refuse such a file or print it wrong.

Sutura works like a careful surgeon: it treats the damage and leaves healthy skin alone. A repair runs as a short chain of steps, and each later step only acts when the earlier ones left work behind:

your file ─► Tamponade ─► Flap ─► Stage 1 clean-up ─► (still open?) Tamponade ─► (still broken?) Graft ─► Stage 2 solid ─► repaired copy
             plugs holes  fills holes  fixes edges,                 plugs holes                  rebuilds only     one closed,
             from inside  along the    closes what is               from inside                  the damaged areas printable solid
                          surface      left
  1. Tamponade: plugs the holes from the inside, like lava (on by default). It runs first, as a pre-pass on the cleaned-up input before Flap and Stage 1, so a genuine crater is sealed at its narrowest point instead of getting a flat lid. It fills the part from the inside: picture the inside of the part flooded with lava. Where the shell is intact the lava simply touches it and stops, so the original surface is not changed. At a hole the lava would escape, so it is held back and sealed right at the shell; that hardened lava is the plug. Folds trapped inside the part are melted from the deepest point outward, one layer at a time; if a layer would create a bad edge (the kind that makes the mesh non-manifold) that last layer is undone, and a very small spot is welded shut instead. It gets a second chance after Flap and Stage 1 for any hole they leave, and is kept only if the result is measurably better. The name comes from surgery: a tamponade fills a cavity from inside to stop bleeding at its opening. It is on by default; turn it off with --no-tamponade / SUTURA_TAMPONADE=0 or the GUI Options checkbox. Inside Tamponade, Stent is an inflate-from-inside plug used when the reference plug is rejected for a loop; it stops at the shell and never leaves the solid. Not related to method #8 Balloon (Poisson surface reconstruction).
  2. Flap: fills holes the way the surface wants to go (new in 0.8.0, on by default). A classic hole fill puts a flat lid on a hole. Flap continues the surrounding surface into the hole instead, like brushing liquid primer or epoxy over a gap: the patch leaves the rim at the same slope as its neighbours, so a hole in a curved surface gets a curved patch, not a dent or a flat spot. Your original triangles are not moved; only the patch is new. If Flap cannot fill a hole safely it leaves it to the next step, so a file is never worse with Flap than without it. Turn it off with --no-flap or the Flap surface hole fill checkbox.
  3. Stage 1: clean-up (PyMeshLab). Removes duplicate and broken triangles, untangles bad edges, makes every face point the same way and closes any hole that is still open.
  4. Graft: only when the damage is serious. If the model is still open or tangled, Graft builds a fresh closed shell over the damaged areas and grafts it onto the healthy original surface, like a skin graft. Healthy areas keep their original triangles, so fine detail such as engraving or ornament survives. Very large scans have a size and time limit so they never hang.
  5. Stage 2: make it one solid (manifold3d). Rebuilds the result as a valid closed solid, the same library Bambu Studio uses.
  6. Optional extras, off by default. Dressing dips the whole model in a virtual coat that always comes out closed, but it can soften fine detail, so Sutura only suggests it when everything else failed and you decide. fTetWild is a last-resort rebuild for badly broken files.

After every step Sutura counts holes and bad edges again, as if the file had been re-opened from disk; a step that makes things worse is thrown away. Your original file is never changed: you get a repaired copy and a report that lists exactly which steps ran. Small self-intersections that slicers ignore are reported but no longer treated as failures (--si-mode repair restores the strict behaviour).

The helpers behind the steps

The numbered steps are the visible repair. Around them sit a few helpers that decide what to do and check the result:

  • Diagnosis looks at the model before any repair and works out what kind of part it is — a machined mechanical shape, a smooth organic shape, a scanned surface, a lattice — so the later steps can pick sensible settings. It runs first.
  • Triage turns that diagnosis and the kind of damage into a plan: which repair methods to try, in what order, and how long to spend on them. It runs between the steps and drops any method that would take too long.
  • Chart is a cache of earlier work, keyed by the shape of the input. It is checked before the heavy steps, so repairing the same geometry again (or the objects inside one file) is fast.
  • X-Ray is the honest ending check. Instead of trusting the counts held in memory, it re-opens the file that was just written and counts holes and bad edges the way a slicer would. It runs at the end of a repair.
  • Stitch runs just before that check. A file can look closed in memory but reload with a small gap once its coordinates are rounded on write; Stitch heals those seams without moving any face.
  • Hull extracts the outer surface. When the model is an assembly of parts that touch or overlap, it keeps only the outside, so internal faces do not turn into defects. It runs as a pre-step when Diagnosis recognises an assembly.
  • Cast is the link to Sutura's own fast geometry engine (a small Rust helper). It is loaded only while a step that needs exact measurements runs, such as the exact self-intersection split or the Graft and Dressing shells.

The repair tiers themselves — Flap, Graft, Tamponade, Dressing and fTetWild — are described in the steps above.

Why a staged pipeline

  • Stage 1 - PyMeshLab (VCG). Removes duplicate and degenerate faces, repairs non-manifold edges and vertices, orients faces coherently, closes holes of any size, and drops tiny open debris components. VCG is the battle-tested classic for 3D-printing repair.
  • Stage 2 - manifold3d. Rebuilds the closed mesh as a valid manifold solid and merges overlapping shells with a boolean union. This is the same library Bambu Studio uses; it guarantees the output is a single closed two-manifold.

Each stage fixes what the other cannot: VCG closes big holes but does not resolve self-intersecting geometry; manifold3d guarantees a watertight result but its Python binding rejects any input that is not already closed (Error.NotManifold), so stage 1 must finish the mesh first.

On Linux, stage 2 runs in a dedicated python3.11 virtualenv (manifold3d ships no wheel for Python 3.14). On single-environment installs (macOS/conda, or any setup where manifold3d is importable from the current Python), stage 2 runs in-process instead. If manifold3d is not available at all, the report explicitly says Stage 2 skipped: manifold3d not available in this environment. — it is never silently omitted.

Optional tiers. Around the two core stages sit tiers for self-intersecting and badly broken meshes. The experimental ones are off by default and enabled by their own CLI flag or a checkbox in the GUI's Options window; the fTetWild fallback is on by default whenever its optional extra is installed:

  • Before stage 1: --experimental-autorefine (splits intersecting triangles along their intersection segments, never deletes a face) and --experimental-indirect-autorefine (the same idea with exact predicates in the Rust sutura-geom core, evaluation-only). In auto mode, when a model fits the mechanical or dense_scan_heavy_si templates and contains multiple closed components that touch or overlap (e.g. multi-part assemblies like Rubik-style models), an outer-shell extraction pre-step dissolves internal interfaces via a slight dilation (5e-4 * bbox_diagonal, ~0.05 mm for a 100 mm object) and boolean union, eliminating internal faces and degenerate self-intersections before repair passes.
  • After stage 1, as a last resort: the fTetWild fallback (tetrahedralizes the original input with fTetWild and extracts a watertight boundary; optional ~1.1 GB extra, see Install). When the extra is installed it runs by default, but only when stage 1 still leaves holes or non-manifold edges; --no-fallback-ftetwild (GUI: Options → Repair tab → uncheck fTetWild fallback) turns it off, --experimental-fallback-ftetwild (GUI: Options → Repair tab → + self-intersections (slow), below it) also runs it on a closed result that still self-intersects. fTetWild's own tetrahedron-quality optimisation is off (only the boundary is used, and it mostly costs time); --ftetwild-optimize (GUI: Optimise tetrahedra, not recommended) turns it on.
  • In Full Mend, after stage 1: a guarded self-intersection excise + re-cap removes residual self-intersecting faces (the faces plus a one-ring band), closes the opened loops with PyMeshLab's refined hole filling (refinehole=True, selfintersection=True — which prevents new crossing caps) and commits a round only when the self-intersection count strictly drops while holes and non-manifold edges do not increase; otherwise the pre-round mesh is restored byte-for-byte. No connected component is ever dropped, at most three rounds run under the intensity preset's time budget (Balanced 5 s, Thorough 20 s, Extreme 60 s), and the step is a no-op on meshes without self-intersections. Disable it with SUTURA_SI_EXCISE=0. The Stage-1 hole fill itself can additionally be refined (refinehole=True) with the experimental SUTURA_REFINE_HOLE=1; it is off by default because it is not yet proven on the corpora.

These after-stage-1 tiers form the deep-repair ladder, selected with --deep-repair {off,local,full} (or SUTURA_DEEP_REPAIR, or the deep_repair key of ~/.config/sutura/config.json; the flag wins, then the environment, then the config). full is the default for the CLI and the file-manager menu and runs the fTetWild tier exactly as described above. off runs no tier; when holes or non-manifold edges remain, the JSON report carries deep_repair.available (remaining holes and non-manifold edges, the tiers that could still run and a rough time estimate estimate_s; the estimate coefficients are placeholders until they are calibrated on the corpora) and --human shows a "Deep repair available" line. local is an experimental prototype that re-meshes only the damaged regions: faces on a boundary or non-manifold edge and their one-vertex ring are deleted, the openings are closed with PyMeshLab's refined hole filling and the new interior vertices are smoothed with the boundary fixed; it is adopted only when holes and non-manifold edges do not get worse, self-intersections do not increase and every face outside the deleted region is unchanged. It only runs on small, simple damage (no non-manifold edge, boundary loops of at most 16 edges, at most 8 damaged regions and 2,000 deleted faces, 10 s); hole refinement is off because it cost ~9 s per 90k-face mesh without changing a result. Measured on the 40 real-world samples it made 3 more meshes strictly watertight (31 → 34); on the 115-mesh corpus it gained none, which is why it is not part of full.

The self-intersection policy is selected with --si-mode {repair,report,off} (or SUTURA_SI_MODE, or the GUI Options → Repair combo Self-intersections [Report only / Repair / Do not measure]). report (the default) measures and reports the residual self-intersection count, but self-intersections no longer block the watertight classification verdict on a closed 2-manifold surface, nor do they escalate the deep-repair ladder on their own — only holes and non-manifold edges drive the automatic tiers and the verdict; the count stays visible in stage1.self_intersections_remaining. A mesh with zero holes and zero non-manifold edges is classified as watertight under report even when residual self-intersections remain. repair restores the earlier behaviour where a positive count counts as damage and can trigger the automatic Dressing fallback. off skips the exact classifier and reports the count as null ("not measured"). Explicit force flags (--experimental-dressing, --experimental-fallback-ftetwild) override the policy and still act on residual self-intersections. The guarded si_excise_recap pass keeps its own independent count in every mode. The chosen mode lands in the report as si_mode (and deep_repair.si_mode).

Every tier is adopted only when its result is no worse than the plain staged result on holes and non-manifold edges, so the core staged path always remains the safety net. An adopted fTetWild result whose one-sided output-to-input Hausdorff distance exceeds 1 % of the bounding-box diagonal (provisional threshold) is kept but flagged: the report carries shape_changed: true and the issue shape_changed ("Shape changed by the fTetWild fallback"), without downgrading the category. Measured on macOS this concerns thingi10k_1038441 (3.7 %), 1038439 (1.7 %) and 224108 (1.1 %) of the 40 samples. When the default fTetWild boundary (run without the quality optimisation) fails the holes and non-manifold guard even after the manifold3d pass, the mesh is tetrahedralized once more with the optimisation on, within what remains of the 180 s budget; the report lists both runs (attempts) and names the adopted one (adopted_attempt). Inputs above 300,000 faces skip the fTetWild tier (reject_reason: too_large): the slowest completed run on the 40 samples took ~55 s at 90,000 faces, so larger scans cannot finish within the budget, and the dense Artec scans of the 115-mesh corpus timed out on every run. Without the fTetWild extra and with no flag set, the behaviour is exactly the Stage-1 + Stage-2 core above.

Measured on the 40 real-world samples (scripts/benchmark_repair_corpus.py, 2-vCPU x86_64 VM): strict watertight 31/40 without the fallback, 39/40 with the default fallback (the tier ran on 9 meshes, adopted on 8; the one left open, artec_metal-nut, hit the 180 s per-mesh budget). Total run time 64 s → 669 s; the extra time is spent only on meshes that would otherwise stay open. Running it also on closed-but-self-intersecting results (--experimental-fallback-ftetwild) gives the same 39/40 in 2,595 s and remeshes 7 already-closed meshes, which is why that stays opt-in. Caveats: fTetWild's output and run time are not reproducible run to run (e.g. thingi10k_248395 took 30–160 s, occasionally beyond the budget, even with one thread); the result is re-triangulated (face counts change, often coarser); every input point stays close to the output (0.2–1.4 % of the bounding-box diagonal on the checked meshes) while the output can add surface away from the input where openings or cavities are closed (one-sided output-to-input Hausdorff distance 1–4 % on thingi10k_224108, 1038439 and 1038441, 5–8 % on two corpus meshes; an earlier figure of 28 % on thingi10k_46012 came from a measurement that also sampled interior tetrahedron vertices). fTetWild now runs without its mesh-quality optimisation (optimize=False; the optimisation only improves the discarded interior tetrahedra): in a parameter sweep on macOS it cut thingi10k_46012 from 34 s to 6 s with a 0.09 % Hausdorff distance, while artec_metal-nut still exceeded the budget. The run times and counts above were measured with the optimisation on.

Since v0.9.1 the retry after a boundary that fails the holes/non-manifold check is deterministic (it no longer depends on the remaining wall clock), and the time budget is only an outer all-or-nothing abort that keeps the previous stage result. The output is still not byte-identical run to run.

Triage Engine intensity presets and named profiles

The effort spent after Stage 1 — the deep-repair ladder, the fTetWild tier's on/off state, its input-size cap and wall-clock budget, the Graft shell-wrap tier's own input-size cap and wall-clock budget, the dense boundary decimation ladder and the Hausdorff sample count — is grouped into four read-only presets plus any number of user-named profiles, selected with --intensity <name> (or SUTURA_INTENSITY, or the intensity key of ~/.config/sutura/config.json; the flag wins, then the environment, then the config). Stage 1 is deliberately not affected: the repair mode (--mode/the classifier) and the optional profile (--profile) keep exactly their previous behaviour in every preset. The chosen intensity is reported as triage_intensity and shown as an Intensity: line by --human; --list-intensities prints the presets and profiles with their effective values and exits.

  • Quick — skips the deep-repair ladder and the fTetWild tier entirely (deep_repair=off, fTetWild off) and has an empty decimation ladder: the fastest run, which leaves hard meshes open.
  • Balanced — the default and the shipped behaviour: every knob is the historical repair.py constant (fTetWild auto when installed, 180 s budget, 300,000-face cap, Graft bounded to a 300 s budget and a 2,000,000-face cap, (1.5, 3.0) decimation ladder, 200,000 Hausdorff samples). --intensity balanced is byte-identical to running without the flag.
  • Thorough — a 600 s fTetWild budget, a 900 s Graft budget, 400,000 Hausdorff samples and one extra decimation rung, threshold (= DENSE_RATIO × max(input, 20,000)).
  • Extreme — no input-size cap for fTetWild or Graft, a 1,800 s budget for both, 1,000,000 Hausdorff samples, the extra threshold rung, and, when every rung of a wastefully dense boundary fails, one more fTetWild run with the tetrahedron-quality optimisation on before falling back to the undecimated boundary.

The extra threshold rung targets the failure mode where a wastefully dense fTetWild boundary cannot be decimated watertight at the lower 1.5×/3× targets. On thingi10k_73444 the optimize=False boundary returned 469,144 faces; the 20,000 and 23,646 targets both failed the watertight check (not_watertight) while the threshold target of 80,000 succeeded, giving a watertight result (adopted, shape_changed flagged at 5 % — the shape guard is invariant and never a preset). The fTetWild and Hausdorff guard itself (watertight + FTETWILD_MAX_HAUSDORFF_REL) is not a knob: it stays on in every preset and in every profile.

Named profiles sit on top of the presets. Each profile is a base preset plus a sparse set of field overrides (only the fields that were changed are stored); the four built-in presets stay read-only. Profiles are stored in ~/.config/sutura/profiles.json — a separate, schema-versioned file written atomically (temp file + rename), not a config.json key, so a corrupt profiles blob can never poison the rest of the configuration. A corrupt, unreadable, wrong-schema or missing file warns on stderr and falls back to the built-in presets; it never crashes a repair. Names must be non-empty, unique case-insensitively and must not shadow a built-in preset name. A profile is selected exactly like a preset (--intensity my-fork); when one is used the report adds triage_profile_base and triage_overrides alongside triage_intensity. The Hausdorff sample floor (200,000) is enforced in the resolver too: a hand-edited profile below it is clamped with a warning. In the GUI (Options → Repair) the combo lists the presets, a separator, then the user profiles, with New, Duplicate, Rename and Delete (confirmed) buttons and an inline Profile settings editor for every field; editing a built-in preset offers Save as new profile, and Reset to recommended selects Balanced without deleting any profile.

The original file is never overwritten. Output is written with a _fixed suffix in the same directory.

Repair method registry and per-object recommendations

sutura_engine.methods (shimmed at sutura/methods.py) is an extensible registry of seventeen repair methods, each identified by a stable integer and canonical slug (--list-methods prints num, id, family, availability, and display name). Methods 1–7 provide fine-grained selectors over the core pipeline (#1 Quick Clean, #2 Local Mend, #3 Full Mend, #4 Join, #5 Autorefine, #6 Exact Refine, and #7 fTetWild); #8–#10 are standalone surface-reconstruction tiers (#8 Balloon screened-Poisson closing and #9 Backplate flat-back closing for single-sided scans, #10 Scaffold proxy-template reconstruction for heavily damaged meshes); #11 Transplant and #12 Transplant+ provide automated and point-guided repetition healing; #13 Graft is the morphology shell-wrap envelope that closes large wounds while preserving healthy surfaces verbatim; #14 Mirror Complete reconstructs symmetric single-sided scans; #15 Wall Thicken is the opt-in morphological thin-wall thickener that never runs automatically; #16 Dressing is the opt-in variable-viscosity level-set skin (its own section below); and #17 Tamponade is the interior plug (on by default) that fills open boundaries from the inside and melts residual self-intersections in rolled-back layers (alias lava, its own section below). Methods 8–10 run on the original input (like the fTetWild tier) and adopt their result only when it is strict-watertight and the original surface is still covered (a one-sided input→output Hausdorff guard), so the estimated back surface cannot silently move the model; the module's notes (e.g. "back surface was estimated") are surfaced in the top-level closing report key. Methods 11–12 run after the stage-1 chain on the watertight result: #11 detects a rotational/translational/helical repeated pattern and transplants a healthy copy onto each damaged or missing element, #12 transplants the element nearest the user's --repeat-source X,Y,Z point onto the one nearest --repeat-target X,Y,Z. The transplant is adopted only when it stays watertight, no worse than the baseline, and the untouched geometry did not move (hausdorff_outside); its repeat report key carries the pattern type and the number of positions repaired.

Repair methods (#1–#17)

The seventeen methods, in the stable integer order printed by --list-methods (canonical IDs shown; legacy slugs remain fully supported as aliases):

# Canonical ID Display Name Family What it does Invents geometry?
1 quick_clean Quick Clean clean Stage 1 + manifold3d rebuild; no deep repair, no fTetWild. no
2 local_mend Local Mend topology Deep-repair ladder local: re-meshes only the damaged regions. no
3 full_mend Full Mend topology Deep-repair ladder full (the default balanced pipeline). no
4 join Join topology Moves small disconnected components onto the nearest larger one. no (moves)
5 autorefine Autorefine si Subdivides intersecting triangles along intersection segments (Lazard & Valque 2025). no
6 exact_refine Exact Refine si Exact arrangement-lite self-intersection split via the Rust core (sutura_geom). no
7 ftetwild fTetWild envelope Tetrahedralizes the original surface and extracts a watertight, SI-free boundary. yes (volume remesh)
8 balloon Balloon closing Screened-Poisson reconstruction closing large openings on single-sided scans. yes (back surface)
9 backplate Backplate closing Flat back plane and perimeter walls for relief/plate meshes; keeps original face. yes (back plane)
10 scaffold Scaffold template Coarse watertight proxy hull with re-projection of healthy original regions. yes (proxy)
11 transplant Transplant pattern Detects rotational/translational patterns and transplants healthy geometry onto defects. yes (transplant)
12 transplant_plus Transplant+ pattern Interactive point-guided transplant (--repeat-source/--repeat-target); opt-in. yes (transplant)
13 graft Graft envelope Morphology shell wrap (dilation -> EDT -> erosion) keeping healthy triangles verbatim. yes (shell wrap)
14 mirror_complete Mirror Complete closing Reflects a single-sided symmetric scan across its detected symmetry plane. yes (mirrored back)
15 wall_thicken Wall Thicken envelope Opt-in morphological dilation thickening walls below --wall-min-thickness. yes (offset)
16 dressing Dressing envelope Opt-in variable-viscosity level-set skin; 2-manifold and SI-free by construction. yes (viscosity coat)
17 tamponade Tamponade interior Interior plug (alias lava, on by default): fills each open boundary from the inside, melts residual self-intersections in rolled-back layers; its Stent candidate inflates a plug inward from the rim (multi-stent partition for large loops); never deletes an input face. yes (plug)

The geometry-inventing methods never run unguarded: #7 is bounded by a one-sided output→input Hausdorff guard; #8, #9, #10, and #14 by an input→output guard (ensuring the original surface remains fully covered); #11 and #12 are self-guarded by hausdorff_outside (preventing movement of untouched geometry); #13 Graft is self-guarded by its own healthy original→result fidelity check (fidelity_ok), automatically falling back to fTetWild if healthy detail is lost; #15 Wall Thicken is guarded input→output and excluded from auto-ranking (needs_user_input) since wall thickening is an explicit design choice; #16 Dressing is self-guarded by its coat→input fidelity gate and likewise excluded from auto-ranking (opt-in until measured on the corpus); and #17 Tamponade is self-guarded by its own reload-honest holes/non-manifold/part-count/healthy-face check; it is on by default in the pipeline but still excluded from auto-ranking (needs_user_input) as an explicit alternative to it.

Object analysis and recommendations

--analyze <file> performs a cheap per-object analysis (faces, components, boundary loops, open-area ratio, single-side/relief scores, repetition score, non-manifold edges, self-intersecting faces, mesh type) and prints ranked method recommendations; third-party engines are listed in a separate section and are never mixed into the ranking. The self-intersection probe is capped at 200,000 faces — above that it samples and flags the value as an estimate. --methods 2,3,5 tags the methods to try, in order. Without a flag the repair is auto: today's default pipeline runs first and, when its result is already strict-watertight, the output is byte-identical to before. Only when that baseline fails are ranked methods tried (at most three extras; a geometry-inventing method such as fTetWild only when its recommendation score is high enough), under a wall-clock budget of min(max(30 s, 2 × baseline), 120 s): once the budget is exhausted no further method is started (a running attempt is not interrupted), the best candidate is kept and the result is marked with a "fallback budget reached" note. A baseline that already took longer than 120 s skips the ranked fallback entirely ("fallback skipped: baseline too slow"). An explicit --deep-repair/--no-fallback-ftetwild keeps today's exact behaviour instead of escalating, and explicitly tagged methods (--methods) are never budgeted. For multi-object 3MF files, auto escalation operates per object: objects that reload watertight after the baseline repair remain untouched, while failing objects escalate through ranked fallback methods independently (reporting per-object method_used). The report adds method_used, methods_tried, method_reached_watertight and, when escalation ran, analysis and recommendations.

Learning triage (local, bounded). When the anonymous usage history is enabled, each repair also records the per-(object type, method) outcome (accepted/rejected) and elapsed time into ~/.local/share/sutura/triage_learning.json (only template ids and method slugs — the same privacy rule as the history). rank_methods then adds a small Bayesian bonus: a Beta(2, 2) posterior success mean scaled to [0, 0.05], damped by a speed factor, and exactly zero without data. The bonus never lifts a method the geometric analysis did not already favour. It is toggled with the learning_triage config key / SUTURA_LEARNING_TRIAGE / --no-learning-triage, and reset from the CLI (sutura clear-learning) or the GUI Options → General → Reset learning button, without touching the usage history. No network is used.

Per-file method tagging in the GUI (right-click menu)

The same registry is exposed in the GUI. The file list has a Method column that shows Auto, Auto (rec. #3 … 82%) after an analysis, or the tag chain the user chose. Right-clicking one or several files opens a menu: Analyze runs the per-object analysis (--analyze) in the background, Recommended methods lists the ranked top methods with a score bar and the reason in the tooltip, Use method lists all seventeen methods (#1–#17) as a checkable, order-preserving popup (the order in which they are checked is the try-order, e.g. #2 → #3 → #5); picking #12 opens a small picker that CPU-rasterises the object (no OpenGL) so the user can click the healthy source element and then the damaged target element, and External engines lists the configured third-party engines (kept separate from the ranking). Clear tags returns a file to Auto. Tagged files run exactly their methods, in order; untagged files keep the auto behaviour. Engines tagged per file are selected with the CLI --engines NAME[,NAME] (None runs every enabled engine as configured).

File-list right-click method menu

Repeated-element repair (auto/manual picker)

--methods 11 (auto) and --methods 12 (manual) target models that repeat a congruent feature — gear teeth, knurls, thread crests, a regular tile/bump grid, a 3D lattice of identical parts — where one or a few copies are broken, dented or protruding. Detection is segmentation-free: it samples the surface, builds the principal inertia axes and tests rotational orders 2–64 (plus high knurl counts 72/80/90/100/120) and translational steps — in 2D and in a full 3D lattice — with a multi-step verification that rejects a fundamental-order aliasing (n=8 vs n=24 on a 24-tooth gear). Alignment supports uniform similarity (scale) and mirror as well as rigid transforms. A damaged element is replaced by a healthy one through a volumetric manifold3d CSG transplant; missing material is detected by the forward point-to-surface deviation, an extraneous bump by the reverse distance, and the cavity cut is shrunk by 0.1 % so the donor and the cavity slightly overlap (removing the coplanar boundary that otherwise collapses into non-manifold edges on a float32 STL write). For multi-component assemblies extract_outer_shell dilates the parts by a relative epsilon and boolean-unions them into a single outer shell before repair. The transplant is adopted only when it stays watertight, no worse than the baseline, and the untouched geometry did not move (hausdorff_outside).

On the owner's patterned samples set (the #11 auto path):

model faces repetition score pattern damaged element outside Hausdorff reload verdict
gear 24teeth 2x.stl 30,144 0.649 rotational C₂₄ 1.5 mm dent on tooth #18 0.000000 mm watertight (0 holes, 0 NM, genus 1)
PET Bottle Cap.stl 29,792 0.669 rotational C₁₂₀ 0.51 mm knurl dent 0.0849 mm (mean 0.00006) watertight (0 holes, 0 NM)
rubics-cubestl.stl 118,956 → 64,380 (outer shell) 0.616 3D lattice (3×3×3) 3.5 mm dent on a corner cubie 0.000000 mm watertight (0 holes, 0 NM, genus 0)

Honest non-results. Not every detected symmetry is repairable, and the tier reports the reason rather than forcing a transplant. On the pistachio bowl (369,732 faces, score 0.749) and the Sea Shell Vase (359,910 faces, score 0.763) the detected order is a continuous spiral shell / a scaling fan of ribs, not a discrete congruent array; a rigid CSG transplant would tear the 1.5 mm shell, so positions_repaired is 0. On large-shell.stl (1,318,202 faces) the score is 0.000 — genuine organic morphology with variable-size growth, no congruent repetition. All three are written unmodified (stage-1 output), never silently reshaped.

Single-side scan closing

Methods #8 (poisson_close) and #9 (flat_back_close) in sutura/closing.py handle a mesh that is a single-sided scan or relief with one dominant opening — the "back" is missing, so no repair can be verified against real geometry. #8 screens a Poisson surface reconstruction over the open boundary; #9 keeps the measured surface exactly and closes the opening with a flat back plane and side walls (the cap follows the boundary-loop orientation, so clockwise loops no longer build an inverted cap). Both run on the original input, not the cleaned arrays (stage 1 would already have flat-capped the loop), and are adopted only when the result is strict-watertight and the original surface is still covered by a one-sided input→output Hausdorff guard. The module's notes (e.g. "back surface was estimated") land in the closing report key.

Mirror completion

Method #14 (mirror_complete, sutura/mirror_repair.py) is a symmetry-aware alternative to Poisson closing for a single-sided scan of an (approximately) bilateral object. detect_mirror_plane fits the dominant boundary loop's plane and scores how planar and dominant that loop is; when the confidence clears the threshold, mirror_close reflects the visible surface across the plane (with flipped winding, so the shared boundary edges cancel and the seam closes), welds the seam, and — for overlapping halves — dissolves them into one outer shell with extract_outer_shell. When the symmetry confidence is low or the mirrored stitch is not a valid solid it falls back to screened-Poisson reconstruction (#8); the closing report's mode field says which path ran (mirror vs poisson_fallback) and mirror_score records the confidence.

Thin-wall analysis and thicken

sutura/wall_thickness.py (behind method #15, wall_thicken) estimates the local wall thickness of a mesh from a signed-distance-field grid: area-weighted surface samples with a nearest-normal sign, and an inward ray march from each face centroid to the first zero crossing (with a nearest opposite-facing-sample fallback). estimate_wall_thickness returns a heatmap-ready per-vertex array plus min/median/mean and the count of vertices below a target. thicken_to_min offsets thin walls by a morphological dilation of the solid (delta = (target - min) / 2, bounded to 25 % of the bbox diagonal) and re-extracts the surface with a dependency-free marching-tetrahedra pass. #15 is opto-in by design (needs_user_input, so it is excluded from the ranking and never auto-escalated) and is selected explicitly — --methods 15, optionally with --wall-min-thickness T (default 1 % of the bbox diagonal).

Proxy template repair

Method #10 (proxy_template, sutura/proxy_repair.py) targets a heavily broken but otherwise healthy mesh: it builds a coarse watertight proxy from a screened-Poisson reconstruction, then re-projects the healthy original regions onto it so the detailed surface is preserved and only the missing parts come from the proxy. It is bounded by a face budget and, like the closing tiers, adopted only when strict-watertight and the original surface stays covered (input→output guard).

Flap (surface hole fill)

Flap (sutura_engine.flap) is a surface-based hole filler that covers a boundary loop by continuing the neighbouring surface instead of laying a flat lid over it. It runs as a Stage-1 pre-pass on the raw input, before the VCG chain and before the Graft/fTetWild tiers, so those tiers see the flap-closed surface. Each loop is triangulated with a minimum-area dynamic program, refined to the surrounding edge length, and faired with a discrete biharmonic (thin-plate) solve that carries the rim's position and slope into the patch. The original triangles are kept verbatim -- only the new patch is added -- and any loop Flap skips or rejects is left to the ordinary Stage-1 flat hole fill, so every hole still gets a lid.

It is on by default. --no-flap (or SUTURA_FLAP=0) turns it off, --flap (or SUTURA_FLAP=1) forces it explicitly, and the GUI exposes the same switch as the Options → Repair checkbox Flap surface hole fill. A candidate is adopted only when the reload-honest holes + non-manifold count does not worsen; a flatness-based cleanup then drops zero-thickness debris sheets and never a legitimate solid part. When the thin-plate patch is rejected (a coplanar fold), the loop tries the membrane, reference-fairing and unfaired refined candidates in turn and commits the first that passes the same guards within the Flap time budget; set SUTURA_FLAP_SELECT=0 to disable this per-loop selection and keep the thin-plate-only behaviour.

The fill core has a Rust implementation (sutura_geom.flap_fill, part of the sutura_geom extension) that is used automatically when present, and otherwise falls back to the pure-numpy oracle flap_fill_python. The two are bit-for-bit equivalent in topology and report fields (faired vertices agree to about 1e-8), so the result does not depend on which engine is available.

The report carries a flap key, present only when the switch is on: ran, adopted, reason, baseline_holes, baseline_non_manifold, candidate_holes, candidate_non_manifold, loops_found, loops_filled, loops_skipped, loops_skipped_by_reason, patch_faces, new_vertices, time_s, max_orig_vertex_move_mm, and debris_cleanup when the cleanup dropped anything.

Measured on the 41-mesh regression: Flap was adopted on 13 meshes, and the run ended reload-watertight on all 41. Across the corpus it reduced the total Stage-1 self-intersection count from 10,051 to 8,880; on ornate-frame it reduced the Stage-1 self-intersections from 4,016 to 3,005; and the maximum surface deviation on thingi10k_1038441 dropped from 4.11 mm to 0.60 mm. The Rust engine keeps the whole 41-mesh corpus Flap step at 7.2 s.

Tamponade (interior hole plug, #17)

Method #17 Tamponade (sutura_engine.tamponade, Rust sutura_geom.tamponade) closes open boundaries from the inside. Instead of laying a patch over the opening, it walks each boundary loop and builds a plug that meets the shell at the narrowest point, without touching the surrounding healthy triangles; the input faces are carried through verbatim (healthy_faces_kept). Residual self-intersections are then melted in thin layers from the deepest point outward, and a layer that would not reduce the crossing count is rolled back, so the tier never ends up worse. It is the interior counterpart of Flap and Graft. It runs as a pre-pass on the cleaned input, before Flap and Stage 1, and gets a second chance after the Stage-1/Flap closing ladder, before the Graft/fTetWild tiers.

It is on by default: disable it with --no-tamponade (or SUTURA_TAMPONADE=0), or the Options → Repair checkbox Tamponade (fill holes from inside). A plug is rejected only when one of these holds, so the tier only changes the output when it strictly helps:

  • it leaks outside the solid — a plug sample lies in empty space beyond clamp(diagonal/10000, 0.0125, 0.05) mm from the original surface and not over the hole aperture;
  • it creates holes or non-manifold edges on the reload-honest (welded) mesh;
  • it overlaps an existing surface with the opposite orientation (a double wall) over more than 20 % of its own area — opposite coincident faces can cancel material in an even-odd slicer, whereas a same-orientation overlap is unioned by a positive-fill slicer and is left alone;
  • it would add a separate part or a closed shell of its own (a plug must be edge-connected to the main shell through its boundary loop);
  • or fewer than 99 % of the pre-tier faces are kept.

Inside the tier, a loop whose reference plug is rejected gets an inflate-from-inside candidate, Stent: the plug is grown inward and clamped so it can never rise past the rim (a flat through-hole cannot inflate into a dome, and a Stent seal is always built on the original rim, never on a reference overshoot trim). A loop a single Stent cannot seal is partitioned into simple sub-loops at its pinch points and across the shortest valid interior chord, and one Stent is inflated per sub-loop; adjacent sub-plugs share the chord edge exactly, so the seam stays manifold, and the group is judged all-or-nothing. The name is deliberately not balloon, to avoid confusion with method #8 Balloon (screened-Poisson reconstruction). Stent runs only when Tamponade runs; its only switch is the env opt-out SUTURA_TAMP_STENT=0 (default on, a geometry no-op).

Because the adoption gate is topological, a plug that is clean but changes the shape or drops the part count could still pass; a final safety net therefore compares the post-tier result against the pre-tier result on the reload-honest mesh and re-runs with Tamponade off whenever the tier did not strictly help, so the tier can never make the final model worse than --no-tamponade.

Self-intersections are report-only (a slicer unions overlapping volumes), so they never block adoption. The Rust extension must provide sutura_geom.tamponade (there is no numpy fallback); otherwise the tier reports itself unavailable and is skipped. The wall-clock budget follows the intensity preset (quick disables the tier). The report carries a tamponade key with ran, adopted, reason, plugs, plug_faces, melt_layers_tried/_accepted/_rolled_back, micro_welds_ok/_failed, healthy_faces_kept, input_faces, output_faces and seconds.

Graft (morphology shell wrap, #13)

Method #13 Graft (sutura_engine.graft, legacy sutura/shell_wrap.py) is a high-fidelity morphology shell-wrap tier tried automatically before fTetWild whenever a mesh contains severe damage or complex topological defects.

Note: Graft (#13) requires the sutura_geom Rust extension (installed automatically by install.sh, install-macos.sh, and bundled in AppImage/.dmg distributions). Without it Graft is listed as unavailable and auto repair uses fTetWild as before.

  1. Generalized-Winding Signed Distance: Using the Rust sutura_geom core, a signed-distance field is constructed on an adaptive voxel grid tuned to the median edge length of healthy triangles.
  2. Morphological Closing: The surface undergoes a morphological dilation followed by a true Euclidean Distance Transform (EDT) re-distancing and erosion. The closing radius begins at ~1.5 voxels and expands dynamically only until the extracted dual-contouring envelope is reload-watertight.
  3. Verbatim Hybridization: The envelope is refined and projected back onto the original vertices. Triangles in healthy regions are preserved byte-identical from the input mesh, while the envelope bridges and seals damaged or missing regions.
  4. Fidelity Guard & Fallback: The result is adopted only if reload-watertight. If the healthy surface deviation exceeds threshold (fidelity_ok == False), auto-triage evaluates fTetWild in parallel and adopts whichever candidate achieves lower healthy-surface deviation. Detail loss is flagged in the report (graft.warnings).
  5. Deterministic resolution: The grid budget is a pure function of the explicit intensity preset (Quick/Balanced 1.5M cells, Thorough 10M, Extreme 25M), so the same input plus the same options always produce the same envelope on any machine. Free RAM never selects an arbitrary resolution: if the preset budget would not fit, Graft falls back once to the Balanced grid (reported in the output) and only a case where even that does not fit skips Graft with a clear low_memory message.
  6. Deterministic closing ladder: The closing-radius ladder stops on a deterministic work budget derived from the input grid and the preset, not on a wall clock, so the same input and preset give the same mesh on a fast or a loaded machine. The wall-clock budget is only an outer safety abort: if it trips, Graft is abandoned, the stage-1 result is kept, and both the report and the GUI say so plainly.
  7. Input-coverage guard: an envelope is accepted only if it covers at least half of the input's real extent (graft.GRAFT_MIN_INPUT_COVERAGE = 0.5; the report carries input_coverage and coverage_ok), so a collapsed result that happens to be accurate where it remains is rejected and the stage-1 result is kept.

Dressing (variable-viscosity volumetric skin, #16)

Method #16 Dressing (sutura_engine.dressing) is the surgical sibling of Graft: instead of bridging the damaged region, it dips the whole part in a spatially varying "liquid" and extracts the level set F(x) = s(x) - r(x), where s is the generalized-winding signed distance and r a viscosity radius that stays thin over detailed healthy surface and grows thicker over damage. The isosurface of a scalar field is 2-manifold and self-intersection-free by construction, which is what makes Dressing the tool for whole-shell topological folds where local patch excision and hybrid splicing fail.

  1. Feature-anchored resolution: the voxel size follows the mesh's characteristic feature size (median edge length), clipped to the intensity preset's diagonal band, so a coarse open fragment is not oversampled (the earlier diag/200 rule produced a 1 M-face coat and 7 % deviation on a 71-face mesh).
  2. Variable viscosity: the base radius is thin (fidelity comes from a thin coat, not surface snapping — projecting the coat back onto the input re-introduced thousands of self-intersections and is permanently rejected), the bridging radius is capped by the preset and by 45 % of the largest gap.
  3. Numpy prototype backend, Rust-ready: the field build and marching tetrahedra run in numpy today; the Rust sutura_geom.dressing_coat narrow-band core is feature-detected and takes over automatically when available. Decimation is a PyMeshLab quadric collapse (optimalplacement=False, planarquadric=True) that rolls back atomically if it would open a hole, create a non-manifold edge or introduce an exact self-intersection.
  4. Drain (healthy-region erode-back): the coat sits ~r_base outside the original, so a drain term is added inside the level set (F = s - r + delta_r) — never by vertex projection, which re-introduced 7,850 self-intersections. Full drain cuts healthy growth ~40 % (median +0.33 → +0.20 mm) with a harmless ~4 µm median inward dip while defects stay covered. --dressing-drain MODE|mm / SUTURA_DRESSING_DRAIN / the GUI Drain combo override the preset default (Quick off, Balanced/Thorough full, Extreme deep).
  5. Auto-ladder fallback behind a single default switch, shipped OFF. Method #13 Graft (verbatim hybrid) remains the default automated repair pass after Stage 1 (it keeps healthy original triangles verbatim where it can). With the switch on (DRESSING_DEFAULT_ENABLED / SUTURA_DRESSING_DEFAULT=1) the Auto ladder treats Dressing as an opt-in fallback after Graft and before fTetWild; under --si-mode repair, a positive exact self-intersection count also triggers the tier (under default --si-mode report, only remaining holes and non-manifold edges trigger fallbacks). --experimental-dressing / SUTURA_DRESSING=1 / the GUI checkbox Dressing (viscosity coat) force it to run (warning: watertight, but the shape may deform / fine detail may be lost); --no-dressing / SUTURA_DRESSING=0 disable it. Adoption requires strict-watertight, exact-SI-free, the preset's coat→input fidelity gate, and the shape-preservation gates below. The switch is OFF by default: on a fine-detail relief (ornate-frame) the voxel staircase visibly flattens fluting even though the mean deviation stays ~0.08 mm, so Dressing must not replace the verbatim Graft/fTetWild result on such parts until that quality issue is fixed.
  6. Shape-preservation gates. Besides fidelity, a candidate is adopted only when, relative to the result the ladder held before Dressing: the signed volume changes by at most DRESSING_MAX_VOLUME_DELTA (10 %), the connected-component count grows by at most DRESSING_MAX_PARTS_SLACK (1), and the P95 coat-vs-input vertex-normal angle over healthy surface stays below DRESSING_MAX_NORMAL_ANGLE_P95 (30°, a placeholder — measured coat P95 is 12–36° on the accepted meshes and 82–88° on the detail lost frames). The normal metric (nearest input vertex via a KD-tree, healthy = not a defect vertex) catches the staircase that the position-only Hausdorff gate does not; the volume/parts gates catch a coat that absorbs a cavity or shatters into debris. The geometric CAD/machined-vs-organic score (cad_likeness) is recorded on every Auto run for the future calibrated CAD guard; the ladder already runs Graft first for all inputs, so a CAD-like part gets the verbatim Graft hybrid before Dressing and Dressing only runs when damage remains.
  7. Defect-set and viscosity overrides: --dressing-defects {all,holes_nm} (SUTURA_DRESSING_DEFECTS / the GUI Defects combo) selects which input defects drive the viscosity mask — all (the default) is holes, non-manifold edges and self-intersections, holes_nm drops the self-intersection term so a scan whose only damage is holes/non-manifold edges is not thickened by spurious SI. --dressing-rmax-scale F / --dressing-sigma-scale F (SUTURA_DRESSING_RMAX_SCALE / SUTURA_DRESSING_SIGMA_SCALE / the GUI r_max and sigma combos) multiply the resolved r_max / sigma; the default (unset = 1.0) is byte-identical, and non-positive or unparseable values are ignored.
  8. Component cleanup: before decimation the extracted triangle soup is split into vertex-connected components and the extraction debris — sub-voxel shells below a face-count, voxel-volume and relative-volume floor — is dropped. A surviving inverted shell is kept only when it is nested inside a kept material shell and the input's generalized winding number at its centre is ~0, i.e. the input is genuinely empty there (a true cavity). On the 13-mesh dressing_tuned set this collapsed thingi10k_145065 from 669 to 1 component and thingi10k_63785 from 25 to 1 (both number_of_parts = 1 in OrcaSlicer afterwards) while keeping the three genuine material lumps of thingi10k_1038439; the removed counts and volume are reported in report['dressing']['cleanup'].
  9. Opt-in suggestion and --dressing-force-adopt. When a plain Auto repair still leaves the geometry open or non-manifold (under default --si-mode report), the report carries a suggestions list (method = dressing, separate from the defect issues, so the category is unchanged). The --human report prints a marked hint (⚠ Not watertight…) that points at --experimental-dressing when Dressing never ran: ⚠ Not watertight. You can try Dressing: --experimental-dressing (watertight, but the shape may deform / fine detail may be lost). or at --dressing-force-adopt when Dressing ran and its coat was rejected by the shape-preservation gates (with the failed volume / normal numbers in parentheses): ⚠ Not watertight. Dressing ran but its result was rejected by the quality gate (...). Force it with --dressing-force-adopt if you accept the deformation (the shape may deform). --dressing-force-adopt implies --experimental-dressing and skips the volume / components / normal / fidelity gates while STILL requiring a strict-watertight, self-intersection-free candidate; the record's forced field marks when the gates were bypassed. The GUI exposes the same flag as a batch-wide Options → Experimental checkbox Dressing: force adopt (may deform) and, after a batch in which any result offered the hint, a non-blocking button ⚠ Try Dressing (makes it watertight, but the part may deform) (or ⚠ Try Dressing on %d file(s) (watertight, but the part may deform)) that re-runs exactly those files with the flag (and sets the checkbox).

Honest watertight verdict (verified after save/reload)

STL stores no vertex sharing, so a mesh that is watertight on the in-memory index topology can reload with non-manifold edges once the loader re-welds exactly-coincident float32 positions — a class of false "watertight" claims that surfaced on 7 of the 40 real-world samples (e.g. thingi10k_1038439). Sutura now judges the saved mesh, not the in-memory arrays:

  • P-HONEST (repair.enforce_reload_verdict) re-welds the final mesh in the save/reload-equivalent form (positions cast to float32, exact duplicates merged) and only lets a report claim watertight when that form is strict-watertight. A false claim is downgraded (reload_watertight = false, stage1.reload_holes / reload_non_manifold, stage2.watertight_after_reload = false), and the method registry applies the same rule, so such a baseline counts as failed and the auto path escalates. A genuinely watertight mesh keeps its report and output byte-identical.
  • P-WELD (repair.p_weld_final) is the reload-safe final pass that fixes the seam instead of only reporting it. When the in-memory topology is already a clean 2-manifold, it splits the float32-coincidence vertex groups with a sub-ULP nudge (face count preserved, geometry moved by less than one part in 10⁶); when the welded mesh itself is repairable it falls back to topology repair plus small-hole capping. A candidate is adopted only when the saved mesh reloads strict-watertight, so the pass can never make the output worse. report['p_weld'] records before/after holes and non-manifold counts, the method and the number of nudged vertices; --human prints a Reload-safe final pass line.

With P-WELD, all seven previously-warning samples now reload strictly watertight with unchanged face counts — thingi10k_100281, 1038439, 1038441, 145065, 224108, 248395, 71691 — while already-watertight meshes are left untouched (p_weld absent).

External repair engines and the fTetWild manager

Sutura can call user-installed third-party repair engines and manage the optional fTetWild fallback extra from the same interface (CLI and GUI). Both parts of this Triage Engine layer are opt-in and inert until configured.

External engines. An engine is a small TOML file in ${XDG_CONFIG_HOME:-~/.config}/sutura/engines/*.toml naming an executable and its arguments (with {input}, {output} and optional {tmpdir} placeholders). Engines are slotted into the pipeline at a placement — before_stage1, after_stage1, after_stage2, replace_ftetwild or final_fallback — or ordered explicitly by a chain.toml listing the built-in stages (stage1, stage2, deep_repair, ftetwild) and engine names; an unknown name rejects the whole chain and falls back to the default. Commands run without a shell, in their own process group, killed on timeout. Every engine result passes the same guard as fTetWild — adopted only when its holes and non-manifold edges are no worse than the current result, and an adopted result far from the input is flagged shape_changed (Hausdorff > FTETWILD_MAX_HAUSDORFF_REL) rather than rejected; an engine can never bypass or disable the guard. Each run adds a per-engine report entry (name, placement, rc, time, adopted, reject_reason, stdout/stderr tails) and broken configs only warn in engine_warnings, never fail a repair. With no engines configured the repair is byte-identical to the pre-engines behaviour. sutura engines list shows the resolved chain and each engine's placement, enabled state and whether its binary was found; sutura engines check additionally exits non-zero when a configured binary is missing (it never runs an engine).

fTetWild manager. sutura ftetwild status|install|uninstall inspects, installs or cleanly removes the fTetWild extra (pytetwild + pyvista + vtk) in the Sutura environment only (Linux venv311 or the macOS conda sutura-env), showing the download and on-disk sizes first and requiring --yes (or an interactive confirmation) for install/uninstall; --dry-run prints the plan and never touches the environment. It refuses unsupported layouts (AppImage, frozen .app/.dmg, system Python, read-only environments) with an explicit reason, and uninstall prunes only the packages the extra added, protecting the base environment. In the GUI this is the Engines tab of the Options window: a status line (installed/version/size or the unsupported reason), Install/Remove with the size shown in the confirmation and a streamed, cancellable progress dialog, plus the configured engine list with Reload, Open engines folder and a link to docs/external-engines.md. The threads are interruptible and joined when the window closes.

Feature status

A honest maturity snapshot of each major area — what is solid, what is a known limitation, and where to be cautious. The percentages are an assessment, not a metric; they are meant to tell you where you can trust Sutura and where you should still double-check the output.

Area Maturity What is solid / where to be careful
STL repair (staged pipeline) ~96% The VCG + manifold3d pipeline is CI-hardened against malformed/adversarial/torture inputs and validated on a 75-model real-world corpus (0 hard failures; the stage-1 chain was reordered and maxholesize made mesh-sensitive so large scan holes close) plus a 115-mesh real-world scan corpus (re-measured with a strict defects.detect() closed-loop check on the actual output geometry: 0 crashes, 103/115 = ~90% strictly watertight, every pipeline claim confirmed 1:1 — see docs/repair-benchmark-strict-watertight-2026-09.md). The top-level verdict is reload-honest (P-HONEST): a mesh is called watertight only when the saved file re-loads strict-watertight, and the P-WELD final pass heals the STL float32 seam collapse that used to make 7 of the 40 samples reload non-manifold — with unchanged face counts — while already-watertight meshes are untouched. Not 100%: pathological self-intersections can be reshaped by the stage-2 rebuild, and the last few stubborn holes / heavy non-manifold structures on scan meshes are a genuine VCG limit.
3MF multi-object ~92% Every object is repaired independently in memory and written back, so no object is lost. An object that stage 1 closes now gets a per-object stage 2 (manifold3d watertight rebuild) through the same shared helper as single-mesh files: per-object stage2 reports, objects_watertight / objects_stage2_ok aggregates, and the file-level verdict considers ALL objects (not just object 0). Byte-identical objects reuse one repair but each still gets its own report. Auto escalation is per-object: baseline-watertight objects remain untouched while failing objects escalate through ranked repair methods independently (reporting per-object method_used). A layered/duplicated-vertex (Bambu-style) 3MF is fixed at Stage 1 (a second duplicate-faces pass after vertex dedup) and repairs to 12 faces / 0 holes per object, confirmed watertight by per-object stage 2. Regression-tested (tests/test_stage2_3mf.py, tests/test_multiobject_escalation.py). Known limits: per-object stage 2 only applies to objects that stage 1 actually closes (open objects are stage-1 output), the object-0 stage1/stage2 top-level fields are kept for backward compatibility, and the <vertex> parser assumes the x,y,z attribute order.
Defect detection (holes / non-manifold) ~90% Stdlib+numpy, single source of truth, unit-tested on clean and broken cubes. Not 100%: it reports input defects only; on a mesh with thousands of micro-cracks the per-defect list gets large, and the CLI JSON omits index data (rendering-only).
GUI ~89% Native Qt batch repair, drag & drop, defect panel, pre-repair analysis with mode suggestions, heatmap, before/after comparison (static + interactive 3D viewer with surface deviation), a color-coded defect view (red = non-manifold, orange = flipped winding, yellow = degenerate face — FAZ11), a "what changed" repair log panel (holes closed, non-manifold edges fixed, faces removed, components, stage 2 — FAZ11, now also reporting the Graft grid fallback / low-memory guard and the Graft and fTetWild time-abort messages), a tabbed Options window (General / Repair / Experimental / Updates / Changelog / Engines; Ctrl+, / Cmd+,) holding the batch-wide switches (fTetWild fallback tier — FAZ17; Flap surface hole fill, on by default; Tamponade interior plug — #17, on by default; plus the opt-in ones: edge-tiebreak classifier head; join-small-components — FAZ14; autorefine self-intersection resolution — FAZ16; exact indirect autorefine — Phase B/C1), repair-mode picker + repair-profile dropdown, a Triage intensity combo (presets + user profiles) with per-preset tooltips, New/Duplicate/Rename/Delete profile buttons and an inline Profile settings editor, and a Reset to recommended button on the Repair tab, an Engines tab (fTetWild install/remove manager with sizes and a cancellable progress dialog, plus the configured external-engine list and a link to the engine docs), status/version row, i18n (EN/TR). Gaps: it shells out to the CLI (no in-process progress), the native KDE file dialog only works when the system Qt matches PySide6's, and on macOS Finder right-click repair is provided by the separate Quick Action rather than the GUI itself.
CLI ~90% Stable flags (-o, --human, --defects, --diff, --mode, --profile, --intensity <preset|profile>, --list-intensities, --analyze, --list-methods, --methods {1..17}, --no-graft, --experimental-graft, --tamponade/--no-tamponade (interior plug, on by default; SUTURA_TAMPONADE=0 disables), --no-dressing, --experimental-dressing, --dressing-drain MODE|mm, --dressing-defects {all,holes_nm}, --dressing-rmax-scale F, --dressing-sigma-scale F, --dressing-force-adopt, --no-cache, --engines NAME, --repeat-source X,Y,Z / --repeat-target X,Y,Z, --wall-min-thickness T, --dry-run, --deep-repair {off,local,full}, --si-mode {report,repair,off}, --flap/--no-flap (surface hole fill, ON by default; SUTURA_FLAP=0 disables), --ftetwild-optimize, --self-check, --diagnostics ZIP, --self-repair/--yes, --version), the read-only validate, clear-cache, clear-learning, export-history, engines list|check and ftetwild status (plus install|uninstall) subcommands, JSON reports, batch summary, exit codes. Plus experimental/prototype flags: --experimental-join-components (moves small components onto the nearest larger one instead of deleting them; changes geometry, evaluation only), --experimental-autorefine (resolves self-intersections by subdividing the intersecting triangles along their intersection segments — Lazard & Valque 2025 — instead of deleting faces; NEVER deletes input faces; adopted only when its final output is not worse than the default chain; on moderate-SI meshes it reduces SI, on dense-SI scans the float64 construction is limited — see docs/alpha-wrap-feasibility-2026-09.md; re-measured 25 Sep 2026 on the 45 real-world samples it changed no final outcome — 31/45 strictly watertight either way — while the total run time went from ~33 s to ~1003 s, so it stays opt-in), --no-fallback-ftetwild / --experimental-fallback-ftetwild (the fTetWild last-resort solidifier — tetrahedralizes the ORIGINAL input with fTetWild via pytetwild, MPL-2.0, and extracts a watertight, SI-free boundary; adopted only when no worse on holes+non-manifold — is ON by default when its optional ~1.1 GB extra is installed, SUTURA_WITH_FTETWILD=1, and then runs only when stage 1 leaves holes or non-manifold edges: strict watertight 31/40 → 39/40 on the real-world samples; the first flag disables it, the second also runs it on closed results that still self-intersect), --experimental-indirect-autorefine (Phase B prototype: exact arrangement-lite self-intersection split via the rust/sutura-geom extension — indirect predicates, broad phase + exact triangle classifier + per-triangle 2D CDT + exact-rational welding; adopted only when no worse on holes+non-manifold, same guard as --experimental-autorefine; evaluation-only; the dense thingi10k_1038441 scan now completes in about ten seconds — on an M2 ~463 s (C1) → 29.8 s (C2) → 10.6 s (C4); on a 2-vCPU x86_64 VM 830 s → 52.5 s (C2) → 17.6 s (C4) — the tier is off by default and intended for evaluation) and --experimental-edge-tiebreak (opt-in 11-feature classifier head — base features + the five strong FAZ10 scan signals; the gain is marginal, 1 mesh on the 71-mesh labeled set, but the signal is statistically real; NOT the default), plus --wall-min-thickness T (target for method #15 Wall Thicken) and --no-learning-triage / the clear-learning subcommand (toggle and reset the bounded local-history ranking bonus). The --human report is English-only (localization is a GUI concern).
Batch processing ~90% Multi-file repair with per-file results and a summary. Hard stops (Ctrl-C / Stop) are handled; the batch summary is not resumable and a failed file does not halt the rest.
Defect heatmap ~80% On-demand CPU rasterizer (no GL), runs in a subprocess, never crashes the GUI. Deliberately CPU-only: offscreen OpenGL segfaults on headless systems, so it is flat-shaded with a three-point lighting model rather than full GL shading, and for multi-object 3MF it renders only the first object.
Before/after comparison ~80% Static CPU-rasterized toggle between original and repaired views with a worst-defect zoom detail close-up and a tri-state colour scheme (grey = never broken, green (46,204,113) = healed, orange (255,140,60) = still broken). The healed map is spatial (repaired-face centroids vs original defect extents) and capped at the 256 largest defects so scan meshes stay fast. A Static/Interactive switch adds a CPU interactive 3D view (drag to rotate, wheel to zoom, LOD while dragging then a full-resolution final frame) and a surface-deviation mode (per-vertex repaired→original distance via pymeshlab's nearest-surface-point filter + global Hausdorff max), both built lazily on first use and cached per dialog; the interactive LOD target is tuned against the 75-model corpus (median ~71 FPS at 720×540). Same GL constraint as the heatmap means it stays a CPU rasterizer; only the first object is compared for multi-object 3MF. Regression-tested (tests/test_healed_mask.py, tests/test_before_after_render.py, tests/test_viewer_data.py, scripts/verify_before_after_dialog.py).
Validate (sutura validate) ~55% (beta) New in 0.1.8-beta.1: read-only analysis of holes / non-manifold regions / self-intersections / connected components / signed volume (orientation) / surface area with a watertight verdict — no repair, no output file. Beta quality: the combined metrics are new and not yet calibrated against real-world repair outcomes, and multi-object 3MF validates each object but keeps only a minimal aggregate report. It has a dedicated regression suite (tests/test_validate.py).
Dry-run (--dry-run) ~50% (beta) New in 0.1.8-beta.1: reports the would-do plan (detected type, mode, Stage 1 thresholds, found holes / debris / self-intersections, stage 2 availability) and writes nothing. Beta quality: the plan is derived from the input analysis, so exact hole-close counts are not guaranteed to match a real run, and the extreme-mode extra passes are not simulated. It is covered by tests/test_validate.py (validate and dry-run share the suite).
Repair modes (--mode ladder) ~80% Five-step aggressiveness ladder (low/medium/auto/aggressive/extreme) for the Stage 1 thresholds, exposed both as a CLI flag and a batch-wide GUI picker; auto keeps the historical classifier + confidence-gate behaviour byte-identical and is regression-tested (tests/test_repair_mode.py). The mode fixes the BASE maxholesize, which is then raised mesh-sensitively (max(base, 2 × longest input loop), never lowered) so large scan holes close in every mode. Caveats: extreme can delete a small object (reported as the distinct extreme_removed_object error, not malformed input) and the per-type tuned threshold values are experimental.
Repair profiles (--profile) ~70% (new) Five named Stage 1 threshold presets (mechanical/organic/scan/miniature/fast), opt-in via CLI or a batch-wide GUI dropdown; only effective while the mode is auto (an explicit fixed mode wins). Default (no profile) is byte-identical to before. Caveat: miniature lowers mincomponentsize to 1 (a deliberate opt-in that preserves tiny parts; the default path keeps it >= 8).
External engines + fTetWild manager ~65% (new) User-installed third-party repair engines configured as TOML files (~/.config/sutura/engines/*.toml) and slotted into the pipeline at a named placement or an explicit chain.toml order; every engine result passes the same holes/non-manifold + Hausdorff guard as fTetWild (adopted only when no worse, a far-off result flagged shape_changed, never a bypass), per-engine report entries, broken configs only warn. With no engines the output is byte-identical to before. sutura engines list|check and the CLI/GUI fTetWild manager (sutura ftetwild status|install|uninstall, the Options Engines tab) install or cleanly remove the optional fTetWild extra in the Sutura environment only, with sizes and confirmation, and refuse AppImage/frozen/system-Python layouts with a reason. Caveat: the engine contract is new (no corpus of third-party engines yet), and the guard adopts on holes/non-manifold no-worse with the shape flag identical to fTetWild.
Triage intensity (--intensity) ~85% Four read-only presets (quick/balanced/thorough/extreme) for the post-Stage-1 effort (deep-repair tier, fTetWild state/cap/budget, Graft input face cap + wall-clock budget, decimation ladder, Hausdorff samples), a single source of truth in sutura/triage.py. Balanced equals the historical module constants and is regression-tested byte-identical; the CLI/GUI precedence is CLI > SUTURA_INTENSITY > config > balanced. Quick/Thorough/Extreme double as a way to bound or extend the two hard failure modes (fTetWild input size, dense-boundary decimation). On top of the presets, named user profiles (base preset + sparse field overrides, stored schema-versioned and atomically in ~/.config/sutura/profiles.json) can be created/renamed/duplicated/deleted in the GUI and selected with --intensity <profile>; --list-intensities prints the effective values, the report adds triage_profile_base/triage_overrides, and the 200,000 Hausdorff floor is clamped in the resolver too. Caveat: the preset values are engineering choices, not corpus-calibrated.
Mesh type-aware repair ~75% Heuristic mechanical/organic guess tunes two Stage 1 thresholds, gated by a per-class confidence gate (mechanical ≥ 0.75, organic ≥ 0.70) and measured by a calibration harness (scripts/calibrate_classifier.py). The default engine is the experimental one (sutura/mesh_classifier_v2.py): RANSAC plane segmentation + a curvature developability signal + a small trained head. On the 40-mesh real-world corpus (36 labeled) it scores 29/36 vs classic's 16/36 (mechanical recall 16/20 vs 2/20 — the classic scanner-bias limitation is much more visible on the expanded corpus), LOO-CV on the 71-mesh labeled set 0.845 vs classic 0.648. It classifies curved-but-mechanical parts (cylinders, tubes, fillets) that classic reads as unknown, and falls back to classic silently on exceptions/invalid results; --classifier-engine classic / SUTURA_CLASSIFIER_ENGINE=classic still selects the old engine. A near-boundary classic-agreement fallback keeps the head's class but inherits classic's confidence when the head is a coin-flip (|p_mech − 0.5|·2 < 0.15) and classic strongly agrees (class-score ≥ 0.70), so a strong agreeing signal is never lost to the tuning gate (this restored artec_metal-nut.stl to watertight — 103/115 on the scan corpus). Experimental: the per-type values are estimated starting points, hard free-form/curved samples (a bent pipe, a spiral pipe, a curved hook, decorative bowls read as mechanical) are still missed, and a confident-but-wrong v2 prediction is not re-checked against classic — treat the detected type on scan-derived input with caution.
Repair confidence score ~70% (beta) Combines existing repair signals (stage 2 outcome, remaining holes, classifier confidence, tuning status, repair mode, self-intersections, volume change) into a single 0–100 score with a High/Medium/Low label: repair_confidence on repaired files, estimated_confidence on validate / --dry-run (with a "result may differ" caveat). Regression-tested (tests/test_confidence.py). Experimental: the weighting model is new and not yet validated against real user feedback.
Repair Health / Risk ~60% (new) A separate, additive scoring system on top of the classic confidence: repair_health (0–100, final-mesh soundness: watertight + no non-manifold/self-intersections/holes) and repair_risk (0–100, how much the repair altered the mesh: face/vertex/component/volume deltas), plus a two-axis status label (safe/review/caution/failed/unavailable) derived from a config lookup table. Weights/thresholds live in sutura/repair_score_config.json, not code; fail-silent (never breaks a repair); regression-tested (tests/test_repair_score.py). Experimental: the weight values and tier boundaries are starting points.
Repair budget (--max-geometry-change / --max-risk / --force) ~60% (new) Optional guard-rails: when the actual geometry change (max of |volume|/|surface| change %) or the repair_risk score exceeds the budget, the output is never saved silently — interactive [y/N] prompt on a TTY, else the save is declined with the explicit "status": "budget_declined" marker (issue budget_exceeded, exit 1), and --force saves without asking. Within budget the numbers are still reported (budget block). Reuses the existing volume/surface/risk metrics (no recomputation); the GUI offers the same budgets and re-runs declined files with --force after confirmation. Regression-tested (tests/test_budget.py). Experimental: 0 = no limit, and a declined save means the file is simply not written.
Unit detection warning ~60% (new) Non-blocking bounding-box heuristic that flags models probably authored in inches/centimetres instead of millimetres (unit_warning + unit_hint in JSON, a WARNING: line in --human, and shown in the GUI log / analysis pane / defect panel). 3MF <model unit="..."> declarations are honoured: a declared inch/centimetre unit is reported as-is, a declared millimetre unit (the spec default) is trusted and suppresses the heuristic. Never changes the repair — informational only. Regression-tested (tests/test_units.py). Experimental: the 25–400 mm "plausible printable part" range and the inches-before-centimetres ordering are heuristics, not calibration.
Cross-platform (Linux/macOS) ~80% Linux (install.sh + AppImage) and macOS (conda) both work, CI covers both; each release also ships an unsigned macOS .dmg (Build macOS .app/.dmg workflow) and macOS installs get a native ~/Applications/Sutura.app so the GUI launches from Spotlight (Cmd+Space → "Sutura"), plus a Finder Quick Action (~/Library/Services/Sutura Quick Action.workflow) for right-click repair. Gaps: the AppImage/GUI cannot self-update in place (read-only squashfs), the .dmg is not notarized (shows Gatekeeper's "unidentified developer" warning), and macOS has no standalone uninstall script (see "Removing a macOS install" below).
Auto-update ~75% Opt-in, backs up and rolls back on a failed self-check. The version check understands prerelease tags, so beta testers are offered the stable release once it is out. Auto-update stops at the v0.2.0 license boundary: a v0.1.x install is never silently upgraded across it (the new terms are shown first and the release must be installed manually from the releases page). Caveats: it is Linux/pip-install only (AppImage downloads a new release instead), and it talks to GitHub so it is not offline.
Dolphin integration ~85% Right-click service menu for STL/OBJ/3MF, single/multi-select handled. Depends on KDE Plasma and kbuildsycoca6 refresh; not available on other file managers or macOS.
OrcaSlicer plugin ~70% — experimental Self-contained script plugin for the new OrcaSlicer script-plugin API (nightly / releases newer than 2.4.2; stable 2.4.x has no plugin system). A "Sutura" dock panel beside the 3D view lists every object on the plate with a Quick/Balanced/Thorough/Extreme preset picker (Balanced default), per-object Analyze (read-only: holes / non-manifold / self-intersections and the top ranked repair methods) and Repair, "Repair selected (n)" and "Select broken", live job phases (queued → exporting → repairing → loading → done/failed/cancelled) with a Cancel button and a Show file link. Non-manifold objects (or objects whose mesh errors Orca repaired on import) raise one warning notification per object per session with a "Repair with Sutura" action. The export writes every model-part volume in world coordinates (mirrored parts keep outward-facing winding), skips parameter modifiers / negative volumes / support blockers, merges multi-part objects into one STL, and loads the repaired copy back as a new object (Undo works; outputs are kept for 7 days). numpy is a declared plugin dependency with a numpy-free fallback. Verified end-to-end in a real OrcaSlicer 2.5.0-dev (macOS); primary target is Linux, macOS is a verified bonus. request_permissions pre-declares the CLI path's fs_read up front (subprocess prompts remain, an OrcaSlicer audit-API limitation). Experimental: stub-tested against a mock host, with no live end-to-end test in CI.
Indirect predicates / exact arrangement-lite (Phase B + C1–C4) ~50% — experimental A Rust prototype (--experimental-indirect-autorefine, rust/sutura-geom) that splits self-intersecting geometry with exact indirect predicates instead of the float64 snap-rounding used by --experimental-autorefine. Phase B built the chain (predicate core, exact triangle–triangle classifier, 2D CDT with implicit points, arrangement_lite PyO3 binding, repair.py wiring). Phase C1 (0.4.0) builds every crossing from the original input planes/lines instead of chaining constructed points, and adds a rigorous interval filter in front of the exact BigRational predicates (results unchanged by construction, differential-tested). Measured on thingi10k_1038441 (M2): 1001-face subset 53 s → 6 s, 5000-face subset timeout → 31 s, full mesh did not finish in 30+ min → ~463 s, identical output face counts. Phase C2 removes the linear scans from the per-host constrained triangulation (walking point location, segment-corridor walk, local updates, integer exact fallback) with byte-identical output: on the same x86_64 VM the full mesh went from 830 s to 52.5 s (5000-face subset 56 s → 13 s); on the M2 the full mesh now takes 29.8 s (C1: ~463 s). Phase C3 (classification shortcuts, integer exact arithmetic, output again identical) brings the VM time to 28.1 s, and Phase C4 (direct hashing of reduced exact keys, integer implicit constructions, output identical) to 17.6 s (M2: 16.8 s after C3, 10.6 s after C4). Known limitations: disabled by default (intended for evaluation); bundled in installers and AppImage/.dmg releases, but not yet benchmarked on the full 115-mesh corpus. An external alternative, Geogram's MeshSurfaceIntersection, was measured and rejected (output not accepted by the manifold3d rebuild, community edition aborts on dense scans) — see docs/geogram-spike-2026-09-24.md.
Test coverage ~85% Plain-script suites, each runnable as python3 tests/<suite>.py (smoke, layered 3MF, adversarial, classification, confidence, defects, heatmap frames, healed-mask, before/after render, viewer data, validate/dry-run, mesh classifier, repair mode, suggestions, updater, obj repair, units, budget, stage2-3mf, torture, autorefine, join-components, fTetWild defaults, pinched vertices, deep-repair ladder, method registry (incl. the P-WELD reload-safe pass), repeated-element repair, mirror completion, thin-wall analysis/thicken, learning triage, scan closing, proxy-template repair, triage, triage profiles, external engines, fTetWild manager, engine integration, engines GUI, OrcaSlicer plugin v2 stub (world-transform/winding export, multi-part union, numpy-free fallback, message protocol, lifecycle debounce, notification de-duplication), history, real-world corpus, manifold3d watertight check, shell-wrap graft (tests/test_shell_wrap.py), viscosity-coat dressing (tests/test_dressing.py), engine isolation without pymeshlab (tests/test_engine_no_pymeshlab.py), Flap surface hole fill (tests/test_flap.py), Flap Rust/numpy parity (tests/test_flap_rust_parity.py), Graft tier budget / input face cap (tests/test_graft_budget.py), localized exact self-union of residual SI clusters (tests/test_local_exact.py), ray-stabbing inside/outside (tests/test_raystab.py), guarded Stage-1 SI excise + re-cap (tests/test_si_excise.py)). CI on every push/PR runs all of them except the manual torture harness on Python 3.11 and 3.14 (the stage-2-dependent budget, stage2-3mf and real-world-corpus suites on the 3.11 leg only); the two suites that build the main window run headless since the first-run dialog is skipped on the offscreen Qt platform. The Rust core has 219 unit tests including filter-vs-exact differential tests, a randomized accelerated-vs-linear CDT query test and regression tests for the evaluated (off-by-default) Sloan and edge-point CDT variants, run in CI both plainly and with --features cdt-check (every accelerated triangulation query asserted against the linear reference scan), and tests/test_sutura_geom*.py smoke-test its Python binding. Not 100%: the GUI itself has no automated UI test beyond construction and checkbox wiring, and there is no reproducible end-to-end test against a live OrcaSlicer.

Requirements

Linux:

  • python3 (>= 3.11) with venv support, for the PyMeshLab venv
  • python3.11 specifically, for the manifold3d venv (manifold3d ships wheels only up to Python 3.13)
  • KDE Plasma for the Dolphin service menu (optional; CLI and GUI work anywhere)

macOS (Apple Silicon / Intel):

  • Homebrew and Miniforge (conda). pymeshlab has no PyPI wheel for Apple Silicon, so it must come from conda-forge; this is why macOS uses a single conda environment (install-macos.sh) rather than the Linux pip-only flow.
  • Install Python 3.11 via conda (install-macos.sh does this automatically).

Linux Python 3.11 install:

  • Arch: Python 3.11 is not in the official repositories; install.sh downloads a standalone runtime automatically (SUTURA_NO_PYTHON_DOWNLOAD=1 to opt out)
  • Debian / Ubuntu 22.04+: sudo apt install python3.11 python3.11-venv
  • Fedora: sudo dnf install python3.11

On other distros, if your default python3 is 3.11, no extra install is needed.

Troubleshooting

  • python3.11 not found. Stage 2 (manifold3d) needs Python 3.11 because it ships wheels only up to 3.13. On systems without a system python3.11 (such as Arch Linux, where Python 3.11 is not in the official repositories), install.sh automatically downloads a standalone CPython 3.11 build into ~/.local/share/sutura/python311. This automatic download can be disabled with SUTURA_NO_PYTHON_DOWNLOAD=1. Alternatively, install Python 3.11 via the system package manager:
    • Debian / Ubuntu 22.04+: sudo apt install python3.11 python3.11-venv
    • Fedora: sudo dnf install python3.11 Then run install.sh again — it reuses existing runtimes and virtualenvs.
  • PySide6 install fails. The GUI needs PySide6-Essentials, which is installed into the venv from PyPI. On distros where pip install PySide6-Essentials fails (missing build tooling or a blocked PyPI), install the system Qt Python bindings instead and point the entry point at them:
    • Debian/Ubuntu: sudo apt install python3-pyside6
    • Fedora: sudo dnf install python3-pyside6
    • Arch: sudo pacman -S pyside6 (in the official extra repository)
  • The GUI has no KDE file dialog. The native dialog needs plasma-integration and a system Qt version that matches PySide6's. If the rubber-band selection is missing, Qt falls back to its embedded dialog — Ctrl/Shift+click still work for multi-selection.
  • macOS: pip install pymeshlab fails or pulls an Intel build. PyMeshLab has no Apple Silicon PyPI wheel — install from conda-forge (conda install -n sutura-env -c conda-forge pymeshlab, see install-macos.sh). The install is a directory of native VCG plugins (pmeshlab.*.so, PlugIns/*.so, lib/*.so, Frameworks/*.dylib) loaded at import; if a filter reports "not loaded", the PlugIns/ dir is missing next to the package. When bundling with PyInstaller you need --collect-data pymeshlab, not just a hidden import.
  • Headless GUI "hangs" right after launch. The first run (no config at ~/.config/sutura/config.json) opens a modal update/usage-history dialog and blocks until answered. Since the version after 0.4.1 the dialog is skipped on Qt's windowless platforms (QT_QPA_PLATFORM=offscreen or minimal) and nothing is written, so it is still asked on the first on-screen start. On any other display without a user (e.g. a forwarded X session in a script), pre-seed the config, e.g. {"check_for_updates": false, "history_enabled": true}. The update prompt is skipped for AppImage builds; the history prompt is always asked on first run.
  • macOS: grep: empty (sub)expression on every git push. The pre-push security hook's secret pattern used an empty alternation branch that BSD grep rejects, so the secret scan silently never ran. Fixed in scripts/pre-push-security-check.sh (optional group) — reinstall the hook with ./install.sh / ./install-macos.sh.
  • Standalone .app: stage 2 reports skipped / low confidence. Stage 2 (manifold3d) needs manifold_bridge.py next to the bundled CLI (e.g. Sutura.app/Contents/MacOS/sutura-cli/manifold_bridge.py) with manifold3d/trimesh bundled; otherwise stage2_bridge_available=false and confidence drops ~25 points.

If a repair fails, or a method looks unavailable, three commands help diagnose and recover the installation:

  • sutura --self-check verifies the installation by exercising the repair methods on a tiny built-in mesh and prints a summary line (Methods: N/17 OK (X failed, Y skipped)) in a couple of seconds. A method that cannot be loaded or that fails fails the check; an optional addon that is simply not installed is reported as skipped, not failed. The command exits non-zero if any method fails. The GUI runs the same availability check when it starts and shows a dismissible banner if a method is unavailable.
  • sutura --diagnostics out.zip writes a single ZIP archive with the system and Python runtime information, the state of the sutura_geom extension, the availability of all seventeen methods, the user settings, and the most recent repair record. Personal paths and user names are scrubbed from the archive, and no file names are included, so it can be attached to a bug report.
  • sutura --self-repair checks the installed application files against the release manifest for the installed version and repairs a missing or modified one. It always asks before replacing anything, backs up the files it changes and rolls back if verification fails, and it never touches user data (history, learning, settings). AppImage and macOS .app installs are read-only, so they print a pointer to the releases page instead of repairing in place. sutura --self-repair --yes skips the confirmation prompt.

Install

Linux

One line (fetches the latest main and installs):

curl -fsSL https://raw.githubusercontent.com/Krateian/Sutura/main/install.sh | bash

Or from a clone:

git clone https://github.com/Krateian/Sutura.git
cd Sutura
./install.sh

This creates two virtualenvs under ~/.local/share/sutura, installs the CLI wrapper at ~/.local/bin/sutura, installs the hicolor app icons, and registers the Dolphin service menu. Re-running is safe.

AppImage (optional). A self-contained AppImage with both Python runtimes bundled (3.14 for stage 1 + GUI, 3.11 for stage 2) — no virtualenvs and no system python3.11 needed. Each tagged release ships a prebuilt Sutura-x86_64.AppImage on the GitHub releases page; you can also build it yourself with scripts/build_appimage.sh (produces dist/Sutura-x86_64.AppImage). Make it executable and run:

chmod +x Sutura-x86_64.AppImage
./Sutura-x86_64.AppImage            # GUI
./Sutura-x86_64.AppImage model.stl  # CLI (writes model_fixed.stl)

Unlike the install.sh flow, the AppImage build cannot update itself in place; grab a new AppImage from the releases page above. The Dolphin right-click service menu is still installed by install.sh.

Installation uses pip inside isolated virtualenvs — no AUR, no yay/paru required, nothing touches your system package manager. The GUI needs PySide6 (~79 MB download, part of the venv); total installed size for the two virtualenvs is roughly 800 MB.

Rust extension (sutura_geom). The Rust extension sutura_geom (~1 MB) is installed automatically by install.sh and install-macos.sh (which download and verify a matching prebuilt abi3 wheel from GitHub releases, or compile from rust/sutura-geom when a Rust toolchain is present; AppImage and macOS .app/.dmg bundles include it). It accelerates Method #13 Graft (morphology shell wrap), the Flap surface-hole fill (flap_fill), the Dressing coat (dressing_coat) and the Tamponade interior plug (tamponade), and carries the exact indirect predicates. Flap and Dressing fall back to pure-numpy implementations when the extension is absent, so only Graft, the exact arrangement tier and Tamponade strictly require it (Tamponade reports itself unavailable and is skipped when the extension does not provide it). Use SUTURA_NO_GEOM=1 to opt out of the extension, or SUTURA_GEOM_FROM_SOURCE=1 to force compilation from source.

Optional extra — fTetWild fallback tier. The fTetWild fallback needs pytetwild + pyvista, and pyvista pulls in VTK: roughly 1.1 GB installed (VTK alone ~520 MB). It is therefore not installed by default (nor bundled in the AppImage / .dmg). Once installed, the tier is on by default for meshes stage 1 cannot close (see Optional tiers above). Install it with:

SUTURA_WITH_FTETWILD=1 ./install.sh

Without it the default changes nothing (and the GUI checkbox has no effect); only --experimental-fallback-ftetwild reports an explicit skip.

The extra can also be managed after install, without re-running the installer: sutura ftetwild install / sutura ftetwild uninstall (with --dry-run, --yes) or the GUI's Options → Engines tab install/remove it in the Sutura environment only, showing the download and on-disk sizes first. Both refuse unsupported layouts (AppImage, .dmg, system Python) with a reason.

On Arch, install.sh automatically downloads a standalone Python 3.11 runtime for stage 2 if none is present (set SUTURA_NO_PYTHON_DOWNLOAD=1 to disable).

macOS

git clone https://github.com/Krateian/Sutura.git
cd Sutura
./install-macos.sh

install-macos.sh checks for Homebrew, installs Miniforge via Homebrew if conda is missing, creates a sutura-env conda environment (Python 3.11), installs pymeshlab from conda-forge and manifold3d/trimesh/PySide6 from pip, verifies the imports, copies the app files to ~/.local/share/sutura/, and creates ~/.local/bin/sutura (CLI) and ~/.local/bin/sutura-gui launchers. It also creates a native ~/Applications/Sutura.app so you can launch the GUI straight from Spotlight (press Cmd+Space, type Sutura, Enter) — no terminal needed. It is macOS-only and re-runnable. The optional fTetWild fallback extra (~1.1 GB, see the Linux section) is installed with SUTURA_WITH_FTETWILD=1 ./install-macos.sh.

The installer also adds a Finder Quick Action ("Sutura — Repair"): select one or more STL/3MF files in Finder, right-click → Quick Actions → Sutura — Repair. Each file is repaired with the bundled CLI and a native macOS notification reports the result — a single file shows Health: X/100 Risk: Y/100 Status: <label>, a batch shows one summary (N/M repaired, K failed — see log). The log for each run is written to ~/Library/Logs/Sutura/sutura-<timestamp>.log. Non-STL/3MF files are skipped and reported. The Quick Action locates a PyInstaller Sutura.app in /Applications or ~/Applications; if it is a downloaded (quarantined) copy, the notification tells you to right-click → Open Sutura.app once first. Because the Quick Action calls the bundled sutura-cli, it does not need this conda environment to be installed.

Removing a macOS install (manual): there is no macOS uninstall script — the Linux uninstall.sh only covers the KDE/Linux artifacts. To remove a macOS installation manually, delete:

  • ~/Applications/Sutura.app (the Spotlight wrapper) and/or the .app in /Applications
  • ~/.local/share/sutura/ (app files, including macos-quick-action.sh)
  • ~/.local/bin/sutura and ~/.local/bin/sutura-gui
  • ~/Library/Services/Sutura Quick Action.workflow (the Finder Quick Action)
  • ~/Library/Logs/Sutura/ (repair logs)
  • optionally the sutura-env conda environment (conda env remove -n sutura-env)

Note: conda can be initialized non-interactively; if the script asks you to restart the terminal for conda init to take effect, do so and re-run it.

Each tagged release also ships a macOS .dmg (Sutura-vX.Y.Z.dmg, built by the Build macOS .app/.dmg workflow) — a self-contained Sutura.app. Drag it to /Applications (or double-click to open) and launch from Spotlight the same way. The .dmg is unsigned (no Apple Developer Program / notarization applied yet), so the first open shows macOS's "unidentified developer" warning. Open it with right-click → Open, or clear the quarantine attribute first:

xattr -dr com.apple.quarantine Sutura.app

Usage

Runs fully offline after installation — no telemetry, no network calls during repair, works without internet once installed.

Sutura checks GitHub for updates only if you opt in (disabled by default). Updates back up the previous install automatically and roll back if the new version fails a self-check — no data beyond the update check itself is sent. Auto-update stops at v0.2.0: starting with v0.2 the license changes to the PolyForm Noncommercial 1.0.0 (commercial use requires a separate agreement), so a v0.1.x install is never silently upgraded across that boundary — the new terms are shown first and the release must be installed manually from the releases page.

CLI:

sutura model.stl            # writes model_fixed.stl
sutura model.obj            # writes model_fixed.obj
sutura model.3mf -o fixed.3mf
sutura model.stl --human    # human-readable report
sutura model.stl --human --defects   # also list input holes / non-manifold regions
sutura model.stl --human --diff      # also print the before/after geometry diff
sutura model.stl --mode aggressive   # use the aggressive repair mode
sutura model.stl --classifier-engine classic  # force the classic classifier engine
sutura model.stl --max-geometry-change 10 --max-risk 40   # repair budgets (see below)
sutura model.stl --max-risk 30 --force                    # save even past the budget, no prompt
sutura validate model.stl   # analyze WITHOUT repairing (read-only report)
sutura model.stl --dry-run  # report what a repair would do, write NOTHING
sutura a.stl b.3mf c.stl    # batch: each file gets a _fixed output
sutura --version            # print the version and exit

OBJ note: materials/textures (mtllib/usemtl) are not preserved in the repaired output — the mesh is rebuilt (vertices + triangles only), so texture coordinates cannot be carried over. The report flags this as material_discarded (JSON) and with a Material: line (--human) when the input OBJ references them. 3D slicers ignore OBJ materials, so this only matters if you keep the repaired OBJ for texturing.

Validate (validate). sutura validate model.stl analyzes a mesh and reports it without repairing or writing anything — a read-only health check. The JSON report carries validation (holes / non_manifold lists with centroid + diameter / face counts, self_intersections, connected_components, watertight, signed_volume, surface_area, orientation), plus detected_type and detected_confidence from the mesh classifier. --human prints it readably (--defects lists each defect region). Multi-object 3MF files validate per object (object_reports). Exit 0 means the analysis ran (even on a broken mesh); a missing / malformed input exits 1.

Dry-run (--dry-run). sutura model.stl --dry-run tells you what a repair WOULD do without doing it: the detected type, the resolved repair mode and exact Stage 1 thresholds (mincomponentsize / maxholesize), tuning_applied, and what was found on the input (holes_found / largest hole diameter / non_manifold_regions / self_intersections / debris_faces_removable) plus whether stage 2 would run. It writes no output file at all — no _fixed, no temp residue. The thresholds come from the same resolve_mode_params the real repair uses, so the two can never diverge.

Repair mode (--mode). A five-step aggressiveness ladder for Stage 1 thresholds, defaulting to auto (which is the historical behaviour: the mesh classifier + confidence gate pick per-type thresholds). The fixed modes bypass the classifier and use exact thresholds:

Mode mincomponentsize maxholesize Effect
low 8 200 most conservative: closes only small holes, minimal debris removal
medium 8 1000 the historical default thresholds
auto — — classifier + confidence gate (default); type-dependent, so not fixed
aggressive 12 3000 more debris removed, larger holes closed
extreme 20 10000 most aggressive; note this can delete an object whose whole connected part has fewer than 20 faces — when that happens the report carries a distinct error (category=error, issue extreme_removed_object), not a generic malformed-input error. On top of the extreme thresholds it runs extra self-intersection passes: any self-intersecting faces are removed and the Stage 1 chain (hole closing + debris removal) runs a second time to catch what the removal exposed. Deliberately not a full remesh (meshing_isotropic_explicit_remeshing is out of scope — it can unpredictably change topology).

The chosen mode is always reported (repair_mode in JSON, Mode: in --human; per object for multi-object 3MF). For extreme, the report also carries extreme_passes_applied (true when the extra passes ran) and, when they did, self_intersections_found/self_intersections_removed; --human shows an "Extreme passes" line. The GUI exposes the same five modes through its Mode button (batch-wide, see the GUI section below).

Repair profile (--profile). An opt-in named preset for a repair character, useful when you know the input's kind. Only effective while the mode is auto — an explicit fixed mode always wins (it is the more specific aggressiveness control). The classifier still runs (its type/confidence stay in the report) but does not drive the thresholds:

Profile mincomponentsize maxholesize When to use
mechanical 8 300 precise/mechanical parts (modest hole fill)
organic 12 1000 smooth/organic models (drop debris, close larger regions)
scan 4 10000 scan meshes: aggressive debris removal + huge hole fill
miniature 1 50 tiny parts you want preserved (opt-in: the default path keeps >= 8)
fast 8 200 a quick pass that only closes small holes

The chosen profile is reported (repair_profile in JSON, Profile: in --human). Default (no --profile) is byte-identical to before. The GUI exposes the same profiles through a Profile dropdown (batch-wide). sutura model.stl --profile scan is equivalent to the old --mode aggressive-style scan handling but keeps the mode at auto.

Unit warning. STL/OBJ files carry no unit metadata, and the size-based repair thresholds (hole size, debris cutoff) assume millimetres. After loading a mesh, Sutura computes its bounding box and, when the dimensions are only plausible when scaled from inches or centimetres to millimetres, emits a NON-BLOCKING warning (unit_warning + unit_hint in JSON, a WARNING: line in --human, and a visible warning in the GUI) that the model may not be in mm — verify the scale before printing. 3MF files declare their unit in the <model unit="..."> attribute: a declared inch/centimetre unit is reported as-is, a declared millimetre unit (the spec default) is trusted and suppresses the heuristic. The warning never changes the repair itself.

Repair budget (--max-geometry-change / --max-risk / --force). Two optional guard-rails on how much a repair is allowed to alter a mesh, both reusing metrics the repair already computes:

  • --max-geometry-change PCT — the actual geometry change is max(|volume change %|, |surface area change %|); a budget above this value is exceeded.
  • --max-risk SCORE — the repair_risk score (0–100); a budget below the actual score is exceeded.
  • 0 (or omitting the flag) disables a budget; the default is no budget at all, so existing behaviour is unchanged.

When a budget is exceeded the output is never saved silently: on an interactive terminal you are prompted (Save anyway? [y/N]), and non-interactive runs (the GUI subprocess, scripts, file-manager integration) decline the save with exit 1. The report then carries the explicit top-level "status": "budget_declined" marker (plus a budget block with the actual numbers vs. the budgets, and the issue budget_exceeded) — a distinct, machine-detectable outcome, separate from a generic repair error. --force saves without prompting (the budget is still reported). When the repair is within budget, saving proceeds normally and the budget block reports the actual numbers so you can see they stayed inside the budget. For multi-object 3MF the WORST object drives the comparison. The GUI exposes the same two budgets in its repair-options dialog and, after a batch, offers to re-run budget-declined files with --force after an explicit confirmation.

Every repair report also carries a repair confidence score: a single 0–100 value (repair_confidence in JSON) with a High/Medium/Low label that combines the stage 2 outcome, remaining holes, classifier confidence, tuning status, repair mode, self-intersections and volume change; --human shows it as a Confidence: X/100 (Label) line. The read-only modes report an estimate instead, because the post-repair signals are not known yet: validate and --dry-run carry estimated_confidence and print Estimated confidence: X/100 (Label) — actual result may differ after repair.

Every repair report also carries Repair Health / Repair Risk — a separate scoring system (independent of the classic confidence score, configurable via sutura/repair_score_config.json):

  • repair_health (0–100) — how geometrically sound the final mesh is: a weighted sum of watertight (stage 2 confirmed), no non-manifold edges, no self-intersections, no remaining holes.
  • repair_risk (0–100) — how much the repair altered the mesh: face/vertex delta %, component changes, and volume delta %.
  • repair_status / repair_status_code — a two-axis label derived from the Health/Risk tier combination via a config lookup table: safe ("Safe to inspect"), review ("Review recommended"), caution ("Caution advised"), failed ("Failed / inspect"), or unavailable. Health and Risk are independent axes, so a high-health + low-risk repair (safe) never collapses to the same status as a mid-health + high-risk one (caution). The per-factor contributions are also reported (repair_health_factors / repair_risk_factors).

--human shows them as Health: X/100 Risk: Y/100 Status: <label>. Scoring is fail-silent: any missing/invalid metric is skipped (its weight redistributed), and scoring never breaks a repair. Multi-object 3MF reports carry these per object. The GUI defect panel shows them in the selected file's header line.

With multiple files, every input is repaired in turn and a summary is printed (N watertight, M with warnings, K failed), including a breakdown of the kinds of warnings/errors that occurred (volume change, Stage 2 skipped, partial repair, malformed input, extreme-mode object deletion). The exit code is non-zero if any file failed. In JSON mode each file's report also carries a category (watertight/warning/error) and an issues list, and the batch summary gains summary.issue_counts. -o is only valid with a single file. In JSON mode each file's report also includes a defects list describing the input's holes (centroid, diameter) and non-manifold regions; in --human mode this list is shown only when --defects is passed, so the default report stays concise. Diameter values assume millimetres, the common STL/3MF convention; if your file uses a different unit, scale the interpretation accordingly (Sutura also flags likely non-millimetre models with a unit warning — see the Unit warning section above).

Every report also records the before/after geometry in stage1: volume_change_percent (signed), surface_area_before/after and surface_area_change_percent, and vertices_before/after, faces_before/after. These are always in the JSON and shown per object for multi-object 3MF files; --human --diff prints them too, and the GUI defect panel shows a one-line summary ("Volume: +0.12% · Surface: -2.37% · Vertex: 12→9").

A mesh is only counted as watertight when stage 2 ran and validated the closed solid. If stage 1 closes a mesh but stage 2 is skipped, errors, or never runs (for example the macOS/conda in-process fallback being unavailable), the file is reported as a warning, not watertight.

Multi-object 3MF files are handled natively: every object mesh is repaired independently and written back into the archive, so no object is lost. An object that stage 1 closes (two-manifold, no holes remaining) is then passed through the same stage 2 helper as single-mesh files, so it gets a manifold3d watertight rebuild and a per-object stage2 report. When auto escalation runs, it operates per object: objects that reload strict-watertight after the baseline repair are left untouched, while failing objects escalate through ranked recommendations independently; each object in object_reports receives its own method_used report. The report lists the result per object (holes remaining, two-manifold, stage-2 verdict) and carries the aggregates objects_watertight / objects_stage2_ok; the file's category is derived from all objects, so a single still-open object prevents the whole file being reported watertight. Defects are also computed per object (object_reports[i].defects); there is no top-level aggregate defects field for a 3MF. Objects with byte-identical geometry reuse one repair, but each still gets its own per-object report (the stage-2 outcome is a pure function of the geometry, so the cached object's report is valid for the duplicates too).

GUI:

~/.local/share/sutura/gui.py

The GUI supports batch repair: add any number of files, press Repair, and each one is processed in turn with its result listed per file. When the batch finishes, a summary strip appears above the log (X watertight, Y with warnings, Z failed) with a clickable show issues link that lists the warning/error types and how many files each affected. Selecting a file shows its input defects (holes with their diameter and non-manifold regions) in a panel below the log, separate from the batch summary strip. Files can be added with Add files… (native multi-select, rubber-band included), Add folder… (every .stl/.3mf in the folder, one level deep), or by dragging files or folders onto the window. Stop terminates the running repair and marks the remaining files as cancelled. Drag & drop works on native Wayland sessions (the GUI is a Qt application, not XWayland). The GUI ships its own dark Fusion theme (teal accent), so it looks the same on every platform and Qt version regardless of the system desktop theme. The batch-wide repair options — the fTetWild fallback tier and the opt-in experimental passes — sit in a separate Options window (button next to Mode, or Ctrl+, / Cmd+,) that can stay open while repairing; the button label shows how many options differ from their defaults, e.g. Options (2). Its tabs: General (anonymous usage history), Repair (fTetWild fallback plus the Triage intensity preset/profile selector and the profile editor), Experimental (opt-in passes), Updates (manual check, automatic updates, optional check on every start), Changelog (release notes from GitHub, with an offline fallback) and Engines (fTetWild install/remove and the configured external-engine list).

Pre-repair analysis

Pre-repair analysis

Before repairing, Analyze runs the same read-only checks as the CLI's validate and --dry-run on every file, without writing anything: it reports the detected type and repair mode, the tuning status, the input's holes / self-intersections / non-manifold / debris counts, a watertight pre-verdict, and an estimated confidence (Estimated confidence: X/100 (Label) — actual result may differ after repair, because the post-repair signals are not known yet). Below that it lists a few mode suggestions (e.g. an aggressive/extreme step for scan-derived holes, a gentle "the current mode may be enough, but a step up can be tried if unsatisfied" tip whenever the input has at least one hole, a low-confidence step-up tip only while the current mode is still gentle, or a caveat when extreme could delete small parts). The suggestions are informational only and never change the mode automatically — the decision stays with the user. A non-blocking unit warning (model may be authored in inches/cm) is also shown when the heuristic fires.

Defect detail panel

Defect detail panel

When a file is selected, the panel below the log lists the defects found in its input mesh: each hole's centroid and diameter (in mm) and each non-manifold region. This complements the batch summary strip above the log — the strip is a per-batch count, this panel is per-file detail. After a repair, the panel header also shows the result's confidence as a Confidence: X/100 — High/Medium/Low segment. If the model may not be in millimetres, a non-blocking unit warning appears in the log and at the top of this panel (see the Unit warning section above).

Defect heatmap. Below the defect list, Show heatmap renders the selected mesh with its defect regions (hole rims and non-manifold areas) highlighted red against the grey mesh, shown as a small thumbnail. Clicking the thumbnail opens a larger zoom dialog. Rendering is on-demand (never automatic, so a large batch doesn't stall) and cached per file. For a multi-object 3MF it renders the first object, matching the defect panel's existing first-object behaviour. The renderer is a CPU rasterizer (works headless, in the AppImage, and on macOS CI) that runs in a subprocess so the GUI stays responsive and crash-free; if a mesh can't be rendered it falls back silently to the text-only panel.

Before/after comparison. After a file is repaired, Show before/after renders the original and repaired meshes with the same camera framing and opens a dialog with the main image, a toggle button that flips between Original and Repaired, and — below the main image — a smaller detail close-up of the worst original defect region (the defect with the largest physical bounding-box diagonal). The camera is automatically directed toward that worst defect so it is never hidden behind the mesh; only a clean mesh (or a defect sitting at the mesh's bounding-box centre) falls back to the fixed isometric view. The repaired view uses a tri-state colour map: grey where the mesh was never broken, a vivid green (46,204,113) highlight where an original defect used to be and is now healthy, and orange (255,140,60) where a defect remains (the original view marks its defects in red (235,60,70)). Because repair changes the mesh topology (the before/after vertex indices don't correspond), the green classification is spatial: each repaired face is measured against the original defect centroids (their real extent from defects.detect), and only the largest defects (capped at 256) drive the highlight so a scan mesh with thousands of micro-cracks stays fast. The close-up uses the same zoomed camera for both sides, so the original vs repaired comparison is apples-to-apples. It is a static click-toggle, deliberately not an interactive 3D slider — same CPU-renderer constraint as the heatmap — and

Before/after comparison dialog runs in a subprocess on-demand, so it never slows a batch.

A Static / Interactive switch at the top of the dialog adds the interactive 3D view (built lazily the first time it is chosen, then cached for the dialog's lifetime): drag rotates the mesh, the mouse wheel zooms. While you drag, an interactive low-poly LOD is rasterized per frame on a background thread (latest frame wins, no pymeshlab in the GUI process); ~300 ms after you stop, a full-resolution frame is drawn. The interactive view starts at the same defect-facing camera as the static comparison. A second toggle switches the repaired view between the Repair status colour map and a Surface deviation map: each face is coloured by the distance of the repaired surface to the original surface (quantile-scaled ramp, navy → cyan → yellow → red) with the still-broken defects drawn on top in orange and the global max deviation (Hausdorff distance) shown in the dialog.

Repair mode. A Mode: Auto button next to the heatmap/before-after buttons opens a small dialog with a five-step slider — Low / Medium / Auto / Aggressive / Extreme — and a one-line description that updates live as the slider moves (the Extreme step warns it can delete objects smaller than 20 faces). The same dialog also carries the two repair budgets (max geometry change %, max repair risk 0–100; 0 = no limit). The mode is a batch-wide setting: it applies to the next Repair run for every file, not per file, and is passed to the CLI as --mode <mode> (the same five values as the CLI flag, default auto). The current mode is always shown on the button.

When a repair exceeds a budget, the CLI declines the save (no output file) and reports "status": "budget_declined"; after the batch the GUI shows a confirmation dialog listing the affected files and their actual numbers, and Save anyway? re-runs exactly those files with --force (the declined rows are then overwritten by the forced results).

Dolphin: right-click an STL/OBJ/3MF file -> Repair with Sutura. With a single selection the GUI opens with the file loaded; with multiple selections each file is repaired headlessly and a summary dialog is shown.

After installing or removing the service menu, run kbuildsycoca6 (the installer does this automatically) or restart Dolphin.

OrcaSlicer plugin (experimental)

There is also an experimental OrcaSlicer script plugin under orcaslicer-plugin/ for the new OrcaSlicer script-plugin API. It targets OrcaSlicer nightly builds / releases newer than 2.4.2 — the stable 2.4.x release has no "Plugins" menu, so the plugin will not work there.

Running the plugin opens a dockable "Sutura" panel beside the 3D view (and automatically at startup unless disabled). The panel lists every object on the plate and offers a Quick / Balanced / Thorough / Extreme preset picker (Balanced default), per-object Analyze (read-only: holes, non-manifold regions, self-intersections and the top ranked repair methods) and Repair, a "Repair selected (n)" footer button and a "Select broken" shortcut, live job phases (queued → exporting → repairing → loading → done / failed / cancelled) with a Cancel button, and a Show file link for a finished job. Repair and Analyze always run through the installed Sutura CLI as a subprocess.

Objects that OrcaSlicer repaired on import (mesh errors) or whose parts are non-manifold raise one warning notification per object per session with a "Repair with Sutura" action. The export writes each model-part volume of an object in world coordinates (mirrored parts keep outward-facing winding), skips parameter modifiers, negative volumes and support blockers, and merges a multi-part object into a single STL. The repaired copy is loaded back as a new object; because the export is in world coordinates OrcaSlicer may place it on top of the original — press A (Arrange) to separate it, or Ctrl/Cmd+Z to remove it. Output files are kept for seven days so OrcaSlicer can read them while the asynchronous load-back completes. numpy is a declared plugin dependency (installed by OrcaSlicer's bundled uv), with a numpy-free fallback path.

Install (nightly / OrcaSlicer newer than 2.4.2):

  1. Install Sutura first (./install.sh on Linux, install-macos.sh on macOS) so ~/.local/bin/sutura exists.

  2. Copy the plugin into OrcaSlicer's plugin directory. Linux:

    mkdir -p ~/.config/OrcaSlicer/orca_plugins/SuturaRepair
    cp sutura_repair_linux_x86_64.py ~/.config/OrcaSlicer/orca_plugins/SuturaRepair/

    macOS: use ~/Library/Application Support/OrcaSlicer/orca_plugins/ as the parent. The file name is a naming artifact — the same single file runs on both Linux and macOS.

  3. Enable the plugin in the OrcaSlicer Plugins dialog, then run it to open the panel.

Permissions: register_capabilities() pre-declares the existing Sutura CLI path with request_permissions(fs_read=...), but the audit API has no declarative form for subprocess spawns, so a spawn permission prompt can still appear once per command target. It has been verified end-to-end in a real OrcaSlicer 2.5.0-dev (macOS); primary target is Linux, and the same file runs on macOS as a verified bonus. See the plugin README for full detail and its limitations.

Mesh type-aware repair

Sutura heuristically guesses whether an input mesh is mechanical (cube, gear, CAD part) or organic (sculpt, scanned model) from pure geometry — adjacent-face dihedral angles, computed in numpy. It is not an ML model and is deliberately conservative: it only acts on high-confidence cases and reports unknown otherwise, in which case the historical default Stage 1 parameters are used unchanged.

The confidence is a signed-margin score: three dihedral bands are computed from adjacent-face angles — near90 ([60,120]°, sharp edges), flat (<1°, true planar surfaces) and gentle ([1,15)°, slight curvature) — and each is mapped through a smooth sigmoid. The bands are combined as mechanical = max(near90 signal, flat signal) (either is enough) and organic = min(low near90, low flat, high gentle) (all three must hold), so the decision is a soft margin rather than a single hard threshold — there is no sharp jump at the [55,60] near90 boundary. The flat/gentle split is what keeps smooth high-poly organic meshes from reading mechanical: their dihedrals are ~2–5°, i.e. gentle, not flat. An unknown result still carries a non-zero proximity value (which class the mesh leans toward, and how close) instead of a flat 0, so even the fallback is informative.

The detected type is shown in the GUI defect-panel header (e.g. Detected: mechanical (0.92)) and in the --human report as a Type: line; the JSON report carries detected_type and detected_confidence. A calibration harness (scripts/calibrate_classifier.py) measures precision/recall and confidence separation against a labeled synthetic set (tests/make_classifier_set.py), so the thresholds stay checkable and reversible.

When classified, the type tunes two Stage 1 thresholds:

Type mincomponentsize (debris cutoff) maxholesize (hole fill) Effect
mechanical 8 300 preserve small sharp details, avoid oversized hole patches
organic 12 1000 drop scan debris more aggressively, close large open regions
unknown 8 1000 historical defaults (unchanged)

These per-type values are experimental starting points, not calibrated on real repair data — a conservative, reversible choice. Only the two thresholds above shift; they can be tuned in repair.py as more samples are collected.

A classified mesh only gets these tuned thresholds when its confidence clears a per-class gate (mechanical ≥ 0.75, organic ≥ 0.70). Below the gate the type is still reported (detected_type) but the conservative default thresholds (mincomponentsize=8, maxholesize=1000) are used instead — the report and the GUI defect panel show this as tuning_applied: false / "default thresholds". (The organic gate used to sit lower because a classifier bug appeared to cap organic confidence at ~0.62; with that bug fixed the confidence is unbounded, so both gates sit just above the worst correct prediction on the calibration set.)

Known limitation of the classifier

Curved-but-mechanical parts (e.g. a cylinder, shaft, or filleted geometry) are not classified by the classic engine — they fall into the unknown bucket and keep the default parameters. This is a deliberate trade-off of classic: it only fires on clearly flat/sharp mechanical or clearly smooth organic meshes, and prefers to do nothing over applying a wrong parameter set. The default (experimental) engine now handles these shapes: its curvature developability signal (the area fraction of the surface whose normals lie on a common great circle, the geometric signature of a cylinder/fillet/pipe) plus the trained head classifies cylinders, tubes and fillets as mechanical, including damaged variants. A genuinely doubly-curved mechanical part (a bent pipe) is still missed — it is geometrically closer to a torus than to a cylinder.

Classifier engines (--classifier-engine)

The experimental engine (sutura/mesh_classifier_v2.py) is the default: on the labeled synthetic set and the 40-mesh real-world corpus it is measurably better than the classic heuristic: on the 36 labeled real samples it scores 29/36 vs classic's 16/36 (mechanical recall 16/20 vs 2/20 — the classic scanner bias is stark on the expanded corpus; organic 13/16 vs 14/16), and on the 71-mesh labeled set (35 synthetic + 36 real) LOO-CV is 0.845 vs classic 0.648. The original classic engine (mesh_classifier) remains available — select it with --classifier-engine classic or the SUTURA_CLASSIFIER_ENGINE=classic env var (valid for repair, validate and --dry-run alike). Both engines report the engine actually used as classifier_engine (JSON) and a Classifier: line (--human).

The experimental engine adds three things to the classic features:

  1. RANSAC plane segmentation — robust planar patches covering ≥1% of the mesh area are detected with a RANSAC loop (face centroid + normal as the sample unit); the patch count and area fraction join the features.
  2. Curvature developability signal (developable_fraction) — the area-weighted fraction of the surface whose normals lie on a common great circle, the geometric signature of a developable (zero-Gaussian-curvature) surface: cylinders, tubes, fillets and other ruled curved mechanical parts. Doubly-curved organic forms (spheres, blobs, torus) only ever have a thin band perpendicular to any single axis, so they score low. This is what a plane-only RANSAC cannot see — a cylinder has no planar patches.
  3. A small trained logistic-regression head (pure numpy) over [near90, flat, gentle, plane_count, plane_area, developable_fraction], trained on the 35-mesh synthetic set plus the 36 labeled meshes of the 40-mesh real-world corpus (tests/real-world-samples/, the mechanical-but-organic scans labeled correctly). The weights are baked in; training + leave-one-out CV live in scripts/train_classifier_v2.py.

Caveats: the trained head is a weak signal on ~71 labeled meshes (LOO-CV 0.845 — expect variance), and a confident-but-wrong experimental prediction is not re-checked against classic (only the near-boundary case is: when the head is a coin-flip — |p_mech − 0.5|·2 < 0.15 — and classic strongly agrees with the barely-chosen class, the head keeps its class but reports classic's confidence so the tuning gate sees the strong signal; see the Feature Status row). If the experimental engine crashes or returns something invalid it silently falls back to classic with a warning on stderr, so a broken v2 degrades to classic instead of crashing a repair.

The v2 engine lives in sutura/mesh_classifier_v2.py as a full copy of the classic engine plus the additions, so the classic module stays untouched. scripts/calibrate_classifier.py (default engine = experimental) and scripts/compare_classifier_engines.py compare both on the same sets; tests/test_mesh_classifier_v2.py guards the invariants (stdlib+numpy only, classic fallback when the head is disabled, synthetic set at 100%).

Classifier methodology

The experimental engine's features are:

Feature Source What it captures
near90 dihedral statistics (numpy) sharp [60,120]° edges — mechanical
flat dihedral statistics true planar surfaces (<1°)
gentle dihedral statistics slight curvature [1,15)° — organic hint
plane_count RANSAC plane segmentation number of robust planar patches
plane_area RANSAC plane segmentation area fraction of those patches
developable_fraction curvature Gauss-map signal normals on a common great circle (cylinder/pipe/fillet)

Candidate evaluated — eigenvalue shape descriptors (NOT integrated). We tested the standard point-set eigenvalue descriptors of Weinmann, Jutzi & Mallet (2015) — "Feature relevance assessment for the semantic interpretation of 3D point cloud data", ISPRS Annals of the Photogrammetry, Remote Sensing and Spatial Information Sciences II-3/W5 (academic reference; the formulas are public-domain-standard, no code was taken from any implementation). These are computed from the eigenvalues λ1 ≥ λ2 ≥ λ3 of the 3×3 covariance matrix of the vertex positions (not normals): Linearity = (λ1−λ2)/λ1, Planarity = (λ2−λ3)/λ1, Sphericity = λ3/λ1, Omnivariance = (λ1·λ2·λ3)^(1/3), Anisotropy = (λ1−λ3)/λ1, Eigentropy = −Σ(λi/s·ln(λi/s)), Surface Variation = λ3/(λ1+λ2+λ3). The hypothesis was that long/thin mechanical parts have high Linearity and round organic forms high Sphericity — a signal orthogonal to the normal-based features. The descriptors were implemented from the formulas in numpy and verified on canonical shapes (sphere → Sphericity≈1; 100×1×1 box → Linearity≈1; flat plate → Planarity≈1).

Result — the descriptors do NOT help on the labeled set, so they were NOT integrated. Leave-one-out CV on the 71 labeled meshes (35 synthetic + 36 real), floor 0.50:

Feature set LOO-CV accuracy mechanical organic
base 6 (current) 0.845 (60/71) 32/39 28/32
+ linearity, sphericity 0.831 (59/71) 33/39 26/32
+ all 7 descriptors 0.817 (58/71) 32/39 26/32

The conclusion is robust across decision floors 0.50–0.65. Mechanical recall gains at most 1 while organic recall loses 2 — the global eigenvalue shape is largely redundant with the existing features on this corpus (the head already sees the curvature/planarity structure), so adding the descriptors creates new errors without a net gain. Per the "only integrate when it genuinely helps" rule, they stay out; the classifier code is unchanged by this evaluation.

Local roughness / geometrical / statistical features as a tie-breaker (NOT integrated)

Follow-up evaluation (FAZ 8): the source repositories were re-read in full and the remaining feature categories were reimplemented from scratch from their academic references (GPL-3.0 code is never copied — only the public formulas): roughness (Gaussian-curvature roughness, Wang et al. 2012; Difference of Normals, Ioannou et al. 2012; local density, Rabbani et al. 2006 — local intensity entropy is N/A because STL has no vertex colour), geometrical (local density, farthest distance, maximum height, height std dev, Blomley / Jutzi / Weinmann 2016) and statistical shape distribution (point-to-centroid distance, pairwise point distance, sqrt triangle area — D1/D2/D3). The timzhang642/3D-Machine-Learning repo was checked again: it is a paper/course link list, no code, so it adds no reference implementation.

Methodology — tie-breaker, not feature fusion. Unlike FAZ 6 (which blended new features into the head's vector and LOO-CV'd the whole corpus), FAZ 8 identified the boundary/disagreement subset first — meshes where the head's confidence is low (< 0.5) or the classic and experimental engines predict different real classes — and measured the new features only as a secondary signal on that subset (leave-one-out logistic over the new local-feature aggregates). This is the intended "extra hint when the engines are unsure" role, not a change to the default decision.

Boundary subset n Baseline (head) Tie-breaker (new features, LOO)
low confidence OR any disagreement 27 0.778 (21/27) 0.741 (20/27)
low confidence OR real-class disagreement (refined) 14 0.643 (9/14) 0.429 (6/14)

Per-feature correlation with the truth label on the refined subset tops out at |r| ≈ 0.36 (gc_std); almost every feature sits at |r| ≤ 0.2. The tie-breaker does not improve the boundary-subset accuracy (0.778 → 0.741 and 0.643 → 0.429) — with ~14–27 samples and 18 features a LOO logistic overfits, and no single local feature carries enough separation signal to disambiguate the genuinely-ambiguous cases. Decision: NOT integrated — no --experimental-tiebreaker-features flag and no GUI toggle. The default classifier is unchanged; this is a documented negative result with the same honesty bar as FAZ 6.

MeshCNN edge features as fusion and tie-breaker (NOT integrated)

Follow-up evaluation (FAZ 9): the MeshCNN 5-D edge-invariant feature (Hanoeka et al. 2019, MIT-licensed; reimplemented here cleanly from the formula, no code copied) was implemented from scratch in pure numpy. It is conceptually different from the rejected Weinmann covariance descriptors: not a point-cloud neighbourhood covariance, but a per-edge feature defined by mesh connectivity. For every interior edge (shared by exactly two triangles): dihedral = the angle between the two adjacent face planes (π − arccos(n₁·n₂)), symmetric_opposite_angles (2 values) = the apex angle at the vertex opposite the edge in each triangle (sorted), and symmetric_ratios (2 values) = the apex-height-to-edge-length ratio in each triangle (sorted). The 5 per-edge values were aggregated to 10 global statistics (mean + std per dimension). Verified on canonical shapes: a cube (a mix of π/2 folds and coplanar diagonals) gives dihedral mean ≈ 2.09, a smooth sphere ≈ 2.97 (near π).

Test A — feature fusion (FAZ 6 style). LOO-CV on the 71 labeled meshes:

Feature vector LOO-CV accuracy mechanical organic
base 6 (current) 0.845 (60/71) 32/39 28/32
base 6 + 10 edge features 0.831 (59/71) 33/39 26/32

Fusion worsens accuracy. Test B — tie-breaker (FAZ 8 style), same boundary/disagreement subset definition:

Boundary subset n Baseline (head) Tie-breaker (edge features)
low confidence OR any disagreement 27 0.778 (21/27) 0.741 (20/27, LOO logistic)
low confidence OR real-class disagreement (refined) 14 0.643 (9/14) 0.857 (12/14, LOO) — but 3-fold×20 repeats: all-10 = 0.733, best-3 = 0.902

Per-feature correlations with the truth on the union subset reach |r| ≈ 0.44 (opp_angle_min_mean); the refined-subset numbers look promising but n = 14 is far too small to be clear evidence, the "best-3 features" estimate carries selection bias, and the larger (n = 27) union subset shows no improvement. Test A is a clean negative and Test B is not a robust win, so the same bar as FAZ 6/FAZ 8 applies: NOT integrated — no --experimental-edge-features flag and no GUI toggle. The default classifier is unchanged; the exact numbers above are the honest record.

Retrospective single-feature scan (FAZ 10)

Per the maintainer's standing rule (always report per-feature correlations on rejected experiments), the three rejected families (Weinmann FAZ 6, local roughness/geometrical/statistical FAZ 8, MeshCNN edge FAZ 9) were re-scanned on the full 71-mesh labeled set (not just the boundary subsets). The top signals are real — above the n=71 noise bar of |r| ≈ 0.4, and sign-stable across 30 bootstrap subsamples (std ≈ 0.04):

Feature r (full 71-set) family
oppmax_std (MeshCNN ratio-max std) +0.718 FAZ 9
don_mean (Difference of Normals) +0.673 FAZ 8
dihed_mean (MeshCNN dihedral mean) −0.633 FAZ 9
rmin_mean (MeshCNN ratio-min mean) +0.563 FAZ 9
gc_mean (Gaussian-curvature roughness) +0.480 FAZ 8

But as single features they add almost nothing to the head: standalone LOO-CV gain is ≤ +0.007 mean accuracy (5-fold×20; deterministic LOO 0.845 → 0.859 = one mesh) and a final-model fit flips zero verdicts. These strong correlates are largely redundant with the existing dihedral-based features (near90/flat/gentle + developable_fraction). None cleared the very-high standalone-integration bar — flagged as "interesting sub-pieces, revisit only via a proper feature-selection framework or with new data", not integrated.

Opt-in edge-tiebreak head (--experimental-edge-tiebreak, NOT the default)

Per the maintainer's "pick the interesting sub-pieces with tweezers" decision, the five strongest FAZ10 scan signals (oppmax_std, don_mean, dihed_mean, rmin_mean, gc_mean) are NOT added to the default vector (LOO-CV showed they flip only ~1 mesh and overlap the dihedral features — no default-path risk taken). Instead they are available as an opt-in 11-feature head through --experimental-edge-tiebreak (CLI) or the Edge-tiebreak classifier checkbox in the GUI's Options window (batch-wide). The 11-feature head was retrained on the same 71 labeled meshes (LOO-CV 0.845, 5-fold×20 mean 0.852 vs base 0.850). This is experimental and not the default; the gain is small but the underlying signals are statistically real.

Test

Synthetic broken mesh:

python3 tests/make_broken_stl.py /tmp/broken.stl
sutura /tmp/broken.stl --human

The generator produces a cube with a missing face, an inverted winding, a duplicated face, a fin triangle and a self-intersecting triangle.

Regression suites:

# These spawn the in-repo sutura/repair.py, so run them with the CLI's Python:
~/.local/share/sutura/venv/bin/python tests/make_layered_multiobject_3mf.py --check   # layered multi-object 3MF
~/.local/share/sutura/venv/bin/python tests/test_adversarial.py                       # malformed-input handling

Real-world samples in tests/real-world-samples/ come from the Thingi10K dataset (Zhou & Jacobson) and the Artec 3D STL catalog: 40 meshes total — mechanical, organic, and a few genuinely broken models. They retain their original licenses (per-model CC BY / CC0 for the Phase 2 additions; see docs/ATTRIBUTION.md for the full per-file attribution) and the repair result expected from each is in tests/real-world-samples/README.md.

Benchmark corpus (115-mesh)

The large repair benchmark corpus (52 Artec STL scans + 63 Thingi10K STLs, ~6 GB uncompressed — the corpus behind docs/repair-benchmark-strict-watertight-2026-09.md) is not in the git repo (it would multiply the repo size ~100×). It is published as a split .tar.gz on the GitHub release benchmark-corpus-v1. Download and verify it with:

scripts/fetch_benchmark_corpus.sh            # -> /tmp/sutura_corpus_100
scripts/fetch_benchmark_corpus.sh /some/dir  # custom destination

The script downloads the release parts, verifies their SHA-256, recombines and extracts them, so a fresh machine (e.g. a future Linux box) can reproduce the benchmark without re-scraping Thingi10K. See the script header and docs/ATTRIBUTION.md for provenance and how to re-package/re-upload the corpus when new meshes are added.

The benchmark harness (scripts/benchmark_repair_corpus.py) also carries an optional manifold3d cross-validation column (FAZ 10): each repaired mesh's final geometry is independently checked with manifold3d (a Manifold constructs with Error.NoError and is non-empty ⇒ watertight) next to the pymeshlab-based defects.detect() strict check, and the two verdicts are compared. It never changes the strict result or the pipeline — when manifold3d is unavailable the column is reported as n/a. manifold3d is an optional/heavy dependency (prebuilt wheels only up to Python 3.13; already declared in requirements-311.txt and the macOS conda env — see the comment in requirements.txt). --experimental-indirect-autorefine runs the exact Rust arrangement tier for every mesh, and the developer hook --cdt-experimental BITS switches on its evaluated-but-not-default CDT variants (see the crate README, "Sloan flips and edge-point propagation").

Torture tests cover hard-but-printable geometry:

~/.local/share/sutura/venv/bin/python tests/torture_tests.py

Rust core (rust/sutura-geom, needs a Rust toolchain; results in the crate README):

cd rust/sutura-geom
cargo test --release                      # unit + filter-vs-exact differential tests
cargo run --release --example bench_arrangement --features profile -- mesh.obj

The Python smoke tests for the extension (tests/test_sutura_geom*.py) need it built into the active environment with maturin develop. For automated installation without manual compilation, scripts/install_sutura_geom.sh downloads and verifies matching prebuilt release wheels.

Developer tooling — synthetic defect injection

scripts/defect_injector.py is a developer tool that corrupts a mesh on purpose: it injects selected defect types (--hole, --non-manifold, --self-intersect, --flipped-normal, --degenerate, any combination; --count N, --seed N) and writes a broken copy to a new file — the input is never modified. It is used to generate synthetic broken meshes for the repair pipeline and the defect detectors, and can extend future synthetic training/test sets. Each injected type is validated by tests/test_defect_injector.py against the corresponding detector (defects.detect() for holes/non-manifold, defect_type_colors() for flipped/degenerate, pymeshlab self-intersection for self-intersect).

This runs five scenarios and reports the before/after for each: a 5M-triangle sphere (repair time), a 0.05 mm thin slab (feature-loss risk — it must survive intact), a multi-part assembly (the 8-face debris-removal threshold must not delete legitimate parts), a rough scan-style mesh with many micro-cracks (residual-holes expectation), and two interpenetrating spheres run in --mode extreme, where the extra self-intersection passes must remove every crossing face and report extreme_passes_applied=True.

Robustness

Malformed or hostile inputs are rejected with a clear error and a non-zero exit code, never a crash or a silently wrong result:

Input Behaviour
Truncated / cut-off binary STL rejected: "Unable to open file ... Malformed file"
Header claims more triangles than the file holds rejected: "Malformed file"
NaN/Infinity vertex coordinates rejected: "input mesh contains NaN or infinite coordinates"
Empty mesh (0 triangles) rejected: "input mesh is empty (no triangles)"
Fully degenerate mesh (only zero-area faces) rejected: "all faces are degenerate; nothing to repair"
Wrong extension (OBJ content in .stl, or the reverse) rejected: "Unable to open file"

Any of these returns exit code 1, so scripts can reliably detect failure.

Libraries

Library Role Why
PyMeshLab stage 1 filter chain VCG-based, proven for print repair, fills holes of any size, Python 3.14 wheel available
manifold3d stage 2 solid rebuild watertight guarantee, robust boolean, same engine as Bambu Studio
numpy pipeline-wide array math for the CLI, GUI, defect detection, history and every pure-python tier; also the fallback that makes the Flap and Dressing Rust paths optional
trimesh stage 2 IO + method registry OBJ/mesh loading in the manifold venv, closest-point projection / mesh IO in the closing, proxy and repeated-element tiers, and IO in the Graft/Dressing tiers (~4.6 MB installed)
scipy method registry + Flap/Dressing sparse linear algebra and KD-trees (scipy.spatial) for the closing, proxy, repeated-element, Flap and Dressing tiers (~99 MB installed; connected-component labelling previously using scipy.sparse.csgraph has been replaced by stdlib/numpy topology.connected_components; Phase 1 dependency reduction saves ~2.1 MB installed by dropping pyrobust-predicates and psutil, preparing the path for Phase 3 SciPy removal)
pytetwild + pyvista fTetWild fallback tier (optional extra) fTetWild tetrahedralization (MPL-2.0); pyvista brings VTK, ~1.1 GB installed, so it is opt-in via SUTURA_WITH_FTETWILD=1
sutura-geom (Rust: robust, num-bigint, num-rational, num-traits, rayon, faer, pyo3, numpy) exact arrangement, morphology, Flap, Dressing, Tamponade and topology-cleanup acceleration exact explicit/indirect predicates (orient3d, insphere), interval filter, exact triangle intersection and CDT for --experimental-autorefine and --experimental-indirect-autorefine, SDF grid and dual contouring for Graft (#13), the flap_fill surface-hole fill, the dressing_coat narrow-band extractor, the tamponade interior-plug module and the native mesh-cleaning filters (remove_duplicate_faces, remove_null_faces, duplicate/unreferenced-vertex removal, vertex welding) that back the Stage-1 cleanup with a transparent pure-numpy fallback; faer (MIT) provides the sparse LU for the Flap fairing. Permissive licenses only (MIT/Apache-2.0/BSD), prebuilt wheels installed automatically via scripts/install_sutura_geom.sh or built with maturin develop

Pinned in requirements.txt, requirements-311.txt and (optional) requirements-ftetwild.txt; Rust crates in rust/sutura-geom/Cargo.lock.

Known limitations

  • macOS right-click needs a Quick Action, not a Dolphin-style menu. macOS has no ServiceMenu equivalent; instead the installer adds a Finder Quick Action ("Sutura — Repair") in ~/Library/Services/, available under right-click → Quick Actions for selected STL/3MF files. It calls the bundled sutura-cli inside a PyInstaller Sutura.app, so it only repairs when such an app is present (in /Applications or ~/Applications) — the dev-install wrapper app alone has no bundled CLI. A downloaded (quarantined) Sutura.app must be opened once via right-click → Open before the Quick Action can run it (Gatekeeper; the notification tells you to do this).
  • No macOS uninstall script. There is no macOS equivalent of the Linux uninstall.sh; removing a macOS install is manual (see "Removing a macOS install" under the macOS install section).
  • Native KDE file dialog. The GUI sets QT_QPA_PLATFORMTHEME=kde and points QT_PLUGIN_PATH at /usr/lib/qt6/plugins so QFileDialog uses the native KDE dialog (rubber-band rectangle selection included). This works only when the system Qt version matches the bundled PySide6 Qt — the GUI checks the versions (via qmake) and only mixes in the system plugins on a match. When they differ (e.g. system Qt 6.11.2 vs bundled 6.11.1), the system platform plugins cannot load into the bundled Qt, so the GUI keeps Qt on its own bundled plugins and falls back to the embedded dialog — rectangle selection may be unavailable, but Ctrl/Shift+click always works.
  • Self-intersections within one connected shell. manifold3d rebuilds the mesh as a solid, which resolves interior/overlapping geometry, but the rebuild can slightly reshape features in pathological cases. Always check the result in a slicer.
  • Large hole patches. VCG fills holes with flat triangulated patches; for very large holes the fill is a simple patch, not a smart reconstruction. It closes the mesh, but the patch quality is average and may need smoothing.
  • Tiny disconnected debris. Components with fewer than 8 faces are removed. A small legitimate part that is not connected to the main body will be removed too.
  • Inverted whole models. If the repaired volume comes out negative, the whole mesh is flipped; a model that was consistently wound "inside out" will be corrected automatically.
  • manifold3d Python binding. It rejects any input with open edges; if stage 1 cannot close a hole, stage 2 is skipped and the stage 1 result is used as-is (the report says so).
  • Layered/duplicated-vertex 3MF exports. Some slicers (Bambu Studio included) write 3MFs whose objects repeat every vertex position ~15x as separate vertex entries, and whose surfaces are folded (several faces coincident on one edge). VCG can turn such meshes into valid 2-manifolds: the Stage 1 chain dedups faces again after the vertex dedup (vertex dedup is what creates the duplicate faces on a layered mesh), then repairs non-manifold vertices before hole closing and finishes with a close pass, so the test exports now repair fully closed — 0 holes remaining (reported as a real boundary-loop count, not half the boundary edges). Closed layered objects also get a per-object stage 2 watertight rebuild, so they are reported watertight rather than stage2_skipped. Example from development: a 2-object Bambu export that previously reported 13 and 26 micro-holes (or was destroyed to 0 faces on some platforms before the duplicate-faces fix) now reports 0 and 0, both stage-2 confirmed.
  • All objects are preserved. Multi-object 3MFs are repaired object by object and written back, so no object is lost. The per-object result (including each object's stage-2 verdict) is reported in the CLI output and the GUI.

Architecture

Sutura is organized as a modular engine (sutura_engine) designed for reliability, strict layer boundaries, and extensibility:

  • sutura_engine.core — Generic geometry and validation: mesh array I/O (STL, OBJ, 3MF), topological measurements, and Hausdorff distance guards; re-exports the specialised passes.
  • sutura_engine.diagnosis — Surface and defect diagnostics: component analysis, extensible geometric templates (mechanical, organic, reliefs, thin sheets, lattices), and mesh classifier integration.
  • sutura_engine.chart — Content-addressed SHA-256 caching under ~/.cache/sutura/ with 500 MB LRU eviction, automatic invalidation across version updates, and CLI (--no-cache, sutura clear-cache) / GUI options.
  • sutura_engine.hull — Outer-surface shell extraction for multi-component assemblies.
  • sutura_engine.cast — Thin wrapper around the Rust sutura_geom extension (exact predicates, arrangement_lite).
  • sutura_engine.methods — Pluggable repair methods conforming to RepairMethodProtocol: built-in methods (#1 Quick Clean through #17 Tamponade) discovered automatically from one named module per method, plus closing, proxy, morphology graft, and repeated-element repair tiers.
  • sutura_engine.triage — Intelligent escalation policy: ranks repair methods based on geometric analysis, enforces user intensity presets (Quick, Balanced, Thorough, Extreme), and respects strict runtime budgets.
  • sutura_engine.adapters — Strict isolation boundaries for external and third-party tools (PyMeshLab, Manifold3D, fTetWild, and external CLI engines). Adapters are imported lazily only when a specific method executes.

In an installed or AppImage layout sutura_engine is a top-level package; in a source checkout a small root bootstrap package (sutura_engine/__init__.py) makes import sutura_engine work from the repository root without a symlink.

What is specific to Sutura

  • X-Ray reload-honest verdict (P-HONEST). Rather than relying on in-memory topological counts, Sutura confirms watertightness only when the serialized float32 mesh reloads strictly closed (defects.detect() reports 0 holes and 0 non-manifold edges). When float quantization introduces boundary gaps on reload, the Stitch pass (P-WELD) fuses coincident seams without altering face topology; this closed-loop check caught 7 false-watertight results across the v0.5.1 corpus.
  • Graft verbatim hybrid (Method #13). Combines mathematical morphology envelopes (Nooruddin & Turk 2003; Suriyababu et al. 2023) computed via exact Euclidean distance transforms (Felzenszwalb & Huttenlocher 2012) and dual contouring (Ju et al. 2002) with verbatim preservation of undamaged original triangles. The reconstructed patch covers only defective regions, constrained by a one-sided healthy-fidelity guard (fidelity_ok) that falls back to fTetWild comparison when deviation occurs, achieving 0.0 healthy deviation on all adopted corpus meshes (see docs/repair-benchmark-strict-watertight-2026-09.md). Note: Graft (#13) requires the sutura_geom Rust extension, which ships with the installers and AppImage/.dmg bundles; without it Graft is listed as unavailable and auto repair falls back to fTetWild as before.
  • Transplant repetition healing (Methods #11–#12). Detects recurring geometric features across rotational, translation lattice, helical, and reflective symmetries without requiring pre-segmentation. When localized damage is identified on one instance of a repeated element, a congruent healthy copy is aligned and transplanted to seal the defect, guarded by Hausdorff constraints outside the target region (hausdorff_outside).
  • Triage escalation policy (sutura_engine.triage). Couples per-object geometric feature diagnosis (component structure, open-boundary ratios, symmetry scores) with ranked method recommendations and a wall-clock fallback budget (min(max(30 s, 2 × baseline), 120 s)). When usage history is enabled, a bounded Bayesian update (Beta(2, 2) posterior mean) locally adjusts method selection probabilities without network communication.
  • Chart content-addressed cache (sutura_engine.chart). Caches intermediate and final repair artifacts by SHA-256 geometry hash under ~/.cache/sutura/ with a 500 MB LRU eviction policy. The cache transparently accelerates repetitive pipeline runs and multi-object 3MF processing, automatically invalidating entries across version bumps or parameter modifications.

Roadmap: moving hot paths to the Rust core

Future development aims to reduce Python runtime overhead by migrating performance-sensitive paths to rust/sutura-geom:

  • Phase 0 (Python Hot-Path Reductions): Removal of unconditional trimesh imports from the outer-shell check shipped in v0.7.0; vectorization of candidate transforms in rotational symmetry detection shipped in v0.7.2 (batched, threaded nearest-neighbour queries, identical results, ~2-3.7x faster on the sample/corpus detector).
  • Phase 1 (Rust Topology Layer): Implement deterministic array operations in native Rust (X-Ray reload welding, strict hole/non-manifold detection, and boundary loop defect walking), using native BVH structures for nearest-neighbor spatial queries.
  • Phase 1c (Rust Hole Triangulation): Liepa-style minimum-weight triangulation and loop closure in native Rust, providing a direct alternative to PyMeshLab hole closing on NumPy arrays.
  • Phase 2 (PyMeshLab Independence): Progressively port duplicate face/vertex removal, non-manifold repairs, and surface orientation passes to reduce external C++ runtime dependencies.
  • Phases 3 & 4 (Native Manifold & Standalone Rust CLI): Link Manifold3D directly into the Rust extension to eliminate the dual-virtualenv architecture, working toward an independent Rust CLI executable.

Usage history (anonymous, opt-out)

Sutura records a technical usage history of repairs so the community can steer the engine (mesh_classifier + stage-1/2 tuning) toward the cases it actually meets. Every shared history helps make the repair engine smarter over time — more real-world meshes means better classifier tuning and fewer edge cases missed.

  • What is recorded: mesh size (vertex/face counts), defect counts (holes, non-manifold, self-intersections), classifier result (type + confidence), repair mode, applied filter count, final category, repair-confidence score, repair timing, and a geometry-only fingerprint for deduplication.
  • What is NEVER recorded: file names, file paths, user names, IP addresses, machine identifiers, environment variables, or any personal data. The fingerprint is derived from mesh geometry only.
  • Default: on. Disable from the CLI with sutura --no-history ..., or uncheck the box in the first-run dialog (GUI). The preference lives in ~/.config/sutura/config.json (history_enabled).
  • Sharing: sutura export-history prints an anonymous summary plus the full JSON records in one command, ready to paste into a GitHub issue. Use --summary-only for just the summary, --last N to limit to the most recent records, and --clear to wipe the file. Nothing is ever uploaded automatically.
  • Where it lives: the data files (history.jsonl, triage_learning.json) live in ~/.local/share/sutura/data/, separate from the application code; set SUTURA_DATA_DIR to store them elsewhere (useful for the AppImage, whose bundled code mount is read-only). The uninstaller keeps this directory by default; uninstall.sh --purge removes it.

Contributing

Sutura is a one-person effort, but it doesn't have to stay that way. Found a bug? Open an issue. Something unclear or wrong? Say so. Have an idea that touches the repair engine? Send a PR. Small contributions — a typo fix, a clearer README line, a new test case — matter just as much as big features.

Missing a feature? Found a mesh that won't repair? Open an issue. A good bug report is worth far more than a bare "it doesn't work", so when you report a mesh please include:

  • the sutura <file> --human output (or the JSON report),
  • the command you ran,
  • and, if you know it, how the mesh was produced — slicer, scanner, CAD export, etc.

This makes the root cause much easier to pin down. Structured reports are encouraged: see .github/ISSUE_TEMPLATE/bug_report.md.

Sponsors

If Sutura has saved you time or a broken print, you can sponsor its development on GitHub.

(No sponsors yet — be the first!)

License

Starting with v0.2.0, Sutura is licensed under the PolyForm Noncommercial 1.0.0. Personal, non-commercial use (hobby, research, education, personal 3D printing, etc.) will always remain free — this is a permanent commitment. For commercial use, fill in the Commercial License Request form — answers are private.

Versions released between v0.1.0 and v0.1.9 remain permanently licensed under Apache 2.0.

The reason for this change: Sutura was not born as a business plan or a startup — it began as a personal need, and the cost of the AI tooling used to develop it is paid by the maintainer out of pocket. For personal, non-commercial use, Sutura will always remain free — this is not a marketing promise but a permanent commitment. Future development will require more time and resources, and allowing a company to use it commercially for free would not be fair to the maintainer; the project is not a charity. Commercial use therefore requires filling in the Commercial License Request form — answers are private. If the maintainer can no longer dedicate time to the project at some point in the future, the plan is to release Sutura fully open source and hand it to the community — but for now, it will continue like this.

Version history

Only versions that added user-facing features are listed (bug-fix-only versions are skipped). Full detail in CHANGELOG.md.

  • v0.9.0 — 2026-10-11 — Tamponade (method #17, on-by-default interior hole plug with an inflate-from-inside Stent candidate and multi-stent partition for large loops; strict plug acceptance rules — no leak, no new holes/non-manifold edges on reload, no anti-parallel double wall over 20 %, must stay connected and add no part); Flap per-loop candidate selection (on by default, tries alternative fills only when the primary patch is rejected, charged to the Flap budget; SUTURA_FLAP_SELECT=0 opts out); a memory/time guard on the Scaffold resample (#10); native self-intersection detection behind the off-by-default SUTURA_NATIVE_SI; and dependency reduction (Phase 1 drops psutil / pyrobust-predicates, Phase 2a/2b route Stage-1 cleaning to the Rust kernel).
  • v0.8.1 — 2026-10-03 — Repairs speed up via a native Rust topology kernel (vertex welding, duplicate-face and edge counting; bit-for-bit identical output, +4–29% end-to-end on the sample models) with a transparent pure-numpy fallback; an automatic post-union micro-sliver cleanup so Stage-2 solid rebuilds open as a single slicer part without touching genuine multi-part assemblies; and corrected non-manifold face indices in the defect report and the heatmap.
  • v0.8.0 — 2026-10-03 — Flap surface hole fill (covers a boundary loop by continuing the neighbouring surface instead of a flat lid; on by default, --no-flap / SUTURA_FLAP=0 disables); Dressing (method #16, opt-in variable-viscosity level-set skin) with a non-watertight suggestion and --dressing-force-adopt; the --si-mode self-intersection policy (default Report only); a Graft input face cap + wall-clock budget; and a guarded Stage-1 self-intersection excise + refined re-cap in Full Mend.
  • v0.7.2 — 2026-10-02 — OrcaSlicer plugin v2 (dock panel, repair notifications, correct transforms, load-back); 2-3.7x faster symmetry detection.
  • v0.7.1 — 2026-10-01 — Graft now works in installed builds: Rust extension shipped via prebuilt wheels + bundled in AppImage/.dmg; macOS .dmg startup fix.
  • v0.7.0 — 2026-09-28 — Sutura Triage Engine: modular architecture (sutura_engine with Triage, Diagnosis, Chart, Stitch, X-Ray, Hull, and Cast); extensible Plugin API (RepairMethodProtocol); content-addressed Chart cache (~/.cache/sutura/); Method #13 Graft (morphology shell wrap with healthy fidelity fallback before fTetWild); complete 15-method registry with canonical display names; and auto-escalation runtime fallback budget.
  • v0.6.1 — 2026-09-28 — Two opt-in geometry methods: Mirror Complete (#14, completes a single-sided symmetric scan by mirroring the visible surface across its detected symmetry plane, with a Poisson fallback) and Wall Thicken (#15, per-vertex wall thickness from an SDF grid plus a morphological thicken-to-min, --wall-min-thickness T); and learning triage (a small, bounded local (object type, method) ranking bonus, toggled with --no-learning-triage / learning_triage and reset with sutura clear-learning or the GUI Options).
  • v0.6.0 — 2026-09-28 — Repair method registry: twelve stable methods (--list-methods), per-object analysis and recommendations (--analyze), explicit tagging (--methods 2,3,5) and GUI right-click per-file method tagging; new standalone tiers for single-side scan closing (#8/#9), proxy-template repair (#10) and repeated-element repair (#11/#12, auto detection + GUI source/target picker); a reload-honest watertight verdict (P-HONEST) plus the P-WELD final pass, so the seven real-world samples that used to reload non-manifold now reload strictly watertight with unchanged face counts; new runtime dependencies scipy and trimesh.
  • v0.5.0 — 2026-09-26 — Triage Engine: intensity presets and named profiles (--intensity quick|balanced|thorough|extreme|<profile>, GUI combo + profile editor), a deep-repair ladder (--deep-repair {off,local,full}) with fTetWild dense-output decimation, external repair engines (~/.config/sutura/engines/*.toml, chain.toml, every result through the same watertight + Hausdorff guard as fTetWild) and an fTetWild install/remove manager (sutura ftetwild, GUI Engines tab), a tabbed Options window, explicit --ftetwild-optimize, and CI Linux/macOS end-to-end installs. The fTetWild fallback tier is on by default when installed (opt-out --no-fallback-ftetwild); the exact Rust arrangement is ~2.8× faster again (thingi10k_1038441: 29.8 s → 10.6 s on an M2, identical output).
  • v0.4.0 — 2026-09-25 — experimental self-intersection tiers: autorefine with iterative snap-rounding and an fTetWild fallback tier (optional ~1.1 GB extra), both as CLI flags + GUI checkboxes; the exact Rust arrangement (--experimental-indirect-autorefine) now completes dense scans (interval-filtered predicates, crossings from original data); OrcaSlicer plugin repairs the selected model in-memory via orca.host (never-tagged 0.3.1 content included).
  • v0.3.0 — 2026-09-20 — curved-but-mechanical classifier signal (developable_fraction) + 40-mesh real-world corpus; 115-mesh strict-watertight benchmark + corpus release asset + manifold3d cross-validation; GUI color-coded defect view + "what changed" repair log; opt-in edge-tiebreak classifier head and join-components options (CLI + GUI checkboxes); synthetic defect injection tool; 3MF zip-bomb guard.
  • v0.2.7 — 2026-09-19 — unit-mismatch detection; repair budgets; per-object Stage 2 for multi-object 3MF.
  • v0.2.6 — 2026-09-19 — Repair Health/Risk scoring; macOS Finder Quick Action.
  • v0.2.5 — 2026-09-19 — repair profiles (--profile / GUI dropdown); macOS native launch (Spotlight); real-world calibration corpus.
  • v0.2.3 — 2026-09-19 — bundle-aware path resolution for a standalone macOS .app; unsigned macOS .dmg packaging.
  • v0.2.1 — 2026-08-30 — anonymous usage history (opt-out); graphify knowledge-graph integration; contributing section.
  • v0.2.0 — 2026-08-26 — interactive 3D viewer (rotate/zoom + surface deviation); .obj support; auto-update license boundary; sponsor section.
  • v0.1.9 — 2026-08-25 — Repair Confidence Score; GUI Analyze button + analysis pane; mode suggestions.
  • v0.1.8 — 2026-08-24 — validate subcommand; --dry-run; before/after comparison with worst-defect zoom.
  • v0.1.7 — 2026-08-22 — repair modes (--mode + GUI picker); extreme extra passes; before/after mesh comparison (GUI).
  • v0.1.6 — 2026-08-20 — before/after geometry diff in reports; --diff.
  • v0.1.4 — 2026-08-19 — batch repair summary; defect detection (sutura/defects.py); mesh type-aware repair; Turkish README.
  • v0.1.3 — 2026-08-19 — opt-in update checker; self-update with backup and rollback.

About

Watertight STL/3MF/OBJ mesh repair for 3D printing. Sutura Triage Engine with 17 ranked repair methods — Tamponade (inside-out hole plug with Stent), Flap, Graft shell wrap, fTetWild and more — on a Rust + PyMeshLab core, with a reload-honest watertight verdict. CLI + Qt GUI for Linux and macOS.

Topics

Resources

Contributing

Security policy

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages