commit dbe7279a8982a5351cc32aa26c14038a2f3099f1 Author: chk <79915315+ChKendel@users.noreply.github.com> Date: Thu May 28 09:08:08 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/json_to_urdf.py b/json_to_urdf.py new file mode 100644 index 0000000..77ffe4c --- /dev/null +++ b/json_to_urdf.py @@ -0,0 +1,246 @@ +# json_to_urdf.py +import json +import math +import xml.etree.ElementTree as ET +from pathlib import Path +from xml.dom import minidom + +# ============================================================ +# CONFIG +# ============================================================ + +ROBOT_JSON = "robot.json" +OUTPUT_URDF = "robot.urdf" + +# ============================================================ +# HELPERS +# ============================================================ + + +def mm_to_m(v): + return float(v) / 1000.0 + + +def vec_mm_to_m(v): + return [mm_to_m(x) for x in v] + + +def deg_to_rad(v): + return math.radians(float(v)) + + +def vec_deg_to_rad(v): + return [deg_to_rad(x) for x in v] + + +def xyz_str(v): + return f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}" + + +# ============================================================ +# LOAD JSON +# ============================================================ + +with open(ROBOT_JSON, "r", encoding="utf-8") as f: + robot_json = json.load(f) + +links = robot_json.get("links", {}) + +# ============================================================ +# ROOT +# ============================================================ + +robot_name = Path(ROBOT_JSON).stem +robot = ET.Element("robot") +robot.set("name", robot_name) + +# ============================================================ +# CREATE LINKS +# ============================================================ + +for link_name, link_info in links.items(): + + urdf_link = ET.SubElement(robot, "link") + urdf_link.set("name", link_name) + + # -------------------------------------------------------- + # VISUALS + # -------------------------------------------------------- + + models = link_info.get("model", []) + + for model in models: + + stl_file = model.get("stlFile") + if not stl_file: + continue + + visual = ET.SubElement(urdf_link, "visual") + + origin = ET.SubElement(visual, "origin") + + xyz = vec_mm_to_m(model.get("originOfModel", [0, 0, 0])) + rpy = vec_deg_to_rad(model.get("rotationOfModelDegree", [0, 0, 0])) + + origin.set("xyz", xyz_str(xyz)) + origin.set("rpy", xyz_str(rpy)) + + geometry = ET.SubElement(visual, "geometry") + + mesh = ET.SubElement(geometry, "mesh") + mesh.set("filename", stl_file) + mesh.set("scale", "0.001 0.001 0.001") + + # -------------------------------------------------------- + # SIMPLE COLLISION FROM SKELETON + # -------------------------------------------------------- + + skeleton = link_info.get("skeleton") + + if isinstance(skeleton, dict): + + p1 = skeleton.get("from", [0, 0, 0]) + p2 = skeleton.get("to", [0, 0, 0]) + + dx = p2[0] - p1[0] + dy = p2[1] - p1[1] + dz = p2[2] - p1[2] + + length_mm = math.sqrt(dx * dx + dy * dy + dz * dz) + length_m = mm_to_m(length_mm) + + radius_mm = skeleton.get("radius", 4) + radius_m = mm_to_m(radius_mm) + + cx = (p1[0] + p2[0]) * 0.5 + cy = (p1[1] + p2[1]) * 0.5 + cz = (p1[2] + p2[2]) * 0.5 + + collision = ET.SubElement(urdf_link, "collision") + + collision_origin = ET.SubElement(collision, "origin") + collision_origin.set( + "xyz", + xyz_str(vec_mm_to_m([cx, cy, cz])) + ) + + # ---------------------------------------------------- + # CYLINDER ORIENTATION + # URDF cylinder points along Z + # ---------------------------------------------------- + + roll = 0.0 + pitch = 0.0 + yaw = 0.0 + + if abs(dx) >= abs(dy) and abs(dx) >= abs(dz): + pitch = math.radians(90) + yaw = 0.0 + + elif abs(dy) >= abs(dx) and abs(dy) >= abs(dz): + pitch = math.radians(90) + yaw = math.radians(90) + + collision_origin.set( + "rpy", + xyz_str([roll, pitch, yaw]) + ) + + geometry = ET.SubElement(collision, "geometry") + + cylinder = ET.SubElement(geometry, "cylinder") + cylinder.set("radius", f"{radius_m:.6f}") + cylinder.set("length", f"{length_m:.6f}") + +# ============================================================ +# CREATE JOINTS +# ============================================================ + +for link_name, link_info in links.items(): + + parent_name = link_info.get("parent") + + if parent_name is None: + continue + + joint_info = link_info.get("jointToParent", {}) + + joint_name = joint_info.get("name", f"joint_{parent_name}_{link_name}") + joint_type = joint_info.get("type", "fixed") + + if joint_type == "linear": + urdf_joint_type = "prismatic" + elif joint_type == "revolute": + urdf_joint_type = "revolute" + else: + urdf_joint_type = "fixed" + + joint = ET.SubElement(robot, "joint") + joint.set("name", joint_name) + joint.set("type", urdf_joint_type) + + # -------------------------------------------------------- + # PARENT / CHILD + # -------------------------------------------------------- + + parent = ET.SubElement(joint, "parent") + parent.set("link", parent_name) + + child = ET.SubElement(joint, "child") + child.set("link", link_name) + + # -------------------------------------------------------- + # ORIGIN + # -------------------------------------------------------- + + origin_xyz = joint_info.get("origin", [0, 0, 0]) + origin_rpy = joint_info.get("rotation", [0, 0, 0]) + + origin = ET.SubElement(joint, "origin") + origin.set( + "xyz", + xyz_str(vec_mm_to_m(origin_xyz)) + ) + origin.set( + "rpy", + xyz_str(vec_deg_to_rad(origin_rpy)) + ) + + # -------------------------------------------------------- + # AXIS + # -------------------------------------------------------- + + axis = joint_info.get("axis", [1, 0, 0]) + + axis_tag = ET.SubElement(joint, "axis") + axis_tag.set("xyz", xyz_str(axis)) + + # -------------------------------------------------------- + # DEFAULT LIMITS + # -------------------------------------------------------- + + if urdf_joint_type in ["revolute", "prismatic"]: + + limit = ET.SubElement(joint, "limit") + + if urdf_joint_type == "revolute": + limit.set("lower", f"{-math.pi:.6f}") + limit.set("upper", f"{math.pi:.6f}") + else: + limit.set("lower", "-1.0") + limit.set("upper", "1.0") + + limit.set("effort", "100") + limit.set("velocity", "10") + +# ============================================================ +# PRETTY XML +# ============================================================ + +xml_string = ET.tostring(robot, encoding="utf-8") +pretty = minidom.parseString(xml_string).toprettyxml(indent=" ") + +with open(OUTPUT_URDF, "w", encoding="utf-8") as f: + f.write(pretty) + +print("URDF written:", OUTPUT_URDF) diff --git a/render.npz b/render.npz new file mode 100644 index 0000000..6243600 Binary files /dev/null and b/render.npz differ diff --git a/render.png b/render.png new file mode 100644 index 0000000..2d98ecc Binary files /dev/null and b/render.png differ diff --git a/render0.png b/render0.png new file mode 100644 index 0000000..e8f98ba Binary files /dev/null and b/render0.png differ diff --git a/render01a.png b/render01a.png new file mode 100644 index 0000000..37762c2 Binary files /dev/null and b/render01a.png differ diff --git a/render_robot.py b/render_robot.py new file mode 100644 index 0000000..e6129f5 --- /dev/null +++ b/render_robot.py @@ -0,0 +1,760 @@ +import bpy +import math +import mathutils +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple +from mathutils import Matrix + +# ============================================================ +# PATHS +# ============================================================ + +ROBOT_JSON_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\robot.json" +OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png" +RENDER_WIDTH = 1280 +RENDER_HEIGHT = 720 + +# ============================================================ +# DEFAULT MATERIALS +# ============================================================ + +DEFAULT_MATERIALS = { + "wood": {"baseColor": (0.72, 0.52, 0.33, 1.0), "roughness": 0.85, "metallic": 0.0}, + "plaWhite": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.45, "metallic": 0.0}, + "steel": {"baseColor": (0.72, 0.72, 0.75, 1.0), "roughness": 0.25, "metallic": 1.0}, + "powderCoatBlue": {"baseColor": (0.15, 0.25, 0.70, 1.0), "roughness": 0.55, "metallic": 0.0}, + "defaultPlastic": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.40, "metallic": 0.0}, + "skeletonRed": {"baseColor": (0.85, 0.20, 0.20, 1.0), "roughness": 0.35, "metallic": 0.0}, + "markerBlack": {"baseColor": (0.04, 0.04, 0.04, 1.0), "roughness": 0.80, "metallic": 0.0}, +} + +STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"] + +# ============================================================ +# JSON LOADING +# ============================================================ + +with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f: + robot: Dict[str, Any] = json.load(f) + +rendering_info = robot.get("renderingInfo", {}) +metric = rendering_info.get("metric", "mm") +scale_factor = 0.001 if metric == "mm" else 1.0 + +RENDER_WIDTH = int(rendering_info.get("width", 1200)) +RENDER_HEIGHT = int(rendering_info.get("height", 800)) + +def as_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + +show_skeleton = as_bool(rendering_info.get("showSkeleton", False)) +show_markers = as_bool(rendering_info.get("showMarkers", False)) + +state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS} +for source_name in ("defaultPosition", "recognized", "movements"): + source = robot.get(source_name, {}) or {} + for k in STATE_KEYS: + v = source.get(k, None) + if v is not None: + try: + state[k] = float(v) + except Exception: + pass + +links_def = robot.get("links", {}) +if not isinstance(links_def, dict): + raise ValueError("robot.json must contain a top-level 'links' object") + +# ============================================================ +# HELPERS +# ============================================================ + +def mm_to_m(value: float) -> float: + return value * scale_factor + +def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + key = value.strip().lower() + if key in state_map: + return float(state_map[key]) + try: + return float(key) + except ValueError: + return 0.0 + return 0.0 + +def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]: + if value is None: + return tuple(0.0 for _ in range(default_len)) + if isinstance(value, (int, float, str)): + return (resolve_scalar(value, state_map),) + if isinstance(value, (list, tuple)): + resolved = [resolve_scalar(v, state_map) for v in value] + if len(resolved) < default_len: + resolved.extend([0.0] * (default_len - len(resolved))) + return tuple(resolved[:default_len]) + return tuple(0.0 for _ in range(default_len)) + +def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]: + vec = list(resolve_vector(value, state_map, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + x, y, z = vec[:3] + return mm_to_m(x), mm_to_m(y), mm_to_m(z) + +def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector: + ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2]))) + return ax.normalized() if ax.length > 0 else mathutils.Vector((1.0, 0.0, 0.0)) + +def euler_deg_xyz(values: Any) -> Tuple[float, float, float]: + vec = list(resolve_vector(values, state, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + return math.radians(vec[0]), math.radians(vec[1]), math.radians(vec[2]) + +def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material: + info = rendering_info.get("materials", {}) or {} + spec = None + + if isinstance(info, dict): + spec = info.get(name) + + if isinstance(spec, dict): + base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy() + if "baseColor" in spec: + color = tuple(spec["baseColor"]) + base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4]) + if "roughness" in spec: + base["roughness"] = float(spec["roughness"]) + if "metallic" in spec: + base["metallic"] = float(spec["metallic"]) + spec = base + else: + spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]) + + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = spec["baseColor"] + bsdf.inputs["Roughness"].default_value = spec["roughness"] + bsdf.inputs["Metallic"].default_value = spec["metallic"] + return mat + +def import_stl(filepath: str) -> List[bpy.types.Object]: + path = Path(filepath).resolve() + if not path.exists(): + raise FileNotFoundError(f"STL file not found:\n{path}") + + before = set(bpy.data.objects) + bpy.ops.wm.stl_import(filepath=str(path)) + after = [obj for obj in bpy.data.objects if obj not in before] + return after + +def create_empty(name: str) -> bpy.types.Object: + empty = bpy.data.objects.new(name, None) + bpy.context.collection.objects.link(empty) + return empty + +def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object], keep_world: bool = False): + if parent is None: + return + world_matrix = child.matrix_world.copy() + child.parent = parent + if keep_world: + child.matrix_parent_inverse = parent.matrix_world.inverted() + child.matrix_world = world_matrix + else: + child.matrix_parent_inverse = Matrix.Identity(4) + +def create_material_segment(name: str, color: Tuple[float, float, float], roughness: float = 0.35) -> bpy.types.Material: + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = 0.0 + return mat + +def create_cylinder_between( + name: str, + p1_local: Tuple[float, float, float], + p2_local: Tuple[float, float, float], + radius_m: float, + parent: bpy.types.Object, + material: bpy.types.Material +) -> bpy.types.Object: + v1 = mathutils.Vector(p1_local) + v2 = mathutils.Vector(p2_local) + delta = v2 - v1 + length = delta.length + if length <= 1e-9: + length = 1e-6 + delta = mathutils.Vector((0.0, 0.0, 1e-6)) + + bpy.ops.mesh.primitive_cylinder_add(radius=radius_m, depth=length) + obj = bpy.context.active_object + obj.name = name + safe_parent(obj, parent, keep_world=False) + obj.location = (v1 + v2) * 0.5 + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(delta.normalized()) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + return obj + +def derive_default_skeleton_from_size(size_mm: List[float]) -> Dict[str, Any]: + sx, sy, sz = (float(size_mm[0]), float(size_mm[1]), float(size_mm[2])) + ax = max((abs(sx), 0), (abs(sy), 1), (abs(sz), 2), key=lambda x: x[0])[1] + + if ax == 0: + return {"from": [0, sy * 0.5, sz * 0.5], "to": [sx, sy * 0.5, sz * 0.5]} + if ax == 1: + return {"from": [sx * 0.5, 0, sz * 0.5], "to": [sx * 0.5, sy, sz * 0.5]} + return {"from": [sx * 0.5, sy * 0.5, 0], "to": [sx * 0.5, sy * 0.5, sz]} + +def resolve_stl_path(stl_file: str) -> Path: + base_dir = Path(ROBOT_JSON_FILE).parent + candidates = [ + base_dir / stl_file, + base_dir / "surfaces" / stl_file, + Path(stl_file), + ] + for c in candidates: + p = c.resolve() + if p.exists(): + return p + raise FileNotFoundError( + "STL file not found in any expected location:\n" + + "\n".join(str(c.resolve()) for c in candidates) + ) + +# ============================================================ +# SCENE RESET +# ============================================================ + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + +scene = bpy.context.scene +scene.unit_settings.system = "METRIC" +scene.unit_settings.length_unit = "MILLIMETERS" +scene.unit_settings.scale_length = scale_factor + +# ============================================================ +# WORLD / RENDER SETTINGS +# ============================================================ + +world = scene.world or bpy.data.worlds.new("World") +scene.world = world +world.use_nodes = True +bg = world.node_tree.nodes["Background"] +bg.inputs[0].default_value = tuple(rendering_info.get("backgroundColor", [0.70, 0.85, 1.0])) + (1.0,) +bg.inputs[1].default_value = float(rendering_info.get("backgroundStrength", 0.20)) + +scene.render.engine = "CYCLES" +scene.view_settings.exposure = float(rendering_info.get("exposure", -1.5)) +scene.cycles.samples = 16 +scene.cycles.preview_samples = 32 +scene.render.resolution_x = RENDER_WIDTH +scene.render.resolution_y = RENDER_HEIGHT +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.render.filepath = OUTPUT_FILE +scene.render.film_transparent = False + +# ============================================================ +# FLOOR +# ============================================================ + +bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0))) +floor = bpy.context.active_object +checker_mat = bpy.data.materials.new(name="Checkerboard") +checker_mat.use_nodes = True +nodes = checker_mat.node_tree.nodes +links = checker_mat.node_tree.links +nodes.clear() + +output_node = nodes.new(type="ShaderNodeOutputMaterial") +bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled") +checker_node = nodes.new(type="ShaderNodeTexChecker") +mapping_node = nodes.new(type="ShaderNodeMapping") +texcoord_node = nodes.new(type="ShaderNodeTexCoord") + +checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0) +checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0) +mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0) + +links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"]) +links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"]) +links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"]) +links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"]) +floor.data.materials.append(checker_mat) + +# ============================================================ +# CAMERA +# ============================================================ + +cam_data = bpy.data.cameras.new("Camera") +cam_obj = bpy.data.objects.new("Camera", cam_data) +bpy.context.collection.objects.link(cam_obj) + +cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-400, -700, 300]), state) +cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state) +cam_obj.location = cam_pos +cam_data.lens = 50 +cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos) +if cam_vec.length == 0: + cam_vec = mathutils.Vector((1, 0, 0)) +cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler() +scene.camera = cam_obj + +# ============================================================ +# EXPORT CAMERA CALIBRATION (.npz) +# ============================================================ + +import numpy as np + +CALIBRATION_OUTPUT = str( + Path(OUTPUT_FILE).with_suffix(".npz") +) + +render = scene.render +cam = cam_obj.data + +width_px = render.resolution_x +height_px = render.resolution_y + +scale = render.resolution_percentage / 100.0 + +width_px *= scale +height_px *= scale + +sensor_width_mm = cam.sensor_width +sensor_height_mm = cam.sensor_height + +focal_mm = cam.lens + +# focal length in pixels +fx = (width_px * focal_mm) / sensor_width_mm +fy = (height_px * focal_mm) / sensor_height_mm + +cx = width_px / 2.0 +cy = height_px / 2.0 + +camera_matrix = np.array([ + [fx, 0, cx], + [0, fy, cy], + [0, 0, 1] +], dtype=np.float32) + +# ideal synthetic camera +dist_coeffs = np.zeros((5, 1), dtype=np.float32) + +np.savez( + CALIBRATION_OUTPUT, + + # common names + camera_matrix=camera_matrix, + dist_coeffs=dist_coeffs, + + # compatibility aliases + K=camera_matrix, + mtx=camera_matrix, + dist=dist_coeffs +) + +print("Saved camera calibration:", CALIBRATION_OUTPUT) +print(camera_matrix) + +# ============================================================ +# LIGHTS +# ============================================================ + +sun_data = bpy.data.lights.new(name="Sun", type="SUN") +sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data) +bpy.context.collection.objects.link(sun_obj) +sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-500, -500, 500]), state) +light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state) +sun_obj.location = sun_pos +light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos) +if light_vec.length == 0: + light_vec = mathutils.Vector((1, 0, -1)) +sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler() +sun_data.energy = float(rendering_info.get("sunEnergy", 0.35)) + +area_data = bpy.data.lights.new(name="AreaLight", type="AREA") +area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data) +bpy.context.collection.objects.link(area_obj) +area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500)) +area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20)) +area_data.energy = float(rendering_info.get("areaEnergy", 120)) +area_data.size = 2.0 + +# ============================================================ +# ROBOT HIERARCHY +# ============================================================ + +link_frames: Dict[str, bpy.types.Object] = {} +for link_name in links_def.keys(): + link_frames[link_name] = create_empty(f"{link_name}_frame") + +for link_name, link_info in links_def.items(): + parent_name = link_info.get("parent") + parent_frame = link_frames.get(parent_name) if parent_name else None + size_mm = link_info.get("size", [100, 100, 100]) + + # mount: static position/rotation in parent coordinates + mount = create_empty(f"{link_name}_mount") + safe_parent(mount, parent_frame, keep_world=False) + mount.location = resolve_vec3_m(link_info.get("mountPosition", [0, 0, 0]), state) + mount.rotation_euler = euler_deg_xyz(link_info.get("mountRotation", [0, 0, 0])) + + # joint: sits inside the mount, defines pivot/orientation + joint_info = link_info.get("jointToParent", {}) or {} + joint = create_empty(f"{link_name}_joint") + safe_parent(joint, mount, keep_world=False) + joint.location = resolve_vec3_m(joint_info.get("origin", [0, 0, 0]), state) + joint.rotation_euler = euler_deg_xyz(joint_info.get("rotation", [0, 0, 0])) + + # motion: only this node gets the commanded position/angle + motion = create_empty(f"{link_name}_motion") + safe_parent(motion, joint, keep_world=False) + + joint_type = str(joint_info.get("type", "fixed")).lower() + control_var = str(joint_info.get("variable", joint_info.get("control", ""))).lower() + axis = joint_info.get("axis", [1, 0, 0]) + + if joint_type == "linear": + value_mm = state.get(control_var, 0.0) if control_var else 0.0 + motion.location = normalize_axis(axis) * mm_to_m(value_mm) + elif joint_type == "revolute": + value_deg = state.get(control_var, 0.0) if control_var else 0.0 + motion.rotation_mode = "QUATERNION" + motion.rotation_quaternion = mathutils.Quaternion(normalize_axis(axis), math.radians(value_deg)) + + # link frame: everything belonging to this link follows motion + link_frame = link_frames[link_name] + safe_parent(link_frame, motion, keep_world=False) + + # -------------------------------------------------------- + # VISUAL MESHES + # -------------------------------------------------------- + + visual_root = create_empty(f"{link_name}_visual") + safe_parent(visual_root, link_frame, keep_world=False) + + model_list = link_info.get("model", []) + if not isinstance(model_list, list): + model_list = [] + + for idx, model_def in enumerate(model_list): + stl_file = model_def.get("stlFile") + if not stl_file: + continue + + stl_path = resolve_stl_path(stl_file) + imported = import_stl(str(stl_path)) + + model_node = create_empty(f"{link_name}_model_{idx}") + safe_parent(model_node, visual_root, keep_world=False) + model_node.location = resolve_vec3_m(model_def.get("originOfModel", [0, 0, 0]), state) + model_node.rotation_euler = euler_deg_xyz(model_def.get("rotationOfModelDegree", [0, 0, 0])) + + material_name = model_def.get("material", "defaultPlastic") + material = create_or_get_material(material_name) + + for obj in imported: + if obj.type != "MESH": + continue + safe_parent(obj, model_node, keep_world=True) + obj.scale = (scale_factor, scale_factor, scale_factor) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + + # -------------------------------------------------------- + # SKELETON DEBUG + # -------------------------------------------------------- + + if show_skeleton: + skeleton_spec = link_info.get("skeleton") + if not isinstance(skeleton_spec, dict): + skeleton_spec = derive_default_skeleton_from_size(size_mm) + + p1_mm = skeleton_spec.get("from", [0, 0, 0]) + p2_mm = skeleton_spec.get("to", [0, 0, 0]) + p1 = resolve_vec3_m(p1_mm, state) + p2 = resolve_vec3_m(p2_mm, state) + + sk_radius_mm = float( + skeleton_spec.get( + "radius", + rendering_info.get("skeletonDefaults", {}).get("radius", 4) + ) + ) + sk_color = skeleton_spec.get( + "color", + rendering_info.get("skeletonDefaults", {}).get("color", [0.85, 0.20, 0.20]) + ) + sk_mat = create_material_segment(f"{link_name}_skeletonMat", tuple(sk_color[:3])) + + create_cylinder_between( + f"{link_name}_skeleton", + p1, + p2, + mm_to_m(sk_radius_mm), + link_frame, + sk_mat + ) + + # -------------------------------------------------------- + # MARKERS + # -------------------------------------------------------- + + import cv2 + import numpy as np + import tempfile + + def create_aruco_material(marker_id: int, marker_name: str): + dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250) + + img = np.zeros((256, 256), dtype=np.uint8) + cv2.aruco.generateImageMarker(dictionary, marker_id, 256, img, 1) + + tmpfile = Path(tempfile.gettempdir()) / f"aruco_{marker_id}.png" + cv2.imwrite(str(tmpfile), img) + + image = bpy.data.images.load(str(tmpfile)) + + mat = bpy.data.materials.new(name=f"{marker_name}_mat") + mat.use_nodes = True + + nodes = mat.node_tree.nodes + links = mat.node_tree.links + nodes.clear() + + out = nodes.new(type="ShaderNodeOutputMaterial") + bsdf = nodes.new(type="ShaderNodeBsdfPrincipled") + tex = nodes.new(type="ShaderNodeTexImage") + + tex.image = image + + links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + + return mat + + + def normal_to_quaternion(normal_vec): + normal = mathutils.Vector(normal_vec).normalized() + + default_normal = mathutils.Vector((0, 0, 1)) + + return default_normal.rotation_difference(normal) + + + if show_markers: + + marker_defaults = rendering_info.get("markerDefaults", {}) or {} + + for m in link_info.get("markers", []): + + if not isinstance(m, dict): + continue + + marker_id = int(m.get("id", 0)) + + marker_name = m.get( + "name", + f"{link_name}_marker_{marker_id}" + ) + + marker_size_mm = float( + m.get( + "size", + marker_defaults.get("size", 25) + ) + ) + + marker_pos = resolve_vec3_m( + m.get("position", [0, 0, 0]), + state + ) + + normal = m.get("normal", [0, 0, 1]) + marker_spin_deg = float(m.get("spin", 0.0)) + + bpy.ops.mesh.primitive_plane_add( + size=mm_to_m(marker_size_mm) + ) + + marker_obj = bpy.context.active_object + marker_obj.name = marker_name + + safe_parent(marker_obj, link_frame, keep_world=False) + + marker_obj.rotation_mode = "QUATERNION" + base_quat = normal_to_quaternion(normal) + + spin_quat = mathutils.Quaternion( + mathutils.Vector((0, 0, 1)), + math.radians(marker_spin_deg) + ) + + marker_obj.rotation_quaternion = ( + base_quat @ spin_quat + ) + + + # Marker-Normale im Welt-/Linkraum + normal_world = ( + marker_obj.matrix_world.to_quaternion() + @ mathutils.Vector((0, 0, 1)) + ) + normal_world.normalize() + + # minimal vorziehen gegen Z-Fighting + marker_obj.location = ( + mathutils.Vector(marker_pos) + + normal_world * mm_to_m(0.05) + ) + + marker_mat = create_aruco_material( + marker_id, + marker_name + ) + + if len(marker_obj.data.materials) == 0: + marker_obj.data.materials.append(marker_mat) + else: + marker_obj.data.materials[0] = marker_mat + # -------------------------------------------------------- + # -------------------------------------------------------- + # BACKING PLATE (white PLA behind marker) + # -------------------------------------------------------- + + plate_side_mm = 28.0 + plate_thickness_mm = 1.0 + gap_mm = 0.2 # kleiner Abstand gegen Z-Fighting + + bpy.ops.mesh.primitive_cube_add(size=1.0) + plate_obj = bpy.context.active_object + plate_obj.name = marker_name + "_plate" + + safe_parent(plate_obj, link_frame, keep_world=False) + + # gleiche Orientierung wie der Marker + plate_obj.rotation_mode = "QUATERNION" + plate_obj.rotation_quaternion = marker_obj.rotation_quaternion.copy() + + # Normale des Markers im Welt-/Linkraum + normal_world = marker_obj.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, 1)) + normal_world.normalize() + + + + # Platte liegt "hinter" dem Marker + plate_obj.location = ( + marker_obj.location + - normal_world * mm_to_m((plate_thickness_mm * 0.5) + gap_mm) + ) + + # exakte Abmessungen: 26 x 26 x 1 mm + plate_obj.dimensions = ( + mm_to_m(plate_side_mm), + mm_to_m(plate_side_mm), + mm_to_m(plate_thickness_mm) + ) + + pla_mat = create_or_get_material("plaWhite") + if len(plate_obj.data.materials) == 0: + plate_obj.data.materials.append(pla_mat) + else: + plate_obj.data.materials[0] = pla_mat + + +# ============================================================ +# DEBUG WORLD AXES +# ============================================================ + +def create_axis_arrow( + name, + direction, + color, + length_mm=200, + radius_mm=2, + cone_radius_mm=5, + cone_length_mm=20 +): + length = mm_to_m(length_mm) + radius = mm_to_m(radius_mm) + cone_radius = mm_to_m(cone_radius_mm) + cone_length = mm_to_m(cone_length_mm) + + dir_vec = mathutils.Vector(direction).normalized() + + bpy.ops.mesh.primitive_cylinder_add( + radius=radius, + depth=length - cone_length + ) + + cyl = bpy.context.active_object + cyl.name = f"{name}_shaft" + cyl.rotation_mode = 'QUATERNION' + cyl.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec) + cyl.location = dir_vec * ((length - cone_length) * 0.5) + + bpy.ops.mesh.primitive_cone_add( + radius1=cone_radius, + depth=cone_length + ) + + cone = bpy.context.active_object + cone.name = f"{name}_tip" + cone.rotation_mode = 'QUATERNION' + cone.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec) + cone.location = dir_vec * (length - cone_length * 0.5) + + mat = bpy.data.materials.new(name=f"{name}_material") + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) + bsdf.inputs["Roughness"].default_value = 0.3 + bsdf.inputs["Metallic"].default_value = 0.0 + + cyl.data.materials.append(mat) + cone.data.materials.append(mat) + +create_axis_arrow("AxisX", (1, 0, 0), (1, 0, 0)) +create_axis_arrow("AxisY", (0, 1, 0), (0, 1, 0)) +create_axis_arrow("AxisZ", (0, 0, 1), (0, 0, 1)) + +# ============================================================ +# RENDER +# ============================================================ + +bpy.ops.render.render(write_still=True) +print("Finished rendering:", OUTPUT_FILE) \ No newline at end of file diff --git a/render_robot_v00.py b/render_robot_v00.py new file mode 100644 index 0000000..94eb9d1 --- /dev/null +++ b/render_robot_v00.py @@ -0,0 +1,365 @@ +import bpy +import mathutils + +# ============================================================ +# CONFIG +# ============================================================ + +robot = { + "vision_config":{ + "MarkerType":"DICT_4X4_250", + "MarkerSize":0.025 + }, + "renderingInfo":{ + "cameraPosition":[-400, -700, 300], + "cameraTarget":["x", 0, 0], + "cameraUpVector":[0, 0, 1], + "lightPosition":[-500, -500, 500], + "lightTarget":[0, 0, 0], + "lightUpVector":[0, 0, 1], + "metric": "mm", + "materials":{ + "wood":{ + "baseColor":[0.72,0.52,0.33], + "roughness":0.8, + "metallic":0.0 + }, + + "plaWhite":{ + "baseColor":[0.95,0.95,0.95], + "roughness":0.45, + "metallic":0.0 + }, + + "steel":{ + "baseColor":[0.7,0.7,0.72], + "roughness":0.25, + "metallic":1.0 + }, + + "powderCoatBlue":{ + "baseColor":[0.15,0.25,0.7], + "roughness":0.55, + "metallic":0.0 + }, + + "marbleStone":{ + "baseColor":[0.85,0.85,0.87], + "roughness":0.9, + "metallic":0.0 + } + } + }, + "recognized":{"x":None, "y":None, "z": None, "a":None, "b":None, "c":None, "e": None}, + "movements":{"x":None, "y":None, "z": None, "a":None, "b":None, "c":None, "e": None}, + "elements":{ + "Board":{"type":"static", "parent":None, "size":[1000, 200, 25], + "model":[{ + "stlFile":"Board.stl", + "originOfModel":[0,0,0], + "rotationOfModelDegree":[0,0,90], + "material":"wood"}, + { + "stlFile":"BoardRail.stl", + "originOfModel":[0,0,0], + "rotationOfModelDegree":[0,0,90], + "material":"steel"} + ]}, + "Base":{"type":"rigid", "parent":"Board", "size":[150, 200, 150], "rotationInParentCoordinates":[0, 0, 0], + "model":[{ "stlFile":"Base.stl" + }], + "jointToParent":{"name":"Slider", "type":"linear", "axis":[1,0,0], "origin":[0, 0, 0], + "rotation":[0, 0, 0], + "variable":"x"}}, + "Arm1":{"type":"rigid", "parent":"Base", "size":[70, 250, 70], + "model":[{ "stlFile":"Holm.stl", + "originOfModel":[0,0,0], + "rotationOfModelDegree":[0,0,0], + "material":"powderCoatBlue" + }] + } + } +} + + + +LOCAL_PATH = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\\" +MODEL_FILE = LOCAL_PATH + r"surfaces\BoardRail.stl" + +OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png" + +RENDER_WIDTH = 2000 +RENDER_HEIGHT = 1000 + +# ============================================================ +# CLEAN SCENE +# ============================================================ + +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete(use_global=False) + +# ============================================================ +# UNITS +# ============================================================ + +scene = bpy.context.scene + +scene.unit_settings.system = 'METRIC' +scene.unit_settings.length_unit = 'MILLIMETERS' + +metric = robot["renderingInfo"]["metric"] + +# IMPORTANT: +# Blender internally uses meters. +# Your STL is already in millimeters. +# We therefore scale mm -> meters. +scale_factor = 0.001 if metric == "mm" else 1.0 + +# ============================================================ +# IMPORT STL +# ============================================================ + +def import_stl(filepath): + try: + bpy.ops.wm.stl_import(filepath=filepath) + except: + bpy.ops.import_mesh.stl(filepath=filepath) + +import_stl(MODEL_FILE) + +imported_objects = bpy.context.selected_objects + +# Apply scale +for obj in imported_objects: + obj.scale = (scale_factor, scale_factor, scale_factor) + +# ============================================================ +# CENTER OBJECT +# ============================================================ + +bpy.ops.object.select_all(action='DESELECT') + +for obj in imported_objects: + obj.select_set(True) + +bpy.context.view_layer.objects.active = imported_objects[0] + +# Move origin to geometry center +bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') + +# Move object to world center +for obj in imported_objects: + obj.location = (0, 0, 0) + +# ============================================================ +# WHITE PLASTIC MATERIAL +# ============================================================ + +mat = bpy.data.materials.new(name="WhitePlastic") +mat.use_nodes = True + +bsdf = mat.node_tree.nodes["Principled BSDF"] + +bsdf.inputs["Base Color"].default_value = ( + 0.95, 0.95, 0.95, 1.0 +) + +bsdf.inputs["Roughness"].default_value = 0.4 +bsdf.inputs["Metallic"].default_value = 0.0 + +for obj in imported_objects: + if obj.type == 'MESH': + + if len(obj.data.materials) == 0: + obj.data.materials.append(mat) + else: + obj.data.materials[0] = mat + +# ============================================================ +# CAMERA +# ============================================================ + +cam_data = bpy.data.cameras.new("Camera") +cam_obj = bpy.data.objects.new("Camera", cam_data) + +bpy.context.collection.objects.link(cam_obj) + +cam_pos = robot["renderingInfo"]["cameraPosition"] +cam_target = robot["renderingInfo"]["cameraTarget"] + +# Convert mm -> meters +cam_obj.location = ( + cam_pos[0] * scale_factor, + cam_pos[1] * scale_factor, + cam_pos[2] * scale_factor +) + +target = mathutils.Vector(( + cam_target[0] * scale_factor, + cam_target[1] * scale_factor, + cam_target[2] * scale_factor +)) + +direction = target - cam_obj.location + +cam_obj.rotation_euler = ( + direction.to_track_quat('-Z', 'Y').to_euler() +) + +cam_data.lens = 50 + +scene.camera = cam_obj + +# ============================================================ +# SUN LIGHT +# ============================================================ + +sun_data = bpy.data.lights.new(name="Sun", type='SUN') +sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data) + +bpy.context.collection.objects.link(sun_obj) + +light_pos = robot["renderingInfo"]["lightPosition"] +light_target = robot["renderingInfo"]["lightTarget"] + +sun_obj.location = ( + light_pos[0] * scale_factor, + light_pos[1] * scale_factor, + light_pos[2] * scale_factor +) + +light_target_vec = mathutils.Vector(( + light_target[0] * scale_factor, + light_target[1] * scale_factor, + light_target[2] * scale_factor +)) + +light_direction = light_target_vec - sun_obj.location + +sun_obj.rotation_euler = ( + light_direction.to_track_quat('-Z', 'Y').to_euler() +) + +sun_data.energy = 3.0 + +# ============================================================ +# ADDITIONAL AREA LIGHT +# ============================================================ + +area_data = bpy.data.lights.new(name="AreaLight", type='AREA') +area_obj = bpy.data.objects.new( + name="AreaLight", + object_data=area_data +) + +bpy.context.collection.objects.link(area_obj) + +area_obj.location = (0, -1.2, 15) + +area_data.energy = 5000 +area_data.size = 2.0 + +# ============================================================ +# CHECKERBOARD FLOOR +# ============================================================ + +# 2m x 2m floor +bpy.ops.mesh.primitive_plane_add(size=2, location=(0, 0, -27)) + +floor = bpy.context.active_object + +# Create checker material +checker_mat = bpy.data.materials.new(name="Checkerboard") +checker_mat.use_nodes = True + +nodes = checker_mat.node_tree.nodes +links = checker_mat.node_tree.links + +nodes.clear() + +output_node = nodes.new(type='ShaderNodeOutputMaterial') +bsdf_node = nodes.new(type='ShaderNodeBsdfPrincipled') +checker_node = nodes.new(type='ShaderNodeTexChecker') +mapping_node = nodes.new(type='ShaderNodeMapping') +texcoord_node = nodes.new(type='ShaderNodeTexCoord') + +# Checker colors +checker_node.inputs["Color1"].default_value = ( + 0.8, 0.8, 0.8, 1.0 +) + +checker_node.inputs["Color2"].default_value = ( + 0.2, 0.2, 0.2, 1.0 +) + +# 100mm tiles +# floor is 2m -> 20 tiles +mapping_node.inputs["Scale"].default_value = ( + 20.0, 20.0, 20.0 +) + +links.new( + texcoord_node.outputs["UV"], + mapping_node.inputs["Vector"] +) + +links.new( + mapping_node.outputs["Vector"], + checker_node.inputs["Vector"] +) + +links.new( + checker_node.outputs["Color"], + bsdf_node.inputs["Base Color"] +) + +links.new( + bsdf_node.outputs["BSDF"], + output_node.inputs["Surface"] +) + +floor.data.materials.append(checker_mat) + +# ============================================================ +# SKY BACKGROUND +# ============================================================ + +world = scene.world +world.use_nodes = True + +bg = world.node_tree.nodes["Background"] + +# Light blue sky +bg.inputs[0].default_value = ( + 0.70, 0.85, 1.0, 1.0 +) + +bg.inputs[1].default_value = 0.8 + +# ============================================================ +# RENDER SETTINGS +# ============================================================ + +scene.render.engine = 'CYCLES' + +scene.cycles.samples = 128 + +scene.render.resolution_x = RENDER_WIDTH +scene.render.resolution_y = RENDER_HEIGHT +scene.render.resolution_percentage = 100 + +scene.render.image_settings.file_format = 'PNG' + +scene.render.filepath = OUTPUT_FILE + +# Slightly nicer shadows +scene.cycles.preview_samples = 32 + +# ============================================================ +# RENDER +# ============================================================ + +bpy.ops.render.render(write_still=True) + +print("Finished rendering:") +print(OUTPUT_FILE) \ No newline at end of file diff --git a/render_robot_v01a.py b/render_robot_v01a.py new file mode 100644 index 0000000..b43f297 --- /dev/null +++ b/render_robot_v01a.py @@ -0,0 +1,580 @@ +import bpy +import math +import mathutils +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + +# ============================================================ +# PATHS +# ============================================================ + +ROBOT_JSON_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\robot.json" +OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png" +RENDER_WIDTH = 1200 +RENDER_HEIGHT = 800 + +# ============================================================ +# FALLBACK DEFAULT MATERIALS (placeholders) +# ============================================================ + +DEFAULT_MATERIALS = { + "wood": { + "baseColor": (0.72, 0.52, 0.33, 1.0), + "roughness": 0.85, + "metallic": 0.0, + }, + "plaWhite": { + "baseColor": (0.95, 0.95, 0.95, 1.0), + "roughness": 0.45, + "metallic": 0.0, + }, + "steel": { + "baseColor": (0.72, 0.72, 0.75, 1.0), + "roughness": 0.25, + "metallic": 1.0, + }, + "powderCoatBlue": { + "baseColor": (0.15, 0.25, 0.70, 1.0), + "roughness": 0.55, + "metallic": 0.0, + }, + "marbleStone": { + "baseColor": (0.85, 0.85, 0.87, 1.0), + "roughness": 0.95, + "metallic": 0.0, + }, + "defaultPlastic": { + "baseColor": (0.95, 0.95, 0.95, 1.0), + "roughness": 0.40, + "metallic": 0.0, + }, +} + +STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"] + +# ============================================================ +# JSON LOADING +# ============================================================ + +robot: Dict[str, Any] = {} +if Path(ROBOT_JSON_FILE).exists(): + with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f: + robot = json.load(f) +else: + # Minimal fallback so the script can still run during development. + robot = { + "renderingInfo": { + "cameraPosition": [-500, -1100, 900], + "cameraTarget": [0, 0, 200], + "cameraUpVector": [0, 0, 1], + "lightPosition": [-1000, -1000, 2000], + "lightTarget": [0, 0, 0], + "lightUpVector": [0, 0, 1], + "metric": "mm", + "materials": {}, + }, + "defaultPosition": {k: 0 for k in STATE_KEYS}, + "recognized": {k: None for k in STATE_KEYS}, + "movements": {k: None for k in STATE_KEYS}, + "links": {}, + } + +rendering_info = robot.get("renderingInfo", {}) +metric = rendering_info.get("metric", "mm") +scale_factor = 0.001 if metric == "mm" else 1.0 + +# Merge current state from multiple places. +# Priority: movements -> recognized -> defaultPosition -> 0 +state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS} +for source_name in ("defaultPosition", "recognized", "movements"): + source = robot.get(source_name, {}) or {} + for k in STATE_KEYS: + v = source.get(k, None) + if v is not None: + state[k] = float(v) + +# ============================================================ +# HELPERS +# ============================================================ + +def mm_to_m(value: float) -> float: + return value * scale_factor + + +def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float: + """Resolve numbers or symbolic placeholders like 'x'/'a'.""" + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + key = value.strip().lower() + if key in state_map: + return float(state_map[key]) + try: + return float(key) + except ValueError: + return 0.0 + return 0.0 + + +def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]: + if value is None: + return tuple(0.0 for _ in range(default_len)) + if isinstance(value, (int, float, str)): + return (resolve_scalar(value, state_map),) + if isinstance(value, (list, tuple)): + resolved = [resolve_scalar(v, state_map) for v in value] + if len(resolved) < default_len: + resolved.extend([0.0] * (default_len - len(resolved))) + return tuple(resolved[:default_len]) + return tuple(0.0 for _ in range(default_len)) + + +def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]: + x, y, z = resolve_vector(value, state_map, default_len=3) + return mm_to_m(x), mm_to_m(y), mm_to_m(z) + + +def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector: + ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2]))) + if ax.length == 0: + return mathutils.Vector((1.0, 0.0, 0.0)) + return ax.normalized() + + +def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material: + if name in bpy.data.materials: + return bpy.data.materials[name] + + info = (robot.get("renderingInfo", {}) or {}).get("materials", {}) or {} + spec = None + + # Support both dict-style and old list-style material definitions. + if isinstance(info, dict): + spec = info.get(name) + elif isinstance(info, list): + for entry in info: + if isinstance(entry, dict) and name in entry: + spec = entry[name] + break + + if not isinstance(spec, dict): + spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]) + else: + # Accept partial specs from JSON. + base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy() + if "baseColor" in spec: + color = tuple(spec["baseColor"]) + base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4]) + if "roughness" in spec: + base["roughness"] = float(spec["roughness"]) + if "metallic" in spec: + base["metallic"] = float(spec["metallic"]) + spec = base + + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = spec["baseColor"] + bsdf.inputs["Roughness"].default_value = spec["roughness"] + bsdf.inputs["Metallic"].default_value = spec["metallic"] + return mat + +def import_stl(filepath: str): + + filepath = str(Path(filepath).resolve()) + + if not Path(filepath).exists(): + raise FileNotFoundError( + f"STL file not found:\\n{filepath}" + ) + + before = set(bpy.data.objects) + + bpy.ops.wm.stl_import(filepath=filepath) + + after = [ + obj for obj in bpy.data.objects + if obj not in before + ] + + return after + + +def link_object(obj: bpy.types.Object): + if obj.name not in bpy.context.collection.objects: + bpy.context.collection.objects.link(obj) + + +def create_empty(name: str, location=(0, 0, 0), rotation=(0, 0, 0)) -> bpy.types.Object: + empty = bpy.data.objects.new(name, None) + bpy.context.collection.objects.link(empty) + empty.location = location + empty.rotation_euler = rotation + return empty + + +def euler_deg_xyz(values: Any) -> Tuple[float, float, float]: + x, y, z = resolve_vector(values, state, default_len=3) + return math.radians(x), math.radians(y), math.radians(z) + + +def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object]): + if parent is not None: + child.parent = parent + # Keep current world transform visually stable after parenting. + child.matrix_parent_inverse = parent.matrix_world.inverted() + + +# ============================================================ +# CLEAN SCENE +# ============================================================ + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + +# ============================================================ +# UNITS / WORLD +# ============================================================ + +scene = bpy.context.scene +scene.unit_settings.system = "METRIC" +scene.unit_settings.length_unit = "MILLIMETERS" +scene.unit_settings.scale_length = scale_factor + +world = scene.world +if world is None: + world = bpy.data.worlds.new("World") + scene.world = world +world.use_nodes = True +bg = world.node_tree.nodes["Background"] +bg.inputs[0].default_value = (0.70, 0.85, 1.0, 1.0) # light blue sky +bg.inputs[1].default_value = 0.2 + +# ============================================================ +# RENDER SETTINGS +# ============================================================ + +scene.render.engine = "CYCLES" +scene.view_settings.exposure = -1.5 +scene.cycles.samples = 64 +scene.cycles.preview_samples = 32 +scene.render.resolution_x = RENDER_WIDTH +scene.render.resolution_y = RENDER_HEIGHT +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.render.filepath = OUTPUT_FILE +scene.render.film_transparent = False + +# ============================================================ +# FLOOR / CHECKERBOARD +# ============================================================ + +# 2m x 2m floor, centered at origin. +bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0))) +floor = bpy.context.active_object + +checker_mat = bpy.data.materials.new(name="Checkerboard") +checker_mat.use_nodes = True +nodes = checker_mat.node_tree.nodes +links = checker_mat.node_tree.links +nodes.clear() + +output_node = nodes.new(type="ShaderNodeOutputMaterial") +bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled") +checker_node = nodes.new(type="ShaderNodeTexChecker") +mapping_node = nodes.new(type="ShaderNodeMapping") +texcoord_node = nodes.new(type="ShaderNodeTexCoord") + +checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0) +checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0) + +# 100mm checker squares across a 2m x 2m floor => 20 tiles each direction. +mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0) + +links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"]) +links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"]) +links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"]) +links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"]) +floor.data.materials.append(checker_mat) + +# ============================================================ +# CAMERA +# ============================================================ + +cam_data = bpy.data.cameras.new("Camera") +cam_obj = bpy.data.objects.new("Camera", cam_data) +bpy.context.collection.objects.link(cam_obj) + +cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-500, -1100, 900]), state) +cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state) + +cam_obj.location = cam_pos +cam_data.lens = 50 + +cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos) +if cam_vec.length == 0: + cam_vec = mathutils.Vector((1, 0, 0)) +cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler() +scene.camera = cam_obj + +# ============================================================ +# LIGHTS +# ============================================================ + +sun_data = bpy.data.lights.new(name="Sun", type="SUN") +sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data) +bpy.context.collection.objects.link(sun_obj) + +sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-1000, -1000, 2000]), state) +light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state) +sun_obj.location = sun_pos +light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos) +if light_vec.length == 0: + light_vec = mathutils.Vector((1, 0, -1)) +sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler() +sun_data.energy = 1.0 + +area_data = bpy.data.lights.new(name="AreaLight", type="AREA") +area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data) +bpy.context.collection.objects.link(area_obj) +area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500)) +area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20)) +area_data.energy = 300 +area_data.size = 2.0 + +# ============================================================ +# ROBOT BUILDING +# ============================================================ + +links_def = robot.get("links") +if links_def is None: + # Backward compatibility with older name. + links_def = robot.get("ElementInfos", {}) + +created_nodes: Dict[str, bpy.types.Object] = {} + +# Create all link containers first. +for link_name in links_def.keys(): + created_nodes[link_name] = create_empty(f"{link_name}_link") + +# Parent/position link containers. +for link_name, link_info in links_def.items(): + parent_name = link_info.get("parent") + parent_obj = created_nodes.get(parent_name) if parent_name else None + + link_obj = created_nodes[link_name] + safe_parent(link_obj, parent_obj) + + # Static mounting transform relative to parent. + # Keep the extra info, but rename it to mountRotation in your JSON. + mount_pos = link_info.get("mountPosition", link_info.get("originInParentCoordinates", [0, 0, 0])) + mount_rot = link_info.get("mountRotation", link_info.get("rotationInParentCoordinates", [0, 0, 0])) + + link_obj.location = resolve_vec3_m(mount_pos, state) + link_obj.rotation_euler = euler_deg_xyz(mount_rot) + + # Joint transform (child-owned). + joint = link_info.get("jointToParent") or link_info.get("joint") + if isinstance(joint, dict): + joint_origin = joint.get("origin", [0, 0, 0]) + joint_rot = joint.get("rotation", [0, 0, 0]) + joint_type = joint.get("type", "fixed") + control_var = str(joint.get("variable", joint.get("control", ""))).lower() + axis = joint.get("axis", [1, 0, 0]) + + joint_offset = create_empty(f"{link_name}_joint") + safe_parent(joint_offset, link_obj) + joint_offset.location = resolve_vec3_m(joint_origin, state) + joint_offset.rotation_euler = euler_deg_xyz(joint_rot) + + # Motion node under the joint offset. + motion_node = create_empty(f"{link_name}_motion") + safe_parent(motion_node, joint_offset) + + if joint_type == "linear": + # Linear joint moves along its local axis by the control value. + move_val_mm = state.get(control_var, 0.0) if control_var else 0.0 + axis_v = normalize_axis(axis) + motion_node.location = axis_v * mm_to_m(move_val_mm) + elif joint_type == "revolute": + # Revolute joint rotates around its local axis by the control value. + angle_deg = state.get(control_var, 0.0) if control_var else 0.0 + axis_v = normalize_axis(axis) + # Convert axis-angle to Euler in local space by using rotation_difference. + quat = mathutils.Quaternion(axis_v, math.radians(angle_deg)) + motion_node.rotation_euler = quat.to_euler() + else: + # fixed / unknown => no motion + pass + + # The link container sits under the motion node. + safe_parent(link_obj, motion_node) + +# Import and attach all meshes for every link. +for link_name, link_info in links_def.items(): + link_obj = created_nodes[link_name] + model_list = link_info.get("model", []) + if not isinstance(model_list, list): + model_list = [] + + # Optional single-file shorthand. + if "stlFile" in link_info: + model_list = model_list + [{"stlFile": link_info["stlFile"]}] + + for idx, model_def in enumerate(model_list): + stl_file = model_def.get("stlFile") + if not stl_file: + continue + + base_dir = Path(ROBOT_JSON_FILE).parent if Path(ROBOT_JSON_FILE).exists() else Path.cwd() + stl_path = (base_dir / stl_file).resolve() + if not stl_path.exists(): + # Try the file as given. + stl_path = Path(stl_file).resolve() + + imported = import_stl(str(stl_path)) + + # Create a mesh container for each imported STL so a link can have many surfaces. + mesh_container = create_empty(f"{link_name}_mesh_{idx}") + safe_parent(mesh_container, link_obj) + + origin_of_model = model_def.get("originOfModel", [0, 0, 0]) + rot_of_model = model_def.get("rotationOfModelDegree", [0, 0, 0]) + mesh_container.location = resolve_vec3_m(origin_of_model, state) + mesh_container.rotation_euler = euler_deg_xyz(rot_of_model) + + material_name = model_def.get("material", "defaultPlastic") + material = create_or_get_material(material_name) + + for obj in imported: + if obj.type != "MESH": + continue + safe_parent(obj, mesh_container) + # Keep STL imports at their own local origin; only scale to meters. + obj.scale = (scale_factor, scale_factor, scale_factor) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + + +# ============================================================ +# DEBUG WORLD AXES +# ============================================================ + +def create_axis_arrow( + name, + direction, + color, + length_mm=200, + radius_mm=2, + cone_radius_mm=5, + cone_length_mm=20 +): + length = mm_to_m(length_mm) + radius = mm_to_m(radius_mm) + cone_radius = mm_to_m(cone_radius_mm) + cone_length = mm_to_m(cone_length_mm) + + dir_vec = mathutils.Vector(direction).normalized() + + # -------------------------------------------------------- + # CYLINDER + # -------------------------------------------------------- + + bpy.ops.mesh.primitive_cylinder_add( + radius=radius, + depth=length - cone_length + ) + + cyl = bpy.context.active_object + cyl.name = f"{name}_shaft" + + # Blender cylinder points along Z by default + cyl.rotation_mode = 'QUATERNION' + cyl.rotation_quaternion = ( + mathutils.Vector((0, 0, 1)) + .rotation_difference(dir_vec) + ) + + cyl.location = dir_vec * ((length - cone_length) * 0.5) + + # -------------------------------------------------------- + # CONE + # -------------------------------------------------------- + + bpy.ops.mesh.primitive_cone_add( + radius1=cone_radius, + depth=cone_length + ) + + cone = bpy.context.active_object + cone.name = f"{name}_tip" + + cone.rotation_mode = 'QUATERNION' + cone.rotation_quaternion = ( + mathutils.Vector((0, 0, 1)) + .rotation_difference(dir_vec) + ) + + cone.location = dir_vec * (length - cone_length * 0.5) + + # -------------------------------------------------------- + # MATERIAL + # -------------------------------------------------------- + + mat = bpy.data.materials.new(name=f"{name}_material") + mat.use_nodes = True + + bsdf = mat.node_tree.nodes["Principled BSDF"] + + bsdf.inputs["Base Color"].default_value = ( + color[0], + color[1], + color[2], + 1.0 + ) + + bsdf.inputs["Roughness"].default_value = 0.3 + bsdf.inputs["Metallic"].default_value = 0.0 + + cyl.data.materials.append(mat) + cone.data.materials.append(mat) + + +# ------------------------------------------------------------ +# CREATE XYZ AXES +# ------------------------------------------------------------ + +# X = red +create_axis_arrow( + "AxisX", + (1, 0, 0), + (1, 0, 0) +) + +# Y = green +create_axis_arrow( + "AxisY", + (0, 1, 0), + (0, 1, 0) +) + +# Z = blue +create_axis_arrow( + "AxisZ", + (0, 0, 1), + (0, 0, 1) +) + + +# ============================================================ +# FINAL RENDER +# ============================================================ + +bpy.ops.render.render(write_still=True) +print("Finished rendering:", OUTPUT_FILE) diff --git a/render_robot_v01b.py b/render_robot_v01b.py new file mode 100644 index 0000000..147c96e --- /dev/null +++ b/render_robot_v01b.py @@ -0,0 +1,595 @@ +import bpy +import math +import mathutils +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +# ============================================================ +# PATHS +# ============================================================ + +ROBOT_JSON_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\robot.json" +OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png" +RENDER_WIDTH = 1200 +RENDER_HEIGHT = 800 + +# ============================================================ +# DEFAULT MATERIALS +# ============================================================ + +DEFAULT_MATERIALS = { + "wood": {"baseColor": (0.72, 0.52, 0.33, 1.0), "roughness": 0.85, "metallic": 0.0}, + "plaWhite": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.45, "metallic": 0.0}, + "steel": {"baseColor": (0.72, 0.72, 0.75, 1.0), "roughness": 0.25, "metallic": 1.0}, + "powderCoatBlue": {"baseColor": (0.15, 0.25, 0.70, 1.0), "roughness": 0.55, "metallic": 0.0}, + "defaultPlastic": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.40, "metallic": 0.0}, + "skeletonRed": {"baseColor": (0.85, 0.20, 0.20, 1.0), "roughness": 0.35, "metallic": 0.0}, + "markerBlack": {"baseColor": (0.04, 0.04, 0.04, 1.0), "roughness": 0.80, "metallic": 0.0}, +} + +STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"] + +# ============================================================ +# JSON LOADING +# ============================================================ + +with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f: + robot: Dict[str, Any] = json.load(f) + +rendering_info = robot.get("renderingInfo", {}) +metric = rendering_info.get("metric", "mm") +scale_factor = 0.001 if metric == "mm" else 1.0 + +def as_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + +show_skeleton = as_bool(rendering_info.get("showSkeleton", False)) +show_markers = as_bool(rendering_info.get("showMarkers", False)) + +state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS} +for source_name in ("defaultPosition", "recognized", "movements"): + source = robot.get(source_name, {}) or {} + for k in STATE_KEYS: + v = source.get(k, None) + if v is not None: + try: + state[k] = float(v) + except Exception: + pass + +links_def = robot.get("links", {}) +if not isinstance(links_def, dict): + raise ValueError("robot.json must contain a top-level 'links' object") + +# ============================================================ +# HELPERS +# ============================================================ + +def mm_to_m(value: float) -> float: + return value * scale_factor + +def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + key = value.strip().lower() + if key in state_map: + return float(state_map[key]) + try: + return float(key) + except ValueError: + return 0.0 + return 0.0 + +def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]: + if value is None: + return tuple(0.0 for _ in range(default_len)) + if isinstance(value, (int, float, str)): + return (resolve_scalar(value, state_map),) + if isinstance(value, (list, tuple)): + resolved = [resolve_scalar(v, state_map) for v in value] + if len(resolved) < default_len: + resolved.extend([0.0] * (default_len - len(resolved))) + return tuple(resolved[:default_len]) + return tuple(0.0 for _ in range(default_len)) + +def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]: + vec = list(resolve_vector(value, state_map, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + x, y, z = vec[:3] + return mm_to_m(x), mm_to_m(y), mm_to_m(z) + +def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector: + ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2]))) + return ax.normalized() if ax.length > 0 else mathutils.Vector((1.0, 0.0, 0.0)) + +def euler_deg_xyz(values: Any) -> Tuple[float, float, float]: + vec = list(resolve_vector(values, state, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + return math.radians(vec[0]), math.radians(vec[1]), math.radians(vec[2]) + +def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material: + info = rendering_info.get("materials", {}) or {} + spec = None + + if isinstance(info, dict): + spec = info.get(name) + + if isinstance(spec, dict): + base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy() + if "baseColor" in spec: + color = tuple(spec["baseColor"]) + base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4]) + if "roughness" in spec: + base["roughness"] = float(spec["roughness"]) + if "metallic" in spec: + base["metallic"] = float(spec["metallic"]) + spec = base + else: + spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]) + + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = spec["baseColor"] + bsdf.inputs["Roughness"].default_value = spec["roughness"] + bsdf.inputs["Metallic"].default_value = spec["metallic"] + return mat + +def import_stl(filepath: str) -> List[bpy.types.Object]: + path = Path(filepath).resolve() + if not path.exists(): + raise FileNotFoundError(f"STL file not found:\n{path}") + + before = set(bpy.data.objects) + bpy.ops.wm.stl_import(filepath=str(path)) + after = [obj for obj in bpy.data.objects if obj not in before] + return after + +def create_empty(name: str) -> bpy.types.Object: + empty = bpy.data.objects.new(name, None) + bpy.context.collection.objects.link(empty) + return empty + +def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object]): + if parent is not None: + child.parent = parent + child.matrix_parent_inverse = parent.matrix_world.inverted() + +def create_material_segment(name: str, color: Tuple[float, float, float], roughness: float = 0.35) -> bpy.types.Material: + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = 0.0 + return mat + +def create_cylinder_between( + name: str, + p1_local: Tuple[float, float, float], + p2_local: Tuple[float, float, float], + radius_m: float, + parent: bpy.types.Object, + material: bpy.types.Material +) -> bpy.types.Object: + v1 = mathutils.Vector(p1_local) + v2 = mathutils.Vector(p2_local) + delta = v2 - v1 + length = delta.length + if length <= 1e-9: + length = 1e-6 + delta = mathutils.Vector((0.0, 0.0, 1e-6)) + + bpy.ops.mesh.primitive_cylinder_add(radius=radius_m, depth=length) + obj = bpy.context.active_object + obj.name = name + safe_parent(obj, parent) + obj.location = (v1 + v2) * 0.5 + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(delta.normalized()) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + return obj + +def derive_default_skeleton_from_size(size_mm: List[float]) -> Dict[str, Any]: + sx, sy, sz = (float(size_mm[0]), float(size_mm[1]), float(size_mm[2])) + ax = max((abs(sx), 0), (abs(sy), 1), (abs(sz), 2), key=lambda x: x[0])[1] + + if ax == 0: + return {"from": [0, sy * 0.5, sz * 0.5], "to": [sx, sy * 0.5, sz * 0.5]} + if ax == 1: + return {"from": [sx * 0.5, 0, sz * 0.5], "to": [sx * 0.5, sy, sz * 0.5]} + return {"from": [sx * 0.5, sy * 0.5, 0], "to": [sx * 0.5, sy * 0.5, sz]} + +def resolve_stl_path(stl_file: str) -> Path: + base_dir = Path(ROBOT_JSON_FILE).parent + candidates = [ + base_dir / stl_file, + base_dir / "surfaces" / stl_file, + Path(stl_file), + ] + for c in candidates: + p = c.resolve() + if p.exists(): + return p + raise FileNotFoundError( + "STL file not found in any expected location:\n" + + "\n".join(str(c.resolve()) for c in candidates) + ) + +# ============================================================ +# SCENE RESET +# ============================================================ + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + +scene = bpy.context.scene +scene.unit_settings.system = "METRIC" +scene.unit_settings.length_unit = "MILLIMETERS" +scene.unit_settings.scale_length = scale_factor + +# ============================================================ +# WORLD / RENDER SETTINGS +# ============================================================ + +world = scene.world or bpy.data.worlds.new("World") +scene.world = world +world.use_nodes = True +bg = world.node_tree.nodes["Background"] +bg.inputs[0].default_value = tuple(rendering_info.get("backgroundColor", [0.70, 0.85, 1.0])) + (1.0,) +bg.inputs[1].default_value = float(rendering_info.get("backgroundStrength", 0.20)) + +scene.render.engine = "CYCLES" +scene.view_settings.exposure = float(rendering_info.get("exposure", -1.5)) +scene.cycles.samples = 64 +scene.cycles.preview_samples = 32 +scene.render.resolution_x = RENDER_WIDTH +scene.render.resolution_y = RENDER_HEIGHT +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.render.filepath = OUTPUT_FILE +scene.render.film_transparent = False + +# ============================================================ +# FLOOR +# ============================================================ + +bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0))) +floor = bpy.context.active_object +checker_mat = bpy.data.materials.new(name="Checkerboard") +checker_mat.use_nodes = True +nodes = checker_mat.node_tree.nodes +links = checker_mat.node_tree.links +nodes.clear() + +output_node = nodes.new(type="ShaderNodeOutputMaterial") +bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled") +checker_node = nodes.new(type="ShaderNodeTexChecker") +mapping_node = nodes.new(type="ShaderNodeMapping") +texcoord_node = nodes.new(type="ShaderNodeTexCoord") + +checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0) +checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0) +mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0) + +links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"]) +links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"]) +links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"]) +links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"]) +floor.data.materials.append(checker_mat) + +# ============================================================ +# CAMERA +# ============================================================ + +cam_data = bpy.data.cameras.new("Camera") +cam_obj = bpy.data.objects.new("Camera", cam_data) +bpy.context.collection.objects.link(cam_obj) + +cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-400, -700, 300]), state) +cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state) +cam_obj.location = cam_pos +cam_data.lens = 50 +cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos) +if cam_vec.length == 0: + cam_vec = mathutils.Vector((1, 0, 0)) +cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler() +scene.camera = cam_obj + +# ============================================================ +# LIGHTS +# ============================================================ + +sun_data = bpy.data.lights.new(name="Sun", type="SUN") +sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data) +bpy.context.collection.objects.link(sun_obj) +sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-500, -500, 500]), state) +light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state) +sun_obj.location = sun_pos +light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos) +if light_vec.length == 0: + light_vec = mathutils.Vector((1, 0, -1)) +sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler() +sun_data.energy = float(rendering_info.get("sunEnergy", 0.35)) + +area_data = bpy.data.lights.new(name="AreaLight", type="AREA") +area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data) +bpy.context.collection.objects.link(area_obj) +area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500)) +area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20)) +area_data.energy = float(rendering_info.get("areaEnergy", 120)) +area_data.size = 2.0 + +# ============================================================ +# ROBOT HIERARCHY +# ============================================================ + +link_frames: Dict[str, bpy.types.Object] = {} + +for link_name in links_def.keys(): + link_frames[link_name] = create_empty(f"{link_name}_frame") + +for link_name, link_info in links_def.items(): + parent_name = link_info.get("parent") + parent_frame = link_frames.get(parent_name) if parent_name else None + size_mm = link_info.get("size", [100, 100, 100]) + + mount = create_empty(f"{link_name}_mount") + safe_parent(mount, parent_frame) + mount.location = resolve_vec3_m(link_info.get("mountPosition", [0, 0, 0]), state) + mount.rotation_euler = euler_deg_xyz(link_info.get("mountRotation", [0, 0, 0])) + + joint_info = link_info.get("jointToParent", {}) or {} + joint = create_empty(f"{link_name}_joint") + safe_parent(joint, mount) + joint.location = resolve_vec3_m(joint_info.get("origin", [0, 0, 0]), state) + joint.rotation_euler = euler_deg_xyz(joint_info.get("rotation", [0, 0, 0])) + + motion = create_empty(f"{link_name}_motion") + safe_parent(motion, joint) + + joint_type = str(joint_info.get("type", "fixed")).lower() + control_var = str(joint_info.get("variable", joint_info.get("control", ""))).lower() + axis = joint_info.get("axis", [1, 0, 0]) + + if joint_type == "linear": + value_mm = state.get(control_var, 0.0) if control_var else 0.0 + motion.location = normalize_axis(axis) * mm_to_m(value_mm) + elif joint_type == "revolute": + value_deg = state.get(control_var, 0.0) if control_var else 0.0 + motion.rotation_mode = "QUATERNION" + motion.rotation_quaternion = mathutils.Quaternion(normalize_axis(axis), math.radians(value_deg)) + + link_frame = link_frames[link_name] + safe_parent(link_frame, motion) + + # -------------------------------------------------------- + # VISUAL MESHES + # -------------------------------------------------------- + + visual_root = create_empty(f"{link_name}_visual") + safe_parent(visual_root, link_frame) + + model_list = link_info.get("model", []) + if not isinstance(model_list, list): + model_list = [] + + for idx, model_def in enumerate(model_list): + stl_file = model_def.get("stlFile") + if not stl_file: + continue + + stl_path = resolve_stl_path(stl_file) + imported = import_stl(str(stl_path)) + + model_node = create_empty(f"{link_name}_model_{idx}") + safe_parent(model_node, visual_root) + model_node.location = resolve_vec3_m(model_def.get("originOfModel", [0, 0, 0]), state) + model_node.rotation_euler = euler_deg_xyz(model_def.get("rotationOfModelDegree", [0, 0, 0])) + + material_name = model_def.get("material", "defaultPlastic") + material = create_or_get_material(material_name) + + for obj in imported: + if obj.type != "MESH": + continue + safe_parent(obj, model_node) + obj.scale = (scale_factor, scale_factor, scale_factor) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + + # -------------------------------------------------------- + # SKELETON DEBUG + # -------------------------------------------------------- + + if show_skeleton: + skeleton_spec = link_info.get("skeleton") + if not isinstance(skeleton_spec, dict): + skeleton_spec = derive_default_skeleton_from_size(size_mm) + + p1_mm = skeleton_spec.get("from", [0, 0, 0]) + p2_mm = skeleton_spec.get("to", [0, 0, 0]) + p1 = resolve_vec3_m(p1_mm, state) + p2 = resolve_vec3_m(p2_mm, state) + + sk_radius_mm = float(skeleton_spec.get("radius", rendering_info.get("skeletonDefaults", {}).get("radius", 4))) + sk_color = skeleton_spec.get("color", rendering_info.get("skeletonDefaults", {}).get("color", [0.85, 0.20, 0.20])) + sk_mat = create_material_segment(f"{link_name}_skeletonMat", tuple(sk_color[:3])) + + create_cylinder_between( + f"{link_name}_skeleton", + p1, + p2, + mm_to_m(sk_radius_mm), + link_frame, + sk_mat + ) + + # -------------------------------------------------------- + # MARKERS + # -------------------------------------------------------- + + if show_markers: + marker_defaults = rendering_info.get("markerDefaults", {}) or {} + marker_mat = create_or_get_material("markerBlack") + + for m in link_info.get("markers", []): + if not isinstance(m, dict): + continue + + marker_name = m.get("name", f"{link_name}_marker_{m.get('id', 'x')}") + marker_size_mm = float(m.get("size", marker_defaults.get("size", 25))) + marker_pos = resolve_vec3_m(m.get("position", [0, 0, 0]), state) + marker_rot = euler_deg_xyz(m.get("rotation", [0, 0, 0])) + + bpy.ops.mesh.primitive_plane_add(size=mm_to_m(marker_size_mm)) + marker_obj = bpy.context.active_object + marker_obj.name = marker_name + safe_parent(marker_obj, link_frame) + marker_obj.location = marker_pos + marker_obj.rotation_euler = marker_rot + if len(marker_obj.data.materials) == 0: + marker_obj.data.materials.append(marker_mat) + else: + marker_obj.data.materials[0] = marker_mat +# ============================================================ +# DEBUG WORLD AXES +# ============================================================ + +def create_axis_arrow( + name, + direction, + color, + length_mm=200, + radius_mm=2, + cone_radius_mm=5, + cone_length_mm=20 +): + length = mm_to_m(length_mm) + radius = mm_to_m(radius_mm) + cone_radius = mm_to_m(cone_radius_mm) + cone_length = mm_to_m(cone_length_mm) + + dir_vec = mathutils.Vector(direction).normalized() + + # -------------------------------------------------------- + # CYLINDER + # -------------------------------------------------------- + + bpy.ops.mesh.primitive_cylinder_add( + radius=radius, + depth=length - cone_length + ) + + cyl = bpy.context.active_object + cyl.name = f"{name}_shaft" + + # Blender cylinder points along Z by default + cyl.rotation_mode = 'QUATERNION' + cyl.rotation_quaternion = ( + mathutils.Vector((0, 0, 1)) + .rotation_difference(dir_vec) + ) + + cyl.location = dir_vec * ((length - cone_length) * 0.5) + + # -------------------------------------------------------- + # CONE + # -------------------------------------------------------- + + bpy.ops.mesh.primitive_cone_add( + radius1=cone_radius, + depth=cone_length + ) + + cone = bpy.context.active_object + cone.name = f"{name}_tip" + + cone.rotation_mode = 'QUATERNION' + cone.rotation_quaternion = ( + mathutils.Vector((0, 0, 1)) + .rotation_difference(dir_vec) + ) + + cone.location = dir_vec * (length - cone_length * 0.5) + + # -------------------------------------------------------- + # MATERIAL + # -------------------------------------------------------- + + mat = bpy.data.materials.new(name=f"{name}_material") + mat.use_nodes = True + + bsdf = mat.node_tree.nodes["Principled BSDF"] + + bsdf.inputs["Base Color"].default_value = ( + color[0], + color[1], + color[2], + 1.0 + ) + + bsdf.inputs["Roughness"].default_value = 0.3 + bsdf.inputs["Metallic"].default_value = 0.0 + + cyl.data.materials.append(mat) + cone.data.materials.append(mat) + + +# ------------------------------------------------------------ +# CREATE XYZ AXES +# ------------------------------------------------------------ + +# X = red +create_axis_arrow( + "AxisX", + (1, 0, 0), + (1, 0, 0) +) + +# Y = green +create_axis_arrow( + "AxisY", + (0, 1, 0), + (0, 1, 0) +) + +# Z = blue +create_axis_arrow( + "AxisZ", + (0, 0, 1), + (0, 0, 1) +) + +# ============================================================ +# RENDER +# ============================================================ + +bpy.ops.render.render(write_still=True) +print("Finished rendering:", OUTPUT_FILE) \ No newline at end of file diff --git a/render_robot_v01c.py b/render_robot_v01c.py new file mode 100644 index 0000000..550457f --- /dev/null +++ b/render_robot_v01c.py @@ -0,0 +1,561 @@ +import bpy +import math +import mathutils +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple +from mathutils import Matrix + +# ============================================================ +# PATHS +# ============================================================ + +ROBOT_JSON_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\robot.json" +OUTPUT_FILE = r"C:\Users\kech\SynologyDrive\2026-AppServer-AppRobot\appRobotRendering\render.png" +RENDER_WIDTH = 1200 +RENDER_HEIGHT = 800 + +# ============================================================ +# DEFAULT MATERIALS +# ============================================================ + +DEFAULT_MATERIALS = { + "wood": {"baseColor": (0.72, 0.52, 0.33, 1.0), "roughness": 0.85, "metallic": 0.0}, + "plaWhite": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.45, "metallic": 0.0}, + "steel": {"baseColor": (0.72, 0.72, 0.75, 1.0), "roughness": 0.25, "metallic": 1.0}, + "powderCoatBlue": {"baseColor": (0.15, 0.25, 0.70, 1.0), "roughness": 0.55, "metallic": 0.0}, + "defaultPlastic": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.40, "metallic": 0.0}, + "skeletonRed": {"baseColor": (0.85, 0.20, 0.20, 1.0), "roughness": 0.35, "metallic": 0.0}, + "markerBlack": {"baseColor": (0.04, 0.04, 0.04, 1.0), "roughness": 0.80, "metallic": 0.0}, +} + +STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"] + +# ============================================================ +# JSON LOADING +# ============================================================ + +with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f: + robot: Dict[str, Any] = json.load(f) + +rendering_info = robot.get("renderingInfo", {}) +metric = rendering_info.get("metric", "mm") +scale_factor = 0.001 if metric == "mm" else 1.0 + +def as_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + +show_skeleton = as_bool(rendering_info.get("showSkeleton", False)) +show_markers = as_bool(rendering_info.get("showMarkers", False)) + +state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS} +for source_name in ("defaultPosition", "recognized", "movements"): + source = robot.get(source_name, {}) or {} + for k in STATE_KEYS: + v = source.get(k, None) + if v is not None: + try: + state[k] = float(v) + except Exception: + pass + +links_def = robot.get("links", {}) +if not isinstance(links_def, dict): + raise ValueError("robot.json must contain a top-level 'links' object") + +# ============================================================ +# HELPERS +# ============================================================ + +def mm_to_m(value: float) -> float: + return value * scale_factor + +def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + key = value.strip().lower() + if key in state_map: + return float(state_map[key]) + try: + return float(key) + except ValueError: + return 0.0 + return 0.0 + +def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]: + if value is None: + return tuple(0.0 for _ in range(default_len)) + if isinstance(value, (int, float, str)): + return (resolve_scalar(value, state_map),) + if isinstance(value, (list, tuple)): + resolved = [resolve_scalar(v, state_map) for v in value] + if len(resolved) < default_len: + resolved.extend([0.0] * (default_len - len(resolved))) + return tuple(resolved[:default_len]) + return tuple(0.0 for _ in range(default_len)) + +def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]: + vec = list(resolve_vector(value, state_map, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + x, y, z = vec[:3] + return mm_to_m(x), mm_to_m(y), mm_to_m(z) + +def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector: + ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2]))) + return ax.normalized() if ax.length > 0 else mathutils.Vector((1.0, 0.0, 0.0)) + +def euler_deg_xyz(values: Any) -> Tuple[float, float, float]: + vec = list(resolve_vector(values, state, default_len=3)) + while len(vec) < 3: + vec.append(0.0) + return math.radians(vec[0]), math.radians(vec[1]), math.radians(vec[2]) + +def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material: + info = rendering_info.get("materials", {}) or {} + spec = None + + if isinstance(info, dict): + spec = info.get(name) + + if isinstance(spec, dict): + base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy() + if "baseColor" in spec: + color = tuple(spec["baseColor"]) + base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4]) + if "roughness" in spec: + base["roughness"] = float(spec["roughness"]) + if "metallic" in spec: + base["metallic"] = float(spec["metallic"]) + spec = base + else: + spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]) + + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = spec["baseColor"] + bsdf.inputs["Roughness"].default_value = spec["roughness"] + bsdf.inputs["Metallic"].default_value = spec["metallic"] + return mat + +def import_stl(filepath: str) -> List[bpy.types.Object]: + path = Path(filepath).resolve() + if not path.exists(): + raise FileNotFoundError(f"STL file not found:\n{path}") + + before = set(bpy.data.objects) + bpy.ops.wm.stl_import(filepath=str(path)) + after = [obj for obj in bpy.data.objects if obj not in before] + return after + +def create_empty(name: str) -> bpy.types.Object: + empty = bpy.data.objects.new(name, None) + bpy.context.collection.objects.link(empty) + return empty + +def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object], keep_world: bool = False): + if parent is None: + return + world_matrix = child.matrix_world.copy() + child.parent = parent + if keep_world: + child.matrix_parent_inverse = parent.matrix_world.inverted() + child.matrix_world = world_matrix + else: + child.matrix_parent_inverse = Matrix.Identity(4) + +def create_material_segment(name: str, color: Tuple[float, float, float], roughness: float = 0.35) -> bpy.types.Material: + if name in bpy.data.materials: + mat = bpy.data.materials[name] + else: + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = 0.0 + return mat + +def create_cylinder_between( + name: str, + p1_local: Tuple[float, float, float], + p2_local: Tuple[float, float, float], + radius_m: float, + parent: bpy.types.Object, + material: bpy.types.Material +) -> bpy.types.Object: + v1 = mathutils.Vector(p1_local) + v2 = mathutils.Vector(p2_local) + delta = v2 - v1 + length = delta.length + if length <= 1e-9: + length = 1e-6 + delta = mathutils.Vector((0.0, 0.0, 1e-6)) + + bpy.ops.mesh.primitive_cylinder_add(radius=radius_m, depth=length) + obj = bpy.context.active_object + obj.name = name + safe_parent(obj, parent, keep_world=False) + obj.location = (v1 + v2) * 0.5 + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(delta.normalized()) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + return obj + +def derive_default_skeleton_from_size(size_mm: List[float]) -> Dict[str, Any]: + sx, sy, sz = (float(size_mm[0]), float(size_mm[1]), float(size_mm[2])) + ax = max((abs(sx), 0), (abs(sy), 1), (abs(sz), 2), key=lambda x: x[0])[1] + + if ax == 0: + return {"from": [0, sy * 0.5, sz * 0.5], "to": [sx, sy * 0.5, sz * 0.5]} + if ax == 1: + return {"from": [sx * 0.5, 0, sz * 0.5], "to": [sx * 0.5, sy, sz * 0.5]} + return {"from": [sx * 0.5, sy * 0.5, 0], "to": [sx * 0.5, sy * 0.5, sz]} + +def resolve_stl_path(stl_file: str) -> Path: + base_dir = Path(ROBOT_JSON_FILE).parent + candidates = [ + base_dir / stl_file, + base_dir / "surfaces" / stl_file, + Path(stl_file), + ] + for c in candidates: + p = c.resolve() + if p.exists(): + return p + raise FileNotFoundError( + "STL file not found in any expected location:\n" + + "\n".join(str(c.resolve()) for c in candidates) + ) + +# ============================================================ +# SCENE RESET +# ============================================================ + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + +scene = bpy.context.scene +scene.unit_settings.system = "METRIC" +scene.unit_settings.length_unit = "MILLIMETERS" +scene.unit_settings.scale_length = scale_factor + +# ============================================================ +# WORLD / RENDER SETTINGS +# ============================================================ + +world = scene.world or bpy.data.worlds.new("World") +scene.world = world +world.use_nodes = True +bg = world.node_tree.nodes["Background"] +bg.inputs[0].default_value = tuple(rendering_info.get("backgroundColor", [0.70, 0.85, 1.0])) + (1.0,) +bg.inputs[1].default_value = float(rendering_info.get("backgroundStrength", 0.20)) + +scene.render.engine = "CYCLES" +scene.view_settings.exposure = float(rendering_info.get("exposure", -1.5)) +scene.cycles.samples = 16 +scene.cycles.preview_samples = 32 +scene.render.resolution_x = RENDER_WIDTH +scene.render.resolution_y = RENDER_HEIGHT +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.render.filepath = OUTPUT_FILE +scene.render.film_transparent = False + +# ============================================================ +# FLOOR +# ============================================================ + +bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0))) +floor = bpy.context.active_object +checker_mat = bpy.data.materials.new(name="Checkerboard") +checker_mat.use_nodes = True +nodes = checker_mat.node_tree.nodes +links = checker_mat.node_tree.links +nodes.clear() + +output_node = nodes.new(type="ShaderNodeOutputMaterial") +bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled") +checker_node = nodes.new(type="ShaderNodeTexChecker") +mapping_node = nodes.new(type="ShaderNodeMapping") +texcoord_node = nodes.new(type="ShaderNodeTexCoord") + +checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0) +checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0) +mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0) + +links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"]) +links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"]) +links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"]) +links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"]) +floor.data.materials.append(checker_mat) + +# ============================================================ +# CAMERA +# ============================================================ + +cam_data = bpy.data.cameras.new("Camera") +cam_obj = bpy.data.objects.new("Camera", cam_data) +bpy.context.collection.objects.link(cam_obj) + +cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-400, -700, 300]), state) +cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state) +cam_obj.location = cam_pos +cam_data.lens = 50 +cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos) +if cam_vec.length == 0: + cam_vec = mathutils.Vector((1, 0, 0)) +cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler() +scene.camera = cam_obj + +# ============================================================ +# LIGHTS +# ============================================================ + +sun_data = bpy.data.lights.new(name="Sun", type="SUN") +sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data) +bpy.context.collection.objects.link(sun_obj) +sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-500, -500, 500]), state) +light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state) +sun_obj.location = sun_pos +light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos) +if light_vec.length == 0: + light_vec = mathutils.Vector((1, 0, -1)) +sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler() +sun_data.energy = float(rendering_info.get("sunEnergy", 0.35)) + +area_data = bpy.data.lights.new(name="AreaLight", type="AREA") +area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data) +bpy.context.collection.objects.link(area_obj) +area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500)) +area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20)) +area_data.energy = float(rendering_info.get("areaEnergy", 120)) +area_data.size = 2.0 + +# ============================================================ +# ROBOT HIERARCHY +# ============================================================ + +link_frames: Dict[str, bpy.types.Object] = {} +for link_name in links_def.keys(): + link_frames[link_name] = create_empty(f"{link_name}_frame") + +for link_name, link_info in links_def.items(): + parent_name = link_info.get("parent") + parent_frame = link_frames.get(parent_name) if parent_name else None + size_mm = link_info.get("size", [100, 100, 100]) + + # mount: static position/rotation in parent coordinates + mount = create_empty(f"{link_name}_mount") + safe_parent(mount, parent_frame, keep_world=False) + mount.location = resolve_vec3_m(link_info.get("mountPosition", [0, 0, 0]), state) + mount.rotation_euler = euler_deg_xyz(link_info.get("mountRotation", [0, 0, 0])) + + # joint: sits inside the mount, defines pivot/orientation + joint_info = link_info.get("jointToParent", {}) or {} + joint = create_empty(f"{link_name}_joint") + safe_parent(joint, mount, keep_world=False) + joint.location = resolve_vec3_m(joint_info.get("origin", [0, 0, 0]), state) + joint.rotation_euler = euler_deg_xyz(joint_info.get("rotation", [0, 0, 0])) + + # motion: only this node gets the commanded position/angle + motion = create_empty(f"{link_name}_motion") + safe_parent(motion, joint, keep_world=False) + + joint_type = str(joint_info.get("type", "fixed")).lower() + control_var = str(joint_info.get("variable", joint_info.get("control", ""))).lower() + axis = joint_info.get("axis", [1, 0, 0]) + + if joint_type == "linear": + value_mm = state.get(control_var, 0.0) if control_var else 0.0 + motion.location = normalize_axis(axis) * mm_to_m(value_mm) + elif joint_type == "revolute": + value_deg = state.get(control_var, 0.0) if control_var else 0.0 + motion.rotation_mode = "QUATERNION" + motion.rotation_quaternion = mathutils.Quaternion(normalize_axis(axis), math.radians(value_deg)) + + # link frame: everything belonging to this link follows motion + link_frame = link_frames[link_name] + safe_parent(link_frame, motion, keep_world=False) + + # -------------------------------------------------------- + # VISUAL MESHES + # -------------------------------------------------------- + + visual_root = create_empty(f"{link_name}_visual") + safe_parent(visual_root, link_frame, keep_world=False) + + model_list = link_info.get("model", []) + if not isinstance(model_list, list): + model_list = [] + + for idx, model_def in enumerate(model_list): + stl_file = model_def.get("stlFile") + if not stl_file: + continue + + stl_path = resolve_stl_path(stl_file) + imported = import_stl(str(stl_path)) + + model_node = create_empty(f"{link_name}_model_{idx}") + safe_parent(model_node, visual_root, keep_world=False) + model_node.location = resolve_vec3_m(model_def.get("originOfModel", [0, 0, 0]), state) + model_node.rotation_euler = euler_deg_xyz(model_def.get("rotationOfModelDegree", [0, 0, 0])) + + material_name = model_def.get("material", "defaultPlastic") + material = create_or_get_material(material_name) + + for obj in imported: + if obj.type != "MESH": + continue + safe_parent(obj, model_node, keep_world=True) + obj.scale = (scale_factor, scale_factor, scale_factor) + if len(obj.data.materials) == 0: + obj.data.materials.append(material) + else: + obj.data.materials[0] = material + + # -------------------------------------------------------- + # SKELETON DEBUG + # -------------------------------------------------------- + + if show_skeleton: + skeleton_spec = link_info.get("skeleton") + if not isinstance(skeleton_spec, dict): + skeleton_spec = derive_default_skeleton_from_size(size_mm) + + p1_mm = skeleton_spec.get("from", [0, 0, 0]) + p2_mm = skeleton_spec.get("to", [0, 0, 0]) + p1 = resolve_vec3_m(p1_mm, state) + p2 = resolve_vec3_m(p2_mm, state) + + sk_radius_mm = float( + skeleton_spec.get( + "radius", + rendering_info.get("skeletonDefaults", {}).get("radius", 4) + ) + ) + sk_color = skeleton_spec.get( + "color", + rendering_info.get("skeletonDefaults", {}).get("color", [0.85, 0.20, 0.20]) + ) + sk_mat = create_material_segment(f"{link_name}_skeletonMat", tuple(sk_color[:3])) + + create_cylinder_between( + f"{link_name}_skeleton", + p1, + p2, + mm_to_m(sk_radius_mm), + link_frame, + sk_mat + ) + + # -------------------------------------------------------- + # MARKERS + # -------------------------------------------------------- + + if show_markers: + marker_defaults = rendering_info.get("markerDefaults", {}) or {} + marker_mat = create_or_get_material("markerBlack") + + for m in link_info.get("markers", []): + if not isinstance(m, dict): + continue + + marker_name = m.get("name", f"{link_name}_marker_{m.get('id', 'x')}") + marker_size_mm = float(m.get("size", marker_defaults.get("size", 25))) + marker_pos = resolve_vec3_m(m.get("position", [0, 0, 0]), state) + marker_rot = euler_deg_xyz(m.get("rotation", [0, 0, 0])) + + bpy.ops.mesh.primitive_plane_add(size=mm_to_m(marker_size_mm)) + marker_obj = bpy.context.active_object + marker_obj.name = marker_name + safe_parent(marker_obj, link_frame, keep_world=False) + marker_obj.location = marker_pos + marker_obj.rotation_euler = marker_rot + if len(marker_obj.data.materials) == 0: + marker_obj.data.materials.append(marker_mat) + else: + marker_obj.data.materials[0] = marker_mat + +# ============================================================ +# DEBUG WORLD AXES +# ============================================================ + +def create_axis_arrow( + name, + direction, + color, + length_mm=200, + radius_mm=2, + cone_radius_mm=5, + cone_length_mm=20 +): + length = mm_to_m(length_mm) + radius = mm_to_m(radius_mm) + cone_radius = mm_to_m(cone_radius_mm) + cone_length = mm_to_m(cone_length_mm) + + dir_vec = mathutils.Vector(direction).normalized() + + bpy.ops.mesh.primitive_cylinder_add( + radius=radius, + depth=length - cone_length + ) + + cyl = bpy.context.active_object + cyl.name = f"{name}_shaft" + cyl.rotation_mode = 'QUATERNION' + cyl.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec) + cyl.location = dir_vec * ((length - cone_length) * 0.5) + + bpy.ops.mesh.primitive_cone_add( + radius1=cone_radius, + depth=cone_length + ) + + cone = bpy.context.active_object + cone.name = f"{name}_tip" + cone.rotation_mode = 'QUATERNION' + cone.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec) + cone.location = dir_vec * (length - cone_length * 0.5) + + mat = bpy.data.materials.new(name=f"{name}_material") + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) + bsdf.inputs["Roughness"].default_value = 0.3 + bsdf.inputs["Metallic"].default_value = 0.0 + + cyl.data.materials.append(mat) + cone.data.materials.append(mat) + +create_axis_arrow("AxisX", (1, 0, 0), (1, 0, 0)) +create_axis_arrow("AxisY", (0, 1, 0), (0, 1, 0)) +create_axis_arrow("AxisZ", (0, 0, 1), (0, 0, 1)) + +# ============================================================ +# RENDER +# ============================================================ + +bpy.ops.render.render(write_still=True) +print("Finished rendering:", OUTPUT_FILE) \ No newline at end of file diff --git a/render_v01c.png b/render_v01c.png new file mode 100644 index 0000000..041aa92 Binary files /dev/null and b/render_v01c.png differ diff --git a/robot.json b/robot.json new file mode 100644 index 0000000..aa2eb8b --- /dev/null +++ b/robot.json @@ -0,0 +1,360 @@ +{ + "coordinateSystem": { + "handedness": "right", + "x": "right", + "y": "backward", + "z": "up" + }, + "units": { + "length": "mm", + "rotation": "degree" + }, + "vision_config": { + "MarkerType": "DICT_4X4_250", + "MarkerSize": 0.025 + }, + "renderingInfo": { + "width": 1280, + "height": 720, + "cameraPosition": [-150, -400, 800], + "cameraTarget": [230, -180, 180], + "cameraUpVector": [0, 0, 1], + "lightPosition": [-500, -500, 500], + "lightTarget": [0, 0, 0], + "lightUpVector": [0, 0, 1], + "metric": "mm", + "showSkeleton": true, + "showMarkers": true, + "backgroundColor": [0.70, 0.85, 1.0], + "backgroundStrength": 0.20, + "sunEnergy": 0.35, + "areaEnergy": 120, + "exposure": -1.5, + "materials": { + "wood": { + "baseColor": [0.72, 0.52, 0.33], + "roughness": 0.85, + "metallic": 0.0 + }, + "plaWhite": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.45, + "metallic": 0.0 + }, + "steel": { + "baseColor": [0.72, 0.72, 0.75], + "roughness": 0.25, + "metallic": 1.0 + }, + "powderCoatBlue": { + "baseColor": [0.15, 0.25, 0.7], + "roughness": 0.55, + "metallic": 0.0 + }, + "defaultPlastic": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.4, + "metallic": 0.0 + }, + "skeletonRed": { + "baseColor": [0.85, 0.20, 0.20], + "roughness": 0.35, + "metallic": 0.0 + }, + "markerBlack": { + "baseColor": [0.04, 0.04, 0.04], + "roughness": 0.80, + "metallic": 0.0 + } + }, + "skeletonDefaults": { + "radius": 4, + "color": [0.85, 0.20, 0.20] + }, + "markerDefaults": { + "size": 25, + "thickness": 1, + "color": [0.04, 0.04, 0.04] + } + }, + "defaultPosition": { + "x": 150, + "y": 30, + "z": -60, + "a": 220, + "b": 30, + "c": 70, + "e": 0 + }, + "recognized": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "movements": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "links": { + "Board": { + "parent": null, + "size": [1000, 200, 25], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "skeleton": { + "from": [0, 0, 16], + "to": [1000, 0, 16], + "radius": 4, + "color": [0.85, 0.20, 0.20] + }, + "markers": [ + ], + "model": [ + { + "stlFile": "surfaces/Board.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "wood" + }, + { + "stlFile": "surfaces/BoardRail.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "steel" + } + ] + }, + "Base": { + "parent": "Board", + "size": [150, 200, 150], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "jointToParent": { + "name": "Slider", + "type": "linear", + "axis": [1, 0, 0], + "origin": [0, 0, 16], + "rotation": [0, 0, 0], + "variable": "x" + }, + "skeleton": { + "from": [0, 108, 45], + "to": [110, 108, 45], + "radius": 4, + "color": [0.20, 0.80, 0.20] + }, + "markers": [ + ], + "model": [ + { + "stlFile": "surfaces/Base.stl", + "originOfModel": [-30, 0, -35], + "rotationOfModelDegree": [0, 0, 0], + "material": "plaWhite" + } + ] + }, + "Arm1": { + "parent": "Base", + "size": [70, 250, 70], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "jointToParent": { + "name": "Joint1", + "type": "revolute", + "axis": [-1, 0, 0], + "origin": [110, 108, 45], + "rotation": [0, 0, 0], + "variable": "y" + }, + "skeleton": { + "from": [0, 0, 0], + "to": [0, -250, 0], + "radius": 4, + "color": [0.20, 0.20, 0.90] + }, + "markers": [ + { + "id": 198, + "name": "aruco_198", + "position": [0, -160, 35], + "normal": [0, 0, 1], + "size": 25, + "spin": 0 + }, + { + "id": 229, + "name": "aruco_229", + "position": [0, -250, 35], + "normal": [0, 0, 1], + "size": 25, + "spin": 0 + }, + { + "id": 242, + "name": "aruco_242", + "position": [0, -250, -35], + "normal": [0, 0, -1], + "size": 25, + "spin": 0 + }, + { + "id": 243, + "name": "aruco_243", + "position": [0, -285, 0], + "normal": [0, -1, 0], + "size": 25, + "spin": 0 + } + ], + "model": [ + { + "stlFile": "surfaces/Holm.stl", + "originOfModel__": [-25,29,-28.5], + "originOfModel": [-29,25,28.5], + "rotationOfModelDegree__": [0, 0, 0], + "rotationOfModelDegree": [180, 0, -90], + "material": "powderCoatBlue" + } + ] + }, + "Ellbow": { + "parent": "Arm1", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint2", + "type": "revolute", + "axis": [-1, 0, 0], + "origin": [0, -250, 0], + "rotation": [0, 0, 0], + "variable": "z" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [90, 0, 0], + "radius": 4, + "color": [0.90, 0.20, 0.20] + }, + "model": [ + { + "stlFile": "surfaces/Ellebogen.stl", + "originOfModel": [90,0,0], + "rotationOfModelDegree": [0,-90,-90], + "material": "defaultPlastic" + } + ], + "markers": [ + { + "id": 244, + "name": "aruco_244", + "position": [125, 0, 0], + "normal": [1, 0, 0], + "size": 25, + "spin": 0 + }, + { + "id": 245, + "name": "aruco_245", + "position": [90, 0, -35], + "normal": [0, 0, -1], + "size": 25, + "spin": 0 + }, + { + "id": 246, + "name": "aruco_246", + "position": [90, 0, 35], + "normal": [0, 0, 1], + "size": 25 + }, + { + "id": 247, + "name": "aruco_247", + "position": [52.5, 0, 35], + "normal": [0, 0, 1], + "size": 25 + } + ] + + }, + "Arm2": { + "parent": "Ellbow", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint3", + "type": "revolute", + "axis": [0, -1, 0], + "origin": [90, 0, 0], + "rotation": [0, 0, 0], + "variable": "a" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [0, -250, 0], + "radius": 4, + "color": [0.95, 0.85, 0.20] + } + }, + "Hand": { + "parent": "Arm2", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint4", + "type": "revolute", + "axis": [1, 0, 0], + "origin": [0, -250, 0], + "rotation": [0, 0, 0], + "variable": "b" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [0, -35, 0], + "radius": 4, + "color": [0.95, 0.55, 0.15] + } + }, + "Palm": { + "parent": "Hand", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint3", + "type": "revolute", + "axis": [0, -1, 0], + "origin": [0, 0, 0], + "rotation": [0, 0, 0], + "variable": "c" + }, + + "skeleton": { + "from": [-50, -35, 0], + "to": [50, -35, 0], + "radius": 7, + "color": [0.95, 0.20, 0.20] + } + } + } +} \ No newline at end of file diff --git a/robot.urdf b/robot.urdf new file mode 100644 index 0000000..f950934 --- /dev/null +++ b/robot.urdf @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/robot_commented.json b/robot_commented.json new file mode 100644 index 0000000..ffc0c76 --- /dev/null +++ b/robot_commented.json @@ -0,0 +1,334 @@ +{ + "_comment": "Robot definition file for Blender/Robot/URDF-style kinematic rendering", + + "coordinateSystem": { + "_comment": "Global coordinate system definition", + + "system": "right-handed", + + "axes": { + "x": "right", + "y": "backward", + "z": "up" + }, + + "_important": [ + "This coordinate system is intentionally identical to Blender.", + "All positions are expressed in millimeters.", + "All rotations are expressed in degrees.", + "Positive rotations follow the right-hand rule." + ] + }, + + "units": { + "length": "mm", + "rotation": "degree" + }, + + "renderingInfo": { + "_comment": "Pure rendering settings. Does NOT affect robot kinematics.", + + "cameraPosition": [-400, -700, 300], + + "cameraTarget": [0, 0, 150], + + "_cameraTargetInfo": [ + "cameraTarget defines the world-space point the camera looks at.", + "Values may also reference state variables like 'x'." + ], + + "cameraUpVector": [0, 0, 1], + + "lightPosition": [-500, -500, 500], + + "lightTarget": [0, 0, 0], + + "lightUpVector": [0, 0, 1], + + "metric": "mm", + + "materials": { + "wood": { + "baseColor": [0.72, 0.52, 0.33], + "roughness": 0.85, + "metallic": 0.0 + }, + + "plaWhite": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.45, + "metallic": 0.0 + }, + + "steel": { + "baseColor": [0.72, 0.72, 0.75], + "roughness": 0.25, + "metallic": 1.0 + }, + + "powderCoatBlue": { + "baseColor": [0.15, 0.25, 0.70], + "roughness": 0.55, + "metallic": 0.0 + }, + + "defaultPlastic": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.40, + "metallic": 0.0 + } + } + }, + + "defaultPosition": { + "_comment": "Robot zero/home position", + + "_important": [ + "These values define the robot's neutral pose.", + "All joints are evaluated relative to this pose." + ], + + "x": 0, + "y": 0, + "z": 0, + + "a": 0, + "b": 0, + "c": 0, + + "e": 0 + }, + + "recognized": { + "_comment": "Pose reconstructed from machine vision / markers", + + "x": null, + "y": null, + "z": null, + + "a": null, + "b": null, + "c": null, + + "e": null + }, + + "movements": { + "_comment": "Current commanded movement state (e.g. from GCode)", + + "x": null, + "y": null, + "z": null, + + "a": null, + "b": null, + "c": null, + + "e": null + }, + + "_statePriority": [ + "movements overrides recognized", + "recognized overrides defaultPosition", + "defaultPosition overrides zero" + ], + + "links": { + "Board": { + "_comment": "Static world/base object", + + "parent": null, + + "_mountPositionMeaning": [ + "Position of THIS LINK coordinate system", + "relative to the PARENT LINK coordinate system" + ], + + "mountPosition": [0, 0, 0], + + "_mountRotationMeaning": [ + "Mechanical installation rotation", + "Defines how the LINK coordinate system", + "is rotated relative to its parent link", + "This is NOT the joint movement." + ], + + "mountRotation": [0, 0, 0], + + "_modelMeaning": [ + "One link may consist of multiple STL surfaces.", + "Each STL is positioned relative to the LINK coordinate system." + ], + + "model": [ + { + "_comment": "Visual geometry only", + + "stlFile": "surfaces/Board.stl", + + "_originOfModelMeaning": [ + "Position of STL inside the LINK coordinate system", + "Purely visual adjustment", + "Used to compensate CAD export offsets" + ], + + "originOfModel": [0, 0, 0], + + "_rotationOfModelMeaning": [ + "Rotation of STL inside LINK coordinate system", + "Purely visual adjustment", + "Used to compensate CAD export orientation" + ], + + "rotationOfModelDegree": [0, 0, 90], + + "material": "wood" + } + ] + }, + + "Base": { + "_comment": "Linear axis mounted on Board", + + "parent": "Board", + + "mountPosition": [0, 0, 25], + + "mountRotation": [0, 0, 0], + + "_jointToParentMeaning": [ + "Defines the kinematic transformation", + "between parent link and this link" + ], + + "jointToParent": { + "name": "Slider", + + "_jointTypeMeaning": [ + "fixed = no movement", + "linear = translational movement", + "revolute = rotational movement" + ], + + "type": "linear", + + "_axisMeaning": [ + "Joint movement axis in LOCAL joint coordinates", + "[1,0,0] = local X axis", + "[0,1,0] = local Y axis", + "[0,0,1] = local Z axis" + ], + + "axis": [1, 0, 0], + + "_originMeaning": [ + "Position of joint coordinate system", + "inside the parent link coordinate system" + ], + + "origin": [0, 0, 0], + + "_rotationMeaning": [ + "Orientation of joint coordinate system", + "inside the parent link coordinate system" + ], + + "rotation": [0, 0, 0], + + "_variableMeaning": [ + "Maps robot state variable", + "to this joint" + ], + + "variable": "x" + }, + + "model": [ + { + "stlFile": "surfaces/Base.stl", + + "originOfModel": [0, 0, 0], + + "rotationOfModelDegree": [0, 0, 0], + + "material": "plaWhite" + } + ] + }, + + "Arm1": { + "_comment": "Rotational arm", + + "parent": "Base", + + "mountPosition": [150, 0, 150], + + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint1", + + "type": "revolute", + + "_important": [ + "Positive rotation follows right-hand rule." + ], + + "axis": [1, 0, 0], + + "origin": [0, 0, 0], + + "rotation": [0, 0, 0], + + "variable": "a" + }, + + "model": [ + { + "stlFile": "surfaces/Holm.stl", + + "originOfModel": [0, 0, 0], + + "rotationOfModelDegree": [0, 0, 0], + + "material": "powderCoatBlue" + } + ] + } + }, + + "_kinematicStructure": { + "_comment": "Conceptual hierarchy", + + "hierarchy": [ + "ParentLink", + "-> JointOrigin", + "-> JointMotion", + "-> ChildLink", + "-> VisualMeshes" + ], + + "_important": [ + "All children automatically inherit parent movement.", + "This creates full forward kinematics automatically.", + "No manual propagation of child transformations is required." + ] + }, + + "_futureExtensions": { + "_comment": "Planned future capabilities", + + "possibleFeatures": [ + "marker definitions", + "inverse kinematics", + "collision geometry", + "joint limits", + "joint damping", + "mass/inertia", + "vision calibration", + "urdf export", + "gcode import", + "trajectory playback" + ] + } +} \ No newline at end of file diff --git a/robot_v01a.json b/robot_v01a.json new file mode 100644 index 0000000..80bf585 --- /dev/null +++ b/robot_v01a.json @@ -0,0 +1,138 @@ +{ + "vision_config": { + "MarkerType": "DICT_4X4_250", + "MarkerSize": 0.025 + }, + "renderingInfo": { + "cameraPosition": [-400, -700, 300], + "cameraTarget": [300, 0, 90], + "cameraUpVector": [0, 0, 1], + "lightPosition": [-500, -500, 500], + "lightTarget": [0, 0, 0], + "lightUpVector": [0, 0, 1], + "metric": "mm", + "showSkeleton": true, + "materials": { + "wood": { + "baseColor": [0.72, 0.52, 0.33], + "roughness": 0.85, + "metallic": 0.0 + }, + "plaWhite": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.45, + "metallic": 0.0 + }, + "steel": { + "baseColor": [0.72, 0.72, 0.75], + "roughness": 0.25, + "metallic": 1.0 + }, + "powderCoatBlue": { + "baseColor": [0.15, 0.25, 0.7], + "roughness": 0.55, + "metallic": 0.0 + }, + "marbleStone": { + "baseColor": [0.85, 0.85, 0.87], + "roughness": 0.95, + "metallic": 0.0 + }, + "defaultPlastic": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.4, + "metallic": 0.0 + } + } + }, + "defaultPosition": { + "x": 0, + "y": 0, + "z": 0, + "a": 0, + "b": 0, + "c": 0, + "e": 0 + }, + "recognized": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "movements": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "links": { + "Board": { + "parent": null, + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "model": [ + { + "stlFile": "surfaces/Board.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "wood" + }, + { + "stlFile": "surfaces/BoardRail.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "steel" + } + ] + }, + "Base": { + "parent": "Board", + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "model": [ + { + "stlFile": "surfaces/Base.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, 0], + "material": "plaWhite" + } + ], + "jointToParent": { + "name": "Slider", + "type": "linear", + "axis": [1, 0, 0], + "origin": [0, 0, 0], + "rotation": [0, 0, 0], + "variable": "x" + } + }, + "Arm1": { + "parent": "Base", + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "model": [ + { + "stlFile": "surfaces/Holm.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, 0], + "material": "powderCoatBlue" + } + ], + "jointToParent": { + "name": "Joint1", + "type": "revolute", + "axis": [1, 0, 0], + "origin": [0, 0, 0], + "rotation": [0, 0, 0], + "variable": "a" + } + } + } +} \ No newline at end of file diff --git a/robot_v01c.json b/robot_v01c.json new file mode 100644 index 0000000..a280c3c --- /dev/null +++ b/robot_v01c.json @@ -0,0 +1,313 @@ +{ + "coordinateSystem": { + "handedness": "right", + "x": "right", + "y": "backward", + "z": "up" + }, + "units": { + "length": "mm", + "rotation": "degree" + }, + "vision_config": { + "MarkerType": "DICT_4X4_250", + "MarkerSize": 0.025 + }, + "renderingInfo": { + "cameraPosition": [-150, -800, 600], + "cameraTarget": [200, 0, 60], + "cameraUpVector": [0, 0, 1], + "lightPosition": [-500, -500, 500], + "lightTarget": [0, 0, 0], + "lightUpVector": [0, 0, 1], + "metric": "mm", + "showSkeleton": true, + "showMarkers": true, + "backgroundColor": [0.70, 0.85, 1.0], + "backgroundStrength": 0.20, + "sunEnergy": 0.35, + "areaEnergy": 120, + "exposure": -1.5, + "materials": { + "wood": { + "baseColor": [0.72, 0.52, 0.33], + "roughness": 0.85, + "metallic": 0.0 + }, + "plaWhite": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.45, + "metallic": 0.0 + }, + "steel": { + "baseColor": [0.72, 0.72, 0.75], + "roughness": 0.25, + "metallic": 1.0 + }, + "powderCoatBlue": { + "baseColor": [0.15, 0.25, 0.7], + "roughness": 0.55, + "metallic": 0.0 + }, + "defaultPlastic": { + "baseColor": [0.95, 0.95, 0.95], + "roughness": 0.4, + "metallic": 0.0 + }, + "skeletonRed": { + "baseColor": [0.85, 0.20, 0.20], + "roughness": 0.35, + "metallic": 0.0 + }, + "markerBlack": { + "baseColor": [0.04, 0.04, 0.04], + "roughness": 0.80, + "metallic": 0.0 + } + }, + "skeletonDefaults": { + "radius": 4, + "color": [0.85, 0.20, 0.20] + }, + "markerDefaults": { + "size": 25, + "thickness": 1, + "color": [0.04, 0.04, 0.04] + } + }, + "defaultPosition": { + "x": 50, + "y": 30, + "z": -60, + "a": 220, + "b": 30, + "c": 70, + "e": 0 + }, + "recognized": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "movements": { + "x": null, + "y": null, + "z": null, + "a": null, + "b": null, + "c": null, + "e": null + }, + "links": { + "Board": { + "parent": null, + "size": [1000, 200, 25], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "skeleton": { + "from": [0, 0, 16], + "to": [1000, 0, 16], + "radius": 4, + "color": [0.85, 0.20, 0.20] + }, + "markers": [ + ], + "model": [ + { + "stlFile": "surfaces/Board.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "wood" + }, + { + "stlFile": "surfaces/BoardRail.stl", + "originOfModel": [0, 0, 0], + "rotationOfModelDegree": [0, 0, -90], + "material": "steel" + } + ] + }, + "Base": { + "parent": "Board", + "size": [150, 200, 150], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "jointToParent": { + "name": "Slider", + "type": "linear", + "axis": [1, 0, 0], + "origin": [0, 0, 16], + "rotation": [0, 0, 0], + "variable": "x" + }, + "skeleton": { + "from": [0, 108, 45], + "to": [110, 108, 45], + "radius": 4, + "color": [0.20, 0.80, 0.20] + }, + "markers": [ + ], + "model": [ + { + "stlFile": "surfaces/Base.stl", + "originOfModel": [-30, 0, -35], + "rotationOfModelDegree": [0, 0, 0], + "material": "plaWhite" + } + ] + }, + "Arm1": { + "parent": "Base", + "size": [70, 250, 70], + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + "jointToParent": { + "name": "Joint1", + "type": "revolute", + "axis": [-1, 0, 0], + "origin": [110, 108, 45], + "rotation": [0, 0, 0], + "variable": "y" + }, + "skeleton": { + "from": [0, 0, 0], + "to": [0, -250, 0], + "radius": 4, + "color": [0.20, 0.20, 0.90] + }, + "markers": [ + { + "id": 198, + "name": "aruco_198", + "position": [-89.5, -160, 35], + "normal": [0, 0, 1], + "size": 25 + }, + { + "id": 229, + "name": "aruco_229", + "position": [-89.5, -250, 35], + "normal": [0, 0, 1], + "size": 25 + }, + { + "id": 242, + "name": "aruco_242", + "position": [-89.5, -250, -35], + "normal": [0, 0, -1], + "size": 25 + }, + { + "id": 243, + "name": "aruco_243", + "position": [-89.5, -285, 0], + "normal": [0, -1, 0], + "size": 25 + } + ], + "model": [ + { + "stlFile": "surfaces/Holm.stl", + "originOfModel__": [-25,29,-28.5], + "originOfModel": [-29,25,28.5], + "rotationOfModelDegree__": [0, 0, 0], + "rotationOfModelDegree": [180, 0, -90], + "material": "powderCoatBlue" + } + ] + }, + "Ellbow": { + "parent": "Arm1", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint2", + "type": "revolute", + "axis": [-1, 0, 0], + "origin": [0, -250, 0], + "rotation": [0, 0, 0], + "variable": "z" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [70, 0, 0], + "radius": 4, + "color": [0.90, 0.20, 0.20] + } + }, + "Arm2": { + "parent": "Ellbow", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint3", + "type": "revolute", + "axis": [0, -1, 0], + "origin": [70, 0, 0], + "rotation": [0, 0, 0], + "variable": "a" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [0, -250, 0], + "radius": 4, + "color": [0.95, 0.85, 0.20] + } + }, + "Hand": { + "parent": "Arm2", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint4", + "type": "revolute", + "axis": [1, 0, 0], + "origin": [0, -250, 0], + "rotation": [0, 0, 0], + "variable": "b" + }, + + "skeleton": { + "from": [0, 0, 0], + "to": [0, -35, 0], + "radius": 4, + "color": [0.95, 0.55, 0.15] + } + }, + "Palm": { + "parent": "Hand", + + "mountPosition": [0, 0, 0], + "mountRotation": [0, 0, 0], + + "jointToParent": { + "name": "Joint3", + "type": "revolute", + "axis": [0, -1, 0], + "origin": [0, 0, 0], + "rotation": [0, 0, 0], + "variable": "c" + }, + + "skeleton": { + "from": [-50, -35, 0], + "to": [50, -35, 0], + "radius": 7, + "color": [0.95, 0.20, 0.20] + } + } + } +} \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..afe3d6c --- /dev/null +++ b/run.bat @@ -0,0 +1 @@ +"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe" -b --python render_robot.py --log-level 2 \ No newline at end of file diff --git a/surfaces/Base.stl b/surfaces/Base.stl new file mode 100644 index 0000000..6ba6f3f Binary files /dev/null and b/surfaces/Base.stl differ diff --git a/surfaces/Board.stl b/surfaces/Board.stl new file mode 100644 index 0000000..c6fd521 Binary files /dev/null and b/surfaces/Board.stl differ diff --git a/surfaces/BoardRail.stl b/surfaces/BoardRail.stl new file mode 100644 index 0000000..9a1579a Binary files /dev/null and b/surfaces/BoardRail.stl differ diff --git a/surfaces/Ellebogen.3mf b/surfaces/Ellebogen.3mf new file mode 100644 index 0000000..f9cafc0 Binary files /dev/null and b/surfaces/Ellebogen.3mf differ diff --git a/surfaces/Ellebogen.stl b/surfaces/Ellebogen.stl new file mode 100644 index 0000000..3289c3f Binary files /dev/null and b/surfaces/Ellebogen.stl differ diff --git a/surfaces/Holm.stl b/surfaces/Holm.stl new file mode 100644 index 0000000..2d4bddb Binary files /dev/null and b/surfaces/Holm.stl differ diff --git a/surfaces/Unterarm.3mf b/surfaces/Unterarm.3mf new file mode 100644 index 0000000..50c04b4 Binary files /dev/null and b/surfaces/Unterarm.3mf differ diff --git a/surfaces/Unterarm.stl b/surfaces/Unterarm.stl new file mode 100644 index 0000000..d50d5ca Binary files /dev/null and b/surfaces/Unterarm.stl differ