Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ 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 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)
df.write_json(export_path)
#from_polars_df_to_json(df, path)
```

- Add JSON Export Menu

## Verison 0.1.9

Expand Down
8 changes: 7 additions & 1 deletion csv_importer/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ExportJsonPolarsOperator
from .utils import add_current_module_to_path

CLASSES = ops.CLASSES + props.CLASSES + ui.CLASSES
Expand All @@ -12,17 +12,23 @@
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)")
self.layout.operator(ExportJsonPolarsOperator.bl_idname, text="JSON 🐻 (.json)")


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
118 changes: 118 additions & 0 deletions csv_importer/exporters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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)


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)
84 changes: 82 additions & 2 deletions csv_importer/ops.py
Original file line number Diff line number Diff line change
@@ -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, 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
Expand Down Expand Up @@ -70,6 +70,84 @@ 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 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"
Expand Down Expand Up @@ -135,6 +213,8 @@ def execute(self, context):
CLASSES = (
ImportCsvPolarsOperator,
CSV_FH_import,
ExportCsvPolarsOperator,
ExportJsonPolarsOperator,
CSV_OP_ReloadData,
CSV_OT_ToggleHotReload,
)