Files
slice_wrap_addon/__init__.py
T

929 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)