# -*- coding: utf-8 -*- """ CSV2Objects Macro CSV2Objects.FCMacro Author: Lukas Waschul Version: 0.1.0 Date: 2025-01-04 FreeCAD version: 0.21 or later License: MIT Website: https://github.com/DasLukas/CSV2Objects Description: CSV2Objects is a macro that generates large batches of 3D text objects from CSV files. It maps CSV columns to horizontal sketch guide lines, creates ShapeStrings on the sketch plane, extrudes them, and optionally fuses them with a target solid. It supports batch export to STL, 3MF, and STEP, and restores the document state after a final export. """ import FreeCAD as App import FreeCADGui as Gui import Part import Draft import Mesh import ImportGui import csv import os import sys import glob try: from PySide2 import QtCore, QtGui, QtWidgets except ImportError: from PySide import QtCore, QtGui, QtGui as QtWidgets MAX_PATH_LEN = 240 # conservative upper bound for file path lengths class CSV2ObjectsTaskPanel: """ Task panel to place CSV-driven text on sketch guide lines and export geometry. - Text from CSV onto guide lines - ShapeString + extrusion - Live preview (first CSV row) - Optional boolean fuse with a target body (per CSV row) - STL/3MF/STEP export into a subfolder next to the FCStd file - Restore the document state after a final export """ def __init__(self): self.doc = App.ActiveDocument if self.doc is None: raise RuntimeError("No active document is open.") # Preview objects (ShapeStrings + Extrudes) of the live preview self.preview_objects = [] # (adoption handled on OK) # ---------- Main widget ---------- self.form = QtWidgets.QWidget() self.form.setWindowTitle("CSV text on guide lines") main_layout = QtWidgets.QVBoxLayout(self.form) # ------------------------------------------------- # CSV settings # ------------------------------------------------- csv_group = QtWidgets.QGroupBox("CSV Settings") csv_layout = QtWidgets.QGridLayout(csv_group) self.csv_path_edit = QtWidgets.QLineEdit() self.csv_browse_btn = QtWidgets.QPushButton("Browse CSV…") csv_layout.addWidget(QtWidgets.QLabel("CSV file:"), 0, 0) csv_layout.addWidget(self.csv_path_edit, 0, 1) csv_layout.addWidget(self.csv_browse_btn, 0, 2) self.encoding_combo = QtWidgets.QComboBox() self.encoding_combo.addItems(["utf-8", "latin-1", "cp1252"]) self.encoding_combo.setCurrentText("utf-8") # Put encoding and delimiter on the same row self.delimiter_edit = QtWidgets.QLineEdit(";") self.delimiter_edit.setFixedWidth(40) enc_widget = QtWidgets.QWidget() enc_h = QtWidgets.QHBoxLayout(enc_widget) enc_h.setContentsMargins(0, 0, 0, 0) enc_h.addWidget(QtWidgets.QLabel("Encoding:")) enc_h.addWidget(self.encoding_combo) enc_h.addSpacing(12) enc_h.addWidget(QtWidgets.QLabel("Delimiter:")) enc_h.addWidget(self.delimiter_edit) csv_layout.addWidget(enc_widget, 1, 0, 1, 3) self.load_csv_btn = QtWidgets.QPushButton("Load CSV") csv_layout.addWidget(self.load_csv_btn, 2, 0, 1, 3) # Info label: show CSV columns/rows after loading (no popup) self.csv_info_label = QtWidgets.QLabel("") csv_layout.addWidget(self.csv_info_label, 3, 0, 1, 3) main_layout.addWidget(csv_group) # ------------------------------------------------- # Sketch / CSV Mapping # ------------------------------------------------- sketch_group = QtWidgets.QGroupBox("Sketch / CSV Mapping") sketch_layout = QtWidgets.QVBoxLayout(sketch_group) hl1 = QtWidgets.QHBoxLayout() self.sketch_combo = QtWidgets.QComboBox() hl1.addWidget(QtWidgets.QLabel("Sketch:")) hl1.addWidget(self.sketch_combo) self.scan_lines_btn = QtWidgets.QPushButton("Scan guide lines") hl1.addWidget(self.scan_lines_btn) try: self.scan_lines_btn.setVisible(False) except Exception: pass sketch_layout.addLayout(hl1) self.lines_table = QtWidgets.QTableWidget() # Columns (internally): Active, Geo index, Y, CSV column, Align, Size, Extrude # Geo index and Y are kept as hidden internal columns for placement data self.lines_table.setColumnCount(7) self.lines_table.setHorizontalHeaderLabels([ "Active", "Geo index", "Y", "CSV column", "Align", "Height [mm]", "Extrude [mm]", ]) # hide internal placement columns from the user try: self.lines_table.setColumnHidden(1, True) self.lines_table.setColumnHidden(2, True) except Exception: pass self.lines_table.horizontalHeader().setStretchLastSection(True) self.lines_table.setEditTriggers(QtWidgets.QAbstractItemView.AllEditTriggers) sketch_layout.addWidget(self.lines_table) main_layout.addWidget(sketch_group) # ------------------------------------------------- # Text / extrusion / boolean / live preview # ------------------------------------------------- geo_group = QtWidgets.QGroupBox("Text & Extrusion Settings") geo_layout = QtWidgets.QGridLayout(geo_group) # System fonts self.system_fonts = self._find_system_fonts() self.system_font_combo = QtWidgets.QComboBox() self.system_font_combo.addItem("– Choose system font –", "") for name, path in sorted(self.system_fonts.items()): self.system_font_combo.addItem(name, path) # Choose a sensible default system font per platform with fallbacks try: preferred = [] if sys.platform.startswith("win"): preferred = ["arial", "segoe", "tahoma", "calibri"] elif sys.platform == "darwin": preferred = ["helvetica", "arial", "menlo", "sf", "san"] else: preferred = ["dejavusans", "liberation", "freesans", "noto", "ubuntu"] chosen = False # search for preferred substrings in the display name (case-insensitive) for p in preferred: for i in range(1, self.system_font_combo.count()): try: txt = self.system_font_combo.itemText(i).lower() except Exception: txt = "" if p in txt: self.system_font_combo.setCurrentIndex(i) chosen = True break if chosen: break # fallback: if no preferred found but there are fonts, pick the first if not chosen and self.system_font_combo.count() > 1: self.system_font_combo.setCurrentIndex(1) # ensure the font path field is updated when we set the index programmatically try: self.on_system_font_changed(self.system_font_combo.currentIndex()) except Exception: pass except Exception: pass geo_layout.addWidget(QtWidgets.QLabel("System Font:"), 0, 0) geo_layout.addWidget(self.system_font_combo, 0, 1, 1, 2) # Custom font file self.font_path_edit = QtWidgets.QLineEdit() self.font_browse_btn = QtWidgets.QPushButton("Choose font file…") geo_layout.addWidget(QtWidgets.QLabel("TTF/OTF file:"), 1, 0) geo_layout.addWidget(self.font_path_edit, 1, 1) geo_layout.addWidget(self.font_browse_btn, 1, 2) # Ensure font_path_edit is populated if a system font was selected above try: idx = self.system_font_combo.currentIndex() if idx >= 0: self.on_system_font_changed(idx) except Exception: pass # Note: per-guideline text height and extrusion height are set # per-line in the Guide Lines table. Global font scale and global # extrusion height controls were removed in favor of per-line values. # Boolean mode (final only) self.boolean_mode_combo = QtWidgets.QComboBox() self.boolean_mode_combo.addItems([ "Generate text bodies only", "Fuse with target body" ]) geo_layout.addWidget(QtWidgets.QLabel("Extrusion Mode:"), 4, 0) geo_layout.addWidget(self.boolean_mode_combo, 4, 1) self.target_body_combo = QtWidgets.QComboBox() geo_layout.addWidget(QtWidgets.QLabel("Target Body (Only for Fuse Mode):"), 5, 0) geo_layout.addWidget(self.target_body_combo, 5, 1, 1, 2) # (moved) Live preview checkbox will be created near the navigator # Export format self.export_format_combo = QtWidgets.QComboBox() self.export_format_combo.addItem("STL", "stl") self.export_format_combo.addItem("3MF", "3mf") self.export_format_combo.addItem("STEP", "step") geo_layout.addWidget(QtWidgets.QLabel("Export Format:"), 7, 0) geo_layout.addWidget(self.export_format_combo, 7, 1) main_layout.addWidget(geo_group) # ------------------------------------------------- # CSV preview navigator (row selector) # ------------------------------------------------- nav_h = QtWidgets.QHBoxLayout() self.prev_row_btn = QtWidgets.QPushButton("◀") self.next_row_btn = QtWidgets.QPushButton("▶") self.row_spin = QtWidgets.QSpinBox() self.row_spin.setMinimum(1) self.row_spin.setMaximum(1) self.row_spin.setValue(1) self.row_spin.setEnabled(False) nav_h.addStretch() nav_h.addWidget(self.prev_row_btn) nav_h.addWidget(self.row_spin) nav_h.addWidget(self.next_row_btn) nav_h.addStretch() # CSV data self.csv_headers = [] self.csv_rows = [] # preview row index (0-based) self.preview_row = 0 # Populate UI initially self.populate_sketches() self.populate_bodies() # If a default sketch exists, perform an initial scan try: if self.sketch_combo.count() > 0: if self.sketch_combo.currentIndex() < 0: self.sketch_combo.setCurrentIndex(0) self.on_scan_lines() except Exception: pass # Connect signals self.csv_browse_btn.clicked.connect(self.on_browse_csv) self.load_csv_btn.clicked.connect(self.on_load_csv) self.scan_lines_btn.clicked.connect(self.on_scan_lines) self.font_browse_btn.clicked.connect(self.on_browse_font) self.system_font_combo.currentIndexChanged.connect(self.on_system_font_changed) # when the sketch selection changes, automatically scan guide lines # and update the preview self.sketch_combo.currentIndexChanged.connect(self.on_sketch_changed) # preview is triggered by per-line widget changes (align/size/auto) # Trigger preview when an item's checkbox in the lines table changes self.lines_table.itemChanged.connect(self.on_lines_table_item_changed) # Create preview/adopt checkboxes above navigator preview_h2 = QtWidgets.QHBoxLayout() self.preview_check = QtWidgets.QCheckBox("Live Preview") self.preview_check.setChecked(True) self.adopt_check = QtWidgets.QCheckBox("Adopt preview as object") # small info button with tooltip explaining adopt behavior info_btn = QtWidgets.QToolButton() try: info_icon = self.form.style().standardIcon(QtWidgets.QStyle.SP_MessageBoxInformation) info_btn.setIcon(info_icon) except Exception: info_btn.setText("i") info_btn.setAutoRaise(True) info_btn.setToolTip( "When checked, the currently selected preview row will be created\n" "as persistent editable objects when you press OK. No export will be performed." ) preview_h2.addStretch() preview_h2.addWidget(self.preview_check) preview_h2.addWidget(self.adopt_check) preview_h2.addWidget(info_btn) preview_h2.addStretch() main_layout.addLayout(preview_h2) # Now add the navigator under the preview controls main_layout.addLayout(nav_h) # Connect preview/adopt signals self.preview_check.stateChanged.connect(self.maybe_trigger_preview) self.adopt_check.stateChanged.connect(self.on_adopt_changed) # Navigator signals self.prev_row_btn.clicked.connect(self.on_prev_row) self.next_row_btn.clicked.connect(self.on_next_row) self.row_spin.valueChanged.connect(self.on_row_changed) # Initially disable all controls below CSV settings until a valid CSV is loaded self.set_controls_enabled(False) # ---------- TaskPanel API ---------- def getStandardButtons(self): return int(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) def accept(self): """OK button: run export but keep the task panel open.""" try: # If adopt is checked, create persistent objects for the # currently selected preview row and skip export. Otherwise # run the normal export generation. adopt_performed = False try: if getattr(self, "adopt_check", None) and self.adopt_check.isChecked(): try: # Remove temporary preview objects first to avoid duplicates if self.preview_objects: for pobj in list(self.preview_objects): try: if pobj in self.doc.Objects: self.doc.removeObject(pobj.Name) except Exception: pass self.preview_objects = [] self.doc.recompute() self.doc.openTransaction("Adopt preview") self._create_persistent_row(self.preview_row) self.doc.commitTransaction() self.doc.recompute() adopt_performed = True except Exception as e: try: self.doc.abortTransaction() except Exception: pass App.Console.PrintError("Adopt error: %s\n" % e) else: self.run_generation(preview=False) except Exception: pass # Close the task panel when adoption was performed if adopt_performed: try: Gui.Control.closeDialog() except Exception: pass except Exception as e: App.Console.PrintError("Error: %s\n" % e) QtWidgets.QMessageBox.critical(self.form, "Error", str(e)) def reject(self): """Cancel button / X: clear preview objects and close the task panel.""" # Remove any remaining preview objects if self.preview_objects: for obj in list(self.preview_objects): if obj in self.doc.Objects: try: self.doc.removeObject(obj.Name) except Exception: pass self.preview_objects = [] self.doc.recompute() # Close the task panel cleanly via FreeCAD try: Gui.Control.closeDialog() except Exception: pass # ---------- System fonts ---------- def _find_system_fonts(self): font_dirs = [] if sys.platform.startswith("win"): win_dir = os.environ.get("WINDIR", r"C:\Windows") font_dirs.append(os.path.join(win_dir, "Fonts")) elif sys.platform == "darwin": font_dirs.extend([ "/System/Library/Fonts", "/Library/Fonts", os.path.expanduser("~/Library/Fonts"), ]) else: font_dirs.extend([ "/usr/share/fonts", "/usr/local/share/fonts", os.path.expanduser("~/.fonts"), os.path.expanduser("~/.local/share/fonts"), ]) fonts = {} for d in font_dirs: if not os.path.isdir(d): continue for ext in ("*.ttf", "*.TTF", "*.otf", "*.OTF"): pattern = os.path.join(d, ext) for path in glob.glob(pattern): name = os.path.basename(path) fonts[name] = path return fonts def on_system_font_changed(self, index): path = self.system_font_combo.itemData(index) if path: self.font_path_edit.setText(path) self.maybe_trigger_preview() # ---------- UI helpers ---------- def populate_sketches(self): self.sketch_combo.clear() for obj in self.doc.Objects: if obj.TypeId == "Sketcher::SketchObject": self.sketch_combo.addItem(obj.Label, obj.Name) def populate_bodies(self): self.target_body_combo.clear() self.target_body_combo.addItem("", "") for obj in self.doc.Objects: if hasattr(obj, "Shape") and obj.Shape.Volume > 0: self.target_body_combo.addItem(obj.Label, obj.Name) def on_browse_csv(self): # default to the directory of the current FreeCAD document if saved, # otherwise fall back to the user's home directory. Works across OSes. try: doc_path = getattr(self.doc, "FileName", None) or "" if doc_path: start_dir = os.path.dirname(doc_path) else: start_dir = os.path.expanduser("~") except Exception: start_dir = os.path.expanduser("~") fn, _ = QtWidgets.QFileDialog.getOpenFileName( self.form, "Choose CSV", start_dir, "CSV files (*.csv);;All files (*)" ) if fn: self.csv_path_edit.setText(fn) self.maybe_trigger_preview() def on_browse_font(self): fn, _ = QtWidgets.QFileDialog.getOpenFileName( self.form, "Choose TTF/OTF font", "", "Font files (*.ttf *.otf);;All files (*)" ) if fn: self.system_font_combo.setCurrentIndex(0) self.font_path_edit.setText(fn) self.maybe_trigger_preview() def set_controls_enabled(self, enabled: bool): """Enable/disable UI elements below the CSV settings group. Keep CSV selection controls enabled separately. """ widgets = [ self.sketch_combo, self.scan_lines_btn, self.lines_table, self.system_font_combo, self.font_path_edit, self.font_browse_btn, # per-line controls used instead of global font/extrude self.boolean_mode_combo, self.target_body_combo, self.preview_check, self.adopt_check, self.export_format_combo, self.prev_row_btn, self.next_row_btn, self.row_spin, ] for w in widgets: try: w.setEnabled(bool(enabled)) except Exception: pass def on_load_csv(self): path = self.csv_path_edit.text().strip() if not path or not os.path.isfile(path): QtWidgets.QMessageBox.warning(self.form, "Error", "CSV file not found.") return enc = self.encoding_combo.currentText() delim = self.delimiter_edit.text() or ";" try: with open(path, "r", encoding=enc, newline="") as f: reader = csv.DictReader(f, delimiter=delim) self.csv_rows = list(reader) self.csv_headers = reader.fieldnames or [] except Exception as e: QtWidgets.QMessageBox.critical(self.form, "CSV error", str(e)) return # Update info label with columns and rows instead of popup cols = ", ".join(self.csv_headers) if self.csv_headers else "(none)" self.csv_info_label.setText(f"Columns: {cols} Rows: {len(self.csv_rows)}") # Enable controls below CSV settings only if we have at least one row have_csv = bool(self.csv_rows and self.csv_headers) self.set_controls_enabled(have_csv) # If no font path is set but a system font is selected, populate it now try: if not self.font_path_edit.text().strip(): idx = self.system_font_combo.currentIndex() if idx >= 0: self.on_system_font_changed(idx) except Exception: pass for row in range(self.lines_table.rowCount()): combo = self.lines_table.cellWidget(row, 3) if isinstance(combo, QtWidgets.QComboBox): combo.clear() combo.addItems(self.csv_headers) # prefer matching successive columns for existing lines try: if self.csv_headers: default_idx = min(row, max(0, len(self.csv_headers) - 1)) combo.setCurrentIndex(default_idx) except Exception: pass # reset per-line alignment/size/extra to sensible defaults try: align_combo = self.lines_table.cellWidget(row, 4) if isinstance(align_combo, QtWidgets.QComboBox): align_combo.setCurrentIndex(1) except Exception: pass try: size_widget = self.lines_table.cellWidget(row, 5) if isinstance(size_widget, QtWidgets.QDoubleSpinBox): size_widget.setValue(10.0) except Exception: pass try: extrude_widget = self.lines_table.cellWidget(row, 6) if isinstance(extrude_widget, QtWidgets.QDoubleSpinBox): extrude_widget.setValue(1.0) except Exception: pass # ensure height is enabled by default (align not auto-fit) try: align_combo = self.lines_table.cellWidget(row, 4) size_widget = self.lines_table.cellWidget(row, 5) if isinstance(align_combo, QtWidgets.QComboBox) and isinstance(size_widget, QtWidgets.QDoubleSpinBox): if "auto" in align_combo.currentText().lower(): size_widget.setEnabled(False) else: size_widget.setEnabled(True) except Exception: pass # Update navigator spinbox to reflect loaded CSV total_rows = len(self.csv_rows) self.row_spin.setMaximum(max(1, total_rows)) self.row_spin.setValue(1) self.row_spin.setEnabled(total_rows > 0) self.preview_row = 0 self.maybe_trigger_preview() def on_scan_lines(self): if self.sketch_combo.currentIndex() < 0: QtWidgets.QMessageBox.warning(self.form, "Error", "No sketch selected.") return sketch_name = self.sketch_combo.currentData() sk = self.doc.getObject(sketch_name) if sk is None: QtWidgets.QMessageBox.warning(self.form, "Error", "Sketch object not found.") return geos = sk.Geometry lines = [] for i, g in enumerate(geos): if hasattr(g, "StartPoint") and hasattr(g, "EndPoint"): if abs(g.StartPoint.y - g.EndPoint.y) < 1e-6: lines.append((i, g)) self.lines_table.setRowCount(0) for idx, (gi, g) in enumerate(lines): self.lines_table.insertRow(idx) chk = QtWidgets.QTableWidgetItem() chk.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled) chk.setCheckState(QtCore.Qt.Checked) self.lines_table.setItem(idx, 0, chk) it_idx = QtWidgets.QTableWidgetItem(str(gi)) it_idx.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) self.lines_table.setItem(idx, 1, it_idx) y_val = g.StartPoint.y it_y = QtWidgets.QTableWidgetItem("%.3f" % y_val) it_y.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) self.lines_table.setItem(idx, 2, it_y) col_combo = QtWidgets.QComboBox() col_combo.addItems(self.csv_headers) # default mapping: when multiple guide lines exist, map them to # successive CSV columns by default (clamped to header count) try: if self.csv_headers: default_idx = min(idx, max(0, len(self.csv_headers) - 1)) col_combo.setCurrentIndex(default_idx) except Exception: pass col_combo.currentIndexChanged.connect(self.maybe_trigger_preview) self.lines_table.setCellWidget(idx, 3, col_combo) # Height spinbox (manual); value in mm relative to global scale size_spin = QtWidgets.QDoubleSpinBox() size_spin.setRange(0.01, 1000.0) size_spin.setSingleStep(0.1) size_spin.setValue(10.0) size_spin.valueChanged.connect(self.maybe_trigger_preview) # Extrusion height spinbox per-line extrude_spin = QtWidgets.QDoubleSpinBox() extrude_spin.setRange(0.0, 1000.0) extrude_spin.setSingleStep(0.1) extrude_spin.setValue(1.0) extrude_spin.valueChanged.connect(self.maybe_trigger_preview) # Alignment combobox: Left / Center / Right / Auto-fit align_combo = QtWidgets.QComboBox() align_combo.addItems(["Left", "Center", "Right", "Auto-fit"]) align_combo.setCurrentIndex(1) # when Auto-fit is selected, disable the manual Height spinbox def _align_changed(i, spin=size_spin, combo=align_combo): try: txt = combo.itemText(i).lower() if i >= 0 else combo.currentText().lower() except Exception: txt = "" is_auto = "auto" in txt try: spin.setEnabled(not is_auto) except Exception: pass try: self.maybe_trigger_preview() except Exception: pass align_combo.currentIndexChanged.connect(_align_changed) # attach widgets to the table in the correct columns self.lines_table.setCellWidget(idx, 4, align_combo) self.lines_table.setCellWidget(idx, 5, size_spin) self.lines_table.setCellWidget(idx, 6, extrude_spin) # Auto-fit is an option inside the Align combobox; no separate checkbox self.maybe_trigger_preview() def get_line_mappings(self): mappings = [] rows = self.lines_table.rowCount() for r in range(rows): item_active = self.lines_table.item(r, 0) if not item_active or item_active.checkState() != QtCore.Qt.Checked: continue item_geo = self.lines_table.item(r, 1) item_y = self.lines_table.item(r, 2) col_combo = self.lines_table.cellWidget(r, 3) align_combo = self.lines_table.cellWidget(r, 4) size_widget = self.lines_table.cellWidget(r, 5) extrude_widget = self.lines_table.cellWidget(r, 6) if not item_geo or not item_y or not col_combo or not align_combo or size_widget is None or extrude_widget is None: continue try: geo_index = int(item_geo.text()) y_line = float(item_y.text()) except ValueError: continue csv_col = col_combo.currentText() if not csv_col: continue align = "Center" try: if isinstance(align_combo, QtWidgets.QComboBox): align = align_combo.currentText() except Exception: pass size_val = None try: if isinstance(size_widget, QtWidgets.QDoubleSpinBox): size_val = float(size_widget.value()) except Exception: size_val = None extrude_val = None try: if isinstance(extrude_widget, QtWidgets.QDoubleSpinBox): extrude_val = float(extrude_widget.value()) except Exception: extrude_val = None auto_fit = False try: if isinstance(align_combo, QtWidgets.QComboBox) and "auto" in align_combo.currentText().lower(): auto_fit = True except Exception: auto_fit = False mappings.append((geo_index, y_line, csv_col, align, auto_fit, size_val, extrude_val)) return mappings def maybe_trigger_preview(self): if not self.preview_check.isChecked(): return try: if not self.csv_rows or not self.csv_headers: return if self.sketch_combo.currentIndex() < 0: return if not self.font_path_edit.text().strip(): return if not self.get_line_mappings(): return self.run_generation(preview=True) except Exception as e: App.Console.PrintError("Preview error: %s\n" % e) def on_sketch_changed(self, index): # automatically scan guide lines for the newly selected sketch try: self.on_scan_lines() except Exception: pass # update preview if applicable try: self.maybe_trigger_preview() except Exception: pass # ---------- Row navigator callbacks ---------- def on_prev_row(self): if not self.row_spin.isEnabled(): return v = self.row_spin.value() if v > 1: self.row_spin.setValue(v - 1) def on_next_row(self): if not self.row_spin.isEnabled(): return v = self.row_spin.value() if v < self.row_spin.maximum(): self.row_spin.setValue(v + 1) def on_row_changed(self, value): # spinbox is 1-based, internal index is 0-based try: self.preview_row = max(0, min(value - 1, max(0, len(self.csv_rows) - 1))) except Exception: self.preview_row = 0 self.maybe_trigger_preview() def on_adopt_changed(self, state): # Do not create or commit objects when toggling the adopt checkbox. # Adoption is performed only when the user confirms with OK. return def on_lines_table_item_changed(self, item): """Called when a QTableWidgetItem in the lines table changes. When the 'Active' checkbox (column 0) is toggled, update the live preview. """ try: if item is None: return # Refresh preview when Active (col 0) checkbox changes if item.column() == 0: try: self.maybe_trigger_preview() except Exception: pass except Exception: pass # ---------- Export helpers ---------- def _get_export_base(self, export_ext): """Determine export base directory/name and clean up old exports with the selected extension.""" fc_path = self.doc.FileName if not fc_path: raise RuntimeError("The document is not saved yet. Please save it first.") base_dir = os.path.dirname(fc_path) fc_name = os.path.splitext(os.path.basename(fc_path))[0] export_dir = os.path.join(base_dir, fc_name) if not os.path.isdir(export_dir): os.makedirs(export_dir, exist_ok=True) else: # Remove old exports with the same extension ext = "." + export_ext.lower() for f in os.listdir(export_dir): if f.lower().endswith(ext): try: os.remove(os.path.join(export_dir, f)) except Exception: pass return export_dir, fc_name def _sanitize_component(self, text, max_len=None): allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" res = "".join(c if c in allowed else "_" for c in text) if max_len and len(res) > max_len: res = res[:max_len] return res def _build_export_path(self, export_dir, fc_name, used_cols, row, export_ext): ext = "." + export_ext.lower() name_parts = [fc_name] for col in used_cols: val = (row.get(col) or "").strip() if not val: continue name_parts.append(self._sanitize_component(val)) base_name = "_".join(name_parts) filename = base_name + ext full_path = os.path.join(export_dir, filename) if len(full_path) <= MAX_PATH_LEN: return full_path dir_part = export_dir reserve = len(dir_part) + 1 + len(ext) max_name_len = MAX_PATH_LEN - reserve if max_name_len < 1: max_name_len = 1 base_name_short = base_name[:max_name_len] filename_short = base_name_short + ext full_path_short = os.path.join(dir_part, filename_short) return full_path_short # ---------- Main logic ---------- def run_generation(self, preview=False): if not self.csv_rows or not self.csv_headers: raise RuntimeError("CSV is not loaded.") mappings = self.get_line_mappings() if not mappings: raise RuntimeError("No active guide line mappings defined.") font_file = self.font_path_edit.text().strip() if not font_file or not os.path.isfile(font_file): raise RuntimeError("Font file is not set or not found.") if self.sketch_combo.currentIndex() < 0: raise RuntimeError("No sketch selected.") sketch_name = self.sketch_combo.currentData() sk = self.doc.getObject(sketch_name) if sk is None: raise RuntimeError("Sketch object not found.") # global font scale removed; use default fallback for relative sizing font_scale = 0.7 boolean_mode = self.boolean_mode_combo.currentIndex() target_body_name = self.target_body_combo.currentData() export_ext = self.export_format_combo.currentData() or "stl" # Remove existing preview objects before the final run if not preview and self.preview_objects: for obj in list(self.preview_objects): if obj in self.doc.Objects: try: self.doc.removeObject(obj.Name) except Exception: pass self.preview_objects = [] self.doc.recompute() # Transaction for final run if not preview: self.doc.openTransaction("CSV2Objects_Export") # Export base if not preview: export_dir, fc_name = self._get_export_base(export_ext) used_cols = [] for m in mappings: try: col = m[2] except Exception: continue if col not in used_cols: used_cols.append(col) else: export_dir, fc_name, used_cols = None, None, None # Original target body (copied per CSV row) orig_target_body = None if not preview and boolean_mode == 1: if not target_body_name: raise RuntimeError("Fuse mode selected, but no target body chosen.") orig_target_body = self.doc.getObject(target_body_name) if orig_target_body is None: raise RuntimeError("Target body not found.") bbox_sk = sk.Shape.BoundBox z_offset = float(bbox_sk.ZMin) geos = sk.Geometry y_vals = [m[1] for m in mappings] if len(y_vals) >= 2: y_top = max(y_vals) y_bottom = min(y_vals) vertical_height = abs(y_top - y_bottom) else: vertical_height = 10.0 text_size = vertical_height * font_scale App.Console.PrintMessage( "font_scale=%.3f, text_size=%.3f, preview=%s\n" % (font_scale, text_size, preview) ) # Remove old preview objects in preview mode if preview and self.preview_objects: for obj in list(self.preview_objects): if obj in self.doc.Objects: try: self.doc.removeObject(obj.Name) except Exception: pass self.preview_objects = [] self.doc.recompute() def make_and_place_text(value, geo_index, y_line, align="Center", auto_fit=False, size_val=None): g = geos[geo_index] x1 = g.StartPoint.x x2 = g.EndPoint.x x_center = 0.5 * (x1 + x2) if not value: return None # determine initial size initial_size = size_val if size_val and size_val > 0 else text_size ss = Draft.makeShapeString( String=value, FontFile=font_file, Size=initial_size, Tracking=0 ) ss.Placement.Base = App.Vector(x1, y_line, z_offset) self.doc.recompute() bbox = ss.Shape.BoundBox width = float(bbox.XLength) if bbox.XLength else 0.0 ymid = 0.5 * (float(bbox.YMin) + float(bbox.YMax)) line_len = abs(x2 - x1) # auto-fit: recreate ShapeString with adjusted size to fit line length if auto_fit and width > 1e-9 and line_len > 1e-6: try: scale = (line_len * 0.95) / width if scale > 0 and abs(scale - 1.0) > 1e-3: new_size = max(0.01, initial_size * scale) # remove the provisional object try: if ss in self.doc.Objects: self.doc.removeObject(ss.Name) except Exception: pass self.doc.recompute() ss = Draft.makeShapeString( String=value, FontFile=font_file, Size=new_size, Tracking=0 ) ss.Placement.Base = App.Vector(x1, y_line, z_offset) self.doc.recompute() bbox = ss.Shape.BoundBox width = float(bbox.XLength) if bbox.XLength else 0.0 ymid = 0.5 * (float(bbox.YMin) + float(bbox.YMax)) except Exception: pass # Placement according to alignment try: if align.lower().startswith("l"): # anchor at the start point x base_x = x1 elif align.lower().startswith("r"): # anchor at the end point minus width base_x = x2 - width else: base_x = x_center - width / 2.0 except Exception: base_x = x_center - width / 2.0 ss.Placement.Base.x = base_x dy = y_line - ymid ss.Placement.Base.y += dy self.doc.recompute() return ss def extrude_text(ss_obj, extrude_h_local): if ss_obj is None: return None ext = self.doc.addObject("Part::Extrusion", "TextExtrude") ext.Base = ss_obj ext.Dir = App.Vector(0, 0, float(extrude_h_local) if extrude_h_local is not None else 1.0) ext.Solid = True ext.TaperAngle = 0 self.doc.recompute() return ext if preview: # Use the selected preview row (self.preview_row, 0-based) try: idx = int(self.preview_row) except Exception: idx = 0 self.preview_row = 0 if idx < 0 or idx >= len(self.csv_rows): idx = 0 self.preview_row = 0 try: self.row_spin.setValue(1) except Exception: pass rows_to_process = [self.csv_rows[idx]] else: rows_to_process = self.csv_rows # Progress dialog only in final mode progress = None if not preview: total = len(rows_to_process) progress = QtWidgets.QProgressDialog( "Exporting…", "Cancel", 0, total ) progress.setWindowModality(QtCore.Qt.WindowModal) progress.setMinimumDuration(0) for row_idx, row in enumerate(rows_to_process): # Update progress if progress is not None: progress.setValue(row_idx) progress.setLabelText( f"Exporting {row_idx + 1} / {len(rows_to_process)}" ) QtWidgets.QApplication.processEvents() if progress.wasCanceled(): break App.Console.PrintMessage( "CSV row %d (%s)\n" % (row_idx, "Preview" if preview else "Final") ) new_extrudes = [] for (geo_index, y_line, csv_col, align, auto_fit, size_val, extrude_val) in mappings: if csv_col not in row: continue value = (row.get(csv_col) or "").strip() # determine per-line size per_size = size_val if size_val else text_size ss = make_and_place_text(value, geo_index, y_line, align=align, auto_fit=auto_fit, size_val=per_size) per_extrude = extrude_val if extrude_val is not None else 1.0 ext = extrude_text(ss, per_extrude) if ext: new_extrudes.append(ext) if preview: self.preview_objects.extend([ss, ext]) # Per-row fuse: create a dedicated copy of the target body for each CSV row if not preview and boolean_mode == 1 and orig_target_body is not None: base_copy = self.doc.copyObject(orig_target_body, True) current = base_copy for ext in new_extrudes: fuse = self.doc.addObject("Part::Fuse", "TextFuse") fuse.Base = current fuse.Tool = ext self.doc.recompute() current.ViewObject.Visibility = False current = fuse export_obj_list = [current] else: export_obj_list = new_extrudes if not preview and export_obj_list: export_path = self._build_export_path(export_dir, fc_name, used_cols, row, export_ext) if os.path.exists(export_path): try: os.remove(export_path) except Exception: pass try: ext = export_ext.lower() # STL and 3MF via Mesh.export, STEP and others via ImportGui.export if ext in ("stl", "3mf"): Mesh.export(export_obj_list, export_path) else: ImportGui.export(export_obj_list, export_path) App.Console.PrintMessage("Exported: %s\n" % export_path) except Exception as e: App.Console.PrintError( "Export error (%s) for '%s': %s\n" % (export_ext, export_path, e) ) # Finish the progress dialog if not preview and progress is not None: progress.setValue(len(rows_to_process)) if not preview: QtWidgets.QMessageBox.information( self.form, "Done", "Text bodies were generated, extruded, and exported.\n" "Boolean fuse executed with target body when selected.", ) else: App.Console.PrintMessage("Preview updated.\n") # After the final run discard model changes to restore the original state if not preview: try: self.doc.abortTransaction() except Exception: pass self.doc.recompute() def _create_persistent_row(self, row_idx): """Create persistent objects for the CSV row index (0-based). This is executed inside a transaction by the caller so Undo/Redo will work after commit. """ if not self.csv_rows or not self.csv_headers: raise RuntimeError("CSV is not loaded.") mappings = self.get_line_mappings() if not mappings: raise RuntimeError("No active guide line mappings defined.") font_file = self.font_path_edit.text().strip() if not font_file or not os.path.isfile(font_file): raise RuntimeError("Font file is not set or not found.") if self.sketch_combo.currentIndex() < 0: raise RuntimeError("No sketch selected.") sketch_name = self.sketch_combo.currentData() sk = self.doc.getObject(sketch_name) if sk is None: raise RuntimeError("Sketch object not found.") # global font_scale/extrude removed; use fallback defaults font_scale = 0.7 bbox_sk = sk.Shape.BoundBox z_offset = float(bbox_sk.ZMin) geos = sk.Geometry y_vals = [m[1] for m in mappings] if len(y_vals) >= 2: y_top = max(y_vals) y_bottom = min(y_vals) vertical_height = abs(y_top - y_bottom) else: vertical_height = 10.0 text_size = vertical_height * font_scale # validate index try: idx = int(row_idx) except Exception: idx = 0 if idx < 0 or idx >= len(self.csv_rows): raise RuntimeError("Preview row index out of range") row = self.csv_rows[idx] created = [] def make_and_place_text_local(value, geo_index, y_line, align="Center", auto_fit=False, size_val=None): g = geos[geo_index] x1 = g.StartPoint.x x2 = g.EndPoint.x x_center = 0.5 * (x1 + x2) if not value: return None initial_size = size_val if size_val and size_val > 0 else text_size ss = Draft.makeShapeString( String=value, FontFile=font_file, Size=initial_size, Tracking=0 ) ss.Placement.Base = App.Vector(x1, y_line, z_offset) self.doc.recompute() bbox = ss.Shape.BoundBox width = float(bbox.XLength) if bbox.XLength else 0.0 ymid = 0.5 * (float(bbox.YMin) + float(bbox.YMax)) line_len = abs(x2 - x1) if auto_fit and width > 1e-9 and line_len > 1e-6: try: scale = (line_len * 0.95) / width if scale > 0 and abs(scale - 1.0) > 1e-3: new_size = max(0.01, initial_size * scale) try: if ss in self.doc.Objects: self.doc.removeObject(ss.Name) except Exception: pass self.doc.recompute() ss = Draft.makeShapeString( String=value, FontFile=font_file, Size=new_size, Tracking=0 ) ss.Placement.Base = App.Vector(x1, y_line, z_offset) self.doc.recompute() bbox = ss.Shape.BoundBox width = float(bbox.XLength) if bbox.XLength else 0.0 ymid = 0.5 * (float(bbox.YMin) + float(bbox.YMax)) except Exception: pass try: if align.lower().startswith("l"): base_x = x1 elif align.lower().startswith("r"): base_x = x2 - width else: base_x = x_center - width / 2.0 except Exception: base_x = x_center - width / 2.0 ss.Placement.Base.x = base_x dy = y_line - ymid ss.Placement.Base.y += dy self.doc.recompute() return ss def extrude_text_local(ss_obj, extrude_h_local): if ss_obj is None: return None ext = self.doc.addObject("Part::Extrusion", "TextExtrude") ext.Base = ss_obj ext.Dir = App.Vector(0, 0, float(extrude_h_local) if extrude_h_local is not None else 1.0) ext.Solid = True ext.TaperAngle = 0 self.doc.recompute() return ext # create objects for the single row for (geo_index, y_line, csv_col, align, auto_fit, size_val, extrude_val) in mappings: if csv_col not in row: continue value = (row.get(csv_col) or "").strip() per_size = size_val if size_val else text_size per_extrude = extrude_val if extrude_val is not None else 1.0 ss = make_and_place_text_local(value, geo_index, y_line, align=align, auto_fit=auto_fit, size_val=per_size) ext = extrude_text_local(ss, per_extrude) if ext: created.append(ss) created.append(ext) # If fuse mode selected, perform boolean fuse with the chosen target body boolean_mode = self.boolean_mode_combo.currentIndex() if boolean_mode == 1: target_body_name = self.target_body_combo.currentData() if not target_body_name: raise RuntimeError("Fuse mode selected, but no target body chosen.") orig_target_body = self.doc.getObject(target_body_name) if orig_target_body is None: raise RuntimeError("Target body not found.") # create a copy of the target body and fuse all new extrudes into it base_copy = self.doc.copyObject(orig_target_body, True) current = base_copy # collect extrude objects from created list robustly extrudes = [] for obj in created: try: if hasattr(obj, "Base") and hasattr(obj, "Dir"): extrudes.append(obj) except Exception: pass if not extrudes: App.Console.PrintMessage("No extrude objects found to fuse.\n") return created for ext_obj in extrudes: try: fuse = self.doc.addObject("Part::Fuse", "TextFuse") fuse.Base = current fuse.Tool = ext_obj self.doc.recompute() try: current.ViewObject.Visibility = False except Exception: pass current = fuse except Exception as e: App.Console.PrintError("Fuse error: %s\n" % e) # return the final fused object (and keep intermediates in document) return [current] return created def run_csv2objects_macro(): if not App.GuiUp: App.Console.PrintError("CSV2Objects macro requires the FreeCAD GUI.\n") return try: panel = CSV2ObjectsTaskPanel() Gui.Control.showDialog(panel) except Exception as e: App.Console.PrintError("CSV2Objects: failed to open dialog: %s\n" % e) # Run immediately when launched as a macro run_csv2objects_macro()