"""Tests de Slice Wrap en arrière-plan (sans installer l'add-on). "C:\\Program Files\\Blender Foundation\\Blender 5.2\\blender.exe" -b -P tests/run_tests.py -- [dossier_sortie] [--head] - rapide (~40 s) : sphère trouée et bruitée → nom / numérotation / collection / Regenerate, Envelope fermée, Wrap + Freeze (réparation), Despike (global, filtré, refus groupe absent, Protect Detail), Analyze. - --head : chaîne complète sur la tête brute Pixal3D (11,8 M faces, ~2 min) + rendus de contrôle. Termine par « RESULTAT : TOUT OK » ou la liste des échecs ; code de retour 0 si tout est bon, 1 sinon (convention de la chaîne a2m : `tests/run_tests.py`, lancé avant chaque release). """ import importlib import math import os import sys import time import bmesh import bpy import numpy as np from mathutils import Vector ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(ROOT)) for k in [k for k in sys.modules if k.startswith(os.path.basename(ROOT))]: del sys.modules[k] mod = importlib.import_module(os.path.basename(ROOT)) ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] OUT = next((a for a in ARGS if not a.startswith("--")), ROOT) HEAD = r"C:\Users\a-mma\Desktop\CharacterDesign\Work3D\refs\head\3D\head_raw_pixal3d_front_s56.glb" bpy.ops.wm.read_factory_settings(use_empty=True) mod.register() sc = bpy.context.scene s = sc.slice_wrap fails = [] def check(name, cond, msg=""): print(("OK " if cond else "FAIL ") + f"{name} : {msg}") if not cond: fails.append(name) def sel(o): for x in bpy.context.selected_objects: x.select_set(False) o.hide_set(False) o.select_set(True) bpy.context.view_layer.objects.active = o def topo(o): bm = bmesh.new() bm.from_mesh(o.data) seen, pieces = set(), 0 for v in bm.verts: if v in seen: continue pieces += 1 st = [v] seen.add(v) while st: x = st.pop() for e in x.link_edges: w = e.other_vert(x) if w not in seen: seen.add(w) st.append(w) r = dict(faces=len(bm.faces), boundary=sum(e.is_boundary for e in bm.edges), nonmanifold=sum(not e.is_manifold for e in bm.edges), pieces=pieces) bm.free() return r def safe(op, **kw): try: return op(**kw) except RuntimeError: return {"CANCELLED"} # ----------------------------------------------------------------------------- source : sphère trouée + bruit bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=5, radius=1.0) src = bpy.context.active_object src.name = "Tete" bm = bmesh.new() bm.from_mesh(src.data) bmesh.ops.delete(bm, geom=[f for f in bm.faces if f.calc_center_median().z > 0.85 or abs(f.calc_center_median().x - 0.6) < 0.05], context="FACES") # trous rng = np.random.default_rng(0) for v in bm.verts: v.co *= 1.0 + 0.004 * rng.standard_normal() # bruit bm.to_mesh(src.data) bm.free() check("source trouée", topo(src)["boundary"] > 0, str(topo(src))) # ----------------------------------------------------------------------------- nom / collection / Regenerate s.pitch_rel = 1.5 # voxel grossier : test rapide (le défaut 0,6 % → ~100 k faces ×16) pre = bpy.data.collections.new("SliceWrapCollection") sc.collection.children.link(pre) keep = bpy.data.objects.new("DejaLa", None) pre.objects.link(keep) sel(src) t = time.time() r1 = bpy.ops.slice_wrap.envelope() e1 = bpy.context.active_object check("envelope", r1 == {"FINISHED"}, f"{e1.name} en {time.time() - t:.1f} s") check("nom suffixe", e1.name == "Tete_slicewrapped", e1.name) tp = topo(e1) check("enveloppe fermée", tp["boundary"] == 0 and tp["nonmanifold"] == 0 and tp["pieces"] == 1, str(tp)) check("source cachée", src.hide_get()) sel(src) bpy.ops.slice_wrap.envelope() e2 = bpy.context.active_object check("numérotation", e2.name == "Tete_slicewrapped.001" and e1.name in bpy.data.objects, e2.name) check("collection réutilisée", [c.name for c in e1.users_collection] == ["SliceWrapCollection"] and "DejaLa" in pre.objects and len([c for c in bpy.data.collections if c.name.startswith("SliceWrapCollection")]) == 1) nb = len(e2.data.polygons) s.pitch_rel = 1.0 sel(e2) r = bpy.ops.slice_wrap.regenerate() check("regenerate sur place", r == {"FINISHED"} and bpy.context.active_object == e2 and len(e2.data.polygons) != nb, f"{nb} → {len(e2.data.polygons)} faces") s.pitch_rel = 1.5 s.use_collection = False s.name_suffix = "" sel(src) bpy.ops.slice_wrap.envelope() e4 = bpy.context.active_object check("suffixe vide sans écraser la source", e4.name == "Tete.001" and src.name == "Tete" and [c.name for c in e4.users_collection] == ["Scene Collection"], e4.name) s.use_collection = True s.name_suffix = "_slicewrapped" # ----------------------------------------------------------------------------- objet sélectionné / annulation check("plus de réglage Target", "target" not in s.bl_rna.properties) check("opérateurs longs : propriété modal", "modal_run" in bpy.ops.slice_wrap.run.get_rna_type().properties) sel(src) src.select_set(False) check("rien de sélectionné → Slice Wrap grisé", not bpy.ops.slice_wrap.run.poll()) sel(src) check("objet sélectionné → Slice Wrap actif", bpy.ops.slice_wrap.run.poll()) n_obj, n_me, n_col = len(bpy.data.objects), len(bpy.data.meshes), len(bpy.data.collections) g = mod.step_run(src) # chaîne complète pour compter les tranches, puis on la supprime total = 0 try: while True: next(g) total += 1 except StopIteration as e: mod._remove_object(e.value[0]) sel(src) check("chaîne découpée en tranches", total > 20, f"{total} tranches") for stop in (3, total // 2, total): # annulation (Échap) : enveloppe, projection, juste avant le Reshape final g = mod.step_run(src) steps = 0 try: for _ in range(stop): next(g) steps += 1 except StopIteration: pass g.close() check(f"annulation après {steps} tranches : rien de créé", (len(bpy.data.objects), len(bpy.data.meshes)) == (n_obj, n_me) and len(bpy.data.collections) == n_col, f"objets {len(bpy.data.objects)}/{n_obj}, maillages {len(bpy.data.meshes)}/{n_me}") sel(src) # ----------------------------------------------------------------------------- Wrap + Freeze sel(e1) r = bpy.ops.slice_wrap.wrap_setup() check("wrap setup", r == {"FINISHED"} and [m.type for m in e1.modifiers] == ["MULTIRES", "SHRINKWRAP"], str([m.type for m in e1.modifiers])) r = bpy.ops.slice_wrap.freeze() check("freeze", r == {"FINISHED"} and [m.type for m in e1.modifiers] == ["MULTIRES"], str([m.type for m in e1.modifiers])) check("regenerate refusé si wrappé", safe(bpy.ops.slice_wrap.regenerate) == {"CANCELLED"}) # ----------------------------------------------------------------------------- Despike co0 = [v.co.copy() for v in e1.data.vertices] g = mod.step_despike(e1) next(g), next(g), next(g) g.close() check("despike annulé : maillage intact", [m.type for m in e1.modifiers] == ["MULTIRES"] and all((v.co - c).length == 0 for v, c in zip(e1.data.vertices, co0)) and not any(o.name.endswith("_tmp") for o in bpy.data.objects)) vg = e1.vertex_groups.new(name="Zone") vg.add([v.index for v in e1.data.vertices if v.co.z < 0], 1.0, "REPLACE") e1.slice_wrap_despike_groups.add().group = "Zone" r = bpy.ops.slice_wrap.despike() check("despike filtré", r == {"FINISHED"}) e1.slice_wrap_despike_groups.clear() e1.slice_wrap_despike_groups.add().group = "Absent" check("despike refus groupe absent", safe(bpy.ops.slice_wrap.despike) == {"CANCELLED"}) e1.slice_wrap_despike_groups.clear() s.despike_protect = True check("despike global + Protect Detail", bpy.ops.slice_wrap.despike() == {"FINISHED"}) s.despike_protect = False # ----------------------------------------------------------------------------- Analyze r = bpy.ops.slice_wrap.analyze() me = e1.data check("analyze", r == {"FINISHED"} and {"sw_detail", "sw_shape", "sw_flow"} <= {a.name for a in me.attributes} and {"SW_Detail_Color", "SW_Shape_Color"} <= {c.name for c in me.color_attributes} and {"SW_Detail", "SW_Fold", "SW_Bulge"} <= {g.name for g in e1.vertex_groups}) check("pas de collision couleur / groupe", not ({c.name for c in me.color_attributes} & {g.name for g in e1.vertex_groups})) r = bpy.ops.slice_wrap.show(mode="SW_Detail_Color") check("show", r == {"FINISHED"} and me.color_attributes.render_color_index == me.color_attributes.active_color_index) # ----------------------------------------------------------------------------- tête réelle (optionnel) if "--head" in ARGS and os.path.isfile(HEAD): bpy.ops.import_scene.gltf(filepath=HEAD) coque = bpy.context.selected_objects[0] coque.name = "Coque" sel(coque) t = time.time() r = bpy.ops.slice_wrap.run() env = bpy.context.active_object check("tête : chaîne complète", r == {"FINISHED"} and [m.type for m in env.modifiers] == ["MULTIRES"], f"{env.name}, base {len(env.data.polygons)} faces, {time.time() - t:.0f} s") bpy.ops.slice_wrap.analyze() sc.render.engine = "BLENDER_WORKBENCH" sc.render.resolution_x = sc.render.resolution_y = 800 sc.display.shading.light = "MATCAP" cam = bpy.data.objects.new("CAM", bpy.data.cameras.new("CAM")) sc.collection.objects.link(cam) sc.camera = cam cam.data.type = "ORTHO" for o in sc.objects: if o.type == "MESH": o.hide_render = o != env for tag, loc, rot, scl in (("face", (0, -2, 0), (90, 0, 0), 0.75), ("cote", (2, 0.05, 0.08), (90, 0, 90), 0.45)): cam.location = Vector(loc) cam.rotation_euler = tuple(math.radians(a) for a in rot) cam.data.ortho_scale = scl sc.render.filepath = os.path.join(OUT, f"slice_wrap_head_{tag}.png") bpy.ops.render.render(write_still=True) print("IMAGE", sc.render.filepath) mod.unregister() print("RESULTAT :", "TOUT OK" if not fails else "ECHECS " + ", ".join(fails)) sys.exit(1 if fails else 0) # code de retour lu par « a2m validate --tests » et « a2m release »