Added sanity check baseline made by claude.
Also changed the location to a panel in VIEW_3D instead of menu.
This commit is contained in:
1 parent
71b1a61b5c
commit
4617c47ad0
33 files changed
+2771
-52
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Checks shared by every asset context (General, Character Mod, Character Lineart)."""
|
||||
@@ -0,0 +1,49 @@
|
||||
import bpy
|
||||
|
||||
from ...core import Check, Issue, contexts, is_local
|
||||
|
||||
_ANIMATABLE = [
|
||||
"objects", "meshes", "curves", "armatures", "grease_pencils", "materials",
|
||||
"node_groups", "shape_keys", "cameras", "lights", "worlds", "scenes",
|
||||
]
|
||||
|
||||
|
||||
def _animated_ids():
|
||||
for collection_name in _ANIMATABLE:
|
||||
for id_block in getattr(bpy.data, collection_name, ()):
|
||||
if not is_local(id_block):
|
||||
continue
|
||||
ad = id_block.animation_data
|
||||
if ad is not None and (ad.action is not None or len(ad.nla_tracks)):
|
||||
yield id_block, ad
|
||||
|
||||
|
||||
class AnimationCheck(Check):
|
||||
id = "animation"
|
||||
label = "No animation"
|
||||
description = "No action or NLA track is assigned (drivers are allowed)."
|
||||
category = "Animation"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Unassign actions and remove NLA tracks (drivers are kept)"
|
||||
|
||||
def run(self, ctx):
|
||||
for id_block, ad in _animated_ids():
|
||||
parts = []
|
||||
if ad.action is not None:
|
||||
parts.append(f"action '{ad.action.name}'")
|
||||
if len(ad.nla_tracks):
|
||||
parts.append(f"{len(ad.nla_tracks)} NLA track(s)")
|
||||
name = id_block.user.name if isinstance(id_block, bpy.types.Key) else id_block.name
|
||||
yield Issue.for_id(f"{name}: {', '.join(parts)}", id_block)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
id_block = issue.get_id()
|
||||
ad = id_block.animation_data if id_block is not None else None
|
||||
if ad is None:
|
||||
continue
|
||||
ad.action = None
|
||||
for track in list(ad.nla_tracks):
|
||||
ad.nla_tracks.remove(track)
|
||||
if not len(ad.drivers):
|
||||
id_block.animation_data_clear()
|
||||
@@ -0,0 +1,109 @@
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
from ...core.mesh import loop_polygon_index, polygon_arrays, polygon_normals, vertex_coords
|
||||
|
||||
|
||||
class NgonCheck(Check):
|
||||
id = "ngons"
|
||||
label = "N-gons"
|
||||
description = "Meshes contain only triangles and quads (no face with more than 4 sides)."
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 10
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
_, totals, _ = polygon_arrays(ob.data)
|
||||
bad = np.flatnonzero(totals > config.MAX_FACE_SIDES)
|
||||
if bad.size:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {bad.size} face(s) with more than {config.MAX_FACE_SIDES} sides",
|
||||
ob,
|
||||
elements=bad.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
|
||||
|
||||
def concave_polygons(mesh, tolerance):
|
||||
"""Indices of polygons with at least one reflex corner."""
|
||||
if not len(mesh.polygons):
|
||||
return np.empty(0, dtype=np.int32)
|
||||
starts, totals, loop_verts = polygon_arrays(mesh)
|
||||
co = vertex_coords(mesh)
|
||||
normals = polygon_normals(mesh)
|
||||
|
||||
poly_of_loop = loop_polygon_index(totals)
|
||||
start = starts[poly_of_loop]
|
||||
total = totals[poly_of_loop]
|
||||
local = np.arange(len(loop_verts)) - start
|
||||
prev_loop = start + (local - 1) % total
|
||||
next_loop = start + (local + 1) % total
|
||||
|
||||
point = co[loop_verts]
|
||||
incoming = point - co[loop_verts[prev_loop]]
|
||||
outgoing = co[loop_verts[next_loop]] - point
|
||||
turn = np.einsum("ij,ij->i", np.cross(incoming, outgoing), normals[poly_of_loop])
|
||||
scale = np.linalg.norm(incoming, axis=1) * np.linalg.norm(outgoing, axis=1) + 1e-12
|
||||
reflex = (turn / scale < -tolerance) & (total > 3)
|
||||
return np.unique(poly_of_loop[reflex])
|
||||
|
||||
|
||||
class ConcaveFaceCheck(Check):
|
||||
id = "concave_faces"
|
||||
label = "Concave faces"
|
||||
description = "Quads and n-gons are convex (no corner angle above 180 degrees)."
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 20
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
bad = concave_polygons(ob.data, config.CONCAVE_TOLERANCE)
|
||||
if bad.size:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {bad.size} concave face(s)",
|
||||
ob,
|
||||
elements=bad.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
|
||||
|
||||
class NonManifoldCheck(Check):
|
||||
id = "non_manifold"
|
||||
label = "Non-manifold geometry"
|
||||
description = (
|
||||
"No wire edges, edges shared by more than two faces, inconsistent normals, "
|
||||
"bow-tie vertices or (unless allowed in config) open borders."
|
||||
)
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 30
|
||||
|
||||
def run(self, ctx):
|
||||
allow_boundary = config.NON_MANIFOLD_ALLOW_BOUNDARY
|
||||
for ob in ctx.objects("MESH"):
|
||||
bm = bmesh.new()
|
||||
try:
|
||||
bm.from_mesh(ob.data)
|
||||
edges = [
|
||||
e.index for e in bm.edges
|
||||
if not e.is_contiguous and not (allow_boundary and e.is_boundary)
|
||||
]
|
||||
verts = [
|
||||
v.index for v in bm.verts
|
||||
if not v.is_manifold and not (allow_boundary and v.is_boundary)
|
||||
and not any(not e.is_contiguous for e in v.link_edges)
|
||||
]
|
||||
finally:
|
||||
bm.free()
|
||||
if edges:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {len(edges)} non-manifold edge(s)", ob, elements=edges, element_type="EDGE"
|
||||
)
|
||||
if verts:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {len(verts)} non-manifold vertex(es)", ob, elements=verts, element_type="VERT"
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
import re
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
_SUFFIX_RE = re.compile(r"\.\d{3,}$")
|
||||
|
||||
|
||||
def _strip_known_prefix(name, prefixes):
|
||||
for prefix in sorted(prefixes, key=len, reverse=True):
|
||||
if name.upper().startswith(prefix.upper()):
|
||||
return name[len(prefix):]
|
||||
return name
|
||||
|
||||
|
||||
def suggest_name(name, prefix, all_prefixes):
|
||||
"""Best-effort conforming name, or None when a human has to choose."""
|
||||
body = _SUFFIX_RE.sub("", name)
|
||||
body = _strip_known_prefix(body, all_prefixes)
|
||||
body = body.encode("ascii", "ignore").decode()
|
||||
body = re.sub(r"[^A-Za-z0-9]+", "_", body).strip("_")
|
||||
if not body or _is_default(body):
|
||||
return None
|
||||
candidate = prefix + body
|
||||
return candidate if candidate != name else None
|
||||
|
||||
|
||||
def _is_default(body):
|
||||
return re.sub(r"[^a-z]", "", body.lower()) in config.DEFAULT_NAMES
|
||||
|
||||
|
||||
def _validate(name, prefix):
|
||||
"""Return the list of problems of a name for the given prefix."""
|
||||
problems = []
|
||||
if _SUFFIX_RE.search(name):
|
||||
problems.append("duplicate suffix")
|
||||
if not name.isascii():
|
||||
problems.append("non-ASCII characters")
|
||||
if " " in name:
|
||||
problems.append("spaces")
|
||||
if prefix and not name.startswith(prefix):
|
||||
problems.append(f"missing prefix '{prefix}'")
|
||||
body = _SUFFIX_RE.sub("", name)
|
||||
body = body[len(prefix):] if prefix and body.startswith(prefix) else body
|
||||
if _is_default(body):
|
||||
problems.append("Blender default name")
|
||||
elif not re.fullmatch(config.NAME_BODY_PATTERN, body):
|
||||
problems.append("invalid characters or empty tokens")
|
||||
return problems
|
||||
|
||||
|
||||
class NamingCheck(Check):
|
||||
id = "naming"
|
||||
label = "Naming convention"
|
||||
description = (
|
||||
"Objects, mesh data, collections, materials and node groups follow the type prefix "
|
||||
"convention (GEO_, COL_, MAT_...), with no default names, '.001' suffixes, spaces or accents."
|
||||
)
|
||||
category = "Naming"
|
||||
contexts = contexts.ASSET
|
||||
editable_names = True
|
||||
fix_description = "Rename to the suggested name when one can be derived (prefix, suffix and character clean-up)"
|
||||
|
||||
def run(self, ctx):
|
||||
all_prefixes = set(config.OBJECT_PREFIXES.values()) | set(config.DATABLOCK_PREFIXES.values())
|
||||
|
||||
def check(id_block, prefix, kind):
|
||||
problems = _validate(id_block.name, prefix)
|
||||
if problems:
|
||||
suggestion = suggest_name(id_block.name, prefix, all_prefixes)
|
||||
hint = f" -> {suggestion}" if suggestion else ""
|
||||
return Issue.for_id(
|
||||
f"{kind} '{id_block.name}': {', '.join(problems)}{hint}",
|
||||
id_block,
|
||||
data={"suggestion": suggestion},
|
||||
)
|
||||
return None
|
||||
|
||||
objects = ctx.objects()
|
||||
for ob in objects:
|
||||
prefix = config.OBJECT_PREFIXES.get(ob.type)
|
||||
if prefix is None:
|
||||
continue
|
||||
issue = check(ob, prefix, "Object")
|
||||
if issue:
|
||||
yield issue
|
||||
|
||||
if config.MESH_DATA_MATCHES_OBJECT:
|
||||
for ob in objects:
|
||||
me = ob.data
|
||||
if ob.type != "MESH" or me is None or not is_local(me) or me.users > 1:
|
||||
continue
|
||||
if me.name != ob.name:
|
||||
yield Issue.for_id(
|
||||
f"Mesh data '{me.name}' should be named like its object '{ob.name}'",
|
||||
me,
|
||||
severity=Severity.WARNING,
|
||||
data={"match_object": True},
|
||||
)
|
||||
|
||||
for collection_name, prefix in config.DATABLOCK_PREFIXES.items():
|
||||
kind = collection_name.rstrip("s").replace("_", " ").capitalize()
|
||||
for id_block in getattr(bpy.data, collection_name):
|
||||
if not is_local(id_block) or id_block.users == 0:
|
||||
continue
|
||||
if collection_name == "node_groups" and id_block.bl_idname == "CompositorNodeTree":
|
||||
continue
|
||||
issue = check(id_block, prefix, kind)
|
||||
if issue:
|
||||
yield issue
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
skipped = []
|
||||
# Mesh data last: it takes the name of its object once that is fixed.
|
||||
for issue in sorted(issues, key=lambda i: i.data.get("match_object", False)):
|
||||
id_block = issue.get_id()
|
||||
if id_block is None:
|
||||
continue
|
||||
if issue.data.get("match_object"):
|
||||
owners = [ob for ob in bpy.data.objects if ob.data == id_block]
|
||||
suggestion = owners[0].name if len(owners) == 1 else None
|
||||
else:
|
||||
suggestion = issue.data.get("suggestion")
|
||||
if not suggestion:
|
||||
skipped.append(f"'{issue.id_name}': no automatic name, rename it by hand")
|
||||
continue
|
||||
collection = getattr(bpy.data, issue.id_type)
|
||||
existing = collection.get(suggestion)
|
||||
if existing is not None and existing != id_block:
|
||||
skipped.append(f"'{issue.id_name}': '{suggestion}' is already taken")
|
||||
continue
|
||||
id_block.name = suggestion
|
||||
return skipped
|
||||
@@ -0,0 +1,90 @@
|
||||
import bpy
|
||||
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
# Node types that make a tree produce something.
|
||||
_OUTPUT_TYPES = {
|
||||
"OUTPUT_MATERIAL", "OUTPUT_WORLD", "OUTPUT_LIGHT", "OUTPUT_AOV", "OUTPUT_LINESTYLE",
|
||||
"COMPOSITE", "OUTPUT_FILE", "VIEWER",
|
||||
}
|
||||
# Node types never reported as unused.
|
||||
_IGNORED_TYPES = {"FRAME", "GROUP_INPUT"}
|
||||
|
||||
|
||||
def unused_nodes(tree):
|
||||
"""Nodes that do not contribute to any output of ``tree``.
|
||||
|
||||
Returns None when the tree has no output at all (nothing to compare with).
|
||||
"""
|
||||
roots = [
|
||||
node for node in tree.nodes
|
||||
if node.type in _OUTPUT_TYPES
|
||||
or (node.type == "GROUP_OUTPUT" and getattr(node, "is_active_output", True))
|
||||
]
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
upstream = {}
|
||||
for link in tree.links:
|
||||
upstream.setdefault(link.to_node.name, []).append(link.from_node)
|
||||
|
||||
used = set()
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.name in used:
|
||||
continue
|
||||
used.add(node.name)
|
||||
stack.extend(upstream.get(node.name, ()))
|
||||
|
||||
return [node for node in tree.nodes if node.name not in used and node.type not in _IGNORED_TYPES]
|
||||
|
||||
|
||||
def _node_trees():
|
||||
"""(owner datablock, node tree) of every local node tree in use."""
|
||||
for material in bpy.data.materials:
|
||||
if is_local(material) and material.users and material.node_tree:
|
||||
yield material, material.node_tree
|
||||
for world in bpy.data.worlds:
|
||||
if is_local(world) and world.users and world.node_tree:
|
||||
yield world, world.node_tree
|
||||
for group in bpy.data.node_groups:
|
||||
if is_local(group) and group.users:
|
||||
yield group, group
|
||||
|
||||
|
||||
def _tree_of(owner):
|
||||
return owner if isinstance(owner, bpy.types.NodeTree) else owner.node_tree
|
||||
|
||||
|
||||
class UnusedNodesCheck(Check):
|
||||
id = "unused_nodes"
|
||||
label = "Unused nodes"
|
||||
description = "Material, world and node group trees contain no node disconnected from the output."
|
||||
category = "Shading"
|
||||
contexts = contexts.ASSET
|
||||
severity = Severity.WARNING
|
||||
fix_description = "Delete the unused nodes"
|
||||
|
||||
def run(self, ctx):
|
||||
for owner, tree in _node_trees():
|
||||
nodes = unused_nodes(tree)
|
||||
if nodes:
|
||||
names = [node.name for node in nodes]
|
||||
shown = ", ".join(names[:5]) + (" ..." if len(names) > 5 else "")
|
||||
yield Issue.for_id(
|
||||
f"{owner.name}: {len(names)} unused node(s): {shown}",
|
||||
owner,
|
||||
data={"nodes": names},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
owner = issue.get_id()
|
||||
if owner is None:
|
||||
continue
|
||||
tree = _tree_of(owner)
|
||||
for name in issue.data["nodes"]:
|
||||
node = tree.nodes.get(name)
|
||||
if node is not None:
|
||||
tree.nodes.remove(node)
|
||||
@@ -0,0 +1,108 @@
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
|
||||
|
||||
class ModifiersCheck(Check):
|
||||
id = "modifiers"
|
||||
label = "Modifiers"
|
||||
description = "No modifiers other than Subdivision Surface and Multiresolution."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET - {contexts.CHAR_LINEART}
|
||||
fix_description = "Apply the modifiers that are not allowed"
|
||||
# Config key holding the allowed modifier types; subclasses change it.
|
||||
allowed_config_key = "ALLOWED_MODIFIERS"
|
||||
|
||||
def run(self, ctx):
|
||||
allowed = getattr(config, self.allowed_config_key)
|
||||
for ob in ctx.objects():
|
||||
names = [m.name for m in ob.modifiers if m.type not in allowed]
|
||||
if names:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {', '.join(names)}",
|
||||
ob,
|
||||
data={"modifiers": names},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
failed = []
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is None:
|
||||
continue
|
||||
for name in issue.data["modifiers"]:
|
||||
try:
|
||||
with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob]):
|
||||
bpy.ops.object.modifier_apply(modifier=name)
|
||||
except RuntimeError as exc:
|
||||
failed.append(f"{ob.name} / {name}: {str(exc).strip()}")
|
||||
return failed
|
||||
|
||||
|
||||
class ApplyTransformCheck(Check):
|
||||
id = "apply_transform"
|
||||
label = "Transforms applied"
|
||||
description = "Rotation and scale (and location if set in config) of geometry objects are applied."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Apply the transforms (multi-user data is reported, not changed)"
|
||||
|
||||
def run(self, ctx):
|
||||
tolerance = config.TRANSFORM_TOLERANCE
|
||||
channels = config.APPLY_TRANSFORM
|
||||
for ob in ctx.objects(*config.APPLY_TRANSFORM_TYPES):
|
||||
loc, rot, scale = ob.matrix_basis.decompose()
|
||||
problems = []
|
||||
if channels.get("location") and loc.length > tolerance:
|
||||
problems.append("location")
|
||||
if channels.get("rotation") and min(rot.angle, math.tau - rot.angle) > tolerance:
|
||||
problems.append("rotation")
|
||||
if channels.get("scale") and any(abs(s - 1.0) > tolerance for s in scale):
|
||||
problems.append("scale")
|
||||
if problems:
|
||||
yield Issue.for_id(f"{ob.name}: {', '.join(problems)} not applied", ob)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
failed = []
|
||||
channels = config.APPLY_TRANSFORM
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is None:
|
||||
continue
|
||||
if ob.data is not None and ob.data.users > 1:
|
||||
failed.append(f"{ob.name}: data shared by {ob.data.users} users, apply by hand")
|
||||
continue
|
||||
try:
|
||||
with bpy.context.temp_override(
|
||||
object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob]
|
||||
):
|
||||
bpy.ops.object.transform_apply(
|
||||
location=bool(channels.get("location")),
|
||||
rotation=bool(channels.get("rotation")),
|
||||
scale=bool(channels.get("scale")),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
failed.append(f"{ob.name}: {str(exc).strip()}")
|
||||
return failed
|
||||
|
||||
|
||||
class LightCameraCheck(Check):
|
||||
id = "lights_cameras"
|
||||
label = "Lights & cameras"
|
||||
description = "Asset files contain no lights or cameras."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Delete the lights and cameras"
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects(*config.FORBIDDEN_ASSET_OBJECT_TYPES):
|
||||
yield Issue.for_id(f"{ob.type.capitalize()} '{ob.name}' must be removed", ob)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is not None:
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Scene-level settings shared by asset and shot files."""
|
||||
|
||||
import math
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
|
||||
|
||||
class UnitsCheck(Check):
|
||||
id = "units"
|
||||
label = "Units"
|
||||
description = "Unit system, unit scale and length unit follow the production specs."
|
||||
category = "Scene"
|
||||
contexts = contexts.ALL
|
||||
fix_description = "Set the production units"
|
||||
|
||||
def run(self, ctx):
|
||||
units = ctx.scene.unit_settings
|
||||
if units.system != config.UNIT_SYSTEM:
|
||||
yield Issue(f"Unit system is {units.system}, expected {config.UNIT_SYSTEM}")
|
||||
if not math.isclose(units.scale_length, config.UNIT_SCALE_LENGTH, rel_tol=1e-6):
|
||||
yield Issue(f"Unit scale is {units.scale_length:g}, expected {config.UNIT_SCALE_LENGTH:g}")
|
||||
if config.UNIT_LENGTH and units.length_unit != config.UNIT_LENGTH:
|
||||
yield Issue(f"Length unit is {units.length_unit}, expected {config.UNIT_LENGTH}")
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
units = ctx.scene.unit_settings
|
||||
units.system = config.UNIT_SYSTEM
|
||||
units.scale_length = config.UNIT_SCALE_LENGTH
|
||||
if config.UNIT_LENGTH:
|
||||
units.length_unit = config.UNIT_LENGTH
|
||||
|
||||
|
||||
class FilenameCheck(Check):
|
||||
id = "filename"
|
||||
label = "File name"
|
||||
description = "The file is saved and its name follows the naming pattern of the context."
|
||||
category = "File"
|
||||
contexts = contexts.ALL
|
||||
|
||||
def run(self, ctx):
|
||||
if not ctx.filepath:
|
||||
yield Issue("The file has never been saved")
|
||||
return
|
||||
pattern = config.FILENAME_PATTERNS.get(ctx.context_id)
|
||||
if pattern and ctx.filename_match is None:
|
||||
yield Issue(f"'{ctx.filename}' does not match {pattern}")
|
||||
@@ -0,0 +1,50 @@
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
|
||||
class CollectionStructureCheck(Check):
|
||||
"""Placeholder until the production hierarchy is defined: see REQUIRED_COLLECTIONS."""
|
||||
|
||||
id = "collection_structure"
|
||||
label = "Collection structure"
|
||||
description = (
|
||||
"No object directly in the Scene Collection, no empty collections, "
|
||||
"and the required collections (config REQUIRED_COLLECTIONS) exist."
|
||||
)
|
||||
category = "Structure"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Remove empty collections"
|
||||
|
||||
def run(self, ctx):
|
||||
scene = ctx.scene
|
||||
for ob in scene.collection.objects:
|
||||
yield Issue.for_id(f"'{ob.name}' is directly in the Scene Collection", ob)
|
||||
|
||||
for name in config.REQUIRED_COLLECTIONS:
|
||||
if "{asset}" in name:
|
||||
if not ctx.asset_name:
|
||||
yield Issue(f"Cannot resolve required collection '{name}': unknown asset name")
|
||||
continue
|
||||
name = name.format(asset=ctx.asset_name)
|
||||
if name not in bpy.data.collections:
|
||||
yield Issue(f"Required collection '{name}' is missing")
|
||||
|
||||
for collection in scene.collection.children_recursive:
|
||||
if is_local(collection) and not collection.all_objects and not collection.children:
|
||||
yield Issue.for_id(
|
||||
f"Collection '{collection.name}' is empty",
|
||||
collection,
|
||||
severity=Severity.WARNING,
|
||||
data={"empty": True},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
if issue.data.get("empty"):
|
||||
collection = issue.get_id()
|
||||
if collection is not None:
|
||||
bpy.data.collections.remove(collection)
|
||||
unfixed = [i for i in issues if not i.data.get("empty")]
|
||||
return [f"{len(unfixed)} issue(s) must be fixed by hand"] if unfixed else []
|
||||
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
from ...core.mesh import loop_polygon_index, polygon_arrays
|
||||
|
||||
|
||||
class UVCheck(Check):
|
||||
id = "uvs"
|
||||
label = "UVs"
|
||||
description = "Every mesh has a UV map and no UV coordinate is negative."
|
||||
category = "UVs"
|
||||
contexts = contexts.ASSET
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
me = ob.data
|
||||
if not len(me.polygons):
|
||||
continue
|
||||
if not me.uv_layers:
|
||||
yield Issue.for_id(f"{ob.name}: no UV map", ob)
|
||||
continue
|
||||
|
||||
_, totals, _ = polygon_arrays(me)
|
||||
poly_of_loop = loop_polygon_index(totals)
|
||||
for layer in me.uv_layers:
|
||||
uv = np.empty(len(me.loops) * 2, dtype=np.float32)
|
||||
layer.uv.foreach_get("vector", uv)
|
||||
negative = (uv.reshape(-1, 2) < -config.UV_NEGATIVE_TOLERANCE).any(axis=1)
|
||||
if negative.any():
|
||||
faces = np.unique(poly_of_loop[negative])
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: UV map '{layer.name}' has negative UVs on {faces.size} face(s)",
|
||||
ob,
|
||||
elements=faces.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
Reference in new issue
Block a user