Files

583 lines
24 KiB
Python
Raw Permalink 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 — algorithmes (numpy seul, aucune dépendance externe).
Enveloppe fermée d'un maillage troué / à doubles parois :
1. échantillonnage de la surface → voxels occupés (pas = pitch)
2. fermeture morphologique (dilatation puis érosion, voisinage 6) → bouche les petits trous
3. remplissage : 3D global + tranche par tranche selon X, Y, Z (union). Un trou de la coque ne fait
« fuir » que les tranches qu'il traverse, alors qu'un remplissage 3D seul fuit entièrement.
4. ouverture (retire les filaments)
5. surface en cubes → Voxel Remesh de Blender (le volume est déjà plein : pas de fuite) → plus grande pièce
→ lissage de Taubin
Les calculs longs sont des **générateurs** : ils cèdent la main régulièrement en produisant
(fraction 0–1, texte ou None) et renvoient leur résultat par `return`. L'opérateur modal les fait avancer par
tranches (Blender reste affiché, barre de progression, Échap pour annuler) ; `run` les exécute d'une traite.
"""
import numpy as np
import bpy
from mathutils import Matrix
# ----------------------------------------------------------------------------- progression
def run(gen):
"""Exécute un générateur de calcul jusqu'au bout et renvoie son résultat."""
while True:
try:
next(gen)
except StopIteration as e:
return e.value
def sub(gen, a, b):
"""Relaie la progression de `gen` dans l'intervalle [a, b] du parent ; renvoie le résultat de `gen`.
Usage : `res = yield from sub(enfant(...), 0.2, 0.5)`."""
try:
while True:
try:
f, text = next(gen)
except StopIteration as e:
return e.value
yield a + (b - a) * min(max(f, 0.0), 1.0), text
finally:
gen.close() # annulation : le nettoyage de l'enfant s'exécute tout de suite
# ----------------------------------------------------------------------------- voxels
def _shift(a, axis, step):
"""Décale un tableau booléen de `step` (±1) le long de `axis`, sans rebouclage (bord = False)."""
out = np.zeros_like(a)
src = [slice(None)] * 3
dst = [slice(None)] * 3
if step > 0:
src[axis], dst[axis] = slice(0, -step), slice(step, None)
else:
src[axis], dst[axis] = slice(-step, None), slice(0, step)
out[tuple(dst)] = a[tuple(src)]
return out
def dilate(a, iterations=1, axes=(0, 1, 2)):
for _ in range(iterations):
b = a.copy()
for ax in axes:
b |= _shift(a, ax, 1)
b |= _shift(a, ax, -1)
a = b
return a
def erode(a, iterations=1, axes=(0, 1, 2)):
return ~dilate(~a, iterations, axes)
def _outside(walls, axes):
"""Vide relié au bord de la grille, propagé seulement le long de `axes` (2 axes = tranche par tranche)."""
seed = np.zeros_like(walls)
for ax in axes:
idx = [slice(None)] * 3
idx[ax] = 0
seed[tuple(idx)] = True
idx[ax] = -1
seed[tuple(idx)] = True
free = ~walls
out = seed & free
est = max(walls.shape) * 0.5 # nombre de passes probable (estimation pour la progression)
i = 0
while True:
grown = dilate(out, 1, axes) & free
if np.array_equal(grown, out):
return out
out = grown
i += 1
yield min(i / est, 0.95), None
def fill(walls, slice_axes=(0, 1, 2)):
"""Volume plein : 3D global ∪ remplissage 2D tranche par tranche pour chaque axe demandé (générateur)."""
passes = [(0, 1, 2)] + [tuple(a for a in (0, 1, 2) if a != ax) for ax in slice_axes]
solid = None
for k, axes in enumerate(passes):
out = yield from sub(_outside(walls, axes), k / len(passes), (k + 1) / len(passes))
solid = ~out if solid is None else solid | ~out
return solid
# ----------------------------------------------------------------------------- maillage ↔ voxels
def mesh_world_arrays(obj, depsgraph):
"""Sommets (monde) et triangles du maillage évalué."""
ev = obj.evaluated_get(depsgraph)
me = ev.to_mesh()
me.calc_loop_triangles()
nv, nt = len(me.vertices), len(me.loop_triangles)
co = np.empty(nv * 3, np.float64)
me.vertices.foreach_get("co", co)
tri = np.empty(nt * 3, np.int64)
me.loop_triangles.foreach_get("vertices", tri)
ev.to_mesh_clear()
M = np.array(obj.matrix_world)
co = co.reshape(-1, 3) @ M[:3, :3].T + M[:3, 3]
return co, tri.reshape(-1, 3)
def sample_surface(co, tri, pitch, max_points=40_000_000):
"""Sommets + points répartis sur les triangles plus grands qu'un demi-voxel, pour que la coque voxel soit
continue. Sur un maillage déjà dense (scan, IA), seuls les sommets servent : rien n'est dupliqué."""
e0 = np.linalg.norm(co[tri[:, 1]] - co[tri[:, 0]], axis=1)
e1 = np.linalg.norm(co[tri[:, 2]] - co[tri[:, 1]], axis=1)
e2 = np.linalg.norm(co[tri[:, 0]] - co[tri[:, 2]], axis=1)
k = np.clip(np.ceil(np.maximum(e0, np.maximum(e1, e2)) / (0.5 * pitch)).astype(np.int64), 1, 64)
del e0, e1, e2
big = k >= 2 # k = 1 : les 3 points seraient les sommets eux-mêmes
n = ((k[big] + 1) * (k[big] + 2) // 2).sum() + len(co)
if n > max_points:
raise RuntimeError(f"{n:,} points d'échantillonnage : augmentez la taille de voxel")
pts = [co]
tb, kb = tri[big], k[big]
for kk in np.unique(kb):
t = tb[kb == kk]
a, b, c = co[t[:, 0]], co[t[:, 1]], co[t[:, 2]]
i, j = np.meshgrid(np.arange(kk + 1), np.arange(kk + 1), indexing="ij")
m = (i + j) <= kk
u, v = (i[m] / kk)[None, :, None], (j[m] / kk)[None, :, None]
pts.append((a[:, None] + u * (b - a)[:, None] + v * (c - a)[:, None]).reshape(-1, 3))
return np.vstack(pts)
def voxelize(points, pitch, pad):
lo = points.min(0) - pad * pitch
idx = np.floor((points - lo) / pitch).astype(np.int64)
shape = tuple(idx.max(0) + pad + 1)
occ = np.zeros(shape, bool)
occ[idx[:, 0], idx[:, 1], idx[:, 2]] = True
return occ, lo
def blocky_mesh(solid, lo, pitch, name):
"""Surface en cubes du volume plein (faces entre voxel plein et vide), sommets partagés."""
s = np.pad(solid, 1)
lo = lo - pitch
quads = []
corner = { # pour chaque axe : 4 coins de la face (dans le plan des 2 autres axes)
0: [(0, 0, 0), (0, 1, 0), (0, 1, 1), (0, 0, 1)],
1: [(0, 0, 0), (0, 0, 1), (1, 0, 1), (1, 0, 0)],
2: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
}
for ax in (0, 1, 2):
a = s
b = np.roll(s, -1, axis=ax)
for flip, mask in ((False, a & ~b), (True, ~a & b)):
p = np.argwhere(mask)
if not len(p):
continue
p[:, ax] += 1 # la face est sur le plan i+1
cs = corner[ax][::-1] if flip else corner[ax]
quads.append(np.stack([p + np.array(c) for c in cs], axis=1))
q = np.concatenate(quads) # (F, 4, 3) coins en indices de grille
flat = q.reshape(-1, 3)
uniq, inv = np.unique(flat, axis=0, return_inverse=True)
verts = uniq * pitch + lo
faces = inv.reshape(-1, 4)
me = bpy.data.meshes.new(name)
me.from_pydata(verts.astype(np.float32), [], faces.astype(np.int32))
me.validate()
return me
# ----------------------------------------------------------------------------- lissage / nettoyage
def _edges(me):
e = np.empty(len(me.edges) * 2, np.int64)
me.edges.foreach_get("vertices", e)
return e.reshape(-1, 2)
def _neighbor_mean(co, e):
acc = np.zeros_like(co)
cnt = np.zeros(len(co))
np.add.at(acc, e[:, 0], co[e[:, 1]])
np.add.at(acc, e[:, 1], co[e[:, 0]])
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
return acc / np.maximum(cnt, 1)[:, None], cnt
def taubin(me, iterations=20, lamb=0.5, mu=-0.53):
"""Lissage de Taubin : efface les marches et le moiré sans faire rétrécir la forme (générateur)."""
if iterations <= 0:
return
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
for i in range(iterations):
for f in (lamb, mu):
m, _ = _neighbor_mean(co, e)
co = co + f * (m - co)
yield (i + 1) / iterations, None
me.vertices.foreach_set("co", co.ravel())
me.update()
def keep_largest_island(me):
"""Supprime les îlots détachés (garde la plus grande pièce du maillage) (générateur)."""
import bmesh
bm = bmesh.new()
try:
bm.from_mesh(me)
nv = max(len(bm.verts), 1)
seen, best = set(), []
last = 0
for v in bm.verts:
if v in seen:
continue
comp, stack = [], [v]
seen.add(v)
while stack:
x = stack.pop()
comp.append(x)
for ed in x.link_edges:
w = ed.other_vert(x)
if w not in seen:
seen.add(w)
stack.append(w)
if len(comp) > len(best):
best = comp
if len(seen) - last >= 50_000:
last = len(seen)
yield 0.9 * len(seen) / nv, None
keep = set(best)
bmesh.ops.delete(bm, geom=[v for v in bm.verts if v not in keep], context="VERTS")
bm.to_mesh(me)
finally:
bm.free()
def despike(me, threshold, iterations=3, ratio=3.0, strength=1.0, weights=None):
"""Filtre anti-pointes (générateur ; le maillage n'est écrit qu'à la fin). Un sommet est une pointe si
- son écart à la moyenne de ses voisins dépasse `threshold`, ET
- cet écart est `ratio` fois plus grand que l'écart moyen de ses voisins (une pointe est isolée ;
dans une zone simplement courbe — oreille, paupière — tous les voisins dévient pareil).
La pointe est ramenée vers la moyenne de ses voisins de `strength` × `weights` (poids 0–1 par sommet,
None = 1 partout) : le reste du relief n'est pas lissé. Renvoie le nombre de corrections."""
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
w = np.full(len(co), float(strength)) if weights is None else np.clip(weights, 0.0, 1.0) * strength
active = w > 0
total = 0
for it in range(iterations):
m, cnt = _neighbor_mean(co, e)
d = np.linalg.norm(co - m, axis=1)
acc = np.zeros(len(co))
np.add.at(acc, e[:, 0], d[e[:, 1]])
np.add.at(acc, e[:, 1], d[e[:, 0]])
dn = acc / np.maximum(cnt, 1)
bad = active & (d > threshold) & (d > ratio * dn) & (cnt > 0)
if not bad.any():
break
co[bad] += (m[bad] - co[bad]) * w[bad, None]
total += int(bad.sum())
yield (it + 1) / iterations, None
me.vertices.foreach_set("co", co.ravel())
me.update()
return total
def group_weights(me, group_indices):
"""Poids max par sommet sur les groupes de sommets donnés (indices) ; None si aucun groupe (générateur)."""
if not group_indices:
return None
gi = set(group_indices)
nv = max(len(me.vertices), 1)
w = np.zeros(len(me.vertices))
for v in me.vertices:
best = 0.0
for g in v.groups:
if g.group in gi and g.weight > best:
best = g.weight
w[v.index] = best
if v.index % 50_000 == 49_999:
yield v.index / nv, None
return w
def fix_missed_rays(me, before, inflate, low=0.3, deep=2.0, max_iter=500):
"""Répare les ratés de projection, connus exactement en comparant chaque sommet avant / après le Shrinkwrap :
- raté « vide » : le sommet n'a presque pas bougé (< low × gonflement) → aiguille vers l'extérieur ;
- raté « profond » : il a bougé plus de `deep` fois le déplacement moyen de ses voisins → creux (paroi interne).
Les sommets ratés sont reconstruits par diffusion depuis leurs voisins réussis. Renvoie le nombre réparé
(générateur ; le maillage n'est écrit qu'à la fin)."""
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
disp = np.linalg.norm(co - before, axis=1)
e = _edges(me)
acc = np.zeros(len(co))
cnt = np.zeros(len(co))
np.add.at(acc, e[:, 0], disp[e[:, 1]])
np.add.at(acc, e[:, 1], disp[e[:, 0]])
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
disp_n = acc / np.maximum(cnt, 1)
bad = (disp < low * inflate) | (disp > deep * np.maximum(disp_n, low * inflate))
nbad = int(bad.sum())
known = ~bad
for _ in range(max_iter):
if known.all():
break
s_acc = np.zeros_like(co)
s_cnt = np.zeros(len(co))
kf = known.astype(float)
np.add.at(s_acc, e[:, 0], co[e[:, 1]] * kf[e[:, 1], None])
np.add.at(s_acc, e[:, 1], co[e[:, 0]] * kf[e[:, 0], None])
np.add.at(s_cnt, e[:, 0], kf[e[:, 1]])
np.add.at(s_cnt, e[:, 1], kf[e[:, 0]])
fill = ~known & (s_cnt > 0)
co[fill] = s_acc[fill] / s_cnt[fill, None]
known |= fill
yield 0.95 * float(known.mean()), None
for _ in range(3): # adoucit la reprise sur les zones reconstruites seulement
m, _c = _neighbor_mean(co, e)
co[bad] = m[bad]
me.vertices.foreach_set("co", co.ravel())
me.update()
return nbad
def mean_edge_length(me):
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
e = _edges(me)
return float(np.linalg.norm(co[e[:, 0]] - co[e[:, 1]], axis=1).mean())
# ----------------------------------------------------------------------------- enveloppe complète
def unique_object_name(base):
"""`base` s'il est libre, sinon base.001, base.002… (comme Blender) : on n'écrase jamais un objet existant."""
if base not in bpy.data.objects:
return base
n = 1
while f"{base}.{n:03d}" in bpy.data.objects:
n += 1
return f"{base}.{n:03d}"
def build_envelope(src, pitch, close_iter=3, slice_axes=(0, 1, 2), open_iter=1, smooth_iter=20,
largest=True, name=None, report=print, collection=None, replace=None):
"""Crée l'objet enveloppe de `src` sous un nom libre dérivé de `name` (numérotation .001… si besoin), rangé dans
`collection` (ou la fonction qui la renvoie ; None = collections de la source). `replace` = objet existant à recalculer sur place
(Regenerate) : seul cas où un maillage existant est remplacé. Renvoie l'objet (générateur).
La scène n'est modifiée qu'à la toute fin : une annulation en cours de route ne laisse aucun demi-résultat."""
import time
t0 = time.time()
yield 0.0, "Lecture du maillage source…"
dg = bpy.context.evaluated_depsgraph_get()
co, tri = mesh_world_arrays(src, dg)
yield 0.03, "Échantillonnage de la surface…"
pts = sample_surface(co, tri, pitch)
del co, tri
pad = close_iter + 3
yield 0.06, "Voxelisation…"
occ, lo = voxelize(pts, pitch, pad)
del pts
report(f"Grille {occ.shape} — {int(occ.sum()):,} voxels de surface")
yield 0.08, "Fermeture des trous…"
walls = erode(dilate(occ, close_iter), close_iter) if close_iter > 0 else occ
del occ
yield 0.1, "Remplissage tranche par tranche…"
solid = yield from sub(fill(walls, slice_axes), 0.1, 0.6)
del walls
if open_iter > 0:
yield 0.6, "Retrait des filaments…"
solid = dilate(erode(solid, open_iter), open_iter)
report(f"Volume plein : {int(solid.sum()):,} voxels ({time.time() - t0:.1f} s)")
if replace is not None:
name = replace.name
else:
name = unique_object_name(name or f"{src.name}_slicewrapped")
yield 0.62, "Surface du volume…"
me = blocky_mesh(solid, lo, pitch, name)
del solid
new_me = tmp = None
try:
# Voxel Remesh de Blender sur un volume DÉJÀ plein et fermé → aucune fuite possible.
# Objet temporaire : le Remesh est évalué hors de l'objet final.
yield 0.66, "Voxel Remesh (Blender)…"
tmp = bpy.data.objects.new(name + "_sw_tmp", me)
bpy.context.scene.collection.objects.link(tmp)
mod = tmp.modifiers.new("SliceWrap_Remesh", "REMESH")
mod.mode = "VOXEL"
mod.voxel_size = pitch
mod.adaptivity = 0.0
dg = bpy.context.evaluated_depsgraph_get()
new_me = bpy.data.meshes.new_from_object(tmp.evaluated_get(dg), depsgraph=dg)
bpy.data.objects.remove(tmp)
tmp = None
bpy.data.meshes.remove(me)
me = None
if largest:
yield 0.75, "Plus grande pièce…"
yield from sub(keep_largest_island(new_me), 0.75, 0.85)
yield 0.85, "Lissage…"
yield from sub(taubin(new_me, smooth_iter), 0.85, 1.0)
new_me.polygons.foreach_set("use_smooth", [True] * len(new_me.polygons))
new_me.update()
except BaseException: # erreur ou annulation : on retire tout ce qui a été créé
if tmp is not None:
bpy.data.objects.remove(tmp)
for m in (me, new_me):
if m is not None:
bpy.data.meshes.remove(m)
raise
if replace is not None:
obj = replace
old_me = obj.data
obj.data = new_me
if old_me.users == 0:
bpy.data.meshes.remove(old_me)
else:
obj = bpy.data.objects.new(name, new_me)
if callable(collection): # collection créée seulement à la fin (rien de laissé si annulation)
collection = collection()
for c in ([collection] if collection is not None else src.users_collection):
c.objects.link(obj)
new_me.name = name
obj.matrix_world = Matrix.Identity(4)
report(f"Enveloppe {obj.name} : {len(obj.data.polygons):,} faces ({time.time() - t0:.1f} s)")
return obj
# ----------------------------------------------------------------------------- analyse de forme
SHAPES = ("Flat", "Bulge", "Hollow", "Ridge", "Fold", "Saddle")
SHAPE_COLORS = np.array([ # couleurs d'affichage des tags (linéaire)
(0.55, 0.55, 0.55), # Flat gris
(0.90, 0.25, 0.15), # Bulge rouge (dôme convexe)
(0.15, 0.35, 0.90), # Hollow bleu (cuvette concave)
(0.95, 0.75, 0.10), # Ridge jaune (crête convexe, une direction)
(0.10, 0.75, 0.85), # Fold cyan (pli / vallée concave, une direction)
(0.70, 0.25, 0.85), # Saddle violet (selle)
])
def _diffuse(vals, e, cnt, iterations):
"""Moyenne sur le voisinage (k anneaux) d'un champ par sommet : (V, ...) → même forme (générateur)."""
flat = vals.reshape(len(vals), -1)
for i in range(iterations):
acc = flat.copy()
np.add.at(acc, e[:, 0], flat[e[:, 1]])
np.add.at(acc, e[:, 1], flat[e[:, 0]])
flat = acc / (cnt + 1.0)[:, None]
yield (i + 1) / max(iterations, 1), None
return flat.reshape(vals.shape)
def analyze_shape(co, normals, e, rings=4, flat_threshold=0.15, aniso_threshold=0.55):
"""Analyse locale de la forme sur un voisinage de `rings` anneaux.
Tenseur de covariance des normales T = moyenne(n nᵀ) ; valeurs propres λ1 ≥ λ2 ≥ λ3 :
- variation = λ2 + λ3 (0 = plat) → `detail` 0–1 (normalisé au 95e centile)
- anisotropie = (λ2 − λ3) / (λ2 + λ3) → ~1 : la surface tourne dans UNE direction (pli, crête, doigt)
- direction du pli = vecteur propre de λ3 → direction le long de laquelle la surface ne tourne pas
Courbure moyenne signée H ≈ −(moyenne des voisins − p)·n / arête² (> 0 convexe, < 0 concave).
Tags : Flat, Bulge (dôme), Hollow (cuvette), Ridge (crête), Fold (pli), Saddle (selle).
Renvoie dict(detail, curvature, anisotropy, shape, flow) (générateur)."""
n = normals / np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12)
cnt = np.zeros(len(co))
np.add.at(cnt, e[:, 0], 1)
np.add.at(cnt, e[:, 1], 1)
T = n[:, :, None] * n[:, None, :]
T = yield from sub(_diffuse(T, e, cnt, rings), 0.0, 0.7)
w, v = np.linalg.eigh(T) # valeurs propres croissantes : w[:,0] = λ3
lam3, lam2 = np.clip(w[:, 0], 0, None), np.clip(w[:, 1], 0, None)
var = lam2 + lam3
aniso = (lam2 - lam3) / np.maximum(var, 1e-12)
flow = v[:, :, 0] # vecteur propre de λ3
m, _ = _neighbor_mean(co, e)
el = np.linalg.norm(co[e[:, 0]] - co[e[:, 1]], axis=1).mean()
H = -np.einsum("ij,ij->i", m - co, n) / max(el * el, 1e-12)
H = yield from sub(_diffuse(H, e, cnt, rings), 0.75, 1.0)
scale = max(np.percentile(var, 95), 1e-12)
detail = np.clip(var / scale, 0, 1)
hs = max(np.percentile(np.abs(H), 95), 1e-12)
curv = np.clip(H / hs, -1, 1)
shape = np.zeros(len(co), np.int32)
active = detail >= flat_threshold
iso = aniso < aniso_threshold
shape[active & iso & (curv > 0.15)] = 1
shape[active & iso & (curv < -0.15)] = 2
shape[active & ~iso & (curv >= 0)] = 3
shape[active & ~iso & (curv < 0)] = 4
shape[active & iso & (np.abs(curv) <= 0.15)] = 5
return {"detail": detail, "curvature": curv, "anisotropy": aniso, "shape": shape, "flow": flow}
def mesh_arrays(me):
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
nr = np.empty(len(me.vertices) * 3)
me.vertex_normals.foreach_get("vector", nr)
return co.reshape(-1, 3), nr.reshape(-1, 3), _edges(me)
def write_analysis(obj, res, count):
"""Écrit l'analyse sur les `count` premiers sommets du maillage de base de `obj` (les sommets d'origine
viennent en premier dans un maillage subdivisé) : attributs, couleurs d'affichage, groupes de sommets.
Les couleurs portent le suffixe _Color : groupes de sommets et attributs partagent le même espace de noms."""
me = obj.data
nb = len(me.vertices)
k = min(nb, count)
def put(name, dtype, vals):
if name in me.attributes:
me.attributes.remove(me.attributes[name])
a = me.attributes.new(name, dtype, "POINT")
full = np.zeros((nb,) + vals.shape[1:], vals.dtype)
full[:k] = vals[:k]
a.data.foreach_set("value" if dtype != "FLOAT_VECTOR" else "vector", full.ravel())
put("sw_detail", "FLOAT", res["detail"].astype(np.float32))
put("sw_curvature", "FLOAT", res["curvature"].astype(np.float32))
put("sw_anisotropy", "FLOAT", res["anisotropy"].astype(np.float32))
put("sw_shape", "INT", res["shape"].astype(np.int32))
put("sw_flow", "FLOAT_VECTOR", res["flow"].astype(np.float32))
def color(name, rgb):
if name in me.color_attributes:
me.color_attributes.remove(me.color_attributes[name])
ca = me.color_attributes.new(name, "FLOAT_COLOR", "POINT")
full = np.ones((nb, 4), np.float32)
full[:k, :3] = rgb[:k]
ca.data.foreach_set("color", full.ravel())
return ca
d = res["detail"][:, None]
ramp = np.where(d < 0.5, (1 - 2 * d) * np.array([0.05, 0.15, 0.6]) + 2 * d * np.array([0.1, 0.8, 0.2]),
(2 - 2 * d) * np.array([0.1, 0.8, 0.2]) + (2 * d - 1) * np.array([0.9, 0.1, 0.05]))
color("SW_Detail_Color", ramp.astype(np.float32))
color("SW_Shape_Color", SHAPE_COLORS[res["shape"]].astype(np.float32))
# groupes de sommets : densité + un groupe par tag (utilisables comme filtres Despike, masques, etc.)
groups = {"SW_Detail": res["detail"]}
for i, nm in enumerate(SHAPES[1:], start=1):
groups[f"SW_{nm}"] = (res["shape"] == i).astype(float)
for nm, w in groups.items():
vg = obj.vertex_groups.get(nm) or obj.vertex_groups.new(name=nm)
vg.remove(list(range(nb)))
w = np.asarray(w[:k], float)
for val in np.unique(np.round(w, 2)):
if val <= 0:
continue
idx = np.nonzero(np.round(w, 2) == val)[0].tolist()
vg.add(idx, float(val), "REPLACE")
me.update()
return {nm: int((res["shape"][:k] == i).sum()) for i, nm in enumerate(SHAPES)}