From d2d43321e646603a6beea9b7d74eece3063f5145 Mon Sep 17 00:00:00 2001 From: kramer Date: Wed, 25 Mar 2026 15:14:02 +0100 Subject: [PATCH] some more niceties (camera controls and optional spawn animation) --- Blender/import_modular_robots.py | 179 +++++++++++++++++++++++-------- 1 file changed, 136 insertions(+), 43 deletions(-) diff --git a/Blender/import_modular_robots.py b/Blender/import_modular_robots.py index cabc4f6..a4e3121 100644 --- a/Blender/import_modular_robots.py +++ b/Blender/import_modular_robots.py @@ -16,7 +16,6 @@ import traceback from typing import Annotated, Literal, TypeVar -import bmesh import bpy import numpy as np import numpy.typing as npt @@ -33,8 +32,35 @@ def get_exception_traceback_str(exc: Exception) -> str: traceback.print_exception(exc, file=file) return file.getvalue().rstrip() +def euler_from_dir(dir_vec:Vec3, up:Vec3=np.array([0, 0, 1])): + # Normalize forward direction + fwd = -dir_vec / np.linalg.norm(dir_vec) + + # Compute right vector + right = np.cross(up, fwd) + if np.linalg.norm(right) < 1e-6: # forward ∥ up + right = np.array([0, -1, 0]) + else: + right /= np.linalg.norm(right) + + # Re‑orthogonalize up vector + true_up = np.cross(fwd, right) + + # Rotation matrix: columns = right, up, forward + R = np.column_stack((right, true_up, fwd)) + + # XYZ Euler extraction + pitch = -np.arcsin(-R[2, 1]) # rotation about X + yaw = np.arctan2(R[2, 0], R[2, 2]) # rotation about Y + roll = -np.arctan2(R[0, 1], R[1, 1]) # rotation about Z + + return np.array([pitch, yaw, roll]) + +def srgb(rgb_perceptual): + return np.array([pow(channel if channel is float else channel / 255.0, 2.2) for channel in rgb_perceptual]) def new_cube_mesh(name="CubeMesh", size=1.0): + import bmesh mesh = bpy.data.meshes.new(name) bm = bmesh.new() @@ -76,6 +102,7 @@ def new_line_art(name:str,color:Vec3,width,collection): mod.radius = width mod.target_layer = "Line Art" mod.target_material = material + # mod.crease_threshold = 0.78 return gp_obj def new_sun_light(name:str, euler:Vec3, collection): @@ -86,15 +113,16 @@ def new_sun_light(name:str, euler:Vec3, collection): light_data.temperature = 6500 light_data.angle = 0.2 sun_obj.rotation_euler = (*euler,) + sun_obj.location = (0,0,3) return sun_obj -def new_camera(name:str, pos:Vec3, collection): - cam_data = bpy.data.cameras.new("Camera") - cam_obj = bpy.data.objects.new("Camera", cam_data) +def new_camera(name:str, pos:Vec3, focus:Vec3, collection): + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) collection.objects.link(cam_obj) cam_obj.location = (*pos,) - cam_obj.rotation_euler = (0.95, 0.0, 2.35) + cam_obj.rotation_euler = euler_from_dir(focus-pos) #(0.95, 0.0, 2.35) return cam_obj def new_shadow_catcher_material(name:str): @@ -141,7 +169,7 @@ def new_shadow_catcher_material(name:str): new_node.data_type = 'RGBA' new_node.factor_mode = 'UNIFORM' new_node.warning_propagation = 'ALL' - new_node.inputs[6].default_value = [0.0, 0.0, 0.0, 1.0] + new_node.inputs[6].default_value = [0.2, 0.2, 0.25, 1.0] new_node.inputs[7].default_value = [1.0, 1.0, 1.0, 1.0] new_node = nodes.new(type='ShaderNodeMath') @@ -177,7 +205,49 @@ def new_shadow_catcher_material(name:str): links.new(nodes["Math.001"].outputs[0], nodes["Math"].inputs[1]) return new_mat +def new_background_material(world, color:Vec3=np.ones(3), strength:float=32): + node_tree = world.node_tree + nodes = node_tree.nodes + nodes.clear() + links = node_tree.links + + lightpath = nodes.new("ShaderNodeLightPath") + + lightbackground = nodes.new("ShaderNodeBackground") + lightbackground.name = "Light" + lightbackground.inputs["Color"].default_value = (*(np.ones(3)/3), 1.0) + + viewportbackground = nodes.new("ShaderNodeBackground") + viewportbackground.name = "Viewport" + viewportbackground.inputs["Color"].default_value = (*color, 1.0) + viewportbackground.inputs["Strength"].default_value = strength + + mix = nodes.new("ShaderNodeMixShader") + output = nodes.new("ShaderNodeOutputWorld") + output.inputs["Surface"].show_expanded = True + links.new( + lightpath.outputs["Is Camera Ray"], + mix.inputs["Factor"] + ) + + links.new( + lightbackground.outputs["Background"], + mix.inputs[1] + ) + + links.new( + viewportbackground.outputs["Background"], + mix.inputs[2] + ) + + links.new( + mix.outputs["Shader"], + output.inputs["Surface"] + ) + + def new_ground_plane(name:str, pos:int, collection): + import bmesh mesh = bpy.data.meshes.new(name) bm = bmesh.new() @@ -192,11 +262,9 @@ def new_ground_plane(name:str, pos:int, collection): return plane_obj - - class UMLScenario: class RobotType: - def __init__(self, identifier: int, color: Vec3[np.float32], + def __init__(self, identifier: int, color: Vec3, size: float): self.identifier = identifier self.color = color @@ -259,6 +327,8 @@ def __init__(self, name: str = None, self.robot_types: dict[int, UMLScenario.RobotType] = robot_types self.robots: dict[int, UMLScenario.Robot] = robots self.steps: list[UMLScenario.Step] = steps + self.camera_pos:Vec3 = np.array([10,9.7,10]) + self.camera_focus:Vec3 = np.zeros(3) def __str__(self): return f"UMLScenario(#types: {len(self.robot_types)}, #robots: {len(self.robots)}, #steps: {len(self.steps)})" @@ -266,51 +336,53 @@ def __str__(self): @staticmethod def from_file(f): scenario = UMLScenario() - in_block = -1 - # -1 = Header - # 0 = Robot Types - # 1 = Robots - # >=2 = Frames lines = iter(f) def stripped_scen_line(l: str): l = l.strip() if "//" in l: - l = l[:l.find("//")].strip() - return l + return l[:l.find("//")].strip(), l[l.find("//")+2:].strip() + return l, None def values_from_scen_line(l: str): - return map(lambda s: int(s), line.replace(" ", "").split(",")) + return map(lambda s: float(s) if "." in s else int(s), l.replace(" ", "").split(",")) # Heading for line in lines: if len(line.strip()) == 0: break - line = stripped_scen_line(line) + line,comment = stripped_scen_line(line) if not scenario.name: scenario.name = line + if comment: + if comment.startswith("camera_pos"): + scenario.camera_pos = np.array(list(values_from_scen_line(comment[len("camera_pos "):]))) + if comment.startswith("camera_focus"): + scenario.camera_focus = np.array(list(values_from_scen_line(comment[len("camera_focus "):]))) # Robot types for line in lines: if len(line.strip()) == 0: break - line = stripped_scen_line(line) + line,comment = stripped_scen_line(line) - [identifier, r, g, b, size] = values_from_scen_line(line) - rtype = UMLScenario.RobotType(identifier, - np.array([r, g, b]) / 255.0, - size / 100.0) - scenario.robot_types[identifier] = rtype + if len(line) > 0: + [identifier, r, g, b, size] = values_from_scen_line(line) + rtype = UMLScenario.RobotType(identifier, + srgb(np.array([r,g,b])), + size / 100.0) + scenario.robot_types[identifier] = rtype # Robots for line in lines: if len(line.strip()) == 0: break - line = stripped_scen_line(line) + line,comment = stripped_scen_line(line) - [identifier, rtype, x, y, z] = values_from_scen_line(line) - robot = UMLScenario.Robot(identifier, rtype, np.array([x, y, z])) - scenario.robots[identifier] = robot + if len(line) > 0: + [identifier, rtype, x, y, z] = values_from_scen_line(line) + robot = UMLScenario.Robot(identifier, rtype, np.array([x, y, z])) + scenario.robots[identifier] = robot # Steps scenario.steps.append(UMLScenario.Step()) @@ -320,19 +392,20 @@ def values_from_scen_line(l: str): scenario.steps.append(UMLScenario.Step()) continue - line = stripped_scen_line(line) + line,comment = stripped_scen_line(line) - while line.startswith("*"): - line = line[1:] - scenario.steps[-1].break_before = True + if len(line) > 0: + while line.startswith("*"): + line = line[1:] + scenario.steps[-1].break_before = True - [identifier, mtype, x, y, z] = values_from_scen_line(line) - move = UMLScenario.RobotMove(identifier, mtype, np.array([x, y, z])) - scenario.steps[-1].moves.append(move) + [identifier, mtype, x, y, z] = values_from_scen_line(line) + move = UMLScenario.RobotMove(identifier, mtype, np.array([x, y, z])) + scenario.steps[-1].moves.append(move) return scenario - def create_in_blender(self, keyframes_per_step: int, pause_keyframes: int): + def create_in_blender(self, keyframes_per_step: int, pause_keyframes: int,preheat_by_robot:int=3): scen_coll = bpy.data.collections.new(self.name) bpy.context.scene.collection.children.link(scen_coll) @@ -364,6 +437,16 @@ def create_in_blender(self, keyframes_per_step: int, pause_keyframes: int): scen_coll.objects.link(robot) robots[robot_data.identifier] = robot + preheat = len(robots) * preheat_by_robot + keyframe += preheat + if preheat_by_robot: + for (identifier, robot) in robots.items(): + robot.keyframe_insert("delta_scale", frame=preheat_by_robot*identifier) + robot.delta_scale = (0,0,0) + robot.keyframe_insert("delta_scale", frame=1) + robot.keyframe_insert("delta_scale", frame=preheat_by_robot*(identifier-3)) + + for step in self.steps: if step.break_before: keyframe += keyframes_per_step @@ -391,9 +474,10 @@ def create_in_blender(self, keyframes_per_step: int, pause_keyframes: int): keyframe += pause_keyframes new_line_art("Line Art", np.zeros(3), 0.05, scen_coll) - new_sun_light("SunLight", np.array([0.7854,0,0.7854]), scen_coll) - new_camera("Camera", np.ones(3) * 10, scen_coll) + new_sun_light("SunLight", np.array([0.47,0,-0.47]), scen_coll) + new_camera("Camera", pos=self.camera_pos, collection=scen_coll, focus=self.camera_focus) new_ground_plane("GroundPlane", min_z - 0.5, scen_coll) + new_background_material(bpy.context.scene.world) bpy.context.scene.frame_end = keyframe + keyframes_per_step @@ -412,25 +496,34 @@ class ScenarioImportHelper(bpy.types.Operator, ImportHelper): ) steps: bpy.props.IntProperty( name="Frames per Step", - default=30, + default=24, min=1, ) freeze_steps: bpy.props.IntProperty( name="Freeze Frames", - default=5, + default=0, min=0, ) + spawn_animation: bpy.props.BoolProperty( + name="Spawn Animation", + default=False + ) def draw(self, context): layout = self.layout row = layout.row(align=True) row.prop(self, "steps") + row = layout.row(align=True) + row.prop(self, "freeze_steps") + row = layout.row(align=True) + row.prop(self, "spawn_animation") def execute(self, context): try: return self.load_scenario_data(self.filepath, keyframe_step=int(self.steps), - freeze_step=int(self.freeze_steps)) + freeze_step=int(self.freeze_steps), + spawn_animation=bool(self.spawn_animation) * 3) except Exception as e: self.report({'ERROR'}, f"Failed to read JSON: {get_exception_traceback_str(e)}") @@ -442,7 +535,7 @@ def menu_import(self, context): text="Modular Robots (.scen)" ) - def load_scenario_data(self, path, keyframe_step=10, freeze_step=5): + def load_scenario_data(self, path, keyframe_step=10, freeze_step=5, spawn_animation:int=0): scenario: UMLScenario | None = None with (open(path, 'r', encoding='utf-8') as f): @@ -457,7 +550,7 @@ def load_scenario_data(self, path, keyframe_step=10, freeze_step=5): if not scenario.name: scenario.name = pathlib.Path(path).stem - scenario.create_in_blender(keyframe_step, freeze_step) + scenario.create_in_blender(keyframe_step, freeze_step, spawn_animation) return {'FINISHED'}