Opening documents (especially assemblies) inside the QDialog nested event loop can crash FreeCAD when the Assembly solver triggers during document restore. Move FreeCAD.openDocument() to after dialog.exec_() returns.
1952 lines
66 KiB
Python
1952 lines
66 KiB
Python
"""Silo FreeCAD commands - Streamlined workflow for CAD file management."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import FreeCAD
|
|
import FreeCADGui
|
|
|
|
# Preference group for Kindred Silo settings
|
|
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
|
|
|
# Configuration - preferences take priority over env vars
|
|
SILO_PROJECTS_DIR = os.environ.get(
|
|
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
|
|
)
|
|
|
|
|
|
def _get_api_url() -> str:
|
|
"""Get Silo API URL from preferences, falling back to env var then default."""
|
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
|
url = param.GetString("ApiUrl", "")
|
|
if not url:
|
|
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
|
|
url = url.rstrip("/")
|
|
# Auto-append /api when the user provides just a bare origin with no path,
|
|
# e.g. "https://silo.kindred.internal" -> "https://silo.kindred.internal/api"
|
|
# but leave URLs that already have a path alone,
|
|
# e.g. "http://localhost:8080/api" stays as-is.
|
|
if url:
|
|
parsed = urllib.parse.urlparse(url)
|
|
if not parsed.path or parsed.path == "/":
|
|
url = url + "/api"
|
|
return url
|
|
|
|
|
|
def _get_ssl_verify() -> bool:
|
|
"""Get SSL verification setting from preferences."""
|
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
|
return param.GetBool("SslVerify", True)
|
|
|
|
|
|
def _get_ssl_context() -> ssl.SSLContext:
|
|
"""Build an SSL context based on the current SSL verification preference."""
|
|
if _get_ssl_verify():
|
|
ctx = ssl.create_default_context()
|
|
# The bundled Python may not find the system CA store automatically
|
|
# (its compiled-in path points to the build environment). Load the
|
|
# system CA bundle explicitly so internal CAs (e.g. FreeIPA) are trusted.
|
|
for ca_path in (
|
|
"/etc/ssl/certs/ca-certificates.crt", # Debian / Ubuntu
|
|
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL / CentOS
|
|
):
|
|
if os.path.isfile(ca_path):
|
|
try:
|
|
ctx.load_verify_locations(ca_path)
|
|
except Exception:
|
|
pass
|
|
break
|
|
return ctx
|
|
else:
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
return ctx
|
|
|
|
|
|
# Category name mapping for folder structure
|
|
# Format: CCC -> "descriptive_name"
|
|
CATEGORY_NAMES = {
|
|
# Fasteners
|
|
"F01": "screws_bolts",
|
|
"F02": "threaded_rods",
|
|
"F03": "eyebolts",
|
|
"F04": "u_bolts",
|
|
"F05": "nuts",
|
|
"F06": "washers",
|
|
"F07": "shims",
|
|
"F08": "inserts",
|
|
"F09": "spacers",
|
|
"F10": "pins",
|
|
"F11": "anchors",
|
|
"F12": "nails",
|
|
"F13": "rivets",
|
|
"F14": "staples",
|
|
"F15": "key_stock",
|
|
"F16": "retaining_rings",
|
|
"F17": "cable_ties",
|
|
"F18": "hook_loop",
|
|
# Fluid Fittings
|
|
"C01": "full_couplings",
|
|
"C02": "half_couplings",
|
|
"C03": "reducers",
|
|
"C04": "elbows",
|
|
"C05": "tees",
|
|
"C06": "crosses",
|
|
"C07": "unions",
|
|
"C08": "adapters",
|
|
"C09": "plugs_caps",
|
|
"C10": "nipples",
|
|
"C11": "flanges",
|
|
"C12": "valves",
|
|
"C13": "quick_disconnects",
|
|
"C14": "hose_barbs",
|
|
"C15": "compression_fittings",
|
|
"C16": "tubing",
|
|
"C17": "hoses",
|
|
# Motion Components
|
|
"R01": "ball_bearings",
|
|
"R02": "roller_bearings",
|
|
"R03": "sleeve_bearings",
|
|
"R04": "thrust_bearings",
|
|
"R05": "linear_bearings",
|
|
"R06": "spur_gears",
|
|
"R07": "helical_gears",
|
|
"R08": "bevel_gears",
|
|
"R09": "worm_gears",
|
|
"R10": "rack_pinion",
|
|
"R11": "sprockets",
|
|
"R12": "timing_pulleys",
|
|
"R13": "v_belt_pulleys",
|
|
"R14": "idler_pulleys",
|
|
"R15": "wheels",
|
|
"R16": "casters",
|
|
"R17": "shaft_couplings",
|
|
"R18": "clutches",
|
|
"R19": "brakes",
|
|
"R20": "lead_screws",
|
|
"R21": "ball_screws",
|
|
"R22": "linear_rails",
|
|
"R23": "linear_actuators",
|
|
"R24": "brushed_dc_motor",
|
|
"R25": "brushless_dc_motor",
|
|
"R26": "stepper_motor",
|
|
"R27": "servo_motor",
|
|
"R28": "ac_induction_motor",
|
|
"R29": "gear_motor",
|
|
"R30": "motor_driver",
|
|
"R31": "motor_controller",
|
|
"R32": "encoder",
|
|
"R33": "pneumatic_cylinder",
|
|
"R34": "pneumatic_actuator",
|
|
"R35": "pneumatic_valve",
|
|
"R36": "pneumatic_regulator",
|
|
"R37": "pneumatic_frl_unit",
|
|
"R38": "air_compressor",
|
|
"R39": "vacuum_pump",
|
|
"R40": "hydraulic_cylinder",
|
|
"R41": "hydraulic_pump",
|
|
"R42": "hydraulic_motor",
|
|
"R43": "hydraulic_valve",
|
|
"R44": "hydraulic_accumulator",
|
|
# Structural Materials
|
|
"S01": "square_tube",
|
|
"S02": "round_tube",
|
|
"S03": "rectangular_tube",
|
|
"S04": "i_beam",
|
|
"S05": "t_slot_extrusion",
|
|
"S06": "angle",
|
|
"S07": "channel",
|
|
"S08": "flat_bar",
|
|
"S09": "round_bar",
|
|
"S10": "square_bar",
|
|
"S11": "hex_bar",
|
|
"S12": "sheet_metal",
|
|
"S13": "plate",
|
|
"S14": "expanded_metal",
|
|
"S15": "perforated_sheet",
|
|
"S16": "wire_mesh",
|
|
"S17": "grating",
|
|
# Electrical Components
|
|
"E01": "wire",
|
|
"E02": "cable",
|
|
"E03": "connectors",
|
|
"E04": "terminals",
|
|
"E05": "circuit_breakers",
|
|
"E06": "fuses",
|
|
"E07": "relays",
|
|
"E08": "contactors",
|
|
"E09": "switches",
|
|
"E10": "buttons",
|
|
"E11": "indicators",
|
|
"E12": "resistors",
|
|
"E13": "capacitors",
|
|
"E14": "inductors",
|
|
"E15": "transformers",
|
|
"E16": "diodes",
|
|
"E17": "transistors",
|
|
"E18": "ics",
|
|
"E19": "microcontrollers",
|
|
"E20": "sensors",
|
|
"E21": "displays",
|
|
"E22": "power_supplies",
|
|
"E23": "batteries",
|
|
"E24": "pcb",
|
|
"E25": "enclosures",
|
|
"E26": "heat_sinks",
|
|
"E27": "fans",
|
|
# Mechanical Components
|
|
"M01": "compression_springs",
|
|
"M02": "extension_springs",
|
|
"M03": "torsion_springs",
|
|
"M04": "gas_springs",
|
|
"M05": "dampers",
|
|
"M06": "shock_absorbers",
|
|
"M07": "vibration_mounts",
|
|
"M08": "hinges",
|
|
"M09": "latches",
|
|
"M10": "handles",
|
|
"M11": "knobs",
|
|
"M12": "levers",
|
|
"M13": "linkages",
|
|
"M14": "cams",
|
|
"M15": "bellows",
|
|
"M16": "seals",
|
|
"M17": "o_rings",
|
|
"M18": "gaskets",
|
|
# Tooling and Fixtures
|
|
"T01": "jigs",
|
|
"T02": "fixtures",
|
|
"T03": "molds",
|
|
"T04": "dies",
|
|
"T05": "gauges",
|
|
"T06": "templates",
|
|
"T07": "work_holding",
|
|
"T08": "test_fixtures",
|
|
# Assemblies
|
|
"A01": "mechanical_assembly",
|
|
"A02": "electrical_assembly",
|
|
"A03": "electromechanical_assembly",
|
|
"A04": "subassembly",
|
|
"A05": "cable_assembly",
|
|
"A06": "pneumatic_assembly",
|
|
"A07": "hydraulic_assembly",
|
|
# Purchased/Off-the-Shelf
|
|
"P01": "purchased_mechanical",
|
|
"P02": "purchased_electrical",
|
|
"P03": "purchased_assembly",
|
|
"P04": "raw_material",
|
|
"P05": "consumables",
|
|
# Custom Fabricated Parts
|
|
"X01": "machined_part",
|
|
"X02": "sheet_metal_part",
|
|
"X03": "3d_printed_part",
|
|
"X04": "cast_part",
|
|
"X05": "molded_part",
|
|
"X06": "welded_fabrication",
|
|
"X07": "laser_cut_part",
|
|
"X08": "waterjet_cut_part",
|
|
}
|
|
|
|
|
|
# Icon directory - resolve relative to this file so it works regardless of install location
|
|
_ICON_DIR = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
|
|
)
|
|
|
|
|
|
def _icon(name):
|
|
"""Get icon path by name."""
|
|
if _ICON_DIR:
|
|
path = os.path.join(_ICON_DIR, f"silo-{name}.svg")
|
|
if os.path.exists(path):
|
|
return path
|
|
return ""
|
|
|
|
|
|
def get_projects_dir() -> Path:
|
|
"""Get the projects directory."""
|
|
projects_dir = Path(SILO_PROJECTS_DIR)
|
|
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
return projects_dir
|
|
|
|
|
|
class SiloClient:
|
|
"""HTTP client for Silo API."""
|
|
|
|
def __init__(self, base_url: str = None):
|
|
self._explicit_url = base_url
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
if self._explicit_url:
|
|
return self._explicit_url.rstrip("/")
|
|
return _get_api_url().rstrip("/")
|
|
|
|
def _request(
|
|
self, method: str, path: str, data: Optional[Dict] = None
|
|
) -> Dict[str, Any]:
|
|
"""Make HTTP request to Silo API."""
|
|
url = f"{self.base_url}{path}"
|
|
headers = {"Content-Type": "application/json"}
|
|
body = json.dumps(data).encode() if data else None
|
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode()
|
|
raise RuntimeError(f"API error {e.code}: {error_body}")
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"Connection error: {e.reason}")
|
|
|
|
def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool:
|
|
"""Download a file from MinIO storage."""
|
|
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
|
req = urllib.request.Request(url, method="GET")
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
|
with open(dest_path, "wb") as f:
|
|
while True:
|
|
chunk = resp.read(8192)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
return True
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return False
|
|
raise RuntimeError(f"Download error {e.code}: {e.read().decode()}")
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"Connection error: {e.reason}")
|
|
|
|
def _upload_file(
|
|
self, part_number: str, file_path: str, properties: Dict, comment: str = ""
|
|
) -> Dict[str, Any]:
|
|
"""Upload a file and create a new revision."""
|
|
import mimetypes
|
|
|
|
url = f"{self.base_url}/items/{part_number}/file"
|
|
|
|
with open(file_path, "rb") as f:
|
|
file_data = f.read()
|
|
|
|
boundary = "----SiloUploadBoundary" + str(hash(file_path))[-8:]
|
|
body_parts = []
|
|
|
|
filename = os.path.basename(file_path)
|
|
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
body_parts.append(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'
|
|
)
|
|
body_parts.append(file_data)
|
|
body_parts.append(b"\r\n")
|
|
|
|
if comment:
|
|
body_parts.append(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="comment"\r\n\r\n{comment}\r\n'
|
|
)
|
|
|
|
if properties:
|
|
# Ensure properties is valid JSON - handle special float values
|
|
props_json = json.dumps(properties, allow_nan=False, default=str)
|
|
body_parts.append(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="properties"\r\n\r\n{props_json}\r\n'
|
|
)
|
|
|
|
body_parts.append(f"--{boundary}--\r\n")
|
|
|
|
body = b""
|
|
for part in body_parts:
|
|
body += part.encode("utf-8") if isinstance(part, str) else part
|
|
|
|
headers = {
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
"Content-Length": str(len(body)),
|
|
}
|
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}")
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"Connection error: {e.reason}")
|
|
|
|
def get_item(self, part_number: str) -> Dict[str, Any]:
|
|
return self._request("GET", f"/items/{part_number}")
|
|
|
|
def list_items(
|
|
self, search: str = "", item_type: str = "", project: str = ""
|
|
) -> list:
|
|
params = ["limit=100"]
|
|
if search:
|
|
params.append(f"search={urllib.parse.quote(search)}")
|
|
if item_type:
|
|
params.append(f"type={item_type}")
|
|
if project:
|
|
params.append(f"project={project}")
|
|
return self._request("GET", "/items?" + "&".join(params))
|
|
|
|
def create_item(
|
|
self,
|
|
schema: str,
|
|
category: str,
|
|
description: str = "",
|
|
projects: List[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Create a new item with optional project tags."""
|
|
data = {
|
|
"schema": schema,
|
|
"category": category,
|
|
"description": description,
|
|
}
|
|
if projects:
|
|
data["projects"] = projects
|
|
return self._request("POST", "/items", data)
|
|
|
|
def update_item(
|
|
self, part_number: str, description: str = None, item_type: str = None
|
|
) -> Dict[str, Any]:
|
|
data = {}
|
|
if description is not None:
|
|
data["description"] = description
|
|
if item_type is not None:
|
|
data["item_type"] = item_type
|
|
return self._request("PUT", f"/items/{part_number}", data)
|
|
|
|
def get_revisions(self, part_number: str) -> list:
|
|
return self._request("GET", f"/items/{part_number}/revisions")
|
|
|
|
def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]:
|
|
return self._request("GET", f"/schemas/{name}")
|
|
|
|
def get_projects(self) -> list:
|
|
"""Get list of all projects."""
|
|
return self._request("GET", "/projects")
|
|
|
|
def get_item_projects(self, part_number: str) -> list:
|
|
"""Get projects associated with an item."""
|
|
return self._request("GET", f"/items/{part_number}/projects")
|
|
|
|
def add_item_projects(
|
|
self, part_number: str, project_codes: List[str]
|
|
) -> Dict[str, Any]:
|
|
"""Add project tags to an item."""
|
|
return self._request(
|
|
"POST", f"/items/{part_number}/projects", {"projects": project_codes}
|
|
)
|
|
|
|
def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
|
|
"""Check if item has files in MinIO."""
|
|
try:
|
|
revisions = self.get_revisions(part_number)
|
|
for rev in revisions:
|
|
if rev.get("file_key"):
|
|
return True, rev["revision_number"]
|
|
return False, None
|
|
except Exception:
|
|
return False, None
|
|
|
|
def compare_revisions(
|
|
self, part_number: str, from_rev: int, to_rev: int
|
|
) -> Dict[str, Any]:
|
|
"""Compare two revisions and return differences."""
|
|
return self._request(
|
|
"GET",
|
|
f"/items/{part_number}/revisions/compare?from={from_rev}&to={to_rev}",
|
|
)
|
|
|
|
def rollback_revision(
|
|
self, part_number: str, revision: int, comment: str = ""
|
|
) -> Dict[str, Any]:
|
|
"""Create a new revision by rolling back to a previous one."""
|
|
data = {}
|
|
if comment:
|
|
data["comment"] = comment
|
|
return self._request(
|
|
"POST", f"/items/{part_number}/revisions/{revision}/rollback", data
|
|
)
|
|
|
|
def update_revision(
|
|
self, part_number: str, revision: int, status: str = None, labels: list = None
|
|
) -> Dict[str, Any]:
|
|
"""Update revision status and/or labels."""
|
|
data = {}
|
|
if status:
|
|
data["status"] = status
|
|
if labels is not None:
|
|
data["labels"] = labels
|
|
return self._request(
|
|
"PATCH", f"/items/{part_number}/revisions/{revision}", data
|
|
)
|
|
|
|
|
|
_client = SiloClient()
|
|
|
|
|
|
# Utility functions
|
|
|
|
|
|
def sanitize_filename(name: str) -> str:
|
|
"""Sanitize a string for use in filenames."""
|
|
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
|
|
sanitized = re.sub(r"[\s_]+", "_", sanitized)
|
|
sanitized = sanitized.strip("_ ")
|
|
return sanitized[:50]
|
|
|
|
|
|
def parse_part_number(part_number: str) -> Tuple[str, str]:
|
|
"""Parse part number into (category, sequence).
|
|
|
|
New format: CCC-NNNN (e.g., F01-0001)
|
|
Returns: (category_code, sequence)
|
|
"""
|
|
parts = part_number.split("-")
|
|
if len(parts) >= 2:
|
|
return parts[0], parts[1]
|
|
return part_number, ""
|
|
|
|
|
|
def get_category_folder_name(category_code: str) -> str:
|
|
"""Get the folder name for a category (e.g., 'F01_screws_bolts')."""
|
|
name = CATEGORY_NAMES.get(category_code.upper(), "misc")
|
|
return f"{category_code}_{name}"
|
|
|
|
|
|
def get_cad_file_path(part_number: str, description: str = "") -> Path:
|
|
"""Generate canonical file path for a CAD file.
|
|
|
|
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd
|
|
Example: ~/projects/cad/F01_screws_bolts/F01-0001_M3_Socket_Screw.FCStd
|
|
"""
|
|
category, _ = parse_part_number(part_number)
|
|
folder_name = get_category_folder_name(category)
|
|
|
|
if description:
|
|
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
|
|
else:
|
|
filename = f"{part_number}.FCStd"
|
|
|
|
return get_projects_dir() / "cad" / folder_name / filename
|
|
|
|
|
|
def find_file_by_part_number(part_number: str) -> Optional[Path]:
|
|
"""Find existing CAD file for a part number."""
|
|
category, _ = parse_part_number(part_number)
|
|
folder_name = get_category_folder_name(category)
|
|
cad_dir = get_projects_dir() / "cad" / folder_name
|
|
|
|
if cad_dir.exists():
|
|
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
|
|
if matches:
|
|
return matches[0]
|
|
|
|
# Also search in base cad directory (for older files or different structures)
|
|
base_cad_dir = get_projects_dir() / "cad"
|
|
if base_cad_dir.exists():
|
|
# Search all subdirectories
|
|
for subdir in base_cad_dir.iterdir():
|
|
if subdir.is_dir():
|
|
matches = list(subdir.glob(f"{part_number}*.FCStd"))
|
|
if matches:
|
|
return matches[0]
|
|
|
|
return None
|
|
|
|
|
|
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
|
|
"""Search for CAD files in local cad directory."""
|
|
results = []
|
|
cad_dir = get_projects_dir() / "cad"
|
|
if not cad_dir.exists():
|
|
return results
|
|
|
|
search_lower = search_term.lower()
|
|
|
|
for category_dir in cad_dir.iterdir():
|
|
if not category_dir.is_dir():
|
|
continue
|
|
|
|
# Extract category code from folder name (e.g., "F01_screws_bolts" -> "F01")
|
|
folder_name = category_dir.name
|
|
category_code = folder_name.split("_")[0] if "_" in folder_name else folder_name
|
|
|
|
if category_filter and category_code.upper() != category_filter.upper():
|
|
continue
|
|
|
|
for fcstd_file in category_dir.glob("*.FCStd"):
|
|
filename = fcstd_file.stem
|
|
parts = filename.split("_", 1)
|
|
part_number = parts[0]
|
|
description = parts[1].replace("_", " ") if len(parts) > 1 else ""
|
|
|
|
if search_term:
|
|
searchable = f"{part_number} {description}".lower()
|
|
if search_lower not in searchable:
|
|
continue
|
|
|
|
try:
|
|
from datetime import datetime
|
|
|
|
mtime = fcstd_file.stat().st_mtime
|
|
modified = datetime.fromtimestamp(mtime).isoformat()
|
|
except Exception:
|
|
modified = None
|
|
|
|
results.append(
|
|
{
|
|
"path": str(fcstd_file),
|
|
"part_number": part_number,
|
|
"description": description,
|
|
"category": category_code,
|
|
"modified": modified,
|
|
"source": "local",
|
|
}
|
|
)
|
|
|
|
results.sort(key=lambda x: x.get("modified") or "", reverse=True)
|
|
return results
|
|
|
|
|
|
def _safe_float(val):
|
|
"""Convert float to JSON-safe value, handling NaN and Infinity."""
|
|
import math
|
|
|
|
if isinstance(val, float):
|
|
if math.isnan(val) or math.isinf(val):
|
|
return 0.0
|
|
return val
|
|
|
|
|
|
def collect_document_properties(doc) -> Dict[str, Any]:
|
|
"""Collect properties from all objects in a document."""
|
|
result = {
|
|
"_document_name": doc.Name,
|
|
"_file_name": doc.FileName or None,
|
|
"objects": {},
|
|
}
|
|
|
|
for obj in doc.Objects:
|
|
if obj.TypeId.startswith("App::") and obj.TypeId not in ("App::Part",):
|
|
continue
|
|
|
|
props = {"_object_type": obj.TypeId, "_label": obj.Label}
|
|
|
|
if hasattr(obj, "Placement"):
|
|
p = obj.Placement
|
|
props["placement"] = {
|
|
"position": {
|
|
"x": _safe_float(p.Base.x),
|
|
"y": _safe_float(p.Base.y),
|
|
"z": _safe_float(p.Base.z),
|
|
},
|
|
"rotation": {
|
|
"axis": {
|
|
"x": _safe_float(p.Rotation.Axis.x),
|
|
"y": _safe_float(p.Rotation.Axis.y),
|
|
"z": _safe_float(p.Rotation.Axis.z),
|
|
},
|
|
"angle": _safe_float(p.Rotation.Angle),
|
|
},
|
|
}
|
|
|
|
if hasattr(obj, "Shape") and obj.Shape:
|
|
try:
|
|
bbox = obj.Shape.BoundBox
|
|
props["bounding_box"] = {
|
|
"x_length": _safe_float(bbox.XLength),
|
|
"y_length": _safe_float(bbox.YLength),
|
|
"z_length": _safe_float(bbox.ZLength),
|
|
}
|
|
if hasattr(obj.Shape, "Volume"):
|
|
props["volume"] = _safe_float(obj.Shape.Volume)
|
|
except Exception:
|
|
pass
|
|
|
|
result["objects"][obj.Label] = props
|
|
|
|
return result
|
|
|
|
|
|
def set_silo_properties(obj, props: Dict[str, Any]):
|
|
"""Set Silo properties on FreeCAD object."""
|
|
for name, value in props.items():
|
|
if not hasattr(obj, name):
|
|
if isinstance(value, str):
|
|
obj.addProperty("App::PropertyString", name, "Silo", "")
|
|
elif isinstance(value, int):
|
|
obj.addProperty("App::PropertyInteger", name, "Silo", "")
|
|
setattr(obj, name, value)
|
|
|
|
|
|
def get_tracked_object(doc):
|
|
"""Find the primary tracked object in a document."""
|
|
for obj in doc.Objects:
|
|
if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
|
|
return obj
|
|
return None
|
|
|
|
|
|
class SiloSync:
|
|
"""Handles synchronization between FreeCAD and Silo."""
|
|
|
|
def __init__(self, client: SiloClient = None):
|
|
self.client = client or _client
|
|
|
|
def save_to_canonical_path(self, doc, force_rename: bool = False) -> Optional[Path]:
|
|
"""Save document to canonical path."""
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
return None
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
try:
|
|
item = self.client.get_item(part_number)
|
|
description = item.get("description", "")
|
|
new_path = get_cad_file_path(part_number, description)
|
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
existing_path = find_file_by_part_number(part_number)
|
|
current_path = Path(doc.FileName) if doc.FileName else None
|
|
|
|
# Use save() if already at the correct path, saveAs() only if path changes
|
|
if current_path and current_path == new_path:
|
|
doc.save()
|
|
elif (
|
|
existing_path
|
|
and existing_path != new_path
|
|
and (force_rename or current_path == existing_path)
|
|
):
|
|
doc.saveAs(str(new_path))
|
|
try:
|
|
existing_path.unlink()
|
|
except OSError:
|
|
pass
|
|
else:
|
|
doc.saveAs(str(new_path))
|
|
|
|
return new_path
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Save failed: {e}\n")
|
|
return None
|
|
|
|
def create_document_for_item(self, item: Dict[str, Any], save: bool = True):
|
|
"""Create a new FreeCAD document for a database item."""
|
|
part_number = item.get("part_number", "")
|
|
description = item.get("description", "")
|
|
item_type = item.get("item_type", "part")
|
|
|
|
if not part_number:
|
|
return None
|
|
|
|
doc = FreeCAD.newDocument(part_number)
|
|
safe_name = "_" + part_number
|
|
|
|
if item_type == "assembly":
|
|
# Create an Assembly object for assembly items (FreeCAD 1.0+)
|
|
try:
|
|
assembly_obj = doc.addObject("Assembly::AssemblyObject", safe_name)
|
|
assembly_obj.Label = part_number
|
|
set_silo_properties(
|
|
assembly_obj,
|
|
{
|
|
"SiloPartNumber": part_number,
|
|
"SiloRevision": item.get("current_revision", 1),
|
|
"SiloItemType": item_type,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
# Fallback to App::Part if Assembly workbench not available
|
|
FreeCAD.Console.PrintWarning(
|
|
f"Assembly workbench not available, using App::Part: {e}\n"
|
|
)
|
|
part_obj = doc.addObject("App::Part", safe_name)
|
|
part_obj.Label = part_number
|
|
set_silo_properties(
|
|
part_obj,
|
|
{
|
|
"SiloPartNumber": part_number,
|
|
"SiloRevision": item.get("current_revision", 1),
|
|
"SiloItemType": item_type,
|
|
},
|
|
)
|
|
else:
|
|
# Create a Part container for non-assembly items
|
|
part_obj = doc.addObject("App::Part", safe_name)
|
|
part_obj.Label = part_number
|
|
|
|
set_silo_properties(
|
|
part_obj,
|
|
{
|
|
"SiloPartNumber": part_number,
|
|
"SiloRevision": item.get("current_revision", 1),
|
|
"SiloItemType": item_type,
|
|
},
|
|
)
|
|
|
|
# Add a Body for parts (not assemblies)
|
|
body_label = sanitize_filename(description) if description else "Body"
|
|
body = doc.addObject("PartDesign::Body", "_" + body_label)
|
|
body.Label = body_label
|
|
part_obj.addObject(body)
|
|
|
|
doc.recompute()
|
|
|
|
if save:
|
|
file_path = get_cad_file_path(part_number, description)
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
doc.saveAs(str(file_path))
|
|
|
|
return doc
|
|
|
|
def open_item(self, part_number: str):
|
|
"""Open or create item document."""
|
|
existing_path = find_file_by_part_number(part_number)
|
|
|
|
if existing_path and existing_path.exists():
|
|
return FreeCAD.openDocument(str(existing_path))
|
|
|
|
try:
|
|
item = self.client.get_item(part_number)
|
|
return self.create_document_for_item(item, save=True)
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Failed to open: {e}\n")
|
|
return None
|
|
|
|
def upload_file(
|
|
self, part_number: str, file_path: str, comment: str = "Auto-save"
|
|
) -> Optional[Dict]:
|
|
"""Upload file to MinIO."""
|
|
try:
|
|
doc = FreeCAD.openDocument(file_path)
|
|
if not doc:
|
|
return None
|
|
properties = collect_document_properties(doc)
|
|
FreeCAD.closeDocument(doc.Name)
|
|
|
|
return self.client._upload_file(part_number, file_path, properties, comment)
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Upload failed: {e}\n")
|
|
return None
|
|
|
|
def download_file(self, part_number: str) -> Optional[Path]:
|
|
"""Download latest file from MinIO."""
|
|
try:
|
|
item = self.client.get_item(part_number)
|
|
file_path = get_cad_file_path(part_number, item.get("description", ""))
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
revisions = self.client.get_revisions(part_number)
|
|
for rev in revisions:
|
|
if rev.get("file_key"):
|
|
if self.client._download_file(
|
|
part_number, rev["revision_number"], str(file_path)
|
|
):
|
|
return file_path
|
|
return None
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Download failed: {e}\n")
|
|
return None
|
|
|
|
|
|
_sync = SiloSync()
|
|
|
|
|
|
# ============================================================================
|
|
# COMMANDS
|
|
# ============================================================================
|
|
|
|
|
|
class Silo_Open:
|
|
"""Open item - combined search and open dialog."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Open",
|
|
"ToolTip": "Search and open items (Ctrl+O)",
|
|
"Pixmap": _icon("open"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtCore, QtGui
|
|
|
|
dialog = QtGui.QDialog()
|
|
dialog.setWindowTitle("Silo - Open Item")
|
|
dialog.setMinimumWidth(700)
|
|
dialog.setMinimumHeight(500)
|
|
|
|
layout = QtGui.QVBoxLayout(dialog)
|
|
|
|
# Search row
|
|
search_layout = QtGui.QHBoxLayout()
|
|
search_input = QtGui.QLineEdit()
|
|
search_input.setPlaceholderText("Search by part number or description...")
|
|
search_layout.addWidget(search_input)
|
|
layout.addLayout(search_layout)
|
|
|
|
# Filters
|
|
filter_layout = QtGui.QHBoxLayout()
|
|
db_checkbox = QtGui.QCheckBox("Database")
|
|
db_checkbox.setChecked(True)
|
|
local_checkbox = QtGui.QCheckBox("Local Files")
|
|
local_checkbox.setChecked(True)
|
|
filter_layout.addWidget(db_checkbox)
|
|
filter_layout.addWidget(local_checkbox)
|
|
filter_layout.addStretch()
|
|
layout.addLayout(filter_layout)
|
|
|
|
# Results table
|
|
results_table = QtGui.QTableWidget()
|
|
results_table.setColumnCount(5)
|
|
results_table.setHorizontalHeaderLabels(
|
|
["Part Number", "Description", "Type", "Source", "Modified"]
|
|
)
|
|
results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
|
results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
|
results_table.horizontalHeader().setStretchLastSection(True)
|
|
results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
|
layout.addWidget(results_table)
|
|
|
|
results_data = []
|
|
|
|
def do_search():
|
|
nonlocal results_data
|
|
search_term = search_input.text().strip()
|
|
results_data = []
|
|
results_table.setRowCount(0)
|
|
|
|
if db_checkbox.isChecked():
|
|
try:
|
|
for item in _client.list_items(search=search_term):
|
|
results_data.append(
|
|
{
|
|
"part_number": item.get("part_number", ""),
|
|
"description": item.get("description", ""),
|
|
"item_type": item.get("item_type", ""),
|
|
"source": "database",
|
|
"modified": item.get("updated_at", "")[:10]
|
|
if item.get("updated_at")
|
|
else "",
|
|
"path": None,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n")
|
|
|
|
if local_checkbox.isChecked():
|
|
try:
|
|
for item in search_local_files(search_term):
|
|
existing = next(
|
|
(
|
|
r
|
|
for r in results_data
|
|
if r["part_number"] == item["part_number"]
|
|
),
|
|
None,
|
|
)
|
|
if existing:
|
|
existing["source"] = "both"
|
|
existing["path"] = item.get("path")
|
|
else:
|
|
results_data.append(
|
|
{
|
|
"part_number": item.get("part_number", ""),
|
|
"description": item.get("description", ""),
|
|
"item_type": "",
|
|
"source": "local",
|
|
"modified": item.get("modified", "")[:10]
|
|
if item.get("modified")
|
|
else "",
|
|
"path": item.get("path"),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n")
|
|
|
|
results_table.setRowCount(len(results_data))
|
|
for row, data in enumerate(results_data):
|
|
results_table.setItem(
|
|
row, 0, QtGui.QTableWidgetItem(data["part_number"])
|
|
)
|
|
results_table.setItem(
|
|
row, 1, QtGui.QTableWidgetItem(data["description"])
|
|
)
|
|
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
|
|
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
|
|
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
|
|
results_table.resizeColumnsToContents()
|
|
|
|
_open_after_close = [None]
|
|
|
|
def open_selected():
|
|
selected = results_table.selectedItems()
|
|
if not selected:
|
|
return
|
|
row = selected[0].row()
|
|
_open_after_close[0] = dict(results_data[row])
|
|
dialog.accept()
|
|
|
|
search_input.textChanged.connect(lambda: do_search())
|
|
results_table.doubleClicked.connect(open_selected)
|
|
|
|
# Buttons
|
|
btn_layout = QtGui.QHBoxLayout()
|
|
open_btn = QtGui.QPushButton("Open")
|
|
open_btn.clicked.connect(open_selected)
|
|
cancel_btn = QtGui.QPushButton("Cancel")
|
|
cancel_btn.clicked.connect(dialog.reject)
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(open_btn)
|
|
btn_layout.addWidget(cancel_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
do_search()
|
|
dialog.exec_()
|
|
|
|
# Open the document AFTER the dialog has fully closed so that
|
|
# heavy document loads (especially Assembly files) don't run
|
|
# inside the dialog's nested event loop, which can cause crashes.
|
|
data = _open_after_close[0]
|
|
if data is not None:
|
|
if data.get("path"):
|
|
FreeCAD.openDocument(data["path"])
|
|
else:
|
|
_sync.open_item(data["part_number"])
|
|
|
|
def IsActive(self):
|
|
return True
|
|
|
|
|
|
class Silo_New:
|
|
"""Create new item with part number."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "New",
|
|
"ToolTip": "Create new item (Ctrl+N)",
|
|
"Pixmap": _icon("new"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
sel = FreeCADGui.Selection.getSelection()
|
|
|
|
# Category selection
|
|
try:
|
|
schema = _client.get_schema()
|
|
categories = schema.get("segments", [])
|
|
cat_segment = next(
|
|
(s for s in categories if s.get("name") == "category"), None
|
|
)
|
|
if cat_segment and cat_segment.get("values"):
|
|
cat_list = [
|
|
f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())
|
|
]
|
|
category_str, ok = QtGui.QInputDialog.getItem(
|
|
None, "New Item", "Category:", cat_list, 0, False
|
|
)
|
|
if not ok:
|
|
return
|
|
category = category_str.split(" - ")[0]
|
|
else:
|
|
category, ok = QtGui.QInputDialog.getText(
|
|
None, "New Item", "Category code:"
|
|
)
|
|
if not ok:
|
|
return
|
|
except Exception:
|
|
category, ok = QtGui.QInputDialog.getText(
|
|
None, "New Item", "Category code:"
|
|
)
|
|
if not ok:
|
|
return
|
|
|
|
# Description
|
|
default_desc = sel[0].Label if sel else ""
|
|
description, ok = QtGui.QInputDialog.getText(
|
|
None, "New Item", "Description:", text=default_desc
|
|
)
|
|
if not ok:
|
|
return
|
|
|
|
# Optional project tagging
|
|
selected_projects = []
|
|
try:
|
|
projects = _client.get_projects()
|
|
if projects:
|
|
project_codes = [p.get("code", "") for p in projects if p.get("code")]
|
|
if project_codes:
|
|
# Multi-select dialog for projects
|
|
dialog = QtGui.QDialog()
|
|
dialog.setWindowTitle("Tag with Projects (Optional)")
|
|
dialog.setMinimumWidth(300)
|
|
layout = QtGui.QVBoxLayout(dialog)
|
|
|
|
label = QtGui.QLabel("Select projects to tag this item with:")
|
|
layout.addWidget(label)
|
|
|
|
list_widget = QtGui.QListWidget()
|
|
list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection)
|
|
for code in project_codes:
|
|
list_widget.addItem(code)
|
|
layout.addWidget(list_widget)
|
|
|
|
btn_layout = QtGui.QHBoxLayout()
|
|
skip_btn = QtGui.QPushButton("Skip")
|
|
ok_btn = QtGui.QPushButton("Tag Selected")
|
|
btn_layout.addWidget(skip_btn)
|
|
btn_layout.addWidget(ok_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
skip_btn.clicked.connect(dialog.reject)
|
|
ok_btn.clicked.connect(dialog.accept)
|
|
|
|
if dialog.exec_() == QtGui.QDialog.Accepted:
|
|
selected_projects = [
|
|
item.text() for item in list_widget.selectedItems()
|
|
]
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
|
|
|
|
try:
|
|
result = _client.create_item(
|
|
"kindred-rd",
|
|
category,
|
|
description,
|
|
projects=selected_projects if selected_projects else None,
|
|
)
|
|
part_number = result["part_number"]
|
|
|
|
if sel:
|
|
# Tag selected object
|
|
obj = sel[0]
|
|
set_silo_properties(
|
|
obj, {"SiloPartNumber": part_number, "SiloRevision": 1}
|
|
)
|
|
obj.Label = part_number
|
|
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
|
|
else:
|
|
# Create new document
|
|
_sync.create_document_for_item(result, save=True)
|
|
|
|
msg = f"Part number: {part_number}"
|
|
if selected_projects:
|
|
msg += f"\nTagged with projects: {', '.join(selected_projects)}"
|
|
|
|
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
|
|
QtGui.QMessageBox.information(None, "Item Created", msg)
|
|
|
|
except Exception as e:
|
|
QtGui.QMessageBox.critical(None, "Error", str(e))
|
|
|
|
def IsActive(self):
|
|
return True
|
|
|
|
|
|
class Silo_Save:
|
|
"""Save locally and upload to MinIO."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Save",
|
|
"ToolTip": "Save locally and upload to MinIO (Ctrl+S)",
|
|
"Pixmap": _icon("save"),
|
|
}
|
|
|
|
def Activated(self):
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
|
|
# If not tracked, just do a regular FreeCAD save
|
|
if not obj:
|
|
if doc.FileName:
|
|
doc.save()
|
|
FreeCAD.Console.PrintMessage(f"Saved: {doc.FileName}\n")
|
|
else:
|
|
FreeCADGui.runCommand("Std_SaveAs", 0)
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
# Check if document has unsaved changes
|
|
gui_doc = FreeCADGui.getDocument(doc.Name)
|
|
is_modified = gui_doc.Modified if gui_doc else True
|
|
FreeCAD.Console.PrintMessage(
|
|
f"[DEBUG] Modified={is_modified}, FileName={doc.FileName}\n"
|
|
)
|
|
|
|
if gui_doc and not is_modified and doc.FileName:
|
|
FreeCAD.Console.PrintMessage("No changes to save.\n")
|
|
return
|
|
|
|
# Collect properties BEFORE saving to avoid dirtying the document
|
|
# (accessing Shape properties can trigger recompute)
|
|
FreeCAD.Console.PrintMessage("[DEBUG] Collecting properties...\n")
|
|
properties = collect_document_properties(doc)
|
|
|
|
# Check modified state after collecting properties
|
|
is_modified_after_props = gui_doc.Modified if gui_doc else True
|
|
FreeCAD.Console.PrintMessage(
|
|
f"[DEBUG] After collect_properties: Modified={is_modified_after_props}\n"
|
|
)
|
|
|
|
# Save locally
|
|
FreeCAD.Console.PrintMessage("[DEBUG] Saving to canonical path...\n")
|
|
file_path = _sync.save_to_canonical_path(doc, force_rename=True)
|
|
if not file_path:
|
|
# Fallback to regular save if canonical path fails
|
|
if doc.FileName:
|
|
doc.save()
|
|
file_path = Path(doc.FileName)
|
|
else:
|
|
FreeCAD.Console.PrintError("Could not determine save path\n")
|
|
return
|
|
|
|
# Check modified state after save
|
|
is_modified_after_save = gui_doc.Modified if gui_doc else True
|
|
FreeCAD.Console.PrintMessage(
|
|
f"[DEBUG] After save: Modified={is_modified_after_save}\n"
|
|
)
|
|
|
|
# Force clear modified flag if save succeeded (needed for assemblies)
|
|
if is_modified_after_save and gui_doc:
|
|
FreeCAD.Console.PrintMessage(
|
|
"[DEBUG] Attempting to clear Modified flag...\n"
|
|
)
|
|
try:
|
|
gui_doc.Modified = False
|
|
FreeCAD.Console.PrintMessage(
|
|
f"[DEBUG] After force clear: Modified={gui_doc.Modified}\n"
|
|
)
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintMessage(f"[DEBUG] Could not clear Modified: {e}\n")
|
|
|
|
FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n")
|
|
|
|
# Try to upload to MinIO
|
|
try:
|
|
result = _client._upload_file(
|
|
part_number, str(file_path), properties, "Auto-save"
|
|
)
|
|
|
|
new_rev = result["revision_number"]
|
|
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
|
|
|
# Check modified state after upload
|
|
is_modified_after_upload = gui_doc.Modified if gui_doc else True
|
|
FreeCAD.Console.PrintMessage(
|
|
f"[DEBUG] After upload: Modified={is_modified_after_upload}\n"
|
|
)
|
|
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
|
|
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_Commit:
|
|
"""Save as new revision with comment."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Commit",
|
|
"ToolTip": "Save as new revision with comment (Ctrl+Shift+S)",
|
|
"Pixmap": _icon("commit"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
FreeCAD.Console.PrintError(
|
|
"No tracked object. Use 'New' to register first.\n"
|
|
)
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
comment, ok = QtGui.QInputDialog.getText(None, "Commit", "Revision comment:")
|
|
if not ok:
|
|
return
|
|
|
|
# Collect properties BEFORE saving to avoid dirtying the document
|
|
properties = collect_document_properties(doc)
|
|
|
|
try:
|
|
file_path = _sync.save_to_canonical_path(doc, force_rename=True)
|
|
if not file_path:
|
|
return
|
|
|
|
result = _client._upload_file(
|
|
part_number, str(file_path), properties, comment
|
|
)
|
|
|
|
new_rev = result["revision_number"]
|
|
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
|
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_Pull:
|
|
"""Download from MinIO / sync from database."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Pull",
|
|
"ToolTip": "Download latest from MinIO or create from database",
|
|
"Pixmap": _icon("pull"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
part_number = None
|
|
|
|
if doc:
|
|
obj = get_tracked_object(doc)
|
|
if obj:
|
|
part_number = obj.SiloPartNumber
|
|
|
|
if not part_number:
|
|
part_number, ok = QtGui.QInputDialog.getText(None, "Pull", "Part number:")
|
|
if not ok or not part_number:
|
|
return
|
|
part_number = part_number.strip().upper()
|
|
|
|
# Check if local file exists
|
|
existing_local = find_file_by_part_number(part_number)
|
|
|
|
# Check if file exists in MinIO
|
|
has_file, rev_num = _client.has_file(part_number)
|
|
|
|
if has_file:
|
|
# File exists in MinIO
|
|
if existing_local:
|
|
# Local file exists - ask before overwriting
|
|
reply = QtGui.QMessageBox.question(
|
|
None,
|
|
"Pull",
|
|
f"Download revision {rev_num} and overwrite local file?",
|
|
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
|
)
|
|
if reply != QtGui.QMessageBox.Yes:
|
|
return
|
|
|
|
# Download from MinIO (creates local file automatically)
|
|
downloaded = _sync.download_file(part_number)
|
|
if downloaded:
|
|
FreeCAD.Console.PrintMessage(f"Downloaded: {downloaded}\n")
|
|
# Automatically open the downloaded file
|
|
FreeCAD.openDocument(str(downloaded))
|
|
else:
|
|
QtGui.QMessageBox.warning(None, "Pull", "Download failed")
|
|
else:
|
|
# No file in MinIO - create from database
|
|
if existing_local:
|
|
# Local file already exists, just open it
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Opening existing local file: {existing_local}\n"
|
|
)
|
|
FreeCAD.openDocument(str(existing_local))
|
|
else:
|
|
# No local file and no MinIO file - create new from DB
|
|
try:
|
|
item = _client.get_item(part_number)
|
|
new_doc = _sync.create_document_for_item(item, save=True)
|
|
if new_doc:
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Created local file for {part_number}\n"
|
|
)
|
|
else:
|
|
QtGui.QMessageBox.warning(
|
|
None, "Pull", f"Failed to create document for {part_number}"
|
|
)
|
|
except Exception as e:
|
|
QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}")
|
|
|
|
def IsActive(self):
|
|
return True
|
|
|
|
|
|
class Silo_Push:
|
|
"""Upload local files to MinIO."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Push",
|
|
"ToolTip": "Upload local files that aren't in MinIO",
|
|
"Pixmap": _icon("push"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
# Find unuploaded files
|
|
local_files = search_local_files()
|
|
unuploaded = []
|
|
|
|
for lf in local_files:
|
|
pn = lf["part_number"]
|
|
try:
|
|
_client.get_item(pn) # Check if in DB
|
|
has_file, _ = _client.has_file(pn)
|
|
if not has_file:
|
|
unuploaded.append(lf)
|
|
except Exception:
|
|
pass # Not in DB, skip
|
|
|
|
if not unuploaded:
|
|
QtGui.QMessageBox.information(
|
|
None, "Push", "All local files are already uploaded."
|
|
)
|
|
return
|
|
|
|
msg = f"Found {len(unuploaded)} files to upload:\n\n"
|
|
for item in unuploaded[:10]:
|
|
msg += f" {item['part_number']}\n"
|
|
if len(unuploaded) > 10:
|
|
msg += f" ... and {len(unuploaded) - 10} more\n"
|
|
msg += "\nUpload?"
|
|
|
|
reply = QtGui.QMessageBox.question(
|
|
None, "Push", msg, QtGui.QMessageBox.Yes | QtGui.QMessageBox.No
|
|
)
|
|
if reply != QtGui.QMessageBox.Yes:
|
|
return
|
|
|
|
uploaded = 0
|
|
for item in unuploaded:
|
|
result = _sync.upload_file(
|
|
item["part_number"], item["path"], "Synced from local"
|
|
)
|
|
if result:
|
|
uploaded += 1
|
|
|
|
QtGui.QMessageBox.information(None, "Push", f"Uploaded {uploaded} files.")
|
|
|
|
def IsActive(self):
|
|
return True
|
|
|
|
|
|
class Silo_Info:
|
|
"""Show item status and revision history."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Info",
|
|
"ToolTip": "Show item status and revision history",
|
|
"Pixmap": _icon("info"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
FreeCAD.Console.PrintError("No tracked object\n")
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
try:
|
|
item = _client.get_item(part_number)
|
|
revisions = _client.get_revisions(part_number)
|
|
|
|
# Get projects for item
|
|
try:
|
|
projects = _client.get_item_projects(part_number)
|
|
project_codes = [p.get("code", "") for p in projects if p.get("code")]
|
|
except Exception:
|
|
project_codes = []
|
|
|
|
msg = f"<h3>{part_number}</h3>"
|
|
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
|
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
|
|
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
|
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
|
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
|
|
|
has_file, _ = _client.has_file(part_number)
|
|
msg += f"<p><b>File in MinIO:</b> {'Yes' if has_file else 'No'}</p>"
|
|
|
|
# Show current revision status
|
|
if revisions:
|
|
current_status = revisions[0].get("status", "draft")
|
|
current_labels = revisions[0].get("labels", [])
|
|
msg += f"<p><b>Current Status:</b> {current_status}</p>"
|
|
if current_labels:
|
|
msg += f"<p><b>Labels:</b> {', '.join(current_labels)}</p>"
|
|
|
|
msg += "<h4>Revision History</h4><table border='1' cellpadding='4'>"
|
|
msg += "<tr><th>Rev</th><th>Status</th><th>Date</th><th>File</th><th>Comment</th></tr>"
|
|
for rev in revisions:
|
|
file_icon = "✓" if rev.get("file_key") else "-"
|
|
comment = rev.get("comment", "") or "-"
|
|
date = rev.get("created_at", "")[:10]
|
|
status = rev.get("status", "draft")
|
|
msg += f"<tr><td>{rev['revision_number']}</td><td>{status}</td><td>{date}</td><td>{file_icon}</td><td>{comment}</td></tr>"
|
|
msg += "</table>"
|
|
|
|
dialog = QtGui.QMessageBox()
|
|
dialog.setWindowTitle("Item Info")
|
|
dialog.setTextFormat(QtGui.Qt.RichText)
|
|
dialog.setText(msg)
|
|
dialog.exec_()
|
|
|
|
except Exception as e:
|
|
QtGui.QMessageBox.warning(None, "Info", f"Failed to get info: {e}")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_TagProjects:
|
|
"""Manage project tags for an item."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Tag Projects",
|
|
"ToolTip": "Add or remove project tags for an item",
|
|
"Pixmap": _icon("tag"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
FreeCAD.Console.PrintError("No tracked object\n")
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
try:
|
|
# Get current projects for item
|
|
current_projects = _client.get_item_projects(part_number)
|
|
current_codes = {
|
|
p.get("code", "") for p in current_projects if p.get("code")
|
|
}
|
|
|
|
# Get all available projects
|
|
all_projects = _client.get_projects()
|
|
all_codes = [p.get("code", "") for p in all_projects if p.get("code")]
|
|
|
|
if not all_codes:
|
|
QtGui.QMessageBox.information(
|
|
None,
|
|
"Tag Projects",
|
|
"No projects available. Create projects first.",
|
|
)
|
|
return
|
|
|
|
# Multi-select dialog
|
|
dialog = QtGui.QDialog()
|
|
dialog.setWindowTitle(f"Tag Projects for {part_number}")
|
|
dialog.setMinimumWidth(350)
|
|
layout = QtGui.QVBoxLayout(dialog)
|
|
|
|
label = QtGui.QLabel("Select projects to associate with this item:")
|
|
layout.addWidget(label)
|
|
|
|
list_widget = QtGui.QListWidget()
|
|
list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection)
|
|
for code in all_codes:
|
|
item = QtGui.QListWidgetItem(code)
|
|
list_widget.addItem(item)
|
|
if code in current_codes:
|
|
item.setSelected(True)
|
|
layout.addWidget(list_widget)
|
|
|
|
btn_layout = QtGui.QHBoxLayout()
|
|
cancel_btn = QtGui.QPushButton("Cancel")
|
|
save_btn = QtGui.QPushButton("Save")
|
|
btn_layout.addWidget(cancel_btn)
|
|
btn_layout.addWidget(save_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
cancel_btn.clicked.connect(dialog.reject)
|
|
save_btn.clicked.connect(dialog.accept)
|
|
|
|
if dialog.exec_() == QtGui.QDialog.Accepted:
|
|
selected = [item.text() for item in list_widget.selectedItems()]
|
|
|
|
# Add new tags
|
|
to_add = [c for c in selected if c not in current_codes]
|
|
if to_add:
|
|
_client.add_item_projects(part_number, to_add)
|
|
|
|
# Note: removing tags would require a separate API call per project
|
|
# For simplicity, we just add new ones here
|
|
|
|
msg = f"Updated project tags for {part_number}"
|
|
if to_add:
|
|
msg += f"\nAdded: {', '.join(to_add)}"
|
|
|
|
QtGui.QMessageBox.information(None, "Tag Projects", msg)
|
|
FreeCAD.Console.PrintMessage(f"{msg}\n")
|
|
|
|
except Exception as e:
|
|
QtGui.QMessageBox.warning(None, "Tag Projects", f"Failed: {e}")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_Rollback:
|
|
"""Rollback to a previous revision."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Rollback",
|
|
"ToolTip": "Rollback to a previous revision",
|
|
"Pixmap": _icon("rollback"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtCore, QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
FreeCAD.Console.PrintError("No tracked object\n")
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
|
|
try:
|
|
revisions = _client.get_revisions(part_number)
|
|
if len(revisions) < 2:
|
|
QtGui.QMessageBox.information(
|
|
None, "Rollback", "No previous revisions to rollback to."
|
|
)
|
|
return
|
|
|
|
# Build revision list for selection (exclude current/latest)
|
|
current_rev = revisions[0]["revision_number"]
|
|
prev_revisions = revisions[1:] # All except latest
|
|
|
|
# Create selection dialog
|
|
dialog = QtGui.QDialog()
|
|
dialog.setWindowTitle(f"Rollback {part_number}")
|
|
dialog.setMinimumWidth(500)
|
|
dialog.setMinimumHeight(300)
|
|
layout = QtGui.QVBoxLayout(dialog)
|
|
|
|
label = QtGui.QLabel(
|
|
f"Select a revision to rollback to (current: Rev {current_rev}):"
|
|
)
|
|
layout.addWidget(label)
|
|
|
|
# Revision table
|
|
table = QtGui.QTableWidget()
|
|
table.setColumnCount(4)
|
|
table.setHorizontalHeaderLabels(["Rev", "Status", "Date", "Comment"])
|
|
table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
|
table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
|
table.setRowCount(len(prev_revisions))
|
|
table.horizontalHeader().setStretchLastSection(True)
|
|
|
|
for i, rev in enumerate(prev_revisions):
|
|
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
|
|
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
|
|
table.setItem(
|
|
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
|
|
)
|
|
table.setItem(
|
|
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
|
|
)
|
|
|
|
table.resizeColumnsToContents()
|
|
layout.addWidget(table)
|
|
|
|
# Comment field
|
|
comment_label = QtGui.QLabel("Rollback comment (optional):")
|
|
layout.addWidget(comment_label)
|
|
comment_input = QtGui.QLineEdit()
|
|
comment_input.setPlaceholderText("Reason for rollback...")
|
|
layout.addWidget(comment_input)
|
|
|
|
# Buttons
|
|
btn_layout = QtGui.QHBoxLayout()
|
|
cancel_btn = QtGui.QPushButton("Cancel")
|
|
rollback_btn = QtGui.QPushButton("Rollback")
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(cancel_btn)
|
|
btn_layout.addWidget(rollback_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
selected_rev = [None]
|
|
|
|
def on_rollback():
|
|
selected = table.selectedItems()
|
|
if not selected:
|
|
QtGui.QMessageBox.warning(
|
|
dialog, "Rollback", "Please select a revision"
|
|
)
|
|
return
|
|
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
|
|
dialog.accept()
|
|
|
|
cancel_btn.clicked.connect(dialog.reject)
|
|
rollback_btn.clicked.connect(on_rollback)
|
|
|
|
if dialog.exec_() == QtGui.QDialog.Accepted and selected_rev[0]:
|
|
target_rev = selected_rev[0]
|
|
comment = comment_input.text().strip()
|
|
|
|
# Confirm
|
|
reply = QtGui.QMessageBox.question(
|
|
None,
|
|
"Confirm Rollback",
|
|
f"Create new revision by rolling back to Rev {target_rev}?\n\n"
|
|
"This will copy properties and file reference from the selected revision.",
|
|
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
|
)
|
|
if reply != QtGui.QMessageBox.Yes:
|
|
return
|
|
|
|
# Perform rollback
|
|
result = _client.rollback_revision(part_number, target_rev, comment)
|
|
new_rev = result["revision_number"]
|
|
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Created revision {new_rev} (rollback from {target_rev})\n"
|
|
)
|
|
QtGui.QMessageBox.information(
|
|
None,
|
|
"Rollback Complete",
|
|
f"Created revision {new_rev} from rollback to Rev {target_rev}.\n\n"
|
|
"Use 'Pull' to download the rolled-back file.",
|
|
)
|
|
|
|
except Exception as e:
|
|
QtGui.QMessageBox.warning(None, "Rollback", f"Failed: {e}")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_SetStatus:
|
|
"""Set revision status (draft, review, released, obsolete)."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Set Status",
|
|
"ToolTip": "Set the status of the current revision",
|
|
"Pixmap": _icon("status"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtGui
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
if not doc:
|
|
FreeCAD.Console.PrintError("No active document\n")
|
|
return
|
|
|
|
obj = get_tracked_object(doc)
|
|
if not obj:
|
|
FreeCAD.Console.PrintError("No tracked object\n")
|
|
return
|
|
|
|
part_number = obj.SiloPartNumber
|
|
local_rev = getattr(obj, "SiloRevision", 1)
|
|
|
|
try:
|
|
# Get current revision info
|
|
revisions = _client.get_revisions(part_number)
|
|
current_rev = revisions[0] if revisions else None
|
|
if not current_rev:
|
|
QtGui.QMessageBox.warning(None, "Set Status", "No revisions found")
|
|
return
|
|
|
|
current_status = current_rev.get("status", "draft")
|
|
rev_num = current_rev["revision_number"]
|
|
|
|
# Status selection
|
|
statuses = ["draft", "review", "released", "obsolete"]
|
|
status, ok = QtGui.QInputDialog.getItem(
|
|
None,
|
|
"Set Revision Status",
|
|
f"Set status for Rev {rev_num} (current: {current_status}):",
|
|
statuses,
|
|
statuses.index(current_status),
|
|
False,
|
|
)
|
|
|
|
if not ok or status == current_status:
|
|
return
|
|
|
|
# Update status
|
|
_client.update_revision(part_number, rev_num, status=status)
|
|
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Updated Rev {rev_num} status to '{status}'\n"
|
|
)
|
|
QtGui.QMessageBox.information(
|
|
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
|
|
)
|
|
|
|
except Exception as e:
|
|
QtGui.QMessageBox.warning(None, "Set Status", f"Failed: {e}")
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
|
|
class Silo_Settings:
|
|
"""Configure Silo connection settings."""
|
|
|
|
def GetResources(self):
|
|
return {
|
|
"MenuText": "Settings",
|
|
"ToolTip": "Configure Silo API URL and SSL settings",
|
|
"Pixmap": _icon("info"),
|
|
}
|
|
|
|
def Activated(self):
|
|
from PySide import QtCore, QtGui
|
|
|
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
|
|
|
dialog = QtGui.QDialog()
|
|
dialog.setWindowTitle("Silo Settings")
|
|
dialog.setMinimumWidth(450)
|
|
|
|
layout = QtGui.QVBoxLayout(dialog)
|
|
|
|
# URL
|
|
url_label = QtGui.QLabel("Silo API URL:")
|
|
layout.addWidget(url_label)
|
|
|
|
url_input = QtGui.QLineEdit()
|
|
url_input.setPlaceholderText("http://localhost:8080/api")
|
|
current_url = param.GetString("ApiUrl", "")
|
|
if current_url:
|
|
url_input.setText(current_url)
|
|
else:
|
|
env_url = os.environ.get("SILO_API_URL", "")
|
|
if env_url:
|
|
url_input.setText(env_url)
|
|
layout.addWidget(url_input)
|
|
|
|
url_hint = QtGui.QLabel(
|
|
"Full URL with path (e.g. http://localhost:8080/api) or just the "
|
|
"hostname (e.g. https://silo.kindred.internal) and /api is "
|
|
"appended automatically. Leave empty for SILO_API_URL env var."
|
|
)
|
|
url_hint.setWordWrap(True)
|
|
url_hint.setStyleSheet("color: #888; font-size: 11px;")
|
|
layout.addWidget(url_hint)
|
|
|
|
layout.addSpacing(10)
|
|
|
|
# SSL
|
|
ssl_checkbox = QtGui.QCheckBox("Verify SSL certificates")
|
|
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
|
layout.addWidget(ssl_checkbox)
|
|
|
|
ssl_hint = QtGui.QLabel(
|
|
"Disable only for internal servers with self-signed certificates."
|
|
)
|
|
ssl_hint.setWordWrap(True)
|
|
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
|
|
layout.addWidget(ssl_hint)
|
|
|
|
layout.addSpacing(10)
|
|
|
|
# Current effective values (read-only)
|
|
status_label = QtGui.QLabel(
|
|
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
|
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}"
|
|
)
|
|
status_label.setTextFormat(QtCore.Qt.RichText)
|
|
layout.addWidget(status_label)
|
|
|
|
layout.addStretch()
|
|
|
|
# Buttons
|
|
btn_layout = QtGui.QHBoxLayout()
|
|
save_btn = QtGui.QPushButton("Save")
|
|
cancel_btn = QtGui.QPushButton("Cancel")
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(save_btn)
|
|
btn_layout.addWidget(cancel_btn)
|
|
layout.addLayout(btn_layout)
|
|
|
|
def on_save():
|
|
url = url_input.text().strip()
|
|
param.SetString("ApiUrl", url)
|
|
param.SetBool("SslVerify", ssl_checkbox.isChecked())
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Silo settings saved. URL: {_get_api_url()}, "
|
|
f"SSL verify: {_get_ssl_verify()}\n"
|
|
)
|
|
dialog.accept()
|
|
|
|
save_btn.clicked.connect(on_save)
|
|
cancel_btn.clicked.connect(dialog.reject)
|
|
|
|
dialog.exec_()
|
|
|
|
def IsActive(self):
|
|
return True
|
|
|
|
|
|
# Register commands
|
|
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
|
FreeCADGui.addCommand("Silo_New", Silo_New())
|
|
FreeCADGui.addCommand("Silo_Save", Silo_Save())
|
|
FreeCADGui.addCommand("Silo_Commit", Silo_Commit())
|
|
FreeCADGui.addCommand("Silo_Pull", Silo_Pull())
|
|
FreeCADGui.addCommand("Silo_Push", Silo_Push())
|
|
FreeCADGui.addCommand("Silo_Info", Silo_Info())
|
|
FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
|
|
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
|
|
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
|
|
FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
|