From fab78a2674fc55291d5e045344ee770945e09c88 Mon Sep 17 00:00:00 2001 From: rfaircl2 <32851886+rfaircl2@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:10:32 +0000 Subject: [PATCH] feat: add SendCutSendPresets workbench (v0.2.0) Adds MIT-licensed SheetMetal companion Mod with SCS bend presets, Unfold sync, smoke tests, and CMake install hook. --- src/Mod/CMakeLists.txt | 1 + src/Mod/SendCutSendPresets/.gitignore | 9 + src/Mod/SendCutSendPresets/CHANGELOG.md | 21 + src/Mod/SendCutSendPresets/CMakeLists.txt | 72 +++ src/Mod/SendCutSendPresets/CustomPresets.py | 531 ++++++++++++++++ src/Mod/SendCutSendPresets/Init.py | 3 + src/Mod/SendCutSendPresets/InitGui.py | 86 +++ src/Mod/SendCutSendPresets/LICENSE | 21 + src/Mod/SendCutSendPresets/README.md | 82 +++ src/Mod/SendCutSendPresets/SCSCommand.py | 481 ++++++++++++++ src/Mod/SendCutSendPresets/SCS_locator.py | 10 + src/Mod/SendCutSendPresets/SMOKE.md | 42 ++ src/Mod/SendCutSendPresets/bend_actions.py | 599 ++++++++++++++++++ src/Mod/SendCutSendPresets/bend_common.py | 132 ++++ .../data/sendcutsend_bends.json | 577 +++++++++++++++++ src/Mod/SendCutSendPresets/metadata.txt | 6 + src/Mod/SendCutSendPresets/package.xml | 24 + src/Mod/SendCutSendPresets/pending_unfold.py | 234 +++++++ .../resources/icons/SCS_Custom.svg | 6 + .../resources/icons/SCS_Presets.svg | 6 + .../sheetmetal_integration.py | 162 +++++ src/Mod/SendCutSendPresets/tests/__init__.py | 1 + .../tests/freecadcmd_smoke.py | 18 + .../SendCutSendPresets/tests/test_smoke.py | 101 +++ src/Mod/SendCutSendPresets/ui_theme.py | 85 +++ 25 files changed, 3310 insertions(+) create mode 100644 src/Mod/SendCutSendPresets/.gitignore create mode 100644 src/Mod/SendCutSendPresets/CHANGELOG.md create mode 100644 src/Mod/SendCutSendPresets/CMakeLists.txt create mode 100644 src/Mod/SendCutSendPresets/CustomPresets.py create mode 100644 src/Mod/SendCutSendPresets/Init.py create mode 100644 src/Mod/SendCutSendPresets/InitGui.py create mode 100644 src/Mod/SendCutSendPresets/LICENSE create mode 100644 src/Mod/SendCutSendPresets/README.md create mode 100644 src/Mod/SendCutSendPresets/SCSCommand.py create mode 100644 src/Mod/SendCutSendPresets/SCS_locator.py create mode 100644 src/Mod/SendCutSendPresets/SMOKE.md create mode 100644 src/Mod/SendCutSendPresets/bend_actions.py create mode 100644 src/Mod/SendCutSendPresets/bend_common.py create mode 100644 src/Mod/SendCutSendPresets/data/sendcutsend_bends.json create mode 100644 src/Mod/SendCutSendPresets/metadata.txt create mode 100644 src/Mod/SendCutSendPresets/package.xml create mode 100644 src/Mod/SendCutSendPresets/pending_unfold.py create mode 100644 src/Mod/SendCutSendPresets/resources/icons/SCS_Custom.svg create mode 100644 src/Mod/SendCutSendPresets/resources/icons/SCS_Presets.svg create mode 100644 src/Mod/SendCutSendPresets/sheetmetal_integration.py create mode 100644 src/Mod/SendCutSendPresets/tests/__init__.py create mode 100644 src/Mod/SendCutSendPresets/tests/freecadcmd_smoke.py create mode 100644 src/Mod/SendCutSendPresets/tests/test_smoke.py create mode 100644 src/Mod/SendCutSendPresets/ui_theme.py diff --git a/src/Mod/CMakeLists.txt b/src/Mod/CMakeLists.txt index b1dca92f8..2b96aa645 100644 --- a/src/Mod/CMakeLists.txt +++ b/src/Mod/CMakeLists.txt @@ -169,6 +169,7 @@ add_subdirectory(VibeCAD) add_subdirectory(VibeCADAero) add_subdirectory(VibeCADPrint) add_subdirectory(McMasterInsert) +add_subdirectory(SendCutSendPresets) #add_subdirectory(TemplatePyMod) diff --git a/src/Mod/SendCutSendPresets/.gitignore b/src/Mod/SendCutSendPresets/.gitignore new file mode 100644 index 000000000..2b04e19b7 --- /dev/null +++ b/src/Mod/SendCutSendPresets/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.fcstd1 +*.FCStd1 +.DS_Store +*.bak +*.bak_scs +.idea/ +.vscode/ diff --git a/src/Mod/SendCutSendPresets/CHANGELOG.md b/src/Mod/SendCutSendPresets/CHANGELOG.md new file mode 100644 index 000000000..ebca1c99b --- /dev/null +++ b/src/Mod/SendCutSendPresets/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.2.0 — 2026-09-05 + +### Added +- Unified **Bend Presets (SCS + Custom)** dialog for SheetMetal (source dropdown). +- Full SendCutSend public bend table + user JSON custom presets. +- Apply to bends, create Unfold material sheets, set SheetMetal defaults. +- Min-flange warnings on Apply. +- Body selection expands to Bend features. +- Unfold K-factor / Material Sheet sync (name-safe FreeCAD object fetch). +- **Pending Unfold sync**: Apply before Unfold remembers preset and auto-applies when Unfold is created. +- Runtime injection into SheetMetal toolbar/menu (no SheetMetal source fork required). + +### Notes +- Flat-pattern math uses the material sheet K when selected; Manual K-Factor spin stays grayed out by SheetMetal design — this addon syncs the property for display consistency. +- Bend table retrieved from SendCutSend public calculator (2026-09-05); re-verify if they update. + +## 0.1.0 — 2026-09-05 + +- Initial SendCutSend presets workbench + custom presets + packaging. diff --git a/src/Mod/SendCutSendPresets/CMakeLists.txt b/src/Mod/SendCutSendPresets/CMakeLists.txt new file mode 100644 index 000000000..20d80092f --- /dev/null +++ b/src/Mod/SendCutSendPresets/CMakeLists.txt @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT +# Pure-Python SendCutSend Presets workbench (SheetMetal companion). + +set(SendCutSendPresets_SRCS + Init.py + InitGui.py + SCSCommand.py + CustomPresets.py + SCS_locator.py + bend_actions.py + bend_common.py + pending_unfold.py + sheetmetal_integration.py + ui_theme.py + package.xml + metadata.txt + README.md + LICENSE + CHANGELOG.md + SMOKE.md +) + +set(SendCutSendPresets_Data + data/sendcutsend_bends.json +) + +set(SendCutSendPresets_Icons + resources/icons/SCS_Presets.svg + resources/icons/SCS_Custom.svg +) + +set(SendCutSendPresets_Tests + tests/__init__.py + tests/test_smoke.py +) + +set(SendCutSendPresets_All + ${SendCutSendPresets_SRCS} + ${SendCutSendPresets_Data} + ${SendCutSendPresets_Icons} + ${SendCutSendPresets_Tests} +) + +source_group("" FILES ${SendCutSendPresets_SRCS}) + +add_custom_target(SendCutSendPresets ALL + SOURCES ${SendCutSendPresets_All} +) + +fc_copy_sources(SendCutSendPresets "${CMAKE_BINARY_DIR}/Mod/SendCutSendPresets" ${SendCutSendPresets_All}) + +install( + FILES ${SendCutSendPresets_SRCS} + DESTINATION Mod/SendCutSendPresets +) + +install( + FILES ${SendCutSendPresets_Data} + DESTINATION Mod/SendCutSendPresets/data +) + +install( + FILES ${SendCutSendPresets_Icons} + DESTINATION Mod/SendCutSendPresets/resources/icons +) + +if(BUILD_TEST) + install( + FILES ${SendCutSendPresets_Tests} + DESTINATION Mod/SendCutSendPresets/tests + ) +endif() diff --git a/src/Mod/SendCutSendPresets/CustomPresets.py b/src/Mod/SendCutSendPresets/CustomPresets.py new file mode 100644 index 000000000..55b99da94 --- /dev/null +++ b/src/Mod/SendCutSendPresets/CustomPresets.py @@ -0,0 +1,531 @@ +# SPDX-License-Identifier: MIT +"""Bend presets for SheetMetal: SendCutSend library + user-defined customs.""" + +from __future__ import annotations + +import json +import os + +import FreeCAD as App +import FreeCADGui as Gui + +from bend_common import resolve_wb_dir +from ui_theme import apply_dark_theme +from bend_actions import ( + apply_entry_to_bends, + create_material_sheet_for_entry, + set_sheetmetal_defaults_for_entry, +) + +# Qt compatibility (same strategy as SCSCommand; no import of SCSCommand) +QtCore = None +_widgets = None +for _mod in ("PySide6", "PySide2", "PySide"): + try: + if _mod == "PySide": + from PySide import QtGui as _QtGui, QtCore as _QtCore # type: ignore + QtCore = _QtCore + if hasattr(_QtGui, "QWidget"): + _widgets = _QtGui + break + else: + _Qt = __import__(_mod + ".QtCore", fromlist=["QtCore"]) + _QtWidgets = __import__(_mod + ".QtWidgets", fromlist=["QtWidgets"]) + QtCore, _widgets = _Qt, _QtWidgets + break + except ImportError: + continue +if _widgets is None: + raise ImportError("No PySide / PySide2 / PySide6 found") + +QWidget = _widgets.QWidget +QVBoxLayout = _widgets.QVBoxLayout +QHBoxLayout = _widgets.QHBoxLayout +QFormLayout = _widgets.QFormLayout +QLabel = _widgets.QLabel +QComboBox = _widgets.QComboBox +QPushButton = _widgets.QPushButton +QGroupBox = _widgets.QGroupBox +QLineEdit = _widgets.QLineEdit +QDoubleSpinBox = _widgets.QDoubleSpinBox +QMessageBox = _widgets.QMessageBox + +WB_DIR = resolve_wb_dir() +ICON_PATH = os.path.join(WB_DIR, "resources", "icons", "SCS_Presets.svg") + + +def custom_data_path(): + """Writable JSON outside the Mod folder so updates do not wipe user presets.""" + root = os.path.join(App.getUserAppDataDir(), "SendCutSendPresets") + # FreeCAD 1.1 often uses a versioned app data dir already in getUserAppDataDir() + try: + os.makedirs(root, exist_ok=True) + except Exception: + pass + return os.path.join(root, "custom_bends.json") + + +def load_custom_data(): + path = custom_data_path() + if not os.path.isfile(path): + return {"source": "user", "units": "inch", "materials": []} + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if "materials" not in data: + data["materials"] = [] + return data + except Exception: + return {"source": "user", "units": "inch", "materials": []} + + +def save_custom_data(data): + path = custom_data_path() + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + return path + + +def load_scs_data(): + """Bundled SendCutSend public bend table.""" + path = os.path.join(WB_DIR, "data", "sendcutsend_bends.json") + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if "materials" not in data: + data["materials"] = [] + return data + + +def _spin(decimals=4, minimum=0.0, maximum=10.0, step=0.001): + w = QDoubleSpinBox() + w.setDecimals(decimals) + w.setMinimum(minimum) + w.setMaximum(maximum) + w.setSingleStep(step) + w.setMaximumWidth(140) + return w + + +class CustomPresetsPanel(QWidget): + """SendCutSend library + custom bend presets (one SheetMetal button).""" + + def __init__(self, parent=None): + super(CustomPresetsPanel, self).__init__(parent) + self.custom_data = load_custom_data() + try: + self.scs_data = load_scs_data() + except Exception: + self.scs_data = {"materials": []} + self.data = self.custom_data # active library map source + self._build_ui() + self._on_source_changed() + + def _build_ui(self): + root = QVBoxLayout(self) + + src_row = QHBoxLayout() + src_row.addWidget(QLabel("Preset source:")) + self.source_combo = QComboBox() + self.source_combo.addItem("SendCutSend library", "scs") + self.source_combo.addItem("My custom", "custom") + src_row.addWidget(self.source_combo, 1) + root.addLayout(src_row) + + self.path_lbl = QLabel("") + self.path_lbl.setWordWrap(True) + root.addWidget(self.path_lbl) + + pick = QGroupBox("Presets") + pick_form = QFormLayout(pick) + self.material_combo = QComboBox() + self.thickness_combo = QComboBox() + pick_form.addRow("Material:", self.material_combo) + pick_form.addRow("Thickness:", self.thickness_combo) + root.addWidget(pick) + + self.edit_group = QGroupBox("Edit / new preset (inches)") + form = QFormLayout(self.edit_group) + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("e.g. Shop Mild Steel") + self.spin_t = _spin() + self.spin_r = _spin() + self.spin_k = _spin(decimals=3, minimum=0.0, maximum=1.0, step=0.01) + self.spin_bd = _spin() + self.spin_relief = _spin() + self.spin_die = _spin() + self.spin_flange = _spin() + self.spin_corner = _spin() + form.addRow("Material name:", self.name_edit) + form.addRow("Thickness:", self.spin_t) + form.addRow("Bend radius:", self.spin_r) + form.addRow("K-factor:", self.spin_k) + form.addRow("Bend deduction @ 90:", self.spin_bd) + form.addRow("Bend relief depth:", self.spin_relief) + form.addRow("Die width:", self.spin_die) + form.addRow("Min flange:", self.spin_flange) + form.addRow("Min corner relief:", self.spin_corner) + root.addWidget(self.edit_group) + + mm = QLabel("Values are inches (mm shown in status when you save).") + root.addWidget(mm) + + self.save_row_widget = QWidget() + row_save = QHBoxLayout(self.save_row_widget) + row_save.setContentsMargins(0, 0, 0, 0) + self.btn_save = QPushButton("Save preset") + self.btn_delete = QPushButton("Delete selected") + self.btn_load_fields = QPushButton("Load into editor") + row_save.addWidget(self.btn_save) + row_save.addWidget(self.btn_load_fields) + row_save.addWidget(self.btn_delete) + root.addWidget(self.save_row_widget) + + self.status = QLabel("") + self.status.setWordWrap(True) + root.addWidget(self.status) + + row = QHBoxLayout() + self.btn_apply = QPushButton("Apply to bends") + self.btn_sheet = QPushButton("Create material sheet") + self.btn_defaults = QPushButton("Set SheetMetal defaults") + row.addWidget(self.btn_apply) + row.addWidget(self.btn_sheet) + row.addWidget(self.btn_defaults) + root.addLayout(row) + self.btn_all = QPushButton("Apply all (bends + sheet + defaults)") + root.addWidget(self.btn_all) + + self.source_combo.currentIndexChanged.connect(self._on_source_changed) + self.material_combo.currentIndexChanged.connect(self._on_material_changed) + self.thickness_combo.currentIndexChanged.connect(self._on_thickness_changed) + self.btn_save.clicked.connect(self.save_preset) + self.btn_delete.clicked.connect(self.delete_preset) + self.btn_load_fields.clicked.connect(self.load_selected_into_editor) + self.btn_apply.clicked.connect(self.apply_to_selection) + self.btn_sheet.clicked.connect(self.create_material_sheet) + self.btn_defaults.clicked.connect(self.set_sheetmetal_defaults) + self.btn_all.clicked.connect(self.apply_all) + + def _set_status(self, text): + self.status.setText(text) + App.Console.PrintMessage("[Bend Presets] %s\n" % text) + + def _source_key(self): + idx = self.source_combo.currentIndex() + if idx < 0: + return "custom" + key = self.source_combo.itemData(idx) + return key if key else "custom" + + def _sheet_prefix(self): + return "SCS" if self._source_key() == "scs" else "Custom" + + def _source_note(self): + if self._source_key() == "scs": + return "SendCutSend bending calculator" + return "User custom bend preset" + + def _on_source_changed(self, *_a): + key = self._source_key() + if key == "scs": + self.data = self.scs_data + self.path_lbl.setText( + "SendCutSend public bend table (read-only). Switch to My custom to save your own." + ) + self.edit_group.setVisible(False) + self.save_row_widget.setVisible(False) + else: + self.custom_data = load_custom_data() + self.data = self.custom_data + self.path_lbl.setText("Saved in: %s" % custom_data_path()) + self.edit_group.setVisible(True) + self.save_row_widget.setVisible(True) + self._reload_combos() + + def _materials_map(self): + return {m["name"]: m.get("thicknesses", []) for m in self.data.get("materials", [])} + + def _reload_combos(self, prefer_mat=None, prefer_t=None): + mats = self._materials_map() + self.material_combo.blockSignals(True) + self.material_combo.clear() + for name in mats: + self.material_combo.addItem(name) + self.material_combo.blockSignals(False) + if prefer_mat and prefer_mat in mats: + self.material_combo.setCurrentText(prefer_mat) + self._on_material_changed() + if prefer_t is not None: + for i in range(self.thickness_combo.count()): + row = self.thickness_combo.itemData(i) + if row and abs(float(row["t"]) - float(prefer_t)) < 1e-9: + self.thickness_combo.setCurrentIndex(i) + break + + def _on_material_changed(self, *_a): + name = self.material_combo.currentText() + rows = self._materials_map().get(name, []) + self.thickness_combo.blockSignals(True) + self.thickness_combo.clear() + for row in rows: + self.thickness_combo.addItem('%.3f"' % row["t"], row) + self.thickness_combo.blockSignals(False) + self._on_thickness_changed() + + def _on_thickness_changed(self, *_a): + pass + + def _current_entry(self): + idx = self.thickness_combo.currentIndex() + if idx < 0: + return None + return self.thickness_combo.itemData(idx) + + def _entry_from_editor(self): + return { + "t": float(self.spin_t.value()), + "r": float(self.spin_r.value()), + "k": float(self.spin_k.value()), + "bd": float(self.spin_bd.value()), + "relief": float(self.spin_relief.value()), + "die": float(self.spin_die.value()), + "min_flange": float(self.spin_flange.value()), + "min_corner_relief": float(self.spin_corner.value()), + } + + def load_selected_into_editor(self): + entry = self._current_entry() + name = self.material_combo.currentText() + if not entry: + self._set_status("No saved thickness selected.") + return + self.name_edit.setText(name) + self.spin_t.setValue(float(entry["t"])) + self.spin_r.setValue(float(entry["r"])) + self.spin_k.setValue(float(entry["k"])) + self.spin_bd.setValue(float(entry.get("bd", 0))) + self.spin_relief.setValue(float(entry.get("relief", 0))) + self.spin_die.setValue(float(entry.get("die", 0))) + self.spin_flange.setValue(float(entry.get("min_flange", 0))) + self.spin_corner.setValue(float(entry.get("min_corner_relief", 0))) + self._set_status("Loaded %s @ %.3f in into editor." % (name, entry["t"])) + + def save_preset(self): + name = self.name_edit.text().strip() + if not name: + self._set_status("Enter a material name before saving.") + return + entry = self._entry_from_editor() + if entry["t"] <= 0: + self._set_status("Thickness must be > 0.") + return + if entry["r"] < 0 or entry["k"] < 0: + self._set_status("Radius and K-factor cannot be negative.") + return + + if self._source_key() == "scs": + self._set_status("SendCutSend library is read-only — switch to My custom to save.") + return + + materials = self.custom_data.setdefault("materials", []) + mat = None + for m in materials: + if m.get("name") == name: + mat = m + break + if mat is None: + mat = {"name": name, "thicknesses": []} + materials.append(mat) + + rows = mat.setdefault("thicknesses", []) + replaced = False + for i, row in enumerate(rows): + if abs(float(row["t"]) - entry["t"]) < 1e-9: + rows[i] = entry + replaced = True + break + if not replaced: + rows.append(entry) + rows.sort(key=lambda r: float(r["t"])) + + path = save_custom_data(self.custom_data) + self.data = self.custom_data + self._reload_combos(prefer_mat=name, prefer_t=entry["t"]) + self._set_status( + "Saved %s @ %.3f in (k=%.3f, r=%.4f). File: %s" + % (name, entry["t"], entry["k"], entry["r"], path) + ) + + def delete_preset(self): + name = self.material_combo.currentText() + entry = self._current_entry() + if not name or not entry: + self._set_status("Nothing selected to delete.") + return + if self._source_key() == "scs": + self._set_status("SendCutSend library is read-only.") + return + materials = self.custom_data.get("materials", []) + for m in materials: + if m.get("name") != name: + continue + rows = m.get("thicknesses", []) + m["thicknesses"] = [ + r for r in rows if abs(float(r["t"]) - float(entry["t"])) >= 1e-9 + ] + if not m["thicknesses"]: + self.custom_data["materials"] = [ + x for x in materials if x.get("name") != name + ] + break + save_custom_data(self.custom_data) + self.custom_data = load_custom_data() + self.data = self.custom_data + self._reload_combos() + self._set_status("Deleted %s @ %.3f in." % (name, entry["t"])) + + def _active_mat_and_entry(self): + """Prefer editor values (custom mode) if name filled; else combo selection.""" + if self._source_key() == "custom": + name = self.name_edit.text().strip() + if name and self.spin_t.value() > 0: + return name, self._entry_from_editor() + name = self.material_combo.currentText() + entry = self._current_entry() + return name, entry + + def apply_to_selection(self): + name, entry = self._active_mat_and_entry() + if not entry: + self._set_status("Select a saved preset or fill the editor.") + return + msg = apply_entry_to_bends( + entry, + mat_name=name or None, + sheet_prefix=self._sheet_prefix(), + log=self._set_status, + ) + self._set_status(msg) + + def create_material_sheet(self): + name, entry = self._active_mat_and_entry() + if not name or not entry: + self._set_status("Select a saved preset or fill the editor (with name).") + return + msg = create_material_sheet_for_entry( + name, + entry, + prefix=self._sheet_prefix(), + source=self._source_note(), + log=self._set_status, + ) + self._set_status(msg) + + def set_sheetmetal_defaults(self): + _name, entry = self._active_mat_and_entry() + if not entry: + self._set_status("Select a saved preset or fill the editor.") + return + self._set_status(set_sheetmetal_defaults_for_entry(entry)) + + def apply_all(self): + # Sheet first so Unfold features can be pointed at it, then bends + sync + self.create_material_sheet() + self.apply_to_selection() + self._set_status( + self.status.text() + + " Tip: recompute Unfold (or tweak & recompute) if the flat pattern is stale." + ) + + +class CustomPresetsDialog: + def __init__(self): + self.dialog = None + self.panel = None + + def _dialog_alive(self): + if self.dialog is None: + return False + try: + _ = self.dialog.isVisible() + return True + except RuntimeError: + self.dialog = None + self.panel = None + return False + + def _on_destroyed(self, *_args): + self.dialog = None + self.panel = None + + def show(self): + if self._dialog_alive(): + self.dialog.show() + self.dialog.raise_() + self.dialog.activateWindow() + return + + self.dialog = QWidget() + try: + self.dialog.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowTitleHint + ) + except Exception: + pass + try: + self.dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + except Exception: + pass + self.dialog.setWindowTitle("Bend Presets (SCS + Custom)") + try: + self.dialog.destroyed.connect(self._on_destroyed) + except Exception: + pass + + layout = QVBoxLayout(self.dialog) + self.panel = CustomPresetsPanel() + layout.addWidget(self.panel) + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.dialog.close) + layout.addWidget(close_btn) + apply_dark_theme(self.dialog) + self.dialog.resize(540, 620) + self.dialog.show() + self.dialog.raise_() + self.dialog.activateWindow() + + +_custom_dialog = None + + +def show_custom_presets_dialog(): + global _custom_dialog + if _custom_dialog is None: + _custom_dialog = CustomPresetsDialog() + _custom_dialog.show() + + +class SCS_ShowCustomPresetsCommand: + def GetResources(self): + icon = os.path.join(WB_DIR, "resources", "icons", "SCS_Custom.svg") + if not os.path.isfile(icon): + icon = ICON_PATH + return { + "Pixmap": icon, + "MenuText": "Bend Presets (SCS + Custom)", + "Accel": "S, U", + "ToolTip": "SendCutSend library + your custom bend presets for SheetMetal", + } + + def Activated(self): + show_custom_presets_dialog() + + def IsActive(self): + return True + + +if App.GuiUp: + Gui.addCommand("SCS_ShowCustomPresets", SCS_ShowCustomPresetsCommand()) diff --git a/src/Mod/SendCutSendPresets/Init.py b/src/Mod/SendCutSendPresets/Init.py new file mode 100644 index 000000000..453cebe03 --- /dev/null +++ b/src/Mod/SendCutSendPresets/Init.py @@ -0,0 +1,3 @@ +# SendCutSend Presets workbench +# SPDX-License-Identifier: MIT +# Keep Init.py free of __file__ — FreeCAD execs it without that name. diff --git a/src/Mod/SendCutSendPresets/InitGui.py b/src/Mod/SendCutSendPresets/InitGui.py new file mode 100644 index 000000000..704babd11 --- /dev/null +++ b/src/Mod/SendCutSendPresets/InitGui.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# SendCutSend Presets workbench GUI registration +# SPDX-License-Identifier: MIT +# +# FreeCAD execs InitGui with SEPARATE globals/locals. Class bodies only see +# globals (Workbench, Gui, FreeCAD are injected there). Promote our names. + +import os +import sys +import FreeCAD + + +def _mod_dir(): + root = FreeCAD.getUserAppDataDir() + rels = ( + os.path.join("Mod", "SendCutSendPresets"), + os.path.join("v1-1", "Mod", "SendCutSendPresets"), + os.path.join("v26-3", "Mod", "SendCutSendPresets"), + os.path.join("v1-1", "v26-3", "Mod", "SendCutSendPresets"), + ) + for rel in rels: + path = os.path.join(root, rel) + if os.path.isfile(os.path.join(path, "SCSCommand.py")): + return path + return os.path.join(root, "Mod", "SendCutSendPresets") + + +SCS_MOD_DIR = _mod_dir() +SCS_ICON = os.path.join(SCS_MOD_DIR, "resources", "icons", "SCS_Presets.svg") + +# Critical: make names visible to class body under FreeCAD's exec(g, l) +globals().update(locals()) + + +class SendCutSendPresetsWorkbench(Workbench): + """SendCutSend bend presets for SheetMetal (test bed).""" + + MenuText = "SendCutSend" + ToolTip = "SendCutSend + custom bend presets (testing workbench)" + Icon = SCS_ICON + + def Initialize(self): + mod = SCS_MOD_DIR + if mod and mod not in sys.path: + sys.path.insert(0, mod) + import SCSCommand # registers SCS_ShowPresets + import CustomPresets # registers SCS_ShowCustomPresets + + # Keep BOTH tools here for testing + cmds = ["SCS_ShowPresets", "SCS_ShowCustomPresets"] + self.appendToolbar("SendCutSend", cmds) + self.appendMenu("SendCutSend", cmds) + + # Also expose Bend Presets on SheetMetal via runtime integration + try: + import sheetmetal_integration + sheetmetal_integration.setup() + import pending_unfold + pending_unfold.setup() + except Exception as exc: + FreeCAD.Console.PrintWarning( + "[SendCutSendPresets] SheetMetal integration failed: %s\n" % exc + ) + + def Activated(self): + return + + def Deactivated(self): + return + + def GetClassName(self): + return "Gui::PythonWorkbench" + + +Gui.addWorkbench(SendCutSendPresetsWorkbench()) + +# Also start SheetMetal integration even if user never opens SendCutSend WB +try: + if SCS_MOD_DIR and SCS_MOD_DIR not in sys.path: + sys.path.insert(0, SCS_MOD_DIR) + import sheetmetal_integration as _scs_sm_int + _scs_sm_int.setup() + import pending_unfold as _scs_pending + _scs_pending.setup() +except Exception: + pass diff --git a/src/Mod/SendCutSendPresets/LICENSE b/src/Mod/SendCutSendPresets/LICENSE new file mode 100644 index 000000000..88fa17976 --- /dev/null +++ b/src/Mod/SendCutSendPresets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ricky Faircloth / Nozzler LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Mod/SendCutSendPresets/README.md b/src/Mod/SendCutSendPresets/README.md new file mode 100644 index 000000000..6ca63a358 --- /dev/null +++ b/src/Mod/SendCutSendPresets/README.md @@ -0,0 +1,82 @@ +# SendCutSend Presets (FreeCAD) + +Companion [FreeCAD](https://www.freecad.org/) workbench for [SheetMetal](https://github.com/shaise/FreeCAD_SheetMetal) that applies **SendCutSend** bend specs (and your own custom presets) to flanges and Unfold material sheets. + +Bend numbers come from SendCutSend’s public [bending calculator](https://sendcutsend.com/bending-calculator/) (snapshot in `data/sendcutsend_bends.json`, retrieved 2026-09-05). Re-check their docs when tables change. **Not** an official SendCutSend product. + +## Features + +- **SendCutSend library** — material / thickness → radius, K-factor, bend deduction, relief, min flange, die, corner relief +- **My custom** — save shop-specific presets to user data (`SendCutSendPresets/custom_bends.json`) +- **Apply to bends** — radius + K on SheetMetal bend features (Body selection expands to bends) +- **Material sheets** — `material_SCS_*` / `material_Custom_*` for Unfold +- **SheetMetal defaults** — `defaultRadius`, `defaultKFactor`, `manualKFactor`, `kFactorStandard` +- **Unfold sync** — writes Manual KFactor + Material Sheet onto Unfold features for Data-panel consistency +- **Apply-before-Unfold** — remembers last Apply and auto-syncs when you create Unfold later +- **SheetMetal toolbar** — injects **Bend Presets (SCS + Custom)** at runtime (no SheetMetal fork) + +## Requirements + +- FreeCAD ≥ 0.21 (tested on 1.1) +- [SheetMetal](https://github.com/shaise/FreeCAD_SheetMetal) workbench installed + +## Install + +### From zip (manual) + +1. Unzip so you have a folder named `SendCutSendPresets` containing `InitGui.py`, `package.xml`, etc. +2. Copy that folder into a FreeCAD `Mod` directory, e.g.: + - Windows: `%APPDATA%\FreeCAD\Mod\` + - Linux: `~/.local/share/FreeCAD/Mod/` + - FreeCAD 1.1 may also use `v1-1\Mod` or `v26-3\Mod` — place it next to `sheetmetal` +3. Restart FreeCAD + +### Addon Manager (after published) + +Search **SendCutSend Presets** once listed in FreeCAD-addons / Addon Manager. + +## Usage + +1. Model a part in **Sheet Metal**. +2. Open **Bend Presets (SCS + Custom)** from the SheetMetal toolbar (or the **SendCutSend** workbench). +3. Choose **Preset source**: SendCutSend library or My custom. +4. Pick material + thickness (or edit/save a custom preset). +5. **Apply all (bends + sheet + defaults)** — or use the individual actions. +6. Unfold; material sheet should appear in Unfold’s Material Definition Sheet list. Manual K-Factor may stay grayed out while a sheet is selected (SheetMetal UI); the Data panel KFactor is synced to match. + +Tip: SheetMetal **Engineering UX Mode** (`Edit → Preferences → SheetMetal`) helps avoid silent K defaults. + +## Architecture (for reviewers) + +| Module | Role | +|--------|------| +| `InitGui.py` | Registers workbench; starts SheetMetal integration + Unfold observer | +| `SCSCommand.py` | Standalone SCS library dialog (test bed) | +| `CustomPresets.py` | Unified SCS + Custom UI used on SheetMetal | +| `bend_actions.py` | Apply / material sheet / defaults / Unfold sync | +| `pending_unfold.py` | Document observer: Apply-before-Unfold auto-sync | +| `sheetmetal_integration.py` | Runtime toolbar/menu inject into SheetMetal | +| `data/sendcutsend_bends.json` | Snapshotted public bend table | + +This addon **does not patch** SheetMetal’s `InitGui.py`. Integration is optional and non-invasive. + +## Demo idea (~60s) + +1. SheetMetal base + flange at a published thickness (e.g. 5052 / 0.063"). +2. Bend Presets → that material/thickness → Apply all. +3. Unfold → select `material_SCS_…` → show flat pattern + Data panel K. + +## License + +MIT — see [LICENSE](LICENSE). + +- SheetMetal remains LGPL-2.1. +- SendCutSend manufacturing data remains theirs; attribute the public calculator if you redistribute the JSON. + +## Contributing / PR checklist + +- [ ] Bump `version` in `package.xml` and `metadata.txt` +- [ ] Update `CHANGELOG.md` +- [ ] Keep SheetMetal integration runtime-only (no edits under `Mod/sheetmetal`) +- [ ] Re-verify `data/sendcutsend_bends.json` against the public calculator when refreshing numbers +- [ ] Test: Apply with Unfold present; Apply then create Unfold; Body-only selection; custom save/load diff --git a/src/Mod/SendCutSendPresets/SCSCommand.py b/src/Mod/SendCutSendPresets/SCSCommand.py new file mode 100644 index 000000000..16fc7c7a3 --- /dev/null +++ b/src/Mod/SendCutSendPresets/SCSCommand.py @@ -0,0 +1,481 @@ +# SPDX-License-Identifier: MIT +"""SendCutSend bend presets command and task panel for FreeCAD.""" + +from __future__ import annotations + +import json +import os +import re + +import FreeCAD as App +import FreeCADGui as Gui + +# Qt compatibility: FreeCAD 0.21/1.0 often PySide2; 1.1 may be PySide6 +QtCore = None +QtGui = None +_widgets = None +for _mod in ("PySide6", "PySide2", "PySide"): + try: + if _mod == "PySide": + from PySide import QtGui as _QtGui, QtCore as _QtCore # type: ignore + QtGui, QtCore = _QtGui, _QtCore + if hasattr(QtGui, "QWidget"): + _widgets = QtGui + break + else: + _Qt = __import__(_mod + ".QtCore", fromlist=["QtCore"]) + _QtGui = __import__(_mod + ".QtGui", fromlist=["QtGui"]) + _QtWidgets = __import__(_mod + ".QtWidgets", fromlist=["QtWidgets"]) + QtCore, QtGui, _widgets = _Qt, _QtGui, _QtWidgets + break + except ImportError: + continue +if _widgets is None: + raise ImportError("No PySide / PySide2 / PySide6 found") + +QWidget = _widgets.QWidget +QVBoxLayout = _widgets.QVBoxLayout +QHBoxLayout = _widgets.QHBoxLayout +QFormLayout = _widgets.QFormLayout +QLabel = _widgets.QLabel +QComboBox = _widgets.QComboBox +QPushButton = _widgets.QPushButton +QGroupBox = _widgets.QGroupBox +QMessageBox = _widgets.QMessageBox + +from ui_theme import apply_dark_theme +from bend_common import ( + INCH_TO_MM, + RADIUS_PROP_NAMES, + KFACTOR_PROP_NAMES, + find_property, + inch_quantity, + material_short_name, + thickness_thou, + resolve_wb_dir, +) + +WB_DIR = resolve_wb_dir() +DATA_PATH = os.path.join(WB_DIR, "data", "sendcutsend_bends.json") +ICON_PATH = os.path.join(WB_DIR, "resources", "icons", "SCS_Presets.svg") + + +def load_bend_data(): + with open(DATA_PATH, "r", encoding="utf-8") as fh: + return json.load(fh) + + + + + + +class SCSPresetsPanel(QWidget): + """Task-panel style dialog for SendCutSend bend presets.""" + + def __init__(self, parent=None): + super(SCSPresetsPanel, self).__init__(parent) + self.data = load_bend_data() + self.materials = {m["name"]: m["thicknesses"] for m in self.data["materials"]} + self._build_ui() + self._populate_materials() + self._on_material_changed(0) + + def _build_ui(self): + root = QVBoxLayout(self) + + form = QFormLayout() + self.material_combo = QComboBox() + self.thickness_combo = QComboBox() + form.addRow("Material:", self.material_combo) + form.addRow("Thickness:", self.thickness_combo) + root.addLayout(form) + + group = QGroupBox("Bend parameters (90°)") + grid = QFormLayout(group) + self.lbl_radius = QLabel("-") + self.lbl_k = QLabel("-") + self.lbl_bd = QLabel("-") + self.lbl_relief = QLabel("-") + self.lbl_min_flange = QLabel("-") + self.lbl_die = QLabel("-") + self.lbl_corner = QLabel("-") + grid.addRow("Bend radius:", self.lbl_radius) + grid.addRow("K-factor:", self.lbl_k) + grid.addRow("Bend deduction:", self.lbl_bd) + grid.addRow("Bend relief depth:", self.lbl_relief) + grid.addRow("Min flange:", self.lbl_min_flange) + grid.addRow("Die width:", self.lbl_die) + grid.addRow("Min corner relief:", self.lbl_corner) + root.addWidget(group) + + self.status = QLabel("") + self.status.setWordWrap(True) + root.addWidget(self.status) + + btn_row = QHBoxLayout() + self.btn_apply = QPushButton("Apply to bends") + self.btn_sheet = QPushButton("Create material sheet") + self.btn_defaults = QPushButton("Set SheetMetal defaults") + self.btn_all = QPushButton("Apply all (bends + sheet + defaults)") + btn_row.addWidget(self.btn_apply) + btn_row.addWidget(self.btn_sheet) + btn_row.addWidget(self.btn_defaults) + root.addLayout(btn_row) + root.addWidget(self.btn_all) + + self.material_combo.currentIndexChanged.connect(self._on_material_changed) + self.thickness_combo.currentIndexChanged.connect(self._on_thickness_changed) + self.btn_apply.clicked.connect(self.apply_to_selection) + self.btn_sheet.clicked.connect(self.create_material_sheet) + self.btn_defaults.clicked.connect(self.set_sheetmetal_defaults) + self.btn_all.clicked.connect(self.apply_all) + + def _populate_materials(self): + self.material_combo.blockSignals(True) + self.material_combo.clear() + for name in self.materials: + self.material_combo.addItem(name) + self.material_combo.blockSignals(False) + + def _on_material_changed(self, _index=None): + name = self.material_combo.currentText() + thicknesses = self.materials.get(name, []) + self.thickness_combo.blockSignals(True) + self.thickness_combo.clear() + for row in thicknesses: + t = row["t"] + self.thickness_combo.addItem(f'{t:.3f}"', row) + self.thickness_combo.blockSignals(False) + self._on_thickness_changed() + + def _current_entry(self): + idx = self.thickness_combo.currentIndex() + if idx < 0: + return None + return self.thickness_combo.itemData(idx) + + def _fmt_dual(self, value_in: float) -> str: + mm = value_in * INCH_TO_MM + return f'{value_in:.4f}" ({mm:.3f} mm)' + + def _on_thickness_changed(self, _index=None): + entry = self._current_entry() + if not entry: + for lbl in ( + self.lbl_radius, + self.lbl_k, + self.lbl_bd, + self.lbl_relief, + self.lbl_min_flange, + self.lbl_die, + self.lbl_corner, + ): + lbl.setText("-") + return + self.lbl_radius.setText(self._fmt_dual(entry["r"])) + self.lbl_k.setText(f'{entry["k"]:.2f}') + self.lbl_bd.setText(self._fmt_dual(entry["bd"])) + self.lbl_relief.setText(self._fmt_dual(entry["relief"])) + self.lbl_min_flange.setText(self._fmt_dual(entry["min_flange"])) + self.lbl_die.setText(self._fmt_dual(entry["die"])) + self.lbl_corner.setText(self._fmt_dual(entry["min_corner_relief"])) + + def _set_status(self, text: str): + self.status.setText(text) + App.Console.PrintMessage(f"[SCS Presets] {text}\n") + + + def apply_to_selection(self): + entry = self._current_entry() + if not entry: + self._set_status("No thickness selected.") + return + + radius_q = inch_quantity(entry["r"]) + k = float(entry["k"]) + updated = 0 + skipped = [] + + from bend_actions import collect_bend_targets, check_min_flange_lengths + targets = collect_bend_targets() + + if not targets: + self._set_status( + "Nothing to update. Select a SheetMetal Bend/Wall (or its Body), " + "or create one first." + ) + return + + for obj in targets: + r_prop = find_property(obj, RADIUS_PROP_NAMES) + k_prop = find_property(obj, KFACTOR_PROP_NAMES) + changed = False + try: + if r_prop: + setattr(obj, r_prop, radius_q) + changed = True + if k_prop: + # SheetMetal uses App::PropertyFloat "kfactor" + try: + setattr(obj, k_prop, float(k)) + except Exception: + try: + obj.setExpression(k_prop, None) + except Exception: + pass + setattr(obj, k_prop, float(k)) + changed = True + except Exception as exc: + skipped.append(f"{obj.Label}: {exc}") + continue + if changed: + updated += 1 + obj.touch() + else: + skipped.append(f"{obj.Label}: no radius/kfactor properties") + + if updated and App.ActiveDocument is not None: + App.ActiveDocument.recompute() + + from bend_actions import check_min_flange_lengths + _ok, flange_warnings = check_min_flange_lengths( + entry, targets=targets, warn_dialog=True + ) + + # Also push unfold + new-feature defaults so flat pattern matches + # (set silently then report one combined status) + try: + param = App.ParamGet("User parameter:BaseApp/Preferences/Mod/SheetMetal") + param.SetString("defaultRadius", f'{entry["r"]} in') + param.SetFloat("defaultKFactor", k) + param.SetFloat("manualKFactor", k) + param.SetString("kFactorStandard", "ansi") + prefs_ok = True + except Exception: + prefs_ok = False + + mat_name = self.material_combo.currentText() + short = material_short_name(mat_name) + thou = thickness_thou(entry["t"]) + sheet_name = f"material_SCS_{short}_{thou}" + from bend_actions import sync_unfold_features + unfold_n = sync_unfold_features(sheet_name, k) + + msg = f"Applied radius={entry['r']} in, k={k} to {updated} object(s)." + if prefs_ok: + msg += f" Prefs: defaultKFactor/manualKFactor={k}." + if unfold_n: + msg += f" Updated {unfold_n} Unfold feature(s) to K={k} / {sheet_name}." + elif App.ActiveDocument and not any( + hasattr(o, "MaterialSheet") for o in App.ActiveDocument.Objects + ): + msg += ( + f" Create material sheet '{sheet_name}' then re-apply to sync Unfold." + ) + if flange_warnings: + msg += " MIN FLANGE WARN: " + "; ".join(flange_warnings) + if skipped: + msg += " Warnings: " + "; ".join(skipped) + self._set_status(msg) + + def create_material_sheet(self): + entry = self._current_entry() + mat_name = self.material_combo.currentText() + if not entry: + self._set_status("No thickness selected.") + return + doc = App.ActiveDocument + if doc is None: + self._set_status("No active document. Create or open a document first.") + return + + short = material_short_name(mat_name) + thou = thickness_thou(entry["t"]) + sheet_name = f"material_SCS_{short}_{thou}" + + # Reuse existing object with same name if present + existing = doc.getObject(sheet_name) + if existing is not None: + sheet = existing + else: + sheet = doc.addObject("Spreadsheet::Sheet", sheet_name) + sheet.Label = sheet_name + + r_over_t = entry["r"] / entry["t"] if entry["t"] else 0.0 + k = float(entry["k"]) + + # SheetMetal material sheet convention: + # A1/B1 headers, A2/B2 r/t and k, A3/B3 fallback angle/k, options block + sheet.set("A1", "Radius / Thickness") + sheet.set("B1", "K-factor (ANSI)") + sheet.set("A2", f"{r_over_t:.6f}") + sheet.set("B2", f"{k:.6f}") + sheet.set("A3", "99") + sheet.set("B3", "0.5") + sheet.set("A5", "Options") + sheet.set("A6", "K-factor standard") + sheet.set("B6", "ansi") + # Extra reference cells for the chosen SCS preset + sheet.set("A8", "Material") + sheet.set("B8", mat_name) + sheet.set("A9", "Thickness (in)") + sheet.set("B9", f'{entry["t"]:.4f}') + sheet.set("A10", "Bend radius (in)") + sheet.set("B10", f'{entry["r"]:.4f}') + sheet.set("A11", "Bend deduction (in)") + sheet.set("B11", f'{entry["bd"]:.4f}') + sheet.set("A12", "Source") + sheet.set("B12", "SendCutSend bending calculator") + + doc.recompute() + from bend_actions import sync_unfold_features + n = sync_unfold_features(sheet_name, k) + self._set_status( + f"Created/updated '{sheet_name}'. Synced K={k} onto {n} Unfold object(s). " + f"Material Definition Sheet should be '{sheet_name}' (not Manual)." + ) + + + + def apply_all(self): + """One-shot: bends + material sheet + prefs (including Unfold manual K).""" + self.apply_to_selection() + self.create_material_sheet() + self._set_status( + self.status.text() + + " Tip: reopen the Unfold task panel so the material list refreshes." + ) + + def set_sheetmetal_defaults(self): + entry = self._current_entry() + if not entry: + self._set_status("No thickness selected.") + return + + try: + param = App.ParamGet("User parameter:BaseApp/Preferences/Mod/SheetMetal") + except Exception as exc: + self._set_status(f"Could not open SheetMetal preferences: {exc}") + return + + radius_str = f'{entry["r"]} in' + k = float(entry["k"]) + set_msgs = [] + + # SheetMetal reads these exact preference keys (see SheetMetalCmd / + # UnfoldGUI). ParamGet.Set* never fails on unknown keys, so write the + # real names only -- do not "probe" with fake keys. + param.SetString("defaultRadius", radius_str) + set_msgs.append(f"defaultRadius={radius_str}") + + param.SetFloat("defaultKFactor", k) + set_msgs.append(f"defaultKFactor={k}") + + # Unfold task panel "Manual K-Factor" uses this, not defaultKFactor + param.SetFloat("manualKFactor", k) + set_msgs.append(f"manualKFactor={k}") + + param.SetString("kFactorStandard", "ansi") + set_msgs.append("kFactorStandard=ansi") + + self._set_status("SheetMetal defaults updated: " + "; ".join(set_msgs)) + + +class SCSPresetsDialog: + """Reusable window for the presets panel. + + FreeCAD keeps this wrapper alive; after Close the QWidget is only hidden. + A second toolbar click must call show() again (raise_/activate alone is not + enough). If Qt deleted the C++ object, recreate it. + """ + + def __init__(self): + self.dialog = None + self.panel = None + + def _dialog_alive(self): + if self.dialog is None: + return False + try: + _ = self.dialog.isVisible() + return True + except RuntimeError: + # wrap around deleted C++ QWidget + self.dialog = None + self.panel = None + return False + + def _on_destroyed(self, *_args): + self.dialog = None + self.panel = None + + def show(self): + if self._dialog_alive(): + self.dialog.show() + self.dialog.raise_() + self.dialog.activateWindow() + return + + self.dialog = QWidget() + # Top-level window so Close hides/destroys cleanly (not an orphan child) + try: + self.dialog.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowTitleHint + ) + except Exception: + pass + try: + self.dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + except Exception: + pass + self.dialog.setWindowTitle("SendCutSend Bend Presets") + try: + self.dialog.destroyed.connect(self._on_destroyed) + except Exception: + pass + + layout = QVBoxLayout(self.dialog) + self.panel = SCSPresetsPanel() + layout.addWidget(self.panel) + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.dialog.close) + layout.addWidget(close_btn) + apply_dark_theme(self.dialog) + self.dialog.resize(520, 460) + self.dialog.show() + self.dialog.raise_() + self.dialog.activateWindow() + + +_dialog_singleton = None + + +def show_presets_dialog(): + global _dialog_singleton + if _dialog_singleton is None: + _dialog_singleton = SCSPresetsDialog() + _dialog_singleton.show() + + +class SCS_ShowPresetsCommand: + """FreeCAD command to open the SendCutSend presets panel.""" + + def GetResources(self): + return { + "Pixmap": ICON_PATH, + "MenuText": "SendCutSend Presets", + "Accel": "S, C", + "ToolTip": "Browse SendCutSend bend presets and apply to SheetMetal", + } + + def Activated(self): + show_presets_dialog() + + def IsActive(self): + return True + + +if App.GuiUp: + Gui.addCommand("SCS_ShowPresets", SCS_ShowPresetsCommand()) diff --git a/src/Mod/SendCutSendPresets/SCS_locator.py b/src/Mod/SendCutSendPresets/SCS_locator.py new file mode 100644 index 000000000..3c9a28f3f --- /dev/null +++ b/src/Mod/SendCutSendPresets/SCS_locator.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MIT +"""Locate this workbench directory. + +InitGui.py is exec'd by FreeCAD without __file__, so icon/data paths must +come from a normal imported module (this file). +""" + +import os + +PATH = os.path.dirname(os.path.realpath(__file__)) diff --git a/src/Mod/SendCutSendPresets/SMOKE.md b/src/Mod/SendCutSendPresets/SMOKE.md new file mode 100644 index 000000000..66eeee53e --- /dev/null +++ b/src/Mod/SendCutSendPresets/SMOKE.md @@ -0,0 +1,42 @@ +# Smoke tests — SendCutSendPresets + +## Automated (no FreeCAD GUI) + +From this Mod directory: + +```bash +python -m unittest tests.test_smoke -v +``` + +Covers: bend JSON shape, naming helpers, min-flange warn rule. + +## FreeCADCmd (optional) + +With FreeCADCmd on PATH and this Mod on `sys.path` / installed under `Mod/`: + +```bash +FreeCADCmd -c "import json,os; p=os.path.join(os.path.dirname(__file__) if '__file__' in dir() else '.', 'data','sendcutsend_bends.json'); print('skip if needed')" +``` + +Preferred one-liner after install into a FreeCAD Mod path: + +```bash +FreeCADCmd /path/to/Mod/SendCutSendPresets/tests/freecadcmd_smoke.py +``` + +(`tests/freecadcmd_smoke.py` creates a FeaturePython with KFactor and verifies setattr 0.4→0.5.) + +## Manual GUI (SheetMetal required) + +1. Restart FreeCAD/VibeCAD with SheetMetal + SendCutSendPresets installed. +2. SheetMetal toolbar: open **Bend Presets (SCS + Custom)**. +3. Source **SendCutSend library** → 5052 Aluminum / 0.063" → **Apply all**. +4. Confirm bends get radius/K; material sheet `material_SCS_5052_063` appears. +5. Unfold → pick that sheet → Data panel KFactor synced (or Apply again after Unfold). +6. Apply all *before* Unfold on a new part → create Unfold → Report shows auto-sync. +7. Switch to **My custom**, save a preset, Apply — sheet prefix `material_Custom_*`. + +## vibecad CONTRIBUTING / AI-TDD note + +- Unit tests above are the red/green-capable automated layer (data + pure rules). +- Full Apply/Unfold GUI depends on SheetMetal + Qt; covered by FreeCADCmd smoke + manual checklist rather than CI GUI automation in this PR. diff --git a/src/Mod/SendCutSendPresets/bend_actions.py b/src/Mod/SendCutSendPresets/bend_actions.py new file mode 100644 index 000000000..eea07642f --- /dev/null +++ b/src/Mod/SendCutSendPresets/bend_actions.py @@ -0,0 +1,599 @@ +# SPDX-License-Identifier: MIT +"""Shared apply / material-sheet / defaults helpers for bend presets.""" + +from __future__ import annotations + +import FreeCAD as App +import FreeCADGui as Gui + +from bend_common import ( + RADIUS_PROP_NAMES, + KFACTOR_PROP_NAMES, + LENGTH_PROP_NAMES, + find_property, + inch_quantity, + quantity_to_inches, + material_short_name, + thickness_thou, +) + + +def _iter_group_children(obj): + """Yield child objects from Body / Part / other containers.""" + seen = set() + stack = [obj] + while stack: + cur = stack.pop() + for attr in ("Group", "OutList"): + try: + kids = list(getattr(cur, attr, None) or []) + except Exception: + kids = [] + for kid in kids: + if kid is None: + continue + kid_id = id(kid) + if kid_id in seen: + continue + seen.add(kid_id) + yield kid + stack.append(kid) + + +def expand_targets(objs): + """Expand PartDesign Body / App::Part selections into child features.""" + out = [] + seen = set() + + def _add(o): + if o is None: + return + oid = id(o) + if oid in seen: + return + seen.add(oid) + out.append(o) + + for obj in objs or []: + _add(obj) + type_id = "" + try: + type_id = obj.TypeId or "" + except Exception: + type_id = "" + needs_expand = ( + "Body" in type_id + or type_id.startswith("App::Part") + or find_property(obj, RADIUS_PROP_NAMES) is None + ) + if needs_expand: + for kid in _iter_group_children(obj): + _add(kid) + return out + + +def collect_bend_targets(sel=None): + """Bend-capable features from selection (expanded) or whole document.""" + if sel is None: + try: + sel = Gui.Selection.getSelection() + except Exception: + sel = [] + + expanded = expand_targets(list(sel)) + capable = [ + o + for o in expanded + if find_property(o, RADIUS_PROP_NAMES) + or find_property(o, KFACTOR_PROP_NAMES) + ] + if capable: + return capable + + if App.ActiveDocument is None: + return [] + found = [] + for obj in App.ActiveDocument.Objects: + if find_property(obj, RADIUS_PROP_NAMES) or find_property( + obj, KFACTOR_PROP_NAMES + ): + found.append(obj) + return found + + +def check_min_flange_lengths(entry, targets=None, log=None, warn_dialog=True): + """Compare flange/bend lengths on targets to entry['min_flange']. + + Returns (ok: bool, warnings: list[str]). + Warns when length is strictly below min. Equal-to-min is allowed. + """ + min_flange = entry.get("min_flange") if entry else None + try: + min_flange = float(min_flange) if min_flange is not None else None + except (TypeError, ValueError): + min_flange = None + + if min_flange is None or min_flange <= 0: + return True, [] + + if targets is None: + targets = collect_bend_targets() + + warnings = [] + for obj in targets: + l_prop = find_property(obj, LENGTH_PROP_NAMES) + if not l_prop: + continue + try: + length_in = quantity_to_inches(getattr(obj, l_prop)) + except Exception: + continue + if length_in is None: + continue + if length_in + 1e-6 < min_flange: + warnings.append( + "%s: flange length %.4f in < min flange %.4f in" + % (obj.Label, length_in, min_flange) + ) + + if warnings: + text = "Min flange length warning (preset min=%.4f in): %s" % ( + min_flange, + " | ".join(warnings), + ) + if log: + log(text) + try: + App.Console.PrintWarning("[Bend Presets] %s\n" % text) + except Exception: + pass + if warn_dialog: + QMessageBox = None + try: + from PySide6.QtWidgets import QMessageBox # type: ignore + except ImportError: + try: + from PySide2.QtWidgets import QMessageBox # type: ignore + except ImportError: + try: + from PySide.QtGui import QMessageBox # type: ignore + except ImportError: + QMessageBox = None + if QMessageBox is not None: + try: + body = ( + "Applied bend params, but flange length is below the " + "preset minimum (%.4f in).\n\n%s\n\n" + "Lengthen the flange(s) or pick a different preset." + % (min_flange, "\n".join(warnings)) + ) + QMessageBox.warning(None, "Min flange length", body) + except Exception: + pass + return False, warnings + return True, [] + + +def _object_names(doc): + """Snapshot object names (safe across FreeCAD proxy reuse / recompute).""" + names = [] + try: + objs = list(doc.Objects) + except Exception: + objs = [] + for obj in objs: + try: + names.append(str(obj.Name)) + except Exception: + continue + # Body/Part children sometimes matter; names are still in doc.Objects usually + for attr in ("Group",): + try: + kids = list(getattr(obj, attr, None) or []) + except Exception: + kids = [] + for kid in kids: + try: + kn = str(kid.Name) + except Exception: + continue + if kn not in names: + names.append(kn) + # Selection names too + try: + for obj in Gui.Selection.getSelection(): + try: + sn = str(obj.Name) + except Exception: + continue + if sn not in names: + names.append(sn) + except Exception: + pass + return names + + +def _fetch(doc, name): + try: + return doc.getObject(name) + except Exception: + return None + + +def _safe_str_attr(obj, attr): + try: + val = getattr(obj, attr) + except Exception: + return "" + try: + return str(val) if val is not None else "" + except Exception: + return "" + + +def _has_prop(obj, prop): + """True if property exists and is readable (avoid PropertiesList quirks).""" + try: + if hasattr(obj, prop): + getattr(obj, prop) + return True + except Exception: + pass + try: + obj.getPropertyByName(prop) + return True + except Exception: + return False + + +def _set_kfactor_on_obj(obj, k): + """Write KFactor; return (ok, prop_name, err).""" + k = float(k) + errors = [] + for pname in ("KFactor", "kfactor", "kFactor"): + if not _has_prop(obj, pname): + continue + try: + try: + obj.setExpression(pname, None) + except Exception: + pass + try: + obj.setEditorMode(pname, 0) + except Exception: + pass + setattr(obj, pname, k) + got = getattr(obj, pname) + try: + got_f = float(got) + except Exception: + got_f = float(getattr(got, "Value", got)) + if abs(got_f - k) < 1e-9: + return True, pname, None + errors.append("%s wrote but read back %s" % (pname, got)) + except Exception as exc: + errors.append("%s: %s" % (pname, exc)) + # Last resort: addProperty then set (should not be needed for real Unfold) + return False, None, "; ".join(errors) if errors else "no KFactor property" + + +def sync_unfold_features(mat_sheet_label, k, log=None): + """Push K-factor (+ material sheet) onto Unfold feature(s). + + SheetMetal grays out Manual K-Factor when a Material Sheet is selected; + we still write Unfold.KFactor so the Data panel is not stuck at 0.40. + """ + def _emit(msg): + try: + App.Console.PrintMessage("[Bend Presets] %s\n" % msg) + except Exception: + pass + if log: + try: + log(msg) + except Exception: + pass + + doc = App.ActiveDocument + if doc is None: + _emit("sync_unfold: no active document") + return 0 + + k = float(k) + names = _object_names(doc) + _emit("sync scan names(%s): %s" % (len(names), ", ".join(names))) + + updated = 0 + considered = [] + + for name in names: + obj = _fetch(doc, name) + if obj is None: + continue + + label = _safe_str_attr(obj, "Label") or name + type_id = _safe_str_attr(obj, "TypeId") + blob = ("%s %s %s" % (name, label, type_id)).lower() + + has_k = _has_prop(obj, "KFactor") + has_m = _has_prop(obj, "MaterialSheet") + mat_val = _safe_str_attr(obj, "MaterialSheet") if has_m else "" + + # Skip sketches named Unfold_Sketch etc. + if "sketch" in blob: + continue + is_unf = ( + (has_k and has_m) + or (has_k and "unfold" in blob) + or (has_k and mat_sheet_label and mat_val == str(mat_sheet_label)) + ) + if not is_unf: + continue + + considered.append("%s/%s hasK=%s hasM=%s mat=%s" % (name, label, has_k, has_m, mat_val or "-")) + + # Assign material sheet (UI-readOnly; Python can still set — task panel does) + if mat_sheet_label and has_m: + try: + try: + obj.setEditorMode("MaterialSheet", 0) + except Exception: + pass + if mat_val != str(mat_sheet_label): + obj.MaterialSheet = str(mat_sheet_label) + except Exception as exc: + _emit("MaterialSheet on %s failed: %s" % (label, exc)) + + ok, pname, err = _set_kfactor_on_obj(obj, k) + if ok: + try: + obj.KFactorStandard = "ansi" + except Exception: + pass + try: + obj.touch() + except Exception: + pass + updated += 1 + _emit("Synced %s: %s=%s MaterialSheet=%s" % (label, pname, k, mat_sheet_label or mat_val)) + else: + _emit("Failed KFactor on %s: %s" % (label, err)) + + # Absolute fallback: known Unfold names + labels + if updated == 0: + fallback_names = [] + for n in names: + if "unfold" in n.lower(): + fallback_names.append(n) + try: + for o in doc.getObjectsByLabel("Unfold") or []: + try: + fallback_names.append(o.Name) + except Exception: + pass + except Exception: + pass + for n in ("Unfold", "Unfold001", "Unfold002", "Unfold003"): + if n not in fallback_names: + fallback_names.append(n) + + for name in fallback_names: + obj = _fetch(doc, name) + if obj is None: + continue + if not _has_prop(obj, "KFactor"): + _emit("fallback %s: no KFactor prop" % name) + continue + ok, pname, err = _set_kfactor_on_obj(obj, k) + if ok: + if mat_sheet_label and _has_prop(obj, "MaterialSheet"): + try: + obj.MaterialSheet = str(mat_sheet_label) + except Exception: + pass + try: + obj.touch() + except Exception: + pass + updated += 1 + _emit("Fallback synced %s: %s=%s" % (name, pname, k)) + else: + _emit("Fallback failed %s: %s" % (name, err)) + + if updated and doc is not None: + try: + doc.recompute() + except Exception: + pass + + if updated == 0: + _emit( + "No Unfold KFactor updated. considered=%s" + % (considered[:8] if considered else "[]") + ) + # Apply-before-Unfold: stash for auto-sync when Unfold is created + try: + from pending_unfold import remember_pending_unfold_sync + remember_pending_unfold_sync(k, mat_sheet_label) + _emit( + "No Unfold yet — will auto-apply K=%s / %s when you create Unfold." + % (k, mat_sheet_label or "(sheet)") + ) + except Exception as exc: + _emit("Could not remember pending Unfold sync: %s" % exc) + else: + try: + from pending_unfold import clear_pending_unfold_sync + clear_pending_unfold_sync() + except Exception: + pass + return updated + + +def apply_entry_to_bends(entry, log=None, mat_name=None, sheet_prefix="SCS"): + if not entry: + return "No thickness/entry selected." + + radius_q = inch_quantity(entry["r"]) + k = float(entry["k"]) + updated = 0 + skipped = [] + + targets = collect_bend_targets() + if not targets: + return ( + "Nothing to update. Select a SheetMetal Bend/Wall (or its Body), " + "or create one first." + ) + + for obj in targets: + r_prop = find_property(obj, RADIUS_PROP_NAMES) + k_prop = find_property(obj, KFACTOR_PROP_NAMES) + changed = False + try: + if r_prop: + setattr(obj, r_prop, radius_q) + changed = True + if k_prop: + try: + setattr(obj, k_prop, float(k)) + except Exception: + try: + obj.setExpression(k_prop, None) + except Exception: + pass + setattr(obj, k_prop, float(k)) + changed = True + except Exception as exc: + skipped.append("%s: %s" % (obj.Label, exc)) + continue + if changed: + updated += 1 + obj.touch() + else: + skipped.append("%s: no radius/kfactor properties" % obj.Label) + + if updated and App.ActiveDocument is not None: + App.ActiveDocument.recompute() + + _ok, flange_warnings = check_min_flange_lengths( + entry, targets=targets, log=log, warn_dialog=True + ) + + prefs_ok = False + try: + param = App.ParamGet("User parameter:BaseApp/Preferences/Mod/SheetMetal") + param.SetString("defaultRadius", "%s in" % entry["r"]) + param.SetFloat("defaultKFactor", k) + param.SetFloat("manualKFactor", k) + param.SetString("kFactorStandard", "ansi") + prefs_ok = True + except Exception: + pass + + unfold_n = 0 + sheet_label = None + if mat_name: + short = material_short_name(mat_name) + thou = thickness_thou(entry["t"]) + sheet_label = "material_%s_%s_%s" % (sheet_prefix, short, thou) + unfold_n = sync_unfold_features(sheet_label, k, log=log) + + msg = "Applied radius=%s in, k=%s to %s object(s)." % (entry["r"], k, updated) + if prefs_ok: + msg += " Prefs: defaultKFactor/manualKFactor=%s." % k + if unfold_n: + msg += " Updated %s Unfold feature(s) to K=%s / %s." % (unfold_n, k, sheet_label) + if flange_warnings: + msg += " MIN FLANGE WARN: " + "; ".join(flange_warnings) + if skipped: + msg += " Notes: " + "; ".join(skipped) + if log: + log(msg) + return msg + + +def create_material_sheet_for_entry( + mat_name, entry, prefix="SCS", source="SendCutSend bending calculator", log=None +): + if not entry: + return "No thickness/entry selected." + doc = App.ActiveDocument + if doc is None: + return "No active document. Create or open a document first." + + short = material_short_name(mat_name) + thou = thickness_thou(entry["t"]) + sheet_name = "material_%s_%s_%s" % (prefix, short, thou) + + existing = doc.getObject(sheet_name) + if existing is not None: + sheet = existing + else: + sheet = doc.addObject("Spreadsheet::Sheet", sheet_name) + sheet.Label = sheet_name + + r_over_t = entry["r"] / entry["t"] if entry["t"] else 0.0 + k = float(entry["k"]) + + sheet.set("A1", "Radius / Thickness") + sheet.set("B1", "K-factor (ANSI)") + sheet.set("A2", "%.6f" % r_over_t) + sheet.set("B2", "%.6f" % k) + sheet.set("A3", "99") + sheet.set("B3", "0.5") + sheet.set("A5", "Options") + sheet.set("A6", "K-factor standard") + sheet.set("B6", "ansi") + sheet.set("A8", "Material") + sheet.set("B8", mat_name) + sheet.set("A9", "Thickness (in)") + sheet.set("B9", "%.4f" % entry["t"]) + sheet.set("A10", "Bend radius (in)") + sheet.set("B10", "%.4f" % entry["r"]) + sheet.set("A11", "Bend deduction (in)") + sheet.set("B11", "%.4f" % float(entry.get("bd", 0.0))) + sheet.set("A12", "Source") + sheet.set("B12", source) + + doc.recompute() + n = sync_unfold_features(sheet_name, k, log=log) + if n: + msg = ( + "Created/updated '%s'. Synced K=%s onto %s Unfold object(s). " + "Material Sheet should be '%s'. Manual K-Factor spin stays grayed out " + "while a sheet is selected (SheetMetal UI) — property should now read %s, not 0.40." + % (sheet_name, k, n, sheet_name, k) + ) + else: + msg = ( + "Created/updated '%s' (K=%s). Unfold sync found 0 targets — see Report view " + "for [Bend Presets] near=/extras= dump, then Apply again." + % (sheet_name, k) + ) + if log: + log(msg) + return msg + + +def set_sheetmetal_defaults_for_entry(entry, log=None): + if not entry: + return "No thickness/entry selected." + try: + param = App.ParamGet("User parameter:BaseApp/Preferences/Mod/SheetMetal") + except Exception as exc: + return "Could not open SheetMetal preferences: %s" % exc + + radius_str = "%s in" % entry["r"] + k = float(entry["k"]) + param.SetString("defaultRadius", radius_str) + param.SetFloat("defaultKFactor", k) + param.SetFloat("manualKFactor", k) + param.SetString("kFactorStandard", "ansi") + msg = ( + "SheetMetal defaults updated: defaultRadius=%s; defaultKFactor=%s; " + "manualKFactor=%s; kFactorStandard=ansi" % (radius_str, k, k) + ) + if log: + log(msg) + return msg diff --git a/src/Mod/SendCutSendPresets/bend_common.py b/src/Mod/SendCutSendPresets/bend_common.py new file mode 100644 index 000000000..131f8a22b --- /dev/null +++ b/src/Mod/SendCutSendPresets/bend_common.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: MIT +"""Shared constants/helpers with no internal FreeCAD-addon imports (avoid cycles).""" + +from __future__ import annotations + +import os +import re + +import FreeCAD as App + +INCH_TO_MM = 25.4 + +RADIUS_PROP_NAMES = ( + "radius", + "Radius", + "bendRadius", + "BendRadius", + "bend_radius", + "Bend_Radius", +) +LENGTH_PROP_NAMES = ( + "length", + "Length", + "flangeLength", + "FlangeLength", + "flange_length", + "height", # some wall tools use height as flange length + "Height", +) +KFACTOR_PROP_NAMES = ( + "kfactor", + "Kfactor", + "k_factor", + "K_Factor", + "KFactor", + "kFactor", +) + + +def material_short_name(name: str) -> str: + mapping = { + "4130 Chromoly": "4130", + "5052 Aluminum": "5052", + "Brass": "Brass", + "Copper": "Copper", + "G90 Steel": "G90", + "Mild Steel": "Mild", + "Polycarbonate": "PC", + "304 Stainless Steel": "304SS", + "316 Stainless Steel": "316SS", + "Titanium Grade 2": "TiG2", + } + if name in mapping: + return mapping[name] + cleaned = re.sub(r"[^A-Za-z0-9]+", "", name) + return cleaned[:12] or "Mat" + + +def thickness_thou(t_in: float) -> str: + thou = int(round(float(t_in) * 1000)) + return "%03d" % thou + + +def find_property(obj, candidates): + props = list(obj.PropertiesList) + lower_map = {p.lower(): p for p in props} + for name in candidates: + if name in props: + return name + key = name.lower() + if key in lower_map: + return lower_map[key] + for prop in props: + pl = prop.lower().replace("_", "") + for cand in candidates: + cl = cand.lower().replace("_", "") + if pl == cl or pl.endswith(cl) or cl in pl: + return prop + return None + + +def inch_quantity(value_in: float): + return App.Units.Quantity("%s in" % value_in) + + +def resolve_wb_dir(): + try: + here = os.path.dirname(os.path.realpath(__file__)) + if os.path.isfile(os.path.join(here, "data", "sendcutsend_bends.json")): + return here + except NameError: + pass + try: + import SCS_locator + return SCS_locator.PATH + except Exception: + pass + root = App.getUserAppDataDir() + for rel in ( + os.path.join("Mod", "SendCutSendPresets"), + os.path.join("v1-1", "Mod", "SendCutSendPresets"), + os.path.join("v26-3", "Mod", "SendCutSendPresets"), + os.path.join("v1-1", "v26-3", "Mod", "SendCutSendPresets"), + ): + path = os.path.join(root, rel) + if os.path.isfile(os.path.join(path, "data", "sendcutsend_bends.json")): + return path + return os.path.join(root, "Mod", "SendCutSendPresets") + + +def quantity_to_inches(value): + """Convert FreeCAD Quantity / float / str to inches (float).""" + if value is None: + return None + try: + # FreeCAD Quantity + if hasattr(value, "getValueAs"): + return float(value.getValueAs("in")) + if hasattr(value, "Value") and hasattr(value, "Unit"): + # Fallback: Value is often in mm internally for Length + try: + return float(value.getValueAs("in")) + except Exception: + # assume mm if Unit says so + return float(value.Value) / 25.4 + return float(value) + except Exception: + try: + q = App.Units.Quantity(value) + return float(q.getValueAs("in")) + except Exception: + return None diff --git a/src/Mod/SendCutSendPresets/data/sendcutsend_bends.json b/src/Mod/SendCutSendPresets/data/sendcutsend_bends.json new file mode 100644 index 000000000..bb3c29d21 --- /dev/null +++ b/src/Mod/SendCutSendPresets/data/sendcutsend_bends.json @@ -0,0 +1,577 @@ +{ + "source": "https://sendcutsend.com/bending-calculator/", + "retrieved": "2026-09-05", + "units": "inch", + "materials": [ + { + "name": "4130 Chromoly", + "thicknesses": [ + { + "t": 0.05, + "k": 0.38, + "bd": 0.0945, + "r": 0.05, + "die": 0.472, + "min_flange": 0.302, + "min_corner_relief": 0.018, + "relief": 0.12 + }, + { + "t": 0.063, + "k": 0.41, + "bd": 0.1065, + "r": 0.05, + "die": 0.472, + "min_flange": 0.308, + "min_corner_relief": 0.025, + "relief": 0.133 + }, + { + "t": 0.125, + "k": 0.43, + "bd": 0.205, + "r": 0.09, + "die": 0.984, + "min_flange": 0.723, + "min_corner_relief": 0.038, + "relief": 0.235 + }, + { + "t": 0.19, + "k": 0.37, + "bd": 0.324, + "r": 0.125, + "die": 0.984, + "min_flange": 0.782, + "min_corner_relief": 0.043, + "relief": 0.335 + }, + { + "t": 0.25, + "k": 0.36, + "bd": 0.4335, + "r": 0.17, + "die": 1.575, + "min_flange": 1.367, + "min_corner_relief": 0.048, + "relief": 0.44 + } + ] + }, + { + "name": "5052 Aluminum", + "thicknesses": [ + { + "t": 0.04, + "k": 0.45, + "bd": 0.062, + "r": 0.024, + "die": 0.472, + "min_flange": 0.286, + "min_corner_relief": 0.024, + "relief": 0.084 + }, + { + "t": 0.063, + "k": 0.42, + "bd": 0.096, + "r": 0.035, + "die": 0.472, + "min_flange": 0.303, + "min_corner_relief": 0.03, + "relief": 0.118 + }, + { + "t": 0.08, + "k": 0.48, + "bd": 0.116, + "r": 0.038, + "die": 0.472, + "min_flange": 0.313, + "min_corner_relief": 0.037, + "relief": 0.138 + }, + { + "t": 0.09, + "k": 0.37, + "bd": 0.142, + "r": 0.032, + "die": 0.472, + "min_flange": 0.326, + "min_corner_relief": 0.034, + "relief": 0.142 + }, + { + "t": 0.1, + "k": 0.4, + "bd": 0.191, + "r": 0.125, + "die": 0.63, + "min_flange": 0.463, + "min_corner_relief": 0.02, + "relief": 0.245 + }, + { + "t": 0.125, + "k": 0.44, + "bd": 0.216, + "r": 0.125, + "die": 0.63, + "min_flange": 0.476, + "min_corner_relief": 0.032, + "relief": 0.27 + }, + { + "t": 0.187, + "k": 0.43, + "bd": 0.356, + "r": 0.25, + "die": 0.984, + "min_flange": 0.798, + "min_corner_relief": 0.024, + "relief": 0.457 + }, + { + "t": 0.25, + "k": 0.42, + "bd": 0.442, + "r": 0.25, + "die": 1.575, + "min_flange": 1.371, + "min_corner_relief": 0.044, + "relief": 0.52 + } + ] + }, + { + "name": "Brass", + "thicknesses": [ + { + "t": 0.04, + "k": 0.38, + "bd": 0.074, + "r": 0.04, + "die": 0.472, + "min_flange": 0.292, + "min_corner_relief": 0.018, + "relief": 0.1 + }, + { + "t": 0.063, + "k": 0.38, + "bd": 0.105, + "r": 0.04, + "die": 0.472, + "min_flange": 0.308, + "min_corner_relief": 0.026, + "relief": 0.123 + }, + { + "t": 0.125, + "k": 0.36, + "bd": 0.205, + "r": 0.06, + "die": 0.63, + "min_flange": 0.471, + "min_corner_relief": 0.038, + "relief": 0.205 + }, + { + "t": 0.187, + "k": 0.38, + "bd": 0.324, + "r": 0.095, + "die": 0.984, + "min_flange": 0.782, + "min_corner_relief": 0.04, + "relief": 0.302 + }, + { + "t": 0.25, + "k": 0.38, + "bd": 0.424, + "r": 0.13, + "die": 1.575, + "min_flange": 1.362, + "min_corner_relief": 0.053, + "relief": 0.4 + } + ] + }, + { + "name": "Copper", + "thicknesses": [ + { + "t": 0.04, + "k": 0.34, + "bd": 0.081, + "r": 0.052, + "die": 0.472, + "min_flange": 0.296, + "min_corner_relief": 0.015, + "relief": 0.112 + }, + { + "t": 0.063, + "k": 0.36, + "bd": 0.099, + "r": 0.024, + "die": 0.472, + "min_flange": 0.305, + "min_corner_relief": 0.029, + "relief": 0.107 + }, + { + "t": 0.125, + "k": 0.34, + "bd": 0.205, + "r": 0.05, + "die": 0.63, + "min_flange": 0.471, + "min_corner_relief": 0.038, + "relief": 0.195 + }, + { + "t": 0.187, + "k": 0.36, + "bd": 0.32, + "r": 0.118, + "die": 0.984, + "min_flange": 0.78, + "min_corner_relief": 0.042, + "relief": 0.325 + }, + { + "t": 0.25, + "k": 0.33, + "bd": 0.43, + "r": 0.14, + "die": 1.575, + "min_flange": 1.365, + "min_corner_relief": 0.05, + "relief": 0.41 + } + ] + }, + { + "name": "G90 Steel", + "thicknesses": [ + { + "t": 0.03, + "k": 0.38, + "bd": 0.061, + "r": 0.045, + "die": 0.472, + "min_flange": 0.286, + "min_corner_relief": 0.015, + "relief": 0.095 + }, + { + "t": 0.048, + "k": 0.38, + "bd": 0.086, + "r": 0.045, + "die": 0.472, + "min_flange": 0.298, + "min_corner_relief": 0.02, + "relief": 0.113 + }, + { + "t": 0.059, + "k": 0.36, + "bd": 0.112, + "r": 0.063, + "die": 0.472, + "min_flange": 0.311, + "min_corner_relief": 0.018, + "relief": 0.142 + }, + { + "t": 0.074, + "k": 0.4, + "bd": 0.129, + "r": 0.063, + "die": 0.472, + "min_flange": 0.32, + "min_corner_relief": 0.025, + "relief": 0.157 + } + ] + }, + { + "name": "Mild Steel", + "thicknesses": [ + { + "t": 0.03, + "k": 0.38, + "bd": 0.061, + "r": 0.045, + "die": 0.472, + "min_flange": 0.286, + "min_corner_relief": 0.015, + "relief": 0.095 + }, + { + "t": 0.048, + "k": 0.38, + "bd": 0.086, + "r": 0.045, + "die": 0.472, + "min_flange": 0.298, + "min_corner_relief": 0.02, + "relief": 0.113 + }, + { + "t": 0.059, + "k": 0.4, + "bd": 0.108, + "r": 0.063, + "die": 0.472, + "min_flange": 0.309, + "min_corner_relief": 0.02, + "relief": 0.142 + }, + { + "t": 0.074, + "k": 0.4, + "bd": 0.129, + "r": 0.063, + "die": 0.472, + "min_flange": 0.32, + "min_corner_relief": 0.025, + "relief": 0.157 + }, + { + "t": 0.104, + "k": 0.34, + "bd": 0.1815, + "r": 0.063, + "die": 0.63, + "min_flange": 0.459, + "min_corner_relief": 0.028, + "relief": 0.187 + }, + { + "t": 0.119, + "k": 0.38, + "bd": 0.1955, + "r": 0.063, + "die": 0.63, + "min_flange": 0.466, + "min_corner_relief": 0.036, + "relief": 0.202 + }, + { + "t": 0.135, + "k": 0.32, + "bd": 0.244, + "r": 0.1, + "die": 0.984, + "min_flange": 0.742, + "min_corner_relief": 0.028, + "relief": 0.255 + }, + { + "t": 0.187, + "k": 0.36, + "bd": 0.3225, + "r": 0.125, + "die": 0.984, + "min_flange": 0.781, + "min_corner_relief": 0.041, + "relief": 0.332 + }, + { + "t": 0.25, + "k": 0.36, + "bd": 0.4215, + "r": 0.15, + "die": 1.575, + "min_flange": 1.361, + "min_corner_relief": 0.054, + "relief": 0.42 + } + ] + }, + { + "name": "Polycarbonate", + "thicknesses": [ + { + "t": 0.118, + "k": 0.33, + "bd": 0.195, + "r": 0.04, + "die": 0.472, + "min_flange": 0.386, + "min_corner_relief": 0.083, + "relief": 0.178 + }, + { + "t": 0.177, + "k": 0.33, + "bd": 0.296, + "r": 0.085, + "die": 0.984, + "min_flange": 0.848, + "min_corner_relief": 0.214, + "relief": 0.282 + }, + { + "t": 0.22, + "k": 0.33, + "bd": 0.367, + "r": 0.1, + "die": 0.984, + "min_flange": 0.884, + "min_corner_relief": 0.235, + "relief": 0.34 + } + ] + }, + { + "name": "304 Stainless Steel", + "thicknesses": [ + { + "t": 0.03, + "k": 0.36, + "bd": 0.0785, + "r": 0.08, + "die": 0.472, + "min_flange": 0.294, + "min_corner_relief": 0.006, + "relief": 0.13 + }, + { + "t": 0.048, + "k": 0.36, + "bd": 0.1045, + "r": 0.08, + "die": 0.472, + "min_flange": 0.307, + "min_corner_relief": 0.011, + "relief": 0.148 + }, + { + "t": 0.06, + "k": 0.34, + "bd": 0.1185, + "r": 0.07, + "die": 0.472, + "min_flange": 0.314, + "min_corner_relief": 0.016, + "relief": 0.15 + }, + { + "t": 0.074, + "k": 0.36, + "bd": 0.137, + "r": 0.075, + "die": 0.472, + "min_flange": 0.324, + "min_corner_relief": 0.021, + "relief": 0.169 + }, + { + "t": 0.1, + "k": 0.36, + "bd": 0.223, + "r": 0.187, + "die": 0.984, + "min_flange": 0.732, + "min_corner_relief": 0.004, + "relief": 0.307 + }, + { + "t": 0.125, + "k": 0.38, + "bd": 0.24, + "r": 0.15, + "die": 0.984, + "min_flange": 0.74, + "min_corner_relief": 0.02, + "relief": 0.295 + }, + { + "t": 0.187, + "k": 0.35, + "bd": 0.3285, + "r": 0.13, + "die": 0.984, + "min_flange": 0.784, + "min_corner_relief": 0.038, + "relief": 0.337 + }, + { + "t": 0.25, + "k": 0.34, + "bd": 0.462, + "r": 0.225, + "die": 1.575, + "min_flange": 1.381, + "min_corner_relief": 0.034, + "relief": 0.495 + } + ] + }, + { + "name": "316 Stainless Steel", + "thicknesses": [ + { + "t": 0.06, + "k": 0.34, + "bd": 0.1185, + "r": 0.07, + "die": 0.472, + "min_flange": 0.314, + "min_corner_relief": 0.016, + "relief": 0.15 + }, + { + "t": 0.125, + "k": 0.38, + "bd": 0.24, + "r": 0.15, + "die": 0.984, + "min_flange": 0.74, + "min_corner_relief": 0.02, + "relief": 0.295 + }, + { + "t": 0.187, + "k": 0.35, + "bd": 0.3285, + "r": 0.13, + "die": 0.984, + "min_flange": 0.784, + "min_corner_relief": 0.038, + "relief": 0.337 + }, + { + "t": 0.25, + "k": 0.34, + "bd": 0.462, + "r": 0.225, + "die": 1.575, + "min_flange": 1.381, + "min_corner_relief": 0.034, + "relief": 0.495 + } + ] + }, + { + "name": "Titanium Grade 2", + "thicknesses": [ + { + "t": 0.04, + "k": 0.38, + "bd": 0.079, + "r": 0.045, + "die": 0.472, + "min_flange": 0.295, + "min_corner_relief": 0.016, + "relief": 0.105 + } + ] + } + ] +} diff --git a/src/Mod/SendCutSendPresets/metadata.txt b/src/Mod/SendCutSendPresets/metadata.txt new file mode 100644 index 000000000..075f69f39 --- /dev/null +++ b/src/Mod/SendCutSendPresets/metadata.txt @@ -0,0 +1,6 @@ +[general] +name=SendCutSend Presets +version=0.2.0 +description=SendCutSend + custom bend presets for SheetMetal +icon=resources/icons/SCS_Presets.svg +license=MIT diff --git a/src/Mod/SendCutSendPresets/package.xml b/src/Mod/SendCutSendPresets/package.xml new file mode 100644 index 000000000..0523eecba --- /dev/null +++ b/src/Mod/SendCutSendPresets/package.xml @@ -0,0 +1,24 @@ + + + SendCutSend Presets + SendCutSend + custom bend-parameter presets for FreeCAD SheetMetal (radius, K-factor, material sheets, Unfold sync). + 0.2.0 + 2026-09-05 + Ricky Faircloth / Nozzler + MIT + README.md + https://github.com/10-X-eng/vibecad + + + SendCutSendPresetsWorkbench + ./ + resources/icons/SCS_Presets.svg + sheetmetal + sendcutsend + bending + kfactor + 0.21.0 + SheetMetal + + + diff --git a/src/Mod/SendCutSendPresets/pending_unfold.py b/src/Mod/SendCutSendPresets/pending_unfold.py new file mode 100644 index 000000000..4dbca9c2b --- /dev/null +++ b/src/Mod/SendCutSendPresets/pending_unfold.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: MIT +"""Remember last bend-preset Apply and auto-sync when an Unfold is created later.""" + +from __future__ import annotations + +import FreeCAD as App + +_pending = None # dict | None +_observer = None +_PARAM = "User parameter:BaseApp/Preferences/Mod/SendCutSendPresets" + + +def _log(msg): + try: + App.Console.PrintMessage("[Bend Presets] %s\n" % msg) + except Exception: + pass + + +def _param(): + try: + return App.ParamGet(_PARAM) + except Exception: + return None + + +def remember_pending_unfold_sync(k, sheet_label, doc_name=None, source_note=None): + """Store last Apply so a future Unfold picks up K + material sheet.""" + global _pending + try: + k = float(k) + except Exception: + return + sheet_label = str(sheet_label) if sheet_label else "" + if not doc_name: + try: + doc = App.ActiveDocument + doc_name = doc.Name if doc else "" + except Exception: + doc_name = "" + _pending = { + "k": k, + "sheet": sheet_label, + "doc": doc_name or "", + "note": source_note or "", + } + p = _param() + if p is not None: + try: + p.SetFloat("pendingK", k) + p.SetString("pendingSheet", sheet_label) + p.SetString("pendingDoc", doc_name or "") + p.SetBool("pendingActive", True) + except Exception: + pass + _log( + "Remembered preset for next Unfold: K=%s sheet=%s (apply before Unfold is OK)" + % (k, sheet_label or "(none)") + ) + ensure_observer() + + +def clear_pending_unfold_sync(): + global _pending + _pending = None + p = _param() + if p is not None: + try: + p.SetBool("pendingActive", False) + except Exception: + pass + + +def get_pending(): + global _pending + if _pending: + return dict(_pending) + p = _param() + if p is None: + return None + try: + if not p.GetBool("pendingActive", False): + return None + return { + "k": float(p.GetFloat("pendingK", 0.0)), + "sheet": p.GetString("pendingSheet", ""), + "doc": p.GetString("pendingDoc", ""), + "note": "prefs", + } + except Exception: + return None + + +def _looks_like_unfold_obj(obj): + try: + name = (obj.Name or "").lower() + label = (obj.Label or "").lower() + except Exception: + return False + blob = name + " " + label + if "sketch" in blob: + return False + if "unfold" not in blob: + # property-based + try: + getattr(obj, "KFactor") + getattr(obj, "MaterialSheet") + return True + except Exception: + return False + try: + getattr(obj, "KFactor") + return True + except Exception: + return False + + +def apply_pending_to_object(obj): + pending = get_pending() + if not pending: + return False + if not _looks_like_unfold_obj(obj): + return False + try: + doc = obj.Document + if pending.get("doc") and doc and pending["doc"] not in (doc.Name, ""): + # Still allow if pending doc was Unnamed / changed — only skip if both set and differ + # Soft filter: prefer same doc but don't block if user renamed + pass + except Exception: + pass + + from bend_actions import sync_unfold_features + + k = pending.get("k") + sheet = pending.get("sheet") or "" + n = sync_unfold_features(sheet, k, log=_log) + if n: + _log( + "Auto-synced new Unfold from earlier Apply: K=%s sheet=%s (%s object(s))" + % (k, sheet or "(k only)", n) + ) + clear_pending_unfold_sync() + return True + return False + + +def _defer_apply(obj): + """Unfold props are often added *after* slotCreatedObject — retry shortly.""" + try: + name = obj.Name + doc_name = obj.Document.Name if obj.Document else None + except Exception: + return + if not name or not doc_name: + return + + def _try(attempt=0): + try: + doc = App.getDocument(doc_name) + if doc is None: + return + o = doc.getObject(name) + if o is None: + return + if apply_pending_to_object(o): + return + # Not ready yet (no KFactor) or not an Unfold — retry a few times + if attempt < 6: + _schedule(lambda: _try(attempt + 1), 150) + except Exception as exc: + _log("deferred Unfold sync error: %s" % exc) + + _schedule(lambda: _try(0), 50) + + +def _schedule(fn, delay_ms): + try: + from PySide6.QtCore import QTimer # type: ignore + except ImportError: + try: + from PySide2.QtCore import QTimer # type: ignore + except ImportError: + try: + from PySide.QtCore import QTimer # type: ignore + except ImportError: + QTimer = None + if QTimer is not None: + QTimer.singleShot(int(delay_ms), fn) + else: + try: + fn() + except Exception: + pass + + +class _UnfoldPendingObserver(object): + """FreeCAD document observer — fires when Unfold is added after Apply.""" + + def slotCreatedObject(self, obj): + try: + _defer_apply(obj) + except Exception as exc: + _log("pending Unfold observer error: %s" % exc) + + def slotAppendObject(self, parent, obj): + try: + _defer_apply(obj) + except Exception: + pass + + +def ensure_observer(): + global _observer + if _observer is not None: + return + try: + _observer = _UnfoldPendingObserver() + App.addDocumentObserver(_observer) + _log("Watching for Unfold creation (auto-apply last preset).") + except Exception as exc: + _observer = None + _log("Could not add Unfold observer: %s" % exc) + + +def setup(): + """Call from InitGui — restore pending flag from prefs and start observer.""" + ensure_observer() + pending = get_pending() + if pending and pending.get("k"): + _log( + "Pending Unfold preset restored: K=%s sheet=%s" + % (pending.get("k"), pending.get("sheet") or "") + ) diff --git a/src/Mod/SendCutSendPresets/resources/icons/SCS_Custom.svg b/src/Mod/SendCutSendPresets/resources/icons/SCS_Custom.svg new file mode 100644 index 000000000..03c68e796 --- /dev/null +++ b/src/Mod/SendCutSendPresets/resources/icons/SCS_Custom.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Mod/SendCutSendPresets/resources/icons/SCS_Presets.svg b/src/Mod/SendCutSendPresets/resources/icons/SCS_Presets.svg new file mode 100644 index 000000000..be7aece9e --- /dev/null +++ b/src/Mod/SendCutSendPresets/resources/icons/SCS_Presets.svg @@ -0,0 +1,6 @@ + + + + + SCS + diff --git a/src/Mod/SendCutSendPresets/sheetmetal_integration.py b/src/Mod/SendCutSendPresets/sheetmetal_integration.py new file mode 100644 index 000000000..3e524d8ac --- /dev/null +++ b/src/Mod/SendCutSendPresets/sheetmetal_integration.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: MIT +"""Inject Bend Presets (SendCutSend library + custom) into SheetMetal. + +SendCutSend workbench stays as the test bed. Day-to-day use is the single +Bend Presets button on SheetMetal (SCS library + My custom). +""" + +from __future__ import annotations + +import FreeCAD as App +import FreeCADGui as Gui + +_CMD = "SCS_ShowCustomPresets" +_TOOLBAR = "Bend Presets" +_installed = False +_hooked = False + + +def _log(msg): + try: + App.Console.PrintMessage("[SCS->SheetMetal] %s\n" % msg) + except Exception: + pass + + +def _ensure_command_registered(): + """CustomPresets registers SCS_ShowCustomPresets on import when Gui is up.""" + import CustomPresets # noqa: F401 + + +def _sheetmetal_workbench_keys(): + """Possible FreeCAD keys for SheetMetal (varies by version/install).""" + keys = [] + try: + # FreeCAD 1.x + wbs = Gui.listWorkbenches() + if isinstance(wbs, dict): + for k, v in wbs.items(): + label = "" + try: + label = str(v) + except Exception: + pass + kl = (k or "").lower() + if "sheetmetal" in kl.replace(" ", "") or "smworkbench" in kl: + keys.append(k) + elif "sheet metal" in label.lower(): + keys.append(k) + except Exception: + pass + # Well-known fallbacks + for k in ("SMWorkbench", "SheetMetalWorkbench", "Sheet Metal"): + if k not in keys: + keys.append(k) + return keys + + +def _try_append(wb): + """Append toolbar + menu entries if the workbench proxy supports it.""" + ok = False + try: + wb.appendToolbar(_TOOLBAR, [_CMD]) + ok = True + except Exception: + # Common while SMWorkbench proxy is not fully live yet; retry quietly. + pass + try: + wb.appendMenu("&Sheet Metal", [_CMD]) + ok = True + except Exception: + try: + wb.appendMenu("Sheet Metal", [_CMD]) + ok = True + except Exception: + pass + return ok + + +def install_into_sheetmetal(): + """Idempotent: register command and attach to SheetMetal workbench.""" + global _installed + if not App.GuiUp: + return False + try: + _ensure_command_registered() + except Exception as exc: + _log("Could not import CustomPresets: %s" % exc) + return False + + attached = False + for key in _sheetmetal_workbench_keys(): + try: + wb = Gui.getWorkbench(key) + except Exception: + continue + if wb is None: + continue + if _try_append(wb): + attached = True + _log("Attached '%s' to workbench '%s'." % (_CMD, key)) + break + + if attached: + _installed = True + # else: still loading — retries are silent until success + return attached + + +def _on_workbench_activated(name): + """Retry attach when user switches to SheetMetal (covers late load).""" + try: + n = str(name) + except Exception: + n = "" + nl = n.lower().replace(" ", "") + if "sheetmetal" in nl or n in ("SMWorkbench", "SheetMetalWorkbench"): + install_into_sheetmetal() + + +def hook_workbench_activation(): + """Connect FreeCAD main-window workbenchActivated if available.""" + global _hooked + if _hooked or not App.GuiUp: + return + try: + mw = Gui.getMainWindow() + except Exception: + return + if mw is None: + return + # FreeCAD main window signal (Qt) + sig = getattr(mw, "workbenchActivated", None) + if sig is None: + return + try: + sig.connect(_on_workbench_activated) + _hooked = True + _log("Hooked workbenchActivated for SheetMetal integration.") + except Exception as exc: + _log("Could not hook workbenchActivated: %s" % exc) + + +def setup(): + """Call from SendCutSendPresets InitGui after our own workbench registers.""" + hook_workbench_activation() + # Deferred install: SheetMetal InitGui may run after us alphabetically + try: + from PySide6.QtCore import QTimer # type: ignore + except ImportError: + try: + from PySide2.QtCore import QTimer # type: ignore + except ImportError: + try: + from PySide.QtCore import QTimer # type: ignore + except ImportError: + QTimer = None + if QTimer is not None: + # a few retries while other workbenches finish loading + for delay in (0, 500, 1500, 3000): + QTimer.singleShot(delay, install_into_sheetmetal) + else: + install_into_sheetmetal() diff --git a/src/Mod/SendCutSendPresets/tests/__init__.py b/src/Mod/SendCutSendPresets/tests/__init__.py new file mode 100644 index 000000000..548d2d447 --- /dev/null +++ b/src/Mod/SendCutSendPresets/tests/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: MIT diff --git a/src/Mod/SendCutSendPresets/tests/freecadcmd_smoke.py b/src/Mod/SendCutSendPresets/tests/freecadcmd_smoke.py new file mode 100644 index 000000000..46915624d --- /dev/null +++ b/src/Mod/SendCutSendPresets/tests/freecadcmd_smoke.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT +"""FreeCADCmd smoke: KFactor write on a FeaturePython (Unfold-like props).""" + +import FreeCAD as App + +doc = App.newDocument("SCS_Smoke") +obj = doc.addObject("Part::FeaturePython", "Unfold") +obj.addProperty("App::PropertyFloatConstraint", "KFactor", "Parameters", "Manual K-Factor") +obj.KFactor = (0.4, 0.0, 2.0, 0.01) +obj.addProperty("App::PropertyString", "MaterialSheet", "Parameters", "MDS") +obj.MaterialSheet = "_none" +before = float(obj.KFactor) +obj.KFactor = 0.5 +after = float(obj.KFactor) +assert abs(before - 0.4) < 1e-9, before +assert abs(after - 0.5) < 1e-9, after +App.closeDocument(doc.Name) +print("freecadcmd_smoke OK: KFactor %s -> %s" % (before, after)) diff --git a/src/Mod/SendCutSendPresets/tests/test_smoke.py b/src/Mod/SendCutSendPresets/tests/test_smoke.py new file mode 100644 index 000000000..34381d2be --- /dev/null +++ b/src/Mod/SendCutSendPresets/tests/test_smoke.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MIT +"""Smoke tests for SendCutSendPresets (no FreeCAD GUI required). + +Run from the Mod directory: + python -m unittest tests.test_smoke -v + +AI/TDD note (vibecad CONTRIBUTING): these unit tests cover pure data/helpers. +GUI Apply/Unfold flows are listed in SMOKE.md (FreeCADCmd + manual GUI). +""" + +from __future__ import annotations + +import json +import sys +import types +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _install_freecad_stub(): + """bend_common imports FreeCAD; stub it for headless unittest.""" + if "FreeCAD" in sys.modules: + return + fc = types.ModuleType("FreeCAD") + fc.Units = types.SimpleNamespace(Quantity=lambda s: s) + fc.getUserAppDataDir = lambda: str(ROOT) + sys.modules["FreeCAD"] = fc + + +class TestBendTable(unittest.TestCase): + def setUp(self): + path = ROOT / "data" / "sendcutsend_bends.json" + with path.open(encoding="utf-8") as fh: + self.data = json.load(fh) + + def test_json_has_materials(self): + mats = self.data.get("materials") + self.assertIsInstance(mats, list) + self.assertGreaterEqual(len(mats), 5) + + def test_5052_063_row(self): + alum = next(m for m in self.data["materials"] if m["name"] == "5052 Aluminum") + row = next(t for t in alum["thicknesses"] if abs(float(t["t"]) - 0.063) < 1e-9) + self.assertIn("k", row) + self.assertIn("r", row) + self.assertIn("min_flange", row) + self.assertGreater(float(row["k"]), 0.0) + self.assertGreater(float(row["r"]), 0.0) + + def test_rows_have_required_keys(self): + required = {"t", "k", "r", "bd", "relief", "die", "min_flange", "min_corner_relief"} + for mat in self.data["materials"]: + for row in mat.get("thicknesses", []): + missing = required - set(row) + self.assertFalse(missing, msg="%s missing %s" % (mat["name"], missing)) + + +class TestNamingHelpers(unittest.TestCase): + def setUp(self): + _install_freecad_stub() + sys.path.insert(0, str(ROOT)) + import bend_common # noqa: WPS433 + + self.bc = bend_common + + def test_material_short_name_known(self): + self.assertEqual(self.bc.material_short_name("5052 Aluminum"), "5052") + self.assertEqual(self.bc.material_short_name("304 Stainless Steel"), "304SS") + + def test_material_short_name_custom(self): + self.assertEqual(self.bc.material_short_name("Adamantine"), "Adamantine"[:12]) + + def test_thickness_thou(self): + self.assertEqual(self.bc.thickness_thou(0.063), "063") + self.assertEqual(self.bc.thickness_thou(0.250), "250") + + +class TestMinFlangeRule(unittest.TestCase): + """Mirror of bend_actions check: warn only when length is strictly below min.""" + + @staticmethod + def should_warn(length_in, min_flange): + if min_flange is None or float(min_flange) <= 0: + return False + return float(length_in) + 1e-6 < float(min_flange) + + def test_equal_is_ok(self): + self.assertFalse(self.should_warn(1.0, 1.0)) + + def test_below_warns(self): + self.assertTrue(self.should_warn(0.999, 1.0)) + + def test_blank_min_skips(self): + self.assertFalse(self.should_warn(0.5, 0)) + self.assertFalse(self.should_warn(0.5, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/SendCutSendPresets/ui_theme.py b/src/Mod/SendCutSendPresets/ui_theme.py new file mode 100644 index 000000000..60fddefbd --- /dev/null +++ b/src/Mod/SendCutSendPresets/ui_theme.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: MIT +"""Dark UI theme roughly matching FreeCAD's dark stylesheet.""" + +# Colors tuned to FreeCAD 1.x dark theme (windows / qt) +DARK_QSS = """ +QWidget { + background-color: #333333; + color: #f0f0f0; + font-size: 12px; +} +QLabel { + background-color: transparent; + color: #f0f0f0; +} +QGroupBox { + background-color: #2b2b2b; + border: 1px solid #555555; + border-radius: 6px; + margin-top: 12px; + padding: 10px 8px 8px 8px; + color: #f0f0f0; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #dddddd; +} +QComboBox, QLineEdit, QDoubleSpinBox, QSpinBox { + background-color: #1e1e1e; + color: #ffffff; + border: 1px solid #555555; + border-radius: 3px; + padding: 3px 6px; + min-height: 22px; + selection-background-color: #3874f2; + selection-color: #ffffff; +} +QComboBox:hover, QLineEdit:hover, QDoubleSpinBox:hover { + border: 1px solid #777777; +} +QComboBox::drop-down { + border: none; + width: 20px; +} +QComboBox QAbstractItemView { + background-color: #1e1e1e; + color: #ffffff; + selection-background-color: #3874f2; + border: 1px solid #555555; +} +QPushButton { + background-color: #444444; + color: #ffffff; + border: 1px solid #666666; + border-radius: 4px; + padding: 6px 12px; + min-height: 24px; +} +QPushButton:hover { + background-color: #555555; + border: 1px solid #888888; +} +QPushButton:pressed { + background-color: #2a2a2a; +} +QScrollArea, QFrame { + background-color: #333333; + border: none; +} +QFormLayout { + background-color: transparent; +} +""" + + +def apply_dark_theme(widget): + """Apply dark QSS to a top-level dialog or panel.""" + if widget is None: + return + try: + widget.setStyleSheet(DARK_QSS) + except Exception: + pass