"""The greengrocer's stock. /Applications/Blender.app/Contents/MacOS/Blender --background \ --python tools/gen_produce.py -- [--render] ORIGIN RULE, and it differs from every other prop in this repo: round produce is CENTRED, not floor-centred. These things are meant to roll, and a rolling body whose origin sits on the floor plane wobbles like a loaded die. Everything else here (crates, bottles, the banana bunch) keeps the usual floor-centre origin, and Main knows which is which from the level spec. Deliberately a bit under-detailed: these spawn in pyramids of thirty, so the budget goes on silhouette and colour rather than on a nice stem. """ import bpy import math import os import sys from mathutils import Vector, Matrix OUT_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game", "assets", "store") PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews") RENDER = "--render" in sys.argv def reset(): bpy.ops.wm.read_factory_settings(use_empty=True) def mat(name, color, rough=0.55, metal=0.0, alpha=1.0): m = bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha) b.inputs["Roughness"].default_value = rough b.inputs["Metallic"].default_value = metal if alpha < 1.0: b.inputs["Alpha"].default_value = alpha m.blend_method = 'BLEND' return m def assign(o, m): o.data.materials.clear() o.data.materials.append(m) return o def box(size, loc=(0, 0, 0), rot=(0, 0, 0)): bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot) o = bpy.context.active_object o.scale = (size[0], size[1], size[2]) bpy.ops.object.transform_apply(scale=True) return o def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None): if r2 is None: bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts, location=loc, rotation=rot) else: bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth, vertices=verts, location=loc, rotation=rot) return bpy.context.active_object def ball(r, loc=(0, 0, 0), segs=20, rings=12): bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings, location=loc) return bpy.context.active_object def bevel(o, width=0.005, segments=2): m = o.modifiers.new("bevel", "BEVEL") m.width = width m.segments = segments m.limit_method = 'ANGLE' m.angle_limit = math.radians(40) bpy.context.view_layer.objects.active = o try: bpy.ops.object.modifier_apply(modifier=m.name) except Exception: o.modifiers.remove(m) return o def smooth(o, angle=45.0): bpy.context.view_layer.objects.active = o o.select_set(True) try: bpy.ops.object.shade_auto_smooth(angle=math.radians(angle)) except Exception: bpy.ops.object.shade_smooth() o.select_set(False) def span(r0, r1, p0, p1, verts=8): p0, p1 = Vector(p0), Vector(p1) d = p1 - p0 n = d.length if n < 1e-6: return None o = cyl(r0, n, verts=verts, r2=r1) q = Vector((0, 0, 1)).rotation_difference(d.normalized()) o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ q.to_matrix().to_4x4() bpy.ops.object.transform_apply(location=True, rotation=True) return o # ---------------------------------------------------------------- produce # All the round ones are CENTRED on the origin so they roll properly. def p_apple(M): parts = [] b = ball(0.040, segs=18, rings=12) b.scale = (1.0, 1.0, 0.92) bpy.ops.object.transform_apply(scale=True) parts.append(assign(b, M["apple"])) # the dimple: a dark disc SUNK into the top, not a bobble sitting on it d = ball(0.017, loc=(0, 0, 0.029), segs=12, rings=8) d.scale = (1.0, 1.0, 0.34) bpy.ops.object.transform_apply(scale=True) parts.append(assign(d, M["apple_dark"])) parts.append(assign(span(0.004, 0.003, Vector((0, 0, 0.032)), Vector((0.006, 0.004, 0.062)), verts=6), M["stem"])) return parts def p_orange(M): parts = [] b = ball(0.037, segs=18, rings=12) b.scale = (1.0, 1.0, 0.95) bpy.ops.object.transform_apply(scale=True) parts.append(assign(b, M["orange"])) parts.append(assign(cyl(0.006, 0.004, loc=(0, 0, 0.034), verts=8), M["stem"])) return parts def p_tomato(M): parts = [] b = ball(0.033, segs=18, rings=12) b.scale = (1.0, 1.0, 0.82) bpy.ops.object.transform_apply(scale=True) parts.append(assign(b, M["tomato"])) # calyx: five little leaves for i in range(5): a = i * (math.tau / 5.0) parts.append(assign(span(0.006, 0.002, Vector((0, 0, 0.026)), Vector((math.cos(a) * 0.020, math.sin(a) * 0.020, 0.030)), verts=5), M["leaf"])) return parts def p_melon(M): """Watermelon. Big, heavy, and the best thing in the shop to drop on other produce.""" parts = [] b = ball(0.145, segs=26, rings=16) b.scale = (1.0, 1.28, 1.0) bpy.ops.object.transform_apply(scale=True) parts.append(assign(b, M["melon"])) # Stripes as thin lenses through the middle, each a hair proud of the skin, so they # read as bands wrapping the fruit. Boxes poking out radially made it look like a # sea mine. for i in range(7): a = i * (math.pi / 7.0) st = ball(0.1465, segs=26, rings=16) st.scale = (0.115, 1.283, 1.004) bpy.ops.object.transform_apply(scale=True) st.rotation_euler = (0.0, 0.0, a) bpy.ops.object.transform_apply(rotation=True) parts.append(assign(st, M["melon_dark"])) return parts def p_cabbage(M): parts = [] b = ball(0.082, segs=20, rings=14) b.scale = (1.0, 1.0, 0.88) bpy.ops.object.transform_apply(scale=True) parts.append(assign(b, M["cabbage"])) # a few outer leaves, so it isn't just a green ball for i in range(6): a = i * (math.tau / 6.0) lf = ball(0.052, loc=(math.cos(a) * 0.058, math.sin(a) * 0.058, -0.016), segs=12, rings=8) lf.scale = (1.25, 1.25, 0.42) bpy.ops.object.transform_apply(scale=True) parts.append(assign(lf, M["cabbage_out"])) return parts def p_banana(M): """A hand of bananas. Floor-centred — these don't roll, they flop.""" parts = [] for i in range(5): off = (i - 2) * 0.018 pos = Vector((off, 0.0, 0.030)) prev = pos for seg in range(4): a = math.radians(-38 + seg * 26) nxt = prev + Vector((0.0, math.cos(a) * 0.042, math.sin(a) * 0.042 * 0.5)) r = 0.016 - seg * 0.0022 parts.append(assign(span(r, r * 0.86, prev, nxt, verts=8), M["banana"])) prev = nxt crown = box((0.10, 0.030, 0.026), loc=(0, -0.012, 0.030)) parts.append(assign(bevel(crown, 0.008), M["stem"])) return parts # ---------------------------------------------------------------- fittings def p_bottle(M): """Glass juice bottle. Floor-centred. Breaks like glass, not like fruit.""" parts = [] body = cyl(0.037, 0.185, loc=(0, 0, 0.093), verts=20) parts.append(assign(body, M["glass"])) juice = cyl(0.031, 0.140, loc=(0, 0, 0.076), verts=18) parts.append(assign(juice, M["juice"])) shoulder = cyl(0.037, 0.055, loc=(0, 0, 0.212), r2=0.017, verts=20) parts.append(assign(shoulder, M["glass"])) neck = cyl(0.017, 0.048, loc=(0, 0, 0.262), verts=14) parts.append(assign(neck, M["glass"])) cap = cyl(0.020, 0.020, loc=(0, 0, 0.294), verts=14) parts.append(assign(cap, M["cap"])) label = cyl(0.0385, 0.070, loc=(0, 0, 0.085), verts=20) parts.append(assign(label, M["label"])) return parts def p_crate(M): """Angled wooden display crate. Produce piles into it.""" parts = [] W, D, H, T = 0.52, 0.38, 0.15, 0.014 parts.append(assign(box((W, T, H), loc=(0, -D / 2, H / 2)), M["crate"])) parts.append(assign(box((W, T, H * 1.5), loc=(0, D / 2, H * 0.75)), M["crate"])) for s in (-1, 1): parts.append(assign(box((T, D, H * 1.2), loc=(s * W / 2, 0, H * 0.6)), M["crate"])) for i in range(5): parts.append(assign(box((W, D / 5 - 0.01, T), loc=(0, -D / 2 + (i + 0.5) * D / 5, T / 2)), M["crate"])) return parts PROPS = { "produce-apple": p_apple, "produce-orange": p_orange, "produce-tomato": p_tomato, "produce-melon": p_melon, "produce-cabbage": p_cabbage, "produce-banana": p_banana, "juice-bottle": p_bottle, "produce-crate": p_crate, } def materials(): return { "apple": mat("apple", (0.62, 0.06, 0.07), rough=0.32), "apple_dark": mat("apple_dark", (0.30, 0.04, 0.05), rough=0.5), "orange": mat("orange", (0.88, 0.42, 0.05), rough=0.62), "tomato": mat("tomato", (0.72, 0.07, 0.05), rough=0.28), "melon": mat("melon", (0.16, 0.36, 0.12), rough=0.45), "melon_dark": mat("melon_dark", (0.08, 0.20, 0.07), rough=0.45), "cabbage": mat("cabbage", (0.68, 0.78, 0.48), rough=0.55), "cabbage_out": mat("cabbage_out", (0.38, 0.58, 0.26), rough=0.62), "banana": mat("banana", (0.90, 0.78, 0.18), rough=0.5), "leaf": mat("leaf", (0.24, 0.44, 0.16), rough=0.7), "stem": mat("stem", (0.34, 0.26, 0.13), rough=0.85), "glass": mat("glass", (0.74, 0.82, 0.78), rough=0.06, alpha=0.34), "juice": mat("juice", (0.86, 0.44, 0.06), rough=0.22), "cap": mat("cap", (0.72, 0.70, 0.66), rough=0.3, metal=0.8), "label": mat("label", (0.94, 0.92, 0.86), rough=0.85), "crate": mat("crate", (0.56, 0.40, 0.22), rough=0.85), } def export(objs, name): objs = [o for o in objs if o is not None] bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] path = os.path.join(OUT_DIR, name + ".glb") bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True) print("[produce] %-18s -> %s" % (name, os.path.basename(path))) bpy.ops.object.select_all(action='DESELECT') def render_preview(name): scene = bpy.context.scene engines = scene.render.bl_rna.properties['engine'].enum_items.keys() for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'): if want in engines: scene.render.engine = want break scene.render.resolution_x = 480 scene.render.resolution_y = 480 if scene.world is None: scene.world = bpy.data.worlds.new("preview") scene.world.use_nodes = True bg = scene.world.node_tree.nodes.get("Background") if bg: bg.inputs[0].default_value = (0.10, 0.10, 0.11, 1.0) lo = Vector((1e9,) * 3) hi = Vector((-1e9,) * 3) for o in scene.objects: if o.type != 'MESH': continue for c in o.bound_box: w = o.matrix_world @ Vector(c) lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z))) hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z))) centre = (lo + hi) * 0.5 radius = max((hi - lo).length * 0.5, 0.02) dist = radius * 3.0 d = Vector((0.62, -0.74, 0.34)).normalized() bpy.ops.object.camera_add(location=tuple(centre + d * dist)) cam = bpy.context.active_object cam.data.lens = 58 cam.rotation_mode = 'QUATERNION' cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y') scene.camera = cam for off, e, s in ((Vector((1.0, -1.0, 1.1)), 60.0, 1.2), (Vector((-1.1, -0.4, 0.3)), 18.0, 1.8)): bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist)) L = bpy.context.active_object L.data.energy = e * (dist ** 2) L.data.size = s * radius scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png") bpy.ops.render.render(write_still=True) def main(): os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(PREVIEW_DIR, exist_ok=True) for name, builder in PROPS.items(): reset() M = materials() parts = builder(M) for p in parts: smooth(p, 45.0) export(parts, name) if RENDER: render_preview(name) print("[produce] done -> %s" % OUT_DIR) main()