"""Tiger: build the model in Blender from tg_geom.py (headless) and save Tender_Tiger.blend. blender.exe -b --factory-startup -t 0 --python build/tg_build.py -- [render TAG] [samples] * the parts from tg_geom.build() are merged by what they are into three game meshes, origin at the world origin: SM_Tiger_Hull (every opaque outside part), SM_Tiger_Glass (the canopy and the rear bay's blister), SM_Tiger_Cabin (the cockpit and the rear bay's contents); materials "TIGER | ..." (the surfaces pass, tg_surfaces.py, dresses them); * n-gons (the lofts' end caps) are triangulated, coincident vertices welded, normals made consistent, edges over 40 deg marked sharp (the fins' facets, the plates' rims); * a Tender (2026-10-22): the four holder clips on their fairings are part of the hull; the stern's tool arm is its own object, SM_Tiger_ToolArm, origin on its hinge, folded by Tiger_Root["tool_arm_down_deg"] (0 = aft as the picture draws it, 90 = folded down for docking; a simple-expression driver). Everything is moved TENDER_SHIFT_Y (0.16 m) aft of the picture's frame so the clamp axis lies at (0, -0.09) of the Tender's frame (the Kazah's convention); * collision: convex UCX_SM_Tiger_Hull_NN hulls in "TIGER | Collision (UCX)" (hidden in the viewport), UCX_SM_Tiger_ToolArm_00; * the picture's camera ("TIGER | Picture camera", from build/cam.json) and a studio light like the picture's; * with a TAG: a first-look render through the picture's camera (flat paints) to previews/compare/_render.png. """ import bpy import bmesh import math import os import sys import json HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, HERE) import numpy as np # noqa: E402 import tg_geom # noqa: E402 args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] TAG = args[0] if args else None SAMPLES = int(args[1]) if len(args) > 1 else 48 def srgb(r, g, b): def lin(c): c /= 255.0 return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 return (lin(r), lin(g), lin(b), 1.0) # geometry's material keys -> the model's materials (and a flat preview colour; the surfaces pass replaces the node trees) MATS = { "paint_orange": ("Hull paint", srgb(236, 156, 48)), "paint_red": ("Jaw paint", srgb(168, 46, 26)), "fin_grey": ("Fin grey", srgb(112, 138, 152)), "side_grey": ("Fin grey", srgb(112, 138, 152)), "recess_grey": ("Recess grey", srgb(128, 152, 166)), "dark_body": ("Lower hull", srgb(48, 56, 66)), "capsule_grey": ("Capsule grey", srgb(84, 104, 118)), "dark_metal": ("Graphite", srgb(52, 58, 64)), "black": ("Black gloss", srgb(12, 12, 14)), "groove_dark": ("Groove", srgb(14, 10, 12)), "frame": ("Frame", srgb(24, 44, 92)), "intake_dark": ("Intake", srgb(36, 46, 72)), "steel": ("Steel", srgb(150, 156, 162)), "trim_dark": ("Trim", srgb(16, 20, 30)), "white_plate": ("White plate", srgb(235, 236, 232)), "glass": ("Glass", srgb(200, 235, 245)), "glass_pale": ("Glass upper", srgb(235, 245, 250)), "window_glass": ("Bay glass", srgb(60, 80, 95)), "lamp_white": ("Lamp light", srgb(240, 245, 250)), "lens_orange": ("Lens", srgb(255, 110, 40)), "screen_cyan": ("Screen", srgb(70, 200, 215)), "display_cyan": ("Display", srgb(70, 200, 215)), "cockpit_dark": ("Cabin panel", srgb(26, 34, 52)), "cockpit_floor": ("Cabin floor", srgb(28, 34, 48)), "seat": ("Seat", srgb(22, 28, 44)), "seat_red": ("Seat red", srgb(120, 32, 22)), "bay_white": ("Bay white", srgb(225, 228, 230)), "yellow_mark": ("Yellow marking", srgb(230, 175, 30)), } GLASS_PARTS = {"glass", "window"} CABIN_PARTS = {"bulkhead", "display", "seat_pan", "seat_back", "headrest", "cushion", "console", "console_screen", "strut", "bay_box", "bay_canister"} def mname(key): return "TIGER | " + MATS.get(key, (key,))[0] def material(key): full = mname(key) m = bpy.data.materials.get(full) if m is None: col = MATS.get(key, (key, (0.5, 0.5, 0.5, 1)))[1] m = bpy.data.materials.new(full) m.use_nodes = True b = m.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = col b.inputs["Roughness"].default_value = 0.4 m.diffuse_color = col return m def merged_mesh(name, parts): V, F, M, S = [], [], [], [] off = 0 for p in parts: V.append(p.verts) F += [tuple(int(i) + off for i in f) for f in p.faces] M += [mname(k) for k in p.mat] S += [p.smooth] * len(p.faces) off += len(p.verts) me = bpy.data.meshes.new(name) me.from_pydata([tuple(map(float, v)) for v in np.concatenate(V)], [], F) names = [] for n in M: if n not in names: names.append(n) for n in names: me.materials.append(material([k for k in MATS if mname(k) == n][0])) me.polygons.foreach_set("material_index", [names.index(n) for n in M]) me.polygons.foreach_set("use_smooth", S) bm = bmesh.new() bm.from_mesh(me) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) ngons = [f for f in bm.faces if len(f.verts) > 4] if ngons: bmesh.ops.triangulate(bm, faces=ngons, quad_method='BEAUTY', ngon_method='BEAUTY') bmesh.ops.dissolve_degenerate(bm, dist=1e-6, edges=bm.edges) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me) bm.free() me.set_sharp_from_angle(angle=math.radians(40)) me.validate() return me def collision(hull_parts, coll): """convex hulls for Unreal: the body in slices along its length, the jaw, belly box, stern box, the centre fins whole, the big fins, their roots and the prongs one a side""" def hull_of(points, name): bm = bmesh.new() for q in points: bm.verts.new(tuple(map(float, q))) res = bmesh.ops.convex_hull(bm, input=bm.verts) bmesh.ops.delete(bm, geom=list(set(res["geom_interior"] + res["geom_unused"])), context='VERTS') me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new(name, me) coll.objects.link(ob) ob.display_type = 'WIRE' by = {} for p in hull_parts: by.setdefault(p.name, []).append(p) body = np.concatenate([q.verts for n in ("shell", "core", "capsule", "collar", "brow") for q in by.get(n, [])] + [tg_geom.part_canopy()[0].verts + np.array([0.0, tg_geom.TENDER_SHIFT_Y, 0.0])]) k = 0 cuts = [c + tg_geom.TENDER_SHIFT_Y for c in (-2.2, -1.2, -0.3, 0.6, 1.5, 2.4, 3.3, 4.6)] for y0, y1 in zip(cuts, cuts[1:]): sel = body[(body[:, 1] >= y0 - 0.02) & (body[:, 1] <= y1 + 0.02)] if len(sel) >= 4: hull_of(sel, "UCX_SM_Tiger_Hull_%02d" % k) k += 1 for n in ("jaw", "belly", "stern_box", "dorsal", "tail", "ventral"): for q in by.get(n, []): hull_of(q.verts, "UCX_SM_Tiger_Hull_%02d" % k) k += 1 for n in ("clips", "clip_fairings"): for q in by.get(n, []): for sx in (1, -1): for sy in (1, -1): v = q.verts[(q.verts[:, 0] * sx > 0) & ((q.verts[:, 1] - tg_geom.Y_CLAMP - tg_geom.TENDER_SHIFT_Y) * sy > 0)] if len(v) >= 4: hull_of(v, "UCX_SM_Tiger_Hull_%02d" % k) k += 1 for q in by.get("tool_knuckle", []): hull_of(q.verts, "UCX_SM_Tiger_Hull_%02d" % k) k += 1 for n in ("fins", "fin_roots", "prongs"): for q in by.get(n, []): for side in (1, -1): v = q.verts[q.verts[:, 0] * side >= -0.06] if n == "prongs" else q.verts[q.verts[:, 0] * side > 0.0] if len(v) >= 4: hull_of(v, "UCX_SM_Tiger_Hull_%02d" % k) k += 1 if n == "prongs": break return k def build(): for ob in list(bpy.data.objects): bpy.data.objects.remove(ob, do_unlink=True) for me in list(bpy.data.meshes): bpy.data.meshes.remove(me) sc = bpy.context.scene sc.unit_settings.system = 'METRIC' coll = bpy.data.collections.new("TIGER | Model") sc.collection.children.link(coll) parts = tg_geom.build(posed=False) sh = np.array([0.0, tg_geom.TENDER_SHIFT_Y, 0.0]) arm = [p for p in parts if p.name == "tool_arm"][0] parts = [p for p in parts if p.name != "tool_arm"] for p in parts: p.verts = p.verts + sh groups = {"SM_Tiger_Hull": [], "SM_Tiger_Glass": [], "SM_Tiger_Cabin": []} for p in parts: g = "SM_Tiger_Glass" if p.name in GLASS_PARTS else ("SM_Tiger_Cabin" if p.name in CABIN_PARTS else "SM_Tiger_Hull") groups[g].append(p) objs = {} for name, ps in groups.items(): ob = bpy.data.objects.new(name, merged_mesh(name, ps)) coll.objects.link(ob) objs[name] = ob # the tool arm: its own object at its hinge, a root empty with the fold handle, a simple-expression driver ob = bpy.data.objects.new("SM_Tiger_ToolArm", merged_mesh("SM_Tiger_ToolArm", [arm])) coll.objects.link(ob) ob.location = tuple(map(float, arm.extra["origin"] + sh)) objs["SM_Tiger_ToolArm"] = ob root = bpy.data.objects.new("Tiger_Root", None) root.empty_display_type = 'ARROWS' root.empty_display_size = 0.6 coll.objects.link(root) root["tool_arm_down_deg"] = 0.0 root.id_properties_ui("tool_arm_down_deg").update(min=0.0, max=90.0, soft_min=0.0, soft_max=90.0, description="0 = pointing aft as the picture draws it, 90 = folded down for docking") fc = ob.driver_add("rotation_euler", 0) dv = fc.driver dv.type = 'SCRIPTED' var = dv.variables.new() var.name = "d" var.type = 'SINGLE_PROP' var.targets[0].id = root var.targets[0].data_path = '["tool_arm_down_deg"]' dv.expression = "-d * 0.0174533" ucx = bpy.data.collections.new("TIGER | Collision (UCX)") sc.collection.children.link(ucx) n_ucx = collision(groups["SM_Tiger_Hull"], ucx) bm = bmesh.new() for q in arm.verts: bm.verts.new(tuple(map(float, q))) res = bmesh.ops.convex_hull(bm, input=bm.verts) bmesh.ops.delete(bm, geom=list(set(res["geom_interior"] + res["geom_unused"])), context='VERTS') me = bpy.data.meshes.new("UCX_SM_Tiger_ToolArm_00") bm.to_mesh(me) bm.free() uo = bpy.data.objects.new("UCX_SM_Tiger_ToolArm_00", me) ucx.objects.link(uo) uo.display_type = 'WIRE' uo.parent = ob # follows the arm (local coordinates are the arm's) bpy.context.view_layer.layer_collection.children[ucx.name].hide_viewport = True ucx.hide_render = True return objs, n_ucx def picture_camera(): sc = bpy.context.scene cam = json.loads(open(os.path.join(HERE, "cam.json")).read())["params"] cd = bpy.data.cameras.new("TIGER | Picture camera") cd.lens = math.exp(cam[6]) * 36.0 / 827.0 cd.sensor_width = 36.0 cd.sensor_fit = 'HORIZONTAL' cd.clip_start = 0.05 cd.clip_end = 200 ob = bpy.data.objects.new("TIGER | Picture camera", cd) ob.location = (cam[0], cam[1] + tg_geom.TENDER_SHIFT_Y, cam[2]) # the model moved with the Tender's frame ob.rotation_euler = cam[3:6] coll = bpy.data.collections.new("TIGER | Reference") sc.collection.children.link(coll) coll.objects.link(ob) sc.camera = ob sc.render.resolution_x, sc.render.resolution_y = 827, 803 sc.render.resolution_percentage = 100 def studio(): """a light like the picture's: a soft grey-blue surround, the key from above-left-front, a dim underside""" sc = bpy.context.scene w = bpy.data.worlds.new("TIGER | Studio") w.use_nodes = True nt = w.node_tree bg = nt.nodes["Background"] tcn = nt.nodes.new("ShaderNodeTexCoord") sx = nt.nodes.new("ShaderNodeSeparateXYZ") mr = nt.nodes.new("ShaderNodeMapRange") cr = nt.nodes.new("ShaderNodeValToRGB") nt.links.new(tcn.outputs["Generated"], sx.inputs[0]) nt.links.new(sx.outputs["Z"], mr.inputs["Value"]) mr.inputs["From Min"].default_value = -1.0 mr.inputs["From Max"].default_value = 1.0 nt.links.new(mr.outputs["Result"], cr.inputs["Fac"]) nt.links.new(cr.outputs["Color"], bg.inputs[0]) cr.color_ramp.elements[0].color = srgb(70, 82, 88) cr.color_ramp.elements[1].color = srgb(215, 226, 230) bg.inputs[1].default_value = 1.0 sc.world = w coll = bpy.data.collections.new("TIGER | Studio") sc.collection.children.link(coll) def light(name, kind, energy, rot, loc, size=4.0): ld = bpy.data.lights.new(name, kind) ld.energy = energy if kind == 'AREA': ld.shape = 'DISK' ld.size = size else: ld.angle = math.radians(10) o = bpy.data.objects.new(name, ld) coll.objects.link(o) o.rotation_euler = rot o.location = loc from mathutils import Vector d = Vector((-0.30, 0.50, -0.81)).normalized() light("TIGER | Key", 'SUN', 4.2, tuple(d.to_track_quat('-Z', 'Y').to_euler()), (0, 0, 10)) light("TIGER | Fill", 'AREA', 600, (math.radians(70), 0, math.radians(-120)), (9, -7, 2.5), size=9) light("TIGER | Under", 'AREA', 120, (math.radians(170), 0, 0), (0, 1, -6), size=10) def render(tag): sc = bpy.context.scene sc.render.engine = 'CYCLES' sc.cycles.samples = SAMPLES sc.cycles.use_denoising = True try: prefs = bpy.context.preferences.addons['cycles'].preferences for kind in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'): try: prefs.compute_device_type = kind except Exception: continue prefs.refresh_devices() if any(d.type == kind for d in prefs.devices): for d in prefs.devices: d.use = d.type == kind sc.cycles.device = 'GPU' break except Exception as ex: print("GPU not set", ex) sc.view_settings.view_transform = 'Standard' sc.view_settings.look = 'None' sc.render.image_settings.file_format = 'PNG' sc.render.filepath = os.path.join(ROOT, "previews", "compare", tag + "_render.png") bpy.ops.render.render(write_still=True) objs, n_ucx = build() picture_camera() studio() bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT, "Tender_Tiger.blend")) stats = {"ucx": n_ucx} for n, ob in objs.items(): sizes = [len(p.vertices) for p in ob.data.polygons] stats[n] = dict(verts=len(ob.data.vertices), quads=sizes.count(4), tris=sizes.count(3), ngons=sum(1 for s in sizes if s > 4), triangles=sum(s - 2 for s in sizes), materials=[m.name for m in ob.data.materials]) json.dump(stats, open(os.path.join(HERE, "checks", "build_stats.json"), "w"), indent=1) print("TIGER STATS", json.dumps(stats)) if TAG: render(TAG) print("TIGER BUILD OK")