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.
Tamponade and Flap are on by default in Options → Repair.
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
- 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=0or 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). - 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-flapor the Flap surface hole fill checkbox. - 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.
- 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.
- Stage 2: make it one solid (manifold3d). Rebuilds the result as a valid closed solid, the same library Bambu Studio uses.
- 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 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.
- 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 Rustsutura-geomcore, evaluation-only). Inautomode, when a model fits themechanicalordense_scan_heavy_sitemplates 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 withSUTURA_SI_EXCISE=0. The Stage-1 hole fill itself can additionally be refined (refinehole=True) with the experimentalSUTURA_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.
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.pyconstant (fTetWildautowhen 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 balancedis 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
thresholdrung, 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.
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.
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.
--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.
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).
--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.
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.
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.
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).
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 (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.
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.
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_geomRust extension (installed automatically byinstall.sh,install-macos.sh, and bundled in AppImage/.dmg distributions). Without it Graft is listed as unavailable and auto repair uses fTetWild as before.
- Generalized-Winding Signed Distance: Using the Rust
sutura_geomcore, a signed-distance field is constructed on an adaptive voxel grid tuned to the median edge length of healthy triangles. - 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.
- 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.
- 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). - 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_memorymessage. - 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.
- 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 carriesinput_coverageandcoverage_ok), so a collapsed result that happens to be accurate where it remains is rejected and the stage-1 result is kept.
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.
- 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/200rule produced a 1 M-face coat and 7 % deviation on a 71-face mesh). - 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.
- Numpy prototype backend, Rust-ready: the field build and marching
tetrahedra run in numpy today; the Rust
sutura_geom.dressing_coatnarrow-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. - Drain (healthy-region erode-back): the coat sits ~
r_baseoutside 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). - 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=0disable 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. - 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 mostDRESSING_MAX_PARTS_SLACK(1), and the P95 coat-vs-input vertex-normal angle over healthy surface stays belowDRESSING_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. - 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_nmdrops 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 resolvedr_max/sigma; the default (unset = 1.0) is byte-identical, and non-positive or unparseable values are ignored. - 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_tunedset this collapsedthingi10k_145065from 669 to 1 component andthingi10k_63785from 25 to 1 (bothnumber_of_parts = 1in OrcaSlicer afterwards) while keeping the three genuine material lumps ofthingi10k_1038439; the removed counts and volume are reported inreport['dressing']['cleanup']. - 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 asuggestionslist (method = dressing, separate from the defectissues, so the category is unchanged). The--humanreport prints a marked hint (⚠ Not watertight…) that points at--experimental-dressingwhen 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-adoptwhen 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-adoptimplies--experimental-dressingand skips the volume / components / normal / fidelity gates while STILL requiring a strict-watertight, self-intersection-free candidate; the record'sforcedfield 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).
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 tofloat32, 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 thefloat32-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;--humanprints 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).
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.
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. |
Linux:
python3(>= 3.11) with venv support, for the PyMeshLab venvpython3.11specifically, 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.shdoes this automatically).
Linux Python 3.11 install:
- Arch: Python 3.11 is not in the official repositories;
install.shdownloads a standalone runtime automatically (SUTURA_NO_PYTHON_DOWNLOAD=1to 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.
python3.11not found. Stage 2 (manifold3d) needs Python 3.11 because it ships wheels only up to 3.13. On systems without a systempython3.11(such as Arch Linux, where Python 3.11 is not in the official repositories),install.shautomatically downloads a standalone CPython 3.11 build into~/.local/share/sutura/python311. This automatic download can be disabled withSUTURA_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.11Then runinstall.shagain — it reuses existing runtimes and virtualenvs.
- Debian / Ubuntu 22.04+:
- PySide6 install fails. The GUI needs
PySide6-Essentials, which is installed into thevenvfrom PyPI. On distros wherepip install PySide6-Essentialsfails (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 officialextrarepository)
- Debian/Ubuntu:
- The GUI has no KDE file dialog. The native dialog needs
plasma-integrationand 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 pymeshlabfails 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, seeinstall-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", thePlugIns/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=offscreenorminimal) 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)expressionon everygit 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 inscripts/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.pynext to the bundled CLI (e.g.Sutura.app/Contents/MacOS/sutura-cli/manifold_bridge.py) withmanifold3d/trimeshbundled; otherwisestage2_bridge_available=falseand confidence drops ~25 points.
If a repair fails, or a method looks unavailable, three commands help diagnose and recover the installation:
sutura --self-checkverifies 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.zipwrites a single ZIP archive with the system and Python runtime information, the state of thesutura_geomextension, 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-repairchecks 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.appinstalls are read-only, so they print a pointer to the releases page instead of repairing in place.sutura --self-repair --yesskips the confirmation prompt.
One line (fetches the latest main and installs):
curl -fsSL https://raw.githubusercontent.com/Krateian/Sutura/main/install.sh | bashOr from a clone:
git clone https://github.com/Krateian/Sutura.git
cd Sutura
./install.shThis 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.shWithout 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).
git clone https://github.com/Krateian/Sutura.git
cd Sutura
./install-macos.shinstall-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.appin/Applications~/.local/share/sutura/(app files, includingmacos-quick-action.sh)~/.local/bin/suturaand~/.local/bin/sutura-gui~/Library/Services/Sutura Quick Action.workflow(the Finder Quick Action)~/Library/Logs/Sutura/(repair logs)- optionally the
sutura-envconda 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.appRuns 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 exitOBJ 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 ismax(|volume change %|, |surface area change %|); a budget above this value is exceeded.--max-risk SCORE— therepair_riskscore (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"), orunavailable. 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.pyThe 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).
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.
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
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.
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):
-
Install Sutura first (
./install.shon Linux,install-macos.shon macOS) so~/.local/bin/suturaexists. -
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. -
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.
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.pyas 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.)
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.
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:
- 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.
- 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. - 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 inscripts/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%).
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.
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.
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.
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.
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.
Synthetic broken mesh:
python3 tests/make_broken_stl.py /tmp/broken.stl
sutura /tmp/broken.stl --humanThe 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 handlingReal-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.
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 destinationThe 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.pyRust 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.objThe 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.
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.
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.
| 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.
- 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 bundledsutura-cliinside a PyInstallerSutura.app, so it only repairs when such an app is present (in/Applicationsor~/Applications) — the dev-install wrapper app alone has no bundled CLI. A downloaded (quarantined)Sutura.appmust 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=kdeand pointsQT_PLUGIN_PATHat/usr/lib/qt6/pluginsso 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 (viaqmake) 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.
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 Rustsutura_geomextension (exact predicates,arrangement_lite).sutura_engine.methods— Pluggable repair methods conforming toRepairMethodProtocol: built-in methods (#1Quick Clean through#17Tamponade) 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.
- 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 (seedocs/repair-benchmark-strict-watertight-2026-09.md). Note: Graft (#13) requires thesutura_geomRust 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.
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
trimeshimports 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.
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-historyprints an anonymous summary plus the full JSON records in one command, ready to paste into a GitHub issue. Use--summary-onlyfor just the summary,--last Nto limit to the most recent records, and--clearto 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; setSUTURA_DATA_DIRto store them elsewhere (useful for the AppImage, whose bundled code mount is read-only). The uninstaller keeps this directory by default;uninstall.sh --purgeremoves it.
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> --humanoutput (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.
If Sutura has saved you time or a broken print, you can sponsor its development on GitHub.
(No sponsors yet — be the first!)
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.
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=0opts out); a memory/time guard on the Scaffold resample (#10); native self-intersection detection behind the off-by-defaultSUTURA_NATIVE_SI; and dependency reduction (Phase 1 dropspsutil/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=0disables); Dressing (method #16, opt-in variable-viscosity level-set skin) with a non-watertight suggestion and--dressing-force-adopt; the--si-modeself-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_enginewith 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_triageand reset withsutura clear-learningor 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 viaorca.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.dmgpackaging. - 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);
.objsupport; 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 —
validatesubcommand;--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.




