Bump version v1.4.0. The first version published
This commit is contained in:
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.
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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/",
|
||||
]
|
||||
@@ -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)}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user