Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 136 additions & 43 deletions Blender/import_modular_robots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -259,58 +327,62 @@ 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)})"

@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())
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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)}")
Expand All @@ -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):
Expand All @@ -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'}

Expand Down