diff --git a/CITATION.cff b/CITATION.cff index 4e1a3d6..fad6979 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - given-names: Danyal family-names: Sarfraz -version: 1.11.0 +version: 1.12.0 license: MIT repository-code: "https://github.com/Danyals-code/Addon-for-MultiView-Rendering-AMVR" abstract: >- diff --git a/README.md b/README.md index fe8538e..194b05e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ every view in one click, with predictable file names like `AA_01_Widget_Front_cl - **Blender:** 4.0 and newer - **Panel location:** `3D Viewport → Sidebar (N key) → MultiView` -- **Version:** 1.11.0 +- **Version:** 1.12.0 - **Cite:** see [`CITATION.cff`](CITATION.cff) --- @@ -186,6 +186,79 @@ The products' own materials are never edited. The preset is applied by a tempora Geometry Nodes modifier, `MV_Material`, added while the product renders and removed afterwards, so it also works on STL imports, which arrive with no materials at all. +#### Material Pool + +The **Material Pool** sub-panel turns a folder of PBR texture sets into materials for +your products. Point **Texture Folder** at a folder holding one sub-folder per material, as +downloaded from [ambientCG](https://ambientcg.com) or [Poly Haven](https://polyhaven.com), +and click the refresh button. Every folder with a colour map becomes a pool material: + +``` +textures/ + Bricks_A/ Bricks_A_1K-PNG_Color.png, _Roughness, _NormalGL, _Displacement + oak_veneer/ oak_veneer_diff_1k.png, _rough_1k, _arm_1k, _nor_gl_1k + Metal_Plate/ Metal_Plate_Color.jpg, Metal_Plate_Metalness.png +``` + +Maps are recognised by their names: +- colour: `Color`, `BaseColor`, `Albedo`, `Diffuse`, `diff` +- roughness: `Roughness`, `rough` +- metallic: `Metalness`, `Metallic`, `metal` +- packed AO/rough/metal: `arm` +- height: `Displacement`, `Height`, `disp`, `bump` +- opacity: `Opacity`, `Alpha` + +A folder holding a single image uses it as the colour map, unless its name ends in a data +map's word (`_rough`, `_Metalness`, ...). A material is named after its folder. A folder +called `textures`, `maps` or `2k` takes the name of the one above it, since Poly Haven +downloads keep their maps in `/textures`. Two folders with the same name are told +apart by their path (`wood_Oak`, `metal_Oak`). A material keeps its name when you scan +again, so a library that later gains a second `Oak` names only the new one by its path. + +The list shows the maps found for each material as letters (**C** colour, **R** roughness, +**M** metallic, **A** packed, **H** height, **N** normal, **O** opacity). Untick a material +to leave it out. + +The list keeps where each texture file is. If the files move (a moved library, or the +`.blend` opened on another machine), the folder is scanned again before rendering. If the +textures a material uses still cannot be found, pre-flight stops the batch. + +CAD files have no UV maps, so the textures are box projected in each part's own +coordinates. They stay put when the product turns and need no unwrapping. **Texture Size** +is how much of the real part one repeat of a texture covers, in millimetres. A 100 mm wood +texture looks the same size on a 30 mm knob and a 2 m cabinet, whatever scale Fit on Import +gave them. A product built by hand and never standardized is taken to be in metres. + +Normal maps are listed but not used, because they need the UVs these meshes do not have. +Opacity maps are listed but not used either. Masks, part IDs and COCO/BOP annotations are +made once per view, so a material that cut holes in the product would leave them wrong for +that image. + +**Product variants.** Set **Variants** above 0 and every Full render is made that many +times per view, each with a different draw from the pool. With **Per Part** on, every part +of the product draws its own material; off, the whole product shares one. A product set to +**Keep Own** in **Products to render** is left out and renders once, in its own materials. + +The files get a `_v01`, `_v02`, ... suffix (or use `{variant}` in the naming templates), and +each variant gets its own contact sheet. Clay renders, line art, masks, depth, normals and +part IDs do not depend on the material, so they are rendered once per view. The manifest's +`variant` and `materials` columns record what each image wears, for example +`Block=Bricks_A; Peg=oak_veneer`. + +Draws come from the **Seed**, the product's label, the variant number and the part, so +re-running gives the same variants. Adding variants, products or parts never changes the +other draws. Adding or unticking a pool material changes only the draws it wins or held. +The pool's contents are recorded in the manifest's provenance block. + +**Material patches.** **Render Patches** writes one image per ticked material into +`materials/` in the output folder. It shows a flat swatch, one repeat seen straight on, next +to a ball under studio lights, with the material's name above. Tick **With Render All** to +make them as part of every batch, listed in the manifest. In a sharded command-line run the +first shard makes them, and rendering just the active product skips them. Patches render in +a scene of their own with the scene's colour management, and the pool materials are deleted +again after each run, so your scene is untouched. They use Cycles when your engine is +Workbench, which cannot show textures. + ### 2. Import Products Add files to the list with **+**, or a whole folder with the folder button, then click @@ -538,6 +611,7 @@ change it with tokens: | `{product}` | `Widget` | The product collection's name | | `{view}` | `Front` | The camera view, or `Sheet` / `Turntable` | | `{mode}` | `clay` | `clay`, `render` (Both mode), or empty for Full Render | +| `{variant}` | `v01` | The Material Pool variant, empty for everything rendered once | The defaults are `{label}_{product}` for the folder and `{label}_{product}_{view}_{mode}` for the file. A token that comes out empty takes one separator next to it along with it, so @@ -550,6 +624,9 @@ the file. A token that comes out empty takes one separator next to it along with fields (the first image of the batch, as it will be written) instead of silently vanishing. - If the template would give two images the same name (no `{view}`, or no `{mode}` in Both mode), the render stops before it starts and says so. +- With Material Pool variants on, a file name template without `{variant}` gets `_{variant}` + added at the end, unless the folder template has `{variant}`, e.g. + `{label}_{product}/{variant}`, which gives each variant a folder of its own. #### Contact sheets @@ -612,9 +689,9 @@ copies are rebuilt. **Manifest** (CSV + JSON by default) writes `manifest.csv` and/or `manifest.json` into the output directory, listing every output with: -`label`, `product`, `kind` (render, processed, sheet, turntable), `view`, `mode`, `path` -(relative to the output directory), `width`, `height`, `source_file`, `source_size` and -`source_units`. +`label`, `product`, `kind` (render, processed, sheet, turntable, patch, ...), `view`, `mode`, +`path` (relative to the output directory), `width`, `height`, `source_file`, `source_size`, +`source_units`, `source_sha256`, `variant` and `materials` (see Material Pool). `source_file`, `source_size` and `source_units` are recorded when a file is imported: `source_size` is the product's bounding box before Fit on Import rescaled it, in @@ -733,7 +810,7 @@ a faithful outline, not a finished drawing. | **Camera Jitter**, **Distance Jitter** | Each view's camera swings up to this far off its canonical direction, and moves nearer or farther | | **Lights** | The rig turns about the vertical by up to **Light Turn**, and each light changes strength by up to **Light Strength** | | **HDRI** | Each image is lit and backed by a random HDRI from **HDRI Folder** (empty: the studio HDRIs that ship with Blender), turned and scaled at random | -| **Materials** | The product gets a random material preset and colour per image, in Full renders | +| **Materials** | The product gets a random material preset and colour per image, in Full renders (not when Material Pool variants are on, which choose the materials instead) | | **Distractors** | Random shapes in random colours, placed behind the product as each camera sees it, in Full renders | Every draw comes from the **Seed**, the product's label and the view, so the same settings diff --git a/multiview_product_renderer/__init__.py b/multiview_product_renderer/__init__.py index 16f4da4..f1da4cb 100644 --- a/multiview_product_renderer/__init__.py +++ b/multiview_product_renderer/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "MultiView Product Renderer", "author": "Danyal Sarfraz", - "version": (1, 11, 0), + "version": (1, 12, 0), "blender": (4, 0, 0), "location": "View3D > Sidebar > MultiView", "description": "Import CAD and mesh files, set up cameras and studio lighting, batch render products across views.", @@ -13,6 +13,7 @@ import os import re import csv +import hashlib import json import math import struct @@ -413,6 +414,15 @@ def lighting_specs(key): return lights +class MV_PoolMaterial(PropertyGroup): + name: StringProperty(name="Name") + enabled: BoolProperty(name="Use", default=True, + description="Draw this material when making variants and patches") + maps: StringProperty(description="JSON {map kind: file path}", options={'HIDDEN'}) + key: StringProperty(description="Colour map's path inside the pool folder", + options={'HIDDEN'}) + + class MV_Issue(PropertyGroup): severity: StringProperty() message: StringProperty() @@ -578,6 +588,46 @@ class MV_Settings(PropertyGroup): min=0.0, max=1.0, default=(0.8, 0.8, 0.8), ) + pool_folder: StringProperty( + name="Texture Folder", subtype='DIR_PATH', + description="Folder of PBR texture sets, one folder per material, as downloaded " + "from ambientCG or Poly Haven. Scan it to fill the pool", + ) + material_pool: CollectionProperty(type=MV_PoolMaterial) + material_pool_index: IntProperty(default=0) + pool_tile_mm: FloatProperty( + name="Texture Size", + description="How much of the real part one repeat of a texture covers, in " + "millimetres. Kept the same whatever scale the product renders at", + default=100.0, min=0.1, soft_max=2000.0, + ) + pool_variants: IntProperty( + name="Variants", + description="Render every product this many times in Full renders, each time with " + "materials drawn from the pool. 0 turns variants off. Clay, line art, " + "masks and data passes do not change with materials, so they are " + "rendered once", + default=0, min=0, max=500, + ) + pool_seed: IntProperty( + name="Seed", default=0, min=0, + description="The same seed always gives the same materials for each product and " + "variant", + ) + pool_per_part: BoolProperty( + name="Per Part", default=True, + description="Each part draws its own material. Off, the whole product gets one " + "material per variant", + ) + pool_patches: BoolProperty( + name="Patches", + description="With Render All, also write one image per pool material: a flat " + "swatch and a material ball side by side, in materials/", + default=False, + ) + patch_size: IntProperty(name="Patch Size", default=512, min=64, max=4096, + description="Pixels per side of each half of a patch") + render_output_dir: StringProperty( name="Output Directory", subtype='DIR_PATH', default="//renders/", ) @@ -806,8 +856,9 @@ class MV_Settings(PropertyGroup): name="File Name", description="Name of each image, without extension. Tokens: {label} (AA_01), " "{prefix} (AA), {number} (01), {product}, {view}, {mode} (render, " - "clay, or nothing for Full Render). A token that comes out empty " - "takes one separator next to it with it", + "clay, or nothing for Full Render), {variant} (v01, ... for Material " + "Pool variants, added at the end if missing). A token that comes out " + "empty takes one separator next to it with it", default="{label}_{product}_{view}_{mode}", ) folder_template: StringProperty( @@ -3054,6 +3105,505 @@ def remove_product_materials(): for mod in [m for m in obj.modifiers if m.type == 'NODES' and m.name.startswith(MATERIAL_MOD)]: obj.modifiers.remove(mod) + if TILE_KEY in obj: + del obj[TILE_KEY] + + +# -- material pool ---------------------------------------------------------------- +# +# A folder of PBR texture sets, as downloaded from ambientCG or Poly Haven: +# one folder per material, holding its colour, roughness, metallic, normal and +# height maps. Each becomes a Blender material that can be put on any part. +# +# CAD tessellations have no UV maps, so the textures are box projected in the +# object's own coordinates: they stick to the part when it turns, and need no +# unwrapping. How big one repeat of the texture is comes from an object +# custom property the material reads, so one material serves products of +# every size and is always shown at the same real-world scale. + +TILE_KEY = "mv_tile_scale" +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".exr", ".webp", ".bmp", ".tga"} +# Checked in this order, against the file's name with the material's shared +# prefix removed. Metallic is last because "metal" is also a common word in +# material names; the prefix is stripped first for the same reason. +MAP_KEYWORDS = ( + ("arm", {"arm"}), + ("normal_dx", {"normaldx", "nordx", "dx"}), + ("normal", {"normalgl", "norgl", "normal", "nor", "nrm", "gl"}), + ("color", {"basecolor", "albedo", "diffuse", "diff", "color", "colour", "col"}), + ("roughness", {"roughness", "rough", "rgh"}), + ("height", {"displacement", "height", "disp", "bump"}), + ("opacity", {"opacity", "alpha"}), + ("ao", {"ambientocclusion", "occlusion", "ao"}), + ("metallic", {"metalness", "metallic", "metal", "mtl"}), +) +MAP_LETTERS = {"color": "C", "roughness": "R", "metallic": "M", "arm": "A", + "normal": "N", "normal_dx": "N", "height": "H", "opacity": "O", "ao": "o"} + + +def classify_maps(files): + """{map kind: path} for one folder's image files.""" + if len(files) == 1: + # A folder with one picture in it is a plain colour texture + # ("Brushed_Metal.jpg"), unless its name ends in the word for a data + # map ("thing_rough_2k", "x_Metalness"). Plain "metal" is left out: + # it names materials too. + stem = os.path.splitext(os.path.basename(files[0]))[0].lower() + tokens = [t for t in re.split(r"[_\-. ]+", stem) + if t and not re.fullmatch(r"\d+k|png|jpe?g|exr", t)] + last = tokens[-1] if tokens else "" + kind = next((kind for kind, keys in MAP_KEYWORDS + if last in keys and kind != "color" and last != "metal"), None) + return {kind or "color": files[0]} + stems = [os.path.splitext(os.path.basename(f))[0].lower() for f in files] + # The shared start of the names, cut back to a separator so it never eats + # the start of a map's own word: rock_diff and rock_disp share "rock_", + # not "rock_di". + prefix = os.path.commonprefix(stems) + prefix = prefix[:max(prefix.rfind(c) for c in "_-. ") + 1] + found = {} + unknown = [] + for path, stem in zip(files, stems): + tokens = [t for t in re.split(r"[_\-. ]+", stem[len(prefix):]) if t] + # Pairs catch maps split by a separator: nor_gl, normal_dx. + words = set(tokens) | {a + b for a, b in zip(tokens, tokens[1:])} + kind = next((kind for kind, keys in MAP_KEYWORDS if words & keys), None) + if kind is None: + unknown.append(path) + else: + found.setdefault(kind, path) + if "normal" not in found and "normal_dx" in found: + found["normal"] = found["normal_dx"] + # Maps named for what they are, next to one picture named for nothing: + # that picture is the colour. + if "color" not in found and len(unknown) == 1: + found["color"] = unknown[0] + return found + + +# Folders that say how a set was packed rather than what it is: the +# material's name is the folder above. Poly Haven keeps every asset's maps +# in "/textures". +GENERIC_FOLDERS = re.compile(r"^(textures?|maps?|tex|images?|img|pbr|\d+k|png|jpe?g|exr)$", + re.IGNORECASE) + + +def pool_key(folder, color_path): + """What identifies a material across scans: its colour map's path inside + the pool folder, which stays the same when the whole library moves.""" + rel = os.path.relpath(color_path, os.path.normpath(bpy.path.abspath(folder or ""))) + return os.path.normcase(rel).replace("\\", "/") + + +def scan_pool(folder, keep=None): + """[(name, {map: path})] for every texture set under folder, sorted. A + folder counts as a material when it has a colour map. Names are unique, + also once made safe for file names, since patches are saved under them. + + keep is {pool_key: name} from an earlier scan. Those materials keep their + names, so a library that gains a second "Oak" names only the newcomer by + its path, rather than renaming the Oak every draw and tick refers to.""" + keep = keep or {} + folder = os.path.normpath(bpy.path.abspath(folder or "")) + if not os.path.isdir(folder): + return [] + found = [] + for current, dirs, files in os.walk(folder): + dirs.sort() + images = sorted(os.path.join(current, f) for f in files + if file_ext(f) in IMAGE_EXTENSIONS) + maps = classify_maps(images) if images else {} + if "color" not in maps: + continue + place = os.path.normpath(current) + while GENERIC_FOLDERS.match(os.path.basename(place)) and place != folder: + place = os.path.dirname(place) + found.append((place, maps)) + def slot(name): + return safe_name(name).lower() + + names, taken = [None] * len(found), set() + for i, (_, maps) in enumerate(found): + old = keep.get(pool_key(folder, maps["color"])) + if old and slot(old) not in taken: + names[i] = old + taken.add(slot(old)) + counts = {} + for i, (place, _) in enumerate(found): + if names[i] is None: + key = slot(os.path.basename(place)) + counts[key] = counts.get(key, 0) + 1 + for i, (place, _) in enumerate(found): + if names[i] is not None: + continue + name = os.path.basename(place) + if counts[slot(name)] > 1 or slot(name) in taken: + # Two "Oak" folders in different places: name them by where they are. + rel = os.path.relpath(place, folder) + name = "_".join(p for p in rel.split(os.sep) if p not in {"", "."}) or name + unique, n = name, 2 + while slot(unique) in taken: + unique, n = f"{name}_{n}", n + 1 + taken.add(slot(unique)) + names[i] = unique + return [(name, maps) for name, (_, maps) in zip(names, found)] + + +def scan_pool_into(settings): + """Fill the pool list from its folder, keeping each material's name and + tick.""" + old = {m.key: (m.name, m.enabled) for m in settings.material_pool if m.key} + ticks = {m.name: m.enabled for m in settings.material_pool} + settings.material_pool.clear() + folder = settings.pool_folder + for name, maps in scan_pool(folder, keep={k: n for k, (n, _) in old.items()}): + key = pool_key(folder, maps["color"]) + item = settings.material_pool.add() + item.name = name + item.key = key + item.maps = json.dumps(maps) + item.enabled = old[key][1] if key in old else ticks.get(name, True) + return len(settings.material_pool) + + +def pool_entries(settings): + """[(name, maps)] of the ticked pool materials.""" + entries = [] + for item in settings.material_pool: + if item.enabled: + try: + entries.append((item.name, json.loads(item.maps))) + except ValueError: + continue + return entries + + +# The maps pool_material reads. The rest are listed but never loaded, so a +# missing normal or AO file does not matter. +USED_MAPS = {"color", "roughness", "metallic", "arm", "height"} + + +def pool_missing(settings): + """Ticked pool materials with a texture file it uses that is no longer there.""" + return [name for name, maps in pool_entries(settings) + if not all(os.path.isfile(p) for k, p in maps.items() if k in USED_MAPS)] + + +def refresh_pool(settings): + """Scan the folder again when the pool is empty or points at files that + have gone: a moved library, or a .blend opened on another machine, where + the folder setting (relative to the .blend) still finds the textures.""" + if not os.path.isdir(bpy.path.abspath(settings.pool_folder or "")): + return + if not settings.material_pool or pool_missing(settings): + scan_pool_into(settings) + + +def pool_snapshot(settings): + """The pool as provenance records it: every material, ticked or not, and + the files it was made from.""" + snap = [] + for item in settings.material_pool: + try: + maps = json.loads(item.maps) + except ValueError: + maps = {} + snap.append({"name": item.name, "enabled": item.enabled, + "maps": {k: os.path.basename(v) for k, v in sorted(maps.items())}}) + return snap + + +def remove_pool_materials(): + """Delete the pool materials a run made, and the textures only they used.""" + images = set() + for mat in [m for m in bpy.data.materials if m.name.startswith("MV_Pool_")]: + if mat.node_tree is not None: + images.update(n.image for n in mat.node_tree.nodes + if n.type == 'TEX_IMAGE' and n.image is not None) + bpy.data.materials.remove(mat) + for image in images: + if image.users == 0: + bpy.data.images.remove(image) + + +def pool_material(name, maps): + """A Principled material from a texture set, box projected at the scale + each object's mv_tile_scale property asks for (repeats per unit). + + Built once and then reused for as long as the texture set is the same: + rebuilding the nodes would make EEVEE compile the shader again.""" + mat_name = f"MV_Pool_{name}" + signature = json.dumps(maps, sort_keys=True) + mat = bpy.data.materials.get(mat_name) + if mat is not None and mat.get("mv_pool_maps") == signature: + return mat + mat = mat or bpy.data.materials.new(mat_name) + mat["mv_pool_maps"] = signature + tree = node_tree_of(mat) + tree.nodes.clear() + nodes, links = tree.nodes, tree.links + out = nodes.new("ShaderNodeOutputMaterial") + out.location = (900, 0) + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + bsdf.location = (550, 0) + links.new(bsdf.outputs[0], out.inputs["Surface"]) + coords = nodes.new("ShaderNodeTexCoord") + coords.location = (-900, 0) + tile = nodes.new("ShaderNodeAttribute") + tile.location = (-900, -250) + tile.attribute_type = 'OBJECT' + tile.attribute_name = TILE_KEY + scaled = nodes.new("ShaderNodeVectorMath") + scaled.location = (-650, 0) + scaled.operation = 'SCALE' + links.new(coords.outputs["Object"], scaled.inputs[0]) + links.new(tile.outputs["Fac"], scaled.inputs["Scale"]) + + def texture(kind, y, data=True): + node = nodes.new("ShaderNodeTexImage") + node.location = (-350, y) + node.image = bpy.data.images.load(maps[kind], check_existing=True) + # Colour keeps the space Blender reads from the file (sRGB for 8-bit, + # linear for EXR); setting one that is already set reloads the image. + if data and node.image.colorspace_settings.name != 'Non-Color': + node.image.colorspace_settings.name = 'Non-Color' + node.projection = 'BOX' + node.projection_blend = 0.25 + links.new(scaled.outputs["Vector"], node.inputs["Vector"]) + return node + + links.new(texture("color", 300, data=False).outputs["Color"], bsdf.inputs["Base Color"]) + if "roughness" in maps: + links.new(texture("roughness", 0).outputs["Color"], bsdf.inputs["Roughness"]) + if "metallic" in maps: + links.new(texture("metallic", -250).outputs["Color"], bsdf.inputs["Metallic"]) + if "arm" in maps and ("roughness" not in maps or "metallic" not in maps): + arm = texture("arm", -500) + split = nodes.new("ShaderNodeSeparateColor") + split.location = (150, -500) + links.new(arm.outputs["Color"], split.inputs["Color"]) + if "roughness" not in maps: + links.new(split.outputs[1], bsdf.inputs["Roughness"]) + if "metallic" not in maps: + links.new(split.outputs[2], bsdf.inputs["Metallic"]) + # Opacity maps are left off on purpose. Masks, part IDs, COCO and BOP are + # rendered once per view, not per variant, so a material that cut holes + # in the product would leave every annotation of that image wrong. + # Tangent-space normal maps need UVs these meshes do not have; the height + # map drives a bump instead, which works with any projection. + if "height" in maps: + bump = nodes.new("ShaderNodeBump") + bump.location = (300, -300) + bump.inputs["Strength"].default_value = 0.4 + links.new(texture("height", -1000).outputs["Color"], bump.inputs["Height"]) + links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) + return mat + + +def tile_scale(obj, coll, tile_mm): + """Texture repeats per object-space unit, so one repeat covers tile_mm of + the real part whatever scale it is rendered at.""" + to_world = product_to_world(coll) + world_per_source = abs(to_world.to_3x3().determinant()) ** (1.0 / 3.0) or 1.0 + # No recorded units: a product built by hand and never standardized, + # which is in Blender units, metres. + tile_world = tile_mm / UNIT_TO_MM.get(coll.mv_source_units or "m", 1.0) * world_per_source + object_scale = sum(obj.matrix_world.to_3x3().to_scale()) / 3.0 or 1.0 + return object_scale / max(tile_world, 1e-9) + + +def materials_text(assignment): + """'Body=Brushed_Steel; Lid=Oak' for the manifest, or '' for no variant.""" + if not assignment: + return "" + return "; ".join(f"{part}={mat}" for part, mat in sorted(assignment.items())) + + +def pool_draw(seed, label, variant, part, names): + """One material from names, for one part of one variant. + + Every material gets a score from a hash of the seed, the product, the + variant, the part and its own name, and the highest wins. That is a fair + draw, and one that depends on nothing else: adding a product, a variant + or a part changes no one else's materials, and adding or unticking a pool + material changes only the draws it wins or held.""" + def score(name): + key = f"{seed}\x1f{label}\x1f{variant}\x1f{part}\x1f{name}".encode("utf-8") + return hashlib.blake2b(key, digest_size=8).digest() + return max(names, key=score) + + +def takes_variants(coll): + """False for a product set to Keep Own in Products to render: it renders + once, in its own materials, like it does against a batch preset.""" + return getattr(coll, "mv_material", 'DEFAULT') != 'KEEP' + + +def variant_assignments(settings, label, parts, entries): + """[(variant name, {part: pool material})] for one product.""" + names = [n for n, _ in entries] + variants = [] + for v in range(1, settings.pool_variants + 1): + if settings.pool_per_part: + assignment = {p: pool_draw(settings.pool_seed, label, v, p, names) for p in parts} + else: + pick = pool_draw(settings.pool_seed, label, v, "", names) + assignment = {p: pick for p in parts} + variants.append((f"v{v:02d}", assignment)) + return variants + + +def apply_variant(settings, coll, assignment, entries): + """Put a variant's pool materials on a product's parts through the same + temporary modifiers the presets use.""" + maps = dict(entries) + group = material_node_group() + socket = next(item.identifier for item in group.interface.items_tree + if getattr(item, "in_out", None) == 'INPUT' + and getattr(item, "socket_type", None) == 'NodeSocketMaterial') + for obj in collection_meshes(coll): + # Linked objects cannot carry modifiers or properties; like the + # presets, they keep their own materials. + if obj.type != 'MESH' or obj.library is not None or obj.name not in assignment: + continue + pick = assignment[obj.name] + mod = next((m for m in obj.modifiers + if m.type == 'NODES' and m.name.startswith(MATERIAL_MOD)), None) + if mod is None: + mod = obj.modifiers.new(MATERIAL_MOD, 'NODES') + mod.node_group = group + # Only what changed is written: every write makes the object + # evaluate again. + mat = pool_material(pick, maps[pick]) + scale = tile_scale(obj, coll, settings.pool_tile_mm) + if mod.get(socket) != mat or obj.get(TILE_KEY) != scale: + mod[socket] = mat + obj[TILE_KEY] = scale + obj.update_tag() + + +def render_patch(context, name, maps, path, size): + """One image per pool material: a flat swatch seen straight on, and a + ball under studio lights, side by side and labelled. + + Rendered in a scene of its own, so the user's scene is never touched. + Workbench cannot show textures properly, so Cycles stands in for it. + """ + main = context.scene + scene = bpy.data.scenes.new("MV_Patch") + made = [] + work = tempfile.mkdtemp(prefix="mv_patch_") + try: + engine = main.render.engine if main.render.engine != 'BLENDER_WORKBENCH' else 'CYCLES' + scene.render.engine = engine + if engine == 'CYCLES' and hasattr(main, "cycles"): + scene.cycles.samples = min(main.cycles.samples, 64) + scene.cycles.device = main.cycles.device + scene.render.resolution_x = scene.render.resolution_y = size + scene.render.resolution_percentage = 100 + scene.render.film_transparent = False + scene.render.image_settings.file_format = 'PNG' + # The same colour management as the product renders, so a swatch shows + # the material as it looks in them. + scene.display_settings.display_device = main.display_settings.display_device + scene.view_settings.view_transform = main.view_settings.view_transform + scene.view_settings.look = main.view_settings.look + scene.view_settings.exposure = main.view_settings.exposure + scene.view_settings.gamma = main.view_settings.gamma + mat = pool_material(name, maps) + + world = bpy.data.worlds.new("MV_Patch_World") + made.append(world) + tree = node_tree_of(world) + tree.nodes.clear() + bg = tree.nodes.new("ShaderNodeBackground") + out = tree.nodes.new("ShaderNodeOutputWorld") + tree.links.new(bg.outputs["Background"], out.inputs["Surface"]) + scene.world = world + + def add(obj): + scene.collection.objects.link(obj) + made.append(obj) + if obj.data is not None: + made.append(obj.data) + return obj + + def shoot(label): + scene.render.filepath = os.path.join(work, label) + bpy.ops.render.render(write_still=True, scene=scene.name) + return load_pixels(written_path(scene, scene.render.filepath)) + + # Flat swatch: one repeat of the texture filling the frame, lit evenly + # by a plain white environment. + bg.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0) + bg.inputs["Strength"].default_value = 1.0 + plane_mesh = bpy.data.meshes.new("MV_Patch_Plane") + plane_mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], + [], [(0, 1, 2, 3)]) + plane_mesh.materials.append(mat) + plane = add(bpy.data.objects.new("MV_Patch_Plane", plane_mesh)) + plane[TILE_KEY] = 1.0 + top_data = bpy.data.cameras.new("MV_Patch_Top") + top_data.type = 'ORTHO' + top_data.ortho_scale = 1.0 + top = add(bpy.data.objects.new("MV_Patch_Top", top_data)) + top.matrix_world = look_at(Vector((0, 0, 2)), Vector((0, 0, 0))) + scene.camera = top + flat = shoot("flat") + scene.collection.objects.unlink(plane) + + # Ball: the same material on a sphere under a key, fill and rim light. + import bmesh + bg.inputs["Color"].default_value = (0.18, 0.18, 0.18, 1.0) + ball_mesh = bpy.data.meshes.new("MV_Patch_Ball") + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=0.45) + for face in bm.faces: + face.smooth = True + bm.to_mesh(ball_mesh) + bm.free() + ball_mesh.materials.append(mat) + ball = add(bpy.data.objects.new("MV_Patch_Ball", ball_mesh)) + ball[TILE_KEY] = 1.0 + for label, pos, energy in (("Key", (1.6, -1.6, 2.0), 250.0), + ("Fill", (-2.0, -1.2, 0.8), 70.0), + ("Rim", (0.0, 2.2, 1.8), 150.0)): + light = bpy.data.lights.new(f"MV_Patch_{label}", type='AREA') + light.size = 1.5 + light.energy = energy + obj = add(bpy.data.objects.new(f"MV_Patch_{label}", light)) + obj.matrix_world = look_at(Vector(pos), Vector((0, 0, 0))) + cam_data = bpy.data.cameras.new("MV_Patch_View") + cam_data.lens = 50.0 + view = add(bpy.data.objects.new("MV_Patch_View", cam_data)) + view.matrix_world = look_at(Vector((0, -2.3, 1.0)), Vector((0, 0, 0))) + scene.camera = view + sphere = shoot("ball") + + from types import SimpleNamespace + layout = SimpleNamespace(sheet_cell_width=size, sheet_columns=2, sheet_labels=True, + sheet_bg=(1.0, 1.0, 1.0, 1.0), sheet_text_color=(0.1, 0.1, 0.1)) + sheet = contact_sheet([("Flat", flat), ("Ball", sphere)], name, layout, work) + os.makedirs(os.path.dirname(path), exist_ok=True) + save_pixels(sheet, path, 'PNG') + return sheet.shape[1], sheet.shape[0] + finally: + bpy.data.scenes.remove(scene) + for block in made: + try: + if isinstance(block, bpy.types.Object): + bpy.data.objects.remove(block, do_unlink=True) + elif isinstance(block, bpy.types.Mesh): + bpy.data.meshes.remove(block) + elif isinstance(block, bpy.types.Camera): + bpy.data.cameras.remove(block) + elif isinstance(block, bpy.types.Light): + bpy.data.lights.remove(block) + elif isinstance(block, bpy.types.World): + bpy.data.worlds.remove(block) + except ReferenceError: + pass + shutil.rmtree(work, ignore_errors=True) class MV_OT_AddStepFile(Operator, ImportHelper): @@ -3504,6 +4054,71 @@ def execute(self, context): return {'FINISHED'} +class MV_OT_ScanPool(Operator): + """Find the texture sets in the Texture Folder and list them in the pool""" + bl_idname = "mv.scan_pool" + bl_label = "Scan Folder" + + def execute(self, context): + s = context.scene.mv_settings + if not os.path.isdir(bpy.path.abspath(s.pool_folder)): + self.report({'ERROR'}, "Pick a texture folder first.") + return {'CANCELLED'} + found = scan_pool_into(s) + if not found: + self.report({'WARNING'}, "No texture sets found: each needs a colour map " + "(…_Color, …_diff, …_albedo).") + return {'CANCELLED'} + self.report({'INFO'}, f"Found {found} material(s).") + return {'FINISHED'} + + +class MV_OT_SetPool(Operator): + """Tick or untick every material in the pool""" + bl_idname = "mv.set_pool" + bl_label = "Set Pool" + bl_options = {'REGISTER', 'UNDO'} + + state: BoolProperty(default=True) + + def execute(self, context): + for item in context.scene.mv_settings.material_pool: + item.enabled = self.state + return {'FINISHED'} + + +class MV_OT_RenderPatches(Operator): + """Render a flat swatch and a material ball for every ticked pool material, + into the output folder's materials/ folder""" + bl_idname = "mv.render_patches" + bl_label = "Render Patches" + + def execute(self, context): + s = context.scene.mv_settings + refresh_pool(s) + entries = pool_entries(s) + if not entries: + self.report({'ERROR'}, "The material pool is empty: scan a texture folder.") + return {'CANCELLED'} + folder = os.path.join(bpy.path.abspath(s.render_output_dir), "materials") + written, failed = 0, [] + for name, maps in entries: + try: + render_patch(context, name, maps, + os.path.join(folder, safe_name(name) + "_patch.png"), s.patch_size) + written += 1 + except (RuntimeError, OSError) as exc: + failed.append(f"{name}: {exc}") + print(f"[MultiView] patch FAILED {name}: {exc}") + remove_pool_materials() + if failed: + self.report({'WARNING'}, f"Wrote {written} patch(es); {len(failed)} failed, first: " + f"{failed[0]}") + else: + self.report({'INFO'}, f"Wrote {written} patch(es) to {folder}") + return {'FINISHED'} if written else {'CANCELLED'} + + class MV_OT_SaveLightingPreset(Operator): """Save the lights in MV_Lighting, including any you have added or moved, as a preset available in every file""" @@ -4625,12 +5240,12 @@ def expand_template(template, values): return out + literal -def name_values(settings, index, product, view, mode): +def name_values(settings, index, product, view, mode, variant=""): label = product_label(settings.start_prefix, settings.start_number, index, settings.products_per_letter) prefix, _, number = label.rpartition("_") return {"label": label, "prefix": prefix, "number": number, - "product": product, "view": view, "mode": mode} + "product": product, "view": view, "mode": mode, "variant": variant} def output_folder(settings, values): @@ -4649,7 +5264,14 @@ def output_folder(settings, values): def output_basename(settings, values): - return safe_name(expand_template(settings.file_template or DEFAULT_FILE_TEMPLATE, values)) + template = settings.file_template or DEFAULT_FILE_TEMPLATE + # A template written before variants existed still has to keep each + # variant's images apart, so the variant goes on the end, unless the + # folder template already puts each variant in a folder of its own. + if values.get("variant") and "{variant}" not in template \ + and "{variant}" not in (settings.folder_template or DEFAULT_FOLDER_TEMPLATE): + template += "_{variant}" + return safe_name(expand_template(template, values)) class _NameClash(Exception): @@ -5171,7 +5793,7 @@ def expand(k): MEASURABLE_IMAGES = {".png", ".jpg", ".jpeg", ".webp", ".exr", ".tif", ".tiff", ".bmp"} MANIFEST_FIELDS = ("label", "product", "kind", "view", "mode", "path", "width", "height", "source_file", "source_size", "source_units", - "source_sha256") + "source_sha256", "variant", "materials") REPOSITORY_URL = "https://github.com/Danyals-code/Addon-for-MultiView-Rendering-AMVR" @@ -5188,7 +5810,6 @@ def tool_citation(): def file_sha256(path): - import hashlib digest = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(1 << 20), b""): @@ -5212,6 +5833,8 @@ def settings_snapshot(settings): snap["custom_views"] = [{"name": cv.name, "enabled": cv.enabled, "ortho": cv.is_ortho, "direction": [cv.dir_x, cv.dir_y, cv.dir_z]} for cv in settings.custom_views] + # Which materials were in the pool decides every variant's draws. + snap["material_pool"] = pool_snapshot(settings) return snap @@ -5349,6 +5972,10 @@ def write_manifest(out_root, records, labels, fmt, name="manifest", provenance=N files that are no longer on disk are dropped. """ rows = [r for r in read_manifest(out_root, name) if r.get("label") not in labels] + # Rows from a manifest written before these columns existed. + for row in rows: + row.setdefault("variant", "") + row.setdefault("materials", "") for rec in records: if not file_ok(rec["path"]): continue @@ -5356,6 +5983,8 @@ def write_manifest(out_root, records, labels, fmt, name="manifest", provenance=N if width is None and file_ext(rec["path"]) in MEASURABLE_IMAGES: width, height = image_size(rec["path"]) row = {k: rec.get(k) for k in MANIFEST_FIELDS} + row["variant"] = rec.get("variant") or "" + row["materials"] = rec.get("materials") or "" row.update(path=os.path.relpath(rec["path"], out_root).replace("\\", "/"), width=width, height=height) rows.append(row) @@ -5699,6 +6328,20 @@ def preflight(context, only_active=False): _issue(issues, 'ERROR', "Data passes and drawings need Cycles") if s.random_enabled and s.random_hdri and not hdri_files(s): _issue(issues, 'WARNING', "Randomize: no HDRIs in the HDRI folder") + if s.pool_variants or s.pool_patches: + refresh_pool(s) + missing = pool_missing(s) + if not pool_entries(s): + _issue(issues, 'ERROR', "Material pool is empty: scan a folder") + elif missing: + more = f" (+{len(missing) - 1} more)" if len(missing) > 1 else "" + _issue(issues, 'ERROR', f"Material pool: textures of {missing[0]}{more} are " + "missing; point Texture Folder at the library and scan") + if s.pool_variants: + if s.render_mode == 'CLAY': + _issue(issues, 'WARNING', "Variants only change Full renders") + elif scene.render.engine == 'BLENDER_WORKBENCH': + _issue(issues, 'WARNING', "Workbench barely shows textures") if s.export_croissant and not s.dataset_license: _issue(issues, 'WARNING', "Croissant: dataset has no licence set") if s.export_bop: @@ -5919,6 +6562,7 @@ def _restore(self, context): remove_object(cam) reset_light_rig() remove_product_materials() + remove_pool_materials() ground = bpy.data.objects.get(GROUND_NAME) if ground is not None and getattr(self, "_ground_home", None) is not None: ground.location = self._ground_home @@ -5948,7 +6592,8 @@ def _isolate(self, context, product): coll = bpy.data.collections.get(product) if coll is not None: s = context.scene.mv_settings - apply_product_material(s, coll, force=s.random_enabled and s.random_material) + apply_product_material(s, coll, force=(bool(self._pool) and takes_variants(coll)) + or (s.random_enabled and s.random_material)) def _stage(self, context, job, camera): """Turn the lights to this camera and stand the ground under this @@ -6057,7 +6702,7 @@ def _randomize(self, context, job, camera): scene.world = random_world(path, rng.uniform(0.0, 2.0 * math.pi), rng.uniform(s.random_hdri_min, s.random_hdri_max)) - if s.random_material and full: + if s.random_material and full and not job.get("variant"): rng = view_rng(s, label, view, 4) preset = RANDOM_MATERIALS[int(rng.integers(len(RANDOM_MATERIALS)))] preset_material(f"MV_Material_{job['product']}", preset, _random_colour(rng)) @@ -6162,9 +6807,12 @@ def _plan(self, context, targets, cameras): self._labels.add(meta["label"]) name = f"{meta['label']}_{pc.name}" first_job = len(self._queue) - sheet_cells = {mode: [] for mode, _, _ in modes} + sheet_cells = {} parts = part_list(pc) views = [] + variants = (variant_assignments(s, meta["label"], sorted(parts), self._pool) + if self._pool and takes_variants(pc) else []) + self._variants[pc.name] = variants for cam in cameras: view = cam.name.replace("MV_Cam_", "") @@ -6188,47 +6836,55 @@ def _plan(self, context, targets, cameras): "extra": (parts,) if mode == "parts" else (), }) for mode, render, extra in modes: - values = name_values(s, idx, pc.name, view, mode) - rel_dir = output_folder(s, values) - base = output_basename(s, values) - path = os.path.join(root, rel_dir, base) - self._claim(path) - master = written_path(scene, path) - post = self._post_targets(rel_dir, base) if s.post_enabled else [] - for target, _ in post: - self._claim(target) - rec = dict(meta, view=view, mode=mode) - files[mode] = master - sheet_cells[mode].append((view, post[0][0] if post else master)) - - have_master = s.skip_existing and file_ok(master) - if have_master: - self._record(rec, "render", master, *self._render_size(scene)) - if all(file_ok(t) for t, _ in post): - self._skipped += 1 - for target, _ in post: - self._record(rec, "processed", target) - continue - self._queue.append({ - "kind": "post" if have_master else "render", - "product": pc.name, "status": f"{name} {view}", - "camera": cam.name, "render": render, "path": path, - "master": master, "post": post, "rec": rec, - "extra": extra, - }) + # Materials only show in Full renders; every other pass is + # the same for each variant and is rendered once. + choices = (variants if variants and render is _do_full_render + else [("", None)]) + for vname, assignment in choices: + values = name_values(s, idx, pc.name, view, mode, vname) + rel_dir = output_folder(s, values) + base = output_basename(s, values) + path = os.path.join(root, rel_dir, base) + self._claim(path) + master = written_path(scene, path) + post = self._post_targets(rel_dir, base) if s.post_enabled else [] + for target, _ in post: + self._claim(target) + rec = dict(meta, view=view, mode=mode, variant=vname, + materials=materials_text(assignment)) + files[f"{mode}@{vname}" if vname else mode] = master + sheet_cells.setdefault((mode, vname), []).append( + (view, post[0][0] if post else master)) + + have_master = s.skip_existing and file_ok(master) + if have_master: + self._record(rec, "render", master, *self._render_size(scene)) + if all(file_ok(t) for t, _ in post): + self._skipped += 1 + for target, _ in post: + self._record(rec, "processed", target) + continue + self._queue.append({ + "kind": "post" if have_master else "render", + "product": pc.name, + "status": f"{name} {view}" + (f" {vname}" if vname else ""), + "camera": cam.name, "render": render, "path": path, + "master": master, "post": post, "rec": rec, + "extra": extra, "variant": assignment, + }) if s.sheet_enabled: touched = len(self._queue) > first_job - for mode, cells in sheet_cells.items(): - values = name_values(s, idx, pc.name, "Sheet", mode) + for (mode, vname), cells in sheet_cells.items(): + values = name_values(s, idx, pc.name, "Sheet", mode, vname) path = os.path.join(root, output_folder(s, values), output_basename(s, values) + ".png") self._claim(path) - rec = dict(meta, view="Sheet", mode=mode) + rec = dict(meta, view="Sheet", mode=mode, variant=vname) if s.skip_existing and not touched and file_ok(path): self._record(rec, "sheet", path) continue - title = f"{name} ({mode})" if mode else name + title = name + "".join(f" ({t})" for t in (mode, vname) if t) self._queue.append({"kind": "sheet", "status": f"{name} contact sheet", "path": path, "cells": cells, "title": title, "rec": rec}) @@ -6273,6 +6929,21 @@ def _plan(self, context, targets, cameras): if s.turntable_enabled: self._plan_turntable(context, idx, pc, meta) + # Patches belong to the pool, not to a product: with shards, the first + # shard makes them all and the others leave them alone, and rendering + # just the active product leaves them alone too. + if s.pool_patches and s.shard_suffix in {"", "_shard00"} and not self.only_active: + for pool_name, maps in pool_entries(s): + path = os.path.join(root, "materials", safe_name(pool_name) + "_patch.png") + self._claim(path) + rec = {"label": "materials", "product": pool_name, "view": "Patch", "mode": ""} + self._labels.add("materials") + if s.skip_existing and file_ok(path): + self._record(rec, "patch", path) + continue + self._queue.append({"kind": "patch", "status": f"material patch {pool_name}", + "path": path, "maps": maps, "name": pool_name, "rec": rec}) + if coco_sources: path = os.path.join(root, f"coco{s.shard_suffix}.json") self._claim(path) @@ -6331,6 +7002,9 @@ def _plan_turntable(self, context, idx, pc, meta): "render": _do_clay_render if clay else _do_full_render, "extra": (tuple(s.clay_color),) if clay else (), "rec": rec if not outputs else None, + # A Full-render turntable shows the first material variant. + "variant": (None if clay else + next(iter(self._variants.get(pc.name) or []), (None, None))[1]), }) if outputs: self._queue.append({"kind": "turn_encode", "product": pc.name, @@ -6368,6 +7042,13 @@ def _setup(self, context): self._started_at = datetime.now() self._random = None self._cam_restore = None + self._variants = {} + # A job file or a fresh scene may name the folder without having + # scanned it, and a moved library leaves the list pointing at files + # that have gone; either way the folder is read again now. + if s.pool_variants or s.pool_patches: + refresh_pool(s) + self._pool = pool_entries(s) if s.pool_variants else [] self._cancelled = False self._aborted = False @@ -6500,6 +7181,9 @@ def _render_image(self, context, job, camera): if cam is None or cam.type != 'CAMERA': raise RuntimeError(f"camera '{camera}' no longer exists") scene.camera = cam + if job.get("variant"): + apply_variant(scene.mv_settings, bpy.data.collections[job["product"]], + job["variant"], self._pool) self._stage(context, job, cam) started = time.monotonic() try: @@ -6562,7 +7246,7 @@ def rel(path): if record["projection"] != "perspective": continue image = next((p for m, p in entry["files"].items() - if m in {"", "render", "clay"}), None) + if m.split("@")[0] in {"", "render", "clay"}), None) if image is None: continue (fx, _, cx), (_, fy, cy), _ = record["K"] @@ -6601,6 +7285,11 @@ def rel(path): self._write_json(job["transforms"], {"camera_model": "OPENCV", "frames": frames}) self._record(meta, "cameras", job["transforms"]) + def _job_patch(self, context, job): + s = context.scene.mv_settings + size = render_patch(context, job["name"], job["maps"], job["path"], s.patch_size) + self._record(job["rec"], "patch", job["path"], *size) + def _job_drawing(self, context, job): s = context.scene.mv_settings coll = bpy.data.collections.get(job["product"]) @@ -6682,7 +7371,8 @@ def _job_bop(self, context, job): cam = bpy.data.objects.get(entry["camera"]) if cam is None or cam.data.type != 'PERSP': continue - rgb = next((p for m, p in entry["files"].items() if m in {"", "render", "clay"}), None) + rgb = next((p for m, p in entry["files"].items() + if m.split("@")[0] in {"", "render", "clay"}), None) depth_exr = entry["files"].get("depth") if not rgb or not depth_exr or not file_ok(rgb) or not file_ok(depth_exr): continue @@ -6822,7 +7512,7 @@ def _job_coco(self, context, job): "part_id": int(part_id), "part_name": names.get(int(part_id), ""), }) for mode, path in entry["files"].items(): - if mode not in {"", "render", "clay"} or not file_ok(path): + if mode.split("@")[0] not in {"", "render", "clay"} or not file_ok(path): continue image_id = len(images) + 1 images.append({"id": image_id, "width": w, "height": h, @@ -6965,6 +7655,7 @@ def _advance(self, context): "turn_encode": self._job_turn_encode, "cameras": self._job_cameras, "turn_cameras": self._job_turn_cameras, "bop": self._job_bop, "coco": self._job_coco, "drawing": self._job_drawing, + "patch": self._job_patch, }[job["kind"]] try: handler(context, job) @@ -7181,6 +7872,20 @@ def draw_item(self, context, layout, data, item, icon, active_data, active_propn row.operator("mv.remove_custom_view", text="", icon='X').index = index +class MV_UL_Pool(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + row = layout.row(align=True) + row.prop(item, "enabled", text="") + sub = row.row(align=True) + sub.active = item.enabled + sub.label(text=item.name, icon='MATERIAL') + try: + maps = json.loads(item.maps) + except ValueError: + maps = {} + sub.label(text="".join(sorted({MAP_LETTERS.get(k, "") for k in maps}))) + + class MV_UL_Products(UIList): """MV_Products' children, each with its render tick and the label its files will carry. The label is worked out from the product's place in the @@ -7295,6 +8000,46 @@ def draw(self, context): layout.label(text="Products to render to set its own.") +class MV_PT_ScenePool(Panel): + bl_label = "Material Pool" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "MultiView" + bl_parent_id = "MV_PT_SceneSetup" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + row = layout.row(align=True) + row.prop(s, "pool_folder", text="") + row.operator("mv.scan_pool", text="", icon='FILE_REFRESH') + if s.material_pool: + row = layout.row() + row.template_list("MV_UL_Pool", "", s, "material_pool", s, "material_pool_index", + rows=4) + col = row.column(align=True) + col.operator("mv.set_pool", text="", icon='CHECKBOX_HLT').state = True + col.operator("mv.set_pool", text="", icon='CHECKBOX_DEHLT').state = False + layout.label(text="C colour R rough M metal A packed H height N normal", + icon='INFO') + layout.prop(s, "pool_tile_mm") + + box = layout.box() + box.label(text="Product variants (Full renders):") + row = box.row(align=True) + row.prop(s, "pool_variants") + row.prop(s, "pool_seed") + box.prop(s, "pool_per_part") + + box = layout.box() + box.label(text="Material patches:") + row = box.row(align=True) + row.prop(s, "pool_patches", text="With Render All") + row.prop(s, "patch_size", text="Size") + box.operator("mv.render_patches", icon='MATSPHERE') + + class MV_PT_Import(Panel): bl_label = "Import Products" bl_space_type = 'VIEW_3D' @@ -7612,7 +8357,7 @@ def draw(self, context): layout.prop(s, "file_template") col = layout.column(align=True) col.label(text="Tokens: {label} {prefix} {number}") - col.label(text="{product} {view} {mode}") + col.label(text="{product} {view} {mode} {variant}") # The first image of the batch, as it will actually be written. prods = get_products() @@ -7834,6 +8579,7 @@ def draw(self, context): MV_AddonPreferences, MV_StepFileItem, MV_Issue, + MV_PoolMaterial, MV_CustomView, MV_Settings, MV_OT_AddStepFile, @@ -7845,6 +8591,9 @@ def draw(self, context): MV_OT_BuildCameras, MV_OT_BuildLighting, MV_OT_ApplyWorld, + MV_OT_ScanPool, + MV_OT_SetPool, + MV_OT_RenderPatches, MV_OT_SaveLightingPreset, MV_OT_DeleteLightingPreset, MV_OT_AddCustomView, @@ -7864,10 +8613,12 @@ def draw(self, context): MV_UL_StepFiles, MV_UL_CustomViews, MV_UL_Products, + MV_UL_Pool, # Panels draw in the sidebar in the order they are registered. MV_PT_Guide, MV_PT_SceneSetup, MV_PT_SceneMaterials, + MV_PT_ScenePool, MV_PT_Import, MV_PT_Standardize, MV_PT_Cameras, diff --git a/tests/test_materials.py b/tests/test_materials.py new file mode 100644 index 0000000..889fdf1 --- /dev/null +++ b/tests/test_materials.py @@ -0,0 +1,467 @@ +"""Material pool: scanning texture folders, box-projected PBR materials, +rendered patches and seeded product variants.""" +import os +import sys +import json +import shutil +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _harness as h # noqa: E402 + +import bpy # noqa: E402 +import numpy as np # noqa: E402 +import multiview_product_renderer as mv # noqa: E402 + +check = h.check + + +def checker(colour, size=64, squares=4): + """A two-tone checker in colour, so the texture shows up as variance.""" + yy, xx = np.mgrid[0:size, 0:size] // (size // squares) + dark = ((yy + xx) % 2)[..., None].astype(np.float32) + rgb = np.array(colour, dtype=np.float32) * (1.0 - 0.6 * dark) + return np.concatenate([rgb, np.ones((size, size, 1), np.float32)], axis=2) + + +def grey(value, size=16): + px = np.full((size, size, 4), value, np.float32) + px[..., 3] = 1.0 + return px + + +def make_pool(root): + """Texture sets named the way the big free libraries name them.""" + sets = { + # ambientCG + "Bricks_A": {"Bricks_A_1K-PNG_Color.png": checker((0.9, 0.1, 0.1)), + "Bricks_A_1K-PNG_Roughness.png": grey(0.8), + "Bricks_A_1K-PNG_NormalGL.png": grey(0.5), + "Bricks_A_1K-PNG_Displacement.png": grey(0.5)}, + # Poly Haven + "oak_veneer": {"oak_veneer_diff_1k.png": checker((0.1, 0.8, 0.1)), + "oak_veneer_rough_1k.png": grey(0.6), + "oak_veneer_arm_1k.png": grey(0.3), + "oak_veneer_nor_gl_1k.png": grey(0.5)}, + # "metal" in the name must not read as a metallic map. + "Metal_Plate": {"Metal_Plate_Color.jpg": checker((0.1, 0.2, 0.9)), + "Metal_Plate_Metalness.png": grey(1.0)}, + # Not materials: no colour map, or no images at all. + "loose_maps": {"thing_rough.png": grey(0.5)}, + } + for folder, files in sets.items(): + os.makedirs(os.path.join(root, folder)) + for name, px in files.items(): + fmt = 'JPEG' if name.endswith(".jpg") else 'PNG' + mv.save_pixels(px, os.path.join(root, folder, name), fmt) + os.makedirs(os.path.join(root, "notes")) + with open(os.path.join(root, "notes", "readme.txt"), "w") as f: + f.write("not a texture") + + +def manifest_rows(root): + return json.load(open(os.path.join(root, "manifest.json")))["images"] + + +def body(): + mv.register() + scene = bpy.context.scene + s = scene.mv_settings + h.fresh_scene() + out = tempfile.mkdtemp(prefix="mv_pool_") + try: + pool_dir = os.path.join(out, "textures") + make_pool(pool_dir) + + # ---- scanning and map classification + found = dict(mv.scan_pool(pool_dir)) + check("scan finds the three texture sets", sorted(found) == ["Bricks_A", "Metal_Plate", + "oak_veneer"], sorted(found)) + bricks, oak, metal = found["Bricks_A"], found["oak_veneer"], found["Metal_Plate"] + check("ambientCG: colour, roughness, normal, height", + {k: os.path.basename(v) for k, v in bricks.items()} == { + "color": "Bricks_A_1K-PNG_Color.png", + "roughness": "Bricks_A_1K-PNG_Roughness.png", + "normal": "Bricks_A_1K-PNG_NormalGL.png", + "height": "Bricks_A_1K-PNG_Displacement.png"}, bricks) + check("Poly Haven: diff, rough, arm, nor_gl", + {k: os.path.basename(v) for k, v in oak.items()} == { + "color": "oak_veneer_diff_1k.png", "roughness": "oak_veneer_rough_1k.png", + "arm": "oak_veneer_arm_1k.png", "normal": "oak_veneer_nor_gl_1k.png"}, oak) + check("a material named Metal keeps its colour map", + os.path.basename(metal.get("color", "")) == "Metal_Plate_Color.jpg" + and os.path.basename(metal.get("metallic", "")) == "Metal_Plate_Metalness.png", metal) + check("DirectX normals stand in for OpenGL ones", + mv.classify_maps(["x_Color.png", "x_NormalDX.png"]).get("normal") == "x_NormalDX.png") + # Names whose shared start runs into the map words. + check("diff + disp: prefix stops at the separator", + mv.classify_maps(["rock_diff_1k.png", "rock_disp_1k.png"]) + == {"color": "rock_diff_1k.png", "height": "rock_disp_1k.png"}) + check("albedo + ao", mv.classify_maps(["albedo.png", "ao.png"]).get("color") == "albedo.png") + check("basecolor + bump", + mv.classify_maps(["basecolor.png", "bump.png"]).get("color") == "basecolor.png") + check("a lone image is the colour, even one called Metal", + mv.classify_maps(["Brushed_Metal.jpg"]) == {"color": "Brushed_Metal.jpg"}) + check("one unnamed image beside named maps is the colour", + mv.classify_maps(["oak.jpg", "oak_rough.jpg"]).get("color") == "oak.jpg") + check("a lone metalness map is not a colour", + mv.classify_maps(["Metal032_1K-JPG_Metalness.jpg"]) + == {"metallic": "Metal032_1K-JPG_Metalness.jpg"}) + + # Names: packing folders take their parent's name, and duplicates are + # told apart by their path. + naming = os.path.join(out, "naming") + for rel in ("a/Oak/x_Color.png", "b/Oak/y_Color.png", + "rock_wall/textures/rock_wall_diff_1k.png", + "rock_wall/textures/rock_wall_disp_1k.png", + "lib2/rock_wall/textures/rock_wall_diff_1k.png", "plain/oak.jpg"): + path = os.path.join(naming, *rel.split("/")) + os.makedirs(os.path.dirname(path), exist_ok=True) + mv.save_pixels(grey(0.5), path, 'JPEG' if rel.endswith(".jpg") else 'PNG') + names = sorted(n for n, _ in mv.scan_pool(naming)) + check("unique names from the folders", + names == ["a_Oak", "b_Oak", "lib2_rock_wall", "plain", "rock_wall"], names) + + # A material keeps its name, and its tick, when a namesake turns up. + grow = os.path.join(out, "grow") + os.makedirs(os.path.join(grow, "Oak")) + mv.save_pixels(grey(0.5), os.path.join(grow, "Oak", "Oak_Color.png"), 'PNG') + s.pool_folder = grow + mv.scan_pool_into(s) + s.material_pool["Oak"].enabled = False + os.makedirs(os.path.join(grow, "more", "Oak")) + mv.save_pixels(grey(0.5), os.path.join(grow, "more", "Oak", "Oak_Color.png"), 'PNG') + mv.scan_pool_into(s) + kept = {m.name: (m.enabled, m.key) for m in s.material_pool} + check("rescan keeps the old name and tick; the newcomer gets its path", + kept == {"Oak": (False, mv.pool_key(grow, os.path.join(grow, "Oak", "Oak_Color.png"))), + "more_Oak": (True, mv.pool_key(grow, os.path.join(grow, "more", "Oak", + "Oak_Color.png")))}, kept) + s.material_pool.clear() + + s.pool_folder = pool_dir + r = h.run(bpy.ops.mv.scan_pool) + check("Scan Folder fills the pool", r == {'FINISHED'} and len(s.material_pool) == 3, r) + s.material_pool["Metal_Plate"].enabled = False + bpy.ops.mv.scan_pool() + check("rescanning keeps the ticks", not s.material_pool["Metal_Plate"].enabled + and s.material_pool["oak_veneer"].enabled) + check("only ticked materials are used", + [n for n, _ in mv.pool_entries(s)] == ["Bricks_A", "oak_veneer"]) + bpy.ops.mv.set_pool(state=True) + check("tick all", all(m.enabled for m in s.material_pool)) + s.pool_folder = os.path.join(out, "nowhere") + check("scan of a missing folder cancels", h.run(bpy.ops.mv.scan_pool) == {'CANCELLED'}) + s.pool_folder = pool_dir + + # ---- the material + mat = mv.pool_material("oak_veneer", oak) + nodes = mat.node_tree.nodes + images = [n for n in nodes if n.type == 'TEX_IMAGE'] + check("one image node per used map", len(images) == 3, [n.image.name for n in images]) + check("every texture is box projected", all(n.projection == 'BOX' for n in images)) + spaces = {os.path.basename(n.image.filepath): n.image.colorspace_settings.name + for n in images} + check("colour in sRGB, data maps Non-Color", + spaces.get("oak_veneer_diff_1k.png") == "sRGB" + and spaces.get("oak_veneer_rough_1k.png") == "Non-Color" + and spaces.get("oak_veneer_arm_1k.png") == "Non-Color", spaces) + bsdf = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED') + metal_src = bsdf.inputs["Metallic"].links[0].from_node if bsdf.inputs["Metallic"].links else None + check("packed ARM drives metallic", metal_src is not None and metal_src.type == 'SEPARATE_COLOR') + check("roughness map wins over the packed one", + bsdf.inputs["Roughness"].links[0].from_node.type == 'TEX_IMAGE') + attr = next(n for n in nodes if n.type == 'ATTRIBUTE') + check("scale read from the object", attr.attribute_type == 'OBJECT' + and attr.attribute_name == mv.TILE_KEY) + bmat = mv.pool_material("Bricks_A", bricks) + check("height map becomes a bump", any(n.type == 'BUMP' for n in bmat.node_tree.nodes)) + omat = mv.pool_material("Holey", dict(bricks, opacity=bricks["roughness"])) + obsdf = next(n for n in omat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED') + check("opacity maps are not used", not obsdf.inputs["Alpha"].is_linked) + mv.save_pixels(grey(0.5), os.path.join(out, "lonely_Color.png"), 'PNG') + extra = s.material_pool.add() + extra.name = "lonely" + extra.maps = json.dumps({"color": os.path.join(out, "lonely_Color.png"), + "normal": os.path.join(out, "gone_Normal.png")}) + check("a missing normal map does not count as missing", mv.pool_missing(s) == [], + mv.pool_missing(s)) + extra.maps = json.dumps({"color": os.path.join(out, "gone_Color.png")}) + check("a missing colour map does", mv.pool_missing(s) == ["lonely"], mv.pool_missing(s)) + s.material_pool.remove(len(s.material_pool) - 1) + first_node = mat.node_tree.nodes[0].as_pointer() + check("the same material is reused, not rebuilt", + mv.pool_material("oak_veneer", oak) == mat + and mat.node_tree.nodes[0].as_pointer() == first_node) + + # ---- patches + scene.render.engine = 'BLENDER_WORKBENCH' + scenes, objects = len(bpy.data.scenes), len(bpy.data.objects) + patch = os.path.join(out, "patch.png") + w, hgt = mv.render_patch(bpy.context, "Bricks_A", bricks, patch, 96) + check("patch written", os.path.isfile(patch)) + px = mv.load_pixels(patch) + check("patch is two cells wide", px.shape[1] == w and w >= 2 * 96 and px.shape[0] == hgt, + px.shape) + check("patch scene and objects cleaned up", len(bpy.data.scenes) == scenes + and len(bpy.data.objects) == objects and scene.render.engine == 'BLENDER_WORKBENCH') + # Each half of the sheet, without its white title and label margins. + top = px[::-1] + cells = [] + for half in (top[:, :w // 2, :3], top[:, w // 2:, :3]): + inked = half.min(axis=2) < 0.85 + cells.append(half[inked]) + flat, ball = cells + flat_red = flat[:, 0] - flat[:, 2] + check("flat swatch shows the red checker", (flat_red > 0.1).mean() > 0.95, + float((flat_red > 0.1).mean())) + check("flat swatch shows both checker tones", + np.percentile(flat[:, 0], 90) - np.percentile(flat[:, 0], 10) > 0.15) + ball_red = ball[:, 0] - ball[:, 2] + check("ball is red on a grey ground", 0.1 < (ball_red > 0.15).mean() < 0.6 + and abs(np.median(ball_red)) < 0.05, float((ball_red > 0.15).mean())) + # The scene's colour management carries over to the patch. + scene.view_settings.exposure = -1.5 + mv.render_patch(bpy.context, "Bricks_A", bricks, os.path.join(out, "dark.png"), 96) + scene.view_settings.exposure = 0.0 + dark = mv.load_pixels(os.path.join(out, "dark.png"))[::-1] + dark_flat = dark[:, :w // 2, :3][dark[:, :w // 2, :3].min(axis=2) < 0.85] + check("patch follows the scene's exposure", dark_flat[:, 0].mean() < flat[:, 0].mean() - 0.1, + (float(dark_flat[:, 0].mean()), float(flat[:, 0].mean()))) + + # ---- a two-part product + coll, (block, peg) = h.add_product( + "Widget", + lambda: bpy.ops.mesh.primitive_cube_add(size=100, location=(0, 0, 50)), + lambda: bpy.ops.mesh.primitive_cylinder_add(radius=20, depth=40, location=(0, 0, 120))) + block.name, peg.name = "Block", "Peg" + mv.record_source_size(coll, "mm") + bpy.context.view_layer.update() + s.recenter = s.rescale = True + s.auto_smooth = False + bpy.ops.mv.standardize() + bpy.context.view_layer.update() + # One repeat covers Tile Size of the real part. + s.pool_tile_mm = 50.0 + world_per_mm = mv.product_to_world(coll).to_3x3().determinant() ** (1 / 3) + obj_scale = sum(block.matrix_world.to_3x3().to_scale()) / 3 + repeat_obj = 50.0 * world_per_mm / obj_scale + check("tile scale: one repeat per 50 mm", + abs(mv.tile_scale(block, coll, 50.0) * repeat_obj - 1.0) < 1e-6) + loose, (lcube,) = h.add_product("Loose", + lambda: bpy.ops.mesh.primitive_cube_add(size=0.3)) + check("hand-built, never standardized: metres", + loose.mv_source_units == "" and abs(mv.tile_scale(lcube, loose, 100.0) - 10.0) < 1e-6, + mv.tile_scale(lcube, loose, 100.0)) + bpy.data.objects.remove(lcube) + bpy.data.collections.remove(loose) + + # {variant} in the folder template puts variants in folders instead. + values = mv.name_values(s, 0, "Widget", "Front", "", "v02") + check("variant appended to a file name without it", + mv.output_basename(s, values).endswith("_v02"), mv.output_basename(s, values)) + s.folder_template = "{label}_{product}/{variant}" + check("variant folder: file name left alone", + not mv.output_basename(s, values).endswith("v02") + and mv.output_folder(s, values).replace("\\", "/").endswith("/v02"), + (mv.output_folder(s, values), mv.output_basename(s, values))) + s.folder_template = "" + + # ---- variant draws + s.pool_seed = 5 + s.pool_variants = 4 + s.pool_per_part = True + entries = mv.pool_entries(s) + a = mv.variant_assignments(s, "AA_01", ["Block", "Peg"], entries) + check("four variants named v01..v04", [n for n, _ in a] == ["v01", "v02", "v03", "v04"]) + check("every part gets a pool material", all(set(x) == {"Block", "Peg"} + and set(x.values()) <= dict(entries).keys() + for _, x in a)) + check("same seed, same draws", a == mv.variant_assignments(s, "AA_01", ["Block", "Peg"], + entries)) + s.pool_variants = 6 + check("more variants keep the first ones", + mv.variant_assignments(s, "AA_01", ["Block", "Peg"], entries)[:4] == a) + s.pool_seed = 6 + check("other seed, other draws", + mv.variant_assignments(s, "AA_01", ["Block", "Peg"], entries)[:4] != a) + s.pool_per_part = False + whole = mv.variant_assignments(s, "AA_01", ["Block", "Peg"], entries) + check("whole-product mode: one material per variant", + all(len(set(x.values())) == 1 for _, x in whole)) + check("materials text", mv.materials_text({"Peg": "Oak", "Block": "Steel"}) + == "Block=Steel; Peg=Oak") + + # Changing the pool moves only the draws that material won or held. + s.pool_per_part = True + s.pool_variants = 40 + parts = ["Block", "Peg", "Lid"] + before = mv.variant_assignments(s, "AA_01", parts, entries) + s.material_pool["oak_veneer"].enabled = False + after = mv.variant_assignments(s, "AA_01", parts, mv.pool_entries(s)) + moved = [(b[p], a[p]) for (_, b), (_, a) in zip(before, after) for p in parts + if b[p] != a[p]] + check("unticking moves only that material's draws", + moved and all(old == "oak_veneer" for old, _ in moved), moved[:4]) + s.material_pool["oak_veneer"].enabled = True + counts = {} + for _, x in before: + for m in x.values(): + counts[m] = counts.get(m, 0) + 1 + check("draws spread over the pool", len(counts) == 3 and min(counts.values()) >= 20, + counts) + + # ---- render all with variants and patches + scene.render.engine = 'CYCLES' + scene.cycles.samples = 4 + scene.cycles.device = 'CPU' + scene.cycles.use_denoising = False + scene.render.resolution_x, scene.render.resolution_y = 64, 48 + s.render_output_dir = os.path.join(out, "r") + os.sep + s.render_mode = 'BOTH' + s.view_set = 'MIN' + s.manifest_format = 'JSON' + s.export_cameras = False + s.pool_seed = 5 + s.pool_variants = 3 + s.pool_per_part = True + s.pool_patches = True + s.patch_size = 64 + s.pass_mask = True + r = h.run(bpy.ops.mv.render_all) + check("render with variants finished", r == {'FINISHED'}, r) + root = os.path.join(out, "r") + rows = manifest_rows(root) + full = [x for x in rows if x["kind"] == "render" and x["mode"] == "render"] + clay = [x for x in rows if x["kind"] == "render" and x["mode"] == "clay"] + check("clay rendered once per view", clay and all(x["variant"] == "" for x in clay), + [(x["mode"], x["variant"]) for x in rows]) + check("three Full variants per view", len(full) == 3 * len(clay) + and {x["variant"] for x in full} == {"v01", "v02", "v03"}, len(full)) + check("variant files carry the suffix", all(x["path"].endswith(f"_{x['variant']}.png") + for x in full), full[0]["path"]) + check("all variant files exist", all(os.path.isfile(os.path.join(root, x["path"])) + for x in full + clay)) + want = mv.variant_assignments(s, full[0]["label"], ["Block", "Peg"], mv.pool_entries(s)) + check("manifest lists each variant's materials", + {x["variant"]: x["materials"] for x in full} + == {n: mv.materials_text(m) for n, m in want}, full[0]["materials"]) + patches = sorted(os.listdir(os.path.join(root, "materials"))) + check("one patch per pool material", patches == ["Bricks_A_patch.png", "Metal_Plate_patch.png", + "oak_veneer_patch.png"], patches) + check("patches in the manifest", sum(x["kind"] == "patch" for x in rows) == 3) + sheets = [x for x in rows if x["kind"] == "sheet"] + check("one contact sheet per variant and for clay", + not sheets or len(sheets) == 4, [x["path"] for x in sheets]) + + # The texture is what changed: a variant drawn all-red and one drawn + # all-blue look red and blue. + by_mat = {} + for x in full: + mats = set(m.split("=")[1] for m in x["materials"].split("; ")) + if len(mats) == 1: + by_mat.setdefault(mats.pop(), x) + img = {m: mv.load_pixels(os.path.join(root, x["path"])) for m, x in by_mat.items()} + + def redness(px): + sel = px[..., 3] > 0.5 if s.transparent_bg else np.ones(px.shape[:2], bool) + return float((px[..., 0] - px[..., 2])[sel].mean()) + if "Bricks_A" in img and "Metal_Plate" in img: + check("red pool material renders red, blue renders blue", + redness(img["Bricks_A"]) > redness(img["Metal_Plate"]) + 0.1, + (redness(img["Bricks_A"]), redness(img["Metal_Plate"]))) + else: + imgs = [mv.load_pixels(os.path.join(root, x["path"])) for x in full + if x["view"] == full[0]["view"]] + check("variants look different", max(np.abs(imgs[0] - i).max() for i in imgs[1:]) > 0.1) + + check("modifiers and tile scales removed afterwards", + not any(md.name.startswith(mv.MATERIAL_MOD) for o in bpy.data.objects + for md in o.modifiers) + and not any(mv.TILE_KEY in o for o in bpy.data.objects)) + check("user materials untouched", not block.data.materials + or not block.data.materials[0].name.startswith("MV_Pool_")) + check("pool materials and their textures deleted afterwards", + not any(m.name.startswith("MV_Pool_") for m in bpy.data.materials) + and not any(pool_dir in bpy.path.abspath(i.filepath) for i in bpy.data.images), + [m.name for m in bpy.data.materials]) + check("every manifest row has variant and materials", + all("variant" in x and "materials" in x and x["variant"] is not None for x in rows) + and any("mask" in f"{x['kind']} {x['mode']}" for x in rows), + sorted({(x["kind"], x["mode"]) for x in rows})) + prov = json.load(open(os.path.join(root, "manifest.json")))["provenance"]["settings"] + check("provenance records the pool", + [m["name"] for m in prov.get("material_pool", [])] + == ["Bricks_A", "Metal_Plate", "oak_veneer"] + and prov["material_pool"][0]["maps"]["color"] == "Bricks_A_1K-PNG_Color.png", + prov.get("material_pool")) + + # A second run with the same seed reproduces the same images. + first = {x["path"]: mv.load_pixels(os.path.join(root, x["path"])) for x in full} + s.render_output_dir = os.path.join(out, "r2") + os.sep + h.run(bpy.ops.mv.render_all) + same = all(np.abs(px - mv.load_pixels(os.path.join(out, "r2", p))).max() < 1e-6 + for p, px in first.items()) + check("same seed, same variant images", same) + + # Keep Own products render once, in their own materials. + coll.mv_material = 'KEEP' + s.render_output_dir = os.path.join(out, "keep") + os.sep + h.run(bpy.ops.mv.render_all) + keep_rows = manifest_rows(os.path.join(out, "keep")) + keep_full = [x for x in keep_rows if x["kind"] == "render" and x["mode"] == "render"] + check("Keep Own: no variants", keep_full and len(keep_full) == len(clay) + and all(x["variant"] == "" for x in keep_full), len(keep_full)) + coll.mv_material = 'DEFAULT' + + # Rendering only the active product leaves the patches alone. + s.render_output_dir = os.path.join(out, "active") + os.sep + r = h.run(bpy.ops.mv.render_all, only_active=True) + check("Render Active Product skips patches", r == {'FINISHED'} + and not os.path.isdir(os.path.join(out, "active", "materials")), r) + + # ---- shards: only the first makes the patches + s.pass_mask = False + s.render_mode = 'CLAY' + s.shard_suffix = "_shard01" + s.render_output_dir = os.path.join(out, "s1") + os.sep + h.run(bpy.ops.mv.render_all) + check("later shards leave patches alone", + not os.path.isdir(os.path.join(out, "s1", "materials"))) + s.shard_suffix = "_shard00" + s.render_output_dir = os.path.join(out, "s0") + os.sep + h.run(bpy.ops.mv.render_all) + check("the first shard makes them", len(os.listdir(os.path.join(out, "s0", "materials"))) == 3) + s.shard_suffix = "" + + # ---- a moved texture library + moved = os.path.join(out, "moved_textures") + shutil.copytree(pool_dir, moved) + s.pool_folder = moved + shutil.rmtree(pool_dir) + issues = [i["message"] for i in mv.preflight(bpy.context) if i["severity"] == 'ERROR'] + check("moved library: rescanned, no pre-flight error", + not any("pool" in m.lower() for m in issues) + and all(p.startswith(moved) for _, maps in mv.pool_entries(s) for p in maps.values()), + issues) + shutil.copytree(moved, os.path.join(out, "gone")) + s.pool_folder = os.path.join(out, "gone") + bpy.ops.mv.scan_pool() + shutil.rmtree(os.path.join(out, "gone")) + issues = [i["message"] for i in mv.preflight(bpy.context) if i["severity"] == 'ERROR'] + check("missing textures stop the batch", any("missing" in m for m in issues), issues) + + # ---- pre-flight + s.material_pool.clear() + s.pool_folder = os.path.join(out, "nowhere") + issues = [i["message"] for i in mv.preflight(bpy.context) if i["severity"] == 'ERROR'] + check("pre-flight: empty pool is an error", any("pool" in m.lower() for m in issues), + issues) + s.pool_variants = 0 + s.pool_patches = False + mv.unregister() + finally: + shutil.rmtree(out, ignore_errors=True) + + +h.guard(body)