Bump version v1.4.0. The first version published

This commit is contained in:
ange committed 2026-10-17 02:38:29 +02:00
1 parent 336c225e13
commit 0f85dcbc67
13 files changed
+2519

No files matched your search

@@ -0,0 +1,44 @@
---
name: ai-3d-asset-pipeline
description: Générer des modèles 3D de personnage et d'accessoires avec ComfyUI local (Flux 2 Klein pour les références, Pixal3D / Trellis.2 pour la 3D, texturage Trellis multi-vues) et les préparer pour Blender (UV propres, normal map, cartes). À utiliser pour produire les maillages sources que Slice Wrap / Slice Retopo vont nettoyer et retopologiser.
---
# Chaîne IA → 3D (projet Chiara)
Projet : `C:\Users\a-mma\Desktop\CharacterDesign\Work3D` (scripts dans `scripts/`, références dans `refs/<item>/`,
README à la racine). ComfyUI : `C:\ComfyUI-Easy-Install\ComfyUI-Easy-Install`, API `http://127.0.0.1:8188`,
GPU 16 Go. Consignes globales : `~/.claude/CLAUDE.md`.
## Règles impératives
- **Valider avant de lancer** quand l'utilisateur le demande ; présenter le plan et les choix (pose, sexe /
anatomie, nom, dossier) avec des questions fermées.
- **ComfyUI et calculs longs dans des fenêtres PowerShell externes** (`Start-Process pwsh`) : un plantage de
l'application Claude ne doit pas tuer la génération. Lanceurs : `run_props_external.ps1 [items]`,
`run_watertight.ps1 <item> [res] [largest|auto|raw] [pixal3d|trellis2]`, `start_comfyui.ps1`.
- **Vider la VRAM** (`POST /free {"unload_models":true,"free_memory":true}`, réponse vide) avant chaque génération
et entre deux modèles ; symptôme d'oubli : GPU 100 %, 15,7 Go, image de 30 s qui dure des minutes.
- Aucune génération pendant qu'Unreal est ouvert.
- Surveiller une génération : boucle tolérante (ComfyUI ne répond pas pendant les étapes lourdes : plusieurs
échecs consécutifs avant de conclure au plantage).
- **Toujours regarder** les images / rendus (Read) avant de les présenter ; convention : vue validée = suffixe
`.ok` avant l'extension.
## Références (Flux 2 Klein 9B, édition par image de référence)
- Éditer l'image source (« Keep the same … Only change … ») plutôt que régénérer : garde identité et pose.
- Effets de bord vus : retirer les cils efface les yeux (préciser « eyes stay OPEN with irises ») ; les doigts
pendants de la source sont recopiés → **passe dédiée aux mains** (`hands_fix.py`) ; un sourcil existant résiste
à l'effacement ; la vue de profil d'une T-pose dessine souvent le bras vers l'avant.
- Tête seule : recadrer la tête de la vue de face validée, éditer en buste tête + cou (`head_ref.py`).
## 3D
- **PixelArtistry** (`pixelartistry.py`) : Pixal3D (défaut) ou Trellis.2 ; modes `largest` / `auto` (garde une
paire) / **`raw`** (non étanche : export direct du décodage, sans Quad Reconstruct / WTiVo / LODTailor — pas de
moiré mais trous et doubles parois → passer par Slice Wrap).
- Pixal3D > Trellis.2 en fidélité de visage et en poids (12 M vs 28 M faces) ; les deux ajoutent épaules et socle.
- Résolution WTiVo : 1536 pour un corps, 1024 pour petits objets (sinon > 40 M quads, débordement VRAM).
- Chaîne complète (`props_pipeline.py`) : étanche → nettoyage + UV + échelle → texture Trellis2 multi-vues 4K →
normal map bakée → échelle finale → rendu → FBX ; puis **`uv_finalize.py`** : UV propres par zones
anatomiques (`clean_uv.py`, ~50 îlots au lieu des ~3 000 de Trellis), couleur transférée, normal map rebakée.
- Main / pied à recoller : générer la partie seule en gros plan (doigts séparés), la rendre pleine
(`solid_hand.py`), la greffer (`graft_hands.py`) — ou poser par armature (gabarit placé par l'utilisateur,
poids automatiques + isolation des doigts, pose « main ouverte »).
@@ -0,0 +1,52 @@
---
name: blender-addon-testing
description: Tester un add-on ou une extension Blender (4.2 → 5.x) en arrière-plan sans l'installer, produire des rendus de contrôle lisibles, construire / valider le paquet d'extension et éviter les pièges connus de l'API. À utiliser pour Slice Wrap, Slice Retopo ou tout autre add-on Python de l'utilisateur.
---
# Tester et livrer un add-on Blender
Blender : `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe` (5.2.1 LTS). Windows, Git Bash.
## Tester sans installer
```python
import sys, importlib, os, bpy
sys.path.insert(0, r"E:\0000WorkTechnique\0002dev") # dossier PARENT du paquet
for k in [k for k in sys.modules if k.startswith("slice_wrap_addon")]: del sys.modules[k]
mod = importlib.import_module("slice_wrap_addon")
bpy.ops.wm.read_factory_settings(use_empty=True) # AVANT register (sinon tout est effacé)
mod.register()
```
- Lancer : `blender -b -P test.py -- <args>` ; récupérer les args après `--`.
- Les préférences d'add-on n'existent pas hors installation : `context.preferences.addons.get(__package__)` peut
être `None` → prévoir un objet de repli.
- Dans une opération qui échoue, `bpy.ops.x()` lève `RuntimeError` en arrière-plan : l'attraper dans le test.
- Écrire les fichiers temporaires dans le scratchpad de la session ; chemins courts (Windows MAX_PATH).
- Données lourdes (> 10 M faces) : chronométrer chaque étape, les imprimer.
## Rendus de contrôle (toujours regarder l'image avant de conclure)
- Workbench, `scene.display.shading.color_type = "OBJECT"`, couleur par objet.
- Arêtes d'un patch posé sur une surface : modificateur **Displace** (strength ~1 % de la taille, mid_level 0)
puis **Wireframe** (épaisseur fine) — sinon le fil de fer s'enfonce dans la surface.
- Couleurs de sommets : `shading.color_type = "VERTEX"` + `color_attributes.active_color_index` **et**
`render_color_index`.
- Caméra orthographique, plusieurs vues (face, profil) ; assembler avec PIL (Python système) en planche.
- Un zoom extrême sur des millions de faces donne un faux « bruit » (aliasing) : vérifier à une échelle normale.
## Construire / valider l'extension
- `blender --command extension build --source-dir <dir> --output-dir <dir>` ; `--command extension validate <dir>`.
- `blender_manifest.toml` : `[build] paths_exclude_pattern = ["__pycache__/", "/*.zip", "/.claude/", "/CLAUDE.md", "/tests/"]`.
- Monter `version` à chaque livraison (Blender propose alors la mise à jour).
## Pièges de l'API rencontrés
- Pas de modificateur personnalisé en Python → opérateur + paramètres mémorisés + bouton « Regenerate ».
- Groupes de sommets et attributs partagent le même espace de noms → noms distincts (`_Color`).
- GLB : sommets dédoublés aux coutures UV / arêtes vives → `bmesh.ops.remove_doubles` avant analyse ;
exporter en ombrage lissé (`use_smooth`) pour ne pas dédoubler.
- `BVHTree.FromObject(obj, depsgraph)` : coordonnées **locales**.
- `new_from_object(ev, preserve_all_data_layers=True, depsgraph=dg)` pour garder les groupes de sommets d'un
maillage évalué (Multires) ; les sommets d'origine viennent en premier dans un maillage subdivisé.
- Figer dans un Multires : copie évaluée + `multires_reshape` (après retrait des modificateurs suivants).
- `vertex_group.remove([int(i)])` (pas de `numpy.int64`).
- `bpy.ops.object.voxel_remesh` / Voxel Remesh **fuit** sur un maillage troué (doubles parois) → remplir le volume
d'abord (cf. Slice Wrap).
- Gros correctifs : écrire un script Python de patch (asserts sur chaque remplacement) plutôt qu'un heredoc bash.
+42
View File
@@ -0,0 +1,42 @@
---
name: blender-live-mcp
description: Travailler dans le Blender OUVERT de l'utilisateur via le MCP Blender (execute_blender_code, captures) sans rien casser — inspection en lecture seule, vues sauvegardées / restaurées, rendus de contrôle fiables, modifications seulement avec accord. À utiliser dès qu'on agit sur sa scène en direct (diagnostic, rig, nettoyage, analyse).
---
# Agir dans le Blender ouvert de l'utilisateur
## Règles
1. **Lecture seule d'abord** : lister objets, modes, modificateurs, statistiques (`execute_blender_code` qui
renvoie un dict `result`). Rendre compte, puis agir seulement si c'est demandé.
2. Si l'utilisateur dit « ne fais rien, explique » : **aucune** modification, uniquement des mesures.
3. Avant de modifier : vérifier que le fichier n'a pas changé depuis la dernière sauvegarde connue
(`bpy.data.is_dirty`, date du .blend), faire une **copie de sauvegarde** du .blend si on le régénère.
4. Toujours travailler sur une **copie** d'objet quand l'utilisateur peut vouloir revenir en arrière
(« au cas où ») ; cacher l'original plutôt que le supprimer ; ne jamais supprimer sans accord.
5. Ne pas passer en mode Édition sur un maillage de plusieurs millions de faces (gel, plantage) : bmesh hors
mode Édition, numpy `foreach_get`.
6. **Sauvegarder et restaurer** ce qu'on touche dans l'interface : `region_3d.view_location / view_rotation /
view_distance / view_perspective`, `shading.show_xray`, `overlay`, visibilité des objets
(`bpy.app.driver_namespace` pour stocker l'état entre deux appels). Ne pas enregistrer le fichier à sa place.
## Voir la scène de façon fiable
- `get_screenshot_of_window_as_image` peut renvoyer une image **figée / décalée** (fenêtre réorganisée,
plusieurs écrans) : si l'image contredit les données (`result`), la croire fausse.
- Préférer un **rendu de la vue** dans un fichier puis `Read` :
`with context.temp_override(window=…, area=area_view3d, region=region_window): bpy.ops.render.opengl(write_still=True, view_context=True)`
(régler et restaurer `scene.render.resolution_x/y/percentage/filepath`).
- Orienter la vue : `Vector(direction).to_track_quat("Z", "Y")` pour regarder le long d'un axe ; ortho.
## Diagnostics utiles (lecture seule)
- Topologie : pièces (composantes connexes), arêtes de bord / > 2 faces / fil, sommets isolés, doublons
(KDTree), valences (pôles), triangles / n-gones, faces plates (`use_smooth`), normales personnalisées.
- Écart entre deux maillages : KDTree sur un sous-échantillon de la cible + distance signée le long des normales
(dedans / dehors, médiane, centiles 5–95 par zone).
- Armature / poids : groupes vides, sommets sans poids, poids qui débordent entre doigts voisins.
## Pièges rencontrés
- `execute_blender_code` : `result` doit être un **dict** ; importer `bpy` dans chaque appel.
- Un objet avec `scale` ≠ 1 (import FBX / C4D à 0,01, retopo à ×11) : appliquer l'échelle avant bake / greffe.
- Extensions tierces cassées par Blender 5 (ex. RetopoFlow 4.2 : `from .. import bl_info` → `bl_info` retiré
des extensions à manifeste) : corriger minimalement, garder `*.orig`, recharger le module
(`importlib.reload`) sans redémarrer, prévenir que la mise à jour écrasera le correctif.
+46
View File
@@ -0,0 +1,46 @@
---
name: mesh-wrap-cleanup
description: Obtenir une surface propre, fermée et détaillée à partir d'un maillage troué / à doubles parois (scan, génération IA) — méthode « wrap » (enveloppe fermée → Multires → Shrinkwrap Project vers l'intérieur → Freeze → Despike), avec l'add-on Slice Wrap ou à la main, plus le nettoyage manifold. À utiliser quand un remesh donne du moiré, qu'un Trim / booléen échoue, ou avant une retopologie.
---
# Méthode « wrap » et nettoyage de maillage
## Diagnostic d'abord (lecture seule)
Compter : pièces, doublons (coutures UV d'un GLB), arêtes de bord (trous), arêtes > 2 faces (faces internes),
sommets isolés, faces plates parmi lisses, normales personnalisées. Expliquer la cause avant de proposer.
## Nettoyage manifold (dans l'ordre)
1. **Souder** : tout sélectionner → *M > By Distance* (≈ 0,1 mm à l'échelle réelle) — les UV sont conservées.
2. *Clean Up > Delete Loose* (sommets / arêtes isolés).
3. *Shift+N* (normales vers l'extérieur).
4. *Select All by Trait > Non Manifold* (sans Boundaries) → supprimer les faces internes.
5. *Clean Up > Fill Holes* (limite de côtés) pour les petits trous.
6. Ombrage : *Clear Custom Split Normals Data* puis *Shade Smooth* (les faces recréées naissent plates).
Ne **jamais** « Collapse » une sélection non manifold (tout s'effondre en pointes).
## Pourquoi le Voxel Remesh échoue
Il reconstruit un volume par voxels : une coque trouée « fuit » (intérieur non rempli → double peau, moiré) et
deux surfaces à moins de ~2 voxels fusionnent (doigts, orteils). Un masque n'exclut rien du remesh
(*Preserve Paint Mask* ne fait que le reprojeter). Pour exclure : séparer la partie, ou Dyntopo / Multires.
## La méthode (Slice Wrap la fait en un clic)
1. **Enveloppe fermée** : voxels de la surface → fermeture morphologique → remplissage **tranche par tranche
X / Y / Z** (union) → surface (Voxel Remesh sur volume plein) → plus grande pièce → lissage de Taubin.
Taille de voxel ≈ 0,5–0,7 % de la plus grande dimension (trop fin : la projection voit le grain).
2. **Couper** ce qui ne sert pas (socle, épaules) : *Bisect* + *Clear Inner* sur l'enveloppe seulement
(la cible n'a pas besoin d'être coupée ; couper l'enveloppe un peu plus haut que la cible).
3. **Gonfler le long des normales** (Alt+S ou Displace Normal ; avec Displace sans texture, déplacement =
(1 − midlevel) × strength) pour que l'enveloppe soit entièrement **au-dessus** de la peau ; supprimer d'abord
les miettes à volume nul (sinon « double face »).
4. **Multires** niveau 2 (Catmull-Clark) **en tête de pile** (appliquer le Displace avant).
5. **Shrinkwrap** sous le Multires : *Project*, aucun axe (le long des normales), **Negative seul**,
Limit ≈ 3–4 × le gonflement, Cull Off, Offset 0, Subdivision 0.
6. **Freeze** : copie évaluée → *Multires Reshape* (garde base légère + niveaux) ; réparer les ratés de
projection en comparant avant / après (sommet resté sur place = aiguille, sommet allé trop loin = creux) et
en les reconstruisant depuis leurs voisins.
7. **Despike** résiduel : seulement les sommets **isolés** (écart > seuil ET > 3 × l'écart moyen des voisins),
filtrable par groupes de sommets, « Protect Detail » via la carte de détail.
Limite connue : surplombs (pavillon de l'oreille) → pointes résiduelles, retouche Smooth.
## Projeter un plan ?
Un plan ne couvre qu'une face (masque facial) : utiliser une **enveloppe fermée**.
+54
View File
@@ -0,0 +1,54 @@
---
name: slice-wrap-dev
description: Développer, tester et livrer l'add-on Slice Wrap (maillage troué → enveloppe fermée → Multires + Shrinkwrap → Freeze → Despike, plus Analyze). À utiliser pour modifier une étape du pipeline, ajouter un réglage, corriger un bug, régler les performances ou construire le paquet.
---
# Développer Slice Wrap
Lire d'abord `CLAUDE.md` (pipeline, décisions et pourquoi, pistes). Répondre en français ; libellés d'interface
en anglais, descriptions / README en français.
## Boucle de travail
1. **Situer la demande** dans une étape : Envelope (`core.build_envelope`), Wrap Setup (`step_wrap`), Freeze
(`step_freeze` + `core.fix_missed_rays`), Despike (`core.despike`, `step_despike`), Analyze
(`core.analyze_shape`, `write_analysis`). Garder le concept : « maillage troué → surface propre + analyse ».
Toute logique de topologie / flow / motifs de quads va dans **Slice Retopo**, pas ici.
Une nouvelle fonction = sous-panneau ou option repliée, le bouton « Slice Wrap » doit rester simple.
2. **Expliquer avant de coder** si l'utilisateur demande « à quoi sert… » ; attendre sa décision.
3. **Coder** : numpy + `mathutils` uniquement (pas de scipy / skimage). Algorithmes purs dans `core.py`, UI et
opérateurs dans `__init__.py`. Modifs sûres : petits scripts Python de patch avec `assert ancien in texte`
(éviter les heredocs bash à guillemets imbriqués), puis `ast.parse`.
4. **Tester** : ajouter un `check(...)` dans `tests/run_tests.py`, lancer
`"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" -b -P tests/run_tests.py -- <scratchpad>`
(~40 s ; `--head` ~3 min), exiger `RESULTAT : TOUT OK`. Pour un changement de géométrie, lancer
aussi `--head` et **regarder** `slice_wrap_head_face.png` / `slice_wrap_head_cote.png` avant d'annoncer.
5. **Livrer** : monter `version` dans `blender_manifest.toml`, mettre à jour `README.md` (nom du zip dans
Installation) et `CLAUDE.md` (Historique), puis
`blender --command extension build --source-dir . --output-dir .` et vérifier le contenu du zip (pas de
`tests/`, `.claude/`, `CLAUDE.md`). L'utilisateur installe lui-même le zip.
## Règles à ne pas casser
- Ne jamais écraser un objet existant : `core.unique_object_name` ; seul Regenerate (`replace=`) recalcule sur
place, et il refuse une enveloppe déjà wrappée.
- La source n'est jamais modifiée (seulement cachée si Hide Source).
- Collection de résultat : réutiliser sans toucher son contenu, sinon créer + lier à la scène.
- Paramètres mémorisés sur le résultat : `slice_wrap_source`, `slice_wrap_pitch`, `slice_wrap_inflate`.
- Voxel Relative / Absolute : les deux modes restent (décision utilisateur) ; Relative par défaut.
- Despike : toujours le double critère (Distance ET Isolation) ; liste de groupes non vide sans aucun groupe
trouvé → refuser.
- Noms d'affichage couleur suffixés `_Color` ; régler `active_color_index` ET `render_color_index`.
- `vertex_group.add/remove` : listes d'`int` Python (pas d'entiers numpy).
- Analyze écrit sur les N premiers sommets du maillage évalué (= sommets de base, Multires compris).
## Pièges connus
- Voxel Remesh de Blender sur une source trouée → fuite : on ne l'applique que sur le volume déjà rempli.
- Voxel trop fin sur source bruitée → la projection voit le grain (défaut 0,6 %).
- Shrinkwrap Project : rayons ratés sur les surplombs / parois internes → `fix_missed_rays` au Freeze.
- `_outside` (remplissage par dilatation) est la partie lente sur les grandes grilles ; piste : scanline.
- Un GLB réimporté découpe les sommets aux coutures UV (souder avant toute analyse de topologie).
- `BVHTree.FromObject` travaille en espace local.
## Données de test
- Rapide : sphère trouée bruitée générée par le test.
- Réelle : `C:\Users\a-mma\Desktop\CharacterDesign\Work3D\refs\head\3D\head_raw_pixal3d_front_s56.glb`
(11,8 M faces, ~1,5–2 min pour la chaîne) ; autres sources dans `Work3D\refs\head\3D\` (Trellis2 brut).
+131
View File
@@ -0,0 +1,131 @@
# Slice Wrap — add-on Blender : surface propre à partir d'un maillage troué, wrap, nettoyage et analyse
L'utilisateur parle **français** : répondre et documenter en français. Libellés d'interface Blender en anglais
(convention Blender), descriptions / docstrings / README en français.
## But
Transformer un maillage **troué, à doubles parois ou bruité** (scan, génération IA type Pixal3D / Trellis — ex.
tête brute de 11,8 M faces) en une **surface fermée, propre et détaillée**, prête à sculpter ou à retopologiser,
puis fournir une **analyse de forme** (densité de détail, tags, direction des plis).
Né pendant le projet personnage « Chiara » (`C:\Users\a-mma\Desktop\CharacterDesign\Work3D`) : le Voxel Remesh de
Blender donnait du moiré et des fuites sur la tête générée ; un script de remplissage voxel tranche par tranche a
résolu le problème, d'où l'add-on.
**Concept à préserver** : Slice Wrap = « maillage troué → surface propre + analyse ». Aucune logique de topologie /
flow ici : la retopologie guidée est un projet séparé, **Slice Retopo**
(`E:\0000WorkTechnique\0002dev\slice_retopo_addon`), qui lit les attributs produits par Slice Wrap.
## Pipeline (bouton « Slice Wrap » = étapes 1 → 3, + 4 si « Despike After »)
1. **Envelope** (`core.build_envelope`) — sur la source (jamais modifiée) :
échantillonnage de surface (sommets + points sur les triangles > ½ voxel) → voxels occupés → fermeture
morphologique (`close_iter`) → **remplissage 3D global ∪ remplissage 2D tranche par tranche selon X, Y, Z**
(un trou ne fait fuir que les tranches qu'il traverse) → ouverture (`open_iter`, filaments) → surface en cubes
→ **Voxel Remesh de Blender** sur ce volume déjà plein (aucune fuite possible) → plus grande pièce → lissage
de Taubin (`smooth_iter`). Numpy pur (pas de scipy / skimage dans le Python de Blender).
2. **Wrap Setup** — gonflement le long des normales (défaut 2 voxels) pour que l'enveloppe soit entièrement
**au-dessus** de la peau, **Multires** (niveaux, Catmull-Clark) puis **Shrinkwrap** : *Project*, aucun axe
(= le long des normales), **Negative seul**, Limit (défaut 4 × gonflement), Cull Off, Offset 0.
3. **Freeze** — copie évaluée → `multires_reshape` (la projection est figée DANS les niveaux du Multires : base
légère + détails) ; **Repair Missed Rays** : chaque sommet est comparé avant / après projection ; resté sur
place (< 0,3 × gonflement → aiguille) ou parti trop loin (> 2 × la moyenne de ses voisins → creux sur une paroi
interne) = raté, reconstruit par diffusion depuis ses voisins réussis.
4. **Despike** (manuel ou en fin de chaîne) — ne corrige qu'un sommet **isolé** : écart à la moyenne des voisins
> Distance (0 = longueur moyenne d'arête) ET > Isolation × l'écart moyen des voisins ; Strength, Iterations ;
filtres par **groupes de sommets** (poids = intensité ; liste vide = toute la surface ; liste non vide mais
aucun groupe trouvé = refus) ; **Protect Detail** (× (1 − détail)).
5. **Analyze** (optionnel, ne modifie pas la géométrie) — tenseur de covariance des normales sur `Neighborhood`
anneaux (valeurs propres λ1 ≥ λ2 ≥ λ3) + courbure moyenne signée :
`sw_detail` (λ2+λ3 normalisé), `sw_anisotropy` ((λ2−λ3)/(λ2+λ3)), `sw_curvature`, `sw_shape`
(0 Flat, 1 Bulge, 2 Hollow, 3 Ridge, 4 Fold, 5 Saddle), `sw_flow` (vecteur propre de λ3 = direction du pli),
couleurs `SW_Detail_Color` / `SW_Shape_Color`, groupes `SW_Detail`, `SW_Bulge`, `SW_Hollow`, `SW_Ridge`,
`SW_Fold`, `SW_Saddle`. Écrit sur les N premiers sommets du maillage évalué = sommets de base.
## Décisions et pourquoi
- **Pas de vrai modificateur** (impossible en Python) → opérateurs + paramètres mémorisés sur le résultat
(`slice_wrap_source`, `slice_wrap_pitch`, `slice_wrap_inflate`) + **Regenerate** (recalcul sur place).
- **Nom du résultat** = source + **Suffix** (défaut `_slicewrapped`), **numéroté .001, .002…** : aucun objet
existant n'est jamais écrasé (avant la v1.3, relancer remplaçait le maillage existant — corrigé). Seul
Regenerate recalcule l'objet actif. Refusé si l'enveloppe est déjà wrappée (Multires / Shrinkwrap).
- **Collection** optionnelle (défaut cochée, `SliceWrapCollection`) : réutilisée si elle existe (contenu
intact), sinon créée et liée à la scène ; décochée → collections de la source.
- **Voxel Relative / Absolute — conservé (décision utilisateur, 2026-10-17)** : Relative (défaut 0,6 % de la plus
grande dimension, ≈ 170 voxels) rend le réglage indépendant de l'échelle (têtes IA normalisées à ~1 m, tête
réelle 23 cm, main C4D importée à 3,7 m) ; Absolute donne la **même densité physique à plusieurs parties d'un
même personnage** (tête, mains, pieds à raccorder). L'équivalent absolu du mode Relative est affiché.
Possible plus tard : ranger Absolute dans une option avancée repliée.
- Défaut 0,6 % (et non 0,4 %) : une base trop dense fait « voir » le grain de la source à la projection.
- Despike par **isolation** et non par seuil seul : le seuil seul attrapait 8 % des sommets (oreilles, paupières).
- Réparation des ratés **au Freeze** (on connaît exactement les sommets ratés) plutôt que deviner après coup.
- Couleurs suffixées `_Color` : groupes de sommets et attributs partagent le même espace de noms.
- **Plus de réglage Target (décision utilisateur, 2026-10-17)** : il compliquait l'interface pour rien. Tout
s'applique à l'objet actif **et sélectionné** (`_obj_mode_mesh`) ; Wrap Setup projette sur `slice_wrap_source`.
- **Calculs longs = générateurs + opérateur modal** (`_LongOperator`) : l'API bpy n'est pas thread-safe, donc pas
de thread. Les étapes (`step_*`, `core.build_envelope`, `despike`, `fix_missed_rays`, `analyze_shape`…) cèdent
la main en produisant (fraction, texte) ; `core.sub` imbrique les progressions, `core.run` exécute d'une traite
(scripts / tests, `bpy.ops` sans INVOKE). Depuis l'UI : confirmation (`invoke_props_dialog`, préférence
`confirm_long`), puis timer 0,02 s, tranches de 0,15 s, saisie absorbée, Échap → `gen.close()` : chaque étape
nettoie dans `except BaseException` / `finally` (aucun demi-résultat ; la scène n'est modifiée qu'à la fin).
Progression : `workspace.status_text_set(fonction)` + boîte en haut du panneau. Les étapes lisent `bpy.context`
à chaque usage (le contexte d'un opérateur n'est plus valide aux ticks suivants). Pas de `REGISTER` sur ces
opérateurs (un redo relancerait un modal). Surcoût mesuré ~10 %. Appels Blender d'un seul tenant (Multires
subdivide / Reshape, Voxel Remesh) : l'écran reste figé sur leur libellé.
## Fichiers
- `__init__.py` : réglages (`SliceWrapSettings`), étapes (`step_envelope`, `step_wrap`, `step_freeze`,
`step_despike`, `step_analyze`, `_reshape_from_evaluated`, `_result_collection`), opérateurs (`run`,
`envelope`, `regenerate`, `wrap_setup`, `freeze`, `despike`, `analyze`, `show`, `group_add/remove/clear`),
panneaux (principal, Steps replié, Despike, Analyze replié), liste `SLICEWRAP_UL_groups`.
- `core.py` : voxels (`dilate`, `erode`, `_outside`, `fill`), maillage (`mesh_world_arrays`, `sample_surface`,
`voxelize`, `blocky_mesh`), nettoyage (`taubin`, `keep_largest_island`, `despike`, `group_weights`,
`fix_missed_rays`), analyse (`analyze_shape`, `write_analysis`), `unique_object_name`, `build_envelope`.
- `tests/run_tests.py` : suite (sphère trouée bruitée ; `--head` = tête réelle + rendus). Exclu du paquet.
- `blender_manifest.toml` : extension 4.2+ ; `[build]` exclut `__pycache__/`, zips, `.claude/`, `.git/`, `CLAUDE.md`,
`tests/`, `tools/`, `dist/`.
- `tools/Build-Package.ps1` (paquet), `tools/New-GiteaRelease.ps1` (release Gitea). Exclus du paquet.
## Commandes
- Tests : `"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" -b -P tests/run_tests.py -- <dossier> [--head]`
→ `RESULTAT : TOUT OK` (~1 min, 29 contrôles dont annulations) ; avec `--head`, regarder `slice_wrap_head_face.png` / `_cote.png`.
- Paquet : `.\tools\Build-Package.ps1 [-RunTests]` → `dist\slice_wrap-<version>.zip` (validate + build + contrôle
du contenu). **Monter `version`** à chaque livraison. L'utilisateur installe lui-même le zip.
- Release : `.\tools\New-GiteaRelease.ps1 [-DryRun]` — Gitea `https://a2msystemes-git.duckdns.org`, dépôt
`ange/slice_wrap_addon` (déduit de `origin`), jeton `GITEA_TOKEN`. Exige un dépôt propre et poussé ; crée le tag
`v<version>`, la release (notes = entrée de l'Historique ci-dessous) et joint le zip. Publication = action
externe : ne la lancer qu'à la demande de l'utilisateur.
- `.gitignore` : `__pycache__/`, `dist/`, `*.zip`, rendus de test. Le manifeste exclut aussi `tools/`, `dist/`, `.git/`.
## Historique
- 1.0.0 Envelope / Regenerate / Wrap Setup / Freeze (Reshape) / Despike.
- 1.1.0 Bouton unique « Slice Wrap » + Despike After ; Steps en sous-panneau ; Despike : Distance, Strength,
Iterations, filtres par groupes de sommets ; réparation des ratés au Freeze.
- 1.2.0 Analyze (carte de détail, tags, direction des plis), Show Detail / Shapes, Protect Detail.
- 1.3.0 Suffix (`_slicewrapped`), numérotation automatique (plus d'écrasement), collection optionnelle.
- 1.4.0 Réglage Target supprimé (objet sélectionné uniquement) ; confirmation avant les calculs longs, barre de
progression (barre d'état + panneau), Blender non bloqué, Échap annule sans rien laisser.
## Mesures de référence (Blender 5.2.1, tête Pixal3D brute 11,8 M faces)
Enveloppe 70 k faces en 11 s ; chaîne complète ~1,5–2 min ; ~93 k ratés réparés au Freeze ; Despike filtré
« Oreilles » 683 corrections vs 4 749 global ; Analyze 9 s (Flat 86 %, Ridge 5 %, Bulge 3 %, Fold 3 %, Hollow 2 %,
Saddle 1 % — hélice des oreilles et lèvres en Ridge, paupières et commissures en Fold).
## Pistes
- Option avancée repliée pour Absolute ; préréglages par type d'objet (tête, main, accessoire).
- Accélérer le remplissage (propagation par lignes / scanline au lieu d'une dilatation voxel par voxel).
- Surplombs (pavillon de l'oreille) : seconde projection *Nearest Surface Point* limitée à un groupe de sommets.
- Remaillage adaptatif (octree + Dual Contouring) pour props / LOD / cible de bake (pas pour l'animation).
- Retopologie guidée → projet **Slice Retopo** (marqueurs + retopo_tags + motifs ; voir son CLAUDE.md).
## Préférences de travail de l'utilisateur
- « Ne fais rien, explique » = aucune modification ; valider le plan avant les gros lancements ; expliquer les
paramètres (à quoi ils servent) avant de décider.
- Ne pas casser le concept de base d'un add-on en ajoutant des fonctions : sous-panneaux optionnels.
- Dans son Blender ouvert (MCP) : lecture seule d'abord, copie « au cas où », sauvegarder / restaurer la vue.
- Calculs longs dans des fenêtres PowerShell externes ; vider la VRAM ComfyUI avant chaque génération
(`~/.claude/CLAUDE.md`).
## Skills du projet (`.claude/skills/`)
- `slice-wrap-dev` — faire évoluer, tester et livrer cet add-on.
- `mesh-wrap-cleanup` — la méthode wrap et le nettoyage manifold (à la main ou avec l'add-on).
- `blender-addon-testing` — tests en arrière-plan, rendus de contrôle, paquet d'extension, pièges de l'API.
- `blender-live-mcp` — travailler dans le Blender ouvert de l'utilisateur sans rien casser.
- `ai-3d-asset-pipeline` — génération 3D par IA (ComfyUI) qui produit les maillages sources.
+136
View File
@@ -0,0 +1,136 @@
# Slice Wrap — add-on Blender (4.2 → 5.x)
Transforme un maillage **troué, à doubles parois ou bruité** (scan, génération IA type Pixal3D / Trellis)
en une **surface propre, fermée et détaillée**, prête à sculpter. Aucune dépendance : numpy seulement
(fourni avec Blender).
Panneau : **Vue 3D > barre latérale (N) > onglet « Slice Wrap »**.
## Pourquoi pas le Voxel Remesh de Blender ?
Le Voxel Remesh (et *Mesh to Volume* en Geometry Nodes) reconstruit un volume… mais **fuit** dès que la
coque a un trou : l'intérieur n'est pas rempli et on obtient une double peau, du moiré, des ratés.
Slice Wrap remplit le volume **tranche par tranche selon X, Y et Z** : un trou ne fait fuir que les quelques
tranches qu'il traverse, les autres directions le compensent.
## Utilisation
Tout s'applique à **l'objet sélectionné** (actif) : il n'y a pas de cible à choisir. La projection se fait toujours
sur la source de l'enveloppe. Sans maillage sélectionné, les boutons sont grisés.
1. Sélectionnez le maillage source (troué) en mode Objet.
2. Réglez au besoin la section **Envelope** et **Wrap**, puis cliquez **Slice Wrap** (un clic) :
Envelope → Wrap Setup → Freeze, et **Despike** en plus si **Despike After** est coché.
3. Le résultat `<source><suffixe>` (par défaut `<source>_slicewrapped`) est prêt à sculpter (base légère + niveaux
Multires). La source est cachée.
### Calculs longs : confirmation, progression, annulation
Slice Wrap, Envelope, Regenerate et Despike affichent d'abord un message : le calcul peut être long, Blender
ignorera les clics pendant ce temps (il n'est pas planté), Échap annule. Case « Show this message next time »
(aussi dans *Preferences > Add-ons > Slice Wrap > Confirm Long Operations*).
Pendant le calcul (ainsi que Freeze et Analyze) : **barre de progression** avec l'étape en cours et le temps écoulé,
dans la barre d'état en bas de la fenêtre et en haut du panneau. **Échap** annule : rien n'est créé ni modifié.
Limite : quelques opérations internes de Blender (subdivision Multires, Multires Reshape, Voxel Remesh) sont d'un
seul tenant. Sur un maillage lourd, l'affichage reste figé quelques secondes sur cette étape, puis la progression
reprend.
### Nom et rangement du résultat
- **Suffix** (défaut `_slicewrapped`) : nom du maillage créé = nom de la source + suffixe.
- **Numérotation automatique** : si le nom existe déjà, `.001`, `.002`… comme Blender — **aucun objet existant
n'est jamais écrasé** (même avec un suffixe vide, la source reste intacte).
- **Collection** (case + nom, défaut `SliceWrapCollection`) : le résultat y est rangé ; si la collection existe,
elle est simplement réutilisée (son contenu n'est pas touché), sinon elle est créée et liée à la scène.
Case décochée : le résultat va dans les collections de la source.
- **Regenerate** est le seul bouton qui recalcule un objet existant : l'enveloppe active, sur place.
### Sous-panneau « Steps » (replié) — chaque étape séparément
| # | Bouton | Ce qu'il fait |
|---|--------|---------------|
| 1 | **Envelope** | Sur l'objet actif (la source, jamais modifiée) : voxels → fermeture des trous → remplissage par tranches → surface fermée (Voxel Remesh sur volume déjà plein, sans fuite) → plus grande pièce → lissage de Taubin. Crée `<source><suffixe>` (numéroté si le nom existe). |
| | ↻ **Regenerate** | Recalcule l'enveloppe active depuis sa source avec les réglages actuels (pseudo-modificateur). Refusé si l'enveloppe est déjà wrappée. |
| 2 | **Wrap Setup** | Gonfle l'enveloppe le long des normales (pour qu'elle soit entièrement *au-dessus* de la peau), ajoute **Multires** (N niveaux) puis **Shrinkwrap** : *Project*, le long des normales, **vers l'intérieur seulement**, avec une distance limite. |
| 3 | **Freeze** | Fige la projection **dans les niveaux du Multires** (Multires Reshape) et retire le Shrinkwrap. Avec **Repair Missed Rays**, les sommets dont la projection a raté — rien touché (aiguilles) ou trop loin (creux sur une paroi interne) — sont détectés exactement (comparaison avant / après) et reconstruits depuis leurs voisins. |
Astuce : pour couper le socle / les épaules avant la projection, faites **Envelope**, coupez (Bisect), puis
**Wrap Setup** et **Freeze**.
### Sous-panneau « Despike » — outil manuel
Filtre anti-pointes résiduel : un sommet n'est corrigé que s'il est **isolé**, nettement plus loin de la moyenne
de ses voisins que ceux-ci ne le sont des leurs ; le relief normal (oreilles, paupières) n'est pas lissé.
- **Distance** : écart minimal pour être une pointe (0 = automatique : longueur moyenne d'arête).
- **Strength** (0–1) : part du chemin vers la moyenne des voisins.
- **Iterations** : nombre de passes. **Isolation** : la pointe doit dévier N fois plus que ses voisins.
- **Vertex Group Filters** : liste (+ / − / vider) des groupes de sommets à traiter. Le **poids** du groupe module
la force (fondu possible en bordure). **Liste vide = toute la surface.** Si la liste contient des groupes
dont aucun n'existe, le Despike refuse de s'exécuter (pour ne jamais traiter toute la surface par erreur).
Les groupes de la base sont propagés aux niveaux du Multires.
- **Protect Detail** : atténue le Despike en proportion du détail (analyse de forme recalculée à la volée), pour
ne jamais aplatir paupières, narines ou oreilles.
### Sous-panneau « Analyze » — optionnel, ne modifie jamais la géométrie
Analyse la forme de l'objet actif (Multires compris) : pour chaque sommet, la répartition des normales sur un
voisinage (tenseur de covariance) et la courbure moyenne signée. Résultat écrit sur le maillage de base :
| Donnée | Type | Contenu |
|--------|------|---------|
| `sw_detail` | attribut float 0–1 | densité de détail (la surface « tourne » beaucoup) — carte de densité pour une retopo |
| `sw_curvature` | attribut float −1…1 | > 0 convexe (bosse), < 0 concave (creux) |
| `sw_anisotropy` | attribut float 0–1 | ~1 : la surface tourne dans **une** direction (pli, crête, doigt) ; ~0 : dans toutes |
| `sw_shape` | attribut entier | tag : 0 Flat, 1 Bulge (dôme), 2 Hollow (cuvette), 3 Ridge (crête), 4 Fold (pli), 5 Saddle (selle) |
| `sw_flow` | attribut vecteur | direction du pli / de la crête = direction que doit suivre une boucle d'arêtes |
| `SW_Detail_Color`, `SW_Shape_Color` | couleurs | affichage (boutons **Detail** / **Shapes**, ✕ pour revenir) |
| `SW_Detail`, `SW_Bulge`, `SW_Hollow`, `SW_Ridge`, `SW_Fold`, `SW_Saddle` | groupes de sommets | filtres Despike, masques, ou carte de densité d'un remailleur |
Couleurs des tags : gris Flat · rouge Bulge · bleu Hollow · jaune Ridge · cyan Fold · violet Saddle.
Les couleurs portent le suffixe `_Color` car groupes de sommets et attributs partagent le même espace de noms.
## Réglages
- **Voxel : Relative / Absolute** — la finesse de l'enveloppe.
- *Relative* (défaut, **Voxel %** = 0,6 % de la plus grande dimension) : indépendant de l'échelle — le même
réglage donne le même résultat sur une tête de 23 cm, une tête normalisée à 1 m ou une main importée à 3,7 m.
Plus petit = plus fidèle mais plus lourd ; trop fin sur une source bruitée → la projection « voit » le grain.
- *Absolute* (**Voxel Size** en unités) : même finesse physique sur plusieurs objets de tailles différentes
(ex. tête et mains d'un même personnage, pour qu'elles se raccordent avec une densité identique), ou pour
viser une densité précise. La valeur équivalente du mode Relative est affichée sous le champ.
- **Close Holes** (3) : bouche les trous jusqu'à ~2 × cette valeur en voxels.
- **Slice Fill X/Y/Z** : axes de remplissage par tranches (les trois par défaut).
- **Remove Filaments** (1), **Smooth** (20 itérations de Taubin).
- **Inflate** (0 = auto : 2 voxels) et **Limit** (0 = auto : 4 × gonflement) pour le Shrinkwrap.
- **Multires Levels** (2) : densité de la projection (×16 faces).
- **Repair Missed Rays** (activé) et **Despike After** (désactivé) pour la chaîne en un clic.
- Analyze : **Neighborhood** (8 anneaux d'arêtes), **Flat Below** (0,15), **Directional Above** (0,55).
## Conseils
- Couper la source **et** l'enveloppe là où elles ne servent pas (socle, épaules) améliore la projection.
- Les **surplombs** (pavillon de l'oreille) restent le point faible d'une projection le long des normales :
quelques pointes peuvent subsister → Despike, ou pinceau Smooth.
- Après Freeze, l'objet garde une **base légère** + niveaux Multires : sculptez, ou bakez les détails.
## Installation
- **Paquet** : `slice_wrap-1.4.0.zip` (page *Releases* du dépôt Gitea, ou `dist\` après construction) →
*Edit > Preferences > Get Extensions > ⌄ > Install from Disk…*
- **Construire le paquet** : `.\tools\Build-Package.ps1` (→ `dist\slice_wrap-<version>.zip` ; `-RunTests` lance
d'abord les tests ; Blender trouvé automatiquement, ou `-Blender <chemin>` / variable `BLENDER_EXE`).
- **Publier une release Gitea** : monter `version` dans `blender_manifest.toml`, commiter et pousser, puis
`.\tools\New-GiteaRelease.ps1` (tests, paquet, tag `v<version>`, release avec le zip en pièce jointe).
Jeton : variable `GITEA_TOKEN` (sinon demandé). `-DryRun` montre ce qui serait fait sans rien publier ;
`-Prerelease`, `-Draft`, `-Notes "…"` (par défaut : l'entrée de la version dans l'historique).
- **Développement** : copier / lier ce dossier dans
`%APPDATA%\Blender Foundation\Blender\<version>\extensions\user_default\slice_wrap`.
## Fichiers
- `__init__.py` : réglages, opérateurs, panneaux (principal, Steps, Despike, Analyze), liste des groupes.
- `core.py` : algorithmes (voxelisation, morphologie, remplissage par tranches, Taubin, réparation des ratés, despike,
analyse de forme).
- `blender_manifest.toml` : manifeste d'extension.
## Tests (Blender 5.2.1)
Tête Pixal3D brute de 11,8 M faces → enveloppe 70 k faces (11 s) → Wrap Multires 2 → Freeze avec réparation
(~93 k ratés réparés) — chaîne complète en ~1,5–2 min. Despike filtré sur un groupe « Oreilles » : 683 sommets
corrigés contre 4 749 sur toute la surface. Analyze (1,1 M sommets évalués) en 9 s : Flat 86 %, Ridge 5 %,
Bulge 3 %, Fold 3 %, Hollow 2 %, Saddle 1 % — hélice des oreilles et lèvres en Ridge, paupières et commissures en Fold.
## Pistes (non implémentées)
- **Marqueurs de flow** dessinés par l'utilisateur (courbes / Grease Pencil sur la surface) avec des tags
(« pli », « gonflement », « boucle œil »…), combinés aux tags automatiques d'Analyze.
- **Stratégies par tag** pour une retopologie guidée : boucles parallèles à `sw_flow` sur un Fold / Ridge, anneaux
concentriques autour d'un Hollow (œil, bouche), pôle au sommet d'un Bulge, anneaux perpendiculaires sur un
cylindre (doigt), densité pilotée par `sw_detail`.
+928
View File
@@ -0,0 +1,928 @@
"""Slice Wrap — enveloppe fermée d'un maillage troué, puis « wrap » (projection) et nettoyage.
Panneau : Vue 3D > barre latérale (N) > onglet « Slice Wrap ». Tout s'applique à l'objet sélectionné (actif).
Slice Wrap (un clic) : Envelope → Wrap Setup → Freeze (+ Despike si « Despike After » est coché).
Opérations longues (Slice Wrap, Envelope, Regenerate, Freeze, Despike, Analyze) : confirmation (désactivable
dans les préférences de l'add-on), puis calcul par tranches dans un opérateur modal — Blender reste affiché,
barre de progression dans la barre d'état et le panneau, clics ignorés, Échap annule sans rien modifier.
Steps (sous-panneau) : chaque étape séparément.
1. Envelope : maillage source (troué, doubles parois, scan / IA) → nouvel objet fermé et lisse
(remplissage tranche par tranche X/Y/Z, robuste aux trous). La source n'est jamais modifiée.
Regenerate : recalcule l'enveloppe active avec les réglages actuels (pseudo-modificateur).
2. Wrap Setup : gonfle l'enveloppe, ajoute Multires (N niveaux) + Shrinkwrap (Project, vers l'intérieur).
3. Freeze : fige la projection DANS les niveaux du Multires (Reshape), répare les ratés de projection
(sommets qui n'ont rien touché ou sont allés trop loin) et retire le Shrinkwrap.
Despike (sous-panneau, manuel) : filtre anti-pointes, distance / force / passes, limité aux groupes de sommets
de la liste (poids = intensité) ; liste vide = toute la surface ; « Protect Detail » épargne les zones de détail.
Analyze (sous-panneau, optionnel) : analyse de forme SANS modifier la géométrie → attributs sw_detail,
sw_curvature, sw_anisotropy, sw_shape, sw_flow (direction des plis), couleurs SW_Detail_Color / SW_Shape_Color et groupes
de sommets SW_Detail, SW_Bulge, SW_Hollow, SW_Ridge, SW_Fold, SW_Saddle.
"""
import time
import traceback
import bpy
from bpy.props import (BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty,
PointerProperty, StringProperty)
from . import core
# ============================================================================= réglages
class SliceWrapPreferences(bpy.types.AddonPreferences):
bl_idname = __package__
confirm_long: BoolProperty(name="Confirm Long Operations", default=True,
description="Avant Slice Wrap, Envelope, Regenerate et Despike : rappeler que le calcul "
"peut être long et comment l'annuler")
def draw(self, context):
self.layout.prop(self, "confirm_long")
def _prefs(context):
a = context.preferences.addons.get(__package__)
return a.preferences if a is not None else None
class SliceWrapGroupItem(bpy.types.PropertyGroup):
group: StringProperty(name="Vertex Group", description="Groupe de sommets à traiter (poids = intensité)")
class SliceWrapSettings(bpy.types.PropertyGroup):
pitch_mode: EnumProperty(name="Voxel", items=[
("RELATIVE", "Relative", "Pas = pourcentage de la plus grande dimension de la source"),
("ABSOLUTE", "Absolute", "Pas en unités de la scène")], default="RELATIVE")
pitch_rel: FloatProperty(name="Voxel %", default=0.6, min=0.05, max=5.0, precision=2,
description="Taille de voxel en % de la plus grande dimension (0,6 % ≈ 170 voxels)")
pitch_abs: FloatProperty(name="Voxel Size", default=0.006, min=1e-5, precision=4, unit="LENGTH")
close_iter: IntProperty(name="Close Holes", default=3, min=0, max=20,
description="Fermeture morphologique en voxels : bouche les trous jusqu'à ~2× cette taille")
slice_x: BoolProperty(name="X", default=True, description="Remplissage tranche par tranche perpendiculaire à X")
slice_y: BoolProperty(name="Y", default=True, description="Remplissage tranche par tranche perpendiculaire à Y")
slice_z: BoolProperty(name="Z", default=True, description="Remplissage tranche par tranche perpendiculaire à Z")
open_iter: IntProperty(name="Remove Filaments", default=1, min=0, max=5,
description="Ouverture morphologique : retire les filaments plus fins que ~2× cette taille")
smooth_iter: IntProperty(name="Smooth", default=20, min=0, max=200, description="Itérations de lissage de Taubin")
hide_source: BoolProperty(name="Hide Source", default=True)
# Résultat
name_suffix: StringProperty(name="Suffix", default="_slicewrapped",
description="Nom du maillage créé = nom de la source + ce suffixe "
"(numéroté .001, .002… si le nom existe déjà : rien n'est écrasé)")
use_collection: BoolProperty(name="Put in Collection", default=True,
description="Ranger le maillage créé dans une collection dédiée "
"(sinon : dans les collections de la source)")
collection_name: StringProperty(name="Collection", default="SliceWrapCollection",
description="Collection de destination : utilisée si elle existe, créée sinon")
# Wrap (la cible de la projection est toujours la source de l'enveloppe)
inflate: FloatProperty(name="Inflate", default=0.0, min=0.0, precision=4, unit="LENGTH",
description="Gonflement le long des normales avant projection (0 = automatique : 2 voxels)")
levels: IntProperty(name="Multires Levels", default=2, min=0, max=5)
limit: FloatProperty(name="Limit", default=0.0, min=0.0, precision=4, unit="LENGTH",
description="Distance maximale de projection (0 = automatique : 4 × gonflement)")
fix_missed: BoolProperty(name="Repair Missed Rays", default=True,
description="Au Freeze : reconstruit les sommets dont la projection a raté (aiguilles, creux)")
despike_after: BoolProperty(name="Despike After", default=False,
description="Slice Wrap (un clic) : lance aussi le Despike en fin de chaîne")
# Despike
despike_distance: FloatProperty(name="Distance", default=0.0, min=0.0, precision=5, unit="LENGTH",
description="Écart minimal à la moyenne des voisins pour être une pointe "
"(0 = automatique : longueur moyenne d'arête)")
despike_strength: FloatProperty(name="Strength", default=1.0, min=0.0, max=1.0, subtype="FACTOR",
description="Part du chemin vers la moyenne des voisins (1 = entièrement)")
despike_iterations: IntProperty(name="Iterations", default=3, min=1, max=20)
despike_ratio: FloatProperty(name="Isolation", default=3.0, min=1.0, max=10.0,
description="Une pointe doit dévier N fois plus que ses voisins (protège le relief)")
despike_protect: BoolProperty(name="Protect Detail", default=False,
description="Atténue le Despike là où la surface est riche en détails "
"(analyse de forme recalculée à la volée)")
# Analyze
analyze_rings: IntProperty(name="Neighborhood", default=8, min=1, max=40,
description="Taille du voisinage analysé, en anneaux d'arêtes (au niveau évalué)")
analyze_flat: FloatProperty(name="Flat Below", default=0.15, min=0.0, max=1.0, subtype="FACTOR",
description="Sous ce niveau de détail, la zone est classée « Flat »")
analyze_aniso: FloatProperty(name="Directional Above", default=0.55, min=0.0, max=1.0, subtype="FACTOR",
description="Au-dessus de cette anisotropie, la forme tourne dans une seule direction "
"(Ridge / Fold) ; en dessous, dans toutes (Bulge / Hollow / Saddle)")
def _pitch(settings, src):
if settings.pitch_mode == "ABSOLUTE":
return settings.pitch_abs
return max(src.dimensions) * settings.pitch_rel / 100.0
def _select_only(obj):
for o in bpy.context.selected_objects:
o.select_set(False)
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
def _remove_object(obj):
me = obj.data
bpy.data.objects.remove(obj)
if me is not None and me.users == 0:
bpy.data.meshes.remove(me)
# ============================================================================= étapes (générateurs)
# Chaque étape est un générateur (voir core) : elle produit (fraction, texte) et renvoie son message de fin.
# Elles lisent `bpy.context` à chaque usage : le contexte reçu par un opérateur n'est plus valide aux ticks suivants.
def _result_collection():
"""Collection de destination : existante (rien n'est modifié) ou créée et liée à la scène."""
scene = bpy.context.scene
s = scene.slice_wrap
name = s.collection_name.strip()
if not s.use_collection or not name:
return None
col = bpy.data.collections.get(name)
if col is None:
col = bpy.data.collections.new(name)
if col.name not in scene.collection.children and scene.user_of_id(col) == 0:
scene.collection.children.link(col) # existante mais hors de la scène : on la rend visible
return col
def step_envelope(src, replace=None):
"""Nouvelle enveloppe (nom = source + suffixe, numéroté si besoin) ou recalcul sur place de `replace`.
Renvoie (objet, message)."""
s = bpy.context.scene.slice_wrap
axes = tuple(i for i, on in enumerate((s.slice_x, s.slice_y, s.slice_z)) if on)
pitch = _pitch(s, src)
msgs = []
obj = yield from core.build_envelope(src, pitch, s.close_iter, axes, s.open_iter, s.smooth_iter, True,
f"{src.name}{s.name_suffix}", msgs.append,
collection=None if replace is not None else _result_collection,
replace=replace)
obj.slice_wrap_source = src
obj["slice_wrap_pitch"] = pitch
return obj, f"{msgs[-1]} — voxel {pitch:.5f}"
def _inflate_mesh(me, dist):
import numpy as np
n = len(me.vertices)
co = np.empty(n * 3)
nr = np.empty(n * 3)
me.vertices.foreach_get("co", co)
me.vertex_normals.foreach_get("vector", nr)
me.vertices.foreach_set("co", co + nr * dist)
me.update()
def step_wrap(obj, target):
s = bpy.context.scene.slice_wrap
if obj.modifiers:
raise RuntimeError("L'enveloppe a déjà des modificateurs : appliquez-les ou supprimez-les d'abord")
pitch = obj.get("slice_wrap_pitch") or max(obj.dimensions) * 0.006
inflate = s.inflate if s.inflate > 0 else 2.0 * pitch
limit = s.limit if s.limit > 0 else 4.0 * inflate
yield 0.0, "Wrap Setup : gonflement…"
_inflate_mesh(obj.data, inflate)
obj["slice_wrap_inflate"] = inflate
mr = obj.modifiers.new("Multires", "MULTIRES")
for i in range(s.levels):
yield i / s.levels, f"Multires : subdivision {i + 1}/{s.levels}…"
with bpy.context.temp_override(object=obj, active_object=obj):
bpy.ops.object.multires_subdivide(modifier=mr.name, mode="CATMULL_CLARK")
sw = obj.modifiers.new("SliceWrap_Shrinkwrap", "SHRINKWRAP")
sw.target = target
sw.wrap_method = "PROJECT"
sw.use_negative_direction = True
sw.use_positive_direction = False
sw.use_project_x = sw.use_project_y = sw.use_project_z = False
sw.subsurf_levels = 0
sw.cull_face = "OFF"
sw.offset = 0.0
sw.project_limit = limit
return f"Gonflement {inflate:.4f}, Multires {s.levels}, limite {limit:.4f}"
def _reshape_from_evaluated(obj, mesh_fn=None):
"""Fige la forme évaluée de `obj` dans les niveaux de son Multires (copie temporaire + Multires Reshape),
puis retire les modificateurs placés après le Multires. `mesh_fn(mesh)` (générateur) peut modifier la copie
avant ; son résultat est renvoyé. L'objet n'est touché qu'à la fin (annulation possible jusque-là)."""
mr = next(m for m in obj.modifiers if m.type == "MULTIRES")
yield 0.0, "Évaluation de la forme (modificateurs)…"
dg = bpy.context.evaluated_depsgraph_get()
tmp_me = bpy.data.meshes.new_from_object(obj.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg)
tmp = None
try:
result = (yield from core.sub(mesh_fn(tmp_me), 0.05, 0.9)) if mesh_fn else None
yield 0.9, "Multires Reshape…"
tmp = bpy.data.objects.new(obj.name + "_tmp", tmp_me)
tmp.matrix_world = obj.matrix_world
bpy.context.scene.collection.objects.link(tmp)
for o in bpy.context.selected_objects:
o.select_set(False)
tmp.select_set(True)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
after = False
for m in list(obj.modifiers): # le Reshape lit la forme SANS ce qui suit le Multires
if m == mr:
after = True
elif after:
obj.modifiers.remove(m)
with bpy.context.temp_override(object=obj, active_object=obj, selected_editable_objects=[obj, tmp],
selected_objects=[obj, tmp]):
bpy.ops.object.multires_reshape(modifier=mr.name)
finally: # fin normale, erreur ou annulation : la copie disparaît
if tmp is not None:
bpy.data.objects.remove(tmp)
bpy.data.meshes.remove(tmp_me)
obj.select_set(True)
return result
def _freeze_problem(obj):
mods = [m.type for m in obj.modifiers]
if "MULTIRES" not in mods or mods.index("MULTIRES") == len(mods) - 1:
return "Rien à figer : il faut un Multires suivi d'un Shrinkwrap"
return None
def step_freeze(obj):
problem = _freeze_problem(obj)
if problem:
raise RuntimeError(problem)
fixed = None
sw = next((m for m in obj.modifiers if m.type == "SHRINKWRAP"), None)
if bpy.context.scene.slice_wrap.fix_missed and sw is not None:
import numpy as np
yield 0.0, "Freeze : forme avant projection…"
sw.show_viewport = False
try:
dg = bpy.context.evaluated_depsgraph_get()
tmp = bpy.data.meshes.new_from_object(obj.evaluated_get(dg), depsgraph=dg)
before = np.empty(len(tmp.vertices) * 3)
tmp.vertices.foreach_get("co", before)
bpy.data.meshes.remove(tmp)
finally:
sw.show_viewport = True
inflate = obj.get("slice_wrap_inflate") or (obj.get("slice_wrap_pitch") or 0.006) * 2.0
def repair(me):
if len(me.vertices) * 3 != len(before):
return None
yield 0.0, "Réparation des projections ratées…"
return (yield from core.fix_missed_rays(me, before.reshape(-1, 3), inflate))
fixed = yield from core.sub(_reshape_from_evaluated(obj, repair), 0.1, 1.0)
else:
yield from _reshape_from_evaluated(obj)
return "Projection figée" + (f", {fixed} raté(s) réparé(s)" if fixed is not None else "")
def _despike_problem(obj):
"""Message d'erreur si le Despike ne peut pas s'exécuter sur `obj`, sinon None."""
if obj.slice_wrap_despike_groups and not any(it.group in obj.vertex_groups for it in obj.slice_wrap_despike_groups):
# la liste n'est pas vide mais aucun groupe n'existe : ne surtout pas traiter toute la surface
return ("Aucun groupe de la liste n'existe sur cet objet : corrigez la liste "
"(ou videz-la pour traiter toute la surface)")
if obj.modifiers and not any(m.type == "MULTIRES" for m in obj.modifiers):
return "Appliquez d'abord les modificateurs (ou Freeze s'il y a un Multires)"
return None
def step_despike(obj):
s = bpy.context.scene.slice_wrap
problem = _despike_problem(obj)
if problem:
raise RuntimeError(problem)
names = [it.group for it in obj.slice_wrap_despike_groups if it.group in obj.vertex_groups]
missing = [it.group for it in obj.slice_wrap_despike_groups if it.group and it.group not in obj.vertex_groups]
idx = [obj.vertex_groups[n].index for n in names]
def clean(me):
yield 0.0, "Despike : préparation…"
thr = s.despike_distance if s.despike_distance > 0 else core.mean_edge_length(me)
w = yield from core.sub(core.group_weights(me, idx), 0.0, 0.2)
if s.despike_protect:
yield 0.2, "Despike : analyse du détail (Protect Detail)…"
co, nr, e = core.mesh_arrays(me)
det = (yield from core.sub(core.analyze_shape(co, nr, e, s.analyze_rings), 0.2, 0.6))["detail"]
w = (1.0 - det) if w is None else w * (1.0 - det)
yield 0.6, "Despike : correction des pointes…"
n = yield from core.sub(core.despike(me, thr, s.despike_iterations, s.despike_ratio, s.despike_strength, w),
0.6, 1.0)
return n, thr
if any(m.type == "MULTIRES" for m in obj.modifiers):
n, thr = yield from _reshape_from_evaluated(obj, clean)
else:
n, thr = yield from clean(obj.data)
zone = ", ".join(names) if names else "toute la surface"
msg = f"Despike ({zone}) : {n} sommet(s) corrigé(s), seuil {thr:.5f}"
if missing:
msg += f" — groupe(s) introuvable(s) ignoré(s) : {', '.join(missing)}"
return msg
def step_analyze(obj):
"""Analyse la forme évaluée (Multires compris) et écrit le résultat sur le maillage de base."""
s = bpy.context.scene.slice_wrap
yield 0.0, "Analyse : évaluation de la forme…"
dg = bpy.context.evaluated_depsgraph_get()
me = bpy.data.meshes.new_from_object(obj.evaluated_get(dg), depsgraph=dg)
try:
co, nr, e = core.mesh_arrays(me)
yield 0.05, "Analyse de forme…"
res = yield from core.sub(core.analyze_shape(co, nr, e, s.analyze_rings, s.analyze_flat, s.analyze_aniso),
0.05, 0.9)
finally:
bpy.data.meshes.remove(me)
yield 0.9, "Écriture des attributs et groupes…"
counts = core.write_analysis(obj, res, len(obj.data.vertices))
tot = max(sum(counts.values()), 1)
return "Analyse : " + ", ".join(f"{k} {100 * v / tot:.0f} %" for k, v in counts.items())
def step_run(src):
"""Chaîne complète : Envelope → Wrap Setup (sur la source) → Freeze (→ Despike). Renvoie (enveloppe, messages).
Erreur ou annulation : l'enveloppe en cours est supprimée, la scène revient à son état de départ."""
s = bpy.context.scene.slice_wrap
env = None
msgs = []
end_freeze = 0.93 if s.despike_after else 1.0
try:
env, m = yield from core.sub(step_envelope(src), 0.0, 0.25)
msgs.append(m)
_select_only(env)
msgs.append((yield from core.sub(step_wrap(env, src), 0.25, 0.35)))
msgs.append((yield from core.sub(step_freeze(env), 0.35, end_freeze)))
if s.despike_after:
msgs.append((yield from core.sub(step_despike(env), end_freeze, 1.0)))
except BaseException:
if env is not None:
_remove_object(env)
_select_only(src)
raise
return env, msgs
# ============================================================================= opérateurs
# Calcul long en cours (un seul à la fois) : lu par la barre d'état et le panneau.
_job = {"title": "", "text": "", "f": 0.0, "t0": 0.0}
def _busy():
return bool(_job["title"])
def _fmt_time(sec):
sec = int(sec)
return f"{sec // 60} min {sec % 60:02d} s" if sec >= 60 else f"{sec} s"
def _draw_progress(layout, compact=False):
f = _job["f"]
el = _fmt_time(time.time() - _job["t0"])
if compact: # barre d'état : une ligne
row = layout.row(align=True)
row.label(text=f"{_job['title']} — {el}", icon="TIME")
bar = row.row()
bar.ui_units_x = 18
bar.progress(factor=f, type="BAR", text=f"{100 * f:.0f} % — {_job['text']}")
row.label(text="Échap : annuler", icon="EVENT_ESC")
return
col = layout.column(align=True)
col.label(text=f"{_job['title']} en cours — {el}", icon="TIME")
col.progress(factor=f, type="BAR", text=f"{100 * f:.0f} %")
col.label(text=_job["text"])
col.label(text="Échap : annuler (rien n'est modifié)", icon="EVENT_ESC")
def _draw_status(header, _context):
_draw_progress(header.layout, compact=True)
def _refresh_ui(context):
if _busy():
context.workspace.status_text_set(_draw_status) # redessine aussi la barre d'état
else:
context.workspace.status_text_set(None)
for win in context.window_manager.windows:
for area in win.screen.areas:
if area.type == "VIEW_3D":
for region in area.regions:
if region.type == "UI":
region.tag_redraw()
def _obj_mode_mesh(context):
"""L'objet traité est l'objet actif, qui doit être sélectionné (pas de cible à choisir ailleurs)."""
o = context.active_object
return (o is not None and o.type == "MESH" and context.mode == "OBJECT" and o.select_get()
and not _busy())
class _LongOperator:
"""Opérateur long. Depuis l'interface : confirmation (désactivable), puis calcul par tranches dans un modal —
Blender reste affiché, barre de progression (barre d'état + panneau), clics et touches ignorés, Échap annule.
Depuis un script (bpy.ops sans INVOKE) : exécution d'une traite, comme avant.
Sous-classes : `job(context)` → générateur (voir core) ; `done(context, résultat)` → {'FINISHED'}."""
bl_options = {"UNDO"} # pas de REGISTER : un « Adjust Last Operation » relancerait un modal
confirm = () # lignes du message de confirmation (vide = pas de confirmation)
modal_run: BoolProperty(options={"HIDDEN", "SKIP_SAVE"})
@classmethod
def poll(cls, context):
return _obj_mode_mesh(context)
def precheck(self, context):
"""Message d'erreur si l'opération ne peut pas démarrer (vérifié avant la confirmation)."""
return None
def job(self, context):
raise NotImplementedError
def done(self, context, result):
self.report({"INFO"}, result)
return {"FINISHED"}
def invoke(self, context, event):
problem = self.precheck(context)
if problem:
self.report({"ERROR"}, problem)
return {"CANCELLED"}
self.modal_run = True
prefs = _prefs(context)
if self.confirm and (prefs is None or prefs.confirm_long):
return context.window_manager.invoke_props_dialog(self, width=440, title=self.bl_label,
confirm_text="Start")
return self.execute(context)
def draw(self, context): # contenu de la boîte de confirmation
o = context.active_object
col = self.layout.column(align=True)
info = f"« {o.name} » : {len(o.data.polygons):,} faces".replace(",", " ")
mr = next((m for m in o.modifiers if m.type == "MULTIRES"), None)
if mr is not None:
info += f" + Multires niveau {mr.total_levels}"
col.label(text=info, icon="MESH_DATA")
col.separator()
for line in self.confirm:
col.label(text=line)
col.separator()
col.label(text="Pendant le calcul, Blender ignore clics et touches : c'est normal,", icon="INFO")
col.label(text="il n'est pas planté — ne le fermez pas. La progression s'affiche en bas", icon="BLANK1")
col.label(text="de la fenêtre (barre d'état) et dans ce panneau.", icon="BLANK1")
col.label(text="Échap : annuler à tout moment (rien n'est modifié).", icon="EVENT_ESC")
prefs = _prefs(context)
if prefs is not None:
col.separator()
col.prop(prefs, "confirm_long", text="Show this message next time")
def execute(self, context):
if _busy():
self.report({"WARNING"}, "Un calcul Slice Wrap est déjà en cours")
return {"CANCELLED"}
problem = self.precheck(context)
if problem:
self.report({"ERROR"}, problem)
return {"CANCELLED"}
self._t0 = time.time()
self._gen = self.job(context)
if self.modal_run and context.window is not None:
_job.update(title=self.bl_label, text="Démarrage…", f=0.0, t0=self._t0)
wm = context.window_manager
self._timer = wm.event_timer_add(0.02, window=context.window)
wm.modal_handler_add(self)
context.window.cursor_modal_set("WAIT")
_refresh_ui(context)
return {"RUNNING_MODAL"}
try:
result = core.run(self._gen)
except RuntimeError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
return self.done(context, result)
def _end(self, context):
context.window_manager.event_timer_remove(self._timer)
context.window.cursor_modal_restore()
_job.update(title="", text="", f=0.0)
_refresh_ui(context)
def modal(self, context, event):
if event.type == "ESC" and event.value == "PRESS":
self._gen.close() # chaque étape nettoie ce qu'elle a commencé
self._end(context)
self.report({"WARNING"}, f"{self.bl_label} annulé — rien n'a été modifié")
return {"CANCELLED"}
if event.type != "TIMER":
return {"RUNNING_MODAL"} # clics et touches ignorés pendant le calcul
t0 = time.perf_counter()
try:
while time.perf_counter() - t0 < 0.15: # tranche de calcul, puis Blender redessine
f, text = next(self._gen)
_job["f"] = max(_job["f"], f)
if text and text != _job["text"]:
_job["text"] = text
break # afficher la nouvelle étape avant un calcul Blender d'un seul tenant
except StopIteration as e:
self._end(context)
return self.done(context, e.value)
except Exception as e:
self._end(context)
if not isinstance(e, RuntimeError):
traceback.print_exc()
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
_refresh_ui(context)
return {"RUNNING_MODAL"}
_LONG = "Le calcul peut prendre plusieurs minutes sur un maillage lourd (scan, IA)."
class SLICEWRAP_OT_run(_LongOperator, bpy.types.Operator):
"""Chaîne complète sur l'objet sélectionné : Envelope → Wrap Setup → Freeze (→ Despike si coché)"""
bl_idname = "slice_wrap.run"
bl_label = "Slice Wrap"
confirm = ("Enveloppe, projection (Multires + Shrinkwrap) puis Freeze.", _LONG)
def job(self, context):
self._src = context.active_object
return step_run(self._src)
def done(self, context, result):
env, msgs = result
if context.scene.slice_wrap.hide_source:
self._src.hide_set(True)
_select_only(env)
for m in msgs:
print("[Slice Wrap]", m)
self.report({"INFO"}, f"Slice Wrap terminé en {_fmt_time(time.time() - self._t0)} — "
+ " — ".join(msgs[2:]))
return {"FINISHED"}
class SLICEWRAP_OT_envelope(_LongOperator, bpy.types.Operator):
"""Crée une enveloppe fermée et lisse de l'objet sélectionné (la source n'est pas modifiée)"""
bl_idname = "slice_wrap.envelope"
bl_label = "Envelope"
confirm = ("Calcul de l'enveloppe fermée (voxels, remplissage par tranches).", _LONG)
def job(self, context):
self._src = context.active_object
return step_envelope(self._src)
def done(self, context, result):
obj, msg = result
if context.scene.slice_wrap.hide_source:
self._src.hide_set(True)
_select_only(obj)
self.report({"INFO"}, msg)
return {"FINISHED"}
class SLICEWRAP_OT_regenerate(_LongOperator, bpy.types.Operator):
"""Recalcule SUR PLACE l'enveloppe sélectionnée depuis sa source, avec les réglages actuels (même objet, même nom)"""
bl_idname = "slice_wrap.regenerate"
bl_label = "Regenerate"
confirm = ("Recalcul de l'enveloppe depuis sa source, sur place.", _LONG)
@classmethod
def poll(cls, context):
return _obj_mode_mesh(context) and context.active_object.slice_wrap_source is not None
def precheck(self, context):
if any(m.type in {"MULTIRES", "SHRINKWRAP"} for m in context.active_object.modifiers):
return "Enveloppe déjà wrappée : Regenerate effacerait le travail (dupliquez-la d'abord)"
return None
def job(self, context):
obj = context.active_object
return step_envelope(obj.slice_wrap_source, replace=obj)
def done(self, context, result):
self.report({"INFO"}, result[1])
return {"FINISHED"}
class SLICEWRAP_OT_wrap_setup(bpy.types.Operator):
"""Gonfle l'enveloppe sélectionnée puis ajoute Multires + Shrinkwrap (Project vers l'intérieur) sur sa source"""
bl_idname = "slice_wrap.wrap_setup"
bl_label = "Wrap Setup"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _obj_mode_mesh(context):
return False
o = context.active_object
return o.slice_wrap_source is not None and o.slice_wrap_source != o
def execute(self, context):
obj = context.active_object
try:
msg = core.run(step_wrap(obj, obj.slice_wrap_source))
except RuntimeError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
self.report({"INFO"}, msg)
return {"FINISHED"}
class SLICEWRAP_OT_freeze(_LongOperator, bpy.types.Operator):
"""Fige la projection dans les niveaux du Multires et retire le Shrinkwrap (prêt à sculpter)"""
bl_idname = "slice_wrap.freeze"
bl_label = "Freeze"
@classmethod
def poll(cls, context):
return _obj_mode_mesh(context) and any(m.type == "MULTIRES" for m in context.active_object.modifiers)
def precheck(self, context):
return _freeze_problem(context.active_object)
def job(self, context):
return step_freeze(context.active_object)
class SLICEWRAP_OT_despike(_LongOperator, bpy.types.Operator):
"""Ramène vers la moyenne de leurs voisins les sommets en pointe de l'objet sélectionné, dans les groupes de la
liste (liste vide = toute la surface), sans lisser le reste"""
bl_idname = "slice_wrap.despike"
bl_label = "Despike"
confirm = ("Correction des pointes (Multires compris).",
"Le calcul peut prendre une à plusieurs minutes sur un maillage dense.")
def precheck(self, context):
return _despike_problem(context.active_object)
def job(self, context):
return step_despike(context.active_object)
class SLICEWRAP_OT_analyze(_LongOperator, bpy.types.Operator):
"""Analyse la forme (densité de détail, courbure, tags Flat/Bulge/Hollow/Ridge/Fold/Saddle, direction des plis)
et l'écrit en attributs, couleurs et groupes de sommets — la géométrie n'est pas modifiée"""
bl_idname = "slice_wrap.analyze"
bl_label = "Analyze Shape"
def job(self, context):
return step_analyze(context.active_object)
class SLICEWRAP_OT_show(bpy.types.Operator):
"""Affiche une carte d'analyse dans la vue (ombrage Solid, couleur par attribut)"""
bl_idname = "slice_wrap.show"
bl_label = "Show Analysis"
mode: EnumProperty(items=[("SW_Detail_Color", "Detail", ""), ("SW_Shape_Color", "Shapes", ""), ("NONE", "Off", "")])
@classmethod
def poll(cls, context):
return context.active_object is not None and context.active_object.type == "MESH"
def execute(self, context):
me = context.active_object.data
sd = context.space_data
shading = sd.shading if sd is not None and sd.type == "VIEW_3D" else None
if self.mode == "NONE":
if shading:
shading.color_type = "MATERIAL"
return {"FINISHED"}
if self.mode not in me.color_attributes:
self.report({"WARNING"}, "Lancez d'abord Analyze Shape")
return {"CANCELLED"}
ca = me.color_attributes
idx = next(i for i, c in enumerate(ca) if c.name == self.mode)
ca.active_color_index = idx # couleur active (édition / vue)
ca.render_color_index = idx # couleur utilisée au rendu
if shading:
shading.type = "SOLID"
shading.color_type = "VERTEX"
return {"FINISHED"}
class SLICEWRAP_OT_group_add(bpy.types.Operator):
"""Ajoute un filtre de groupe de sommets (le groupe actif de l'objet, s'il y en a un)"""
bl_idname = "slice_wrap.group_add"
bl_label = "Add Vertex Group Filter"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.active_object is not None and context.active_object.type == "MESH"
def execute(self, context):
obj = context.active_object
it = obj.slice_wrap_despike_groups.add()
vg = obj.vertex_groups.active
names = {i.group for i in obj.slice_wrap_despike_groups}
if vg is not None and vg.name not in names:
it.group = vg.name
obj.slice_wrap_despike_index = len(obj.slice_wrap_despike_groups) - 1
return {"FINISHED"}
class SLICEWRAP_OT_group_remove(bpy.types.Operator):
"""Retire le filtre sélectionné"""
bl_idname = "slice_wrap.group_remove"
bl_label = "Remove Vertex Group Filter"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
o = context.active_object
return o is not None and o.type == "MESH" and len(o.slice_wrap_despike_groups) > 0
def execute(self, context):
obj = context.active_object
i = obj.slice_wrap_despike_index
if 0 <= i < len(obj.slice_wrap_despike_groups):
obj.slice_wrap_despike_groups.remove(i)
obj.slice_wrap_despike_index = max(0, min(i, len(obj.slice_wrap_despike_groups) - 1))
return {"FINISHED"}
class SLICEWRAP_OT_group_clear(bpy.types.Operator):
"""Vide la liste : le Despike traitera toute la surface"""
bl_idname = "slice_wrap.group_clear"
bl_label = "Clear Vertex Group Filters"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
o = context.active_object
return o is not None and o.type == "MESH" and len(o.slice_wrap_despike_groups) > 0
def execute(self, context):
context.active_object.slice_wrap_despike_groups.clear()
context.active_object.slice_wrap_despike_index = 0
return {"FINISHED"}
# ============================================================================= interface
class SLICEWRAP_UL_groups(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_prop, index):
obj = data
ok = item.group in obj.vertex_groups
row = layout.row(align=True)
row.prop_search(item, "group", obj, "vertex_groups", text="", icon="GROUP_VERTEX" if ok else "ERROR")
class SLICEWRAP_PT_main(bpy.types.Panel):
bl_label = "Slice Wrap"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Slice Wrap"
def draw(self, context):
s = context.scene.slice_wrap
o = context.active_object
lay = self.layout
if _busy():
_draw_progress(lay.box())
col = lay.column()
col.label(text="Envelope", icon="MESH_ICOSPHERE")
col.prop(s, "pitch_mode", expand=True)
col.prop(s, "pitch_rel" if s.pitch_mode == "RELATIVE" else "pitch_abs")
if o is not None and o.type == "MESH" and s.pitch_mode == "RELATIVE":
src = o.slice_wrap_source or o
col.label(text=f"≈ {max(src.dimensions) * s.pitch_rel / 100:.5f} / voxel", icon="INFO")
col.prop(s, "close_iter")
row = col.row(align=True)
row.label(text="Slice Fill")
row.prop(s, "slice_x", toggle=True)
row.prop(s, "slice_y", toggle=True)
row.prop(s, "slice_z", toggle=True)
col.prop(s, "open_iter")
col.prop(s, "smooth_iter")
col.prop(s, "hide_source")
col.separator()
col.label(text="Wrap", icon="MOD_SHRINKWRAP")
col.prop(s, "name_suffix")
row = col.row(align=True)
row.prop(s, "use_collection", text="")
sub = row.row(align=True)
sub.active = s.use_collection
sub.prop(s, "collection_name", text="Collection")
col.prop(s, "inflate")
col.prop(s, "levels")
col.prop(s, "limit")
col.prop(s, "fix_missed")
col.prop(s, "despike_after")
col.separator()
big = col.row()
big.scale_y = 1.6
big.operator(SLICEWRAP_OT_run.bl_idname, icon="PLAY")
if o is not None and o.type == "MESH" and o.select_get():
col.label(text=f"Object : {o.name}", icon="OBJECT_DATA")
if o.slice_wrap_source is not None:
col.label(text=f"Source : {o.slice_wrap_source.name}", icon="LINKED")
elif not _busy():
col.label(text="Sélectionnez un maillage (mode Objet)", icon="ERROR")
class SLICEWRAP_PT_steps(bpy.types.Panel):
bl_label = "Steps"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Slice Wrap"
bl_parent_id = "SLICEWRAP_PT_main"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
col = self.layout.column(align=True)
row = col.row(align=True)
row.operator(SLICEWRAP_OT_envelope.bl_idname, text="1. Envelope", icon="ADD")
row.operator(SLICEWRAP_OT_regenerate.bl_idname, text="", icon="FILE_REFRESH")
col.operator(SLICEWRAP_OT_wrap_setup.bl_idname, text="2. Wrap Setup", icon="MOD_SHRINKWRAP")
col.operator(SLICEWRAP_OT_freeze.bl_idname, text="3. Freeze", icon="FREEZE")
class SLICEWRAP_PT_despike(bpy.types.Panel):
bl_label = "Despike"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Slice Wrap"
bl_parent_id = "SLICEWRAP_PT_main"
def draw(self, context):
s = context.scene.slice_wrap
o = context.active_object
lay = self.layout
col = lay.column()
col.prop(s, "despike_distance")
col.prop(s, "despike_strength")
col.prop(s, "despike_iterations")
col.prop(s, "despike_ratio")
if o is not None and o.type == "MESH":
col.label(text="Vertex Group Filters (vide = toute la surface)")
row = col.row()
row.template_list("SLICEWRAP_UL_groups", "", o, "slice_wrap_despike_groups", o, "slice_wrap_despike_index", rows=3)
side = row.column(align=True)
side.operator(SLICEWRAP_OT_group_add.bl_idname, text="", icon="ADD")
side.operator(SLICEWRAP_OT_group_remove.bl_idname, text="", icon="REMOVE")
side.separator()
side.operator(SLICEWRAP_OT_group_clear.bl_idname, text="", icon="X")
col.prop(s, "despike_protect")
r = col.row()
r.scale_y = 1.3
r.operator(SLICEWRAP_OT_despike.bl_idname, icon="SHARPCURVE")
if o is not None and o.type == "MESH" and o.select_get():
col.label(text=f"Object : {o.name}", icon="OBJECT_DATA")
class SLICEWRAP_PT_analyze(bpy.types.Panel):
bl_label = "Analyze"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Slice Wrap"
bl_parent_id = "SLICEWRAP_PT_main"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
s = context.scene.slice_wrap
col = self.layout.column()
col.prop(s, "analyze_rings")
col.prop(s, "analyze_flat")
col.prop(s, "analyze_aniso")
r = col.row()
r.scale_y = 1.3
r.operator(SLICEWRAP_OT_analyze.bl_idname, icon="VIEWZOOM")
row = col.row(align=True)
row.operator(SLICEWRAP_OT_show.bl_idname, text="Detail", icon="COLORSET_01_VEC").mode = "SW_Detail_Color"
row.operator(SLICEWRAP_OT_show.bl_idname, text="Shapes", icon="COLORSET_03_VEC").mode = "SW_Shape_Color"
row.operator(SLICEWRAP_OT_show.bl_idname, text="", icon="X").mode = "NONE"
box = col.box()
box.label(text="Gris Flat - Rouge Bulge - Bleu Hollow")
box.label(text="Jaune Ridge - Cyan Fold - Violet Saddle")
classes = (SliceWrapPreferences, SliceWrapGroupItem, SliceWrapSettings,
SLICEWRAP_OT_run, SLICEWRAP_OT_envelope, SLICEWRAP_OT_regenerate, SLICEWRAP_OT_wrap_setup,
SLICEWRAP_OT_freeze, SLICEWRAP_OT_despike, SLICEWRAP_OT_analyze, SLICEWRAP_OT_show,
SLICEWRAP_OT_group_add, SLICEWRAP_OT_group_remove, SLICEWRAP_OT_group_clear, SLICEWRAP_UL_groups,
SLICEWRAP_PT_main, SLICEWRAP_PT_steps, SLICEWRAP_PT_despike, SLICEWRAP_PT_analyze)
def register():
for c in classes:
bpy.utils.register_class(c)
bpy.types.Scene.slice_wrap = PointerProperty(type=SliceWrapSettings)
bpy.types.Object.slice_wrap_source = PointerProperty(type=bpy.types.Object, name="Slice Wrap Source")
bpy.types.Object.slice_wrap_despike_groups = CollectionProperty(type=SliceWrapGroupItem)
bpy.types.Object.slice_wrap_despike_index = IntProperty(default=0)
def unregister():
del bpy.types.Object.slice_wrap_despike_index
del bpy.types.Object.slice_wrap_despike_groups
del bpy.types.Object.slice_wrap_source
del bpy.types.Scene.slice_wrap
for c in reversed(classes):
bpy.utils.unregister_class(c)
+29
View File
@@ -0,0 +1,29 @@
schema_version = "1.0.0"
id = "slice_wrap"
version = "1.4.0"
name = "Slice Wrap"
tagline = "Closed envelope of holey meshes, then wrap, freeze and despike"
maintainer = "a-mma"
type = "add-on"
tags = ["Mesh", "Modeling", "Sculpt"]
blender_version_min = "4.2.0"
license = [
"SPDX:GPL-3.0-or-later",
]
[build]
paths_exclude_pattern = [
"__pycache__/",
"/*.zip",
"/.claude/",
"/.git/",
"/.gitignore",
"/CLAUDE.md",
"/tests/",
"/tools/",
"/dist/",
]
+582
View File
@@ -0,0 +1,582 @@
"""Slice Wrap — algorithmes (numpy seul, aucune dépendance externe).
Enveloppe fermée d'un maillage troué / à doubles parois :
1. échantillonnage de la surface → voxels occupés (pas = pitch)
2. fermeture morphologique (dilatation puis érosion, voisinage 6) → bouche les petits trous
3. remplissage : 3D global + tranche par tranche selon X, Y, Z (union). Un trou de la coque ne fait
« fuir » que les tranches qu'il traverse, alors qu'un remplissage 3D seul fuit entièrement.
4. ouverture (retire les filaments)
5. surface en cubes → Voxel Remesh de Blender (le volume est déjà plein : pas de fuite) → plus grande pièce
→ lissage de Taubin
Les calculs longs sont des **générateurs** : ils cèdent la main régulièrement en produisant
(fraction 0–1, texte ou None) et renvoient leur résultat par `return`. L'opérateur modal les fait avancer par
tranches (Blender reste affiché, barre de progression, Échap pour annuler) ; `run` les exécute d'une traite.
"""
import numpy as np
import bpy
from mathutils import Matrix
# ----------------------------------------------------------------------------- progression
def run(gen):
"""Exécute un générateur de calcul jusqu'au bout et renvoie son résultat."""
while True:
try:
next(gen)
except StopIteration as e:
return e.value
def sub(gen, a, b):
"""Relaie la progression de `gen` dans l'intervalle [a, b] du parent ; renvoie le résultat de `gen`.
Usage : `res = yield from sub(enfant(...), 0.2, 0.5)`."""
try:
while True:
try:
f, text = next(gen)
except StopIteration as e:
return e.value
yield a + (b - a) * min(max(f, 0.0), 1.0), text
finally:
gen.close() # annulation : le nettoyage de l'enfant s'exécute tout de suite
# ----------------------------------------------------------------------------- voxels
def _shift(a, axis, step):
"""Décale un tableau booléen de `step` (±1) le long de `axis`, sans rebouclage (bord = False)."""
out = np.zeros_like(a)
src = [slice(None)] * 3
dst = [slice(None)] * 3
if step > 0:
src[axis], dst[axis] = slice(0, -step), slice(step, None)
else:
src[axis], dst[axis] = slice(-step, None), slice(0, step)
out[tuple(dst)] = a[tuple(src)]
return out
def dilate(a, iterations=1, axes=(0, 1, 2)):
for _ in range(iterations):
b = a.copy()
for ax in axes:
b |= _shift(a, ax, 1)
b |= _shift(a, ax, -1)
a = b
return a
def erode(a, iterations=1, axes=(0, 1, 2)):
return ~dilate(~a, iterations, axes)
def _outside(walls, axes):
"""Vide relié au bord de la grille, propagé seulement le long de `axes` (2 axes = tranche par tranche)."""
seed = np.zeros_like(walls)
for ax in axes:
idx = [slice(None)] * 3
idx[ax] = 0
seed[tuple(idx)] = True
idx[ax] = -1
seed[tuple(idx)] = True
free = ~walls
out = seed & free
est = max(walls.shape) * 0.5 # nombre de passes probable (estimation pour la progression)
i = 0
while True:
grown = dilate(out, 1, axes) & free
if np.array_equal(grown, out):
return out
out = grown
i += 1
yield min(i / est, 0.95), None
def fill(walls, slice_axes=(0, 1, 2)):
"""Volume plein : 3D global ∪ remplissage 2D tranche par tranche pour chaque axe demandé (générateur)."""
passes = [(0, 1, 2)] + [tuple(a for a in (0, 1, 2) if a != ax) for ax in slice_axes]
solid = None
for k, axes in enumerate(passes):
out = yield from sub(_outside(walls, axes), k / len(passes), (k + 1) / len(passes))
solid = ~out if solid is None else solid | ~out
return solid
# ----------------------------------------------------------------------------- maillage ↔ voxels
def mesh_world_arrays(obj, depsgraph):
"""Sommets (monde) et triangles du maillage évalué."""
ev = obj.evaluated_get(depsgraph)
me = ev.to_mesh()
me.calc_loop_triangles()
nv, nt = len(me.vertices), len(me.loop_triangles)
co = np.empty(nv * 3, np.float64)
me.vertices.foreach_get("co", co)
tri = np.empty(nt * 3, np.int64)
me.loop_triangles.foreach_get("vertices", tri)
ev.to_mesh_clear()
M = np.array(obj.matrix_world)
co = co.reshape(-1, 3) @ M[:3, :3].T + M[:3, 3]
return co, tri.reshape(-1, 3)
def sample_surface(co, tri, pitch, max_points=40_000_000):
"""Sommets + points répartis sur les triangles plus grands qu'un demi-voxel, pour que la coque voxel soit
continue. Sur un maillage déjà dense (scan, IA), seuls les sommets servent : rien n'est dupliqué."""
e0 = np.linalg.norm(co[tri[:, 1]] - co[tri[:, 0]], axis=1)
e1 = np.linalg.norm(co[tri[:, 2]] - co[tri[:, 1]], axis=1)
e2 = np.linalg.norm(co[tri[:, 0]] - co[tri[:, 2]], axis=1)
k = np.clip(np.ceil(np.maximum(e0, np.maximum(e1, e2)) / (0.5 * pitch)).astype(np.int64), 1, 64)
del e0, e1, e2
big = k >= 2 # k = 1 : les 3 points seraient les sommets eux-mêmes
n = ((k[big] + 1) * (k[big] + 2) // 2).sum() + len(co)
if n > max_points:
raise RuntimeError(f"{n:,} points d'échantillonnage : augmentez la taille de voxel")
pts = [co]
tb, kb = tri[big], k[big]
for kk in np.unique(kb):
t = tb[kb == kk]
a, b, c = co[t[:, 0]], co[t[:, 1]], co[t[:, 2]]
i, j = np.meshgrid(np.arange(kk + 1), np.arange(kk + 1), indexing="ij")
m = (i + j) <= kk
u, v = (i[m] / kk)[None, :, None], (j[m] / kk)[None, :, None]
pts.append((a[:, None] + u * (b - a)[:, None] + v * (c - a)[:, None]).reshape(-1, 3))
return np.vstack(pts)
def voxelize(points, pitch, pad):
lo = points.min(0) - pad * pitch
idx = np.floor((points - lo) / pitch).astype(np.int64)
shape = tuple(idx.max(0) + pad + 1)
occ = np.zeros(shape, bool)
occ[idx[:, 0], idx[:, 1], idx[:, 2]] = True
return occ, lo
def blocky_mesh(solid, lo, pitch, name):
"""Surface en cubes du volume plein (faces entre voxel plein et vide), sommets partagés."""
s = np.pad(solid, 1)
lo = lo - pitch
quads = []
corner = { # pour chaque axe : 4 coins de la face (dans le plan des 2 autres axes)
0: [(0, 0, 0), (0, 1, 0), (0, 1, 1), (0, 0, 1)],
1: [(0, 0, 0), (0, 0, 1), (1, 0, 1), (1, 0, 0)],
2: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
}
for ax in (0, 1, 2):
a = s
b = np.roll(s, -1, axis=ax)
for flip, mask in ((False, a & ~b), (True, ~a & b)):
p = np.argwhere(mask)
if not len(p):
continue
p[:, ax] += 1 # la face est sur le plan i+1
cs = corner[ax][::-1] if flip else corner[ax]
quads.append(np.stack([p + np.array(c) for c in cs], axis=1))
q = np.concatenate(quads) # (F, 4, 3) coins en indices de grille
flat = q.reshape(-1, 3)
uniq, inv = np.unique(flat, axis=0, return_inverse=True)
verts = uniq * pitch + lo
faces = inv.reshape(-1, 4)
me = bpy.data.meshes.new(name)
me.from_pydata(verts.astype(np.float32), [], faces.astype(np.int32))
me.validate()
return me
# ----------------------------------------------------------------------------- lissage / nettoyage
def _edges(me):
e = np.empty(len(me.edges) * 2, np.int64)
me.edges.foreach_get("vertices", e)
return e.reshape(-1, 2)
def _neighbor_mean(co, e):
acc = np.zeros_like(co)
cnt = np.zeros(len(co))
np.add.at(acc, e[:, 0], co[e[:, 1]])
np.add.at(acc, e[:, 1], co[e[:, 0]])
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
return acc / np.maximum(cnt, 1)[:, None], cnt
def taubin(me, iterations=20, lamb=0.5, mu=-0.53):
"""Lissage de Taubin : efface les marches et le moiré sans faire rétrécir la forme (générateur)."""
if iterations <= 0:
return
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
for i in range(iterations):
for f in (lamb, mu):
m, _ = _neighbor_mean(co, e)
co = co + f * (m - co)
yield (i + 1) / iterations, None
me.vertices.foreach_set("co", co.ravel())
me.update()
def keep_largest_island(me):
"""Supprime les îlots détachés (garde la plus grande pièce du maillage) (générateur)."""
import bmesh
bm = bmesh.new()
try:
bm.from_mesh(me)
nv = max(len(bm.verts), 1)
seen, best = set(), []
last = 0
for v in bm.verts:
if v in seen:
continue
comp, stack = [], [v]
seen.add(v)
while stack:
x = stack.pop()
comp.append(x)
for ed in x.link_edges:
w = ed.other_vert(x)
if w not in seen:
seen.add(w)
stack.append(w)
if len(comp) > len(best):
best = comp
if len(seen) - last >= 50_000:
last = len(seen)
yield 0.9 * len(seen) / nv, None
keep = set(best)
bmesh.ops.delete(bm, geom=[v for v in bm.verts if v not in keep], context="VERTS")
bm.to_mesh(me)
finally:
bm.free()
def despike(me, threshold, iterations=3, ratio=3.0, strength=1.0, weights=None):
"""Filtre anti-pointes (générateur ; le maillage n'est écrit qu'à la fin). Un sommet est une pointe si
- son écart à la moyenne de ses voisins dépasse `threshold`, ET
- cet écart est `ratio` fois plus grand que l'écart moyen de ses voisins (une pointe est isolée ;
dans une zone simplement courbe — oreille, paupière — tous les voisins dévient pareil).
La pointe est ramenée vers la moyenne de ses voisins de `strength` × `weights` (poids 0–1 par sommet,
None = 1 partout) : le reste du relief n'est pas lissé. Renvoie le nombre de corrections."""
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
w = np.full(len(co), float(strength)) if weights is None else np.clip(weights, 0.0, 1.0) * strength
active = w > 0
total = 0
for it in range(iterations):
m, cnt = _neighbor_mean(co, e)
d = np.linalg.norm(co - m, axis=1)
acc = np.zeros(len(co))
np.add.at(acc, e[:, 0], d[e[:, 1]])
np.add.at(acc, e[:, 1], d[e[:, 0]])
dn = acc / np.maximum(cnt, 1)
bad = active & (d > threshold) & (d > ratio * dn) & (cnt > 0)
if not bad.any():
break
co[bad] += (m[bad] - co[bad]) * w[bad, None]
total += int(bad.sum())
yield (it + 1) / iterations, None
me.vertices.foreach_set("co", co.ravel())
me.update()
return total
def group_weights(me, group_indices):
"""Poids max par sommet sur les groupes de sommets donnés (indices) ; None si aucun groupe (générateur)."""
if not group_indices:
return None
gi = set(group_indices)
nv = max(len(me.vertices), 1)
w = np.zeros(len(me.vertices))
for v in me.vertices:
best = 0.0
for g in v.groups:
if g.group in gi and g.weight > best:
best = g.weight
w[v.index] = best
if v.index % 50_000 == 49_999:
yield v.index / nv, None
return w
def fix_missed_rays(me, before, inflate, low=0.3, deep=2.0, max_iter=500):
"""Répare les ratés de projection, connus exactement en comparant chaque sommet avant / après le Shrinkwrap :
- raté « vide » : le sommet n'a presque pas bougé (< low × gonflement) → aiguille vers l'extérieur ;
- raté « profond » : il a bougé plus de `deep` fois le déplacement moyen de ses voisins → creux (paroi interne).
Les sommets ratés sont reconstruits par diffusion depuis leurs voisins réussis. Renvoie le nombre réparé
(générateur ; le maillage n'est écrit qu'à la fin)."""
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
disp = np.linalg.norm(co - before, axis=1)
e = _edges(me)
acc = np.zeros(len(co))
cnt = np.zeros(len(co))
np.add.at(acc, e[:, 0], disp[e[:, 1]])
np.add.at(acc, e[:, 1], disp[e[:, 0]])
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
disp_n = acc / np.maximum(cnt, 1)
bad = (disp < low * inflate) | (disp > deep * np.maximum(disp_n, low * inflate))
nbad = int(bad.sum())
known = ~bad
for _ in range(max_iter):
if known.all():
break
s_acc = np.zeros_like(co)
s_cnt = np.zeros(len(co))
kf = known.astype(float)
np.add.at(s_acc, e[:, 0], co[e[:, 1]] * kf[e[:, 1], None])
np.add.at(s_acc, e[:, 1], co[e[:, 0]] * kf[e[:, 0], None])
np.add.at(s_cnt, e[:, 0], kf[e[:, 1]])
np.add.at(s_cnt, e[:, 1], kf[e[:, 0]])
fill = ~known & (s_cnt > 0)
co[fill] = s_acc[fill] / s_cnt[fill, None]
known |= fill
yield 0.95 * float(known.mean()), None
for _ in range(3): # adoucit la reprise sur les zones reconstruites seulement
m, _c = _neighbor_mean(co, e)
co[bad] = m[bad]
me.vertices.foreach_set("co", co.ravel())
me.update()
return nbad
def mean_edge_length(me):
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
return float(np.linalg.norm(co[e[:, 0]] - co[e[:, 1]], axis=1).mean())
# ----------------------------------------------------------------------------- enveloppe complète
def unique_object_name(base):
"""`base` s'il est libre, sinon base.001, base.002… (comme Blender) : on n'écrase jamais un objet existant."""
if base not in bpy.data.objects:
return base
n = 1
while f"{base}.{n:03d}" in bpy.data.objects:
n += 1
return f"{base}.{n:03d}"
def build_envelope(src, pitch, close_iter=3, slice_axes=(0, 1, 2), open_iter=1, smooth_iter=20,
largest=True, name=None, report=print, collection=None, replace=None):
"""Crée l'objet enveloppe de `src` sous un nom libre dérivé de `name` (numérotation .001… si besoin), rangé dans
`collection` (ou la fonction qui la renvoie ; None = collections de la source). `replace` = objet existant à recalculer sur place
(Regenerate) : seul cas où un maillage existant est remplacé. Renvoie l'objet (générateur).
La scène n'est modifiée qu'à la toute fin : une annulation en cours de route ne laisse aucun demi-résultat."""
import time
t0 = time.time()
yield 0.0, "Lecture du maillage source…"
dg = bpy.context.evaluated_depsgraph_get()
co, tri = mesh_world_arrays(src, dg)
yield 0.03, "Échantillonnage de la surface…"
pts = sample_surface(co, tri, pitch)
del co, tri
pad = close_iter + 3
yield 0.06, "Voxelisation…"
occ, lo = voxelize(pts, pitch, pad)
del pts
report(f"Grille {occ.shape} — {int(occ.sum()):,} voxels de surface")
yield 0.08, "Fermeture des trous…"
walls = erode(dilate(occ, close_iter), close_iter) if close_iter > 0 else occ
del occ
yield 0.1, "Remplissage tranche par tranche…"
solid = yield from sub(fill(walls, slice_axes), 0.1, 0.6)
del walls
if open_iter > 0:
yield 0.6, "Retrait des filaments…"
solid = dilate(erode(solid, open_iter), open_iter)
report(f"Volume plein : {int(solid.sum()):,} voxels ({time.time() - t0:.1f} s)")
if replace is not None:
name = replace.name
else:
name = unique_object_name(name or f"{src.name}_slicewrapped")
yield 0.62, "Surface du volume…"
me = blocky_mesh(solid, lo, pitch, name)
del solid
new_me = tmp = None
try:
# Voxel Remesh de Blender sur un volume DÉJÀ plein et fermé → aucune fuite possible.
# Objet temporaire : le Remesh est évalué hors de l'objet final.
yield 0.66, "Voxel Remesh (Blender)…"
tmp = bpy.data.objects.new(name + "_sw_tmp", me)
bpy.context.scene.collection.objects.link(tmp)
mod = tmp.modifiers.new("SliceWrap_Remesh", "REMESH")
mod.mode = "VOXEL"
mod.voxel_size = pitch
mod.adaptivity = 0.0
dg = bpy.context.evaluated_depsgraph_get()
new_me = bpy.data.meshes.new_from_object(tmp.evaluated_get(dg), depsgraph=dg)
bpy.data.objects.remove(tmp)
tmp = None
bpy.data.meshes.remove(me)
me = None
if largest:
yield 0.75, "Plus grande pièce…"
yield from sub(keep_largest_island(new_me), 0.75, 0.85)
yield 0.85, "Lissage…"
yield from sub(taubin(new_me, smooth_iter), 0.85, 1.0)
new_me.polygons.foreach_set("use_smooth", [True] * len(new_me.polygons))
new_me.update()
except BaseException: # erreur ou annulation : on retire tout ce qui a été créé
if tmp is not None:
bpy.data.objects.remove(tmp)
for m in (me, new_me):
if m is not None:
bpy.data.meshes.remove(m)
raise
if replace is not None:
obj = replace
old_me = obj.data
obj.data = new_me
if old_me.users == 0:
bpy.data.meshes.remove(old_me)
else:
obj = bpy.data.objects.new(name, new_me)
if callable(collection): # collection créée seulement à la fin (rien de laissé si annulation)
collection = collection()
for c in ([collection] if collection is not None else src.users_collection):
c.objects.link(obj)
new_me.name = name
obj.matrix_world = Matrix.Identity(4)
report(f"Enveloppe {obj.name} : {len(obj.data.polygons):,} faces ({time.time() - t0:.1f} s)")
return obj
# ----------------------------------------------------------------------------- analyse de forme
SHAPES = ("Flat", "Bulge", "Hollow", "Ridge", "Fold", "Saddle")
SHAPE_COLORS = np.array([ # couleurs d'affichage des tags (linéaire)
(0.55, 0.55, 0.55), # Flat gris
(0.90, 0.25, 0.15), # Bulge rouge (dôme convexe)
(0.15, 0.35, 0.90), # Hollow bleu (cuvette concave)
(0.95, 0.75, 0.10), # Ridge jaune (crête convexe, une direction)
(0.10, 0.75, 0.85), # Fold cyan (pli / vallée concave, une direction)
(0.70, 0.25, 0.85), # Saddle violet (selle)
])
def _diffuse(vals, e, cnt, iterations):
"""Moyenne sur le voisinage (k anneaux) d'un champ par sommet : (V, ...) → même forme (générateur)."""
flat = vals.reshape(len(vals), -1)
for i in range(iterations):
acc = flat.copy()
np.add.at(acc, e[:, 0], flat[e[:, 1]])
np.add.at(acc, e[:, 1], flat[e[:, 0]])
flat = acc / (cnt + 1.0)[:, None]
yield (i + 1) / max(iterations, 1), None
return flat.reshape(vals.shape)
def analyze_shape(co, normals, e, rings=4, flat_threshold=0.15, aniso_threshold=0.55):
"""Analyse locale de la forme sur un voisinage de `rings` anneaux.
Tenseur de covariance des normales T = moyenne(n nᵀ) ; valeurs propres λ1 ≥ λ2 ≥ λ3 :
- variation = λ2 + λ3 (0 = plat) → `detail` 0–1 (normalisé au 95e centile)
- anisotropie = (λ2 − λ3) / (λ2 + λ3) → ~1 : la surface tourne dans UNE direction (pli, crête, doigt)
- direction du pli = vecteur propre de λ3 → direction le long de laquelle la surface ne tourne pas
Courbure moyenne signée H ≈ −(moyenne des voisins − p)·n / arête² (> 0 convexe, < 0 concave).
Tags : Flat, Bulge (dôme), Hollow (cuvette), Ridge (crête), Fold (pli), Saddle (selle).
Renvoie dict(detail, curvature, anisotropy, shape, flow) (générateur)."""
n = normals / np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12)
cnt = np.zeros(len(co))
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
T = n[:, :, None] * n[:, None, :]
T = yield from sub(_diffuse(T, e, cnt, rings), 0.0, 0.7)
w, v = np.linalg.eigh(T) # valeurs propres croissantes : w[:,0] = λ3
lam3, lam2 = np.clip(w[:, 0], 0, None), np.clip(w[:, 1], 0, None)
var = lam2 + lam3
aniso = (lam2 - lam3) / np.maximum(var, 1e-12)
flow = v[:, :, 0] # vecteur propre de λ3
m, _ = _neighbor_mean(co, e)
el = np.linalg.norm(co[e[:, 0]] - co[e[:, 1]], axis=1).mean()
H = -np.einsum("ij,ij->i", m - co, n) / max(el * el, 1e-12)
H = yield from sub(_diffuse(H, e, cnt, rings), 0.75, 1.0)
scale = max(np.percentile(var, 95), 1e-12)
detail = np.clip(var / scale, 0, 1)
hs = max(np.percentile(np.abs(H), 95), 1e-12)
curv = np.clip(H / hs, -1, 1)
shape = np.zeros(len(co), np.int32)
active = detail >= flat_threshold
iso = aniso < aniso_threshold
shape[active & iso & (curv > 0.15)] = 1
shape[active & iso & (curv < -0.15)] = 2
shape[active & ~iso & (curv >= 0)] = 3
shape[active & ~iso & (curv < 0)] = 4
shape[active & iso & (np.abs(curv) <= 0.15)] = 5
return {"detail": detail, "curvature": curv, "anisotropy": aniso, "shape": shape, "flow": flow}
def mesh_arrays(me):
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
nr = np.empty(len(me.vertices) * 3)
me.vertex_normals.foreach_get("vector", nr)
return co.reshape(-1, 3), nr.reshape(-1, 3), _edges(me)
def write_analysis(obj, res, count):
"""Écrit l'analyse sur les `count` premiers sommets du maillage de base de `obj` (les sommets d'origine
viennent en premier dans un maillage subdivisé) : attributs, couleurs d'affichage, groupes de sommets.
Les couleurs portent le suffixe _Color : groupes de sommets et attributs partagent le même espace de noms."""
me = obj.data
nb = len(me.vertices)
k = min(nb, count)
def put(name, dtype, vals):
if name in me.attributes:
me.attributes.remove(me.attributes[name])
a = me.attributes.new(name, dtype, "POINT")
full = np.zeros((nb,) + vals.shape[1:], vals.dtype)
full[:k] = vals[:k]
a.data.foreach_set("value" if dtype != "FLOAT_VECTOR" else "vector", full.ravel())
put("sw_detail", "FLOAT", res["detail"].astype(np.float32))
put("sw_curvature", "FLOAT", res["curvature"].astype(np.float32))
put("sw_anisotropy", "FLOAT", res["anisotropy"].astype(np.float32))
put("sw_shape", "INT", res["shape"].astype(np.int32))
put("sw_flow", "FLOAT_VECTOR", res["flow"].astype(np.float32))
def color(name, rgb):
if name in me.color_attributes:
me.color_attributes.remove(me.color_attributes[name])
ca = me.color_attributes.new(name, "FLOAT_COLOR", "POINT")
full = np.ones((nb, 4), np.float32)
full[:k, :3] = rgb[:k]
ca.data.foreach_set("color", full.ravel())
return ca
d = res["detail"][:, None]
ramp = np.where(d < 0.5, (1 - 2 * d) * np.array([0.05, 0.15, 0.6]) + 2 * d * np.array([0.1, 0.8, 0.2]),
(2 - 2 * d) * np.array([0.1, 0.8, 0.2]) + (2 * d - 1) * np.array([0.9, 0.1, 0.05]))
color("SW_Detail_Color", ramp.astype(np.float32))
color("SW_Shape_Color", SHAPE_COLORS[res["shape"]].astype(np.float32))
# groupes de sommets : densité + un groupe par tag (utilisables comme filtres Despike, masques, etc.)
groups = {"SW_Detail": res["detail"]}
for i, nm in enumerate(SHAPES[1:], start=1):
groups[f"SW_{nm}"] = (res["shape"] == i).astype(float)
for nm, w in groups.items():
vg = obj.vertex_groups.get(nm) or obj.vertex_groups.new(name=nm)
vg.remove(list(range(nb)))
w = np.asarray(w[:k], float)
for val in np.unique(np.round(w, 2)):
if val <= 0:
continue
idx = np.nonzero(np.round(w, 2) == val)[0].tolist()
vg.add(idx, float(val), "REPLACE")
me.update()
return {nm: int((res["shape"][:k] == i).sum()) for i, nm in enumerate(SHAPES)}
+238
View File
@@ -0,0 +1,238 @@
"""Tests de Slice Wrap en arrière-plan (sans installer l'add-on).
"C:\\Program Files\\Blender Foundation\\Blender 5.2\\blender.exe" -b -P tests/run_tests.py -- [dossier_sortie] [--head]
- rapide (~40 s) : sphère trouée et bruitée → nom / numérotation / collection / Regenerate, Envelope fermée,
Wrap + Freeze (réparation), Despike (global, filtré, refus groupe absent, Protect Detail), Analyze.
- --head : chaîne complète sur la tête brute Pixal3D (11,8 M faces, ~2 min) + rendus de contrôle.
Termine par « RESULTAT : TOUT OK » ou la liste des échecs.
"""
import importlib
import math
import os
import sys
import time
import bmesh
import bpy
import numpy as np
from mathutils import Vector
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(ROOT))
for k in [k for k in sys.modules if k.startswith(os.path.basename(ROOT))]:
del sys.modules[k]
mod = importlib.import_module(os.path.basename(ROOT))
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
OUT = next((a for a in ARGS if not a.startswith("--")), ROOT)
HEAD = r"C:\Users\a-mma\Desktop\CharacterDesign\Work3D\refs\head\3D\head_raw_pixal3d_front_s56.glb"
bpy.ops.wm.read_factory_settings(use_empty=True)
mod.register()
sc = bpy.context.scene
s = sc.slice_wrap
fails = []
def check(name, cond, msg=""):
print(("OK " if cond else "FAIL ") + f"{name} : {msg}")
if not cond:
fails.append(name)
def sel(o):
for x in bpy.context.selected_objects:
x.select_set(False)
o.hide_set(False)
o.select_set(True)
bpy.context.view_layer.objects.active = o
def topo(o):
bm = bmesh.new()
bm.from_mesh(o.data)
seen, pieces = set(), 0
for v in bm.verts:
if v in seen:
continue
pieces += 1
st = [v]
seen.add(v)
while st:
x = st.pop()
for e in x.link_edges:
w = e.other_vert(x)
if w not in seen:
seen.add(w)
st.append(w)
r = dict(faces=len(bm.faces), boundary=sum(e.is_boundary for e in bm.edges),
nonmanifold=sum(not e.is_manifold for e in bm.edges), pieces=pieces)
bm.free()
return r
def safe(op, **kw):
try:
return op(**kw)
except RuntimeError:
return {"CANCELLED"}
# ----------------------------------------------------------------------------- source : sphère trouée + bruit
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=5, radius=1.0)
src = bpy.context.active_object
src.name = "Tete"
bm = bmesh.new()
bm.from_mesh(src.data)
bmesh.ops.delete(bm, geom=[f for f in bm.faces if f.calc_center_median().z > 0.85
or abs(f.calc_center_median().x - 0.6) < 0.05], context="FACES") # trous
rng = np.random.default_rng(0)
for v in bm.verts:
v.co *= 1.0 + 0.004 * rng.standard_normal() # bruit
bm.to_mesh(src.data)
bm.free()
check("source trouée", topo(src)["boundary"] > 0, str(topo(src)))
# ----------------------------------------------------------------------------- nom / collection / Regenerate
s.pitch_rel = 1.5 # voxel grossier : test rapide (le défaut 0,6 % → ~100 k faces ×16)
pre = bpy.data.collections.new("SliceWrapCollection")
sc.collection.children.link(pre)
keep = bpy.data.objects.new("DejaLa", None)
pre.objects.link(keep)
sel(src)
t = time.time()
r1 = bpy.ops.slice_wrap.envelope()
e1 = bpy.context.active_object
check("envelope", r1 == {"FINISHED"}, f"{e1.name} en {time.time() - t:.1f} s")
check("nom suffixe", e1.name == "Tete_slicewrapped", e1.name)
tp = topo(e1)
check("enveloppe fermée", tp["boundary"] == 0 and tp["nonmanifold"] == 0 and tp["pieces"] == 1, str(tp))
check("source cachée", src.hide_get())
sel(src)
bpy.ops.slice_wrap.envelope()
e2 = bpy.context.active_object
check("numérotation", e2.name == "Tete_slicewrapped.001" and e1.name in bpy.data.objects, e2.name)
check("collection réutilisée", [c.name for c in e1.users_collection] == ["SliceWrapCollection"]
and "DejaLa" in pre.objects and len([c for c in bpy.data.collections if c.name.startswith("SliceWrapCollection")]) == 1)
nb = len(e2.data.polygons)
s.pitch_rel = 1.0
sel(e2)
r = bpy.ops.slice_wrap.regenerate()
check("regenerate sur place", r == {"FINISHED"} and bpy.context.active_object == e2 and len(e2.data.polygons) != nb,
f"{nb} → {len(e2.data.polygons)} faces")
s.pitch_rel = 1.5
s.use_collection = False
s.name_suffix = ""
sel(src)
bpy.ops.slice_wrap.envelope()
e4 = bpy.context.active_object
check("suffixe vide sans écraser la source", e4.name == "Tete.001" and src.name == "Tete"
and [c.name for c in e4.users_collection] == ["Scene Collection"], e4.name)
s.use_collection = True
s.name_suffix = "_slicewrapped"
# ----------------------------------------------------------------------------- objet sélectionné / annulation
check("plus de réglage Target", "target" not in s.bl_rna.properties)
check("opérateurs longs : propriété modal", "modal_run" in bpy.ops.slice_wrap.run.get_rna_type().properties)
sel(src)
src.select_set(False)
check("rien de sélectionné → Slice Wrap grisé", not bpy.ops.slice_wrap.run.poll())
sel(src)
check("objet sélectionné → Slice Wrap actif", bpy.ops.slice_wrap.run.poll())
n_obj, n_me, n_col = len(bpy.data.objects), len(bpy.data.meshes), len(bpy.data.collections)
g = mod.step_run(src) # chaîne complète pour compter les tranches, puis on la supprime
total = 0
try:
while True:
next(g)
total += 1
except StopIteration as e:
mod._remove_object(e.value[0])
sel(src)
check("chaîne découpée en tranches", total > 20, f"{total} tranches")
for stop in (3, total // 2, total): # annulation (Échap) : enveloppe, projection, juste avant le Reshape final
g = mod.step_run(src)
steps = 0
try:
for _ in range(stop):
next(g)
steps += 1
except StopIteration:
pass
g.close()
check(f"annulation après {steps} tranches : rien de créé",
(len(bpy.data.objects), len(bpy.data.meshes)) == (n_obj, n_me) and len(bpy.data.collections) == n_col,
f"objets {len(bpy.data.objects)}/{n_obj}, maillages {len(bpy.data.meshes)}/{n_me}")
sel(src)
# ----------------------------------------------------------------------------- Wrap + Freeze
sel(e1)
r = bpy.ops.slice_wrap.wrap_setup()
check("wrap setup", r == {"FINISHED"} and [m.type for m in e1.modifiers] == ["MULTIRES", "SHRINKWRAP"],
str([m.type for m in e1.modifiers]))
r = bpy.ops.slice_wrap.freeze()
check("freeze", r == {"FINISHED"} and [m.type for m in e1.modifiers] == ["MULTIRES"], str([m.type for m in e1.modifiers]))
check("regenerate refusé si wrappé", safe(bpy.ops.slice_wrap.regenerate) == {"CANCELLED"})
# ----------------------------------------------------------------------------- Despike
co0 = [v.co.copy() for v in e1.data.vertices]
g = mod.step_despike(e1)
next(g), next(g), next(g)
g.close()
check("despike annulé : maillage intact", [m.type for m in e1.modifiers] == ["MULTIRES"]
and all((v.co - c).length == 0 for v, c in zip(e1.data.vertices, co0))
and not any(o.name.endswith("_tmp") for o in bpy.data.objects))
vg = e1.vertex_groups.new(name="Zone")
vg.add([v.index for v in e1.data.vertices if v.co.z < 0], 1.0, "REPLACE")
e1.slice_wrap_despike_groups.add().group = "Zone"
r = bpy.ops.slice_wrap.despike()
check("despike filtré", r == {"FINISHED"})
e1.slice_wrap_despike_groups.clear()
e1.slice_wrap_despike_groups.add().group = "Absent"
check("despike refus groupe absent", safe(bpy.ops.slice_wrap.despike) == {"CANCELLED"})
e1.slice_wrap_despike_groups.clear()
s.despike_protect = True
check("despike global + Protect Detail", bpy.ops.slice_wrap.despike() == {"FINISHED"})
s.despike_protect = False
# ----------------------------------------------------------------------------- Analyze
r = bpy.ops.slice_wrap.analyze()
me = e1.data
check("analyze", r == {"FINISHED"} and {"sw_detail", "sw_shape", "sw_flow"} <= {a.name for a in me.attributes}
and {"SW_Detail_Color", "SW_Shape_Color"} <= {c.name for c in me.color_attributes}
and {"SW_Detail", "SW_Fold", "SW_Bulge"} <= {g.name for g in e1.vertex_groups})
check("pas de collision couleur / groupe", not ({c.name for c in me.color_attributes} & {g.name for g in e1.vertex_groups}))
r = bpy.ops.slice_wrap.show(mode="SW_Detail_Color")
check("show", r == {"FINISHED"} and me.color_attributes.render_color_index == me.color_attributes.active_color_index)
# ----------------------------------------------------------------------------- tête réelle (optionnel)
if "--head" in ARGS and os.path.isfile(HEAD):
bpy.ops.import_scene.gltf(filepath=HEAD)
coque = bpy.context.selected_objects[0]
coque.name = "Coque"
sel(coque)
t = time.time()
r = bpy.ops.slice_wrap.run()
env = bpy.context.active_object
check("tête : chaîne complète", r == {"FINISHED"} and [m.type for m in env.modifiers] == ["MULTIRES"],
f"{env.name}, base {len(env.data.polygons)} faces, {time.time() - t:.0f} s")
bpy.ops.slice_wrap.analyze()
sc.render.engine = "BLENDER_WORKBENCH"
sc.render.resolution_x = sc.render.resolution_y = 800
sc.display.shading.light = "MATCAP"
cam = bpy.data.objects.new("CAM", bpy.data.cameras.new("CAM"))
sc.collection.objects.link(cam)
sc.camera = cam
cam.data.type = "ORTHO"
for o in sc.objects:
if o.type == "MESH":
o.hide_render = o != env
for tag, loc, rot, scl in (("face", (0, -2, 0), (90, 0, 0), 0.75), ("cote", (2, 0.05, 0.08), (90, 0, 90), 0.45)):
cam.location = Vector(loc)
cam.rotation_euler = tuple(math.radians(a) for a in rot)
cam.data.ortho_scale = scl
sc.render.filepath = os.path.join(OUT, f"slice_wrap_head_{tag}.png")
bpy.ops.render.render(write_still=True)
print("IMAGE", sc.render.filepath)
mod.unregister()
print("RESULTAT :", "TOUT OK" if not fails else "ECHECS " + ", ".join(fails))
+78
View File
@@ -0,0 +1,78 @@
<#
.SYNOPSIS
Construit le paquet d'extension Blender de Slice Wrap dans dist\.
.DESCRIPTION
Valide blender_manifest.toml, construit slice_wrap-<version>.zip avec « blender --command extension build »
(exclusions : section [build] du manifeste), puis vérifie que le zip ne contient ni tests, ni .claude, ni
CLAUDE.md, ni .git. Renvoie le chemin du zip.
.EXAMPLE
.\tools\Build-Package.ps1
.EXAMPLE
.\tools\Build-Package.ps1 -RunTests -Blender "C:\Program Files\Blender Foundation\Blender 5.2\blender.exe"
#>
param(
# Exécutable Blender (défaut : variable BLENDER_EXE, sinon le Blender 5.x le plus récent installé)
[string]$Blender = $env:BLENDER_EXE,
# Dossier de sortie
[string]$OutDir = (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist'),
# Lance d'abord la suite de tests en arrière-plan (exige « RESULTAT : TOUT OK »)
[switch]$RunTests
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
function Find-Blender {
$base = 'C:\Program Files\Blender Foundation'
$exe = Get-ChildItem $base -Directory -Filter 'Blender *' -ErrorAction SilentlyContinue |
Sort-Object { [version]($_.Name -replace '^Blender\s+', '') } -Descending |
ForEach-Object { Join-Path $_.FullName 'blender.exe' } |
Where-Object { Test-Path $_ } |
Select-Object -First 1
if (-not $exe) { throw "Blender introuvable : passez -Blender <chemin\blender.exe> ou définissez BLENDER_EXE" }
return $exe
}
if (-not $Blender) { $Blender = Find-Blender }
if (-not (Test-Path $Blender)) { throw "Blender introuvable : $Blender" }
$manifest = Get-Content (Join-Path $root 'blender_manifest.toml') -Raw
if ($manifest -notmatch '(?m)^\s*version\s*=\s*"([^"]+)"') { throw "version introuvable dans blender_manifest.toml" }
$version = $Matches[1]
if ($manifest -notmatch '(?m)^\s*id\s*=\s*"([^"]+)"') { throw "id introuvable dans blender_manifest.toml" }
$id = $Matches[1]
Write-Host "Slice Wrap $version — $Blender" -ForegroundColor Cyan
if ($RunTests) {
$testOut = Join-Path ([IO.Path]::GetTempPath()) 'slice_wrap_tests'
New-Item -ItemType Directory -Force $testOut | Out-Null
Write-Host "Tests (~1 min)…"
$log = & $Blender -b --factory-startup -P (Join-Path $root 'tests\run_tests.py') -- $testOut 2>&1
$log | Where-Object { $_ -match '^(OK|FAIL|RESULTAT)' } | ForEach-Object { Write-Host $_ }
if (-not ($log -match 'RESULTAT : TOUT OK')) { throw "Les tests ont échoué : paquet non construit" }
}
Write-Host "Validation du manifeste…"
$out = & $Blender --command extension validate $root 2>&1
if ($LASTEXITCODE -ne 0) { $out | Write-Host; throw "Manifeste invalide" }
New-Item -ItemType Directory -Force $OutDir | Out-Null
$zip = Join-Path $OutDir "$id-$version.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Write-Host "Construction…"
$out = & $Blender --command extension build --source-dir $root --output-dir $OutDir 2>&1
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $zip)) { $out | Write-Host; throw "Échec de la construction" }
# Contrôle du contenu : rien de privé ni de développement dans le paquet
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($zip)
try { $entries = $archive.Entries | ForEach-Object FullName } finally { $archive.Dispose() }
$bad = $entries | Where-Object { $_ -match '^(tests|tools|dist|\.claude|\.git)/|^CLAUDE\.md$|__pycache__|\.zip$' }
if ($bad) { throw "Fichiers indésirables dans le paquet : $($bad -join ', ')" }
Write-Host "Paquet : $zip ($([math]::Round((Get-Item $zip).Length / 1KB)) Ko)" -ForegroundColor Green
$entries | ForEach-Object { Write-Host " $_" }
return $zip
+159
View File
@@ -0,0 +1,159 @@
<#
.SYNOPSIS
Publie une release de Slice Wrap sur Gitea : tag v<version>, release et paquet zip en pièce jointe.
.DESCRIPTION
1. Lit la version dans blender_manifest.toml.
2. Vérifie que le dépôt est propre (tout est commité) et que HEAD est poussé sur origin.
3. Construit le paquet (tools\Build-Package.ps1, tests compris sauf -SkipTests).
4. Crée le tag annoté v<version> sur HEAD et le pousse (s'il existe déjà, il doit pointer sur HEAD).
5. Crée la release via l'API Gitea (ou réutilise celle du tag) et y joint slice_wrap-<version>.zip
(une pièce jointe du même nom est remplacée).
Notes de version : -Notes, sinon l'entrée de la version dans la section « Historique » de CLAUDE.md.
Jeton : variable d'environnement GITEA_TOKEN (Gitea > Paramètres > Applications > jeton avec la portée
« repository : lecture et écriture »), sinon il est demandé (saisie masquée).
.EXAMPLE
.\tools\New-GiteaRelease.ps1 -DryRun # montre ce qui serait fait, ne publie rien
.EXAMPLE
$env:GITEA_TOKEN = '...'; .\tools\New-GiteaRelease.ps1
.EXAMPLE
.\tools\New-GiteaRelease.ps1 -Prerelease -Notes "Version de test"
#>
param(
[string]$Server = 'https://a2msystemes-git.duckdns.org',
# Propriétaire et dépôt : déduits de l'URL du remote « origin » si absents
[string]$Owner,
[string]$Repo,
[string]$Notes,
[switch]$Prerelease,
[switch]$Draft,
[switch]$SkipTests,
# Publier même si le dépôt contient des modifications non commitées
[switch]$AllowDirty,
[switch]$DryRun,
[string]$Blender = $env:BLENDER_EXE
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
function Invoke-Git {
$out = & git -C $root @args 2>&1
if ($LASTEXITCODE -ne 0) { throw "git $($args -join ' ') : $out" }
return $out
}
# ------------------------------------------------------------------ version, dépôt, notes
$manifest = Get-Content (Join-Path $root 'blender_manifest.toml') -Raw
if ($manifest -notmatch '(?m)^\s*version\s*=\s*"([^"]+)"') { throw "version introuvable dans blender_manifest.toml" }
$version = $Matches[1]
$tag = "v$version"
if (-not $Owner -or -not $Repo) {
$url = (& git -C $root remote get-url origin 2>$null)
if (-not $url) { throw "Aucun remote « origin » : ajoutez-le (git remote add origin $Server/<owner>/<repo>.git) ou passez -Owner / -Repo" }
if ($url -notmatch '[:/]([^/:]+)/([^/]+?)(\.git)?/?$') { throw "URL du remote non reconnue : $url" }
if (-not $Owner) { $Owner = $Matches[1] }
if (-not $Repo) { $Repo = $Matches[2] }
}
if (-not $Notes) {
# Entrée « - <version> … » de la section Historique de CLAUDE.md (lignes de continuation indentées comprises)
$claude = Join-Path $root 'CLAUDE.md'
if (Test-Path $claude) {
$lines = Get-Content $claude -Encoding utf8
$i = [array]::FindIndex($lines, [Predicate[string]] { param($l) $l -match "^- $([regex]::Escape($version))\b" })
if ($i -ge 0) {
$entry = @($lines[$i] -replace "^- $([regex]::Escape($version))\s*", '')
for ($j = $i + 1; $j -lt $lines.Count -and $lines[$j] -match '^\s{2,}\S'; $j++) { $entry += $lines[$j].Trim() }
$Notes = $entry -join ' '
}
}
if (-not $Notes) { $Notes = "Slice Wrap $version" }
}
$body = @"
$Notes
**Installation** : Blender 4.2+ > *Edit > Preferences > Get Extensions > ⌄ > Install from Disk…* > ``slice_wrap-$version.zip``
"@
# ------------------------------------------------------------------ état du dépôt
& git -C $root rev-parse --verify --quiet HEAD *> $null
if ($LASTEXITCODE -ne 0) { throw "Le dépôt n'a encore aucun commit : commitez et poussez avant de publier une release." }
$dirty = Invoke-Git status --porcelain
if ($dirty -and -not $AllowDirty) { throw "Modifications non commitées :`n$($dirty -join "`n")`nCommitez-les (ou -AllowDirty)." }
$head = (Invoke-Git rev-parse HEAD).Trim()
$branch = (Invoke-Git rev-parse --abbrev-ref HEAD).Trim()
& git -C $root fetch origin --tags --quiet 2>$null
$remoteHead = (& git -C $root rev-parse "origin/$branch" 2>$null)
if ($remoteHead -ne $head -and -not $DryRun) { throw "HEAD ($($head.Substring(0,8))) n'est pas poussé sur origin/$branch : faites git push d'abord." }
$tagCommit = (& git -C $root rev-list -n 1 $tag 2>$null)
if ($tagCommit -and $tagCommit.Trim() -ne $head) { throw "Le tag $tag existe déjà sur un autre commit ($($tagCommit.Substring(0,8))) : montez la version dans blender_manifest.toml." }
Write-Host "Release $tag de $Owner/$Repo sur $Server (commit $($head.Substring(0,8)), branche $branch)" -ForegroundColor Cyan
Write-Host "Notes : $Notes"
# ------------------------------------------------------------------ paquet
$buildArgs = @{}
if ($Blender) { $buildArgs.Blender = $Blender }
if (-not $SkipTests) { $buildArgs.RunTests = $true }
$zip = & (Join-Path $PSScriptRoot 'Build-Package.ps1') @buildArgs | Select-Object -Last 1
$zipName = Split-Path $zip -Leaf
if ($DryRun) {
Write-Host "`n[DryRun] Rien n'est publié. Seraient faits :" -ForegroundColor Yellow
if (-not $tagCommit) { Write-Host " git tag -a $tag $($head.Substring(0,8)) ; git push origin $tag" }
Write-Host " POST $Server/api/v1/repos/$Owner/$Repo/releases (tag $tag$(if ($Prerelease) {', préversion'})$(if ($Draft) {', brouillon'}))"
Write-Host " pièce jointe $zipName"
return
}
# ------------------------------------------------------------------ jeton
$token = $env:GITEA_TOKEN
if (-not $token) {
$sec = Read-Host "Jeton Gitea (portée repository, lecture/écriture)" -AsSecureString
$token = [Net.NetworkCredential]::new('', $sec).Password
}
if (-not $token) { throw "Jeton Gitea manquant" }
$headers = @{ Authorization = "token $token"; Accept = 'application/json' }
$api = "$Server/api/v1/repos/$Owner/$Repo"
# ------------------------------------------------------------------ tag
if (-not $tagCommit) {
Invoke-Git tag -a $tag -m "Slice Wrap $version" | Out-Null
Invoke-Git push origin $tag | Out-Null
Write-Host "Tag $tag créé et poussé"
}
# ------------------------------------------------------------------ release
$release = $null
try {
$release = Invoke-RestMethod -Method Get -Uri "$api/releases/tags/$tag" -Headers $headers
Write-Host "Release $tag existante (id $($release.id)) : réutilisée"
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
}
if (-not $release) {
$payload = @{
tag_name = $tag
name = "Slice Wrap $version"
body = $body
draft = [bool]$Draft
prerelease = [bool]$Prerelease
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers `
-ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($payload))
Write-Host "Release créée (id $($release.id))"
}
# ------------------------------------------------------------------ pièce jointe (remplacée si elle existe)
foreach ($a in @($release.assets) | Where-Object { $_ -and $_.name -eq $zipName }) {
Invoke-RestMethod -Method Delete -Uri "$api/releases/$($release.id)/assets/$($a.id)" -Headers $headers | Out-Null
Write-Host "Ancienne pièce jointe $zipName supprimée"
}
Invoke-RestMethod -Method Post -Uri "$api/releases/$($release.id)/assets?name=$zipName" -Headers $headers `
-Form @{ attachment = Get-Item $zip } | Out-Null
Write-Host "Publié : $($release.html_url)" -ForegroundColor Green