feat(create): silo tree foundation for .kc files #267
@@ -1,6 +1,12 @@
|
||||
# Kindred Create core module
|
||||
# Handles auto-loading of ztools and Silo addons
|
||||
|
||||
# C++ module targets
|
||||
add_subdirectory(App)
|
||||
if(BUILD_GUI)
|
||||
add_subdirectory(Gui)
|
||||
endif(BUILD_GUI)
|
||||
|
||||
# Generate version.py from template with Kindred Create version
|
||||
configure_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/version.py.in
|
||||
@@ -15,6 +21,10 @@ install(
|
||||
InitGui.py
|
||||
addon_loader.py
|
||||
kc_format.py
|
||||
silo_document.py
|
||||
silo_objects.py
|
||||
silo_tree.py
|
||||
silo_viewproviders.py
|
||||
update_checker.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/version.py
|
||||
DESTINATION
|
||||
|
||||
@@ -24,6 +24,16 @@ def _register_kc_format():
|
||||
FreeCAD.Console.PrintLog(f"Create: kc_format registration skipped: {e}\n")
|
||||
|
||||
|
||||
def _register_silo_document_observer():
|
||||
"""Register the Silo document observer for .kc tree building."""
|
||||
try:
|
||||
import silo_document
|
||||
|
||||
silo_document.register()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: silo_document registration skipped: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Silo integration enhancements
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -144,6 +154,7 @@ try:
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(500, _register_kc_format)
|
||||
QTimer.singleShot(600, _register_silo_document_observer)
|
||||
QTimer.singleShot(1500, _register_silo_origin)
|
||||
QTimer.singleShot(2000, _setup_silo_auth_panel)
|
||||
QTimer.singleShot(3000, _check_silo_first_start)
|
||||
|
||||
@@ -18,6 +18,19 @@ import FreeCAD
|
||||
# Cache: filepath -> {entry_name: bytes}
|
||||
_silo_cache = {}
|
||||
|
||||
# Pre-reinject hooks: called with (doc, filename, entries) before ZIP write.
|
||||
_pre_reinject_hooks = []
|
||||
|
||||
|
||||
def register_pre_reinject(callback):
|
||||
"""Register a callback invoked before silo/ entries are written to ZIP.
|
||||
|
||||
Signature: callback(doc, filename, entries) -> None
|
||||
``entries`` is a dict {entry_name: bytes} or None. Mutate in place.
|
||||
"""
|
||||
_pre_reinject_hooks.append(callback)
|
||||
|
||||
|
||||
KC_VERSION = "1.0"
|
||||
|
||||
|
||||
@@ -62,6 +75,13 @@ class _KcFormatObserver:
|
||||
_silo_cache.pop(filename, None)
|
||||
return
|
||||
entries = _silo_cache.pop(filename, None)
|
||||
for _hook in _pre_reinject_hooks:
|
||||
try:
|
||||
_hook(doc, filename, entries)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kc_format: pre_reinject hook failed: {exc}\n"
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(filename, "a") as zf:
|
||||
existing = set(zf.namelist())
|
||||
|
||||
85
src/Mod/Create/silo_document.py
Normal file
85
src/Mod/Create/silo_document.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
silo_document.py - Document observer that builds the Silo metadata tree
|
||||
when a .kc file is opened in FreeCAD.
|
||||
|
||||
Hooks slotCreatedDocument (primary) and slotActivateDocument (fallback)
|
||||
to detect .kc opens, then defers tree building to the next event loop
|
||||
tick via QTimer.singleShot(0, ...) so the document is fully loaded.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_observer = None
|
||||
|
||||
|
||||
def _on_kc_restored(doc):
|
||||
"""Deferred callback: build the Silo tree after document is fully loaded."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename:
|
||||
return
|
||||
from silo_tree import SiloTreeBuilder
|
||||
|
||||
contents = SiloTreeBuilder.read_silo_directory(filename)
|
||||
if contents:
|
||||
SiloTreeBuilder.build_tree(doc, contents)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: failed to build silo tree for {doc.Name!r}: {exc}\n"
|
||||
)
|
||||
|
||||
|
||||
class SiloDocumentObserver:
|
||||
"""Singleton observer that triggers Silo tree creation for .kc files."""
|
||||
|
||||
def slotCreatedDocument(self, doc):
|
||||
"""Called when a document is created or opened."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename or not filename.lower().endswith(".kc"):
|
||||
return
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(0, lambda: _on_kc_restored(doc))
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: slotCreatedDocument error: {exc}\n"
|
||||
)
|
||||
|
||||
def slotActivateDocument(self, doc):
|
||||
"""Fallback for documents opened before observer registration."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename or not filename.lower().endswith(".kc"):
|
||||
return
|
||||
if doc.getObject("Silo") is not None:
|
||||
return
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(0, lambda: _on_kc_restored(doc))
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: slotActivateDocument error: {exc}\n"
|
||||
)
|
||||
|
||||
def slotDeletedDocument(self, doc):
|
||||
"""Placeholder for future cleanup."""
|
||||
pass
|
||||
|
||||
|
||||
def register():
|
||||
"""Register the singleton observer. Safe to call multiple times."""
|
||||
global _observer
|
||||
if _observer is not None:
|
||||
return
|
||||
|
||||
_observer = SiloDocumentObserver()
|
||||
FreeCAD.addDocumentObserver(_observer)
|
||||
FreeCAD.Console.PrintLog("silo_document: observer registered\n")
|
||||
|
||||
# Bootstrap: handle documents already open before registration.
|
||||
try:
|
||||
for doc in FreeCAD.listDocuments().values():
|
||||
_observer.slotActivateDocument(doc)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(f"silo_document: bootstrap scan failed: {exc}\n")
|
||||
58
src/Mod/Create/silo_objects.py
Normal file
58
src/Mod/Create/silo_objects.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
silo_objects.py - FreeCAD FeaturePython proxy for Silo tree leaf nodes.
|
||||
|
||||
Each silo/ ZIP entry in a .kc file gets one SiloViewerObject in the
|
||||
FreeCAD document tree. All properties are Transient so they are never
|
||||
persisted in Document.xml.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
class SiloViewerObject:
|
||||
"""Proxy for App::FeaturePython silo viewer nodes.
|
||||
|
||||
Properties (all Transient):
|
||||
SiloPath - ZIP entry path, e.g. "silo/manifest.json"
|
||||
ContentType - "json", "yaml", or "py"
|
||||
RawContent - decoded UTF-8 content of the entry
|
||||
"""
|
||||
|
||||
def __init__(self, obj):
|
||||
obj.Proxy = self
|
||||
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"SiloPath",
|
||||
"Silo",
|
||||
"ZIP entry path of this silo item",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"ContentType",
|
||||
"Silo",
|
||||
"Content type of this silo item",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"RawContent",
|
||||
"Silo",
|
||||
"Raw text content of this silo entry",
|
||||
)
|
||||
|
||||
obj.setPropertyStatus("SiloPath", "Transient")
|
||||
obj.setPropertyStatus("ContentType", "Transient")
|
||||
obj.setPropertyStatus("RawContent", "Transient")
|
||||
|
||||
obj.setEditorMode("SiloPath", 1) # read-only in property panel
|
||||
obj.setEditorMode("ContentType", 1)
|
||||
obj.setEditorMode("RawContent", 2) # hidden in property panel
|
||||
|
||||
def execute(self, obj):
|
||||
pass
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
229
src/Mod/Create/silo_tree.py
Normal file
229
src/Mod/Create/silo_tree.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
silo_tree.py - Builds the Silo metadata tree in the FreeCAD document.
|
||||
|
||||
Reads silo/ entries from a .kc ZIP and creates a conditional hierarchy
|
||||
of App::FeaturePython and App::DocumentObjectGroup objects in the
|
||||
document tree.
|
||||
|
||||
Tree structure:
|
||||
Silo (App::DocumentObjectGroup)
|
||||
+-- Manifest (always present)
|
||||
+-- Metadata (if metadata.json is non-empty)
|
||||
+-- History (if history.json has revisions)
|
||||
+-- Approvals (if approvals.json has eco field)
|
||||
+-- Dependencies (if dependencies.json has links)
|
||||
+-- Jobs (group, if silo/jobs/ has YAML files)
|
||||
| +-- default.yaml
|
||||
+-- Macros (group, if silo/macros/ has .py files)
|
||||
+-- on_save
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_SILO_GROUP_NAME = "Silo"
|
||||
|
||||
# Top-level silo/ entries with their object names, labels, and
|
||||
# optional JSON field checks for conditional creation.
|
||||
_KNOWN_ENTRIES = [
|
||||
# (zip_name, object_name, label, json_check)
|
||||
# json_check is None (always create) or (field_name, check_fn)
|
||||
("silo/manifest.json", "SiloManifest", "Manifest", None),
|
||||
("silo/metadata.json", "SiloMetadata", "Metadata", None),
|
||||
(
|
||||
"silo/history.json",
|
||||
"SiloHistory",
|
||||
"History",
|
||||
("revisions", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
(
|
||||
"silo/approvals.json",
|
||||
"SiloApprovals",
|
||||
"Approvals",
|
||||
("eco", lambda v: v is not None),
|
||||
),
|
||||
(
|
||||
"silo/dependencies.json",
|
||||
"SiloDependencies",
|
||||
"Dependencies",
|
||||
("links", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _content_type(entry_name):
|
||||
"""Determine content type from ZIP entry name."""
|
||||
if entry_name.endswith(".json"):
|
||||
return "json"
|
||||
if entry_name.endswith((".yaml", ".yml")):
|
||||
return "yaml"
|
||||
if entry_name.endswith(".py"):
|
||||
return "py"
|
||||
return "text"
|
||||
|
||||
|
||||
def _decode(data):
|
||||
"""Decode bytes to UTF-8 string, returning '' on failure."""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _should_create(data, json_check):
|
||||
"""Check whether a conditional node should be created."""
|
||||
if json_check is None:
|
||||
return True
|
||||
field_name, check_fn = json_check
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
return check_fn(parsed.get(field_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _create_leaf(doc, parent, entry_name, data, obj_name, label):
|
||||
"""Create one App::FeaturePython leaf and add it to parent group."""
|
||||
from silo_objects import SiloViewerObject
|
||||
from silo_viewproviders import SiloViewerViewProvider
|
||||
|
||||
obj = doc.addObject("App::FeaturePython", obj_name)
|
||||
SiloViewerObject(obj)
|
||||
|
||||
obj.SiloPath = entry_name
|
||||
obj.ContentType = _content_type(entry_name)
|
||||
obj.RawContent = _decode(data)
|
||||
obj.Label = label
|
||||
|
||||
try:
|
||||
import FreeCADGui # noqa: F401
|
||||
|
||||
if obj.ViewObject is not None:
|
||||
SiloViewerViewProvider(obj.ViewObject)
|
||||
except ImportError:
|
||||
pass # headless mode
|
||||
|
||||
parent.addObject(obj)
|
||||
return obj
|
||||
|
||||
|
||||
class SiloTreeBuilder:
|
||||
"""Reads silo/ from a .kc ZIP and builds the document tree."""
|
||||
|
||||
@staticmethod
|
||||
def read_silo_directory(filename):
|
||||
"""Read silo/ entries from a .kc ZIP.
|
||||
|
||||
Returns dict {entry_name: bytes}, e.g. {"silo/manifest.json": b"..."}.
|
||||
Returns {} on failure.
|
||||
"""
|
||||
entries = {}
|
||||
try:
|
||||
with zipfile.ZipFile(filename, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("silo/") and not name.endswith("/"):
|
||||
entries[name] = zf.read(name)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_tree: could not read silo/ from {filename!r}: {exc}\n"
|
||||
)
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def build_tree(doc, silo_contents):
|
||||
"""Create the Silo group hierarchy in doc from silo/ entries."""
|
||||
if not silo_contents:
|
||||
return
|
||||
|
||||
SiloTreeBuilder.remove_silo_tree(doc)
|
||||
|
||||
root = doc.addObject("App::DocumentObjectGroup", _SILO_GROUP_NAME)
|
||||
root.Label = "Silo"
|
||||
|
||||
# Top-level known entries (conditional creation)
|
||||
for zip_name, obj_name, label, json_check in _KNOWN_ENTRIES:
|
||||
if zip_name not in silo_contents:
|
||||
continue
|
||||
data = silo_contents[zip_name]
|
||||
if not _should_create(data, json_check):
|
||||
continue
|
||||
_create_leaf(doc, root, zip_name, data, obj_name, label)
|
||||
|
||||
# Jobs subgroup
|
||||
job_entries = {
|
||||
k: v
|
||||
for k, v in silo_contents.items()
|
||||
if k.startswith("silo/jobs/") and not k.endswith("/")
|
||||
}
|
||||
if job_entries:
|
||||
jobs_group = doc.addObject("App::DocumentObjectGroup", "SiloJobs")
|
||||
jobs_group.Label = "Jobs"
|
||||
root.addObject(jobs_group)
|
||||
for entry_name in sorted(job_entries):
|
||||
basename = entry_name.split("/")[-1]
|
||||
safe_name = "SiloJob_" + basename.replace(".", "_").replace("-", "_")
|
||||
_create_leaf(
|
||||
doc,
|
||||
jobs_group,
|
||||
entry_name,
|
||||
job_entries[entry_name],
|
||||
safe_name,
|
||||
basename,
|
||||
)
|
||||
|
||||
# Macros subgroup
|
||||
macro_entries = {
|
||||
k: v
|
||||
for k, v in silo_contents.items()
|
||||
if k.startswith("silo/macros/") and not k.endswith("/")
|
||||
}
|
||||
if macro_entries:
|
||||
macros_group = doc.addObject("App::DocumentObjectGroup", "SiloMacros")
|
||||
macros_group.Label = "Macros"
|
||||
root.addObject(macros_group)
|
||||
for entry_name in sorted(macro_entries):
|
||||
basename = entry_name.split("/")[-1]
|
||||
label = basename[:-3] if basename.endswith(".py") else basename
|
||||
safe_name = "SiloMacro_" + basename.replace(".", "_").replace("-", "_")
|
||||
_create_leaf(
|
||||
doc,
|
||||
macros_group,
|
||||
entry_name,
|
||||
macro_entries[entry_name],
|
||||
safe_name,
|
||||
label,
|
||||
)
|
||||
|
||||
doc.recompute()
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"silo_tree: built tree with {len(silo_contents)} entries in {doc.Name!r}\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_silo_tree(doc):
|
||||
"""Remove the Silo group and all descendants. Safe if absent."""
|
||||
root = doc.getObject(_SILO_GROUP_NAME)
|
||||
if root is None:
|
||||
return
|
||||
|
||||
names = []
|
||||
|
||||
def _collect(obj):
|
||||
if obj.Name in names:
|
||||
return
|
||||
names.append(obj.Name)
|
||||
if hasattr(obj, "OutList"):
|
||||
for child in obj.OutList:
|
||||
_collect(child)
|
||||
|
||||
_collect(root)
|
||||
|
||||
for name in reversed(names):
|
||||
try:
|
||||
doc.removeObject(name)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_tree: could not remove {name!r}: {exc}\n"
|
||||
)
|
||||
77
src/Mod/Create/silo_viewproviders.py
Normal file
77
src/Mod/Create/silo_viewproviders.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
silo_viewproviders.py - ViewProvider proxy for Silo tree leaf nodes.
|
||||
|
||||
Controls tree icon, double-click behavior, and context menu for
|
||||
SiloViewerObject nodes in the document tree.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Icon directory — Phase 6 will add SVGs here.
|
||||
_ICON_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"resources",
|
||||
"icons",
|
||||
)
|
||||
|
||||
# Map silo/ paths to icon basenames (without .svg extension).
|
||||
_SILO_PATH_ICONS = {
|
||||
"silo/manifest.json": "silo-manifest",
|
||||
"silo/metadata.json": "silo-metadata",
|
||||
"silo/history.json": "silo-history",
|
||||
"silo/approvals.json": "silo-approvals",
|
||||
"silo/dependencies.json": "silo-dependencies",
|
||||
}
|
||||
|
||||
# Prefix-based fallbacks for subdirectory entries.
|
||||
_SILO_PREFIX_ICONS = {
|
||||
"silo/jobs/": "silo-job",
|
||||
"silo/macros/": "silo-macro",
|
||||
}
|
||||
|
||||
|
||||
def _icon_for_path(silo_path):
|
||||
"""Return absolute icon path for a silo/ entry, or '' if not found."""
|
||||
name = _SILO_PATH_ICONS.get(silo_path)
|
||||
if name is None:
|
||||
for prefix, icon_name in _SILO_PREFIX_ICONS.items():
|
||||
if silo_path.startswith(prefix):
|
||||
name = icon_name
|
||||
break
|
||||
if name is None:
|
||||
return ""
|
||||
path = os.path.join(_ICON_DIR, f"{name}.svg")
|
||||
return path if os.path.exists(path) else ""
|
||||
|
||||
|
||||
class SiloViewerViewProvider:
|
||||
"""ViewProvider proxy for SiloViewerObject leaf nodes."""
|
||||
|
||||
def __init__(self, vobj):
|
||||
vobj.Proxy = self
|
||||
self.Object = vobj.Object
|
||||
|
||||
def attach(self, vobj):
|
||||
"""Store back-reference; called on document restore."""
|
||||
self.Object = vobj.Object
|
||||
|
||||
def getIcon(self):
|
||||
"""Return icon path based on SiloPath; '' uses FreeCAD default."""
|
||||
try:
|
||||
return _icon_for_path(self.Object.SiloPath)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def doubleClicked(self, vobj):
|
||||
"""Phase 1: no action on double-click."""
|
||||
return False
|
||||
|
||||
def setupContextMenu(self, vobj, menu):
|
||||
"""Phase 1: no context menu items."""
|
||||
pass
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
Reference in New Issue
Block a user