929 lines
41 KiB
Python
929 lines
41 KiB
Python
"""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)
|