diff --git a/bpy_speckle/converter/to_native.py b/bpy_speckle/converter/to_native.py new file mode 100644 index 0000000..5630143 --- /dev/null +++ b/bpy_speckle/converter/to_native.py @@ -0,0 +1,459 @@ +import bpy +import bmesh +from bpy.types import Object +from mathutils import Vector +from typing import Any, List, Optional, Tuple, Iterable +from specklepy.objects.base import Base +from specklepy.objects.geometry.line import Line +from specklepy.objects.geometry.mesh import Mesh +from specklepy.objects.geometry.polyline import Polyline + +# Constants for naming and conversion +OBJECT_NAME_MAX_LENGTH = 62 +OBJECT_NAME_SPECKLE_SEPARATOR = "::" +OBJECT_NAME_NUMERAL_SEPARATOR = "." + +# Property aliases for finding geometry in various Speckle object types +DISPLAY_VALUE_PROPERTY_ALIASES = ["displayValue", "displayMesh", "displayStyle"] +ELEMENTS_PROPERTY_ALIASES = ["elements", "Elements", "@elements"] + +def _has_native_conversion(speckle_object: Base) -> bool: + """Check if object has a direct conversion method.""" + return isinstance(speckle_object, (Line, Mesh, Polyline)) + +def _has_fallback_conversion(speckle_object: Base) -> bool: + """Check if object has displayValue properties that can be converted.""" + return any(getattr(speckle_object, alias, None) for alias in DISPLAY_VALUE_PROPERTY_ALIASES) + +def can_convert_to_native(speckle_object: Base) -> bool: + """Check if a Speckle object can be converted to Blender. + + Args: + speckle_object: The Speckle object to check + + Returns: + True if the object can be converted, False otherwise + """ + return _has_native_conversion(speckle_object) or _has_fallback_conversion(speckle_object) + +def convert_to_native(speckle_object: Base) -> Object: + """Convert a Speckle object to a Blender object. + + Args: + speckle_object: The Speckle object to convert + + Returns: + A Blender object + """ + # Generate a name for the object + object_name = _generate_object_name(speckle_object) + + converted = None + children = [] + + # First try native conversion if available + if isinstance(speckle_object, Line): + converted = line_to_native(speckle_object, object_name) + elif isinstance(speckle_object, Mesh): + converted = mesh_to_native(speckle_object, object_name, 1.0) # Using 1.0 as default scale + elif isinstance(speckle_object, Polyline): + converted = polyline_to_native(speckle_object, object_name) + + # If no native conversion was possible, try displayValue conversion + if not converted: + (converted, children) = display_value_to_native( + speckle_object, object_name, 1.0 # Using 1.0 as default scale + ) + if not converted and not children: + raise ValueError(f"Failed to convert object: {speckle_object.speckle_type}") + + # Create a Blender object if the converter returned data instead of an object + if not isinstance(converted, Object): + blender_object = create_new_object(converted, object_name) + else: + blender_object = converted + + # Store Speckle ID + if hasattr(blender_object, "speckle"): + blender_object.speckle.object_id = str(speckle_object.id) + blender_object.speckle.enabled = True + + # Parent children to the main object if any were created + for child in children: + child.parent = blender_object + + return blender_object + +def line_to_native(speckle_line: Line, name: str) -> bpy.types.Curve: + """Convert a Speckle line to a Blender curve. + + Args: + speckle_line: The Speckle line to convert + name: The name for the new Blender curve + + Returns: + A Blender curve data block + """ + # Check if the line has valid start and end points + if not speckle_line.start or not speckle_line.end: + raise ValueError("Line is missing start or end point") + + # Create a new curve data block + blender_curve = bpy.data.curves.new(name, type="CURVE") + blender_curve.dimensions = "3D" + + # Create a new spline in the curve + spline = blender_curve.splines.new("POLY") + spline.points.add(1) # Add one point (default has 1, so total will be 2) + + # Set the coordinates + # Note: Blender curve points are 4D (x, y, z, w) where w is weight + spline.points[0].co = ( + float(speckle_line.start.x), + float(speckle_line.start.y), + float(speckle_line.start.z), + 1.0, + ) + + spline.points[1].co = ( + float(speckle_line.end.x), + float(speckle_line.end.y), + float(speckle_line.end.z), + 1.0, + ) + + return blender_curve + +def mesh_to_native(speckle_mesh: Mesh, name: str, scale: float) -> bpy.types.Mesh: + """Convert a Speckle mesh to a Blender mesh. + + Args: + speckle_mesh: The Speckle mesh to convert + name: The name for the new Blender mesh + scale: The scale factor to apply + + Returns: + A Blender mesh data block + """ + # Check if mesh already exists with this name + if name in bpy.data.meshes.keys(): + return bpy.data.meshes[name] + + # Create a new mesh data block + blender_mesh = bpy.data.meshes.new(name=name) + + # Create a BMesh for easier manipulation + bm = bmesh.new() + + # Add vertices + add_vertices(speckle_mesh, bm, scale) + bm.verts.ensure_lookup_table() + + # Add faces + add_faces(speckle_mesh, bm, 0, 0) + bm.faces.ensure_lookup_table() + + # Finalize and cleanup + bm.to_mesh(blender_mesh) + bm.free() + + return blender_mesh + +def add_vertices(mesh: Mesh, bm: bmesh.types.BMesh, scale: float) -> None: + """Add vertices from a Speckle mesh to a Blender BMesh. + + Args: + mesh: The Speckle mesh containing vertices + bm: The Blender BMesh to add vertices to + scale: The scale factor to apply + """ + if not mesh.vertices: + return + + # Add vertices + for i in range(0, len(mesh.vertices), 3): + x = float(mesh.vertices[i]) * scale + y = float(mesh.vertices[i + 1]) * scale + z = float(mesh.vertices[i + 2]) * scale + bm.verts.new(Vector((x, y, z))) + +def add_faces(mesh: Mesh, bm: bmesh.types.BMesh, vertex_offset: int, material_index: int) -> None: + """Add faces from a Speckle mesh to a Blender BMesh. + + Args: + mesh: The Speckle mesh containing faces + bm: The Blender BMesh to add faces to + vertex_offset: Offset to apply to vertex indices + material_index: Material index to assign to faces + """ + if not mesh.faces: + return + + # Ensure lookup table is up to date + bm.verts.ensure_lookup_table() + + i = 0 + while i < len(mesh.faces): + face_size = mesh.faces[i] + i += 1 + + # Skip invalid faces + if face_size < 3: + continue + + # Get vertices for this face + verts = [] + for j in range(face_size): + if i >= len(mesh.faces): + break + + vert_idx = mesh.faces[i] + vertex_offset + i += 1 + + if vert_idx >= len(bm.verts): + continue + + verts.append(bm.verts[vert_idx]) + + # Create the face if we have enough valid vertices + if len(verts) >= 3: + try: + face = bm.faces.new(verts) + face.material_index = material_index + except Exception as e: + print(f"Failed to create face: {e}") + +def polyline_to_native(speckle_polyline: Polyline, name: str) -> bpy.types.Curve: + """Convert a Speckle polyline to a Blender curve. + + Args: + speckle_polyline: The Speckle polyline to convert + name: The name for the new Blender curve + + Returns: + A Blender curve data block + """ + # Get points from the polyline + points = speckle_polyline.get_points() + if not points: + raise ValueError("Polyline has no points") + + # Create a new curve data block + blender_curve = bpy.data.curves.new(name, type="CURVE") + blender_curve.dimensions = "3D" + + # Create a new spline in the curve + spline = blender_curve.splines.new("POLY") + spline.points.add(len(points) - 1) # Add points (default has 1, so add n-1 more) + + # Set the coordinates for each point + # Note: Blender curve points are 4D (x, y, z, w) where w is weight + for i, point in enumerate(points): + spline.points[i].co = ( + float(point.x), + float(point.y), + float(point.z), + 1.0, + ) + + # If the polyline is closed, set the spline to be cyclic + if speckle_polyline.is_closed(): + spline.use_cyclic_u = True + + return blender_curve + +def display_value_to_native( + speckle_object: Base, name: str, scale: float +) -> Tuple[Optional[bpy.types.Mesh], List[Object]]: + """Convert displayValue properties to Blender objects. + + Args: + speckle_object: The Speckle object to convert + name: The name for the new Blender objects + scale: The scale factor to apply + + Returns: + Tuple of (converted mesh, list of child objects) + """ + return _members_to_native( + speckle_object, name, scale, DISPLAY_VALUE_PROPERTY_ALIASES, True + ) + +def _members_to_native( + speckle_object: Base, + name: str, + scale: float, + members: Iterable[str], + combineMeshes: bool, +) -> Tuple[Optional[bpy.types.Mesh], List[Object]]: + """Convert specific members of a Speckle object to Blender objects. + + Args: + speckle_object: The Speckle object to convert + name: The name for the new Blender objects + scale: The scale factor to apply + members: The member properties to look for + combineMeshes: Whether to combine meshes into one + + Returns: + Tuple of (combined mesh, list of child objects) + """ + meshes: List[Mesh] = [] + others: List[Base] = [] + + for alias in members: + display = getattr(speckle_object, alias, None) + + count = 0 + MAX_DEPTH = 255 # Prevent infinite recursion + + def separate(value: Any) -> bool: + nonlocal meshes, others, count, MAX_DEPTH + + if combineMeshes and isinstance(value, Mesh): + meshes.append(value) + elif isinstance(value, Base): + others.append(value) + elif isinstance(value, list): + count += 1 + if count > MAX_DEPTH: + return True + for x in value: + separate(x) + + return False + + did_halt = separate(display) + + if did_halt: + print(f"Traversal halted after exceeding depth {MAX_DEPTH}") + + # Convert meshes and other objects + children: List[Object] = [] + mesh = None + + if meshes: + mesh = meshes_to_native(speckle_object, meshes, name, scale) + + for item in others: + try: + blender_object = convert_to_native(item) + children.append(blender_object) + except Exception as ex: + print(f"Failed to convert display value {item}: {ex}") + + return (mesh, children) + +def meshes_to_native(element: Base, meshes: List[Mesh], name: str, scale: float) -> bpy.types.Mesh: + """Convert multiple Speckle meshes to a single Blender mesh. + + Args: + element: The parent Speckle object + meshes: The Speckle meshes to convert + name: The name for the new Blender mesh + scale: The scale factor to apply + + Returns: + A Blender mesh + """ + if name in bpy.data.meshes.keys(): + return bpy.data.meshes[name] + + blender_mesh = bpy.data.meshes.new(name=name) + + bm = bmesh.new() + + # First pass: add vertices + for mesh in meshes: + add_vertices(mesh, bm, scale) + + bm.verts.ensure_lookup_table() + + # Second pass: add faces + offset = 0 + for i, mesh in enumerate(meshes): + if not mesh.vertices: + continue + + add_faces(mesh, bm, offset, i) + + offset += len(mesh.vertices) // 3 + + # Finalize and cleanup + bm.to_mesh(blender_mesh) + bm.free() + + return blender_mesh + +def create_new_object(obj_data, desired_name: str) -> bpy.types.Object: + """Create a new Blender object with a unique name. + + Args: + obj_data: The data to use for the object (e.g., mesh, curve) + desired_name: The desired name for the object + + Returns: + A new Blender object + """ + # Make sure the name is unique + name = _make_unique_name(desired_name, bpy.data.objects.keys()) + + # Create the object + blender_object = bpy.data.objects.new(name, obj_data) + + # Link it to the active collection if possible + if bpy.context.collection: + bpy.context.collection.objects.link(blender_object) + else: + # If no active collection, link to scene collection + bpy.context.scene.collection.objects.link(blender_object) + + return blender_object + +def _make_unique_name(desired_name: str, existing_names) -> str: + """Create a unique name by appending a number if necessary. + + Args: + desired_name: The desired name + existing_names: Collection of existing names to avoid duplicates + + Returns: + A unique name + """ + if desired_name not in existing_names: + return desired_name + + # If name exists, append numbers until we find a unique one + counter = 1 + while True: + new_name = f"{desired_name}.{counter:03d}" + if new_name not in existing_names: + return new_name + counter += 1 + +def _generate_object_name(speckle_object: Base) -> str: + """Generate a name for a Blender object based on a Speckle object. + + Args: + speckle_object: The Speckle object + + Returns: + A name for the object + """ + # Try to get a meaningful name + name = getattr(speckle_object, "name", None) + + if not name: + # Use the object type as a fallback + speckle_type = speckle_object.speckle_type + name = speckle_type.split(".")[-1] # Get the last part of the type name + + # Truncate if necessary + if len(name) > OBJECT_NAME_MAX_LENGTH - 10: # Leave room for speckle ID + name = name[:OBJECT_NAME_MAX_LENGTH - 10] + + # Add the Speckle ID for uniqueness + if hasattr(speckle_object, "id") and speckle_object.id: + return f"{name}{OBJECT_NAME_SPECKLE_SEPARATOR}{speckle_object.id[:8]}" + else: + return name \ No newline at end of file diff --git a/bpy_speckle/operators/load.py b/bpy_speckle/operators/load.py index 22b4817..d969414 100644 --- a/bpy_speckle/operators/load.py +++ b/bpy_speckle/operators/load.py @@ -1,5 +1,11 @@ import bpy from typing import Set +from specklepy.api.credentials import get_local_accounts +from specklepy.transports.server import ServerTransport +from specklepy.api import operations +from specklepy.api.client import SpeckleClient +from specklepy.objects.base import Base +from ..converter.to_native import can_convert_to_native, convert_to_native class SPECKLE_OT_load(bpy.types.Operator): bl_idname = "speckle.load" @@ -18,4 +24,112 @@ class SPECKLE_OT_load(bpy.types.Operator): self.report({'INFO'}, f"Load button clicked at {context.scene.speckle_state.mouse_position[0], context.scene.speckle_state.mouse_position[1]}") # Opens project_selection_dialog bpy.ops.speckle.project_selection_dialog("INVOKE_DEFAULT") - return {'FINISHED'} \ No newline at end of file + + return {'FINISHED'} + + @classmethod + def load(cls, context: bpy.types.Context, model_card) -> None: + + print("Load process started") + print(f"Loading project: {model_card.project_name}, model: {model_card.model_name}") + print(f"Project ID: {model_card.project_id}") + print(f"Version ID: {model_card.version_id}") + + try: + # get the account from local accounts + account = next((acc for acc in get_local_accounts() if acc.id == context.window_manager.selected_account_id), None) + if not account: + raise Exception("No Speckle account found") + + # initialize the Speckle client + client = SpeckleClient(host=account.serverInfo.url) + # authenticate with account + client.authenticate_with_account(account) + + # now we need a transport + transport = ServerTransport( + stream_id=model_card.project_id, + client=client + ) + + # get the version + version = client.version.get(model_card.version_id, model_card.project_id) + obj_id = version.referenced_object + + # receive the data + version_data = operations.receive( + obj_id, + transport + ) + + # TO DISCUSS + # create a root collection in Blender to hold all imported objects + root_collection_name = f"{model_card.model_name} - {model_card.version_id[:8]}" + root_collection = bpy.data.collections.new(root_collection_name) + context.scene.collection.children.link(root_collection) + + # start conversion process + context.window_manager.progress_begin(0, 100) + + # process and convert the received data to Blender objects + converted_objects = {} + traversal_queue = [version_data] + converted_count = 0 + total_count = getattr(version_data, "totalChildrenCount", 100) or 100 + + while traversal_queue: + current_object = traversal_queue.pop(0) + + # Skip if already processed + if hasattr(current_object, "id") and current_object.id in converted_objects: + continue + + try: + # check if this object can be converted + if can_convert_to_native(current_object): + # convert the object to Blender + blender_obj = convert_to_native(current_object) + + # store the converted object + if hasattr(current_object, "id"): + converted_objects[current_object.id] = blender_obj + + # link to the root collection if not already in a collection + if blender_obj.name not in root_collection.objects: + try: + root_collection.objects.link(blender_obj) + except RuntimeError: + # Object might already be linked to another collection + pass + + print(f"Successfully converted: {current_object.speckle_type}") + + # check for children/elements to process + children = [] + # look for common element properties + for prop_name in ["elements", "Elements", "@elements"]: + if hasattr(current_object, prop_name): + elements = getattr(current_object, prop_name) + if isinstance(elements, list): + children.extend(elements) + elif isinstance(elements, Base): + children.append(elements) + + # add all children to the traversal queue + traversal_queue.extend(children) + + except Exception as e: + print(f"Error converting object: {str(e)}") + + # update progress + converted_count += 1 + progress = int((converted_count / total_count) * 100) + context.window_manager.progress_update(min(progress, 100)) + + context.window_manager.progress_end() + print(f"Conversion completed. Converted {converted_count} objects.") + + except Exception as e: + print(f"Error loading from Speckle: {str(e)}") + context.window_manager.progress_end() + return \ No newline at end of file diff --git a/bpy_speckle/ui/model_card.py b/bpy_speckle/ui/model_card.py index 7d01fdd..89f7710 100644 --- a/bpy_speckle/ui/model_card.py +++ b/bpy_speckle/ui/model_card.py @@ -15,6 +15,7 @@ class speckle_model_card(bpy.types.PropertyGroup): version_id (StringProperty): Unique identifier of the selected version. """ project_name: bpy.props.StringProperty(name="Project Name", description="Name of the project", default="") # type: ignore + project_id: bpy.props.StringProperty(name="Project ID", description="ID of the selected project", default="") # type: ignore model_name: bpy.props.StringProperty(name="Model Name", description="Name of the model", default="") # type: ignore is_publish: bpy.props.BoolProperty(name="Publish/Load", description="If the model is published or loaded", default=False) # type: ignore selection_summary: bpy.props.StringProperty(name="Selection Summary", description="Summary of the selection", default="") # type: ignore @@ -28,6 +29,7 @@ class speckle_model_card(bpy.types.PropertyGroup): """ return { "project_name": self.project_name, + "project_id": self.project_id, "model_name": self.model_name, "is_publish": self.is_publish, "selection_summary": self.selection_summary, @@ -46,6 +48,7 @@ class speckle_model_card(bpy.types.PropertyGroup): """ item = cls() item.project_name = data["project_name"] + item.project_id = data["project_id"] item.model_name = data["model_name"] item.is_publish = data["is_publish"] item.selection_summary = data["selection_summary"] diff --git a/bpy_speckle/ui/version_selection_dialog.py b/bpy_speckle/ui/version_selection_dialog.py index 1dd0f53..d6e21b9 100644 --- a/bpy_speckle/ui/version_selection_dialog.py +++ b/bpy_speckle/ui/version_selection_dialog.py @@ -7,6 +7,7 @@ import bpy from bpy.types import WindowManager, UILayout, Context, PropertyGroup, Event from .mouse_position_mixin import MousePositionMixin from ..utils.version_manager import get_versions_for_model +from ..operators.load import SPECKLE_OT_load class speckle_version(bpy.types.PropertyGroup): """PropertyGroup for storing version information. @@ -132,11 +133,16 @@ class SPECKLE_OT_version_selection_dialog(MousePositionMixin, bpy.types.Operator def execute(self, context: Context) -> set[str]: model_card = context.scene.speckle_state.model_cards.add() model_card.project_name = self.project_name + model_card.project_id = self.project_id model_card.model_name = self.model_name model_card.is_publish = False # Store the selected version ID selected_version = context.window_manager.speckle_versions[self.version_index] model_card.version_id = selected_version.id + + # Call the load process class method + SPECKLE_OT_load.load(context, model_card) + return {'FINISHED'} def invoke(self, context: Context, event: Event) -> set[str]: diff --git a/bpy_speckle/utils/model_manager.py b/bpy_speckle/utils/model_manager.py index 79ff7ac..c5f8a41 100644 --- a/bpy_speckle/utils/model_manager.py +++ b/bpy_speckle/utils/model_manager.py @@ -60,7 +60,7 @@ def get_models_for_project(account_id: str, project_id: str, search: Optional[st # Get models models: List[Model] = client.model.get_models(project_id=project_id, models_limit=10, models_filter=filter).items - return [(model.name, model.id, format_relative_time(model.createdAt)) for model in models] + return [(model.name, model.id, format_relative_time(model.created_at)) for model in models] except Exception as e: print(f"Error fetching models: {str(e)}") diff --git a/bpy_speckle/utils/project_manager.py b/bpy_speckle/utils/project_manager.py index e587868..0398ef3 100644 --- a/bpy_speckle/utils/project_manager.py +++ b/bpy_speckle/utils/project_manager.py @@ -45,7 +45,7 @@ def get_projects_for_account(account_id: str, search: Optional[str] = None) -> L # Fetch projects projects = client.active_user.get_projects(limit=10, filter=filter).items - return [(project.name, format_role(project.role), format_relative_time(project.updatedAt), project.id) for project in projects] + return [(project.name, format_role(project.role), format_relative_time(project.updated_at), project.id) for project in projects] except Exception as e: import traceback diff --git a/bpy_speckle/utils/version_manager.py b/bpy_speckle/utils/version_manager.py index 08ab950..fc41a43 100644 --- a/bpy_speckle/utils/version_manager.py +++ b/bpy_speckle/utils/version_manager.py @@ -53,7 +53,7 @@ def get_versions_for_model(account_id: str, project_id: str, model_id: str, sear # Get versions versions: List[Version] = client.version.get_versions(project_id=project_id, model_id=model_id, limit=10, filter=filter).items - return [(version.id, version.message or "No message", format_relative_time(version.createdAt)) for version in versions] + return [(version.id, version.message or "No message", format_relative_time(version.created_at)) for version in versions] except Exception as e: print(f"Error fetching versions: {str(e)}") diff --git a/pyproject.toml b/pyproject.toml index a8b6d08..3f4e90a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,10 @@ name = "speckle-blender" version = "3.0.0" description = "Next-Gen Speckle connector for Blender!" -requires-python = ">=3.8, <4.0.0" +requires-python = ">=3.11.9, <4.0.0" license = "Apache-2.0" dependencies = [ - "specklepy>=2.21.1,<3", - "attrs>=23.1.0,<24", + "specklepy>=3.0.0a3" ] [dependency-groups]