diff --git a/.claude/skills/ai-3d-asset-pipeline/SKILL.md b/.claude/skills/ai-3d-asset-pipeline/SKILL.md new file mode 100644 index 0000000..afab76b --- /dev/null +++ b/.claude/skills/ai-3d-asset-pipeline/SKILL.md @@ -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//`, +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 [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 »). diff --git a/.claude/skills/blender-addon-testing/SKILL.md b/.claude/skills/blender-addon-testing/SKILL.md new file mode 100644 index 0000000..cc727e1 --- /dev/null +++ b/.claude/skills/blender-addon-testing/SKILL.md @@ -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 -- ` ; 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 --output-dir ` ; `--command extension validate `. +- `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. diff --git a/.claude/skills/blender-live-mcp/SKILL.md b/.claude/skills/blender-live-mcp/SKILL.md new file mode 100644 index 0000000..5b919a2 --- /dev/null +++ b/.claude/skills/blender-live-mcp/SKILL.md @@ -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. diff --git a/.claude/skills/mesh-wrap-cleanup/SKILL.md b/.claude/skills/mesh-wrap-cleanup/SKILL.md new file mode 100644 index 0000000..e22b0f8 --- /dev/null +++ b/.claude/skills/mesh-wrap-cleanup/SKILL.md @@ -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**. diff --git a/.claude/skills/slice-wrap-dev/SKILL.md b/.claude/skills/slice-wrap-dev/SKILL.md new file mode 100644 index 0000000..5dd4262 --- /dev/null +++ b/.claude/skills/slice-wrap-dev/SKILL.md @@ -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 -- ` + (~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). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c8044ad --- /dev/null +++ b/CLAUDE.md @@ -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 -- [--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-.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`, 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aba182c --- /dev/null +++ b/README.md @@ -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 `` (par défaut `_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 `` (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-.zip` ; `-RunTests` lance + d'abord les tests ; Blender trouvé automatiquement, ou `-Blender ` / 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`, 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\\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`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..8a953fe --- /dev/null +++ b/__init__.py @@ -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) diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..5093b74 --- /dev/null +++ b/blender_manifest.toml @@ -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/", +] diff --git a/core.py b/core.py new file mode 100644 index 0000000..cd04bff --- /dev/null +++ b/core.py @@ -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)} diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..17337fd --- /dev/null +++ b/tests/run_tests.py @@ -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)) diff --git a/tools/Build-Package.ps1 b/tools/Build-Package.ps1 new file mode 100644 index 0000000..b99f9e6 --- /dev/null +++ b/tools/Build-Package.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + Construit le paquet d'extension Blender de Slice Wrap dans dist\. + +.DESCRIPTION + Valide blender_manifest.toml, construit slice_wrap-.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 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 diff --git a/tools/New-GiteaRelease.ps1 b/tools/New-GiteaRelease.ps1 new file mode 100644 index 0000000..76ab239 --- /dev/null +++ b/tools/New-GiteaRelease.ps1 @@ -0,0 +1,159 @@ +<# +.SYNOPSIS + Publie une release de Slice Wrap sur Gitea : tag v, 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 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-.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//.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 « - … » 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