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