From 5900c5feeb14658bece5d08f720501ef114cc12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Mon, 16 Jun 2025 08:35:50 +0200 Subject: [PATCH 1/4] another exporter implementation --- csv_importer/addon.py | 7 +++- csv_importer/exporters.py | 74 +++++++++++++++++++++++++++++++++++++++ csv_importer/ops.py | 44 +++++++++++++++++++++-- 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 csv_importer/exporters.py diff --git a/csv_importer/addon.py b/csv_importer/addon.py index 700325c..5f16665 100644 --- a/csv_importer/addon.py +++ b/csv_importer/addon.py @@ -2,7 +2,7 @@ from . import ops, props, ui from .props import CSVImporterObjectProperties from bpy.props import PointerProperty -from .ops import ImportCsvPolarsOperator +from .ops import ImportCsvPolarsOperator, ExportCsvPolarsOperator from .utils import add_current_module_to_path CLASSES = ops.CLASSES + props.CLASSES + ui.CLASSES @@ -12,17 +12,22 @@ def menu_func_import(self, context): self.layout.operator(ImportCsvPolarsOperator.bl_idname, text="CSV 🐻 (.csv)") +def menu_func_export(self, context): + self.layout.operator(ExportCsvPolarsOperator.bl_idname, text="CSV 🐻 (.csv)") + def register(): add_current_module_to_path() for cls in CLASSES: bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) bpy.types.Object.csv = PointerProperty(type=CSVImporterObjectProperties) # type: ignore def unregister(): bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) for cls in reversed(CLASSES): bpy.utils.unregister_class(cls) del bpy.types.Object.csv # type: ignore 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 c19136e..388b56c 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -1,11 +1,11 @@ import bpy from bpy.props import StringProperty import time -from bpy_extras.io_utils import ImportHelper +from bpy_extras.io_utils import ImportHelper, ExportHelper from .csv import load_csv from .parsers import update_obj_from_csv from pathlib import Path - +from .exporters import from_blender_to_polars_df, from_polars_df_to_csv # based on the blender docs: https://docs.blender.org/api/current/bpy.types.FileHandler.html#basic-filehandler-for-operator-that-imports-just-one-file # and tweaked with this prompt: https://chatgpt.com/share/675b0831-354c-8013-bae0-9bb91d527f32 @@ -70,6 +70,45 @@ def poll_drop(cls, context): return context.area +class ExportCsvPolarsOperator(bpy.types.Operator, ExportHelper): + bl_idname = "export_scene.export_csv_polars" + bl_label = "Export CSV (Polars)" + bl_options = {"PRESET", "UNDO"} + + filepath: StringProperty(subtype="FILE_PATH") # type: ignore + + filename_ext = ".csv" + filter_glob: StringProperty( # type: ignore + default="*.csv", + options={"HIDDEN"}, + maxlen=255, + ) + + def execute(self, context): + export_object = context.active_object + + if export_object is None: + self.report({"WARNING"}, "No object selected") + return {"CANCELLED"} + + if export_object.type != "MESH": + self.report({"WARNING"}, "Selected object is not a mesh") + return {"CANCELLED"} + + start_time = time.perf_counter() + + df = from_blender_to_polars_df(export_object) + from_polars_df_to_csv(df, self.filepath) + + elapsed_time_ms = (time.perf_counter() - start_time) * 1000 + + self.report( + {"INFO"}, + f" 🐻‍❄️ 📤 Exported {export_object.name} in {elapsed_time_ms:.2f} ms", + ) + return {"FINISHED"} + + class CSV_OP_ReloadData(bpy.types.Operator): bl_idname = "csv.reload_data" bl_label = "Reload Data" @@ -135,6 +174,7 @@ def execute(self, context): CLASSES = ( ImportCsvPolarsOperator, CSV_FH_import, + ExportCsvPolarsOperator, CSV_OP_ReloadData, CSV_OT_ToggleHotReload, ) From 0807377b6b2e5a77ac15de8950f2f5caaa47b49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:02:52 +0200 Subject: [PATCH 2/4] api --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index c502309..1224970 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,22 @@ uv run -m pytest # Changelog +## Version 0.2 + +- Add CSV Export Menu (at `File -> Export` ) +- Add Export API for CSV: +```py +from csv_importer.exporters import from_blender_to_polars_df, from_polars_df_to_csv +from pathlib import Path +import bpy + +path = Path.home() / "Desktop/export.csv" +export_object = bpy.data.objects["Cube"] +df = from_blender_to_polars_df(export_object) +from_polars_df_to_csv(df, path) +``` + +- Add JSON Export Menu ## Verison 0.1.9 From 1740b3597be993c5dbc7bf78836a979897e7e7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:09:43 +0200 Subject: [PATCH 3/4] add json export --- README.md | 12 ++++++++++ csv_importer/addon.py | 3 ++- csv_importer/exporters.py | 46 ++++++++++++++++++++++++++++++++++++++- csv_importer/ops.py | 42 ++++++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1224970..b59767f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,18 @@ df = from_blender_to_polars_df(export_object) from_polars_df_to_csv(df, path) ``` +- Add Export API for JSON: +```py +from csv_importer.exporters import from_blender_to_polars_df, from_polars_df_to_json +from pathlib import Path +import bpy + +path = Path.home() / "Desktop/export.json" +export_object = bpy.data.objects["Cube"] +df = from_blender_to_polars_df(export_object) +from_polars_df_to_json(df, path) +``` + - Add JSON Export Menu ## Verison 0.1.9 diff --git a/csv_importer/addon.py b/csv_importer/addon.py index 5f16665..dff93d7 100644 --- a/csv_importer/addon.py +++ b/csv_importer/addon.py @@ -2,7 +2,7 @@ from . import ops, props, ui from .props import CSVImporterObjectProperties from bpy.props import PointerProperty -from .ops import ImportCsvPolarsOperator, ExportCsvPolarsOperator +from .ops import ImportCsvPolarsOperator, ExportCsvPolarsOperator, ExportJsonPolarsOperator from .utils import add_current_module_to_path CLASSES = ops.CLASSES + props.CLASSES + ui.CLASSES @@ -14,6 +14,7 @@ def menu_func_import(self, context): def menu_func_export(self, context): self.layout.operator(ExportCsvPolarsOperator.bl_idname, text="CSV 🐻 (.csv)") + self.layout.operator(ExportJsonPolarsOperator.bl_idname, text="JSON 🐻 (.json)") def register(): diff --git a/csv_importer/exporters.py b/csv_importer/exporters.py index 9a2f998..5212c0d 100644 --- a/csv_importer/exporters.py +++ b/csv_importer/exporters.py @@ -71,4 +71,48 @@ 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) + + +def from_polars_df_to_json(df: pl.DataFrame, export_path: str) -> None: + """ + Process a Polars DataFrame and export it to a JSON file. + Handles array expansion, column reordering, and JSON writing. + + Args: + df: The Polars DataFrame to process and export + export_path: The file path where the JSON 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 JSON file + expanded_df.write_json(export_path) \ No newline at end of file diff --git a/csv_importer/ops.py b/csv_importer/ops.py index 388b56c..73a2f0e 100644 --- a/csv_importer/ops.py +++ b/csv_importer/ops.py @@ -5,7 +5,7 @@ from .csv import load_csv from .parsers import update_obj_from_csv from pathlib import Path -from .exporters import from_blender_to_polars_df, from_polars_df_to_csv +from .exporters import from_blender_to_polars_df, from_polars_df_to_csv, from_polars_df_to_json # based on the blender docs: https://docs.blender.org/api/current/bpy.types.FileHandler.html#basic-filehandler-for-operator-that-imports-just-one-file # and tweaked with this prompt: https://chatgpt.com/share/675b0831-354c-8013-bae0-9bb91d527f32 @@ -109,6 +109,45 @@ def execute(self, context): return {"FINISHED"} +class ExportJsonPolarsOperator(bpy.types.Operator, ExportHelper): + bl_idname = "export_scene.export_json_polars" + bl_label = "Export JSON (Polars)" + bl_options = {"PRESET", "UNDO"} + + filepath: StringProperty(subtype="FILE_PATH") # type: ignore + + filename_ext = ".json" + filter_glob: StringProperty( # type: ignore + default="*.json", + options={"HIDDEN"}, + maxlen=255, + ) + + def execute(self, context): + export_object = context.active_object + + if export_object is None: + self.report({"WARNING"}, "No object selected") + return {"CANCELLED"} + + if export_object.type != "MESH": + self.report({"WARNING"}, "Selected object is not a mesh") + return {"CANCELLED"} + + start_time = time.perf_counter() + + df = from_blender_to_polars_df(export_object) + from_polars_df_to_json(df, self.filepath) + + elapsed_time_ms = (time.perf_counter() - start_time) * 1000 + + self.report( + {"INFO"}, + f" 🐻‍❄️ 📤 Exported {export_object.name} as JSON in {elapsed_time_ms:.2f} ms", + ) + return {"FINISHED"} + + class CSV_OP_ReloadData(bpy.types.Operator): bl_idname = "csv.reload_data" bl_label = "Reload Data" @@ -175,6 +214,7 @@ def execute(self, context): ImportCsvPolarsOperator, CSV_FH_import, ExportCsvPolarsOperator, + ExportJsonPolarsOperator, CSV_OP_ReloadData, CSV_OT_ToggleHotReload, ) From 9bd0cf0529e98d935f7d2c60682c162755d6635a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Hendrik=20M=C3=BCller?= <44469195+kolibril13@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:13:35 +0200 Subject: [PATCH 4/4] write json --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b59767f..ae47ee1 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ import bpy path = Path.home() / "Desktop/export.json" export_object = bpy.data.objects["Cube"] df = from_blender_to_polars_df(export_object) -from_polars_df_to_json(df, path) +df.write_json(export_path) +#from_polars_df_to_json(df, path) ``` - Add JSON Export Menu