commit 6db0286fc1cd1b89aa2bb464bae11f72358febf4 Author: Jude Southworth Date: Tue Oct 27 09:02:46 2026 +0000 initial diff --git a/assets/checker.png b/assets/checker.png new file mode 100644 index 0000000..3a2b321 Binary files /dev/null and b/assets/checker.png differ diff --git a/assets/face_crop.png b/assets/face_crop.png new file mode 100644 index 0000000..4efa690 Binary files /dev/null and b/assets/face_crop.png differ diff --git a/assets/face_rect.png b/assets/face_rect.png new file mode 100644 index 0000000..dc9282e Binary files /dev/null and b/assets/face_rect.png differ diff --git a/assets/face_uv.png b/assets/face_uv.png new file mode 100644 index 0000000..ac09fb0 Binary files /dev/null and b/assets/face_uv.png differ diff --git a/assets/fonts/comic.ttf b/assets/fonts/comic.ttf new file mode 100644 index 0000000..d17e1be Binary files /dev/null and b/assets/fonts/comic.ttf differ diff --git a/assets/fonts/comicbd.ttf b/assets/fonts/comicbd.ttf new file mode 100644 index 0000000..75be504 Binary files /dev/null and b/assets/fonts/comicbd.ttf differ diff --git a/assets/food_apple_01/food_apple_01_1k.blend b/assets/food_apple_01/food_apple_01_1k.blend new file mode 100644 index 0000000..b06f779 Binary files /dev/null and b/assets/food_apple_01/food_apple_01_1k.blend differ diff --git a/assets/food_apple_01/textures/food_apple_01_diff_1k.jpg b/assets/food_apple_01/textures/food_apple_01_diff_1k.jpg new file mode 100644 index 0000000..24ec0d8 Binary files /dev/null and b/assets/food_apple_01/textures/food_apple_01_diff_1k.jpg differ diff --git a/assets/food_apple_01/textures/food_apple_01_nor_gl_1k.exr b/assets/food_apple_01/textures/food_apple_01_nor_gl_1k.exr new file mode 100644 index 0000000..6a981d7 Binary files /dev/null and b/assets/food_apple_01/textures/food_apple_01_nor_gl_1k.exr differ diff --git a/assets/food_apple_01/textures/food_apple_01_rough_1k.jpg b/assets/food_apple_01/textures/food_apple_01_rough_1k.jpg new file mode 100644 index 0000000..fa23fce Binary files /dev/null and b/assets/food_apple_01/textures/food_apple_01_rough_1k.jpg differ diff --git a/make_label.py b/make_label.py new file mode 100644 index 0000000..0c38a8d --- /dev/null +++ b/make_label.py @@ -0,0 +1,158 @@ +"""Render a model with render_model.py and compose it into a bottle label: +the render on the left, title and subtitle on the right. + +Takes the same arguments as render_model.py (the model path first, then +--texture, --project, --azimuth, ...), plus the label options below. +--output is an A4 sheet with the label repeated --labels-per-sheet times +down the page (PDF or PNG by extension, at --dpi). The render itself is made +with a transparent background, trimmed to its visible pixels and scaled to fit +the left panel of each label. + +Usage: + python make_label.py MODEL.blend --texture TEX --output labels.pdf \ + [render_model.py options] [--title jider] \ + [--subtitle "alc 5.5% · mostly maggot-free"] [--labels-per-sheet 4] +""" + +import argparse +import os +import subprocess +import sys +import tempfile + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +RENDER_MODEL = os.path.join(HERE, "render_model.py") +A4_MM = (210, 297) + + +def parse_args(): + """Split argv into label options and the arguments passed on to render_model.py.""" + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="All other options are passed to render_model.py " + f"(see: python {os.path.basename(RENDER_MODEL)} " + "MODEL.blend --help).") + p.add_argument("model", help="the .blend file to render") + p.add_argument("--output", required=True, help="output A4 sheet (.pdf or .png)") + p.add_argument("--save-render", help="also keep the apple render at this path") + p.add_argument("--title", default="jider") + p.add_argument("--subtitle", default="alc 5.5% · mostly maggot-free") + p.add_argument("--labels-per-sheet", type=int, default=4, + help="labels stacked vertically on the A4 page") + p.add_argument("--page-margin", type=float, default=10.0, + help="blank margin around the page in mm") + p.add_argument("--label-gap", type=float, default=4.0, help="space between labels in mm") + p.add_argument("--dpi", type=int, default=300) + p.add_argument("--label-split", type=float, default=0.42, + help="fraction of the width given to the apple panel") + p.add_argument("--label-background", default="#ffffff", help="label colour") + p.add_argument("--label-ink", default="#7a1f12", help="text and border colour") + p.add_argument("--label-font", default=os.path.join(HERE, "assets/fonts/comic.ttf"), + help="subtitle font") + p.add_argument("--label-title-font", default=os.path.join(HERE, "assets/fonts/comicbd.ttf"), + help="title font") + return p.parse_known_args() + + +def render_apple(model, render_args, out_path): + # --resolution 1500 is a default the caller's own --resolution overrides (last one wins). + cmd = [sys.executable, RENDER_MODEL, model, "--resolution", "1500", *render_args, + "--transparent", "--output", out_path] + print("Running:", " ".join(cmd)) + result = subprocess.run(cmd) + if result.returncode != 0 or not os.path.exists(out_path): + sys.exit(f"render_model.py failed (exit code {result.returncode})") + + +def fit_font(path, text, max_w, max_h): + """Largest font size at which `text` fits inside max_w x max_h.""" + lo, hi = 4, 2000 + while lo < hi: + size = (lo + hi + 1) // 2 + left, top, right, bottom = ImageFont.truetype(path, size).getbbox(text) + if right - left <= max_w and bottom - top <= max_h: + lo = size + else: + hi = size - 1 + return ImageFont.truetype(path, lo) + + +def draw_centered(draw, font, text, cx, top, fill): + """Draw text horizontally centred on cx with its ink starting at y = top.""" + left, t, right, _ = font.getbbox(text) + draw.text((cx - (left + right) / 2, top - t), text, font=font, fill=fill) + + +def main(): + args, render_args = parse_args() + with tempfile.TemporaryDirectory() as tmp: + apple_path = args.save_render or os.path.join(tmp, "apple.png") + render_apple(args.model, render_args, apple_path) + apple = Image.open(apple_path).convert("RGBA") + + # A4 page with the labels stacked vertically. + def px(mm): + return round(mm / 25.4 * args.dpi) + + page_w, page_h = px(A4_MM[0]), px(A4_MM[1]) + n = args.labels_per_sheet + margin, gap = px(args.page_margin), px(args.label_gap) + label_w = page_w - 2 * margin + label_h = (page_h - 2 * margin - (n - 1) * gap) // n + label = compose_label(args, apple, label_w, label_h) + + page = Image.new("RGB", (page_w, page_h), "white") + for i in range(n): + page.paste(label, (margin, margin + i * (label_h + gap))) + page.save(args.output, dpi=(args.dpi, args.dpi), resolution=args.dpi) + print(f"Wrote {args.output}: {n} labels of " + f"{label_w / args.dpi * 25.4:.0f} x {label_h / args.dpi * 25.4:.1f} mm on A4") + + +def compose_label(args, apple, W, H): + pad = round(H * 0.07) + label = Image.new("RGBA", (W, H), args.label_background) + draw = ImageDraw.Draw(label) + ink = args.label_ink + + # Double border. + for inset, width in [(pad * 0.35, max(2, H // 120)), (pad * 0.55, max(1, H // 300))]: + draw.rounded_rectangle((inset, inset, W - inset, H - inset), radius=pad * 0.6, + outline=ink, width=round(width)) + + # Left panel: apple trimmed to its pixels, scaled to fit, with a soft contact shadow. + apple = apple.crop(apple.getchannel("A").getbbox()) + panel_w = round(W * args.label_split) + box_w, box_h = panel_w - 2 * pad, H - 2.6 * pad + scale = min(box_w / apple.width, box_h / apple.height) + apple = apple.resize((round(apple.width * scale), round(apple.height * scale)), Image.LANCZOS) + ax = pad + (box_w - apple.width) // 2 + ay = round(1.1 * pad + (box_h - apple.height) / 2) + + shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + sw, sh = apple.width * 0.75, apple.height * 0.08 + scx, scy = ax + apple.width / 2, ay + apple.height - sh * 0.3 + ImageDraw.Draw(shadow).ellipse((scx - sw / 2, scy - sh / 2, scx + sw / 2, scy + sh / 2), + fill=(60, 30, 10, 110)) + label.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(H / 60))) + label.alpha_composite(apple, (ax, ay)) + + # Right panel: big title over the subtitle, the pair centred vertically. + text_l, text_r = panel_w, W - 1.5 * pad + text_w, cx = text_r - text_l, (text_l + text_r) / 2 + title_font = fit_font(args.label_title_font, args.title, text_w, H * 0.5) + sub_font = fit_font(args.label_font, args.subtitle, text_w, H * 0.09) + _, tt, _, tb = title_font.getbbox(args.title) + _, st, _, sb = sub_font.getbbox(args.subtitle) + gap = H * 0.06 + top = (H - ((tb - tt) + gap + (sb - st))) / 2 + draw_centered(draw, title_font, args.title, cx, top, ink) + draw_centered(draw, sub_font, args.subtitle, cx, top + (tb - tt) + gap, ink) + + return label.convert("RGB") + + +if __name__ == "__main__": + main() diff --git a/render_model.py b/render_model.py new file mode 100644 index 0000000..4e1c774 --- /dev/null +++ b/render_model.py @@ -0,0 +1,510 @@ +"""Render a .blend model with a texture blended over its albedo. + +Every material on every mesh is replaced with a Principled BSDF whose base +colour is the model's albedo mixed with the supplied texture, with the model's +roughness and normal (bump) maps applied. A light is placed behind the camera. +The albedo/roughness/normal images are taken from the model's existing material +unless overridden on the command line. Texture alpha is respected: transparent +areas keep the albedo. + +By default --texture is a UV texture. With --project it is instead an ordinary +picture (e.g. a face) that is stretched over the model's silhouette as seen +from the camera, projected onto the visible side (leaving the stem as-is) and +baked into the UV layout first (save that with --save-projection). + +Usage: + python render_model.py MODEL.blend --texture TEXTURE.png --output out.png [options] +or directly: + blender -b --disable-autoexec MODEL.blend --python render_model.py -- \ + --texture TEXTURE.png --output out.png [options] + +Options include --blend-mode MIX|MULTIPLY|OVERLAY|..., --blend-factor 0-1, +--project/--project-size/--project-offset-x/-y/--stem-max-width, --albedo/--roughness/--normal +overrides, --light-strength, --light-left/--light-up, --ambient, --unlit, +--azimuth, --elevation, --transparent; see --help. +""" + +import argparse +import math +import os +import shutil +import sys + +try: + import bmesh + import bpy + import numpy as np + from mathutils import Vector +except ImportError: + # Run with plain Python: relaunch inside Blender. + # python render_model.py MODEL.blend --texture TEX --output OUT [...] + if len(sys.argv) < 2 or not sys.argv[1].endswith(".blend"): + sys.exit("usage: python render_model.py MODEL.blend --texture TEX --output OUT [options]") + blender = os.environ.get("BLENDER", shutil.which("blender")) + if not blender: + sys.exit("blender not found on PATH (or set $BLENDER)") + os.execv(blender, [blender, "-b", "--disable-autoexec", sys.argv[1], + "--python", os.path.abspath(__file__), "--", *sys.argv[2:]]) + +BLEND_MODES = ["MIX", "MULTIPLY", "OVERLAY", "SCREEN", "SOFT_LIGHT", "ADD", + "DARKEN", "LIGHTEN", "COLOR", "HUE", "SATURATION", "VALUE"] + + +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--texture", required=True, help="image blended over the albedo") + p.add_argument("--output", required=True, help="output image path (PNG)") + p.add_argument("--blend-mode", default="MIX", choices=BLEND_MODES, + help="how --texture combines with the albedo") + p.add_argument("--blend-factor", type=float, default=0.5, + help="0 = albedo only, 1 = full --texture effect") + + g = p.add_argument_group("projection") + g.add_argument("--project", action="store_true", + help="project --texture onto the model from the camera direction " + "instead of using it as a UV texture") + g.add_argument("--project-azimuth", type=float, + help="projection azimuth (default: --azimuth)") + g.add_argument("--project-elevation", type=float, + help="projection elevation (default: --elevation)") + g.add_argument("--project-size", type=float, default=1.0, + help="projected size relative to the model's silhouette (1 = stretched to fill it)") + g.add_argument("--project-offset-x", type=float, default=0.0, + help="shift right, as a fraction of the silhouette width") + g.add_argument("--project-offset-y", type=float, default=0.0, + help="shift up, as a fraction of the silhouette height") + g.add_argument("--stem-max-width", type=float, default=0.12, + help="UV islands in the top half narrower than this fraction of the model's " + "width count as the stem and keep the albedo (0 = no stem detection)") + g.add_argument("--project-resolution", type=int, default=1024, + help="baked UV texture size in px") + g.add_argument("--save-projection", help="also save the baked UV texture to this PNG") + + p.add_argument("--albedo", help="albedo image (default: from the model's material)") + p.add_argument("--roughness", help="roughness image (default: from the model's material)") + p.add_argument("--normal", help="OpenGL normal map (default: from the model's material)") + p.add_argument("--normal-strength", type=float, default=1.0) + p.add_argument("--light-strength", type=float, default=3.0, + help="light brightness, roughly sun-equivalent W/m^2 at the model") + p.add_argument("--light-left", type=float, default=35.0, + help="degrees the light is swung to the camera's left (negative = right)") + p.add_argument("--light-up", type=float, default=35.0, + help="degrees the light is raised above the camera's view axis (negative = down)") + p.add_argument("--ambient", type=float, default=0.1, + help="uniform fill light strength (0 = none)") + p.add_argument("--unlit", action="store_true", + help="skip lighting/roughness/normal; emit the blended colour as-is") + p.add_argument("--resolution", type=int, default=1024, help="square output size in px") + p.add_argument("--samples", type=int, default=64, help="render samples") + p.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE"]) + p.add_argument("--transparent", action="store_true", help="transparent background") + p.add_argument("--background", default="0,0,0", help="background RGB 0-1 if not transparent") + p.add_argument("--azimuth", type=float, default=30.0, + help="camera rotation about Z in degrees (0 = front, from -Y)") + p.add_argument("--elevation", type=float, default=20.0, help="camera elevation in degrees") + p.add_argument("--margin", type=float, default=1.15, help="framing margin (>1 = more space)") + return p.parse_args(argv) + + +def bounds(meshes): + """Centre, bounding-sphere radius and largest dimension of the meshes.""" + corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box] + lo = Vector(min(c[i] for c in corners) for i in range(3)) + hi = Vector(max(c[i] for c in corners) for i in range(3)) + return (lo + hi) / 2, (hi - lo).length / 2, max(hi - lo) + + +def view_basis(azimuth, elevation): + """Direction from the model towards a camera at azimuth/elevation, plus the camera's right/up.""" + az, el = math.radians(azimuth), math.radians(elevation) + direction = Vector((math.cos(el) * math.sin(az), -math.cos(el) * math.cos(az), math.sin(el))) + right = (-direction).cross(Vector((0, 0, 1))) + if right.length < 1e-6: # looking straight up/down + right = Vector((1, 0, 0)) + right.normalize() + return direction, right, right.cross(-direction) + + +def linked_image(material, socket_name): + """Find the image feeding a Principled BSDF input, looking through one Normal Map node.""" + if not (material and material.use_nodes): + return None + for node in material.node_tree.nodes: + if node.type != "BSDF_PRINCIPLED" or not node.inputs[socket_name].is_linked: + continue + src = node.inputs[socket_name].links[0].from_node + if src.type == "NORMAL_MAP" and src.inputs["Color"].is_linked: + src = src.inputs["Color"].links[0].from_node + if src.type == "TEX_IMAGE": + return src.image + return None + + +def load_image(path, non_color=False): + img = bpy.data.images.load(bpy.path.abspath(path), check_existing=True) + if non_color: + img.colorspace_settings.name = "Non-Color" + return img + + +def uv_islands(bm, uv_layer): + """Group faces into UV islands (faces sharing an edge with matching UVs).""" + def edge_uvs(loop): + return {loop[uv_layer].uv.to_tuple(5), loop.link_loop_next[uv_layer].uv.to_tuple(5)} + + seen, islands = set(), [] + for start in bm.faces: + if start.index in seen: + continue + seen.add(start.index) + stack, island = [start], [] + while stack: + face = stack.pop() + island.append(face) + for loop in face.loops: + for other in loop.edge.link_loops: + if other.face.index not in seen and edge_uvs(other) == edge_uvs(loop): + seen.add(other.face.index) + stack.append(other.face) + islands.append(island) + return islands + + +def mark_stem(meshes, max_width): + """Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices. + + The stem is any UV island in the top half of the model whose horizontal + extent is under max_width x the model's width. + """ + center, _, _ = bounds(meshes) + corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box] + model_width = max(max(c[i] for c in corners) - min(c[i] for c in corners) for i in (0, 1)) + body_verts = [] + for obj in meshes: + mesh, mw = obj.data, obj.matrix_world + stem_faces = set() + if max_width > 0 and mesh.uv_layers.active: + bm = bmesh.new() + bm.from_mesh(mesh) + for island in uv_islands(bm, bm.loops.layers.uv.active): + pts = [mw @ v.co for f in island for v in f.verts] + spread = max(max(p[i] for p in pts) - min(p[i] for p in pts) for i in (0, 1)) + if spread < max_width * model_width and min(p.z for p in pts) > center.z: + stem_faces.update(f.index for f in island) + bm.free() + print(f"{obj.name}: {len(stem_faces)} stem faces kept as-is") + + keep = mesh.attributes.get("projection_keep") or \ + mesh.attributes.new("projection_keep", "FLOAT", "CORNER") + for poly in mesh.polygons: + stem = poly.index in stem_faces + for li in poly.loop_indices: + keep.data[li].value = 0.0 if stem else 1.0 + if not stem: + body_verts.extend(mw @ mesh.vertices[vi].co for vi in poly.vertices) + return body_verts + + +def bake_projection(scene, meshes, image, args): + """Stretch `image` over the model's silhouette as seen from the camera direction, + project it onto the front-facing surfaces (except the stem) and bake to UV RGBA.""" + azimuth = args.azimuth if args.project_azimuth is None else args.project_azimuth + elevation = args.elevation if args.project_elevation is None else args.project_elevation + center, _, _ = bounds(meshes) + direction, right, up = view_basis(azimuth, elevation) + + # Silhouette bounds of the body (stem excluded) in the projector's right/up plane. + body = mark_stem(meshes, args.stem_max_width) + xs = [(p - center).dot(right) for p in body] + ys = [(p - center).dot(up) for p in body] + sil_w, sil_h = max(xs) - min(xs), max(ys) - min(ys) + mid_x = (min(xs) + max(xs)) / 2 + args.project_offset_x * sil_w + mid_y = (min(ys) + max(ys)) / 2 + args.project_offset_y * sil_h + + mat = bpy.data.materials.new("projection") + mat.use_nodes = True + nodes, links = mat.node_tree.nodes, mat.node_tree.links + nodes.clear() + + width = sil_w * args.project_size + height = sil_h * args.project_size + origin = center + right * mid_x + up * mid_y + + geo = nodes.new("ShaderNodeNewGeometry") + rel = nodes.new("ShaderNodeVectorMath") + rel.operation = "SUBTRACT" + rel.inputs[1].default_value = origin + links.new(geo.outputs["Position"], rel.inputs[0]) + + def image_coord(axis, span): + # (p - origin) . axis / span + 0.5 + dot = nodes.new("ShaderNodeVectorMath") + dot.operation = "DOT_PRODUCT" + dot.inputs[1].default_value = axis + links.new(rel.outputs["Vector"], dot.inputs[0]) + scale = nodes.new("ShaderNodeMath") + scale.operation = "MULTIPLY_ADD" + scale.inputs[1].default_value = 1 / span + scale.inputs[2].default_value = 0.5 + links.new(dot.outputs["Value"], scale.inputs[0]) + return scale.outputs["Value"] + + coord = nodes.new("ShaderNodeCombineXYZ") + links.new(image_coord(right, width), coord.inputs["X"]) + links.new(image_coord(up, height), coord.inputs["Y"]) + tex = nodes.new("ShaderNodeTexImage") + tex.image = image + tex.extension = "CLIP" + links.new(coord.outputs["Vector"], tex.inputs["Vector"]) + + # Only surfaces facing the projector (so it doesn't bleed onto the back), minus the stem. + facing = nodes.new("ShaderNodeVectorMath") + facing.operation = "DOT_PRODUCT" + facing.inputs[1].default_value = direction + links.new(geo.outputs["Normal"], facing.inputs[0]) + fade = nodes.new("ShaderNodeMapRange") + fade.inputs["From Min"].default_value = 0.0 + fade.inputs["From Max"].default_value = 0.05 + links.new(facing.outputs["Value"], fade.inputs["Value"]) + keep = nodes.new("ShaderNodeAttribute") + keep.attribute_name = "projection_keep" + visible = nodes.new("ShaderNodeMath") + visible.operation = "MULTIPLY" + links.new(fade.outputs["Result"], visible.inputs[0]) + links.new(keep.outputs["Fac"], visible.inputs[1]) + mask = nodes.new("ShaderNodeMath") + mask.operation = "MULTIPLY" + links.new(tex.outputs["Alpha"], mask.inputs[0]) + links.new(visible.outputs["Value"], mask.inputs[1]) + + emit = nodes.new("ShaderNodeEmission") + out = nodes.new("ShaderNodeOutputMaterial") + links.new(emit.outputs["Emission"], out.inputs["Surface"]) + target = nodes.new("ShaderNodeTexImage") # bake target: the active, unconnected node + nodes.active = target + + assign_material(meshes, mat) + bpy.ops.object.select_all(action="DESELECT") + for obj in meshes: + obj.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + scene.render.engine = "CYCLES" + scene.cycles.samples = 1 + scene.cycles.device = "CPU" + + res = args.project_resolution + + def bake(source, name, non_color=False): + img = bpy.data.images.new(name, res, res) + if non_color: + img.colorspace_settings.name = "Non-Color" + for link in list(emit.inputs["Color"].links): + links.remove(link) + links.new(source, emit.inputs["Color"]) + target.image = img + bpy.ops.object.bake(type="EMIT", margin=16) + px = np.empty(res * res * 4, dtype=np.float32) + img.pixels.foreach_get(px) + return px + + rgba = bake(tex.outputs["Color"], "bake_color") + rgba[3::4] = bake(mask.outputs["Value"], "bake_mask", non_color=True)[0::4] + + result = bpy.data.images.new("projected_texture", res, res, alpha=True) + result.pixels.foreach_set(rgba) + if args.save_projection: + result.filepath_raw = bpy.path.abspath(args.save_projection) + result.file_format = "PNG" + result.save() + print(f"Wrote {result.filepath_raw}") + return result + + +def make_material(args, maps, texture): + mat = bpy.data.materials.new("blended_texture") + mat.use_nodes = True + nodes, links = mat.node_tree.nodes, mat.node_tree.links + nodes.clear() + + uv = nodes.new("ShaderNodeUVMap") + + def image_node(image): + tex = nodes.new("ShaderNodeTexImage") + tex.image = image + links.new(uv.outputs["UV"], tex.inputs["Vector"]) + return tex + + # Base colour: albedo blended with the user texture. + user_tex = image_node(texture) + if maps["albedo"]: + mix = nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = args.blend_mode + mix.clamp_result = True + # Factor = blend factor x texture alpha, so transparent areas keep the albedo. + factor = nodes.new("ShaderNodeMath") + factor.operation = "MULTIPLY" + factor.inputs[1].default_value = args.blend_factor + links.new(user_tex.outputs["Alpha"], factor.inputs[0]) + links.new(factor.outputs["Value"], mix.inputs["Factor"]) + a = next(s for s in mix.inputs if s.name == "A" and s.type == "RGBA") + b = next(s for s in mix.inputs if s.name == "B" and s.type == "RGBA") + links.new(image_node(maps["albedo"]).outputs["Color"], a) + links.new(user_tex.outputs["Color"], b) + color = next(s for s in mix.outputs if s.name == "Result" and s.type == "RGBA") + else: + print("No albedo found; using --texture alone") + color = user_tex.outputs["Color"] + + out = nodes.new("ShaderNodeOutputMaterial") + if args.unlit: + emit = nodes.new("ShaderNodeEmission") + links.new(color, emit.inputs["Color"]) + links.new(emit.outputs["Emission"], out.inputs["Surface"]) + return mat + + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + links.new(color, bsdf.inputs["Base Color"]) + if maps["roughness"]: + maps["roughness"].colorspace_settings.name = "Non-Color" + links.new(image_node(maps["roughness"]).outputs["Color"], bsdf.inputs["Roughness"]) + if maps["normal"]: + maps["normal"].colorspace_settings.name = "Non-Color" + nmap = nodes.new("ShaderNodeNormalMap") + nmap.inputs["Strength"].default_value = args.normal_strength + links.new(image_node(maps["normal"]).outputs["Color"], nmap.inputs["Color"]) + links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) + links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + return mat + + +def find_maps(args, meshes): + original = next((m for o in meshes for m in o.data.materials if m), None) + maps = {} + for key, socket, override in [("albedo", "Base Color", args.albedo), + ("roughness", "Roughness", args.roughness), + ("normal", "Normal", args.normal)]: + maps[key] = load_image(override) if override else linked_image(original, socket) + print(f"{key}: {maps[key].filepath if maps[key] else 'none'}") + return maps + + +def assign_material(meshes, mat): + for obj in meshes: + obj.data.materials.clear() + obj.data.materials.append(mat) + + +def setup_world(scene, transparent, rgb, ambient): + # Camera rays see the background colour; everything else sees a uniform + # grey fill of strength `ambient`, so the background doesn't light the model. + world = bpy.data.worlds.new("render_world") + scene.world = world + world.use_nodes = True + nodes, links = world.node_tree.nodes, world.node_tree.links + nodes.clear() + fill = nodes.new("ShaderNodeBackground") + fill.inputs["Strength"].default_value = ambient + visible = nodes.new("ShaderNodeBackground") + visible.inputs["Color"].default_value = (*rgb, 1.0) + path = nodes.new("ShaderNodeLightPath") + mix = nodes.new("ShaderNodeMixShader") + out = nodes.new("ShaderNodeOutputWorld") + links.new(path.outputs["Is Camera Ray"], mix.inputs["Fac"]) + links.new(fill.outputs["Background"], mix.inputs[1]) + links.new(visible.outputs["Background"], mix.inputs[2]) + links.new(mix.outputs["Shader"], out.inputs["Surface"]) + scene.render.film_transparent = transparent + + +def frame_camera(scene, meshes, azimuth, elevation, margin): + center, radius, _ = bounds(meshes) + + cam_data = bpy.data.cameras.new("render_cam") + cam = bpy.data.objects.new("render_cam", cam_data) + scene.collection.objects.link(cam) + scene.camera = cam + + # Distance so the bounding sphere fits the field of view. + fov = cam_data.angle + dist = radius * margin / math.sin(fov / 2) + direction, _, _ = view_basis(azimuth, elevation) + cam.location = center + direction * dist + cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler() + cam_data.clip_start = dist / 100 + cam_data.clip_end = dist * 10 + return center, dist, radius + + +def add_light_behind_camera(scene, center, cam_dist, radius, args): + # Point light behind the camera, swung left/up (in the camera's frame) around the model. + direction, right, up = view_basis(args.azimuth, args.elevation) + a, b = math.radians(args.light_left), math.radians(args.light_up) + direction = (direction * math.cos(a) - right * math.sin(a)) * math.cos(b) + up * math.sin(b) + + light_dist = cam_dist * 1.5 + data = bpy.data.lights.new("camera_light", "POINT") + data.shadow_soft_size = radius * 0.5 + # Blender point light power P gives ~P / (4 pi^2 d^2) W/m^2 at distance d. + data.energy = args.light_strength * 4 * math.pi ** 2 * light_dist ** 2 + light = bpy.data.objects.new("camera_light", data) + light.location = center + direction * light_dist + scene.collection.objects.link(light) + + +def setup_render(scene, args): + scene.render.engine = args.engine + if args.engine == "CYCLES": + scene.cycles.samples = args.samples + scene.cycles.use_denoising = False + scene.cycles.device = "CPU" + else: + scene.eevee.taa_render_samples = args.samples + + r = scene.render + r.resolution_x = r.resolution_y = args.resolution + r.resolution_percentage = 100 + r.image_settings.file_format = "PNG" + r.image_settings.color_mode = "RGBA" if args.transparent else "RGB" + r.filepath = bpy.path.abspath(args.output) + + # "Standard" keeps texture colours faithful (Filmic/AgX would remap them). + scene.view_settings.view_transform = "Standard" + scene.view_settings.look = "None" + scene.view_settings.exposure = 0.0 + scene.view_settings.gamma = 1.0 + + +def main(): + args = parse_args() + scene = bpy.context.scene + + meshes = [o for o in scene.objects if o.type == "MESH" and o.visible_get()] + if not meshes: + sys.exit("No visible mesh objects in scene") + + # Only the lights/camera this script adds should be used. + for o in scene.objects: + if o.type in {"LIGHT", "CAMERA"}: + o.hide_render = True + + maps = find_maps(args, meshes) + texture = load_image(args.texture) + if args.project: + texture = bake_projection(scene, meshes, texture, args) + assign_material(meshes, make_material(args, maps, texture)) + setup_world(scene, args.transparent, [float(v) for v in args.background.split(",")], + 0.0 if args.unlit else args.ambient) + center, dist, radius = frame_camera(scene, meshes, args.azimuth, args.elevation, args.margin) + if not args.unlit: + add_light_behind_camera(scene, center, dist, radius, args) + setup_render(scene, args) + + bpy.ops.render.render(write_still=True) + print(f"Wrote {scene.render.filepath}") + + +if __name__ == "__main__": + main() diff --git a/renders/apple_front_checker.png b/renders/apple_front_checker.png new file mode 100644 index 0000000..0594698 Binary files /dev/null and b/renders/apple_front_checker.png differ diff --git a/renders/apple_front_diff.png b/renders/apple_front_diff.png new file mode 100644 index 0000000..715f3ee Binary files /dev/null and b/renders/apple_front_diff.png differ diff --git a/renders/face_apple_transparent.png b/renders/face_apple_transparent.png new file mode 100644 index 0000000..6e97e04 Binary files /dev/null and b/renders/face_apple_transparent.png differ diff --git a/renders/face_az15.png b/renders/face_az15.png new file mode 100644 index 0000000..346db04 Binary files /dev/null and b/renders/face_az15.png differ diff --git a/renders/face_az15_combined.png b/renders/face_az15_combined.png new file mode 100644 index 0000000..f2e6de7 Binary files /dev/null and b/renders/face_az15_combined.png differ diff --git a/renders/face_stretched.png b/renders/face_stretched.png new file mode 100644 index 0000000..4daa1d9 Binary files /dev/null and b/renders/face_stretched.png differ diff --git a/renders/face_stretched_more.png b/renders/face_stretched_more.png new file mode 100644 index 0000000..a98ed4b Binary files /dev/null and b/renders/face_stretched_more.png differ diff --git a/renders/jider_labels_a4.pdf b/renders/jider_labels_a4.pdf new file mode 100644 index 0000000..02d1763 Binary files /dev/null and b/renders/jider_labels_a4.pdf differ diff --git a/renders/lit_mix.png b/renders/lit_mix.png new file mode 100644 index 0000000..33f749d Binary files /dev/null and b/renders/lit_mix.png differ diff --git a/renders/lit_multiply.png b/renders/lit_multiply.png new file mode 100644 index 0000000..9fa1bd4 Binary files /dev/null and b/renders/lit_multiply.png differ diff --git a/renders/lit_upleft.png b/renders/lit_upleft.png new file mode 100644 index 0000000..2e2d905 Binary files /dev/null and b/renders/lit_upleft.png differ diff --git a/test.png b/test.png new file mode 100644 index 0000000..80aa9e7 Binary files /dev/null and b/test.png differ