From fad55bf3e9836b3ac481c5309bcd6227aba4b797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:29:22 +0200 Subject: [PATCH 1/5] add export CSV field --- csv_importer/addon.py | 4 +++- csv_importer/ops.py | 37 +++++++++++++++++++++++++++++++++++++ csv_importer/props.py | 11 ++++++++++- csv_importer/ui.py | 24 +++++++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/csv_importer/addon.py b/csv_importer/addon.py index 700325c..7de2a6e 100644 --- a/csv_importer/addon.py +++ b/csv_importer/addon.py @@ -1,6 +1,6 @@ import bpy from . import ops, props, ui -from .props import CSVImporterObjectProperties +from .props import CSVImporterObjectProperties, CSVExporterSceneProperties from bpy.props import PointerProperty from .ops import ImportCsvPolarsOperator from .utils import add_current_module_to_path @@ -19,6 +19,7 @@ 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 def unregister(): @@ -26,3 +27,4 @@ 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 diff --git a/csv_importer/ops.py b/csv_importer/ops.py index c19136e..2650fce 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -5,6 +5,8 @@ from .csv import load_csv from .parsers import update_obj_from_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 @@ -132,9 +134,44 @@ def execute(self, context): return {"FINISHED"} +class CSV_OT_ExportData(bpy.types.Operator): + bl_idname = "csv.export_data" + bl_label = "Export CSV" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Export an empty CSV file at the specified path" + + def execute(self, context): + scene = context.scene + export_path = bpy.path.abspath(scene.csv_export.export_path) + + # 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: + # Create an empty CSV file with basic header + with open(export_path, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile) + # Write a basic header row + writer.writerow(['x', 'y', 'z']) + + self.report({"INFO"}, f"Empty CSV file created at: {export_path}") + return {"FINISHED"} + + except Exception as e: + self.report({"ERROR"}, f"Failed to create CSV 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..fb78a85 100644 --- a/csv_importer/props.py +++ b/csv_importer/props.py @@ -23,4 +23,13 @@ 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", + ) + + +CLASSES = (CSVImporterObjectProperties, CSVExporterSceneProperties) diff --git a/csv_importer/ui.py b/csv_importer/ui.py index c9a7510..b833916 100644 --- a/csv_importer/ui.py +++ b/csv_importer/ui.py @@ -41,4 +41,26 @@ def draw(self, context): ) -CLASSES = (CSV_PT_ObjectPanel,) +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 + + # Export path field + row = layout.row() + row.prop(scene.csv_export, "export_path", text="Export Path") + + # Export button + row = layout.row() + row.operator("csv.export_data", text="Export CSV", icon="EXPORT") + + +CLASSES = (CSV_PT_ObjectPanel, CSV_PT_ExportPanel) From af4414f86431ae2a869bfa60d69fbce1d7466ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:33:15 +0200 Subject: [PATCH 2/5] add export object selector --- csv_importer/ops.py | 11 ++++++++++- csv_importer/props.py | 10 +++++++++- csv_importer/ui.py | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/csv_importer/ops.py b/csv_importer/ops.py index 2650fce..4dd7216 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -143,6 +143,15 @@ class CSV_OT_ExportData(bpy.types.Operator): 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) @@ -160,7 +169,7 @@ def execute(self, context): # Write a basic header row writer.writerow(['x', 'y', 'z']) - self.report({"INFO"}, f"Empty CSV file created at: {export_path}") + self.report({"INFO"}, f"Empty CSV file created at: {export_path} for object: {export_object.name}") return {"FINISHED"} except Exception as e: diff --git a/csv_importer/props.py b/csv_importer/props.py index fb78a85..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): @@ -30,6 +31,13 @@ class CSVExporterSceneProperties(PropertyGroup): 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 b833916..b56539f 100644 --- a/csv_importer/ui.py +++ b/csv_importer/ui.py @@ -54,6 +54,10 @@ 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") From 01e525511162cb2aa573539581b3b165bae0fb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:37:28 +0200 Subject: [PATCH 3/5] add logic --- csv_importer/ops.py | 63 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/csv_importer/ops.py b/csv_importer/ops.py index 4dd7216..0788230 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -138,7 +138,7 @@ class CSV_OT_ExportData(bpy.types.Operator): bl_idname = "csv.export_data" bl_label = "Export CSV" bl_options = {"REGISTER", "UNDO"} - bl_description = "Export an empty CSV file at the specified path" + bl_description = "Export mesh attribute data to CSV file" def execute(self, context): scene = context.scene @@ -163,17 +163,64 @@ def execute(self, context): return {"CANCELLED"} try: - # Create an empty CSV file with basic header - with open(export_path, 'w', newline='', encoding='utf-8') as csvfile: - writer = csv.writer(csvfile) - # Write a basic header row - writer.writerow(['x', 'y', 'z']) + import databpy as db + import polars as pl - self.report({"INFO"}, f"Empty CSV file created at: {export_path} for object: {export_object.name}") + # Evaluate the object and get mesh data + evaluated_obj = db.evaluate_object(export_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 polars DataFrame + df = pl.DataFrame(attribute_data) + + # 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 to CSV file + expanded_df.write_csv(export_path) + + self.report({"INFO"}, f"CSV file exported to: {export_path} for object: {export_object.name} ({len(expanded_df)} rows, {len(expanded_df.columns)} columns)") return {"FINISHED"} except Exception as e: - self.report({"ERROR"}, f"Failed to create CSV file: {e}") + self.report({"ERROR"}, f"Failed to export CSV file: {e}") return {"CANCELLED"} From 3c9e7569f4aa2661b4c180e241bee629cbcff008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:39:45 +0200 Subject: [PATCH 4/5] move to exporter logic --- csv_importer/exporters.py | 74 +++++++++++++++++++++++++++++++++++++++ csv_importer/ops.py | 58 ++++-------------------------- 2 files changed, 80 insertions(+), 52 deletions(-) create mode 100644 csv_importer/exporters.py diff --git a/csv_importer/exporters.py b/csv_importer/exporters.py new file mode 100644 index 0000000..9a2f998 --- /dev/null +++ b/csv_importer/exporters.py @@ -0,0 +1,74 @@ +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) \ No newline at end of file diff --git a/csv_importer/ops.py b/csv_importer/ops.py index 0788230..1b9959c 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -4,6 +4,7 @@ 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 @@ -163,60 +164,13 @@ def execute(self, context): return {"CANCELLED"} try: - import databpy as db - import polars as pl + # Convert Blender object to Polars DataFrame + df = from_blender_to_polars_df(export_object) - # Evaluate the object and get mesh data - evaluated_obj = db.evaluate_object(export_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 polars DataFrame - df = pl.DataFrame(attribute_data) - - # 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 to CSV file - expanded_df.write_csv(export_path) + # Export DataFrame to CSV + from_polars_df_to_csv(df, export_path) - self.report({"INFO"}, f"CSV file exported to: {export_path} for object: {export_object.name} ({len(expanded_df)} rows, {len(expanded_df.columns)} columns)") + self.report({"INFO"}, f"CSV file exported to: {export_path} for object: {export_object.name} ({len(df)} rows, {len(df.columns)} columns)") return {"FINISHED"} except Exception as e: From d183f992faf27a92f2077daaad77a545f5d9fabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:53:41 +0200 Subject: [PATCH 5/5] add JSON and PARQUET --- csv_importer/addon.py | 15 ++++++++++++++- csv_importer/exporters.py | 5 ++++- csv_importer/ops.py | 29 ++++++++++++++++++++++------- csv_importer/ui.py | 12 ++++++++---- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/csv_importer/addon.py b/csv_importer/addon.py index 7de2a6e..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, CSVExporterSceneProperties -from bpy.props import PointerProperty +from bpy.props import PointerProperty, EnumProperty from .ops import ImportCsvPolarsOperator from .utils import add_current_module_to_path @@ -20,6 +20,18 @@ def register(): 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(): @@ -28,3 +40,4 @@ def unregister(): 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 index 9a2f998..02403a0 100644 --- a/csv_importer/exporters.py +++ b/csv_importer/exporters.py @@ -71,4 +71,7 @@ def from_polars_df_to_csv(df: pl.DataFrame, export_path: str) -> None: expanded_df = expanded_df.select(column_order) # Write processed DataFrame to CSV file - expanded_df.write_csv(export_path) \ No newline at end of file + expanded_df.write_csv(export_path) + + + diff --git a/csv_importer/ops.py b/csv_importer/ops.py index 1b9959c..03ad4f6 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -134,12 +134,22 @@ 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 CSV" + bl_label = "Export Data" bl_options = {"REGISTER", "UNDO"} - bl_description = "Export mesh attribute data to CSV file" + 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 @@ -167,14 +177,19 @@ def execute(self, context): # Convert Blender object to Polars DataFrame df = from_blender_to_polars_df(export_object) - # Export DataFrame to CSV - from_polars_df_to_csv(df, export_path) + # 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"CSV file exported to: {export_path} for object: {export_object.name} ({len(df)} rows, {len(df.columns)} columns)") + 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 CSV file: {e}") + self.report({"ERROR"}, f"Failed to export {self.export_type} file: {e}") return {"CANCELLED"} diff --git a/csv_importer/ui.py b/csv_importer/ui.py index b56539f..d3490bb 100644 --- a/csv_importer/ui.py +++ b/csv_importer/ui.py @@ -40,7 +40,6 @@ 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" @@ -62,9 +61,14 @@ def draw(self, context): row = layout.row() row.prop(scene.csv_export, "export_path", text="Export Path") - # Export button - row = layout.row() - row.operator("csv.export_data", text="Export CSV", icon="EXPORT") + # 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, CSV_PT_ExportPanel)