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.

541 lines
16 KiB
Python

# FreeCADExchange manual wiring helpers.
import json
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
import TerminalObjects as TerminalObjects
import WiringObjects
try:
import DeviceImport
except Exception:
DeviceImport = None
class ManualWiringError(RuntimeError):
pass
def _append_debug_log(message):
if DeviceImport is None:
return
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _vector_from_point(point):
if isinstance(point, App.Vector):
return point
if isinstance(point, (list, tuple)) and len(point) >= 3:
return App.Vector(float(point[0]), float(point[1]), float(point[2]))
return None
def _vector_length(vector):
return (
float(getattr(vector, "x", 0.0)) ** 2
+ float(getattr(vector, "y", 0.0)) ** 2
+ float(getattr(vector, "z", 0.0)) ** 2
) ** 0.5
def _normalize_vector(vector, fallback=None):
length = _vector_length(vector)
if length <= 0.000001:
return fallback or App.Vector(0, 0, 1)
return App.Vector(
float(getattr(vector, "x", 0.0)) / length,
float(getattr(vector, "y", 0.0)) / length,
float(getattr(vector, "z", 0.0)) / length,
)
def _offset_point(point, direction, distance):
return App.Vector(
float(getattr(point, "x", 0.0)) + float(getattr(direction, "x", 0.0)) * distance,
float(getattr(point, "y", 0.0)) + float(getattr(direction, "y", 0.0)) * distance,
float(getattr(point, "z", 0.0)) + float(getattr(direction, "z", 0.0)) * distance,
)
def _vector_close(left, right, tolerance=0.000001):
return (
abs(float(getattr(left, "x", 0.0)) - float(getattr(right, "x", 0.0))) <= tolerance
and abs(float(getattr(left, "y", 0.0)) - float(getattr(right, "y", 0.0))) <= tolerance
and abs(float(getattr(left, "z", 0.0)) - float(getattr(right, "z", 0.0))) <= tolerance
)
def _axis_value(vector, axis):
return float(getattr(vector, axis, 0.0))
def _vector_with_axis(vector, axis, value):
return App.Vector(
float(value) if axis == "x" else float(getattr(vector, "x", 0.0)),
float(value) if axis == "y" else float(getattr(vector, "y", 0.0)),
float(value) if axis == "z" else float(getattr(vector, "z", 0.0)),
)
def _dominant_axis(vector):
if vector is None:
return None
components = {
"x": abs(float(getattr(vector, "x", 0.0))),
"y": abs(float(getattr(vector, "y", 0.0))),
"z": abs(float(getattr(vector, "z", 0.0))),
}
axis = max(components, key=components.get)
if components[axis] <= 0.000001:
return None
return axis
def _coerce_waypoint(point_like):
if isinstance(point_like, dict):
point = _vector_from_point(
point_like.get("point")
or point_like.get("base")
or point_like.get("position")
or point_like.get("origin")
)
if point is None and {"x", "y", "z"}.issubset(set(point_like.keys())):
point = App.Vector(
float(point_like.get("x", 0.0)),
float(point_like.get("y", 0.0)),
float(point_like.get("z", 0.0)),
)
if point is None:
return None
support_axis = (point_like.get("support_axis", "") or "").strip().lower()
if support_axis not in {"x", "y", "z"}:
support_axis = None
support_normal = _vector_from_point(point_like.get("support_normal"))
if support_axis is None:
support_axis = _dominant_axis(support_normal)
return {
"point": point,
"support_axis": support_axis,
"anchor_kind": (point_like.get("anchor_kind", "") or "").strip(),
"source_label": (point_like.get("source_label", "") or "").strip(),
"subelement_name": (point_like.get("subelement_name", "") or "").strip(),
}
point = _vector_from_point(point_like)
if point is None:
return None
return {
"point": point,
"support_axis": None,
"anchor_kind": "",
"source_label": "",
"subelement_name": "",
}
def _orthogonal_segment_points(start_point, end_point, preferred_axis=None):
if _vector_close(start_point, end_point):
return [start_point]
axis_order = []
if preferred_axis in {"x", "y", "z"}:
axis_order.append(preferred_axis)
remaining = sorted(
("x", "y", "z"),
key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)),
reverse=True,
)
for axis in remaining:
if axis not in axis_order:
axis_order.append(axis)
points = [start_point]
current = start_point
for axis in axis_order:
target = _axis_value(end_point, axis)
if abs(_axis_value(current, axis) - target) <= 0.000001:
continue
current = _vector_with_axis(current, axis, target)
if not _vector_close(current, points[-1]):
points.append(current)
if not _vector_close(points[-1], end_point):
points.append(end_point)
return points
def _terminal_exit_direction(terminal):
placement = None
try:
if hasattr(terminal, "getGlobalPlacement"):
placement = terminal.getGlobalPlacement()
except Exception:
placement = None
if placement is None:
placement = getattr(terminal, "Placement", None)
rotation = getattr(placement, "Rotation", None)
if rotation is not None:
try:
return _normalize_vector(rotation.multVec(App.Vector(0, 0, 1)))
except Exception:
pass
return App.Vector(0, 0, 1)
def _manual_waypoints_payload(waypoints):
payload = []
for waypoint in waypoints or []:
point = waypoint.get("point")
if point is None:
continue
payload.append(
{
"point": {
"x": float(getattr(point, "x", 0.0)),
"y": float(getattr(point, "y", 0.0)),
"z": float(getattr(point, "z", 0.0)),
},
"support_axis": waypoint.get("support_axis", ""),
"anchor_kind": waypoint.get("anchor_kind", ""),
"source_label": waypoint.get("source_label", ""),
"subelement_name": waypoint.get("subelement_name", ""),
}
)
return payload
def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit_length=0.0):
start_origin = TerminalObjects.terminal_origin(start_terminal)
end_origin = TerminalObjects.terminal_origin(end_terminal)
exit_length = max(float(terminal_exit_length or 0.0), 0.0)
points = [start_origin]
normalized_waypoints = []
if exit_length > 0:
points.append(
_offset_point(
start_origin,
_terminal_exit_direction(start_terminal),
exit_length,
)
)
for point_like in waypoints or []:
waypoint = _coerce_waypoint(point_like)
if waypoint is None:
continue
normalized_waypoints.append(waypoint)
if not _vector_close(points[-1], waypoint["point"]):
points.append(waypoint["point"])
if exit_length > 0:
end_exit = _offset_point(
end_origin,
_terminal_exit_direction(end_terminal),
exit_length,
)
if not _vector_close(points[-1], end_exit):
points.append(end_exit)
if len(points) < 2 or not _vector_close(points[-1], end_origin):
points.append(end_origin)
return points, normalized_waypoints
def _wire_object_name(start_terminal, end_terminal):
start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", ""))
end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", ""))
return "QETWire_{0}_{1}".format(start_uuid, end_uuid)
def _set_wire_properties(
obj,
project_uuid,
start_terminal,
end_terminal,
wire_uuid="",
wire_label="",
net_uuid="",
group_uuid="",
wire_mark="",
wire_mark_is_manual=False,
manual_waypoints=None,
):
WiringObjects.set_routed_wire_semantics(
obj,
project_uuid,
wire_uuid,
wire_label,
getattr(start_terminal, "QetTerminalUuid", "").strip(),
getattr(end_terminal, "QetTerminalUuid", "").strip(),
getattr(start_terminal, "QetInstanceId", "").strip(),
getattr(end_terminal, "QetInstanceId", "").strip(),
route_type="Manual",
route_status="Routed",
route_mode="Manual",
net_uuid=net_uuid,
group_uuid=group_uuid,
wire_mark=wire_mark,
wire_mark_is_manual=wire_mark_is_manual,
)
try:
if "QetManualWaypointsJson" not in getattr(obj, "PropertiesList", []):
obj.addProperty(
"App::PropertyString",
"QetManualWaypointsJson",
"QET Wiring",
"Manual waypoint metadata",
)
obj.QetManualWaypointsJson = json.dumps(
_manual_waypoints_payload(manual_waypoints or []),
ensure_ascii=False,
)
except Exception:
pass
def _set_wire_points(obj, points):
try:
if "Points" not in getattr(obj, "PropertiesList", []):
obj.addProperty(
"App::PropertyVectorList",
"Points",
"QET Wiring",
"Manual route points",
)
except Exception:
pass
try:
obj.Points = list(points)
except Exception:
pass
def _create_wire_geometry(doc, wire_name, points):
if getattr(App, "ActiveDocument", None) is doc:
try:
import Draft
wire_obj = Draft.make_wire(
points,
closed=False,
placement=None,
face=None,
support=None,
bs2wire=False,
)
if wire_obj is not None:
_set_wire_points(wire_obj, points)
return wire_obj
except Exception as exc:
_append_debug_log("Draft wire creation failed, falling back to Part polygon: {0}".format(exc))
import Part
wire_obj = doc.addObject("Part::Feature", wire_name)
wire_obj.Shape = Part.makePolygon(points)
_set_wire_points(wire_obj, points)
return wire_obj
def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None):
for terminal in (start_terminal, end_terminal):
element_uuid = getattr(terminal, "QetElementUuid", "").strip()
if not element_uuid:
continue
device_group = TerminalObjects.find_device_group(doc, element_uuid)
if device_group is None:
continue
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
return TerminalObjects.ensure_wire_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=device_instance_id,
)
return fallback_group
def create_manual_wire(
doc,
start_terminal,
end_terminal,
waypoints=None,
parent_group=None,
terminal_exit_length=0.0,
wire_uuid="",
wire_label="",
net_uuid="",
group_uuid="",
wire_mark="",
wire_mark_is_manual=False,
):
if not TerminalObjects.is_terminal_object(start_terminal):
raise ManualWiringError("The start selection is not a valid terminal.")
if not TerminalObjects.is_terminal_object(end_terminal):
raise ManualWiringError("The end selection is not a valid terminal.")
if start_terminal == end_terminal:
raise ManualWiringError("The start and end terminal must be different.")
project_uuid = (
getattr(start_terminal, "QetProjectUuid", "").strip()
or getattr(end_terminal, "QetProjectUuid", "").strip()
or getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
)
if not project_uuid:
raise ManualWiringError("A project UUID is required to create a wire.")
wire_base_name = TerminalObjects.safe_token(
_wire_object_name(start_terminal, end_terminal)
)
wire_name = wire_base_name
suffix = 1
while doc.getObject(wire_name) is not None:
wire_name = "{0}_{1}".format(wire_base_name, suffix)
suffix += 1
points, normalized_waypoints = _terminal_points(
start_terminal,
end_terminal,
waypoints=waypoints,
terminal_exit_length=terminal_exit_length,
)
if len(points) < 2:
raise ManualWiringError("A wire requires at least two points.")
wire_obj = _create_wire_geometry(doc, wire_name, points)
wire_obj.Label = wire_label or "QET Manual Wire"
_set_wire_properties(
wire_obj,
project_uuid,
start_terminal,
end_terminal,
wire_uuid=wire_uuid,
wire_label=wire_label,
net_uuid=net_uuid,
group_uuid=group_uuid,
wire_mark=wire_mark,
wire_mark_is_manual=wire_mark_is_manual,
manual_waypoints=normalized_waypoints,
)
routed_group = WiringObjects.ensure_routed_group(doc, project_uuid)
if wire_obj not in getattr(routed_group, "Group", []):
routed_group.addObject(wire_obj)
if parent_group is None:
try:
parent_group = _wire_parent_group(
doc,
project_uuid,
start_terminal,
end_terminal,
fallback_group=TerminalObjects.ensure_root_group(doc, project_uuid),
)
except Exception:
parent_group = None
if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []):
parent_group.addObject(wire_obj)
if parent_group is not None:
try:
if (
getattr(parent_group, "Name", "").startswith(TerminalObjects.WIRE_GROUP_PREFIX)
or (getattr(parent_group, "QetGroupKind", "") or "").strip()
== TerminalObjects.WIRE_GROUP_KIND
):
parent_group.ViewObject.Visibility = False
except Exception:
pass
try:
wire_obj.ViewObject.LineWidth = 2.0
except Exception:
pass
try:
wire_obj.ViewObject.LineColor = (0.0, 0.6, 1.0)
except Exception:
pass
doc.recompute()
return wire_obj
class CommandCreateManualWire:
def GetResources(self):
return {
"MenuText": "连接选中端子",
"ToolTip": "在两个选中的工程端子之间创建手动导线",
}
def IsActive(self):
return App.ActiveDocument is not None and Gui is not None
def Activated(self):
if Gui is None:
return
selection = [
obj
for obj in Gui.Selection.getSelection()
if TerminalObjects.is_terminal_object(obj)
]
if len(selection) != 2:
try:
App.Console.PrintWarning(
"Select exactly two valid terminals before creating a wire.\n"
)
except Exception:
pass
return
try:
create_manual_wire(App.ActiveDocument, selection[0], selection[1])
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError(
"[FreeCADExchange] manual wire creation failed: {0}\n".format(exc)
)
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None:
return
try:
Gui.addCommand("QET_Exchange_CreateManualWire", CommandCreateManualWire())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register manual wiring command: {0}".format(exc))
register_commands()