diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b399b4d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "stdint.h": "c" + } +} \ No newline at end of file diff --git a/portal/__init__.py b/portal/__init__.py index 0742c30..6313049 100644 --- a/portal/__init__.py +++ b/portal/__init__.py @@ -11,7 +11,7 @@ bl_info = { "name": "Portal", "author": "Zeke Zhang", - "version": (0, 1, 2), + "version": (0, 2, 0), "blender": (4, 2, 0), "category": "System", "location": "View3D > Sidebar > Portal", diff --git a/portal/bin/crc16-ccitt.dll b/portal/bin/crc16-ccitt.dll new file mode 100644 index 0000000..b136a2d Binary files /dev/null and b/portal/bin/crc16-ccitt.dll differ diff --git a/portal/data_struct/color.py b/portal/data_struct/color.py index fb4606d..23e0749 100644 --- a/portal/data_struct/color.py +++ b/portal/data_struct/color.py @@ -1,80 +1,91 @@ -from typing import Tuple +from enum import Enum +from typing import Tuple, Union + + +class ColorType(Enum): + RGB = "rgb" + RGBA = "rgba" + class Color: def __init__(self, r: int, g: int, b: int, a: float = 1.0) -> None: - """Initialize a Color object with RGB and alpha values.""" - self.r = r - self.g = g - self.b = b - self.a = a + """Initialize a Color object with RGB and optional alpha values.""" + self.r = self._validate_color_value(r, "r") + self.g = self._validate_color_value(g, "g") + self.b = self._validate_color_value(b, "b") + self.a = self._validate_alpha_value(a) - def to_hex(self) -> str: - """Convert the color to a hexadecimal string.""" - return f"#{self.r:02x}{self.g:02x}{self.b:02x}" - - def to_tuple(self) -> Tuple[int, int, int, float]: - """Return the color as a tuple of (r, g, b, a).""" - return (self.r, self.g, self.b, self.a) + @staticmethod + def _validate_color_value(value: int, component: str) -> int: + """Ensure color component (r, g, b) is a valid integer in range [0, 255].""" + if not isinstance(value, int): + raise TypeError(f"Component '{component}' must be an integer.") + if not 0 <= value <= 255: + raise ValueError(f"Component '{component}' must be between 0 and 255.") + return value - def to_normalized_tuple(self) -> Tuple[float, float, float, float]: - """Return the color as a normalized tuple of (r, g, b, a).""" - return (self.r / 255, self.g / 255, self.b / 255, self.a) + @staticmethod + def _validate_alpha_value(value: float) -> float: + """Ensure alpha component (a) is a valid float in range [0.0, 1.0].""" + if not isinstance(value, (float, int)): + raise TypeError("Alpha must be a float or int.") + value = float(value) + if not 0.0 <= value <= 1.0: + raise ValueError("Alpha must be between 0.0 and 1.0.") + return value + + def to_hex(self, type: Union[ColorType, str] = ColorType.RGBA) -> str: + """Convert color to hexadecimal string (RGB or RGBA).""" + type = type.lower() if isinstance(type, str) else type.value + if type == "rgb": + return f"#{self.r:02X}{self.g:02X}{self.b:02X}" + elif type == "rgba": + alpha_int = int(round(self.a * 255)) + return f"#{self.r:02X}{self.g:02X}{self.b:02X}{alpha_int:02X}" + else: + raise ValueError("Invalid color type. Use 'rgb' or 'rgba'.") + + def to_tuple(self, type: Union[ColorType, str] = ColorType.RGBA, normalize: bool = False) -> Union[Tuple[int, int, int], Tuple[int, int, int, float]]: + """Return the color as an (r, g, b) or (r, g, b, a) tuple, optionally normalized.""" + type = type.lower() if isinstance(type, str) else type.value + if type == "rgb": + result = (self.r, self.g, self.b) + else: + result = (self.r, self.g, self.b, self.a) + + if normalize: + return tuple(x / 255 for x in result) if type == "rgb" else (result[0] / 255, result[1] / 255, result[2] / 255, self.a) + + return result def __str__(self) -> str: - """Return the string representation of the color.""" - return f"Color({self.r}, {self.g}, {self.b}, {self.a})" + return f"Color(r={self.r}, g={self.g}, b={self.b}, a={self.a})" + def __repr__(self) -> str: + return self.__str__() -class ColorFactory: @staticmethod - def from_hex(hex_str: str) -> Color: - """Create a Color object from a hexadecimal string.""" + def from_hex(hex_str: str) -> "Color": + """Create a Color from a hexadecimal string (#RRGGBB or #RRGGBBAA).""" + if not hex_str.startswith("#") or len(hex_str) not in (7, 9): + raise ValueError("Hex string must start with '#' and be 7 or 9 characters long.") hex_str = hex_str.lstrip("#") - return Color(*[int(hex_str[i : i + 2], 16) for i in (0, 2, 4)]) - - @staticmethod - def from_rgb(r: int, g: int, b: int, a: float = 1.0) -> Color: - """Create a Color object from RGB and alpha values.""" + r, g, b = (int(hex_str[i:i + 2], 16) for i in (0, 2, 4)) + a = int(hex_str[6:8], 16) / 255.0 if len(hex_str) == 8 else 1.0 return Color(r, g, b, a) @staticmethod - def from_tuple(color_tuple: Tuple[int, int, int, float]) -> Color: - """Create a Color object from a tuple of (r, g, b, a).""" - return Color(*color_tuple) + def from_tuple(color_tuple: Union[Tuple[int, int, int], Tuple[int, int, int, float]]) -> "Color": + """Create a Color from an (r, g, b) or (r, g, b, a) tuple.""" + if len(color_tuple) not in (3, 4): + raise ValueError("Tuple must have 3 or 4 elements.") + return Color(*color_tuple) if len(color_tuple) == 3 else Color(*color_tuple[:3], color_tuple[3]) @staticmethod - def from_normalized_tuple(color_tuple: Tuple[float, float, float, float]) -> Color: - """Create a Color object from a normalized tuple of (r, g, b, a).""" - return Color(*(int(x * 255) for x in color_tuple)) - - -class ColorDecorator: - def __init__(self, color: Color) -> None: - """Initialize a ColorDecorator with a Color object.""" - self._color = color - - def to_hex(self) -> str: - """Convert the decorated color to a hexadecimal string.""" - return self._color.to_hex() - - def to_tuple(self) -> Tuple[int, int, int, float]: - """Return the decorated color as a tuple of (r, g, b, a).""" - return self._color.to_tuple() - - def __str__(self) -> str: - """Return the string representation of the decorated color.""" - return str(self._color) - - -class AlphaColorDecorator(ColorDecorator): - def __init__(self, color: Color, alpha: float) -> None: - """Initialize an AlphaColorDecorator with a Color object and an alpha value.""" - super().__init__(color) - self._color.a = alpha - - -# Example usage: -# color1 = ColorFactory.from_hex("#ff5733") -# color2 = ColorFactory.from_rgb(255, 87, 51) -# decorated_color = AlphaColorDecorator(color1, 0.5) -# print(decorated_color) \ No newline at end of file + def from_normalized_tuple(color_tuple: Union[Tuple[float, float, float], Tuple[float, float, float, float]]) -> "Color": + """Create a Color from a normalized (0.0-1.0) tuple.""" + if len(color_tuple) not in (3, 4): + raise ValueError("Normalized tuple must have 3 or 4 elements.") + r, g, b = (int(round(x * 255)) for x in color_tuple[:3]) + a = color_tuple[3] if len(color_tuple) == 4 else 1.0 + return Color(r, g, b, a) diff --git a/portal/data_struct/light.py b/portal/data_struct/light.py new file mode 100644 index 0000000..bbd4596 --- /dev/null +++ b/portal/data_struct/light.py @@ -0,0 +1,232 @@ +from typing import Any, Optional + +import bpy +import mathutils +from mathutils import Vector + +from .color import Color + + +class Light: + def __init__(self) -> None: + self.object_name: Optional[str] = None + + # Light properties + self.name: Optional[str] = None + self.rgb_color: Optional[tuple] = None + self.energy: Optional[float] = None + self.type: Optional[str] = None + self.location: Optional[tuple] = None + + # Spot light properties + self.spot_size: Optional[float] = None + self.spot_blend: Optional[float] = None + self.rotation_euler: Optional[Vector] = None + + # Area light properties + self.size: Optional[tuple] = None + self.distance: Optional[float] = None + + def create_or_replace(self, object_name: str, layer_path: Optional[str] = None) -> None: + self.object_name = object_name + existing_light = bpy.data.objects.get(object_name) + if existing_light: + self._replace_light(existing_light) + else: + self._create_new(layer_path) + + def _set_light_data( + self, name: str, color: tuple, energy: float, type: str, location: tuple + ) -> None: + self.name = name + self.rgb_color = color + self.energy = energy + self.location = location + if type.upper() not in ["SPOT", "POINT", "DIRECTIONAL", "RECTANGULAR"]: + raise ValueError(f"Unsupported light type: {type}") + if type.upper() == "SPOT": + self.type = "SPOT" + elif type.upper() == "POINT": + self.type = "POINT" + elif type.upper() == "DIRECTIONAL": + self.type = "SUN" + elif type.upper() == "RECTANGULAR": + self.type = "AREA" + + def _set_spot_data(self, spot_size: float, spot_blend: float, rotation_vec: Vector) -> None: + self.spot_size = spot_size + self.spot_blend = spot_blend + default_direction = mathutils.Vector((0, 0, -1)) + if rotation_vec.length > 0: + self.rotation_euler = default_direction.rotation_difference(rotation_vec).to_euler() + else: + self.rotation_euler = mathutils.Euler((0, 0, 0)) + + def _set_area_data(self, size: tuple, distance: float, rotation_euler: Vector) -> None: + self.size = size + self.distance = distance + self.rotation_euler = rotation_euler + + def _replace_light(self, existing_obj: Any) -> None: + existing_light_type = existing_obj.data.type + if existing_light_type != self.type: + existing_obj.data.type = self.type + existing_obj.location = self.location + light_data = existing_obj.data + light_data.color = self.rgb_color + light_data.energy = self.energy + if self.type == "SPOT": + light_data.spot_size = self.spot_size + light_data.spot_blend = self.spot_blend + existing_obj.rotation_euler = self.rotation_euler + elif self.type == "POINT": + pass + elif self.type == "SUN": + pass + elif self.type == "AREA": + light_data.size = self.size[0] + light_data.size_y = self.size[1] + light_data.use_custom_distance = True + light_data.cutoff_distance = self.distance + existing_obj.rotation_euler = self.rotation_euler + else: + raise ValueError(f"Unsupported light type: {self.type}") + + def _create_new(self, layer_path: Optional[str] = None) -> None: + name = self.name if self.name else f"{self.object_name}_{type}" + light_data = bpy.data.lights.new(name, type) + light_data.color = self.rgb_color + light_data.energy = self.energy + if self.type == "SPOT": + light_data.spot_size = self.spot_size + light_data.spot_blend = self.spot_blend + elif self.type == "POINT": + pass + elif self.type == "SUN": + pass + elif self.type == "AREA": + light_data.size = self.size[0] + light_data.size_y = self.size[1] + + light_data.cutoff_distance = self.distance + else: + raise ValueError(f"Unsupported light type: {self.type}") + + light_object = bpy.data.objects.new(self.object_name, light_data) + light_object.location = self.location + if self.type == "SPOT": + light_object.rotation_euler = self.rotation_euler + if self.type == "AREA": + light_object.rotation_euler = self.rotation_euler + light_object.use_cutoff_distance = True + + self._link_object_to_collection(light_object, layer_path) + + def _link_object_to_collection(self, obj: Any, layer_path: Optional[str] = None) -> None: + """Link the object to the appropriate Blender collection, handling nested layers.""" + if layer_path: + layer_names = layer_path.split("::") + parent_collection = None + + for i, layer in enumerate(layer_names): + # If it's the last layer in the path, check for duplicates and rename if necessary + if i == len(layer_names) - 1: + collection_name = layer + suffix = 1 + while bpy.data.collections.get(collection_name): + collection_name = f"{layer}_{suffix}" + suffix += 1 + else: + collection_name = layer + + # Create or get the collection + collection = bpy.data.collections.get(collection_name) + if not collection: + collection = bpy.data.collections.new(collection_name) + if parent_collection: + parent_collection.children.link(collection) + else: + bpy.context.scene.collection.children.link(collection) + + # Set parent for the next nested layer + parent_collection = collection + + # Link the object to the final collection in the nested structure + parent_collection.objects.link(obj) + else: + bpy.context.collection.objects.link(obj) + + @staticmethod + def from_dict(data: dict) -> "Light": + light = Light() + name: str = data.get("Name") + color: tuple = Color.from_hex(data.get("Color", "#FFFFFF")).to_tuple("rgb") + type: str = data.get("LightType") + energy: float = data.get("Intensity") + pos: dict = data.get("Position") + if not all([type, energy, pos]): + raise ValueError("Missing required light data") + location = (pos["X"], pos["Y"], pos["Z"]) + light._set_light_data(name, color, energy, type, location) + + if type.upper() == "SPOT": + spot_size: float = data.get("SpotAngleRadians") + radii: dict = data.get("SpotRadii") + spot_blend = 1 - ( + radii.get("Inner") / radii.get("Outer") + ) # FIXME: blender's spot_blend is probably not linear. + direction: dict = data.get("Direction") + if not all([spot_size, spot_blend, direction]): + raise ValueError("Missing required spot light data") + direction_vector = mathutils.Vector( + (direction["X"], direction["Y"], direction["Z"]) + ).normalized() + # TODO: implement spot light scale. Currently scale is default (1, 1, 1). + light._set_spot_data(spot_size, spot_blend, direction_vector) + elif type.upper() == "POINT": + pass + elif type.upper() == "DIRECTIONAL": + pass + elif type.upper() == "RECTANGULAR": + length_dict: dict = data.get("Length") + width_dict: dict = data.get("Width") + direction: dict = data.get("Direction") + if not all([length_dict, width_dict, direction]): + raise ValueError("Missing required area light data") + length_vec = mathutils.Vector((length_dict["X"], length_dict["Y"], length_dict["Z"])) + length = length_vec.length + width_vec = mathutils.Vector((width_dict["X"], width_dict["Y"], width_dict["Z"])) + width = width_vec.length + + direction_vec = mathutils.Vector((direction["X"], direction["Y"], direction["Z"])) + distance = direction_vec.length + + # Normalize vectors + length_vec.normalize() + width_vec.normalize() + direction_vec.normalize() + + # area light faces along negative Z axis in local space + z_axis = -direction_vec + x_axis = length_vec + + # ensure x_axis is orthogonal to z_axis + x_axis = (x_axis - x_axis.project(z_axis)).normalized() + y_axis = z_axis.cross(x_axis).normalized() + + rotation_matrix = mathutils.Matrix((x_axis, y_axis, z_axis)).transposed() + rotation_euler = rotation_matrix.to_euler() + + # Calculate the offset vector to center the area light + offset_vector = (x_axis * (length / 2)) + (y_axis * (width / 2)) + + # Adjust the location + original_location = mathutils.Vector((pos["X"], pos["Y"], pos["Z"])) + adjusted_location = original_location + offset_vector + + light._set_area_data((length, width), distance, rotation_euler) + light.location = adjusted_location + else: + raise ValueError(f"Unsupported light type: {type}") + + return light diff --git a/portal/data_struct/material.py b/portal/data_struct/material.py index b7511d7..018dceb 100644 --- a/portal/data_struct/material.py +++ b/portal/data_struct/material.py @@ -2,7 +2,7 @@ import bpy -from .color import ColorFactory +from .color import Color class Material: @@ -48,8 +48,8 @@ def create_or_replace(self, material_name): def _is_same_material(self): """Check if the existing material parameters match the new ones.""" # Check diffuse color - current_diffuse = self.material.diffuse_color[:3] # Compare RGB values only - new_diffuse = ColorFactory.from_hex(self.diffuse_color).to_normalized_tuple()[:3] + current_diffuse = self.material.diffuse_color + new_diffuse = Color.from_hex(self.diffuse_color).to_tuple(type='rgb', normalize=True) if not self._compare_colors(current_diffuse, new_diffuse): return False @@ -81,9 +81,9 @@ def _compare_colors(self, color1, color2, tolerance=1e-4): def _set_diffuse_color(self): """Set the base color of the material.""" - self.material.diffuse_color = ColorFactory.from_hex( + self.material.diffuse_color = Color.from_hex( self.diffuse_color - ).to_normalized_tuple() + ).to_tuple(type='rgb',normalize=True) def _apply_textures(self): """Apply textures to the material.""" diff --git a/portal/data_struct/mesh.py b/portal/data_struct/mesh.py index f47e622..17e19ce 100644 --- a/portal/data_struct/mesh.py +++ b/portal/data_struct/mesh.py @@ -1,6 +1,6 @@ import bpy -from .color import ColorFactory +from .color import Color from .material import Material from .p_types import PGeoType @@ -18,45 +18,18 @@ def __init__(self): self.mesh_data = None self.object_name = None - def to_dict(self, meta: dict | None = None, is_float=False, precision: float | None = None) -> dict: + def to_dict(self, meta: dict | None = None) -> dict: """ Convert the mesh data to a dictionary for serialization. Args: meta (dict | None): Metadata dictionary. - is_float (bool): Whether to convert values to floats. - precision (float | None): Precision value to determine rounding. Returns: dict: Serialized mesh data. """ - def apply_precision(value, precision): - """Helper function to round a value according to the specified precision.""" - if precision: - return round(value / precision) * precision - return value - - if is_float and precision is not None: - # Apply precision rounding to vertices and uvs - vertices = [ - {"X": apply_precision(v[0], precision), - "Y": apply_precision(v[1], precision), - "Z": apply_precision(v[2], precision)} - for v in self.vertices - ] - uvs = [ - {"X": apply_precision(uv[0], precision), - "Y": apply_precision(uv[1], precision)} - for uv in self.uvs - ] - elif is_float: - # Default behavior without precision - vertices = [{"X": float(v[0]), "Y": float(v[1]), "Z": float(v[2])} for v in self.vertices] - uvs = [{"X": float(uv[0]), "Y": float(uv[1])} for uv in self.uvs] - else: - # Non-float serialization - vertices = [{"X": v[0], "Y": v[1], "Z": v[2]} for v in self.vertices] - uvs = [{"X": uv[0], "Y": uv[1]} for uv in self.uvs] + vertices = [{"X": v[0], "Y": v[1], "Z": v[2]} for v in self.vertices] + uvs = [{"X": uv[0], "Y": uv[1]} for uv in self.uvs] mesh_dict = { "Type": PGeoType.MESH.value, @@ -64,7 +37,7 @@ def apply_precision(value, precision): "Faces": [list(face) for face in self.faces], "UVs": uvs, "VertexColors": [ - ColorFactory.from_normalized_tuple(col).to_hex() for col in self.vertex_colors + Color.from_normalized_tuple(col).to_hex('rgb') for col in self.vertex_colors ], } return {"Items": mesh_dict, "Meta": meta if meta else {}} @@ -216,7 +189,7 @@ def from_dict(dict): vertex_colors = None if color_hexs and len(color_hexs) == len(vertices): vertex_colors = [ - ColorFactory.from_hex(hex_str).to_normalized_tuple() for hex_str in color_hexs + Color.from_hex(hex_str).to_tuple(normalize=True) for hex_str in color_hexs ] mesh = Mesh() diff --git a/portal/handlers/string_handler.py b/portal/handlers/string_handler.py index 4433358..8d5093e 100644 --- a/portal/handlers/string_handler.py +++ b/portal/handlers/string_handler.py @@ -4,6 +4,7 @@ from ..data_struct.camera import Camera from ..data_struct.material import Material from ..data_struct.mesh import Mesh +from ..data_struct.light import Light from .custom_handler import CustomHandler @@ -13,7 +14,6 @@ def handle_string(payload, data_type, uuid, channel_name, handler_src): """Handle generic string data for different types.""" if payload is None: return - try: if data_type == "Custom": StringHandler._handle_custom_data(payload, channel_name, uuid, handler_src) @@ -21,9 +21,20 @@ def handle_string(payload, data_type, uuid, channel_name, handler_src): StringHandler._handle_mesh_data(payload, channel_name) elif data_type == "Camera": StringHandler._handle_camera_data(payload) + elif data_type == "Light": + StringHandler._handle_light_data(payload) except json.JSONDecodeError: raise ValueError(f"Unsupported data: {payload}") + @staticmethod + def _handle_light_data(payload): + """Handle light data payload.""" + light_data = json.loads(payload) + if not light_data: + raise ValueError("Light data is empty.") + light = Light.from_dict(light_data) + light.create_or_replace("Light") + @staticmethod def _handle_custom_data(payload, channel_name, uuid, handler_src): custom_handler = CustomHandler.load( diff --git a/portal/ui/operators/connections.py b/portal/ui/operators/connections.py index b21b2fc..50730fe 100644 --- a/portal/ui/operators/connections.py +++ b/portal/ui/operators/connections.py @@ -96,6 +96,11 @@ def execute(self, context): server_manager.stop_server() connection.running = False CONNECTION_MANAGER.remove(self.uuid) # Remove the manager from SERVER_MANAGERS + # Cancel the modal operator if it is running and unregister the event handlers + if self.uuid in MODAL_OPERATORS: + modal_operator = MODAL_OPERATORS[self.uuid] + modal_operator.cancel(context) # This will trigger _unregister_event_handlers + modal_operator._unregister_event_handlers() # Ensuring handlers are unregistered else: # Start the server if it's not running if server_manager and not server_manager.is_running(): diff --git a/portal/ui/operators/dict_editor.py b/portal/ui/operators/dict_editor.py index e403cbf..f55aecc 100644 --- a/portal/ui/operators/dict_editor.py +++ b/portal/ui/operators/dict_editor.py @@ -57,7 +57,7 @@ def draw(self, context): # ADVANCED TYPES elif item.value_type == "PROPERTY_PATH": - box.prop(item, "value_property_path", text="Property Path") + box.prop(item, "value_property_path", text="Path") elif item.value_type == "TIMESTAMP": box.label(text=str(int(time.time() * 1000))) elif item.value_type == "UUID": diff --git a/portal/ui/operators/modal.py b/portal/ui/operators/modal.py index cb76af0..461e282 100644 --- a/portal/ui/operators/modal.py +++ b/portal/ui/operators/modal.py @@ -1,3 +1,4 @@ +import time import queue import traceback @@ -22,6 +23,7 @@ def __init__(self): self.frame_change_handler = None self.scene_update_handler = None self.custom_event_handler = None + self.last_update_time = 0 # Track the last update time for the delay def modal(self, context, event): connection = self._get_connection(context) @@ -78,9 +80,10 @@ def cancel(self, context): def _handle_send_event(self, context, connection, server_manager): try: - message_to_send = construct_packet_dict(connection.dict_items, connection.precision) - if message_to_send: - server_manager.data_queue.put(message_to_send) + message_to_send = construct_packet_dict(connection.dict_items) + if not message_to_send or message_to_send == "{}" or message_to_send == "[]": + return + server_manager.data_queue.put(message_to_send) except Exception as e: self._report_error( context, @@ -94,6 +97,8 @@ def _handle_recv_event(self, context, connection, server_manager): while not server_manager.data_queue.empty(): try: data = server_manager.data_queue.get_nowait() + if not data or data == "{}" or data == "[]": # Empty data + break StringHandler.handle_string( data, connection.data_type, @@ -140,6 +145,12 @@ def _report_error(self, context, message, server_manager, connection, traceback= return {"CANCELLED"} def _send_data_on_event(self, scene, connection): + current_time = time.time() + if current_time - self.last_update_time < connection.event_timer: + return # Skip sending if within the delay threshold + + self.last_update_time = current_time # Update the last event time + server_manager = self._get_server_manager(connection) if not server_manager: return @@ -166,23 +177,18 @@ def _is_server_shutdown(self, server_manager): def _register_event_handlers(self, connection): if "RENDER_COMPLETE" in connection.event_types: - # https://docs.blender.org/api/current/bpy.app.handlers.html#bpy.app.handlers.render_complete self.render_complete_handler = lambda scene: self._send_data_on_event(scene, connection) bpy.app.handlers.render_complete.append(self.render_complete_handler) if "FRAME_CHANGE" in connection.event_types: - # https://docs.blender.org/api/current/bpy.app.handlers.html#bpy.app.handlers.frame_change_post self.frame_change_handler = lambda scene: self._send_data_on_event(scene, connection) bpy.app.handlers.frame_change_post.append(self.frame_change_handler) if "SCENE_UPDATE" in connection.event_types: - # on any scene event - # https://docs.blender.org/api/current/bpy.app.handlers.html#bpy.app.handlers.depsgraph_update_post self.scene_update_handler = lambda scene: self._send_data_on_event(scene, connection) bpy.app.handlers.depsgraph_update_post.append(self.scene_update_handler) if "CUSTOM" in connection.event_types: - # Custom event try: handler = CustomHandler.load( connection.custom_handler, diff --git a/portal/ui/operators/text_editor.py b/portal/ui/operators/text_editor.py index a4c2d45..5b206b4 100644 --- a/portal/ui/operators/text_editor.py +++ b/portal/ui/operators/text_editor.py @@ -66,20 +66,17 @@ def execute(self, context): area.spaces.active.text = text return {"FINISHED"} - # Try to find a non-critical area (e.g., VIEW_3D or OUTLINER) to turn into a TEXT_EDITOR + # Open a new window with a TEXT_EDITOR if no suitable area exists + bpy.ops.screen.area_split(direction='VERTICAL', factor=0.5) + new_area = None for area in context.screen.areas: - if area.type not in {"PROPERTIES", "OUTLINER", "PREFERENCES", "INFO"}: - area.type = "TEXT_EDITOR" - area.spaces.active.text = text - return {"FINISHED"} + if area.type == 'VIEW_3D': + new_area = area + break - # If no suitable area, open a new window with a TEXT_EDITOR - new_window = bpy.ops.screen.area_split(direction="VERTICAL", factor=0.5) - if new_window == "FINISHED": - for area in context.screen.areas: - if area.type == "TEXT_EDITOR": - area.spaces.active.text = text - break + if new_area: + new_area.type = 'TEXT_EDITOR' + new_area.spaces.active.text = text return {"FINISHED"} diff --git a/portal/ui/panels/server_control.py b/portal/ui/panels/server_control.py index 4e3cb2f..1e1de13 100644 --- a/portal/ui/panels/server_control.py +++ b/portal/ui/panels/server_control.py @@ -85,11 +85,8 @@ def draw(self, context): sub_box.prop(connection, "event_types", text="Trigger Event") if connection.event_types == "CUSTOM": self._draw_custom_handler(sub_box, connection) - elif connection.event_types == "TIMER": - sub_box.prop(connection, "event_timer", text="Interval (sec)") - if not connection.event_types == "CUSTOM": - sub_box.prop(connection, "precision", text="Precision") + sub_box.prop(connection, "event_timer", text="Interval (sec)") sub_box.separator() sub_box.operator( "portal.dict_item_editor", text="Data Editor", icon="MODIFIER_DATA" @@ -108,8 +105,8 @@ def _draw_custom_handler(self, box: UILayout, connection): ).uuid = connection.uuid if connection.custom_handler: - box.operator( - "portal.open_text_editor", text="Open in Text Editor" + row.operator( + "portal.open_text_editor", text="", icon="ZOOM_ALL" ).text_name = connection.custom_handler diff --git a/portal/ui/properties/connection_properties.py b/portal/ui/properties/connection_properties.py index cba10c3..ba7f974 100644 --- a/portal/ui/properties/connection_properties.py +++ b/portal/ui/properties/connection_properties.py @@ -31,6 +31,7 @@ class PortalConnection(bpy.types.PropertyGroup): items=[ ("Mesh", "Mesh", "Receive data as mesh"), ("Camera", "Camera", "Receive data as camera"), + ("Light", "Light", "Receive data as light"), ("Custom", "Custom", "Handle data with custom handler"), ], default="Mesh", @@ -61,11 +62,6 @@ class PortalConnection(bpy.types.PropertyGroup): ("CUSTOM", "Custom", "Trigger on custom event"), ], ) - precision: bpy.props.FloatProperty( - name="Update Precision", - description="minimum numerical change to trigger an update", - default=0.01, - ) dict_items: bpy.props.CollectionProperty(type=DictionaryItem) dict_items_index: bpy.props.IntProperty(default=0) diff --git a/portal/ui/properties/dictionary_item_properties.py b/portal/ui/properties/dictionary_item_properties.py index e519bfb..f22dccf 100644 --- a/portal/ui/properties/dictionary_item_properties.py +++ b/portal/ui/properties/dictionary_item_properties.py @@ -12,7 +12,7 @@ class DictionaryItem(bpy.types.PropertyGroup): ("NUMBER", "Number", "Numerical value"), ("BOOL", "Boolean", "Boolean value"), ("SCENE_OBJECT", "Scene Object", "Scene object value"), - ("PROPERTY_PATH", "Property Path", "Property path value"), + ("PROPERTY_PATH", "Property Full Path", "Property full path. (copy from the context menu)"), ("TIMESTAMP", "Timestamp", "Timestamp value"), ("UUID", "UUID", "UUID value"), ], diff --git a/portal/ui/ui_utils/helper.py b/portal/ui/ui_utils/helper.py index 73d1f38..517059b 100644 --- a/portal/ui/ui_utils/helper.py +++ b/portal/ui/ui_utils/helper.py @@ -13,7 +13,7 @@ def is_connection_duplicated(connections, name_to_check, uuid_to_ignore=None): return False -def construct_packet_dict(data_items, update_precision) -> str: +def construct_packet_dict(data_items) -> str: """Helper function to construct a dictionary from a collection of dictionary items""" payload = Payload() meta = {} @@ -34,7 +34,7 @@ def construct_packet_dict(data_items, update_precision) -> str: scene_obj = item.value_scene_object if scene_obj.type == "MESH": payload.add_items( - Mesh.from_obj(scene_obj).to_dict(is_float=True, precision=update_precision) + Mesh.from_obj(scene_obj).to_dict() ) elif scene_obj.type == "CAMERA": raise NotImplementedError("Camera object type is not supported yet") @@ -43,7 +43,7 @@ def construct_packet_dict(data_items, update_precision) -> str: else: raise ValueError(f"Unsupported object type: {scene_obj.type}") elif item.value_type == "PROPERTY_PATH": - meta[item.key] = item.value_property_path + meta[item.key] = get_property_from_path(item.value_property_path) elif item.value_type == "UUID": meta[item.key] = item.value_uuid @@ -51,3 +51,9 @@ def construct_packet_dict(data_items, update_precision) -> str: payload.set_meta(meta) return payload.to_json_str() return json.dumps(meta) + + +def get_property_from_path(path: str): + # Use eval to resolve the path + value = eval(path) + return value diff --git a/portal/utils/crypto.py b/portal/utils/crypto.py index 009e60a..bb704ec 100644 --- a/portal/utils/crypto.py +++ b/portal/utils/crypto.py @@ -1,23 +1,36 @@ +import ctypes +from ctypes import POINTER, c_size_t, c_ubyte, c_uint16 + + class Crc16: - POLYNOMIAL = 0xA001 # A001 is the Crc16 polynomial - - def __init__(self): - self._table = [0] * 256 - for i in range(256): - value = 0 - temp = i - for _ in range(8): - if (value ^ temp) & 0x0001: - value = (value >> 1) ^ self.POLYNOMIAL - else: - value >>= 1 - temp >>= 1 - self._table[i] = value + def __init__(self) -> None: + # load the DLL + self.dll = ctypes.CDLL("portal/bin/crc16-ccitt.dll") + + # initialize function prototype + self.dll.crc_init.restype = c_uint16 + self.dll.crc_update.argtypes = [ + c_uint16, # crc_t crc + POINTER(c_ubyte), # const void *data + c_size_t # size_t data_len + ] + self.dll.crc_update.restype = c_uint16 + self.dll.crc_finalize.argtypes = [c_uint16] + self.dll.crc_finalize.restype = c_uint16 + def compute_checksum(self, byte_array: bytes) -> int: - """Generate a CRC16 checksum for the given byte array.""" - crc = 0 - for byte in byte_array: - index = (crc ^ byte) & 0xFF - crc = (crc >> 8) ^ self._table[index] + # initialize the crc + crc = self.dll.crc_init() + + # convert the byte array to a c_ubyte array + data_len = len(byte_array) + data_array = (c_ubyte * data_len)(*byte_array) + + # update the crc + crc = self.dll.crc_update(crc, data_array, data_len) + + # finalize the crc + crc = self.dll.crc_finalize(crc) + return crc diff --git a/portal_c/compile.bat b/portal_c/compile.bat new file mode 100644 index 0000000..5c12de3 --- /dev/null +++ b/portal_c/compile.bat @@ -0,0 +1,5 @@ +@ECHO OFF +gcc -shared -o ../portal/bin/crc16-ccitt.dll crc16/crc16-ccitt.c + +echo Compilation done. Find the DLL in the `portal/bin` folder. +pause \ No newline at end of file diff --git a/portal_c/crc16/crc16-ccitt.c b/portal_c/crc16/crc16-ccitt.c new file mode 100644 index 0000000..a12f31c --- /dev/null +++ b/portal_c/crc16/crc16-ccitt.c @@ -0,0 +1,82 @@ +/** + * \file + * Functions and types for CRC checks. + * + * Generated on Fri Oct 4 10:30:06 2024 + * by pycrc v0.10.0, https://pycrc.org + * using the configuration: + * - Width = 16 + * - Poly = 0x1021 + * - XorIn = 0x1d0f + * - ReflectIn = False + * - XorOut = 0x0000 + * - ReflectOut = False + * - Algorithm = table-driven + */ +#include "crc16-ccitt.h" /* include the header file generated with pycrc */ +#include +#include + + + +/** + * Static table used for the table_driven implementation. + */ +static const crc_t crc_table[256] = { + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, + 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, + 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, + 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, + 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, + 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, + 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, + 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, + 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, + 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, + 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, + 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, + 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, + 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, + 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, + 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, + 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, + 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, + 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, + 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, + 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, + 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, + 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, + 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, + 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, + 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, + 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, + 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, + 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, + 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 +}; + + +DLL_EXPORT crc_t crc_init(void) +{ + return 0x1d0f; +} + +DLL_EXPORT crc_t crc_finalize(crc_t crc) +{ + return crc; +} + +DLL_EXPORT crc_t crc_update(crc_t crc, const void *data, size_t data_len) +{ + const unsigned char *d = (const unsigned char *)data; + unsigned int tbl_idx; + + while (data_len--) { + tbl_idx = ((crc >> 8) ^ *d) & 0xff; + crc = (crc_table[tbl_idx] ^ (crc << 8)) & 0xffff; + d++; + } + return crc & 0xffff; +} diff --git a/portal_c/crc16/crc16-ccitt.h b/portal_c/crc16/crc16-ccitt.h new file mode 100644 index 0000000..33927f7 --- /dev/null +++ b/portal_c/crc16/crc16-ccitt.h @@ -0,0 +1,28 @@ +#ifndef CRC16_CCITT_H +#define CRC16_CCITT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef _WIN32 + #define DLL_EXPORT __declspec(dllexport) +#else + #define DLL_EXPORT +#endif + +#define CRC_ALGO_TABLE_DRIVEN 1 +typedef uint_fast16_t crc_t; + +DLL_EXPORT crc_t crc_init(void); +DLL_EXPORT crc_t crc_update(crc_t crc, const void *data, size_t data_len); +DLL_EXPORT crc_t crc_finalize(crc_t crc); + +#ifdef __cplusplus +} /* closing brace for extern "C" */ +#endif + +#endif /* CRC16_CCITT_H */ diff --git a/portal_c/readme.md b/portal_c/readme.md new file mode 100644 index 0000000..0d874f9 --- /dev/null +++ b/portal_c/readme.md @@ -0,0 +1,21 @@ +# Portal C code + +This is a collection of C modules with algorithm that can run faster than the native Python implementation. +The C code is compiled into a shared library that can be imported into Python using the `ctypes` module. + + + +## Compilation +Make sure you have a `gcc` compiler installed on your system. You can download the MSYS2 compiler from [here](https://www.msys2.org/#installation). + +> Usually you don't need to compile the C code yourself, as the shared library is already provided in the `portal/bin` folder. +> But just in case you want to compile it yourself or make changes to the C code, follow the instructions below. +### Automatic compilation +Execute `compile.bat` to compile the C code into a shared library. It will automatically generate the `.dll` file and place in the `portal/bin` folder. + +### Manual compilation +To compile the module, run the following command: + +```bash +gcc -shared -o crc16-ccitt.dll crc16-ccitt.c +``` \ No newline at end of file