Rendered headless by the example itself — click to zoom.
-
- Source
-
- """Game-ready blacksmith bellows — a showcase piece, not an example.
-
-Asserts budget conformance of a procedural bellows (hinged timber
-paddles, leather accordion, iron nozzle) after composing shipped
-pipeline pieces: bmesh construction, UVs, three materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
-
-Budgets are declared below and recomputed from the generated result.
-They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
-DECIMATE stage so the LOD-ratio budget fails.
-
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
-are not byte-identical across Blender versions — the LOD gate is a
-ratio band, not an exact count.
-
- blender --background --python bellows.py --
- blender --background --python bellows.py -- --skip-decimate
- blender --background --python bellows.py -- --output bellows.png
-"""
-import argparse
-import math
-import os
-import sys
-import tempfile
-import traceback
-
-import bmesh
-import bpy
-from mathutils import Euler, Vector
-
-_REPO = os.path.abspath(
- os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
-)
-sys.path.insert(0, os.path.join(_REPO, "examples"))
-sys.dont_write_bytecode = True
-import gallery_framing # noqa: E402
-
-BOARD_L = 0.52
-BOARD_W = 0.30
-BOARD_T = 0.034
-OPEN = 0.26
-N_FOLDS = 5
-NOZZLE_L = 0.10
-NOZZLE_R = 0.018
-
-BBOX_TOL = 0.01
-OUTER_SIZE = (0.630, 0.403, 0.277)
-BASE_TRIS_MIN = 1180
-BASE_TRIS_MAX = 1380
-LOD1_RATIO_MIN = 0.32
-LOD1_RATIO_MAX = 0.62
-LOD2_RATIO_MIN = 0.10
-LOD2_RATIO_MAX = 0.35
-LOD1_TARGET = 0.50
-LOD2_TARGET = 0.22
-MATERIAL_COUNT = 3
-UV_EPS = 1e-4
-UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 180
-BAKE_RES = 256
-CAGE_EXTRUSION = 0.08
-METAL_FACES_MIN = 24
-WOOD_FACES_MIN = 12
-LEATHER_FACES_MIN = 24
-
-WOOD_IDX = 0
-LEATHER_IDX = 1
-METAL_IDX = 2
-
-
-def eevee_engine_id():
- return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
-
-
-def fail(msg, code):
- print(f"ERROR: {msg}", file=sys.stderr)
- return code
-
-
-def triangle_count(mesh):
- mesh.calc_loop_triangles()
- return len(mesh.loop_triangles)
-
-
-def evaluated_triangle_count(obj):
- depsgraph = bpy.context.evaluated_depsgraph_get()
- eval_obj = obj.evaluated_get(depsgraph)
- eval_mesh = eval_obj.to_mesh()
- try:
- eval_mesh.calc_loop_triangles()
- return len(eval_mesh.loop_triangles)
- finally:
- eval_obj.to_mesh_clear()
-
-
-def deselect_all():
- for ob in list(bpy.context.view_layer.objects):
- if ob is None:
- continue
- ob.select_set(False)
-
-
-def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
- geo = bmesh.ops.create_cube(bm, size=1.0)
- verts = geo["verts"]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
- v.co = rot @ p + origin
- faces = {f for v in verts for f in v.link_faces}
- for f in faces:
- f.material_index = mat_idx
- return verts
-
-
-def add_oriented_box(bm, a, b, scale_xy, mat_idx):
- a = Vector(a)
- b = Vector(b)
- delta = b - a
- length = delta.length
- if length < 1e-8:
- return []
- quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
- eul = quat.to_euler("XYZ")
- return add_box(
- bm,
- ((a + b) * 0.5),
- (scale_xy[0], scale_xy[1], length),
- mat_idx,
- euler=(eul.x, eul.y, eul.z),
- )
-
-
-def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
- geo = bmesh.ops.create_cone(
- bm,
- cap_ends=True,
- cap_tris=False,
- segments=segments,
- radius1=radius,
- radius2=radius,
- depth=depth,
- )
- verts = geo["verts"]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- faces = {f for v in verts for f in v.link_faces}
- for f in faces:
- f.material_index = mat_idx
- return verts
-
-
-def _quad(bm, verts, mat_idx):
- try:
- face = bm.faces.new(verts)
- except ValueError:
- return None
- face.material_index = mat_idx
- return face
-
-
-def add_leather_panel(
- bm, a0, a1, b0, b1, n_u, n_v, folds, bulge, thickness, outward, mat_idx
-):
- a0 = Vector(a0)
- a1 = Vector(a1)
- b0 = Vector(b0)
- b1 = Vector(b1)
- outward = Vector(outward)
- if outward.length < 1e-8:
- return []
- outward = outward.normalized()
- grid_o = []
- grid_i = []
- collected = []
- for iv in range(n_v + 1):
- tv = iv / n_v
- row_o = []
- row_i = []
- for iu in range(n_u + 1):
- tu = iu / n_u
- p = a0.lerp(a1, tu).lerp(b0.lerp(b1, tu), tv)
- fold = abs(math.sin(tu * folds * math.pi))
- puff = math.sin(tv * math.pi)
- p = p + outward * (0.003 + bulge * fold * puff)
- vo = bm.verts.new(p + outward * (thickness * 0.5))
- vi = bm.verts.new(p - outward * (thickness * 0.5))
- row_o.append(vo)
- row_i.append(vi)
- collected.extend((vo, vi))
- grid_o.append(row_o)
- grid_i.append(row_i)
- bm.verts.ensure_lookup_table()
- for iv in range(n_v):
- for iu in range(n_u):
- _quad(
- bm,
- (
- grid_o[iv][iu],
- grid_o[iv][iu + 1],
- grid_o[iv + 1][iu + 1],
- grid_o[iv + 1][iu],
- ),
- mat_idx,
- )
- _quad(
- bm,
- (
- grid_i[iv][iu + 1],
- grid_i[iv][iu],
- grid_i[iv + 1][iu],
- grid_i[iv + 1][iu + 1],
- ),
- mat_idx,
- )
- for iu in range(n_u):
- _quad(
- bm,
- (grid_o[0][iu], grid_i[0][iu], grid_i[0][iu + 1], grid_o[0][iu + 1]),
- mat_idx,
- )
- _quad(
- bm,
- (
- grid_o[n_v][iu + 1],
- grid_i[n_v][iu + 1],
- grid_i[n_v][iu],
- grid_o[n_v][iu],
- ),
- mat_idx,
- )
- for iv in range(n_v):
- _quad(
- bm,
- (grid_o[iv][0], grid_o[iv + 1][0], grid_i[iv + 1][0], grid_i[iv][0]),
- mat_idx,
- )
- _quad(
- bm,
- (
- grid_o[iv][n_u],
- grid_i[iv][n_u],
- grid_i[iv + 1][n_u],
- grid_o[iv + 1][n_u],
- ),
- mat_idx,
- )
- return collected
-
-
-def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
- geo = bmesh.ops.create_cone(
- bm,
- cap_ends=True,
- cap_tris=False,
- segments=segments,
- radius1=radius1,
- radius2=radius2,
- depth=depth,
- )
- verts = geo["verts"]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- faces = {f for v in verts for f in v.link_faces}
- for f in faces:
- f.material_index = mat_idx
- return verts
-
-
-def pack_uvs(bm, margin=0.08):
- uv = bm.loops.layers.uv.new("UVMap")
- faces = list(bm.faces)
- n = len(faces)
- cols = max(1, math.ceil(math.sqrt(n)))
- rows = max(1, math.ceil(n / cols))
- cell_w = 1.0 / cols
- cell_h = 1.0 / rows
- pad_u = margin * cell_w * 0.5
- pad_v = margin * cell_h * 0.5
- usable_w = cell_w - 2.0 * pad_u
- usable_h = cell_h - 2.0 * pad_v
- for i, face in enumerate(faces):
- col = i % cols
- row = i // cols
- nrm = face.normal
- ax = abs(nrm.x)
- ay = abs(nrm.y)
- az = abs(nrm.z)
- coords = []
- for loop in face.loops:
- co = loop.vert.co
- if az >= ax and az >= ay:
- coords.append((co.x, co.y))
- elif ax >= ay:
- coords.append((co.y, co.z))
- else:
- coords.append((co.x, co.z))
- xs = [c[0] for c in coords]
- ys = [c[1] for c in coords]
- minx, maxx = min(xs), max(xs)
- miny, maxy = min(ys), max(ys)
- dx = max(maxx - minx, 1e-8)
- dy = max(maxy - miny, 1e-8)
- origin_u = col * cell_w + pad_u
- origin_v = row * cell_h + pad_v
- for loop, (x, y) in zip(face.loops, coords):
- loop[uv].uv = (
- origin_u + (x - minx) / dx * usable_w,
- origin_v + (y - miny) / dy * usable_h,
- )
-
-
-def build_bellows_mesh(name, bevel_offset, bevel_segments):
- bm = bmesh.new()
- try:
- wood = []
- leather = []
- metal = []
- z_bot = BOARD_T
- open_ang = math.atan2(OPEN, BOARD_L)
- hinge = Vector((BOARD_L / 2.0 - 0.018, 0.0, z_bot + 0.006))
-
- wood.extend(
- add_box(
- bm,
- (0.0, 0.0, BOARD_T / 2.0),
- (BOARD_L, BOARD_W, BOARD_T),
- WOOD_IDX,
- )
- )
-
- rot = Euler((0.0, -open_ang, 0.0)).to_matrix()
- local_center = Vector((-BOARD_L / 2.0 + 0.018, 0.0, BOARD_T / 2.0))
- top_c = hinge + rot @ local_center
- wood.extend(
- add_box(
- bm,
- (top_c.x, top_c.y, top_c.z),
- (BOARD_L, BOARD_W, BOARD_T),
- WOOD_IDX,
- euler=(0.0, -open_ang, 0.0),
- )
- )
- for y in (-0.065, 0.065):
- p0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T))
- p1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T + 0.072))
- wood.extend(add_oriented_box(bm, p0, p1, (0.022, 0.022), WOOD_IDX))
- g0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, -0.085, BOARD_T + 0.072))
- g1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, 0.085, BOARD_T + 0.072))
- wood.extend(add_oriented_box(bm, g0, g1, (0.018, 0.018), WOOD_IDX))
-
- if bevel_offset > 0.0:
- edges = list({e for v in wood for e in v.link_edges})
- ret = bmesh.ops.bevel(
- bm,
- geom=edges,
- offset=bevel_offset,
- segments=bevel_segments,
- profile=0.5,
- affect='EDGES',
- clamp_overlap=True,
- )
- for f in ret.get('faces') or []:
- f.material_index = WOOD_IDX
-
- hw = BOARD_W / 2.0 - 0.003
- hx0 = -BOARD_L / 2.0 + 0.010
- hx1 = BOARD_L / 2.0 - 0.036
-
- def top_under(lx, ly):
- return hinge + rot @ (local_center + Vector((lx, ly, -BOARD_T / 2.0)))
-
- for side in (-1.0, 1.0):
- y = side * hw
- leather.extend(
- add_leather_panel(
- bm,
- Vector((hx0, y, z_bot)),
- Vector((hx1, y, z_bot)),
- top_under(-BOARD_L / 2.0, y),
- top_under(BOARD_L / 2.0 - 0.028, y),
- 8,
- 5,
- N_FOLDS,
- 0.050,
- 0.008,
- Vector((0.0, side, 0.0)),
- LEATHER_IDX,
- )
- )
- leather.extend(
- add_leather_panel(
- bm,
- Vector((hx0, -hw, z_bot)),
- Vector((hx0, hw, z_bot)),
- top_under(-BOARD_L / 2.0, -hw),
- top_under(-BOARD_L / 2.0, hw),
- 6,
- 5,
- N_FOLDS,
- 0.022,
- 0.008,
- Vector((-1.0, 0.0, 0.0)),
- LEATHER_IDX,
- )
- )
-
- nz = z_bot + 0.028
- metal.extend(
- add_cyl(
- bm,
- (BOARD_L / 2.0 + NOZZLE_L / 2.0 - 0.01, 0.0, nz),
- NOZZLE_R,
- NOZZLE_L,
- 12,
- METAL_IDX,
- euler=(0.0, math.pi / 2.0, 0.0),
- )
- )
- metal.extend(
- add_cyl(
- bm,
- (BOARD_L / 2.0 + 0.006, 0.0, nz),
- 0.030,
- 0.018,
- 12,
- METAL_IDX,
- euler=(0.0, math.pi / 2.0, 0.0),
- )
- )
- metal.extend(
- add_cone(
- bm,
- (BOARD_L / 2.0 + NOZZLE_L - 0.012, 0.0, nz),
- NOZZLE_R,
- 0.013,
- 0.042,
- 12,
- METAL_IDX,
- euler=(0.0, math.pi / 2.0, 0.0),
- )
- )
- metal.extend(
- add_box(
- bm,
- (BOARD_L / 2.0 - 0.04, 0.0, hinge.z),
- (0.018, 0.070, 0.018),
- METAL_IDX,
- )
- )
-
- xs = [v.co.x for v in bm.verts]
- ys = [v.co.y for v in bm.verts]
- zs = [v.co.z for v in bm.verts]
- rcx = 0.5 * (min(xs) + max(xs))
- rcy = 0.5 * (min(ys) + max(ys))
- zmin = min(zs)
- for v in bm.verts:
- v.co.x -= rcx
- v.co.y -= rcy
- v.co.z -= zmin
- if v.co.z < 0.0:
- v.co.z = 0.0
-
- pack_uvs(bm)
- bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
- for face in bm.faces:
- face.smooth = False
- me = bpy.data.meshes.new(name)
- bm.to_mesh(me)
- me.update()
- for poly in me.polygons:
- poly.use_smooth = False
- finally:
- bm.free()
- out = bpy.data.objects.new(name, me)
- bpy.context.collection.objects.link(out)
- return out
-
-
-def principled(name, color, metallic, roughness):
- mat = bpy.data.materials.new(name)
- mat.use_nodes = True
- bsdf = mat.node_tree.nodes["Principled BSDF"]
- bsdf.inputs["Base Color"].default_value = color
- bsdf.inputs["Metallic"].default_value = metallic
- bsdf.inputs["Roughness"].default_value = roughness
- return mat
-
-
-def assign_slots(obj, wood, leather, metal):
- mats = obj.data.materials
- wanted = (wood, leather, metal)
- for i, mat in enumerate(wanted):
- if i < len(mats):
- mats[i] = mat
- else:
- mats.append(mat)
-
-
-def world_bbox(obj):
- corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- xs = [c.x for c in corners]
- ys = [c.y for c in corners]
- zs = [c.z for c in corners]
- return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
-
-
-def uv_stats(mesh):
- uv = mesh.uv_layers.active
- if uv is None:
- return 0.0, 0.0, 1.0, 1.0, 0, 1.0
- data = uv.data
- us = [loop.uv[0] for loop in data]
- vs = [loop.uv[1] for loop in data]
- aabbs = []
- for poly in mesh.polygons:
- pu = [data[i].uv[0] for i in poly.loop_indices]
- pv = [data[i].uv[1] for i in poly.loop_indices]
- aabbs.append((min(pu), min(pv), max(pu), max(pv)))
- overlap = 0.0
- for i in range(len(aabbs)):
- a = aabbs[i]
- for j in range(i + 1, len(aabbs)):
- b = aabbs[j]
- x0 = max(a[0], b[0])
- y0 = max(a[1], b[1])
- x1 = min(a[2], b[2])
- y1 = min(a[3], b[3])
- overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
- return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
-
-
-def make_lod(obj, name, ratio, skip_decimate):
- mesh = obj.data.copy()
- lod = bpy.data.objects.new(name, mesh)
- lod.matrix_world = obj.matrix_world.copy()
- bpy.context.scene.collection.objects.link(lod)
- if not skip_decimate and 0.0 < ratio < 1.0:
- mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
- mod.decimate_type = "COLLAPSE"
- mod.ratio = ratio
- return lod
-
-
-def convex_hull_collider(obj, name):
- mesh = bpy.data.meshes.new(name)
- bm = bmesh.new()
- try:
- bm.from_mesh(obj.data)
- result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
- interior = result.get("geom_interior") or []
- unused = result.get("geom_unused") or []
- if interior:
- bmesh.ops.delete(bm, geom=interior, context="VERTS")
- if unused:
- bmesh.ops.delete(bm, geom=unused, context="VERTS")
- bm.to_mesh(mesh)
- mesh.update()
- finally:
- bm.free()
- collider = bpy.data.objects.new(name, mesh)
- bpy.context.collection.objects.link(collider)
- collider.matrix_world = obj.matrix_world.copy()
- return collider
-
-
-def setup_bake_image(obj, target_mat, size=BAKE_RES):
- if not obj.data.uv_layers:
- return None, None
- img = bpy.data.images.new("BellowsNrm", size, size, alpha=True, float_buffer=False)
- img.colorspace_settings.name = "Non-Color"
- nodes = target_mat.node_tree.nodes
- tex = nodes.new("ShaderNodeTexImage")
- tex.image = img
- nodes.active = tex
- tex.select = True
- obj.active_material_index = WOOD_IDX
- return img, tex
-
-
-def bake_normal(high, low):
- scene = bpy.context.scene
- scene.render.engine = "CYCLES"
- scene.cycles.device = "CPU"
- scene.cycles.samples = 1
- scene.cycles.use_denoising = False
- deselect_all()
- high.select_set(True)
- low.select_set(True)
- bpy.context.view_layer.objects.active = low
- return bpy.ops.object.bake(
- type="NORMAL",
- use_selected_to_active=True,
- cage_extrusion=CAGE_EXTRUSION,
- use_cage=False,
- normal_space="TANGENT",
- margin=4,
- margin_type="ADJACENT_FACES",
- use_clear=True,
- target="IMAGE_TEXTURES",
- )
-
-
-def export_unity(path, objects):
- deselect_all()
- for ob in objects:
- ob.select_set(True)
- bpy.context.view_layer.objects.active = objects[0]
- bpy.ops.export_scene.gltf(
- filepath=path,
- use_selection=True,
- export_yup=True,
- export_apply=True,
- export_draco_mesh_compression_enable=False,
- export_animations=False,
- )
-
-
-def check(skip_decimate):
- bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_bellows_mesh("BellowsLow", bevel_offset=0.004, bevel_segments=2)
- high = build_bellows_mesh("BellowsHigh", bevel_offset=0.004, bevel_segments=4)
- wood = principled("BellowsWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58)
- leather = principled("BellowsLeather", (0.45, 0.16, 0.08, 1.0), 0.0, 0.78)
- metal = principled("BellowsIron", (0.16, 0.155, 0.15, 1.0), 1.0, 0.35)
- assign_slots(low, wood, leather, metal)
- assign_slots(high, wood, leather, metal)
-
- if low.data is None or len(low.data.polygons) < 6:
- return fail("bellows mesh did not build", 3), None, None, None, None, None
-
- base_tris = triangle_count(low.data)
- mats = [s for s in low.data.materials if s is not None]
- nmat = len(mats)
- distinct_mats = len({id(s) for s in mats})
- idx_counts = {}
- for poly in low.data.polygons:
- idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
- print(f"measured mat_index_counts={idx_counts}")
- u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
- bb = world_bbox(low)
- size_x = bb[3] - bb[0]
- size_y = bb[4] - bb[1]
- size_z = bb[5] - bb[2]
-
- img, tex = setup_bake_image(low, wood)
- if img is None:
- return fail("bellows has no UV layer", 3), None, None, None, None, None
- bake_result = bake_normal(high, low)
-
- lod1 = make_lod(low, "BellowsLOD1", LOD1_TARGET, skip_decimate)
- lod2 = make_lod(low, "BellowsLOD2", LOD2_TARGET, skip_decimate)
- bpy.context.view_layer.update()
- lod1_tris = evaluated_triangle_count(lod1)
- lod2_tris = evaluated_triangle_count(lod2)
- r1 = lod1_tris / base_tris if base_tris else 0.0
- r2 = lod2_tris / base_tris if base_tris else 0.0
-
- collider_src = build_bellows_mesh(
- "BellowsColSrc", bevel_offset=0.0, bevel_segments=1
- )
- collider = convex_hull_collider(collider_src, "BellowsCollider")
- bpy.data.objects.remove(collider_src, do_unlink=True)
- col_tris = triangle_count(collider.data)
-
- export_path = os.path.join(
- tempfile.gettempdir(),
- f"bdt_bellows_{os.getpid()}.glb",
- )
- if os.path.exists(export_path):
- os.remove(export_path)
- export_unity(export_path, [low, collider])
- export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
-
- print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
- print(
- f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
- f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
- )
- print(
- f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
- f"overlap={overlap:.6f} nfaces={nfaces}"
- )
- print(
- f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
- f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
- )
- print(
- f"measured collider_tris={col_tris} bake={bake_result} "
- f"bake_has_data={img.has_data} export_bytes={export_size}"
- )
-
- if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
- return fail(
- f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
- 4,
- ), None, None, None, None, None
- if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
- return fail(
- f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
- 5,
- ), None, None, None, None, None
- if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
- return fail(
- f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
- 5,
- ), None, None, None, None, None
- if idx_counts.get(LEATHER_IDX, 0) < LEATHER_FACES_MIN:
- return fail(
- f"leather faces {idx_counts.get(LEATHER_IDX, 0)} < {LEATHER_FACES_MIN}",
- 5,
- ), None, None, None, None, None
- if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
- return fail(
- f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
- 5,
- ), None, None, None, None, None
- if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
- return fail(
- f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
- 6,
- ), None, None, None, None, None
- if overlap > UV_OVERLAP_MAX:
- return fail(
- f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
- 7,
- ), None, None, None, None, None
- if (
- abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
- or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
- or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
- ):
- return fail(
- f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
- f"off outer {OUTER_SIZE}",
- 8,
- ), None, None, None, None, None
- if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
- return fail(
- f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
- "(--skip-decimate is the designed fail)",
- 9,
- ), None, None, None, None, None
- if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
- return fail(
- f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
- 9,
- ), None, None, None, None, None
- if col_tris > COLLIDER_TRIS_MAX:
- return fail(
- f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
- 11,
- ), None, None, None, None, None
- if bake_result != {"FINISHED"} or not img.has_data:
- return fail(
- f"bake failed result={bake_result} has_data={img.has_data}",
- 12,
- ), None, None, None, None, None
- if export_size <= 0:
- return fail("export file missing or empty", 13), None, None, None, None, None
- return 0, low, high, wood, tex, collider
-
-
-def wire_normal(mat, tex):
- nt = mat.node_tree
- bsdf = nt.nodes["Principled BSDF"]
- nrm = nt.nodes.new("ShaderNodeNormalMap")
- nrm.inputs["Strength"].default_value = 1.0
- nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
- nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
-
-
-def render_still(low, wood, tex, path, engine):
- scene = bpy.context.scene
- wire_normal(wood, tex)
- for ob in list(scene.objects):
- if ob.type == "MESH" and ob != low:
- ob.hide_render = True
- ob.hide_viewport = True
-
- low.rotation_euler.z = math.radians(32.0)
- low.rotation_euler.x = math.radians(0.0)
-
- floor_me = bpy.data.meshes.new("Floor")
- bm = bmesh.new()
- try:
- bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
- bm.to_mesh(floor_me)
- finally:
- bm.free()
- fmat = bpy.data.materials.new("Floor")
- fmat.use_nodes = True
- fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
- fb.inputs["Roughness"].default_value = 0.7
- floor_me.materials.append(fmat)
- floor = bpy.data.objects.new("Floor", floor_me)
- scene.collection.objects.link(floor)
- wall = bpy.data.objects.new("Wall", floor_me.copy())
- wall.location = (0.0, 8.5, 0.0)
- wall.rotation_euler = (math.radians(90), 0.0, 0.0)
- scene.collection.objects.link(wall)
-
- world = bpy.data.worlds.new("World")
- world.use_nodes = True
- world.node_tree.nodes["Background"].inputs["Color"].default_value = (
- 0.02, 0.021, 0.025, 1.0,
- )
- scene.world = world
-
- def light(name, loc, energy, size, col, rot):
- ld = bpy.data.lights.new(name, "AREA")
- ld.energy = energy
- ld.size = size
- ld.color = col
- ob = bpy.data.objects.new(name, ld)
- ob.location = loc
- ob.rotation_euler = tuple(math.radians(a) for a in rot)
- scene.collection.objects.link(ob)
-
- light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
- light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
- light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
-
- cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 50.0
- cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (-0.691, -0.861, 0.231)
- scene.collection.objects.link(cam)
- aim = bpy.data.objects.new("Aim", None)
- aim.location = (-0.04, 0.0, 0.11)
- scene.collection.objects.link(aim)
- con = cam.constraints.new("TRACK_TO")
- con.target = aim
- con.track_axis = "TRACK_NEGATIVE_Z"
- con.up_axis = "UP_Y"
- scene.camera = cam
-
- scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
- if engine == "cycles":
- scene.cycles.samples = 32
- scene.cycles.device = "CPU"
- else:
- try:
- scene.eevee.taa_render_samples = 64
- except AttributeError:
- pass
- scene.render.resolution_x = 1280
- scene.render.resolution_y = 720
- scene.render.image_settings.file_format = (
- "WEBP" if path.lower().endswith(".webp") else "PNG"
- )
- if path.lower().endswith(".webp"):
- scene.render.image_settings.quality = 90
- scene.render.filepath = path
- scene.view_settings.view_transform = "Standard"
-
- fcode = gallery_framing.check_framing(
- scene, cam, hero=[low], elements=[low], stage=[floor, wall],
- )
- if fcode:
- return fcode
- bpy.ops.render.render(write_still=True)
- if not (os.path.exists(path) and os.path.getsize(path) > 0):
- return fail("render produced no file", 14)
- return 0
-
-
-def main():
- argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
- p = argparse.ArgumentParser()
- p.add_argument("--output", default=None)
- p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
- p.add_argument(
- "--skip-decimate",
- action="store_true",
- help="falsification: skip the LOD DECIMATE stage",
- )
- args = p.parse_args(argv)
-
- code, low, _high, hero_mat, tex, _col = check(args.skip_decimate)
- if code:
- return code
- if args.output:
- rcode = render_still(low, hero_mat, tex, os.path.abspath(args.output), args.engine)
- if rcode:
- return rcode
- print(f"rendered still {args.output}")
- print("bellows OK")
- return 0
-
-
-if __name__ == "__main__":
- try:
- sys.exit(main())
- except Exception as e:
- traceback.print_exc()
- print(f"FATAL: {e}", file=sys.stderr)
- sys.exit(1)
-
-
-