diff --git a/freecad/InitGui.py b/freecad/InitGui.py index 74af1e2..d2710ee 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -57,6 +57,7 @@ class SiloWorkbench(FreeCADGui.Workbench): "Silo_TagProjects", "Silo_SetStatus", "Silo_Rollback", + "Silo_SaveAsTemplate", "Separator", "Silo_Settings", "Silo_Auth", diff --git a/freecad/schema_form.py b/freecad/schema_form.py index ae2c1c6..4c7c9dc 100644 --- a/freecad/schema_form.py +++ b/freecad/schema_form.py @@ -234,9 +234,11 @@ class SchemaFormWidget(QtWidgets.QWidget): self._prop_groups = [] # list of _CollapsibleGroup to clear on category change self._categories = {} self._projects = [] + self._templates = [] # List[TemplateInfo] self._load_schema_data() self._build_ui() + self._update_template_combo() # Part number preview debounce timer self._pn_timer = QtCore.QTimer(self) @@ -251,7 +253,9 @@ class SchemaFormWidget(QtWidgets.QWidget): try: schema = self._client.get_schema() segments = schema.get("segments", []) - cat_segment = next((s for s in segments if s.get("name") == "category"), None) + cat_segment = next( + (s for s in segments if s.get("name") == "category"), None + ) if cat_segment and cat_segment.get("values"): self._categories = cat_segment["values"] except Exception as e: @@ -262,6 +266,16 @@ class SchemaFormWidget(QtWidgets.QWidget): except Exception: self._projects = [] + try: + from templates import discover_templates, get_search_paths + + self._templates = discover_templates(get_search_paths()) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Schema form: failed to discover templates: {e}\n" + ) + self._templates = [] + def _fetch_properties(self, category: str) -> dict: """Fetch merged property definitions for a category.""" try: @@ -301,7 +315,9 @@ class SchemaFormWidget(QtWidgets.QWidget): # Part number preview banner self._pn_label = QtWidgets.QLabel("Part Number: \u2014") - self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;") + self._pn_label.setStyleSheet( + "font-size: 16px; font-weight: bold; padding: 8px;" + ) self._pn_label.setAlignment(QtCore.Qt.AlignCenter) root.addWidget(self._pn_label) @@ -323,8 +339,15 @@ class SchemaFormWidget(QtWidgets.QWidget): self._type_combo = QtWidgets.QComboBox() for t in _ITEM_TYPES: self._type_combo.addItem(t.capitalize(), t) + self._type_combo.currentIndexChanged.connect( + lambda _: self._update_template_combo() + ) fl.addRow("Type:", self._type_combo) + self._template_combo = QtWidgets.QComboBox() + self._template_combo.addItem("(No template)", None) + fl.addRow("Template:", self._template_combo) + self._desc_edit = QtWidgets.QLineEdit() self._desc_edit.setPlaceholderText("Item description") fl.addRow("Description:", self._desc_edit) @@ -404,11 +427,25 @@ class SchemaFormWidget(QtWidgets.QWidget): root.addLayout(btn_layout) + # -- template filtering ------------------------------------------------- + + def _update_template_combo(self): + """Repopulate the template combo based on current type and category.""" + from templates import filter_templates + + self._template_combo.clear() + self._template_combo.addItem("(No template)", None) + item_type = self._type_combo.currentData() or "" + category = self._cat_picker.selected_category() or "" + for t in filter_templates(self._templates, item_type, category): + self._template_combo.addItem(t.name, t.path) + # -- category change ---------------------------------------------------- def _on_category_changed(self, category: str): """Rebuild property groups when category selection changes.""" self._create_btn.setEnabled(bool(category)) + self._update_template_combo() # Remove old property groups for group in self._prop_groups: @@ -540,6 +577,7 @@ class SchemaFormWidget(QtWidgets.QWidget): "long_description": long_description, "projects": selected_projects if selected_projects else None, "properties": properties if properties else None, + "template_path": self._template_combo.currentData(), } def _on_create(self): diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index f819354..ab8244f 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -551,6 +551,86 @@ class SiloSync: return doc + def create_document_from_template( + self, item: Dict[str, Any], template_path: str, save: bool = True + ): + """Create a new document by copying a template .kc and stamping Silo properties. + + Falls back to :meth:`create_document_for_item` if *template_path* + is missing or invalid. + """ + import shutil + + part_number = item.get("part_number", "") + description = item.get("description", "") + item_type = item.get("item_type", "part") + + if not part_number or not os.path.isfile(template_path): + return self.create_document_for_item(item, save=save) + + dest_path = get_cad_file_path(part_number, description) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy2(template_path, str(dest_path)) + self._clean_template_zip(str(dest_path)) + + doc = FreeCAD.openDocument(str(dest_path)) + if not doc: + return None + + # Find the root container (first App::Part or Assembly) + root_obj = None + for obj in doc.Objects: + if obj.TypeId in ("App::Part", "Assembly::AssemblyObject"): + root_obj = obj + break + if root_obj is None and doc.Objects: + root_obj = doc.Objects[0] + + if root_obj: + root_obj.Label = part_number + set_silo_properties( + root_obj, + { + "SiloItemId": item.get("id", ""), + "SiloPartNumber": part_number, + "SiloRevision": item.get("current_revision", 1), + "SiloItemType": item_type, + }, + ) + + doc.recompute() + + if save: + doc.save() + + return doc + + @staticmethod + def _clean_template_zip(filepath: str): + """Strip ``silo/template.json`` and ``silo/manifest.json`` from a copied template. + + The manifest is auto-recreated by ``kc_format.py`` on next save. + """ + import zipfile + + tmp_path = filepath + ".tmp" + try: + with zipfile.ZipFile(filepath, "r") as zf_in: + with zipfile.ZipFile(tmp_path, "w") as zf_out: + for entry in zf_in.infolist(): + if entry.filename in ( + "silo/template.json", + "silo/manifest.json", + ): + continue + zf_out.writestr(entry, zf_in.read(entry.filename)) + os.replace(tmp_path, filepath) + except Exception as exc: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + FreeCAD.Console.PrintWarning(f"Failed to clean template ZIP: {exc}\n") + def open_item(self, part_number: str): """Open or create item document.""" existing_path = find_file_by_part_number(part_number) @@ -742,7 +822,13 @@ class Silo_New: FreeCAD.ActiveDocument, force_rename=True ) else: - _sync.create_document_for_item(result, save=True) + template_path = result.get("_form_data", {}).get("template_path") + if template_path: + _sync.create_document_from_template( + result, template_path, save=True + ) + else: + _sync.create_document_for_item(result, save=True) FreeCAD.Console.PrintMessage(f"Created: {part_number}\n") except Exception as e: @@ -4016,6 +4102,218 @@ class Silo_StartPanel: return True +class SaveAsTemplateDialog: + """Dialog to capture template metadata for Save as Template.""" + + _ITEM_TYPES = ["part", "assembly", "consumable", "tool"] + + def __init__(self, doc, parent=None): + self._doc = doc + self._parent = parent + + def exec_(self): + """Show dialog. Returns template_info dict or None if cancelled.""" + from PySide import QtGui, QtWidgets + + dlg = QtWidgets.QDialog(self._parent) + dlg.setWindowTitle("Save as Template") + dlg.setMinimumWidth(420) + + layout = QtWidgets.QVBoxLayout(dlg) + + form = QtWidgets.QFormLayout() + form.setSpacing(6) + + # Pre-populate defaults from document state + default_name = self._doc.Label or Path(self._doc.FileName).stem + obj = get_tracked_object(self._doc) + + default_author = _get_auth_username() or os.environ.get("USER", "") + default_item_type = "" + default_category = "" + if obj: + if hasattr(obj, "SiloItemType"): + default_item_type = getattr(obj, "SiloItemType", "") + if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: + default_category, _ = _parse_part_number(obj.SiloPartNumber) + + # Name (required) + name_edit = QtWidgets.QLineEdit(default_name) + name_edit.setPlaceholderText("Template display name") + form.addRow("Name:", name_edit) + + # Description + desc_edit = QtWidgets.QLineEdit() + desc_edit.setPlaceholderText("What this template is for") + form.addRow("Description:", desc_edit) + + # Item Types (multi-select) + type_list = QtWidgets.QListWidget() + type_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + type_list.setMaximumHeight(80) + for t in self._ITEM_TYPES: + item = QtWidgets.QListWidgetItem(t.capitalize()) + item.setData(QtCore.Qt.UserRole, t) + type_list.addItem(item) + if t == default_item_type: + item.setSelected(True) + form.addRow("Item Types:", type_list) + + # Categories (comma-separated prefixes) + cat_edit = QtWidgets.QLineEdit(default_category) + cat_edit.setPlaceholderText("e.g. F, M01 (empty = all)") + form.addRow("Categories:", cat_edit) + + # Author + author_edit = QtWidgets.QLineEdit(default_author) + form.addRow("Author:", author_edit) + + # Tags (comma-separated) + tags_edit = QtWidgets.QLineEdit() + tags_edit.setPlaceholderText("e.g. sheet metal, fabrication") + form.addRow("Tags:", tags_edit) + + layout.addLayout(form) + + # Buttons + btn_layout = QtWidgets.QHBoxLayout() + btn_layout.addStretch() + cancel_btn = QtWidgets.QPushButton("Cancel") + cancel_btn.clicked.connect(dlg.reject) + btn_layout.addWidget(cancel_btn) + save_btn = QtWidgets.QPushButton("Save Template") + save_btn.setDefault(True) + save_btn.clicked.connect(dlg.accept) + btn_layout.addWidget(save_btn) + layout.addLayout(btn_layout) + + if dlg.exec_() != QtWidgets.QDialog.Accepted: + return None + + name = name_edit.text().strip() + if not name: + return None + + selected_types = [] + for i in range(type_list.count()): + item = type_list.item(i) + if item.isSelected(): + selected_types.append(item.data(QtCore.Qt.UserRole)) + + categories = [c.strip() for c in cat_edit.text().split(",") if c.strip()] + tags = [t.strip() for t in tags_edit.text().split(",") if t.strip()] + + return { + "template_version": "1.0", + "name": name, + "description": desc_edit.text().strip(), + "item_types": selected_types, + "categories": categories, + "icon": "", + "author": author_edit.text().strip(), + "tags": tags, + } + + +class Silo_SaveAsTemplate: + """Save a copy of the current document as a reusable template.""" + + def GetResources(self): + return { + "MenuText": "Save as Template", + "ToolTip": "Save a copy of this document as a reusable template", + "Pixmap": _icon("new"), + } + + def Activated(self): + import shutil + + from PySide import QtGui + from templates import get_default_template_dir, inject_template_json + + doc = FreeCAD.ActiveDocument + if not doc: + return + + if not doc.FileName: + QtGui.QMessageBox.warning( + None, + "Save as Template", + "Please save the document first.", + ) + return + + # Capture template metadata from user + dialog = SaveAsTemplateDialog(doc) + template_info = dialog.exec_() + if not template_info: + return + + # Determine destination + dest_dir = get_default_template_dir() + filename = _sanitize_filename(template_info["name"]) + ".kc" + dest = os.path.join(dest_dir, filename) + + # Check for overwrite + if os.path.exists(dest): + reply = QtGui.QMessageBox.question( + None, + "Save as Template", + f"Template '{filename}' already exists. Overwrite?", + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + ) + if reply != QtGui.QMessageBox.Yes: + return + + # Save current state, then copy + doc.save() + shutil.copy2(doc.FileName, dest) + + # Strip Silo identity and inject template descriptor + _sync._clean_template_zip(dest) + inject_template_json(dest, template_info) + + FreeCAD.Console.PrintMessage(f"Template saved: {dest}\n") + + # Offer Silo upload if connected + if _server_mode == "normal": + reply = QtGui.QMessageBox.question( + None, + "Save as Template", + "Upload template to Silo for team sharing?", + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + ) + if reply == QtGui.QMessageBox.Yes: + self._upload_to_silo(dest, template_info) + + QtGui.QMessageBox.information( + None, + "Save as Template", + f"Template '{template_info['name']}' saved.", + ) + + @staticmethod + def _upload_to_silo(file_path, template_info): + """Upload template to Silo as a shared item. Non-blocking on failure.""" + try: + schema = _get_schema_name() + result = _client.create_item( + schema, "T00", template_info.get("name", "Template") + ) + part_number = result["part_number"] + _client._upload_file(part_number, file_path, {}, "Template upload") + FreeCAD.Console.PrintMessage( + f"Template uploaded to Silo as {part_number}\n" + ) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Template upload to Silo failed (saved locally): {e}\n" + ) + + def IsActive(self): + return FreeCAD.ActiveDocument is not None + + class _DiagWorker(QtCore.QThread): """Background worker that runs connectivity diagnostics.""" @@ -4168,3 +4466,4 @@ FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel()) FreeCADGui.addCommand("Silo_Diag", Silo_Diag()) FreeCADGui.addCommand("Silo_Jobs", Silo_Jobs()) FreeCADGui.addCommand("Silo_Runners", Silo_Runners()) +FreeCADGui.addCommand("Silo_SaveAsTemplate", Silo_SaveAsTemplate()) diff --git a/freecad/templates.py b/freecad/templates.py new file mode 100644 index 0000000..de34696 --- /dev/null +++ b/freecad/templates.py @@ -0,0 +1,167 @@ +"""Template discovery and metadata for Kindred Create .kc templates. + +A template is a normal ``.kc`` file that contains a ``silo/template.json`` +descriptor inside the ZIP archive. This module scans known directories for +template files, parses their descriptors, and provides filtering helpers +used by the schema form UI. + +Search paths (checked in order, later shadows earlier by name): + 1. ``{silo_addon}/templates/`` — system templates shipped with the addon + 2. ``{userAppData}/Templates/`` — personal templates (sister to Macro/) + 3. ``~/projects/templates/`` — org-shared project templates +""" + +import json +import os +import shutil +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + + +@dataclass +class TemplateInfo: + """Parsed template descriptor from ``silo/template.json``.""" + + path: str # Absolute path to the .kc file + name: str = "" + description: str = "" + item_types: List[str] = field(default_factory=list) + categories: List[str] = field(default_factory=list) + icon: str = "" + author: str = "" + tags: List[str] = field(default_factory=list) + + +def read_template_info(kc_path: str) -> Optional[TemplateInfo]: + """Read ``silo/template.json`` from a ``.kc`` file. + + Returns a :class:`TemplateInfo` if the file is a valid template, + or ``None`` if the file does not contain a template descriptor. + """ + try: + with zipfile.ZipFile(kc_path, "r") as zf: + if "silo/template.json" not in zf.namelist(): + return None + raw = zf.read("silo/template.json") + data = json.loads(raw) + return TemplateInfo( + path=str(kc_path), + name=data.get("name", Path(kc_path).stem), + description=data.get("description", ""), + item_types=data.get("item_types", []), + categories=data.get("categories", []), + icon=data.get("icon", ""), + author=data.get("author", ""), + tags=data.get("tags", []), + ) + except Exception: + return None + + +def inject_template_json(kc_path: str, template_info: dict) -> bool: + """Inject ``silo/template.json`` into a ``.kc`` (ZIP) file. + + Rewrites the ZIP to avoid duplicate entries. Returns ``True`` on + success, raises on I/O errors. + """ + if not os.path.isfile(kc_path): + return False + + payload = json.dumps(template_info, indent=2).encode("utf-8") + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".kc") + os.close(tmp_fd) + try: + with zipfile.ZipFile(kc_path, "r") as zin: + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + if item.filename == "silo/template.json": + continue + zout.writestr(item, zin.read(item.filename)) + zout.writestr("silo/template.json", payload) + shutil.move(tmp_path, kc_path) + return True + except Exception: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + + +def get_default_template_dir() -> str: + """Return the user templates directory, creating it if needed. + + Located at ``{userAppData}/Templates/``, a sister to ``Macro/``. + """ + import FreeCAD + + d = os.path.join(FreeCAD.getUserAppDataDir(), "Templates") + os.makedirs(d, exist_ok=True) + return d + + +def discover_templates(search_paths: List[str]) -> List[TemplateInfo]: + """Scan search paths for ``.kc`` files containing ``silo/template.json``. + + Later paths shadow earlier paths by template name. + """ + by_name = {} + for search_dir in search_paths: + if not os.path.isdir(search_dir): + continue + for filename in sorted(os.listdir(search_dir)): + if not filename.lower().endswith(".kc"): + continue + full_path = os.path.join(search_dir, filename) + info = read_template_info(full_path) + if info is not None: + by_name[info.name] = info + return sorted(by_name.values(), key=lambda t: t.name) + + +def get_search_paths() -> List[str]: + """Return the ordered list of template search directories.""" + paths = [] + + # 1. System templates (shipped with the silo addon) + system_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") + paths.append(system_dir) + + # 2. User app-data templates (personal, sister to Macro/) + try: + import FreeCAD + + user_app_templates = os.path.join(FreeCAD.getUserAppDataDir(), "Templates") + paths.append(user_app_templates) + except Exception: + pass + + # 3. Shared project templates + try: + from silo_commands import get_projects_dir + + user_dir = str(get_projects_dir() / "templates") + paths.append(user_dir) + except Exception: + pass + + return paths + + +def filter_templates( + templates: List[TemplateInfo], + item_type: str = "", + category: str = "", +) -> List[TemplateInfo]: + """Filter templates by item type and category prefix.""" + result = [] + for t in templates: + if item_type and t.item_types and item_type not in t.item_types: + continue + if category and t.categories: + if not any(category.startswith(prefix) for prefix in t.categories): + continue + result.append(t) + return result diff --git a/freecad/templates/inject_template.py b/freecad/templates/inject_template.py new file mode 100644 index 0000000..940e9ba --- /dev/null +++ b/freecad/templates/inject_template.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Inject silo/template.json into a .kc file to make it a template. + +Usage: + python inject_template.py [--type part|assembly] [--description "..."] + +Examples: + python inject_template.py part.kc "Part (Generic)" --type part + python inject_template.py sheet-metal-part.kc "Sheet Metal Part" --type part \ + --description "Body with SheetMetal base feature and laser-cut job" + python inject_template.py assembly.kc "Assembly" --type assembly +""" + +import argparse +import json +import os +import sys + +# Allow importing from the parent freecad/ directory when run standalone +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from templates import inject_template_json + + +def main(): + parser = argparse.ArgumentParser( + description="Inject template metadata into a .kc file" + ) + parser.add_argument("kc_file", help="Path to the .kc file") + parser.add_argument("name", help="Template display name") + parser.add_argument( + "--type", + dest="item_types", + action="append", + default=[], + help="Item type filter (part, assembly). Can be repeated.", + ) + parser.add_argument("--description", default="", help="Template description") + parser.add_argument("--icon", default="", help="Icon identifier") + parser.add_argument("--author", default="Kindred Systems", help="Author name") + parser.add_argument( + "--category", + dest="categories", + action="append", + default=[], + help="Category prefix filter. Can be repeated. Empty = all.", + ) + parser.add_argument( + "--tag", + dest="tags", + action="append", + default=[], + help="Searchable tags. Can be repeated.", + ) + args = parser.parse_args() + + if not args.item_types: + args.item_types = ["part"] + + template_info = { + "template_version": "1.0", + "name": args.name, + "description": args.description, + "item_types": args.item_types, + "categories": args.categories, + "icon": args.icon, + "author": args.author, + "tags": args.tags, + } + + if inject_template_json(args.kc_file, template_info): + print(f"Injected silo/template.json into {args.kc_file}") + print(json.dumps(template_info, indent=2)) + else: + sys.exit(1) + + +if __name__ == "__main__": + main()