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 @@
+
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