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
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]
|