added motion trajectory simulation
This commit is contained in:
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -41,8 +42,16 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, Qt # noqa: E402
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap, QPolygon # noqa: E402
|
||||
from PySide6.QtCore import QPoint, QPointF, QRect, Qt # noqa: E402
|
||||
from PySide6.QtGui import ( # noqa: E402
|
||||
QColor,
|
||||
QFont,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygon,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------- geometry ---
|
||||
@@ -157,7 +166,6 @@ STRINGS = {
|
||||
"finger_initial_view": "初始视角",
|
||||
"finger_reset": "复位视角",
|
||||
"finger_textured_note": "UV 贴图:patch_texture_compute.png",
|
||||
"finger_axes_note": "黄色为工具轴 · X/Y 为工作台,Z 为工具行程 · 绿=对齐,红=不可对齐",
|
||||
"finger_bundle_summary": "1032 顶点 · 1974 三角面 · 15.00 × 7.50 × 15.81 mm · 0.018388 mm/单位",
|
||||
"finger_flat_title": "展平图像(点击取点)",
|
||||
"finger_pick_hint": "点击传感面上的像素即可取点;落在 UV 缝隙上的像素会被拒绝。",
|
||||
@@ -166,21 +174,32 @@ STRINGS = {
|
||||
["传感器 (mm)", "2.841, 3.512, 0.940", None],
|
||||
["法向", "-0.18, -0.88, 0.43", None],
|
||||
["平台 (mm)", "2.841, 0.940, -3.512", None],
|
||||
["X / Y (mm)", "2.841, 0.940", None],
|
||||
["yaw / pitch", "-78.42° / 23.10°", None],
|
||||
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
|
||||
["对轴残差", "0.03° 可对轴", "#12b76a"],
|
||||
["坐标系映射", "x→x, y→z, z→-y", None],
|
||||
],
|
||||
"finger_plate_label": "安装板厚 (mm)",
|
||||
"finger_plate_value": "0.000",
|
||||
"finger_tool_label": "刀具 X / Y",
|
||||
"finger_tool_values": ["0.000", "0.000"],
|
||||
"finger_sensor_xy_label": "传感器 X / Y",
|
||||
"finger_sensor_xy_values": ["0.000", "0.000"],
|
||||
"finger_sensor_zy_label": "传感器 Z / yaw",
|
||||
"finger_sensor_zy_values": ["0.000", "0.000"],
|
||||
"finger_preview_title": "三维预览",
|
||||
"finger_preview_legend": "曲面颜色 = 对轴残差(绿为对正,红为对不上) · 黄色为 Z 轴",
|
||||
"finger_point_cols": ["#", "像素 u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
|
||||
"finger_point_rows": [
|
||||
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
|
||||
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
|
||||
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
|
||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||
"finger_play": "播放动作",
|
||||
"finger_preview_notes": [
|
||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||
"青色线 = 规划路径;紫圈 = 转台当前姿态、浅紫圈 = 结束姿态,与固定方格网对比即看出整个变换;白点+蓝线 = 接触点及法向。",
|
||||
],
|
||||
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "残差"],
|
||||
"finger_point_rows": [
|
||||
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
|
||||
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
|
||||
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
|
||||
],
|
||||
"finger_table_buttons": [("删除所选", False), ("上移", False), ("下移", False), ("清空标定点", False)],
|
||||
"finger_bad_word": "撞 yaw 行程限位",
|
||||
"finger_point_count": "3 个取点 · 展平 1024 × 1024 · 有效像素 72.9%",
|
||||
"sensor_note": "设备与信号源、实时图像、力曲线",
|
||||
@@ -284,7 +303,6 @@ STRINGS = {
|
||||
"finger_initial_view": "Initial view",
|
||||
"finger_reset": "Reset view",
|
||||
"finger_textured_note": "UV texture: patch_texture_compute.png",
|
||||
"finger_axes_note": "Yellow is the tool axis · X/Y are the table, Z the tool travel · green aligned, red not",
|
||||
"finger_bundle_summary": "1032 vertices · 1974 triangles · 15.00 × 7.50 × 15.81 mm · 0.018388 mm per unit",
|
||||
"finger_flat_title": "Flat image (click to pick)",
|
||||
"finger_pick_hint": "Click a pixel on the sensing surface to pick a point; pixels in a UV gap are refused.",
|
||||
@@ -293,21 +311,33 @@ STRINGS = {
|
||||
["Sensor (mm)", "2.841, 3.512, 0.940", None],
|
||||
["Normal", "-0.18, -0.88, 0.43", None],
|
||||
["Platform (mm)", "2.841, 0.940, -3.512", None],
|
||||
["X / Y (mm)", "2.841, 0.940", None],
|
||||
["yaw / pitch", "-78.42° / 23.10°", None],
|
||||
["Pose X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||
["Table move X / Y", "+2.841 -0.940 mm", None],
|
||||
["Residual", "0.03° can align", "#12b76a"],
|
||||
["Frame map", "x→x, y→z, z→-y", None],
|
||||
],
|
||||
"finger_plate_label": "Mounting plate (mm)",
|
||||
"finger_plate_value": "0.000",
|
||||
"finger_tool_label": "Tool X / Y",
|
||||
"finger_tool_values": ["0.000", "0.000"],
|
||||
"finger_sensor_xy_label": "Sensor X / Y",
|
||||
"finger_sensor_xy_values": ["0.000", "0.000"],
|
||||
"finger_sensor_zy_label": "Sensor Z / yaw",
|
||||
"finger_sensor_zy_values": ["0.000", "0.000"],
|
||||
"finger_preview_title": "3D preview",
|
||||
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
|
||||
"finger_point_cols": ["#", "pixel u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
|
||||
"finger_point_rows": [
|
||||
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
|
||||
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
|
||||
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
|
||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||
"finger_play": "Play motion",
|
||||
"finger_preview_notes": [
|
||||
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
|
||||
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
|
||||
"Cyan line = the planned path; the violet ring is the table's pose now and the faint one where it finishes, against the fixed grid that is where it started; white dot with blue line = the contact and its normal.",
|
||||
],
|
||||
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
|
||||
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "residual"],
|
||||
"finger_point_rows": [
|
||||
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
|
||||
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
|
||||
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
|
||||
],
|
||||
"finger_table_buttons": [("Remove selected", False), ("Move up", False), ("Move down", False), ("Clear points", False)],
|
||||
"finger_bad_word": "hits yaw travel limit",
|
||||
"finger_point_count": "3 picked points · flat 1024 × 1024 · 72.9% valid pixels",
|
||||
"sensor_note": "Device and source, live image, force curves",
|
||||
@@ -352,6 +382,28 @@ def _font(size: int = 11, *, bold: bool = False) -> QFont:
|
||||
return font
|
||||
|
||||
|
||||
def _arrowhead(painter: QPainter, path: QPainterPath, share: float, colour: QColor, size: int) -> None:
|
||||
"""An arrowhead on ``path`` at ``share``, pointing the way the path runs.
|
||||
|
||||
The direction comes from two nearby points on the path rather than from
|
||||
``angleAtPercent``: that angle is measured counter-clockwise from +x while Qt's
|
||||
y axis grows downward, and every attempt to reconcile the two by hand has
|
||||
produced an arrow pointing backwards.
|
||||
"""
|
||||
tip = path.pointAtPercent(share)
|
||||
ahead = path.pointAtPercent(min(1.0, share + 0.01))
|
||||
dx, dy = ahead.x() - tip.x(), ahead.y() - tip.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / length, dy / length
|
||||
painter.setPen(QPen(colour, 2))
|
||||
for sign in (1.0, -1.0):
|
||||
barb = QPointF(
|
||||
tip.x() - ux * size + sign * -uy * size * 0.6,
|
||||
tip.y() - uy * size + sign * ux * size * 0.6,
|
||||
)
|
||||
painter.drawLine(tip, barb)
|
||||
|
||||
|
||||
def _panel(painter: QPainter, rect: QRect, palette: Palette) -> QRect:
|
||||
painter.fillRect(rect, palette.panel)
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
@@ -1008,45 +1060,141 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
_text(painter, QRect(inner_read.x(), row_y, label_w, 20), label, palette, size=9, muted=True)
|
||||
_text(painter, QRect(inner_read.x() + label_w, row_y, inner_read.width() - label_w, 20), value, palette, size=9, colour=QColor(colour) if colour else None)
|
||||
row_y += 20
|
||||
_text(painter, QRect(inner_read.x(), inner_read.bottom() - 36, inner_read.width(), 18), s["finger_plate_label"], palette, size=9, muted=True)
|
||||
_field(painter, QRect(inner_read.x(), inner_read.bottom() - 20, inner_read.width(), 20), s["finger_plate_value"], palette)
|
||||
# The setup and tool fields: pairs of numbers, as in the app, where they are
|
||||
# spin boxes two to a row because they get dialled in against the real machine.
|
||||
pairs = (
|
||||
("finger_tool_label", "finger_tool_values"),
|
||||
("finger_sensor_xy_label", "finger_sensor_xy_values"),
|
||||
("finger_sensor_zy_label", "finger_sensor_zy_values"),
|
||||
)
|
||||
pair_y = row_y + 4
|
||||
for label_key, values_key in pairs:
|
||||
_text(painter, QRect(inner_read.x(), pair_y, label_w, 20), s[label_key], palette, size=9, muted=True)
|
||||
field_w = int((inner_read.width() - label_w - 6) / 2)
|
||||
for index, value in enumerate(s[values_key][:2]):
|
||||
_field(
|
||||
painter,
|
||||
QRect(inner_read.x() + label_w + index * (field_w + 6), pair_y, field_w, 20),
|
||||
value,
|
||||
palette,
|
||||
)
|
||||
pair_y += 22
|
||||
|
||||
# 3D preview: the tool axis and a normal with its residual.
|
||||
# 3D preview: the machine's frame at the table origin, the sensor on it, and the
|
||||
# planned path. Schematic, but the parts and their colours are the ones the real
|
||||
# view draws — a proposal that disagrees with the app is worse than no proposal.
|
||||
inner_preview = _panel(painter, preview, palette)
|
||||
_text(painter, QRect(inner_preview.x(), inner_preview.y(), inner_preview.width(), 18), s["finger_preview_title"], palette, size=10, bold=True)
|
||||
view = QRect(inner_preview.x(), inner_preview.y() + 22, inner_preview.width(), inner_preview.height() - 22 - 34)
|
||||
checks_h = 20
|
||||
play_h = 28
|
||||
notes_h = 3 * 14 + 4
|
||||
view = QRect(
|
||||
inner_preview.x(),
|
||||
inner_preview.y() + 22,
|
||||
inner_preview.width(),
|
||||
inner_preview.height() - 22 - checks_h - play_h - notes_h,
|
||||
)
|
||||
painter.fillRect(view, QColor("#12161c"))
|
||||
painter.setPen(QPen(QColor("#2a313c"), 1))
|
||||
for i in range(1, 4):
|
||||
painter.drawLine(view.x() + i * view.width() // 4, view.y(), view.x() + i * view.width() // 4, view.bottom())
|
||||
cx, cy = view.center().x(), view.center().y() + 40
|
||||
axis = QPoint(cx, view.y() + 24)
|
||||
painter.setPen(QPen(QColor("#f2c94c"), 3))
|
||||
painter.drawLine(axis, QPoint(cx, cy))
|
||||
painter.setBrush(QColor("#f2c94c"))
|
||||
painter.drawEllipse(QPoint(cx, cy), 3, 3)
|
||||
# The motion axes: the platform frame, drawn in the sensor frame.
|
||||
|
||||
cx, cy = view.center().x(), view.center().y() + int(view.height() * 0.12)
|
||||
# The table origin's plane: a grid, not a filled surface.
|
||||
half = int(view.width() * 0.30)
|
||||
floor = QRect(cx - half, cy - int(half * 0.26), 2 * half, int(half * 0.52))
|
||||
painter.setPen(QPen(QColor("#8c95ab"), 1))
|
||||
for index in range(5):
|
||||
fx = floor.x() + index * floor.width() // 4
|
||||
fy = floor.y() + index * floor.height() // 4
|
||||
painter.drawLine(fx, floor.y(), fx, floor.bottom())
|
||||
painter.drawLine(floor.x(), fy, floor.right(), fy)
|
||||
|
||||
# The sensor: the sensing face as an arc, the shell below it as a wireframe-ish
|
||||
# outline. Grey, because the surface carries the residual colour instead.
|
||||
dome_w = int(view.width() * 0.34)
|
||||
dome_h = int(view.height() * 0.34)
|
||||
body = QRect(cx - dome_w // 2, cy - dome_h, dome_w, dome_h + int(dome_h * 0.45))
|
||||
painter.setPen(QPen(QColor("#5b6273"), 1))
|
||||
painter.setBrush(QColor("#26b847"))
|
||||
painter.drawChord(QRect(body.x(), body.y(), body.width(), body.height() * 2 // 3), 0, 180 * 16)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(QRect(body.x(), body.y() + body.height() // 4, body.width(), body.height() // 2))
|
||||
|
||||
# The base plane, lifted off the origin by the sensor's z setup offset (the
|
||||
# mounting plate), and the table's own frame: a circle at the pose it ends in,
|
||||
# against the square grid that is where it started.
|
||||
base = QRect(body.x() - 8, body.bottom() - 6, body.width() + 16, int(half * 0.30))
|
||||
painter.setPen(QPen(QColor("#fab829"), 2))
|
||||
painter.drawRect(base)
|
||||
|
||||
ring = QRect(0, 0, int(half * 1.5), int(half * 0.78))
|
||||
ring.moveCenter(QPoint(cx + int(half * 0.30), cy + int(half * 0.05)))
|
||||
painter.setPen(QPen(QColor("#8c6ff7"), 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(ring)
|
||||
painter.drawLine(ring.center().x() - ring.width() // 2, ring.center().y(),
|
||||
ring.center().x() + ring.width() // 2, ring.center().y())
|
||||
painter.drawLine(ring.center().x(), ring.center().y() - ring.height() // 2,
|
||||
ring.center().x(), ring.center().y() + ring.height() // 2)
|
||||
painter.setPen(QPen(QColor(140, 111, 247, 130), 2))
|
||||
painter.drawEllipse(ring.adjusted(-14, -7, -14, -7))
|
||||
|
||||
# The planned path: the stages swing the contact, then the table slides it in.
|
||||
contact = QPoint(body.x() + 10, body.y() + int(body.height() * 0.28))
|
||||
landed = QPoint(cx + 6, cy - int(half * 0.30))
|
||||
path = QPainterPath(contact)
|
||||
path.quadTo(QPoint(contact.x() + int(half * 0.75), contact.y() - int(half * 0.35)), landed)
|
||||
painter.setPen(QPen(QColor("#40d1f2"), 2))
|
||||
painter.drawPath(path)
|
||||
for share in (0.35, 0.7):
|
||||
_arrowhead(painter, path, share, QColor("#ccf4ff"), 7)
|
||||
painter.setBrush(QColor("#40d1f2"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(landed, 5, 5)
|
||||
|
||||
# The table triad at the origin: the only full-strength one in the picture.
|
||||
origin = QPoint(floor.center().x(), floor.center().y() + int(half * 0.12))
|
||||
for colour, end in (
|
||||
("#e65454", QPoint(cx - 96, cy + 18)),
|
||||
("#61cc61", QPoint(cx + 30, cy + 92)),
|
||||
("#5c8cf2", QPoint(cx + 88, cy - 26)),
|
||||
("#e64c4c", QPoint(origin.x(), origin.y() + int(half * 0.62))),
|
||||
("#59c761", QPoint(origin.x() + int(half * 0.85), origin.y())),
|
||||
("#5787f2", QPoint(origin.x(), origin.y() - int(half * 0.42))),
|
||||
):
|
||||
painter.setPen(QPen(QColor(colour), 2))
|
||||
painter.drawLine(QPoint(cx, cy), end)
|
||||
painter.setPen(QPen(QColor("#12b76a"), 3))
|
||||
painter.drawLine(QPoint(cx, cy), QPoint(cx + 26, cy - 96))
|
||||
painter.setPen(QPen(QColor("#d92d20"), 3))
|
||||
painter.drawLine(QPoint(cx, cy), QPoint(cx - 74, cy - 78))
|
||||
painter.drawLine(origin, end)
|
||||
painter.setBrush(QColor("#fa9933"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(origin, 5, 5)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#4a5568"), 1))
|
||||
painter.drawLine(QPoint(cx - 90, cy), QPoint(cx + 90, cy))
|
||||
painter.setPen(QPen(QColor("#e65454"), 1))
|
||||
painter.drawText(QPoint(cx - 108, cy + 24), "X")
|
||||
painter.setPen(QPen(QColor("#61cc61"), 1))
|
||||
painter.drawText(QPoint(cx + 34, cy + 100), "Y")
|
||||
painter.setPen(QPen(QColor("#5c8cf2"), 1))
|
||||
painter.drawText(QPoint(cx + 92, cy - 28), "Z")
|
||||
_text(painter, QRect(inner_preview.x(), view.bottom() + 2, inner_preview.width(), 30), s["finger_axes_note"], palette, size=8, muted=True)
|
||||
|
||||
# The tool axis, through the point the tool touches.
|
||||
painter.setPen(QPen(QColor("#f2e04c"), 2))
|
||||
painter.drawLine(QPoint(landed.x(), view.y() + 16), QPoint(landed.x(), landed.y()))
|
||||
_text(painter, QRect(landed.x() - 8, view.y() + 2, 90, 16), "+Z", palette, size=8, colour=QColor("#f2e04c"))
|
||||
|
||||
# The picked contact and its normal, and the point selected in the table.
|
||||
painter.setPen(QPen(QColor("#59a6ff"), 2))
|
||||
painter.drawLine(contact, QPoint(contact.x() - int(half * 0.30), contact.y() - int(half * 0.34)))
|
||||
painter.setPen(QPen(QColor("#ffffff"), 2))
|
||||
painter.setBrush(QColor("#ffffff"))
|
||||
painter.drawEllipse(contact, 4, 4)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#eb2133"))
|
||||
painter.drawEllipse(QPoint(contact.x() + 14, contact.y() + 4), 5, 5)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
|
||||
checks = s["finger_preview_checks"]
|
||||
check_w = inner_preview.width() // max(1, len(checks))
|
||||
for index, label in enumerate(checks):
|
||||
box = QRect(inner_preview.x() + index * check_w, view.bottom() + 2, check_w, checks_h)
|
||||
painter.setPen(QPen(palette.accent, 2))
|
||||
painter.drawLine(box.x() + 2, box.y() + 9, box.x() + 5, box.y() + 13)
|
||||
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
|
||||
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
|
||||
|
||||
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
|
||||
_button(painter, play, s["finger_play"], palette, primary=True)
|
||||
notes_y = play.bottom() + 4
|
||||
for text in s["finger_preview_notes"]:
|
||||
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||
notes_y += 14
|
||||
|
||||
# Picked points.
|
||||
table = QRect(inner.x(), y + body_h + 8, inner.width(), max(60, table_h))
|
||||
@@ -1055,9 +1203,16 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
painter.drawRect(table.adjusted(0, 0, -1, -1))
|
||||
header_h = 24
|
||||
painter.fillRect(QRect(table.x(), table.y(), table.width(), header_h), palette.panel)
|
||||
# Leading select column, then the data columns: the real table has a checkbox
|
||||
# per row so points can be picked out, edited, reordered or removed.
|
||||
select_w = 34
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
painter.drawLine(table.x() + select_w, table.y(), table.x() + select_w, table.bottom())
|
||||
columns = s["finger_point_cols"]
|
||||
widths = [int(table.width() * share) for share in (0.06, 0.09, 0.1, 0.09, 0.09, 0.07, 0.07, 0.07, 0.09, 0.09, 0.09, 0.09)]
|
||||
x = table.x()
|
||||
widths = [int((table.width() - select_w) * share)
|
||||
for share in (0.09, 0.1, 0.09, 0.09, 0.08, 0.08, 0.08, 0.11,
|
||||
0.07, 0.07, 0.07, 0.07)]
|
||||
x = table.x() + select_w
|
||||
for name, width in zip(columns, widths):
|
||||
_text(painter, QRect(x, table.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter)
|
||||
x += width
|
||||
@@ -1065,12 +1220,28 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
row_y = table.y() + header_h + index * 26
|
||||
if index % 2 == 0:
|
||||
painter.fillRect(QRect(table.x() + 1, row_y, table.width() - 2, 25), palette.panel)
|
||||
x = table.x()
|
||||
# Tick the first row, as the selected point being tested.
|
||||
box = QRect(table.x() + select_w // 2 - 6, row_y + 8, 12, 12)
|
||||
painter.setPen(QPen(palette.muted, 1))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(box)
|
||||
if index == 0:
|
||||
painter.setPen(QPen(palette.accent, 2))
|
||||
painter.drawLine(box.x() + 2, box.y() + 6, box.x() + 5, box.y() + 10)
|
||||
painter.drawLine(box.x() + 5, box.y() + 10, box.x() + 10, box.y() + 3)
|
||||
x = table.x() + select_w
|
||||
for value, width in zip(row, widths):
|
||||
colour = palette.danger if value == s["finger_bad_word"] else None
|
||||
_text(painter, QRect(x, row_y, width, 26), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter, colour=colour)
|
||||
x += width
|
||||
_text(painter, QRect(table.x() + 8, table.bottom() + 2, table.width(), 18), s["finger_point_count"], palette, size=9, muted=True)
|
||||
buttons = s["finger_table_buttons"]
|
||||
bx = table.x()
|
||||
by = table.bottom() + 26
|
||||
for label, primary in buttons:
|
||||
width = 30 + 8 * len(label)
|
||||
_button(painter, QRect(bx, by, width, 26), label, palette, primary=primary)
|
||||
bx += width + 8
|
||||
|
||||
|
||||
TAB_PAINTERS = {
|
||||
|
||||
@@ -30,6 +30,17 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
# The report quotes labels, and half of them are Chinese. A Windows console
|
||||
# defaults to a legacy code page that cannot encode them, so the run ended in a
|
||||
# UnicodeEncodeError *after* every check had passed — the worst kind of noise.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
try:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError): # pragma: no cover - stream already detached
|
||||
pass
|
||||
|
||||
RESOLUTIONS = ((1440, 900), (1920, 1080))
|
||||
|
||||
|
||||
@@ -394,6 +405,10 @@ def main() -> int:
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
pass
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the finger page's 3D preview to a PNG, using a real GL context.
|
||||
|
||||
python apps/autocal/tools/preview_snapshot.py --out shot.png
|
||||
|
||||
``tools/panel_snapshot.py`` renders the whole window with the *offscreen* Qt
|
||||
platform, which cannot create an OpenGL context at all (``QOpenGLWidget is not
|
||||
supported on this platform``), so the preview pane in those shots is empty. This
|
||||
tool is the one that can photograph the 3D view, and it needs the real platform
|
||||
plugin and a visible window: Qt only builds a QOpenGLWidget's framebuffer once the
|
||||
widget has been shown and painted.
|
||||
|
||||
It therefore opens the window briefly, picks a point on the sensing surface, and
|
||||
reads the framebuffer back. Options:
|
||||
|
||||
--points 3 pick several points, so the table and the red dot show
|
||||
--play 0.5 run the motion simulation and capture it at 50%
|
||||
--view initial reset the camera before the shot
|
||||
--whole save the whole window instead of just the GL pane
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
# The offscreen plugin cannot make a GL context, so drop it if the caller's
|
||||
# environment set it (the test suite does).
|
||||
if os.environ.get("QT_QPA_PLATFORM") == "offscreen":
|
||||
del os.environ["QT_QPA_PLATFORM"]
|
||||
# Force the native desktop GL backend. Qt's default choice on Windows has been seen
|
||||
# to hand out a context that validates but rejects every command — a bare
|
||||
# QOpenGLWidget with no application code fails the same way, with
|
||||
# GL_INVALID_OPERATION on the first call after ``makeCurrent`` — and every render
|
||||
# then comes out blank with no clue why. ``setdefault``, so an explicit choice in
|
||||
# the environment still wins.
|
||||
os.environ.setdefault("QT_OPENGL", "desktop")
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
|
||||
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
|
||||
parser.add_argument("--size", default="1180x760", help="WIDTHxHEIGHT of the window")
|
||||
parser.add_argument(
|
||||
"--pane",
|
||||
default="",
|
||||
metavar="WIDTHxHEIGHT",
|
||||
help="force the 3D pane to this size before the shot, for a readable view",
|
||||
)
|
||||
parser.add_argument("--out", default="preview.png")
|
||||
parser.add_argument(
|
||||
"--bundle",
|
||||
default="",
|
||||
help="bundle directory to load; defaults to the remembered preference",
|
||||
)
|
||||
parser.add_argument("--points", type=int, default=1, help="how many points to pick")
|
||||
parser.add_argument(
|
||||
"--setup",
|
||||
default="",
|
||||
metavar="X,Y,Z,YAW",
|
||||
help="sensor mounting pose: mm in the table frame plus the yaw in degrees",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool",
|
||||
default="",
|
||||
metavar="X,Y",
|
||||
help="tool position in the table frame, mm",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--play",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="SHARE",
|
||||
help="start the motion simulation and capture it after this share, 0..1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--view",
|
||||
default="",
|
||||
choices=("", "initial"),
|
||||
help="initial resets the camera before the shot",
|
||||
)
|
||||
parser.add_argument("--tab", default="finger", help="workspace tab to open first")
|
||||
parser.add_argument(
|
||||
"--whole", action="store_true", help="save the whole window, not just the pane"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="report the GL state of the pane"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
# Must follow QApplication and precede the first GL surface.
|
||||
configure_compatible_gl()
|
||||
app.setStyleSheet(build_qss(args.theme))
|
||||
|
||||
prefs = load_preferences()
|
||||
prefs["theme"] = args.theme
|
||||
prefs["language"] = args.language
|
||||
prefs["show_diagnostics"] = False
|
||||
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
window.workspace.set_current_key(args.tab)
|
||||
window.show()
|
||||
# Resized *after* showing: the window restores its remembered geometry on
|
||||
# construction, so a resize before ``show`` is silently overwritten and every
|
||||
# shot comes out at whatever size the operator last used.
|
||||
window.resize(width, height)
|
||||
_settle(app, 0.5)
|
||||
|
||||
if args.bundle:
|
||||
window.load_bundle(args.bundle)
|
||||
_settle(app, 0.6)
|
||||
|
||||
panel = window.finger_panel
|
||||
if panel.view.flat is None:
|
||||
print("no bundle loaded; set finger_bundle_path in preferences", file=sys.stderr)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
if args.setup:
|
||||
numbers = [float(part) for part in args.setup.split(",")]
|
||||
while len(numbers) < 4:
|
||||
numbers.append(0.0)
|
||||
panel.set_sensor_setup(tuple(numbers[:3]), numbers[3])
|
||||
_settle(app, 0.2)
|
||||
if args.tool:
|
||||
numbers = [float(part) for part in args.tool.split(",")]
|
||||
while len(numbers) < 2:
|
||||
numbers.append(0.0)
|
||||
panel.set_tool_xy(numbers[0], numbers[1])
|
||||
_settle(app, 0.2)
|
||||
if args.pane:
|
||||
pane_w, pane_h = (int(part) for part in args.pane.lower().split("x"))
|
||||
panel.preview.setFixedSize(pane_w, pane_h)
|
||||
_settle(app, 0.3)
|
||||
if args.view == "initial":
|
||||
panel.preview.reset_view()
|
||||
|
||||
for x, y in _pick_pixels(panel, max(1, args.points)):
|
||||
panel.handle_pick(x, y)
|
||||
_settle(app, 0.25)
|
||||
|
||||
if args.play is not None:
|
||||
panel.preview.start_simulation()
|
||||
_run_simulation_to(app, panel.preview, args.play)
|
||||
_settle(app, 0.35)
|
||||
|
||||
# The pane is hidden for the read-back, and only for the read-back. A visible
|
||||
# QOpenGLWidget is painted by the window system, which a session with no
|
||||
# interactive desktop never does, so its framebuffer stays blank; hidden, Qt
|
||||
# renders on demand for grabFramebuffer and the picture is real. Hiding it
|
||||
# afterwards does not disturb the layout, so the corner is taken first.
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
corner = panel.preview.mapTo(window, QPoint(0, 0))
|
||||
pane_size = panel.preview.size()
|
||||
panel.preview.hide()
|
||||
_settle(app, 0.25)
|
||||
try:
|
||||
gl_image = _grab_gl(app, panel.preview)
|
||||
finally:
|
||||
panel.preview.show()
|
||||
panel.preview.resize(pane_size)
|
||||
_settle(app, 0.2)
|
||||
if args.debug:
|
||||
context = panel.preview.context()
|
||||
summary = panel.preview.trajectory_summary()
|
||||
print(
|
||||
f"debug: platform={app.platformName()} valid={panel.preview.isValid()} "
|
||||
f"size={panel.preview.width()}x{panel.preview.height()} "
|
||||
f"tab={window.workspace.current_key()} "
|
||||
f"context={'yes' if context is not None else 'none'} "
|
||||
f"problem={panel.preview.problem!r} "
|
||||
f"image={'none' if gl_image is None else f'{gl_image.width()}x{gl_image.height()}'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"debug: trajectory={summary or 'empty'} "
|
||||
f"show={panel.preview.show_trajectory} table={panel.preview.show_table} "
|
||||
f"axes={panel.preview.show_axes} "
|
||||
f"scene_radius={panel.preview.scene_radius():.2f}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"debug: heights panel={panel.height()} preview={panel.preview.height()} "
|
||||
f"point_table={panel.table.height()} window={window.height()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Where the claims land on screen: the path's end must project onto the tool,
|
||||
# and the contact onto both. Printed because "they look close in the picture"
|
||||
# is exactly the judgement this tool exists to replace.
|
||||
path = panel.preview.trajectory()
|
||||
if path.size:
|
||||
tool = panel.preview.tool_point()
|
||||
gap = float(sum((a - b) ** 2 for a, b in zip(path[-1], tool)) ** 0.5)
|
||||
screen = panel.preview.project(tool)
|
||||
print(
|
||||
f"debug: path end at {panel.preview.project(path[-1])} "
|
||||
f"tool at {screen} offscreen {gap:.6f} mm",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# The projection says where the tool line *is* on screen; check the
|
||||
# pixels, because a projection that disagrees with the render looks
|
||||
# perfectly healthy on its own — that is how the labels drifted off
|
||||
# their arrows and the scene sat off to one side without a word.
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
for label, point, colour in _landmarks(panel):
|
||||
screen = panel.preview.project(point)
|
||||
print(
|
||||
f"debug: {label} at {screen} -> "
|
||||
f"{_find_colour(gl_image, screen, colour)} matching pixels",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# The two markers must be the same point: the contact is what is solved for,
|
||||
# the red dot is the row selected in the table, and they are the same pick.
|
||||
contact = panel.preview._point_mm # noqa: SLF001
|
||||
highlight = panel.preview._highlight_mm # noqa: SLF001
|
||||
pose = panel.preview._pose # noqa: SLF001
|
||||
stack = panel.preview.stack
|
||||
print(
|
||||
f"debug: share={panel.preview.motion_share()} "
|
||||
f"simulating={panel.preview.simulating} "
|
||||
f"elapsed={panel.preview._sim_elapsed:.0f} " # noqa: SLF001
|
||||
f"pose={'none' if pose is None else pose.describe()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if contact is not None:
|
||||
drawn = panel.preview._placed(contact) # noqa: SLF001
|
||||
tool = panel.preview.tool_point()
|
||||
print(
|
||||
f"debug: drawn contact {[round(value, 4) for value in stack.mount @ drawn]} "
|
||||
f"tool {[round(value, 4) for value in stack.mount @ tool]} "
|
||||
f"gap {float(sum((a - b) ** 2 for a, b in zip(drawn, tool)) ** 0.5):.6f} mm",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if gl_image is None:
|
||||
print(
|
||||
f"the GL pane produced no framebuffer (valid={panel.preview.isValid()}, "
|
||||
f"size={panel.preview.width()}x{panel.preview.height()}, "
|
||||
f"backend={os.environ.get('QT_OPENGL', 'default')}, "
|
||||
f"problem={panel.preview.problem!r})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
|
||||
if args.whole:
|
||||
image = window.grab()
|
||||
_paint_pane(image, corner, gl_image)
|
||||
else:
|
||||
image = gl_image
|
||||
|
||||
output = Path(args.out).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image.save(str(output)):
|
||||
print(f"could not write {output}", file=sys.stderr)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
print(
|
||||
f"wrote {output} ({image.width()}x{image.height()}, pane={gl_image.width()}"
|
||||
f"x{gl_image.height()}, theme={args.theme}, points={panel.table.rowCount()}, "
|
||||
f"play={args.play})"
|
||||
)
|
||||
_finish(app, window)
|
||||
return 0
|
||||
|
||||
|
||||
def preview_place(panel, point):
|
||||
"""A sensor point as the preview draws it right now."""
|
||||
return panel.preview._placed(point) # noqa: SLF001
|
||||
|
||||
|
||||
def _landmarks(panel):
|
||||
"""``(name, mesh point, palette colour)`` for pixels that identify themselves.
|
||||
|
||||
Each pair is a point whose position is known from the model and an element drawn
|
||||
there in a colour nothing else uses, so "is the projection telling the truth"
|
||||
becomes a pixel count instead of an opinion.
|
||||
"""
|
||||
from orisys_autocal.sensor.frames import to_sensor
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
preview = panel.preview
|
||||
frame = preview.table_frame()
|
||||
tool = preview.tool_point()
|
||||
direction = to_sensor(preview.stack.tool_axis_direction, preview.stack.axis_map)
|
||||
length = float((direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2) ** 0.5) or 1.0
|
||||
direction = direction / length
|
||||
found = [
|
||||
# Unoccluded by construction: the origin dot is drawn last with depth off.
|
||||
("origin", frame["origin"], COLOUR["origin"]),
|
||||
]
|
||||
# The tool line is depth-tested, so parts of it are behind the body: try several
|
||||
# heights along it and take the best, rather than insisting on one that happens
|
||||
# to be visible in one particular pose.
|
||||
for offset_mm in (12.0, 9.0, 6.0, 3.0):
|
||||
found.append(
|
||||
(
|
||||
f"tool line +{offset_mm:.0f}mm",
|
||||
tool + direction * offset_mm,
|
||||
COLOUR["tool_axis"],
|
||||
)
|
||||
)
|
||||
ring = preview.table_marker_points()
|
||||
if ring.size:
|
||||
found.append(("table marker", ring[0], COLOUR["table_marker"]))
|
||||
return found
|
||||
|
||||
|
||||
def _find_colour(image, screen, colour, radius: int = 6) -> int:
|
||||
"""How many pixels near ``screen`` match ``colour``, within a small tolerance.
|
||||
|
||||
The check that ties the projection to the picture. Deliberately tolerant about
|
||||
position (a few pixels) and strict about colour, because the colour is what says
|
||||
*which* element is there: finding the tool-axis yellow near where the tool is
|
||||
claimed to be is the difference between a working projection and a plausible one.
|
||||
"""
|
||||
if screen is None or image is None:
|
||||
return -1
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
wanted = QColor(
|
||||
int(colour[0] * 255), int(colour[1] * 255), int(colour[2] * 255)
|
||||
)
|
||||
found = 0
|
||||
for dy in range(-radius, radius + 1):
|
||||
for dx in range(-radius, radius + 1):
|
||||
x, y = int(round(screen[0])) + dx, int(round(screen[1])) + dy
|
||||
if not (0 <= x < image.width() and 0 <= y < image.height()):
|
||||
continue
|
||||
pixel = image.pixelColor(x, y)
|
||||
if (
|
||||
abs(pixel.red() - wanted.red()) <= 24
|
||||
and abs(pixel.green() - wanted.green()) <= 24
|
||||
and abs(pixel.blue() - wanted.blue()) <= 24
|
||||
):
|
||||
found += 1
|
||||
return found
|
||||
|
||||
|
||||
def _finish(app, window) -> None:
|
||||
"""Close the window, stopping the poller thread first.
|
||||
|
||||
Without the explicit shutdown Qt tears the thread down with the process and
|
||||
prints "QThread: Destroyed while thread is still running", which reads like a
|
||||
fault in the app rather than a tool that left in a hurry.
|
||||
"""
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
pass
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _grab_gl(app, preview, attempts: int = 60):
|
||||
"""The GL pane's framebuffer, once it actually has something drawn in it.
|
||||
|
||||
``grabFramebuffer`` returns a valid but uniformly blank image when the widget
|
||||
has not been painted yet, which is easy to mistake for a rendering bug — the
|
||||
first version of this tool saved a white rectangle and the pane looked broken.
|
||||
So it is called repeatedly until the pixels differ from one another.
|
||||
"""
|
||||
import time
|
||||
|
||||
grab = getattr(preview, "grabFramebuffer", None)
|
||||
last = None
|
||||
for _ in range(attempts):
|
||||
app.processEvents()
|
||||
preview.update()
|
||||
app.processEvents()
|
||||
if grab is not None:
|
||||
try:
|
||||
image = grab()
|
||||
except Exception as error: # noqa: BLE001 - reported below
|
||||
print(f"grabFramebuffer failed: {error}", file=sys.stderr)
|
||||
break
|
||||
if image is not None and not image.isNull():
|
||||
last = image
|
||||
if _has_content(image):
|
||||
return image
|
||||
time.sleep(0.05)
|
||||
if last is not None and _has_content(last):
|
||||
return last
|
||||
# Fall back to a plain widget grab only when there is no GL context at all;
|
||||
# a blank framebuffer is reported instead of saved as a picture of nothing.
|
||||
if not preview.isValid():
|
||||
return preview.grab()
|
||||
return None
|
||||
|
||||
|
||||
def _has_content(image) -> bool:
|
||||
"""True when the image is not one flat colour, i.e. something was drawn."""
|
||||
small = image.scaled(24, 24)
|
||||
first = small.pixel(0, 0)
|
||||
for y in range(small.height()):
|
||||
for x in range(small.width()):
|
||||
if small.pixel(x, y) != first:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _paint_pane(image, corner, pane) -> None:
|
||||
"""Draw the GL content into a whole-window grab, where Qt leaves it blank.
|
||||
|
||||
``QWidget.grab`` on a window containing a ``QOpenGLWidget`` does not composite
|
||||
the GL surface, so the pane comes out empty. Painting the framebuffer in at the
|
||||
widget's own position is what makes the page shot show what the operator sees.
|
||||
"""
|
||||
from PySide6.QtGui import QPainter
|
||||
|
||||
painter = QPainter(image)
|
||||
try:
|
||||
painter.drawImage(corner, pane)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
def _pick_pixels(panel, count: int) -> list[tuple[int, int]]:
|
||||
"""UV-flat pixels that land on the surface, spread around the island.
|
||||
|
||||
Sampled from the flat view rather than hard-coded, so the shot follows the
|
||||
bundle instead of a coordinate that was right for one revision.
|
||||
"""
|
||||
view = panel.view
|
||||
flat = view.flat
|
||||
if flat is None:
|
||||
return []
|
||||
found: list[tuple[int, int]] = []
|
||||
fractions = [(0.5, 0.5), (0.35, 0.45), (0.65, 0.55), (0.5, 0.3), (0.5, 0.7)]
|
||||
for share_x, share_y in fractions:
|
||||
if len(found) >= count:
|
||||
break
|
||||
x = int(flat.width * share_x)
|
||||
y = int(flat.height * share_y)
|
||||
if flat.sample(x, y) is not None:
|
||||
found.append((x, y))
|
||||
return found
|
||||
|
||||
|
||||
def _settle(app, seconds: float) -> None:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def _run_simulation_to(app, preview, share: float) -> None:
|
||||
"""Let the simulation run until ``share`` of it has played.
|
||||
|
||||
Driven off the widget's own clock — the animation is a QTimer, and stepping the
|
||||
event loop is what advances it. Reading the elapsed time rather than sleeping a
|
||||
fixed duration keeps the shot reproducible when the frame budget differs.
|
||||
"""
|
||||
import time
|
||||
|
||||
from orisys_autocal.ui.pose_preview import SIM_DURATION_MS
|
||||
|
||||
target = SIM_DURATION_MS * max(0.0, min(1.0, float(share))) / 1000.0
|
||||
deadline = time.monotonic() + target + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if getattr(preview, "_sim_elapsed", 0.0) / 1000.0 >= target: # noqa: SLF001
|
||||
break
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user