diff --git a/csv_importer/addon.py b/csv_importer/addon.py index 700325c..86a3c45 100644 --- a/csv_importer/addon.py +++ b/csv_importer/addon.py @@ -1,7 +1,7 @@ import bpy from . import ops, props, ui -from .props import CSVImporterObjectProperties -from bpy.props import PointerProperty +from .props import CSVImporterObjectProperties, CSVExporterSceneProperties +from bpy.props import PointerProperty, EnumProperty from .ops import ImportCsvPolarsOperator from .utils import add_current_module_to_path @@ -19,6 +19,19 @@ def register(): bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_file_import.append(menu_func_import) bpy.types.Object.csv = PointerProperty(type=CSVImporterObjectProperties) # type: ignore + bpy.types.Scene.csv_export = PointerProperty(type=CSVExporterSceneProperties) # type: ignore + + # Add export type property to window manager for UI + bpy.types.WindowManager.csv_export_type = EnumProperty( # type: ignore + name="Export Type", + description="Type of file to export", + items=[ + ('CSV', "CSV", "Export as CSV"), + ('JSON', "JSON", "Export as JSON"), + ('PARQUET', "Parquet", "Export as Parquet") + ], + default='CSV' + ) def unregister(): @@ -26,3 +39,5 @@ def unregister(): for cls in reversed(CLASSES): bpy.utils.unregister_class(cls) del bpy.types.Object.csv # type: ignore + del bpy.types.Scene.csv_export # type: ignore + del bpy.types.WindowManager.csv_export_type # type: ignore diff --git a/csv_importer/exporters.py b/csv_importer/exporters.py new file mode 100644 index 0000000..02403a0 --- /dev/null +++ b/csv_importer/exporters.py @@ -0,0 +1,77 @@ +import databpy as db +import polars as pl +import bpy + + +def from_blender_to_polars_df(blender_object: bpy.types.Object) -> pl.DataFrame: + """ + Convert a Blender mesh object to a basic Polars DataFrame containing mesh attributes. + + Args: + blender_object: The Blender object to convert + + Returns: + pl.DataFrame: Basic DataFrame containing raw mesh attribute data + """ + # Evaluate the object and get mesh data + evaluated_obj = db.evaluate_object(blender_object) + mesh = evaluated_obj.to_mesh() + + # Collect all attribute data + attribute_data = {} + for attr in mesh.attributes: + if attr.name not in {'sharp_face', 'UVMap'} and not attr.name.startswith('.'): + a = db.named_attribute(evaluated_obj, attr.name) + attribute_data[attr.name] = a + + # Create and return basic polars DataFrame + return pl.DataFrame(attribute_data) + + +def from_polars_df_to_csv(df: pl.DataFrame, export_path: str) -> None: + """ + Process a Polars DataFrame and export it to a CSV file. + Handles array expansion, column reordering, and CSV writing. + + Args: + df: The Polars DataFrame to process and export + export_path: The file path where the CSV should be saved + """ + # Sort columns so "position" is first + if "position" in df.columns: + column_order = ["position"] + [col for col in df.columns if col != "position"] + df = df.select(column_order) + + # Check dtypes and expand array columns + dtypes = df.dtypes + array_columns = [] + expanded_df = df.clone() + + for i, dtype in enumerate(dtypes): + col_name = df.columns[i] + if str(dtype).startswith('Array'): + array_columns.append(col_name) + + # Get the array length from the first non-null value + first_array = expanded_df.select(pl.col(col_name)).item(0, 0) + array_length = len(first_array) + + # Expand array into separate columns with indexed names + for j in range(array_length): + expanded_df = expanded_df.with_columns([ + pl.col(col_name).arr.get(j).alias(f"{col_name}{j+1}") + ]) + # Drop the original array column + expanded_df = expanded_df.drop(col_name) + + # Reorder columns to place position columns first + position_columns = [col for col in expanded_df.columns if col.startswith('position')] + other_columns = [col for col in expanded_df.columns if not col.startswith('position')] + column_order = position_columns + other_columns + expanded_df = expanded_df.select(column_order) + + # Write processed DataFrame to CSV file + expanded_df.write_csv(export_path) + + + diff --git a/csv_importer/ops.py b/csv_importer/ops.py index c19136e..03ad4f6 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -4,7 +4,10 @@ from bpy_extras.io_utils import ImportHelper from .csv import load_csv from .parsers import update_obj_from_csv +from .exporters import from_blender_to_polars_df, from_polars_df_to_csv from pathlib import Path +import csv +import os # based on the blender docs: https://docs.blender.org/api/current/bpy.types.FileHandler.html#basic-filehandler-for-operator-that-imports-just-one-file @@ -131,10 +134,69 @@ def execute(self, context): self.report({"INFO"}, "Hot reload started") return {"FINISHED"} +class CSV_OT_ExportData(bpy.types.Operator): + bl_idname = "csv.export_data" + bl_label = "Export Data" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Export mesh attribute data to file" + + export_type: bpy.props.EnumProperty( # type: ignore + name="Export Type", + description="Type of file to export", + items=[ + ('CSV', "CSV", "Export as CSV"), + ('JSON', "JSON", "Export as JSON"), + ('PARQUET', "Parquet", "Export as Parquet") + ], + default='CSV' + ) + + def execute(self, context): + scene = context.scene + export_path = bpy.path.abspath(scene.csv_export.export_path) + export_object = scene.csv_export.export_object + + # Check if an object is selected + if export_object is None: + self.report({"WARNING"}, "No object selected for export") + return {"CANCELLED"} + + # Print the object name + print(f"Exporting data from object: {export_object.name}") + + # Create directory if it doesn't exist + directory = os.path.dirname(export_path) + if directory and not os.path.exists(directory): + try: + os.makedirs(directory) + except OSError as e: + self.report({"ERROR"}, f"Failed to create directory: {e}") + return {"CANCELLED"} + + try: + # Convert Blender object to Polars DataFrame + df = from_blender_to_polars_df(export_object) + + # Export DataFrame based on selected type + if self.export_type == 'CSV': + from_polars_df_to_csv(df, export_path) + elif self.export_type == 'JSON': + df.write_json(export_path) + else: # PARQUET + df.write_parquet(export_path) + + self.report({"INFO"}, f"{self.export_type} file exported to: {export_path} for object: {export_object.name} ({len(df)} rows, {len(df.columns)} columns)") + return {"FINISHED"} + + except Exception as e: + self.report({"ERROR"}, f"Failed to export {self.export_type} file: {e}") + return {"CANCELLED"} + CLASSES = ( ImportCsvPolarsOperator, CSV_FH_import, CSV_OP_ReloadData, CSV_OT_ToggleHotReload, + CSV_OT_ExportData, ) diff --git a/csv_importer/props.py b/csv_importer/props.py index bec2987..9a2332d 100644 --- a/csv_importer/props.py +++ b/csv_importer/props.py @@ -1,5 +1,6 @@ from bpy.types import PropertyGroup -from bpy.props import StringProperty, IntProperty, BoolProperty +from bpy.props import StringProperty, IntProperty, BoolProperty, PointerProperty +import bpy class CSVImporterObjectProperties(PropertyGroup): @@ -23,4 +24,20 @@ class CSVImporterObjectProperties(PropertyGroup): ) -CLASSES = (CSVImporterObjectProperties,) +class CSVExporterSceneProperties(PropertyGroup): + export_path: StringProperty( # type: ignore + name="Export Path", + description="Path where the CSV file will be exported", + subtype="FILE_PATH", + default="//exported_data.csv", + ) + + export_object: PointerProperty( # type: ignore + name="Export Object", + description="Object to export data from", + type=bpy.types.Object, + poll=lambda self, obj: obj.type == 'MESH', + ) + + +CLASSES = (CSVImporterObjectProperties, CSVExporterSceneProperties) diff --git a/csv_importer/ui.py b/csv_importer/ui.py index c9a7510..d3490bb 100644 --- a/csv_importer/ui.py +++ b/csv_importer/ui.py @@ -40,5 +40,35 @@ def draw(self, context): "csv.toggle_hot_reload", text=message, icon=icon, depress=obj.csv.hot_reload ) +class CSV_PT_ExportPanel(bpy.types.Panel): + bl_label = "Export" + bl_idname = "CSV_PT_ExportPanel" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_order = 0 + bl_options = {"HEADER_LAYOUT_EXPAND"} + + def draw(self, context): + layout = self.layout + scene = context.scene + + # Object selector + row = layout.row() + row.prop(scene.csv_export, "export_object", text="Object") + + # Export path field + row = layout.row() + row.prop(scene.csv_export, "export_path", text="Export Path") + + # Export format buttons + row = layout.row(align=True) + op = row.operator("csv.export_data", text="CSV", icon="FILE") + op.export_type = 'CSV' + op = row.operator("csv.export_data", text="JSON", icon="FILE") + op.export_type = 'JSON' + op = row.operator("csv.export_data", text="Parquet", icon="FILE") + op.export_type = 'PARQUET' + -CLASSES = (CSV_PT_ObjectPanel,) +CLASSES = (CSV_PT_ObjectPanel, CSV_PT_ExportPanel)