You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

495 lines
13 KiB
Python

# FreeCADExchange template semantics helpers.
import json
from pathlib import Path
import FreeCAD as App
import TerminalObjects as TerminalObjects
STORED_TEMPLATE_SLOTS_PROPERTY = "QetTemplateSlotsJson"
def _sidecar_candidates(model_path):
native = TerminalObjects.native_path(model_path)
if not native:
return []
path = Path(native)
base = path.stem
parent = path.parent
return [
parent / (base + ".terminals.json"),
parent / (base + ".qet_terminals.json"),
parent / (base + ".terminal_slots.json"),
parent / (base + ".qet_template.json"),
]
def _load_json_file(path):
try:
raw_text = path.read_text(encoding="utf-8")
except OSError:
return None
try:
payload = json.loads(raw_text)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
return payload
def _vector_from_value(value):
if isinstance(value, App.Vector):
return value
if isinstance(value, dict):
if {"x", "y", "z"}.issubset(value.keys()):
return App.Vector(
float(value["x"]),
float(value["y"]),
float(value["z"]),
)
if isinstance(value, (list, tuple)) and len(value) >= 3:
return App.Vector(float(value[0]), float(value[1]), float(value[2]))
return None
def _rotation_from_value(value):
if not isinstance(value, dict):
return None
axis = _vector_from_value(value.get("axis"))
angle = value.get("angle")
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _rotation_from_object(source_object):
if source_object is None:
return None
rotation = None
placement = getattr(source_object, "Placement", None)
if placement is not None:
rotation = getattr(placement, "Rotation", None)
if rotation is None:
rotation = getattr(source_object, "Rotation", None)
if rotation is None:
return None
axis = getattr(rotation, "Axis", None)
if axis is None:
axis = getattr(rotation, "axis", None)
angle = getattr(rotation, "Angle", None)
if angle is None:
angle = getattr(rotation, "angle", None)
axis = _vector_from_value(axis)
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _vector_to_payload(value):
vector = _vector_from_value(value)
if vector is None:
return None
return [float(vector.x), float(vector.y), float(vector.z)]
def _rotation_to_payload(value):
if not isinstance(value, dict):
return None
axis = _vector_to_payload(value.get("axis"))
angle = value.get("angle")
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _slot_to_payload(slot):
if not isinstance(slot, dict):
return None
base = _vector_to_payload(slot.get("base"))
if base is None:
return None
name = (slot.get("name") or slot.get("label") or "").strip()
label = (slot.get("label") or name).strip()
payload = {
"name": name,
"label": label,
"base": base,
}
rotation = _rotation_to_payload(slot.get("rotation"))
if rotation is not None:
payload["rotation"] = rotation
return payload
def _stored_template_slot_payloads(container):
raw_text = getattr(container, STORED_TEMPLATE_SLOTS_PROPERTY, "") if container is not None else ""
if not isinstance(raw_text, str) or not raw_text.strip():
return []
try:
payload = json.loads(raw_text)
except (TypeError, ValueError):
return []
if isinstance(payload, dict):
entries = payload.get("terminal_slots")
elif isinstance(payload, list):
entries = payload
else:
entries = None
if not isinstance(entries, list):
return []
return entries
def clear_stored_template_slot_hints(container):
if container is None:
return
if STORED_TEMPLATE_SLOTS_PROPERTY in getattr(container, "PropertiesList", []):
setattr(container, STORED_TEMPLATE_SLOTS_PROPERTY, "")
def store_template_slot_hints(container, slots):
if container is None:
return 0
payloads = []
for slot in slots or []:
payload = _slot_to_payload(slot)
if payload is not None:
payloads.append(payload)
raw_text = ""
if payloads:
raw_text = json.dumps(
{"terminal_slots": payloads},
ensure_ascii=False,
sort_keys=True,
)
TerminalObjects.ensure_string_property(
container,
STORED_TEMPLATE_SLOTS_PROPERTY,
"QET Exchange",
"Serialized FCStd template terminal slots",
raw_text,
)
return len(payloads)
def _slot_from_payload(item, source, index, source_object=None):
if not isinstance(item, dict):
return None
name = (item.get("name") or item.get("slot_name") or item.get("label") or "").strip()
if not name:
name = "SLOT_{0}".format(index + 1)
label = (item.get("label") or item.get("display_tag") or name).strip()
base = None
placement = item.get("placement")
if isinstance(placement, dict):
base = _vector_from_value(placement.get("base"))
if base is None:
base = _vector_from_value(item.get("base"))
if base is None:
base = _vector_from_value(item.get("position"))
if base is None:
base = _vector_from_value(item.get("origin"))
if base is None:
base = _vector_from_value(item.get("point"))
if base is None:
x = item.get("x")
y = item.get("y")
z = item.get("z")
if x is not None and y is not None and z is not None:
base = App.Vector(float(x), float(y), float(z))
if base is None:
return None
rotation = None
placement_rotation = None
placement = item.get("placement")
if isinstance(placement, dict):
placement_rotation = _rotation_from_value(placement.get("rotation"))
rotation = _rotation_from_value(item.get("rotation"))
if rotation is None:
rotation = placement_rotation
if rotation is None:
rotation = _rotation_from_object(source_object)
slot = {
"name": name,
"label": label,
"base": base,
"source": source,
"source_object": source_object,
}
if rotation is not None:
slot["rotation"] = rotation
return slot
def _is_child_group(obj, group_kind):
try:
return (
obj is not None
and obj.isDerivedFrom("App::DocumentObjectGroup")
and getattr(obj, "QetGroupKind", "").strip() == group_kind
)
except Exception:
return False
def _collect_stored_terminal_hints(container):
hints = []
for index, item in enumerate(_stored_template_slot_payloads(container)):
hint = _slot_from_payload(item, "model", index, source_object=None)
if hint is not None:
hints.append(hint)
return hints
def _collect_terminal_hints(container, include_stored):
hints = []
if container is None:
return hints
if include_stored:
hints.extend(_collect_stored_terminal_hints(container))
for child in list(getattr(container, "Group", []) or []):
if TerminalObjects.is_terminal_hint_object(child) and not TerminalObjects.is_terminal_object(child):
slot_name = (
getattr(child, "QetTemplateSlotName", "")
or getattr(child, "Name", "")
)
hint = _slot_from_payload(
{
"name": slot_name,
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
"base": [
getattr(child.Placement.Base, "x", 0.0),
getattr(child.Placement.Base, "y", 0.0),
getattr(child.Placement.Base, "z", 0.0),
],
},
"model",
len(hints),
source_object=child,
)
if hint is not None:
hints.append(hint)
continue
if _is_child_group(child, TerminalObjects.TERMINAL_GROUP_KIND):
continue
if _is_child_group(child, TerminalObjects.WIRE_GROUP_KIND):
continue
if hasattr(child, "Group"):
hints.extend(_collect_terminal_hints(child, include_stored))
hints.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return hints
def collect_terminal_hints(container):
return _collect_terminal_hints(container, include_stored=True)
def collect_live_terminal_hints(container):
return _collect_terminal_hints(container, include_stored=False)
def collect_model_bounding_box(container):
boxes = []
def walk(obj):
if obj is None:
return
if TerminalObjects.is_terminal_hint_object(obj):
return
if _is_child_group(obj, TerminalObjects.TERMINAL_GROUP_KIND):
return
if _is_child_group(obj, TerminalObjects.WIRE_GROUP_KIND):
return
shape = getattr(obj, "Shape", None)
if shape is not None:
try:
if not shape.isNull():
boxes.append(shape.BoundBox)
except Exception:
pass
for child in list(getattr(obj, "Group", []) or []):
walk(child)
walk(container)
if not boxes:
return None
x_min = min(box.XMin for box in boxes)
y_min = min(box.YMin for box in boxes)
z_min = min(box.ZMin for box in boxes)
x_max = max(box.XMax for box in boxes)
y_max = max(box.YMax for box in boxes)
z_max = max(box.ZMax for box in boxes)
return {
"x_min": x_min,
"y_min": y_min,
"z_min": z_min,
"x_max": x_max,
"y_max": y_max,
"z_max": z_max,
"x_span": x_max - x_min,
"y_span": y_max - y_min,
"z_span": z_max - z_min,
"x_center": (x_min + x_max) * 0.5,
"y_center": (y_min + y_max) * 0.5,
"z_center": (z_min + z_max) * 0.5,
}
def _fallback_slot_base(bbox, index, count):
if bbox is None:
step = 8.0
return App.Vector(20.0, float(index) * step, 0.0)
span_x = max(bbox["x_span"], 1.0)
span_y = max(bbox["y_span"], 1.0)
span_z = max(bbox["z_span"], 1.0)
offset = max(8.0, max(span_x, span_y, span_z) * 0.15)
if count <= 1:
y_pos = bbox["y_center"]
z_pos = bbox["z_center"]
else:
if span_y >= span_z:
step = span_y / float(count + 1)
y_pos = bbox["y_min"] + step * float(index + 1)
z_pos = bbox["z_center"]
else:
step = span_z / float(count + 1)
y_pos = bbox["y_center"]
z_pos = bbox["z_min"] + step * float(index + 1)
return App.Vector(bbox["x_max"] + offset, y_pos, z_pos)
def build_fallback_terminal_slots(container, count):
bbox = collect_model_bounding_box(container)
slots = []
for index in range(max(0, count)):
slots.append(
{
"name": "SLOT_{0}".format(index + 1),
"label": "SLOT_{0}".format(index + 1),
"base": _fallback_slot_base(bbox, index, count),
"source": "fallback",
"source_object": None,
}
)
return slots
def load_sidecar_terminal_slots(model_path):
for sidecar_path in _sidecar_candidates(model_path):
if not sidecar_path.is_file():
continue
payload = _load_json_file(sidecar_path)
if payload is None:
continue
entries = payload.get("terminal_slots")
if entries is None:
entries = payload.get("terminals")
if not isinstance(entries, list):
continue
slots = []
for index, item in enumerate(entries):
slot = _slot_from_payload(item, "sidecar", index)
if slot is not None:
slots.append(slot)
if slots:
slots.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return slots
return []
def resolve_terminal_slots(device_group, model_path, desired_count):
desired_count = max(0, int(desired_count or 0))
hints = collect_terminal_hints(device_group)
sidecar_slots = load_sidecar_terminal_slots(model_path)
slots = []
slots.extend(hints[:desired_count])
if len(slots) < desired_count and sidecar_slots:
for slot in sidecar_slots:
if len(slots) >= desired_count:
break
slots.append(slot)
if len(slots) < desired_count:
needed = desired_count - len(slots)
fallback = build_fallback_terminal_slots(device_group, needed)
if fallback:
slots.extend(fallback)
return slots[:desired_count]