# -*- coding: utf-8 -*- """Common base for the channel's 3D scenes (Blender 5.2, EEVEE, headless). Key idea: no keyframes are used. The render walks the frames one by one and calls a refresh(f) function before each one that places everything. That way physics and geometry are computed exactly on every frame and there is no fighting with the layered actions API of Blender 5.x. Timing comes from out/_timeline.json, the same file build_video.py uses to place the audio: sync holds by construction. """ import json, math, os, sys import bpy def env(name, old=None, default=None): """Environment variable, also accepting its older Spanish name.""" return os.environ.get(name, os.environ.get(old, default) if old else default) # project root: defaults to the directory above this file ROOT = env("PROJECT", "PROY", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) FPS = 30 W, H = 1080, 1920 # --- channel palette (sRGB) --------------------------------------------------- HEX = {"ambar": "FFD166", "verde": "06D6A0", "rosa": "EF476F", "celeste": "4CC9F0", "gris": "8D99AE", "fondo": "0B0F1A", "azul": "1D3B6E", "riel": "1B2233", "blanco": "FFFFFF", "punto": "243050"} def srgb(h): """sRGB hex -> linear, which is what Blender expects in materials.""" def c(v): v /= 255.0 return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 h = HEX.get(h, h).lstrip("#") return tuple(c(int(h[i:i + 2], 16)) for i in (0, 2, 4)) def scene_setup(samples=None): """Clean vertical scene, EEVEE, transparent background and no tonemapping.""" bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene sc.render.engine = 'BLENDER_EEVEE' sc.eevee.taa_render_samples = int(samples or env("SAMPLES", "MUESTRAS", 16)) sc.render.resolution_x, sc.render.resolution_y = W, H sc.render.resolution_percentage = int(os.environ.get("PCT", 100)) sc.render.fps = FPS sc.render.film_transparent = True # the dotted background is composited separately sc.render.image_settings.file_format = 'PNG' sc.render.image_settings.color_mode = 'RGBA' sc.render.image_settings.compression = 25 # 'Standard' keeps colors exactly as written; AgX would wash them out and the # channel would end up with a different palette from the Manim videos. for prop, val in (("view_transform", 'Standard'), ("look", 'None')): try: setattr(sc.view_settings, prop, val) except Exception as e: print(f" aviso: view_settings.{prop} -> {e}") world_obj = bpy.data.worlds.new("mundo") world_obj.use_nodes = True world_obj.node_tree.nodes["Background"].inputs[0].default_value = (*srgb("fondo"), 1) world_obj.node_tree.nodes["Background"].inputs[1].default_value = 0.45 sc.world = world_obj return sc def put(node, obj_name, valor): """Tolerant setattr/socket: warns instead of breaking if a name changed.""" try: if obj_name in node.inputs: node.inputs[obj_name].default_value = valor return True except Exception: pass print(f" warning: input '{obj_name}' does not exist") return False def material(obj_name, color, rough=0.35, metal=0.0, emit=0.0, alpha=1.0): m = bpy.data.materials.new(obj_name) m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") col = (*(color if isinstance(color, tuple) else srgb(color)), 1.0) put(b, "Base Color", col) put(b, "Roughness", rough) put(b, "Metallic", metal) if emit: put(b, "Emission Color", col) put(b, "Emission Strength", emit) if alpha < 1.0: put(b, "Alpha", alpha) m.blend_method = 'BLEND' if hasattr(m, "blend_method") else m.blend_method return m def make_object(obj_name, mesh_obj, mat=None, collection_obj=None): ob = bpy.data.objects.new(obj_name, mesh_obj) (collection_obj or bpy.context.collection).objects.link(ob) if mat is not None: ob.data.materials.append(mat) return ob def mesh_from(obj_name, verts, faces, suave=True): me = bpy.data.meshes.new(obj_name) me.from_pydata(verts, [], faces) me.update() if suave: for p in me.polygons: p.use_smooth = True return me def curve_poly(obj_name, splines, thickness_px=0.01, radios=None, mat=None): """A curve with several POLY splines; optional per-point radii.""" cu = bpy.data.curves.new(obj_name, 'CURVE') cu.dimensions = '3D' cu.bevel_depth = thickness_px cu.bevel_resolution = 1 cu.use_fill_caps = True for k, pts in enumerate(splines): sp = cu.splines.new('POLY') sp.points.add(len(pts) - 1) for i, p in enumerate(pts): sp.points[i].co = (p[0], p[1], p[2], 1.0) if radios is not None: sp.points[i].radius = radios[k][i] return make_object(obj_name, cu, mat) def rebuild_curve(ob, splines, radios=None): """Rewrites the points of an existing curve (same number of points).""" for sp, pts, k in zip(ob.data.splines, splines, range(len(splines))): for i, p in enumerate(pts): sp.points[i].co = (p[0], p[1], p[2], 1.0) if radios is not None: sp.points[i].radius = radios[k][i] def camera_obj(loc, sight=(0, 0, 0), lens=50): cd = bpy.data.cameras.new("cam") cd.lens = lens cam = bpy.data.objects.new("cam", cd) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam aim_at(cam, loc, sight) return cam def aim_at(ob, loc, sight): """Places ob at loc looking at 'sight' (camera convention: -Z forward).""" from mathutils import Vector ob.location = loc d = Vector(sight) - Vector(loc) ob.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() def light_obj(obj_name, kind_m, loc, energy_val, color="blanco", size_u=2.0, sight=None): ld = bpy.data.lights.new(obj_name, kind_m) ld.energy = energy_val ld.color = srgb(color) if kind_m == 'AREA': ld.size = size_u if kind_m == 'POINT': ld.shadow_soft_size = size_u ob = bpy.data.objects.new(obj_name, ld) bpy.context.collection.objects.link(ob) if sight is not None: aim_at(ob, loc, sight) else: ob.location = loc return ob # --- timing ------------------------------------------------------------------- class Timeline: """Turns the channel timeline into frames.""" def __init__(self, obj_name): with open(os.path.join(ROOT, "out", f"{obj_name}_timeline.json")) as f: self.tl = json.load(f) self.beats = self.tl["beats"] self.total = self.tl["total"] self.n_frames = int(round(self.total * FPS)) def span(self, i): """(first frame, last frame) of beat i, 1-based inclusive.""" b = self.beats[i] a = int(round(b["start"] * FPS)) + 1 z = int(round((b["start"] + b["dur"]) * FPS)) return a, z def t(self, f): return (f - 1) / FPS def p(self, f, i): """Progress 0..1 within beat i (outside the beat: 0 before, 1 after).""" a, z = self.span(i) if f <= a: return 0.0 if f >= z: return 1.0 return (f - a) / max(1, z - a) def suave(x): x = max(0.0, min(1.0, x)) return x * x * (3 - 2 * x) def mix_m(a, b, x): return a + (b - a) * suave(x) # --- render ------------------------------------------------------------------- def render_sequence(obj_name, elapsed_t, refresh, since=None, until=None): sc = bpy.context.scene folder = os.path.join(ROOT, "render", obj_name) os.makedirs(folder, exist_ok=True) a = int(since or env("FROM_FRAME", "DESDE", 1)) z = int(until or env("TO_FRAME", "HASTA", elapsed_t.n_frames)) jump = env("EVERY", "SALTO") # for tests: 1 out of every N frames = range(a, z + 1, int(jump) if jump else 1) import time t0 = time.time() done = 0 for f in frames: target = os.path.join(folder, f"f{f:04d}.png") if env("RESUME", "SEGUIR") and os.path.exists(target): continue sc.frame_set(f) refresh(f) sc.render.filepath = target bpy.ops.render.render(write_still=True) done += 1 if done % 25 == 0: d = time.time() - t0 print(f" [{obj_name}] frame {f}/{z} {d / done:.2f}s/frame " f"faltan {(len(frames) - done) * d / done / 60:.1f} min", flush=True) print(f"[{obj_name}] {done} frames in {(time.time() - t0) / 60:.1f} min", flush=True) # --- 3D text ----------------------------------------------------------------- FONT_PATH = "/usr/share/fonts/TTF/Roboto-Bold.ttf" _font = None def txt_m(body_obj, size_u=0.18, color="blanco", align='CENTER', emit=1.8, plano_xz=True): """Flat emissive text on the XZ plane (for cameras looking along -Y).""" global _font if _font is None: _font = bpy.data.fonts.load(FONT_PATH) cu = bpy.data.curves.new("txt", 'FONT') cu.body = body_obj cu.font = _font cu.size = size_u cu.align_x = align cu.align_y = 'CENTER' ob = make_object("txt", cu, material(f"m_txt_{color}", color, emit=emit, rough=0.6)) if plano_xz: ob.rotation_euler = (math.pi / 2, 0, 0) return ob def cylinder(obj_name, radio, largo, mat=None, sides=20): """Cylinder centred on the origin, +Z axis, to be repositioned per frame.""" verts, faces = [], [] for i in range(sides): a = 2 * math.pi * i / sides verts.append((radio * math.cos(a), radio * math.sin(a), -largo / 2)) verts.append((radio * math.cos(a), radio * math.sin(a), largo / 2)) for i in range(sides): j = (i + 1) % sides faces.append((2 * i, 2 * j, 2 * j + 1, 2 * i + 1)) lid_a = [2 * i for i in range(sides)][::-1] lid_b = [2 * i + 1 for i in range(sides)] faces += [tuple(lid_a), tuple(lid_b)] me = mesh_from(obj_name, verts, faces, suave=False) for p in me.polygons[:sides]: p.use_smooth = True return make_object(obj_name, me, mat) def sphere(obj_name, radio, mat=None, seg_m=24, rings=14): verts, faces = [], [] for i in range(1, rings): phi = math.pi * i / rings for j in range(seg_m): th = 2 * math.pi * j / seg_m verts.append((radio * math.sin(phi) * math.cos(th), radio * math.sin(phi) * math.sin(th), radio * math.cos(phi))) north = len(verts); verts.append((0, 0, radio)) south = len(verts); verts.append((0, 0, -radio)) for i in range(rings - 2): for j in range(seg_m): a = i * seg_m + j b = i * seg_m + (j + 1) % seg_m faces.append((a, b, b + seg_m, a + seg_m)) for j in range(seg_m): faces.append((north, (j + 1) % seg_m, j)) faces.append((south, (rings - 2) * seg_m + j, (rings - 2) * seg_m + (j + 1) % seg_m)) return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) def orient(ob, since, until): """Places a cylinder created with cylinder() between two points.""" from mathutils import Vector a, b = Vector(since), Vector(until) ob.location = (a + b) / 2 d = b - a ob.rotation_euler = d.to_track_quat('Z', 'Y').to_euler() def torus(obj_name, R, r, mat=None, u=64, v=24): verts, faces = [], [] for i in range(u): a = 2 * math.pi * i / u ca, sa = math.cos(a), math.sin(a) for j in range(v): b = 2 * math.pi * j / v rr = R + r * math.cos(b) verts.append((rr * ca, rr * sa, r * math.sin(b))) for i in range(u): i2 = (i + 1) % u for j in range(v): j2 = (j + 1) % v faces.append((i * v + j, i2 * v + j, i2 * v + j2, i * v + j2)) return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) SLACK = 0.55 # 0.5 = no play; a bit more leaves light between flanks def bevel_gear(obj_name, axis_obj, N=18, gamma=45.0, d_i=0.50, d_o=0.70, alto=0.055, mat=None, nd=10, per_tooth=10, phase=0.0, spiral=0.0, thickness=0.10, gap_m=0.0): """Bevel gear with its apex at the origin and axis 'axis_obj'. gamma = half-angle of the pitch cone. Two gears mesh at 90 degrees when their gammas add up to 90: 45+45 for the differential ones, 72+18 for ring gear and pinion. spiral = twist of the tooth along the generatrix (spiral bevel, which is what is really used; straight teeth look like a toy). thickness = how far the body is extruded backwards, so it is a solid and not a shell. """ from mathutils import Vector a = Vector(axis_obj).normalized() u = Vector((0, 0, 1)) if abs(a.z) < 0.9 else Vector((1, 0, 0)) u = (u - a * u.dot(a)).normalized() v = a.cross(u) cg, sg = math.cos(math.radians(gamma)), math.sin(math.radians(gamma)) nphi = N * per_tooth verts, faces = [], [] def profile(x): return max(0.0, min(1.0, (math.cos(x) + 0.30) / 0.60)) def point(d, phi, h): rad = u * math.cos(phi) + v * math.sin(phi) base = (a * cg + rad * sg) * d nrm = rad * cg - a * sg return base + nrm * h for i in range(nd + 1): s_ = i / nd d = d_i + (d_o - d_i) * s_ for j in range(nphi): phi = 2 * math.pi * j / nphi # the tooth starts low at the inner end and grows outwards # the tooth fades at the inner edge: otherwise the body closure # looks like a fan of fins # the tooth sits half above and half below the pitch cone: # that way the opposite gear goes into the gap instead of through the body h = alto * (profile(N * phi + phase + spiral * s_) - SLACK) * (s_ ** 0.8) p = point(d, phi, h) verts.append((p.x, p.y, p.z)) for i in range(nd): for j in range(nphi): j2 = (j + 1) % nphi faces.append((i * nphi + j, i * nphi + j2, (i + 1) * nphi + j2, (i + 1) * nphi + j)) # body: the outer edge is extruded backwards and closed with the bottom o_ext = nd * nphi base_ext = len(verts) for j in range(nphi): phi = 2 * math.pi * j / nphi # extruded from the tooth, not from the cone: that way the profile reaches # the outer face like on a real gear h = alto * (profile(N * phi + phase + spiral) - SLACK) # the body goes on the back side (away from the apex), not towards the # opposite gear p = point(d_o, phi, h) + a * thickness verts.append((p.x, p.y, p.z)) for j in range(nphi): j2 = (j + 1) % nphi faces.append((o_ext + j, o_ext + j2, base_ext + j2, base_ext + j)) base_int = len(verts) r_h = max(gap_m, 0.02) for j in range(nphi): phi = 2 * math.pi * j / nphi rad = u * math.cos(phi) + v * math.sin(phi) p = rad * r_h + a * (d_i * cg) + a * thickness * 0.35 verts.append((p.x, p.y, p.z)) for j in range(nphi): j2 = (j + 1) % nphi faces.append((base_ext + j, base_ext + j2, base_int + j2, base_int + j)) faces.append((j2, j, base_int + j, base_int + j2)) me = mesh_from(obj_name, verts, faces, suave=False) import bmesh bm = bmesh.new() bm.from_mesh(me) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me) bm.free() ob = make_object(obj_name, me, mat) # the toothed face smooth (it is a continuous surface, like a machined # gear); the body flat, so the edges read for k, pol in enumerate(me.polygons): pol.use_smooth = k < nd * nphi return ob def flat_ring(obj_name, r_int, r_ext, thickness, mat=None, sides=64): """Solid ring (flange, bolt circle, rim).""" verts, faces = [], [] for j in range(sides): ang = 2 * math.pi * j / sides c, s_ = math.cos(ang), math.sin(ang) for (r, z) in ((r_int, -thickness / 2), (r_ext, -thickness / 2), (r_ext, thickness / 2), (r_int, thickness / 2)): verts.append((r * c, r * s_, z)) for j in range(sides): j2 = (j + 1) % sides for k in range(4): k2 = (k + 1) % 4 faces.append((j * 4 + k, j2 * 4 + k, j2 * 4 + k2, j * 4 + k2)) return make_object(obj_name, mesh_from(obj_name, verts, faces, suave=False), mat) def tire(obj_name, R, r, mat=None, nu=72, nv=28, lugs=26, depth=0.055): """Torus with a tread: lugs and two longitudinal grooves.""" verts, faces = [], [] for i in range(nu): aa = 2 * math.pi * i / nu ca, sa = math.cos(aa), math.sin(aa) for j in range(nv): b = 2 * math.pi * j / nv cb, sb = math.cos(b), math.sin(b) rr = r if abs(sb) < 0.72: # tread area lug = 0.5 + 0.5 * math.cos(lugs * aa + 3.0 * b) groove = 1.0 if abs(sb) > 0.22 and abs(sb) < 0.5 else 0.0 rr -= depth * (0.45 * (lug < 0.45) + 0.55 * groove) verts.append(((R + rr * cb) * ca, (R + rr * cb) * sa, rr * sb)) for i in range(nu): i2 = (i + 1) % nu for j in range(nv): j2 = (j + 1) % nv faces.append((i * nv + j, i2 * nv + j, i2 * nv + j2, i * nv + j2)) return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) def studio_world(force=0.55, above="#8FA6C4", below="#10141F"): """Horizon gradient in the world. It is not visible (the film is transparent) but it is what metal reflects: without it steel looks flat.""" w = bpy.data.worlds.new("estudio") w.use_nodes = True nt = w.node_tree for n in list(nt.nodes): if n.type != 'OUTPUT_WORLD': nt.nodes.remove(n) out_node = next(n for n in nt.nodes if n.type == 'OUTPUT_WORLD') background = nt.nodes.new("ShaderNodeBackground") geo = nt.nodes.new("ShaderNodeNewGeometry") sep = nt.nodes.new("ShaderNodeSeparateXYZ") map_obj = nt.nodes.new("ShaderNodeMapRange") ramp = nt.nodes.new("ShaderNodeValToRGB") map_obj.inputs[1].default_value = -0.45 map_obj.inputs[2].default_value = 0.85 ramp.color_ramp.elements[0].color = (*srgb(below), 1) ramp.color_ramp.elements[1].color = (*srgb(above), 1) ramp.color_ramp.elements[0].position = 0.15 ramp.color_ramp.elements[1].position = 0.92 nt.links.new(geo.outputs["Incoming"], sep.inputs[0]) nt.links.new(sep.outputs["Z"], map_obj.inputs[0]) nt.links.new(map_obj.outputs[0], ramp.inputs[0]) nt.links.new(ramp.outputs["Color"], background.inputs[0]) background.inputs[1].default_value = force nt.links.new(background.outputs[0], out_node.inputs[0]) bpy.context.scene.world = w return w def metal(obj_name, color="#B9C0CC", rough=0.26, met=1.0): return material(obj_name, color, rough=rough, metal=met) def full_wheel(obj_name, mats, R=0.36, r=0.135, width_px=0.20, radios=5): """Tire with lugs + alloy rim with spokes + brake disc. Returns the group, with the spin axis along X.""" g = bpy.data.objects.new(obj_name, None) bpy.context.collection.objects.link(g) rubber = tire(f"{obj_name}_goma", R, r, mats["goma"]) rubber.rotation_euler = (0, math.pi / 2, 0) rubber.parent = g r_int = R - r * 0.72 hoop = flat_ring(f"{obj_name}_aro", r_int - 0.045, r_int + 0.02, width_px, mats["alu"]) hoop.rotation_euler = (0, math.pi / 2, 0) hoop.parent = g cube = cylinder(f"{obj_name}_cubo", 0.085, width_px * 0.9, mats["alu"], sides=24) cube.rotation_euler = (0, math.pi / 2, 0) cube.parent = g for k in range(radios): ang = 2 * math.pi * k / radios d = (0.0, math.cos(ang), math.sin(ang)) rad = cylinder(f"{obj_name}_r{k}", 0.040, 1.0, mats["alu"], sides=12) orient(rad, tuple(c * 0.075 for c in d), tuple(c * (r_int - 0.02) for c in d)) rad.scale = (1, 1, (r_int - 0.02) - 0.075) rad.parent = g disco = cylinder(f"{obj_name}_disco", r_int - 0.09, 0.035, mats["freno"], sides=40) disco.rotation_euler = (0, math.pi / 2, 0) disco.location = (-width_px * 0.55, 0, 0) disco.parent = g return g def screws(obj_name, n, radio, axis_x, largo=0.05, r_t=0.028, mat=None, padre=None): """Bolt circle on a flange.""" g = bpy.data.objects.new(obj_name, None) bpy.context.collection.objects.link(g) for k in range(n): ang = 2 * math.pi * k / n t = cylinder(f"{obj_name}_{k}", r_t, largo, mat, sides=8) t.rotation_euler = (0, math.pi / 2, 0) t.location = (axis_x, radio * math.cos(ang), radio * math.sin(ang)) t.parent = g if padre: g.parent = padre return g def ring_sector(obj_name, r_int, r_ext, thickness, ang0, ang1, mat=None, sides=28): """Curved wall: a ring sector with thickness along its axis. Used to build a differential housing with windows.""" verts, faces = [], [] n = max(3, sides) for j in range(n + 1): ang = ang0 + (ang1 - ang0) * j / n c, s_ = math.cos(ang), math.sin(ang) for (r, z) in ((r_int, -thickness / 2), (r_ext, -thickness / 2), (r_ext, thickness / 2), (r_int, thickness / 2)): verts.append((r * c, r * s_, z)) for j in range(n): for k in range(4): k2 = (k + 1) % 4 faces.append((j * 4 + k, (j + 1) * 4 + k, (j + 1) * 4 + k2, j * 4 + k2)) faces.append((0, 3, 2, 1)) o = n * 4 faces.append((o, o + 1, o + 2, o + 3)) return make_object(obj_name, mesh_from(obj_name, verts, faces, suave=False), mat) # --- madera procedural ---------------------------------------------------------- def madera(obj_name, light_c="#9A6A40", dark="#4E2E17", scale_to=4.0, rough=0.42, grain=(1, 1, 9)): """Wood grain: waves distorted by noise, stretched along one axis.""" m = bpy.data.materials.new(obj_name) m.use_nodes = True nt = m.node_tree b = nt.nodes.get("Principled BSDF") tc = nt.nodes.new("ShaderNodeTexCoord") mp = nt.nodes.new("ShaderNodeMapping") mp.inputs["Scale"].default_value = grain wave_tex = nt.nodes.new("ShaderNodeTexWave") wave_tex.wave_type = 'RINGS' wave_tex.inputs["Scale"].default_value = scale_to wave_tex.inputs["Distortion"].default_value = 7.0 wave_tex.inputs["Detail"].default_value = 4.0 wave_tex.inputs["Detail Scale"].default_value = 1.6 ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].color = (*srgb(dark), 1) ramp.color_ramp.elements[1].color = (*srgb(light_c), 1) ramp.color_ramp.elements[0].position = 0.25 ramp.color_ramp.elements[1].position = 0.85 nt.links.new(tc.outputs["Object"], mp.inputs["Vector"]) nt.links.new(mp.outputs["Vector"], wave_tex.inputs["Vector"]) nt.links.new(wave_tex.outputs["Fac"], ramp.inputs["Fac"]) nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"]) put(b, "Roughness", rough) bump = nt.nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.08 nt.links.new(wave_tex.outputs["Fac"], bump.inputs["Height"]) nt.links.new(bump.outputs["Normal"], b.inputs["Normal"]) return m def box_obj(obj_name, size_u, loc=(0, 0, 0), mat=None, rot=(0, 0, 0)): """Prism with its origin at the centre (so Godot takes the collision box straight from the mesh size).""" sx, sy, sz = (t / 2 for t in size_u) verts = [(x, y, z) for x in (-sx, sx) for y in (-sy, sy) for z in (-sz, sz)] faces = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] ob = make_object(obj_name, mesh_from(obj_name, verts, faces, suave=False), mat) ob.location = loc ob.rotation_euler = rot return ob # --- bridge to Godot: Blender designs, Godot simulates ------------------------- GODOT = os.environ.get("GODOT", "godot") GODOT_PROJECT = os.path.join(ROOT, "godot") def export_physics(obj_name, object_list, cfg): """Exports to .glb only the objects that take part in the physics (the name defines the body type, see godot/sim.gd) and writes the config.""" folder = os.path.join(GODOT_PROJECT, "work") os.makedirs(folder, exist_ok=True) glb = os.path.join(folder, f"{obj_name}.glb") for ob in bpy.context.scene.objects: ob.select_set(False) for ob in object_list: ob.select_set(True) bpy.context.view_layer.objects.active = object_list[0] bpy.ops.export_scene.gltf(filepath=glb, export_format='GLB', use_selection=True, export_yup=True, export_apply=True, export_materials='NONE', export_animations=False) cfg = dict(cfg, glb=glb, output=os.path.join(folder, obj_name)) path_str = os.path.join(folder, f"{obj_name}.json") with open(path_str, "w") as f: json.dump(cfg, f, indent=1) print(f"[{obj_name}] {len(object_list)} objects -> {glb}", flush=True) return path_str def run_godot(cfg_path): import subprocess, time t0 = time.time() r = subprocess.run([GODOT, "--headless", "--path", GODOT_PROJECT, "--fixed-fps", "240", "--", cfg_path], capture_output=True, text=True) for l in (r.stdout + r.stderr).splitlines(): if "[sim]" in l or "ERROR" in l or "SCRIPT" in l: print(" godot:", l, flush=True) if r.returncode != 0: raise SystemExit(f"godot failed ({r.returncode})") print(f" godot took {time.time() - t0:.1f}s", flush=True) def load_sim(obj_name): """-> (dict name->index, array [frames, bodies, 7]) in Blender coordinates.""" import numpy as np base_ = os.path.join(GODOT_PROJECT, "work", obj_name) header = json.load(open(base_ + ".header.json")) dataset = np.fromfile(base_ + ".bin", dtype=np.float32) n = len(header["names"]) dataset = dataset.reshape(-1, n, 7) return {k: i for i, k in enumerate(header["names"])}, dataset def set_pose(ob, row): ob.rotation_mode = 'QUATERNION' ob.location = (float(row[0]), float(row[1]), float(row[2])) ob.rotation_quaternion = (float(row[3]), float(row[4]), float(row[5]), float(row[6]))