"""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 def p_scale_head(M): """The dial and hanger of a shop scale. STATIC — the pan hangs off it on a joint.""" parts = [] parts.append(assign(cyl(0.010, 0.36, loc=(0, 0, 0.18), verts=8), M["steel"])) hook = cyl(0.013, 0.070, loc=(0, 0, 0.02), verts=8) parts.append(assign(hook, M["steel"])) # the dial: a fat disc facing -Y, which is the direction shoppers stand in body = cyl(0.135, 0.075, loc=(0, 0, -0.085), rot=(math.radians(90), 0, 0), verts=26) parts.append(assign(body, M["brass"])) face = cyl(0.120, 0.012, loc=(0, -0.042, -0.085), rot=(math.radians(90), 0, 0), verts=26) parts.append(assign(face, M["dial"])) for i in range(12): a = i * (math.tau / 12.0) tick = box((0.006, 0.006, 0.022), loc=(math.cos(a) * 0.098, -0.049, -0.085 + math.sin(a) * 0.098), rot=(0, a, 0)) parts.append(assign(tick, M["steel"])) needle = box((0.005, 0.008, 0.090), loc=(0.012, -0.050, -0.050), rot=(0, 0, math.radians(18))) parts.append(assign(needle, M["needle"])) parts.append(assign(cyl(0.014, 0.014, loc=(0, -0.050, -0.085), rot=(math.radians(90), 0, 0), verts=12), M["steel"])) # the eye the pan hangs from parts.append(assign(cyl(0.011, 0.045, loc=(0, 0, -0.165), verts=8), M["steel"])) return parts def p_scale_pan(M): """The swinging half: three chains and a brass pan. Origin at the TOP, on the pivot, so a PinJoint3D at the origin hangs it correctly.""" parts = [] ring = cyl(0.016, 0.010, loc=(0, 0, -0.006), verts=12) parts.append(assign(ring, M["steel"])) for i in range(3): a = i * (math.tau / 3.0) top = Vector((0, 0, -0.010)) bot = Vector((math.cos(a) * 0.115, math.sin(a) * 0.115, -0.230)) parts.append(assign(span(0.005, 0.005, top, bot, verts=6), M["steel"])) pan = cyl(0.155, 0.030, loc=(0, 0, -0.246), r2=0.185, verts=28) parts.append(assign(pan, M["brass"])) lip = cyl(0.186, 0.016, loc=(0, 0, -0.262), verts=28) parts.append(assign(lip, M["brass"])) return parts def p_bag_roll(M): """The roll of produce bags. The single most infuriating object in any shop.""" parts = [] # A floor stand, at the height a shop actually puts one: the roll wants to be under # your hands, not your knees. H = 1.14 parts.append(assign(cyl(0.140, 0.030, loc=(0, 0, 0.015), verts=18), M["steel"])) parts.append(assign(box((0.034, 0.034, H), loc=(0, 0, H * 0.5)), M["steel"])) parts.append(assign(box((0.30, 0.026, 0.026), loc=(0, 0, H)), M["steel"])) for s in (-1, 1): parts.append(assign(box((0.026, 0.026, 0.10), loc=(s * 0.14, 0, H - 0.045)), M["steel"])) # the roll itself, on the spindle roll = cyl(0.075, 0.245, loc=(0, 0, H - 0.045), rot=(0, math.radians(90), 0), verts=22) parts.append(assign(roll, M["bag"])) core = cyl(0.020, 0.255, loc=(0, 0, H - 0.045), rot=(0, math.radians(90), 0), verts=12) parts.append(assign(core, M["core"])) # and one hanging off it, half torn, forever hang = box((0.20, 0.008, 0.30), loc=(0, 0.055, H - 0.185), rot=(math.radians(-8), 0, 0)) parts.append(assign(hang, M["bag"])) tail = box((0.16, 0.006, 0.10), loc=(0.02, 0.060, H - 0.330), rot=(math.radians(-16), 0, math.radians(7))) parts.append(assign(tail, M["bag"])) return parts PROPS = { "scale-head": p_scale_head, "scale-pan": p_scale_pan, "bag-roll": p_bag_roll, "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), "steel": mat("steel", (0.66, 0.67, 0.70), rough=0.30, metal=1.0), "brass": mat("brass", (0.72, 0.56, 0.22), rough=0.28, metal=1.0), "dial": mat("dial", (0.94, 0.93, 0.89), rough=0.5), "needle": mat("needle", (0.68, 0.08, 0.06), rough=0.4), # tinted: a white bag on a white wall is invisible, and you have to be able to # find the thing you are about to be defeated by "bag": mat("bag", (0.62, 0.78, 0.80), rough=0.40, alpha=0.66), "core": mat("core", (0.62, 0.50, 0.34), rough=0.9), } 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] # Blender's cone primitive winds its caps inward when radius2 > radius1, which is # exactly how the scale pan is built — and a backface-culled renderer then shows you # the unlit inside of the dish as a black hole. Recalculate outward for everything, # once, here, so no generator has to remember. bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') 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()