"""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)