Draw prop models with a single atlas material
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m39s

Each generated prop used one material per part (body, accent, metal...). The
model pipeline now packs a prop's palette into one power-of-two colour atlas plus a
matching metallic/roughness atlas and emits a single material:

- atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and
  metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat.
- build_glb(): every part merges into one primitive with constant per-vertex atlas
  UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0.
- audit_glb(): fails unless a prop has exactly one material, both atlas textures,
  two images, and TEXCOORD_0 on every primitive.
- Removed the now-unused sRGB->linear factor helpers; colour travels in the texture.

Verification: headless 181 -> 182 (new "one material per prop scene" test), Python
tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old
factor tests), model audit OK for all six props, screenshots regenerated and
screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
This commit is contained in:
zwitschi committed 2026-10-31 23:47:15 +01:00
1 parent 7f92b8861d
commit f2863abeac
32 files changed
+275 -97

No files matched your search

+181 -67
View File
@@ -475,99 +475,196 @@ def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
# glTF binary writer.
# ---------------------------------------------------------------------------
ATLAS_CELL = 8
def _next_pow2(value: int) -> int:
size = 1
while size < value:
size *= 2
return size
def _png_bytes(width: int, height: int, rgba: bytearray) -> bytes:
"""Encode raw RGBA rows as a PNG without requiring Pillow."""
import zlib
rows = bytearray()
stride = width * 4
for y in range(height):
rows.append(0) # filter type 0
rows.extend(rgba[y * stride:(y + 1) * stride])
def chunk(tag: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + tag + payload +
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
def atlas_for(prop: dict, palette: dict = None) -> dict:
"""Pack every colour this prop uses into one tiny texture atlas.
Each part gets a constant UV pointing at its palette cell, so all parts can
share a single material instead of one material per part. The same layout
is written into a metallic/roughness atlas (glTF reads roughness from G and
metallic from B), keeping the per-role surface response.
"""
palette = palette or PALETTE
slots = {}
for part in prop["parts"]:
role = part.get("role", "body")
colour = tuple(round(float(c), 5)
for c in palette.get(role, PALETTE[role])[:3])
metallic, roughness = PBR.get(role, PBR["body"])
slots[role] = (colour, metallic, roughness)
roles = sorted(slots.keys())
grid = max(1, int(math.ceil(math.sqrt(len(roles)))))
size = _next_pow2(grid * ATLAS_CELL)
base = bytearray(size * size * 4)
metal_rough = bytearray(size * size * 4)
role_uv = {}
for index, role in enumerate(roles):
colour, metallic, roughness = slots[role]
col = index % grid
row = index // grid
x0 = col * ATLAS_CELL
y0 = row * ATLAS_CELL
rgb = bytes((int(round(colour[0] * 255)), int(round(colour[1] * 255)),
int(round(colour[2] * 255)), 255))
mr = bytes((0, int(round(roughness * 255)), int(round(metallic * 255)), 255))
for y in range(ATLAS_CELL):
for x in range(ATLAS_CELL):
offset = ((y0 + y) * size + (x0 + x)) * 4
base[offset:offset + 4] = rgb
metal_rough[offset:offset + 4] = mr
role_uv[role] = ((x0 + ATLAS_CELL / 2.0) / size,
(y0 + ATLAS_CELL / 2.0) / size)
return {
"size": size,
"roles": roles,
"role_uv": role_uv,
"colours": {role: slots[role][0] for role in roles},
"base_png": _png_bytes(size, size, base),
"metal_rough_png": _png_bytes(size, size, metal_rough),
}
def _pad(data: bytes, fill: bytes) -> bytes:
remainder = len(data) % 4
return data if remainder == 0 else data + fill * (4 - remainder)
def srgb_to_linear(channel: float) -> float:
"""Convert one 0..1 sRGB channel to linear.
def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
"""Serialise one prop as a self-contained .glb (glTF 2.0) file.
glTF stores `baseColorFactor` in linear space, while the catalogue palettes
(and the colours sampled from a concept PNG) are sRGB. Writing sRGB numbers
straight into the factor makes every prop render several shades too light,
which is why the first build's bins and newspaper boxes looked pastel.
All parts share one material backed by a colour atlas and a
metallic/roughness atlas, so the prop draws with a single material.
"""
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
def _linear_rgba(color) -> list:
return [round(srgb_to_linear(float(c)), 5) for c in color[:3]] + [1.0]
def build_glb(prop: dict, palette: dict = None) -> bytes:
"""Serialise one prop as a self-contained .glb (glTF 2.0) file."""
palette = palette or PALETTE
atlas = atlas or atlas_for(prop, palette)
positions = []
normals = []
uvs = []
indices = []
for part in prop["parts"]:
mesh = build_part_mesh(part)
base = len(positions)
uv = atlas["role_uv"][part.get("role", "body")]
for position, normal in zip(mesh.positions, mesh.normals):
positions.append(position)
normals.append(normal)
uvs.append(uv)
indices.extend(index + base for index in mesh.indices)
binary = bytearray()
buffer_views = []
accessors = []
materials = []
primitives = []
def add_view(payload: bytes, target: int) -> int:
buffer_views.append({
def add_view(payload: bytes, target=None) -> int:
view = {
"buffer": 0,
"byteOffset": len(binary),
"byteLength": len(payload),
"target": target,
})
}
if target is not None:
view["target"] = target
buffer_views.append(view)
binary.extend(_pad(payload, b"\x00"))
return len(buffer_views) - 1
# One primitive per part: the parts share a mesh but need separate
# materials, and a glTF primitive is the unit of material assignment.
for part in prop["parts"]:
mesh = build_part_mesh(part)
positions = b"".join(struct.pack("<3f", *p) for p in mesh.positions)
normals = b"".join(struct.pack("<3f", *n) for n in mesh.normals)
indices = b"".join(struct.pack("<H", i) for i in mesh.indices)
pos_view = add_view(positions, 34962)
nrm_view = add_view(normals, 34962)
idx_view = add_view(indices, 34963)
pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
idx_payload = b"".join(struct.pack("<H", i) for i in indices)
xs = [p[0] for p in mesh.positions] or [0.0]
ys = [p[1] for p in mesh.positions] or [0.0]
zs = [p[2] for p in mesh.positions] or [0.0]
pos_accessor = len(accessors)
accessors.append({"bufferView": pos_view, "componentType": 5126,
"count": len(mesh.positions), "type": "VEC3",
"min": [min(xs), min(ys), min(zs)],
"max": [max(xs), max(ys), max(zs)]})
nrm_accessor = len(accessors)
accessors.append({"bufferView": nrm_view, "componentType": 5126,
"count": len(mesh.normals), "type": "VEC3"})
idx_accessor = len(accessors)
accessors.append({"bufferView": idx_view, "componentType": 5123,
"count": len(mesh.indices), "type": "SCALAR"})
pos_view = add_view(pos_payload, 34962)
nrm_view = add_view(nrm_payload, 34962)
uv_view = add_view(uv_payload, 34962)
idx_view = add_view(idx_payload, 34963)
role = part.get("role", "body")
color = palette.get(role, PALETTE[role])
metallic, roughness = PBR.get(role, PBR["body"])
materials.append({
"name": role,
"doubleSided": False,
"pbrMetallicRoughness": {
"baseColorFactor": _linear_rgba(color),
"metallicFactor": metallic,
"roughnessFactor": roughness,
},
})
primitives.append({
"attributes": {"POSITION": pos_accessor, "NORMAL": nrm_accessor},
"indices": idx_accessor,
"material": len(materials) - 1,
})
xs = [p[0] for p in positions] or [0.0]
ys = [p[1] for p in positions] or [0.0]
zs = [p[2] for p in positions] or [0.0]
pos_accessor = len(accessors)
accessors.append({"bufferView": pos_view, "componentType": 5126,
"count": len(positions), "type": "VEC3",
"min": [min(xs), min(ys), min(zs)],
"max": [max(xs), max(ys), max(zs)]})
nrm_accessor = len(accessors)
accessors.append({"bufferView": nrm_view, "componentType": 5126,
"count": len(normals), "type": "VEC3"})
uv_accessor = len(accessors)
accessors.append({"bufferView": uv_view, "componentType": 5126,
"count": len(uvs), "type": "VEC2"})
idx_accessor = len(accessors)
accessors.append({"bufferView": idx_view, "componentType": 5123,
"count": len(indices), "type": "SCALAR"})
base_view = add_view(atlas["base_png"])
mr_view = add_view(atlas["metal_rough_png"])
gltf = {
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
"scene": 0,
"scenes": [{"name": prop["name"], "nodes": [0]}],
"nodes": [{"name": prop["name"], "mesh": 0}],
"meshes": [{"name": prop["name"], "primitives": primitives}],
"materials": materials,
"meshes": [{
"name": prop["name"],
"primitives": [{
"attributes": {"POSITION": pos_accessor,
"NORMAL": nrm_accessor,
"TEXCOORD_0": uv_accessor},
"indices": idx_accessor,
"material": 0,
}],
}],
"materials": [{
"name": "prop_palette",
"doubleSided": False,
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0},
"metallicRoughnessTexture": {"index": 1},
"metallicFactor": 1.0,
"roughnessFactor": 1.0,
},
}],
"textures": [
{"sampler": 0, "source": 0},
{"sampler": 0, "source": 1},
],
"samplers": [{
"magFilter": 9728, # NEAREST, so a palette cell stays a flat colour
"minFilter": 9728,
"wrapS": 33071, # CLAMP_TO_EDGE
"wrapT": 33071,
}],
"images": [
{"bufferView": base_view, "mimeType": "image/png"},
{"bufferView": mr_view, "mimeType": "image/png"},
],
"accessors": accessors,
"bufferViews": buffer_views,
"buffers": [{"byteLength": len(binary)}],
@@ -646,8 +743,25 @@ def audit_glb(path: Path) -> dict:
issues.append("primitive references a missing accessor")
if triangles <= 0:
issues.append("no triangles")
if not gltf.get("materials"):
materials = gltf.get("materials", [])
if not materials:
issues.append("no materials")
elif len(materials) != 1:
issues.append(f"expected 1 shared material, found {len(materials)}")
else:
pbr = materials[0].get("pbrMetallicRoughness", {})
if "baseColorTexture" not in pbr:
issues.append("shared material has no baseColorTexture")
if "metallicRoughnessTexture" not in pbr:
issues.append("shared material has no metallicRoughnessTexture")
if len(gltf.get("textures", [])) < 2:
issues.append("expected colour and metallic/roughness textures")
if len(gltf.get("images", [])) < 2:
issues.append("expected colour and metallic/roughness atlas images")
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
if "TEXCOORD_0" not in primitive.get("attributes", {}):
issues.append("primitive has no atlas UVs (TEXCOORD_0)")
for buffer in gltf.get("buffers", []):
if int(buffer.get("byteLength", 0)) > len(data):
issues.append("buffer larger than the file")
+36 -23
View File
@@ -94,14 +94,17 @@ class GlbTests(unittest.TestCase):
self.assertEqual(gltf["asset"]["version"], "2.0")
self.assertEqual(len(gltf["scenes"]), 1)
primitives = gltf["meshes"][0]["primitives"]
self.assertEqual(len(primitives), len(prop("bench")["parts"]),
"one primitive per part")
self.assertEqual(len(gltf["materials"]), len(primitives))
self.assertEqual(len(primitives), 1,
"parts share one primitive (one material)")
self.assertEqual(len(gltf["materials"]), 1,
"the prop draws with a single material")
for primitive in primitives:
self.assertIn("POSITION", primitive["attributes"])
self.assertIn("NORMAL", primitive["attributes"])
self.assertIn("TEXCOORD_0", primitive["attributes"],
"atlas UVs are required")
self.assertIsNotNone(primitive["indices"])
self.assertLess(primitive["material"], len(gltf["materials"]))
self.assertEqual(primitive["material"], 0)
for accessor in gltf["accessors"]:
self.assertIn(accessor["bufferView"],
range(len(gltf["bufferViews"])))
@@ -114,31 +117,41 @@ class GlbTests(unittest.TestCase):
second = model_pipeline.build_glb(prop("bollard"))
self.assertEqual(first, second)
def test_palette_override_changes_material_colour(self):
def test_palette_override_changes_the_atlas_colour(self):
palette = {role: list(rgb)
for role, rgb in model_pipeline.PALETTE.items()}
palette["metal"] = [0.9, 0.1, 0.1]
gltf = parse_glb(model_pipeline.build_glb(
prop("bollard"), palette))["gltf"]
factors = [m["pbrMetallicRoughness"]["baseColorFactor"]
for m in gltf["materials"]]
self.assertIn(model_pipeline._linear_rgba([0.9, 0.1, 0.1]), factors)
atlas = model_pipeline.atlas_for(prop("bollard"), palette)
self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
default = model_pipeline.build_glb(prop("bollard"))
overridden = model_pipeline.build_glb(prop("bollard"), palette)
self.assertNotEqual(default, overridden,
"the atlas texture carries the palette")
def test_glb_colours_are_written_in_linear_space(self):
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
def test_material_uses_both_atlas_textures(self):
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
factor = gltf["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"]
srgb_metal = model_pipeline.PALETTE["metal"][0]
self.assertLess(factor[0], srgb_metal)
self.assertAlmostEqual(factor[0],
model_pipeline.srgb_to_linear(srgb_metal),
places=4)
pbr = gltf["materials"][0]["pbrMetallicRoughness"]
self.assertEqual(pbr["metallicFactor"], 1.0)
self.assertEqual(pbr["roughnessFactor"], 1.0)
self.assertIn("baseColorTexture", pbr)
self.assertIn("metallicRoughnessTexture", pbr)
self.assertEqual(len(gltf["images"]), 2)
self.assertEqual(len(gltf["textures"]), 2)
for image in gltf["images"]:
self.assertEqual(image["mimeType"], "image/png")
self.assertEqual(gltf["samplers"][0]["magFilter"], 9728,
"nearest sampling keeps palette cells flat")
def test_srgb_to_linear_reference_values(self):
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.0), 0.0)
self.assertAlmostEqual(model_pipeline.srgb_to_linear(1.0), 1.0)
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.5), 0.2140,
places=4)
def test_atlas_is_deterministic_and_power_of_two(self):
first = model_pipeline.atlas_for(prop("planter_box"))
second = model_pipeline.atlas_for(prop("planter_box"))
self.assertEqual(first["base_png"], second["base_png"])
size = first["size"]
self.assertEqual(size & (size - 1), 0, "atlas size is a power of two")
for u, v in first["role_uv"].values():
self.assertTrue(0.0 < u < 1.0 and 0.0 < v < 1.0,
"UVs stay on the texture")
self.assertEqual(len(first["roles"]), len(set(first["roles"])))
def test_indices_stay_inside_their_primitive(self):
payload = model_pipeline.build_glb(prop("planter_box"))