All checks were successful
Build and Test / build (pull_request) Successful in 29m51s
Expose AssemblyObject::getSolveContext() to Python and hook into the .kc save flow so that silo/solver/context.json is packed into every assembly archive. This lets server-side solver runners operate on pre-extracted constraint graphs without a full FreeCAD installation. Changes: - Add public getSolveContext() to AssemblyObject (C++ and Python) - Build Python dict via CPython C API matching kcsolve.SolveContext.to_dict() - Register _solver_context_hook in kc_format.py pre-reinject hooks - Add silo/solver/context.json to silo_tree.py _KNOWN_ENTRIES
236 lines
7.2 KiB
Python
236 lines
7.2 KiB
Python
"""
|
|
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),
|
|
),
|
|
(
|
|
"silo/solver/context.json",
|
|
"SiloSolverContext",
|
|
"Solver Context",
|
|
("parts", 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"
|
|
)
|