Audit prop budgets and add a prop-gallery art check
- audit_glb decodes geometry: 400 tris / 600 verts budget, degenerate triangles, UV range, ground contact, one material; newspaper box parts moved to sit on y=0 - capture_prop_gallery.gd renders the six props on a studio backdrop; new prop_gallery catalogue entry + AI mockup (compare 10 good, judge match 0.90) - headless test that instances share the imported atlas material - README documents the gallery capture and budget; TODO props pipeline section moved to DONE
This commit is contained in:
1 parent
bb40a98537
commit
2a4e0d1998
12 files changed
+310
-22
No files matched your search
+93
-6
@@ -11,7 +11,9 @@ keeps that art direction in one place:
|
||||
`generate_textures.py`) supplies the colour palette, so a model can look
|
||||
like the concept without a 3D-model API;
|
||||
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
|
||||
`PackedScene`s, and `audit` checks the files are structurally valid.
|
||||
`PackedScene`s, and `audit` checks the files are structurally valid and stay
|
||||
inside the art budget (<= 400 triangles, <= 600 vertices per prop, no
|
||||
degenerate triangles, UVs in the atlas, base at y ~= 0, one material).
|
||||
|
||||
Commands (run from the repo root):
|
||||
|
||||
@@ -118,13 +120,13 @@ PROPS = [
|
||||
"short stand, plain light grey studio background"),
|
||||
"parts": [
|
||||
{"shape": "box", "size": [0.44, 0.10, 0.38],
|
||||
"at": [0, 0.08, 0], "role": "metal"},
|
||||
"at": [0, 0.05, 0], "role": "metal"},
|
||||
{"shape": "box", "size": [0.42, 0.72, 0.34],
|
||||
"at": [0, 0.49, 0], "role": "body"},
|
||||
"at": [0, 0.46, 0], "role": "body"},
|
||||
{"shape": "box", "size": [0.34, 0.30, 0.03],
|
||||
"at": [0, 0.60, 0.18], "role": "glass"},
|
||||
"at": [0, 0.57, 0.18], "role": "glass"},
|
||||
{"shape": "box", "size": [0.44, 0.10, 0.36],
|
||||
"at": [0, 0.90, 0], "role": "accent"},
|
||||
"at": [0, 0.87, 0], "role": "accent"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -705,8 +707,58 @@ def build(only="", quiet=False) -> list:
|
||||
# Audit.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAX_TRIANGLES = 400
|
||||
MAX_VERTICES = 600
|
||||
_DEGENERATE_AREA2 = 1e-9
|
||||
|
||||
|
||||
def _read_float_accessor(gltf: dict, binary: bytes, index: int) -> list:
|
||||
"""Decode a float SCALAR/VEC2/VEC3 accessor into a list of tuples/values."""
|
||||
accessor = gltf["accessors"][index]
|
||||
count = int(accessor["count"])
|
||||
kind = accessor["type"]
|
||||
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[kind]
|
||||
view = gltf["bufferViews"][accessor["bufferView"]]
|
||||
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
|
||||
stride = int(view.get("byteStride", width * 4))
|
||||
values = []
|
||||
for i in range(count):
|
||||
base = offset + i * stride
|
||||
row = struct.unpack_from("<" + "f" * width, binary, base)
|
||||
values.append(row[0] if width == 1 else row)
|
||||
return values
|
||||
|
||||
|
||||
def _read_index_accessor(gltf: dict, binary: bytes, index: int) -> list:
|
||||
accessor = gltf["accessors"][index]
|
||||
width = {5121: 1, 5123: 2, 5125: 4}.get(int(accessor["componentType"]), 2)
|
||||
fmt = {1: "B", 2: "H", 4: "I"}[width]
|
||||
view = gltf["bufferViews"][accessor["bufferView"]]
|
||||
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
|
||||
stride = int(view.get("byteStride", width))
|
||||
out = []
|
||||
for i in range(int(accessor["count"])):
|
||||
out.append(struct.unpack_from("<" + fmt, binary,
|
||||
offset + i * stride)[0])
|
||||
return out
|
||||
|
||||
|
||||
def _triangle_area2(a, b, c) -> float:
|
||||
ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||||
ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
|
||||
cross = (ab[1] * ac[2] - ab[2] * ac[1],
|
||||
ab[2] * ac[0] - ab[0] * ac[2],
|
||||
ab[0] * ac[1] - ab[1] * ac[0])
|
||||
return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]
|
||||
|
||||
|
||||
def audit_glb(path: Path) -> dict:
|
||||
"""Check a .glb file's container, JSON chunk, and triangle count."""
|
||||
"""Check a .glb file's container, JSON chunk, and mesh contract.
|
||||
|
||||
The mesh must stay in the prop budget, have no degenerate triangles, keep
|
||||
atlas UVs inside [0, 1], sit on the ground (min y ~= 0), and use exactly one
|
||||
atlas material.
|
||||
"""
|
||||
issues = []
|
||||
if not path.is_file():
|
||||
return {"path": str(path), "ok": False,
|
||||
@@ -765,6 +817,41 @@ def audit_glb(path: Path) -> dict:
|
||||
for buffer in gltf.get("buffers", []):
|
||||
if int(buffer.get("byteLength", 0)) > len(data):
|
||||
issues.append("buffer larger than the file")
|
||||
if triangles > MAX_TRIANGLES:
|
||||
issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})")
|
||||
# Decode geometry for the deeper checks. A malformed buffer view is already
|
||||
# reported above, so skip quietly when the binary chunk is unusable.
|
||||
try:
|
||||
_, bin_len, _ = struct.unpack_from("<III", data, 12 + 8 + json_len)
|
||||
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
|
||||
vertices = 0
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
positions = _read_float_accessor(
|
||||
gltf, binary, attributes["POSITION"])
|
||||
vertices += len(positions)
|
||||
if positions:
|
||||
min_y = min(p[1] for p in positions)
|
||||
if abs(min_y) > 0.02:
|
||||
issues.append(
|
||||
f"prop does not sit on the ground (min y {min_y:.3f})")
|
||||
uvs = _read_float_accessor(
|
||||
gltf, binary, attributes["TEXCOORD_0"])
|
||||
if any(not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
|
||||
for u, v in uvs):
|
||||
issues.append("atlas UV outside [0, 1]")
|
||||
indices = _read_index_accessor(
|
||||
gltf, binary, primitive["indices"])
|
||||
for tri in range(0, len(indices), 3):
|
||||
a, b, c = (positions[indices[tri + i]] for i in range(3))
|
||||
if _triangle_area2(a, b, c) < _DEGENERATE_AREA2:
|
||||
issues.append("degenerate triangle")
|
||||
break
|
||||
if vertices > MAX_VERTICES:
|
||||
issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})")
|
||||
except (KeyError, IndexError, ValueError, struct.error) as exc:
|
||||
issues.append(f"unreadable geometry: {exc}")
|
||||
return {"path": str(path), "ok": not issues, "issues": issues,
|
||||
"triangles": triangles}
|
||||
|
||||
|
||||
Reference in new issue
Block a user