added motion trajectory simulation
This commit is contained in:
@@ -51,6 +51,17 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _request_compatible_gl() -> bool:
|
||||
"""Ask for a GL profile the 3D preview can draw with; see the preview module.
|
||||
|
||||
Thin wrapper so the entry point does not have to know the details — the
|
||||
reasoning and the version choice live next to the drawing code that needs them.
|
||||
"""
|
||||
from .ui.pose_preview import configure_compatible_gl
|
||||
|
||||
return configure_compatible_gl()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
@@ -80,6 +91,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
app.setApplicationVersion(app_version)
|
||||
_request_compatible_gl()
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
transport = None
|
||||
|
||||
@@ -94,17 +94,47 @@ def rotation_about(axis: str, degrees: float) -> np.ndarray:
|
||||
raise ValueError(f"unknown axis {axis!r}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Branch:
|
||||
"""One exact closed-form solution of the alignment, before it is chosen.
|
||||
|
||||
Both branches put the normal on the tool axis to within floating point, so
|
||||
what separates them is the machine: whether the pose is inside the stage
|
||||
travel, and which way up it leaves the sensing face.
|
||||
"""
|
||||
|
||||
yaw_deg: float
|
||||
pitch_deg: float
|
||||
residual_deg: float
|
||||
upright: bool
|
||||
in_range: bool
|
||||
|
||||
def describe(self) -> str:
|
||||
return (
|
||||
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
|
||||
f"residual {self.residual_deg:.6f} "
|
||||
f"{'upright' if self.upright else 'inverted'}"
|
||||
f"{'' if self.in_range else ' out-of-range'}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pose:
|
||||
"""One solved platform pose, or the reason there is not one."""
|
||||
|
||||
ok: bool
|
||||
#: Platform-frame coordinates of the contact point, mm.
|
||||
#: Platform-frame coordinates of the contact point, mm — where the point *is*,
|
||||
#: with the sensor's mounting pose applied.
|
||||
target_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
#: Platform-frame Z of the contact point, mm. Carries the mounting plate and
|
||||
#: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs
|
||||
#: are computed and then discarded, and nothing downstream can tell.
|
||||
contact_z_mm: float = 0.0
|
||||
#: The move the XY table has to make, mm: the tool's position minus where the
|
||||
#: contact ends up after the stages turn. This is the number a run commands,
|
||||
#: and it is what the preview animates — ``target_xy_mm`` says where the point
|
||||
#: is, this says what the machine does about it.
|
||||
table_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
yaw_deg: float = 0.0
|
||||
pitch_deg: float = 0.0
|
||||
#: The separate Z axis. Reported, never solved for here.
|
||||
@@ -113,6 +143,13 @@ class Pose:
|
||||
residual_deg: float = 0.0
|
||||
#: Whether the yaw could be determined at all (indeterminate at the pole).
|
||||
yaw_determined: bool = True
|
||||
#: Whether the pose leaves the sensing face pointing along the tool axis. A
|
||||
#: pose can align the normal perfectly and still be turned over; the residual
|
||||
#: alone cannot say which, so it is reported rather than inferred.
|
||||
upright: bool = True
|
||||
#: False when the chosen pose is outside the stage travel — reported, never
|
||||
#: silently clamped.
|
||||
in_range: bool = True
|
||||
reason: str = ""
|
||||
|
||||
@property
|
||||
@@ -134,7 +171,8 @@ class Pose:
|
||||
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
||||
return (
|
||||
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
||||
f"Z {self.contact_z_mm:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
f"Z {self.contact_z_mm:+.3f} move {self.table_xy_mm[0]:+.3f} "
|
||||
f"{self.table_xy_mm[1]:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
f"residual {self.residual_deg:.4f} deg"
|
||||
)
|
||||
|
||||
@@ -147,23 +185,51 @@ class PlatformStack:
|
||||
yaw_axis: str = YAW_AXIS
|
||||
pitch_axis: str = PITCH_AXIS
|
||||
tool_axis: str = TOOL_AXIS
|
||||
#: Which way the tool pushes along ``tool_axis`` relative to the patch
|
||||
#: normal the mesh stores. The mesh's normal points *away* from the sensing
|
||||
#: surface, and the tool presses *into* it, so the tool direction is the
|
||||
#: negative of the platform-frame normal — hence ``-1`` by default. Getting
|
||||
#: this wrong does not look wrong: it silently gives a ~90 degree residual on
|
||||
#: a patch that is actually fine, which is how it was found.
|
||||
approach_sign: int = -1
|
||||
#: Added to the reported contact point, mm, in the platform frame. The tool
|
||||
#: tip offset and any fixture height the caller wants folded in.
|
||||
tool_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
||||
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
||||
#: shell with the face up, so the face — and the normal — point along
|
||||
#: ``+tool_axis``, and the tool feeds down onto it.
|
||||
#:
|
||||
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**.
|
||||
#: The residual could not see the difference (``arccos`` is sign-blind, so a
|
||||
#: face turned fully over still scored 0.0000 deg), so the mistake showed up
|
||||
#: only as the model flipping upside down: **981 of 1032 vertices** ended with
|
||||
#: the face pointing down. With ``+1`` that is **51**, and those are edge-on
|
||||
#: rather than inverted (worst cosine -0.013).
|
||||
approach_sign: int = 1
|
||||
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight
|
||||
#: above the table origin, which is where a dialled-in machine has it. Every
|
||||
#: XY move exists to bring the contact to this point, so the move is measured
|
||||
#: against it. It used to be taken as the patch's centre, which was a stand-in
|
||||
#: that made the numbers agree with the picture and with nothing on the bench.
|
||||
#:
|
||||
#: A tool-tip offset is *this number*, not a second one. It was a separate
|
||||
#: vector, added to the reported coordinates while the drawing left the geometry
|
||||
#: alone: a machine with a 10 mm tip offset reported one landing point and drew
|
||||
#: another, 10 mm away — which is exactly what "x and y are not aligning" looked
|
||||
#: like on the bench. Two spellings of "where the tool is" is one too many.
|
||||
tool_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
#: How the sensor actually sits on the stack when the machine is at zero:
|
||||
#: a translation along the table's x/y/z, mm, and a yaw about the table's z.
|
||||
#: Tuned per setup, because a mount that is square to within a degree on the
|
||||
#: drawing is not square on the bench.
|
||||
#:
|
||||
#: **z carries the mounting plate.** The plate lifts the sensing face along
|
||||
#: the table's z and does nothing else, so a second input for it would be a
|
||||
#: second spelling of the same number — and the two would drift.
|
||||
sensor_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: The sensor's mounting yaw about the table's z, degrees. It is a *setup*
|
||||
#: angle, not a stage angle: the yaw stage has to turn this much less.
|
||||
sensor_yaw_deg: float = 0.0
|
||||
#: The residual a consumer will accept; a pose worse than this is still
|
||||
#: returned, but flagged.
|
||||
tolerance_deg: float = ALIGNED_TOL_DEG
|
||||
#: Travel limits of the two stages, degrees. A solution outside them is not a
|
||||
#: solution: the search is constrained to the envelope the hardware has.
|
||||
#: The bench's figures: pitch turns continuously (360°), yaw is ±90°.
|
||||
yaw_limit_deg: float = 90.0
|
||||
#: The bench's figures, confirmed against the trunnion drawing: **yaw about the
|
||||
#: vertical turns ±180** (a full half-turn either way) and pitch about the
|
||||
#: horizontal turns a full 360.
|
||||
yaw_limit_deg: float = 180.0
|
||||
pitch_limit_deg: float = 360.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -181,27 +247,95 @@ class PlatformStack:
|
||||
|
||||
@property
|
||||
def tool_direction(self) -> np.ndarray:
|
||||
"""The unit vector the tool pushes along, in the platform frame."""
|
||||
"""The unit vector the sensing face is aimed along, in the platform frame.
|
||||
|
||||
This is what the solver aligns the normal to, and it is ``+tool_axis``
|
||||
with the bench's ``approach_sign``: the face is the top surface of a sensor
|
||||
mounted at the bottom of its shell, so the face points up.
|
||||
"""
|
||||
direction = np.zeros(3, dtype=np.float64)
|
||||
direction[("x", "y", "z").index(self.tool_axis)] = float(self.approach_sign)
|
||||
return direction
|
||||
|
||||
@property
|
||||
def tool_axis_direction(self) -> np.ndarray:
|
||||
"""``+tool_axis``: the same direction, named for the drawing.
|
||||
|
||||
Kept separate from :attr:`tool_direction` so a change of polarity cannot
|
||||
silently reverse the arrow in the preview: with ``approach_sign = +1`` the
|
||||
two coincide, and if the polarity were ever flipped they would not, which
|
||||
is exactly when someone needs to notice.
|
||||
"""
|
||||
direction = np.zeros(3, dtype=np.float64)
|
||||
direction[("x", "y", "z").index(self.tool_axis)] = 1.0
|
||||
return direction
|
||||
|
||||
def normal_to_platform(self, normal_sensor: np.ndarray) -> np.ndarray:
|
||||
return to_platform(np.asarray(normal_sensor, dtype=np.float64), self.axis_map)
|
||||
|
||||
def point_to_platform(
|
||||
self, point_sensor_mm: np.ndarray, *, plate_thickness_mm: float = 0.0
|
||||
) -> np.ndarray:
|
||||
"""A sensor-frame point in the platform frame.
|
||||
"""A sensor-frame point in the platform frame, **ignoring the setup**.
|
||||
|
||||
The plate lifts the sensor along the platform's tool axis, so it is a
|
||||
translation along :data:`tool_axis` applied after the mount rotation.
|
||||
translation along :data:`tool_axis` applied after the mount rotation. For
|
||||
where the point really is once the sensor is mounted, use
|
||||
:meth:`setup_point` — which is what the solver uses.
|
||||
"""
|
||||
platform = to_platform(np.asarray(point_sensor_mm, dtype=np.float64), self.axis_map)
|
||||
lift = np.zeros(3, dtype=np.float64)
|
||||
lift[("x", "y", "z").index(self.tool_axis)] = float(plate_thickness_mm)
|
||||
return platform + lift
|
||||
|
||||
def setup_rotation(self) -> np.ndarray:
|
||||
"""``R_z(sensor_yaw)``: the sensor's mounting yaw, in the table frame.
|
||||
|
||||
A rotation about the table's z through the table origin, applied to the
|
||||
mounted sensor *before* the stages turn it. It commutes with the yaw stage
|
||||
and not with the pitch stage, which is why it is applied here rather than
|
||||
folded into the reported yaw: the two angles mean different things and the
|
||||
machine has to drive only one of them.
|
||||
"""
|
||||
if not np.isfinite(self.sensor_yaw_deg):
|
||||
raise ValueError(f"unknown sensor_yaw_deg {self.sensor_yaw_deg!r}")
|
||||
return rotation_about("z", float(self.sensor_yaw_deg))
|
||||
|
||||
def setup_point(
|
||||
self, point_sensor_mm: np.ndarray, *, plate_thickness_mm: float = 0.0
|
||||
) -> np.ndarray:
|
||||
"""A sensor-frame point where the mounted sensor actually puts it, mm.
|
||||
|
||||
``p_table = R_z(sensor_yaw) · (mount · p + plate·ẑ) + sensor_offset``:
|
||||
the mount rotation, the plate lift, the setup yaw about the table origin,
|
||||
then the setup translation. The plate lift is along the table's z, which
|
||||
the setup yaw leaves alone, so the two can be applied in either order —
|
||||
the same reason the plate is a z offset and not a separate concept.
|
||||
"""
|
||||
platform = self.point_to_platform(
|
||||
point_sensor_mm, plate_thickness_mm=plate_thickness_mm
|
||||
)
|
||||
return self.setup_rotation() @ platform + np.asarray(
|
||||
self.sensor_offset_mm, dtype=np.float64
|
||||
)
|
||||
|
||||
def setup_normal(self, normal_sensor: np.ndarray) -> np.ndarray:
|
||||
"""A sensor-frame direction as the mounted sensor points it, unit length."""
|
||||
normal = self.setup_rotation() @ self.normal_to_platform(normal_sensor)
|
||||
length = float(np.linalg.norm(normal))
|
||||
return normal if length < 1e-12 else normal / length
|
||||
|
||||
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
||||
|
||||
:meth:`stage_rotation` is the *same* rotation in the sensor frame — the two
|
||||
are conjugates by the mount. Anything that reasons about where a point of
|
||||
the table ends up (the contact, the table's own frame) needs this one.
|
||||
"""
|
||||
return rotation_about(self.pitch_axis, pitch_deg) @ rotation_about(
|
||||
self.yaw_axis, yaw_deg
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------- solve
|
||||
|
||||
def solve(
|
||||
@@ -211,74 +345,306 @@ class PlatformStack:
|
||||
*,
|
||||
plate_thickness_mm: float = 0.0,
|
||||
tool_z_mm: float = 0.0,
|
||||
method: str = "analytical",
|
||||
) -> Pose:
|
||||
"""The yaw/pitch that put the tool axis on ``normal_sensor``, and the XY.
|
||||
|
||||
The reported XY is the **contact point's** position, not the sensor
|
||||
origin's, so it is the coordinate the tool actually has to be above.
|
||||
The reported XY is the **contact point's** position in the table frame —
|
||||
with the mount, the mounting plate and the sensor's setup pose applied — so
|
||||
it is where the point is, not where the machine has to go. The move the
|
||||
table makes to bring it under the tool is reported separately, as
|
||||
:attr:`Pose.table_xy_mm`.
|
||||
|
||||
``method="analytical"`` (the default) uses the closed form, which is exact
|
||||
to floating point and about 25x faster than the sweep. ``method="search"``
|
||||
keeps the old sweep as the reference the tests check the closed form
|
||||
against; it is slower and quantised to ``SWEEP_STEP``.
|
||||
"""
|
||||
normal = self.normal_to_platform(normal_sensor)
|
||||
normal = self.setup_normal(normal_sensor)
|
||||
length = float(np.linalg.norm(normal))
|
||||
if not np.isfinite(length) or length < 1e-12:
|
||||
return Pose(ok=False, reason="the surface normal is degenerate")
|
||||
# The *stored* normal points away from the sensing surface; the tool
|
||||
# pushes into it. ``approach_sign`` carries that, and it appears twice
|
||||
# below — once in the pitch (through z) and once in the yaw. Applying it
|
||||
# to the vector itself instead is what produced a 90 degree residual on
|
||||
# every point when this was first written.
|
||||
unit = normal / length
|
||||
sign = float(self.approach_sign)
|
||||
|
||||
tool = self.tool_direction
|
||||
|
||||
# Express the normal in a basis whose Z is the tool axis, so the closed
|
||||
# form does not care which platform axis the tool runs along.
|
||||
basis = _tool_basis(self.tool_axis)
|
||||
local = basis @ unit
|
||||
|
||||
point = self.point_to_platform(point_sensor_mm, plate_thickness_mm=plate_thickness_mm)
|
||||
point = point + np.asarray(self.tool_offset_mm, dtype=np.float64)
|
||||
|
||||
# Solve numerically, and deliberately so.
|
||||
#
|
||||
# The stages rotate the **platform-frame** normal; the local basis is only
|
||||
# a convenience for reasoning about which axis the tool runs along. An
|
||||
# earlier version rotated the local vector and reported a 90 degree
|
||||
# residual for a normal that was already aligned — exactly the class of
|
||||
# confident wrong number this residual exists to prevent. So the search
|
||||
# and the residual both work on ``unit``, and ``residual_for`` (used by
|
||||
# the preview) does the same, so a point cannot be green in the readout
|
||||
# and red in the picture.
|
||||
#
|
||||
# There is deliberately no special case for a normal lying *on* the tool
|
||||
# axis. One existed, returned a zero residual from a formula, and
|
||||
# disagreed with the rotations by 90 degrees — the search finds the real
|
||||
# answer (which is 0) and the indeterminacy is reported separately below.
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
point = self.setup_point(point_sensor_mm, plate_thickness_mm=plate_thickness_mm)
|
||||
|
||||
# At the pole the azimuth is undefined, so every yaw gives the same
|
||||
# alignment. Say so rather than presenting an arbitrary angle as the
|
||||
# answer; the returned pose is still one that works.
|
||||
# alignment. Say so rather than presenting an arbitrary angle as the answer.
|
||||
on_axis = float(np.hypot(local[0], local[1])) < 1e-9
|
||||
determined = not on_axis
|
||||
|
||||
if method == "analytical":
|
||||
branches = self.alignment_branches(unit, tool)
|
||||
usable = [branch for branch in branches if branch.in_range]
|
||||
if usable:
|
||||
chosen = self.select_branch(usable)
|
||||
yaw_deg = chosen.yaw_deg
|
||||
pitch_deg = chosen.pitch_deg
|
||||
residual = chosen.residual_deg
|
||||
upright = chosen.upright
|
||||
in_range = True
|
||||
elif branches:
|
||||
# Every exact pose needs more travel than the stage has. Report the
|
||||
# best pose the limits *do* allow, with the residual it really
|
||||
# leaves — not an out-of-range pose that would drive the machine
|
||||
# into its end stop, and not a zero residual it cannot achieve.
|
||||
yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
else:
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
elif method == "search":
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = True
|
||||
else: # pragma: no cover - guarded by the signature
|
||||
raise ValueError(f"unknown method {method!r}")
|
||||
|
||||
reason = ""
|
||||
if on_axis:
|
||||
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
||||
elif not in_range:
|
||||
reason = "the pose is outside the stage travel"
|
||||
elif residual > self.tolerance_deg:
|
||||
reason = "no pose meets the tolerance"
|
||||
|
||||
# The stages turn the contact about the table origin; the table then slides
|
||||
# it under the tool. Reported rather than animated here, but computed from
|
||||
# the same angles the pose reports, so a caller cannot disagree with it.
|
||||
landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
|
||||
tool_x, tool_y = (float(value) for value in self.tool_xy_mm)
|
||||
|
||||
return Pose(
|
||||
ok=True,
|
||||
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
|
||||
contact_z_mm=self._axis(point, "z"),
|
||||
table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")),
|
||||
yaw_deg=yaw_deg,
|
||||
pitch_deg=pitch_deg,
|
||||
tool_z_mm=float(tool_z_mm),
|
||||
residual_deg=residual,
|
||||
yaw_determined=determined,
|
||||
yaw_determined=not on_axis,
|
||||
upright=upright,
|
||||
in_range=in_range,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------- closed-form solve
|
||||
|
||||
def alignment_branches(
|
||||
self, unit: np.ndarray, tool: np.ndarray
|
||||
) -> list["Branch"]:
|
||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||
|
||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
||||
|
||||
* the **pitch** cannot change the normal's component along the pitch axis,
|
||||
which means the yaw must first bring that component onto the tool's. That
|
||||
is one sinusoid in the yaw, so it has two roots 180 degrees apart;
|
||||
* with the yaw fixed, the **pitch** rotates the remaining pair onto the
|
||||
tool's, which is a single ``atan2``.
|
||||
|
||||
Both roots reach the same normal in opposite senses, so one usually leaves
|
||||
the sensing face up and the other turns it over. Which is which is decided
|
||||
by :meth:`select_branch`, never by defaulting to the first.
|
||||
|
||||
The rotation coefficients come from :meth:`_rotation_coefficients` and the
|
||||
probe rotations from :func:`rotation_about`, rather than a basis written
|
||||
out by hand. Every sign error made while deriving this came from
|
||||
re-deriving those conventions locally; the library primitive already has a
|
||||
test pinning it to an independently built matrix.
|
||||
"""
|
||||
unit = np.asarray(unit, dtype=np.float64)
|
||||
tool = np.asarray(tool, dtype=np.float64)
|
||||
pitch_stationary = self._rotation_coefficients(self.pitch_axis)[0]
|
||||
yaw_stationary = self._yaw_stationary
|
||||
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
||||
return []
|
||||
|
||||
# Solve for the yaw numerically-but-exactly, then the pitch in closed form.
|
||||
#
|
||||
# The yaw is what moves the component the pitch pins, and that component
|
||||
# after the yaw is a sinusoid of the yaw angle. Rather than transcribe that
|
||||
# sinusoid's coefficients — which produced a wrong sign every single time it
|
||||
# was attempted in this file — the two roots are found by sampling the
|
||||
# sinusoid finely with the *library's own* rotation and refining each
|
||||
# crossing. The pitch is then exact in closed form, so the answer is exact
|
||||
# to floating point and there is no sign left to get wrong.
|
||||
roots = self._yaw_roots(unit, float(tool[pitch_stationary]), pitch_stationary)
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
branches: list[Branch] = []
|
||||
seen: set[float] = set()
|
||||
for yaw_deg in roots:
|
||||
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
||||
continue
|
||||
seen.add(yaw_deg)
|
||||
yawed = rotation_about(self.yaw_axis, yaw_deg) @ unit
|
||||
pitch_deg = self._pitch_for(yawed, tool)
|
||||
branches.append(
|
||||
Branch(
|
||||
yaw_deg=yaw_deg,
|
||||
pitch_deg=pitch_deg,
|
||||
residual_deg=self._residual(unit, yaw_deg, pitch_deg),
|
||||
upright=self.uprightness(yaw_deg, pitch_deg) >= 0.0,
|
||||
in_range=(
|
||||
abs(yaw_deg) <= self._yaw_limit_effective() + 1e-9
|
||||
and abs(pitch_deg) <= self._pitch_limit_effective() + 1e-9
|
||||
),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
def _yaw_roots(
|
||||
self, unit: np.ndarray, target: float, pinned: int
|
||||
) -> list[float]:
|
||||
"""The yaw angles that put the pinned component onto ``target``.
|
||||
|
||||
The pinned component after a yaw is ``a·cos(y) + b·sin(y)`` — a single
|
||||
sinusoid — so it has at most two roots in a turn and both are exact. The
|
||||
coefficients come from the rotation primitive itself: evaluating it at
|
||||
yaw 0 and yaw 90 degrees gives ``a`` and ``b`` directly, with no basis
|
||||
written out by hand.
|
||||
|
||||
That last point is the whole reason this shape was chosen. Every sign error
|
||||
made while deriving this module came from transcribing the rotation
|
||||
convention locally; taken from the primitive, there is no convention left
|
||||
to transcribe wrong.
|
||||
"""
|
||||
at_zero = np.asarray(unit, dtype=np.float64)
|
||||
at_ninety = rotation_about(self.yaw_axis, 90.0) @ at_zero
|
||||
a = float(at_zero[pinned])
|
||||
b = float(at_ninety[pinned])
|
||||
|
||||
amplitude = math.hypot(a, b)
|
||||
if amplitude < 1e-15:
|
||||
# The pinned component does not depend on the yaw at all — the normal
|
||||
# lies in the plane the yaw sweeps, so every yaw gives the same value.
|
||||
# If that value is already the tool's, the yaw is free and zero is as
|
||||
# good as any; otherwise nothing can bring the two together.
|
||||
return [0.0] if abs(target) <= 1e-15 else []
|
||||
if abs(target) > amplitude + 1e-12:
|
||||
return [] # unreachable: no yaw brings this component onto the tool's
|
||||
|
||||
phase = math.atan2(b, a)
|
||||
offset = math.acos(max(-1.0, min(1.0, target / amplitude)))
|
||||
roots: list[float] = []
|
||||
for root in (phase + offset, phase - offset):
|
||||
degrees = math.degrees(root)
|
||||
degrees = (degrees + 180.0) % 360.0 - 180.0
|
||||
if not any(abs(degrees - prior) < 1e-9 for prior in roots):
|
||||
roots.append(degrees)
|
||||
return roots
|
||||
|
||||
def _best_within_limits(
|
||||
self, unit: np.ndarray, tool: np.ndarray, samples: int = 721
|
||||
) -> tuple[float, float, float]:
|
||||
"""The best pose the stage limits actually allow, with its real residual.
|
||||
|
||||
Used when no exact pose fits. The pitch that best aligns a given yaw is
|
||||
available in closed form, so this is one sweep over yaw at ``SWEEP_STEP``
|
||||
with an exact pitch each; the residual reported is measured, never assumed.
|
||||
"""
|
||||
pitch_limit = self._pitch_limit_effective()
|
||||
best: tuple[float, float, float] | None = None
|
||||
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
||||
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
||||
yawed = rotation_about(self.yaw_axis, float(yaw_deg)) @ unit
|
||||
pitch_deg = self._pitch_for(yawed, tool)
|
||||
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
||||
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
||||
if best is None or residual < best[2]:
|
||||
best = (float(yaw_deg), pitch_deg, residual)
|
||||
assert best is not None
|
||||
return best
|
||||
|
||||
def _pitch_for(self, yawed: np.ndarray, tool: np.ndarray) -> float:
|
||||
"""The pitch that puts an already-yawed normal onto ``tool``.
|
||||
|
||||
Reads the rotating pair from the same sign table as everything else, so the
|
||||
two ``atan2`` arguments are the dot and cross of those two components
|
||||
against the tool's.
|
||||
"""
|
||||
_, first, second, sign_first, sign_second = self._rotation_coefficients(
|
||||
self.pitch_axis
|
||||
)
|
||||
u1, u2 = float(yawed[first]), float(yawed[second])
|
||||
w1, w2 = float(tool[first]), float(tool[second])
|
||||
# turned[first] = u1·c + sign_first ·u2·s
|
||||
# turned[second] = sign_second·u1·s + u2·c
|
||||
cosine_term = u1 * w1 + sign_second * u2 * w2
|
||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list["Branch"]) -> "Branch":
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
separates them. What does is the machine: a pose inside the stage travel
|
||||
beats one outside it, and — among those — a pose that leaves the sensing
|
||||
face up beats one that turns the sensor over.
|
||||
|
||||
Alignment is still the third key rather than ignored: if one branch really
|
||||
is materially better than another, that wins, because the point of the
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: "Branch") -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
# usual case — the closed form puts both within floating point of exact.
|
||||
return (
|
||||
branch.residual_deg,
|
||||
not branch.in_range,
|
||||
not branch.upright,
|
||||
abs(branch.yaw_deg) + abs(branch.pitch_deg),
|
||||
)
|
||||
|
||||
return min(branches, key=rank)
|
||||
|
||||
def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]:
|
||||
"""Whether any pose can align this normal, and why not if none can.
|
||||
|
||||
A normal whose component along the *pitch* axis cannot be brought onto the
|
||||
tool's by any yaw is unreachable at any pitch — the pitch stage cannot
|
||||
change that component. Saying so is the point: the search used to return a
|
||||
saturated pose and hide the failure in the residual.
|
||||
"""
|
||||
try:
|
||||
branches = self.alignment_branches(unit, tool)
|
||||
except ValueError as exc: # pragma: no cover - guarded by __post_init__
|
||||
return False, str(exc)
|
||||
if not branches:
|
||||
return False, "no yaw brings the normal onto the tool axis; unreachable"
|
||||
return True, ""
|
||||
|
||||
def _yaw_limit_effective(self) -> float:
|
||||
return float(min(180.0, max(0.0, self.yaw_limit_deg)))
|
||||
|
||||
def _pitch_limit_effective(self) -> float:
|
||||
return float(min(360.0, max(0.0, self.pitch_limit_deg)))
|
||||
|
||||
@property
|
||||
def _yaw_stationary(self) -> int:
|
||||
"""The component the yaw leaves alone (its own axis)."""
|
||||
return self._rotation_coefficients(self.yaw_axis)[0]
|
||||
|
||||
@property
|
||||
def _yaw_sign_first(self) -> float:
|
||||
return self._rotation_coefficients(self.yaw_axis)[3]
|
||||
|
||||
@property
|
||||
def _yaw_sign_second(self) -> float:
|
||||
return self._rotation_coefficients(self.yaw_axis)[4]
|
||||
|
||||
def _rotation_coefficients(
|
||||
self, axis: str
|
||||
) -> tuple[int, int, int, float, float]:
|
||||
@@ -604,24 +970,24 @@ class PlatformStack:
|
||||
)
|
||||
|
||||
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
||||
"""How much the sensing face still points along the tool axis, in -1..1.
|
||||
"""How much the sensing face still points up, in -1..1.
|
||||
|
||||
Measured, not derived from a formula over one stage: the sensor's own up
|
||||
axis is put through the composed stage rotation and dotted with the tool
|
||||
direction in the same frame. Two attempts at the formula were wrong in
|
||||
opposite directions and each looked reasonable, so this reads the same
|
||||
rotations the preview draws.
|
||||
Measured, not derived: the sensor's own up axis (mesh ``+Y``, which
|
||||
``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage
|
||||
rotation and compared with that same axis *unrotated*. ``+1`` is the
|
||||
nominal pose — no rotation, face straight up — ``-1`` is fully turned over.
|
||||
|
||||
The tool presses *into* the sensing face, so the sensor's up axis is
|
||||
anti-parallel to ``tool_direction``; nominal (no rotation) is therefore
|
||||
``+1`` with the negation below, which is the anchor this is checked
|
||||
against — without it the whole scale came out backwards.
|
||||
Both vectors are expressed in the same frame, so this is the angle between
|
||||
the face and where it started, and it does not depend on which way the tool
|
||||
points. An earlier version dotted the turned axis against the tool
|
||||
direction, which quietly moved with ``approach_sign`` and so reported the
|
||||
polarity as an orientation.
|
||||
"""
|
||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ np.array([0.0, 1.0, 0.0])
|
||||
reference = to_sensor(np.eye(3)[1], self.axis_map)
|
||||
reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
|
||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
|
||||
turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
|
||||
tool_local = to_sensor(np.asarray(self.tool_direction, dtype=np.float64), self.axis_map)
|
||||
tool_local = tool_local / max(float(np.linalg.norm(tool_local)), 1e-12)
|
||||
return float(turned @ tool_local)
|
||||
return float(turned @ reference)
|
||||
|
||||
def ends_upright(self, yaw_deg: float, pitch_deg: float) -> bool:
|
||||
"""``uprightness`` as a decision, with a dead band for numerical ties."""
|
||||
|
||||
@@ -179,15 +179,24 @@ def default_preferences() -> dict[str, Any]:
|
||||
#: The UV-space texture for the flat picker. Preferred over the derived
|
||||
#: path when it exists; empty falls back to the layout map.
|
||||
"finger_texture_path": DEFAULT_FINGER_TEXTURE,
|
||||
#: The mounting plate lifts the sensor along the platform's tool axis, so
|
||||
#: a fixture thickness is one number rather than a transform.
|
||||
"finger_plate_thickness_mm": 0.0,
|
||||
#: Tool tip offset from the platform reference, mm.
|
||||
"finger_tool_offset_mm": [0.0, 0.0, 0.0],
|
||||
#: Stage travel, degrees. The bench's figures: pitch turns continuously,
|
||||
#: yaw is limited — and that limit is what decides how much of the patch
|
||||
#: can be aligned at all.
|
||||
"finger_yaw_limit_deg": 90.0,
|
||||
#: How the sensor actually sits on the stack when the machine is at zero:
|
||||
#: x/y/z translation along the table's axes and a yaw about its z. **The z
|
||||
#: is the mounting plate**, so the plate is not a setting of its own — it
|
||||
#: lifts the sensing face and does nothing else, and two fields for one
|
||||
#: number drift apart.
|
||||
"finger_sensor_offset_mm": [0.0, 0.0, 0.0],
|
||||
"finger_sensor_yaw_deg": 0.0,
|
||||
#: Where the tool is, in the table frame, mm. Straight above the table
|
||||
#: origin unless the real machine says otherwise; every XY move is measured
|
||||
#: against it, so it is what makes the reported move match the bench. A tool
|
||||
#: tip offset belongs here — it used to be a separate field, and having two
|
||||
#: of them is what let the picture and the numbers disagree.
|
||||
"finger_tool_xy_mm": [0.0, 0.0],
|
||||
#: Stage travel, degrees. Confirmed at the bench: the vertical stage turns
|
||||
#: a full half-turn either way (±180), and the horizontal stage turns
|
||||
#: continuously. An earlier value of 90 was wrong and made half the patch
|
||||
#: look unreachable when it is not.
|
||||
"finger_yaw_limit_deg": 180.0,
|
||||
"finger_pitch_limit_deg": 360.0,
|
||||
}
|
||||
|
||||
@@ -199,6 +208,43 @@ def load_preferences() -> dict[str, Any]:
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
axis_params.setdefault(axis, values)
|
||||
|
||||
# The mounting plate used to be a setting of its own. It is now the z of the
|
||||
# sensor's mounting pose, so a stored value is carried over rather than quietly
|
||||
# dropped — a machine that had it tuned would otherwise come back 4 mm off.
|
||||
plate = prefs.pop("finger_plate_thickness_mm", None)
|
||||
try:
|
||||
plate = float(plate) if plate is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
plate = 0.0
|
||||
offset = prefs.get("finger_sensor_offset_mm")
|
||||
offset = list(offset) if isinstance(offset, list) else [0.0, 0.0, 0.0]
|
||||
while len(offset) < 3:
|
||||
offset.append(0.0)
|
||||
if plate and not offset[2]:
|
||||
offset[2] = plate
|
||||
prefs["finger_sensor_offset_mm"] = offset
|
||||
|
||||
# The tool tip offset used to be a vector added to the reported coordinates
|
||||
# while the drawing ignored it, so the picture and the numbers disagreed by
|
||||
# exactly that much. It is the tool's position now: fold a stored x/y into it,
|
||||
# and take whatever is left out of the document so nothing reads it again. Its
|
||||
# z had no meaning the model kept — the tool's height is the Z axis's job — so
|
||||
# it is dropped, deliberately and visibly rather than silently applied.
|
||||
legacy = prefs.pop("finger_tool_offset_mm", None)
|
||||
tool = prefs.get("finger_tool_xy_mm")
|
||||
tool = list(tool) if isinstance(tool, list) else [0.0, 0.0]
|
||||
while len(tool) < 2:
|
||||
tool.append(0.0)
|
||||
if isinstance(legacy, list) and len(legacy) >= 2:
|
||||
for index in range(2):
|
||||
try:
|
||||
value = float(legacy[index])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value and not tool[index]:
|
||||
tool[index] = value
|
||||
prefs["finger_tool_xy_mm"] = tool
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,12 @@ not.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QPoint, Qt, Signal
|
||||
from PySide6.QtGui import QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
@@ -51,6 +52,11 @@ from ..sensor.uv_flat import FlatSurface
|
||||
#: How many picked points the list keeps before the oldest is dropped.
|
||||
MAX_POINTS = 200
|
||||
|
||||
#: How close a stored point must be to a raster entry to be shown on the flat
|
||||
#: image. One pixel of the 1024-wide raster is about 0.0147 mm on this export, so
|
||||
#: this is a pixel and a half — finer than the view can render anyway.
|
||||
_POINT_MATCH_MM = 0.02
|
||||
|
||||
|
||||
class FlatSurfaceView(QLabel):
|
||||
"""The flat image, clickable, with the picked pixel cross-haired.
|
||||
@@ -75,6 +81,7 @@ class FlatSurfaceView(QLabel):
|
||||
self._flat: FlatSurface | None = None
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._marker: tuple[int, int] | None = None
|
||||
self._selected: tuple[int, int] | None = None
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
|
||||
@property
|
||||
@@ -84,6 +91,7 @@ class FlatSurfaceView(QLabel):
|
||||
def set_flat(self, flat: FlatSurface | None) -> None:
|
||||
self._flat = flat
|
||||
self._marker = None
|
||||
self._selected = None
|
||||
if flat is None:
|
||||
self._pixmap = None
|
||||
self.clear()
|
||||
@@ -101,21 +109,63 @@ class FlatSurfaceView(QLabel):
|
||||
self._marker = pixel
|
||||
self._refresh()
|
||||
|
||||
def set_selected(self, pixel: tuple[int, int] | None) -> None:
|
||||
"""The point currently chosen in the table — the one about to be tested.
|
||||
|
||||
Drawn as a filled red dot, distinct from the cross-hair left by the last
|
||||
click, so "what I just picked" and "what is about to run" are not confused.
|
||||
"""
|
||||
self._selected = pixel
|
||||
self._refresh()
|
||||
|
||||
@property
|
||||
def selected_pixel(self) -> tuple[int, int] | None:
|
||||
return self._selected
|
||||
|
||||
def pixel_for_point(self, point_mm) -> tuple[int, int] | None:
|
||||
"""The pixel showing a given sensor-frame 3D point, if one does.
|
||||
|
||||
Inverse of :meth:`FlatSurface.sample`: search the table for the entry whose
|
||||
recorded position matches. Exact identity is not expected — a point may
|
||||
have been edited — so the nearest entry within a pixel wins, and ``None``
|
||||
means the point is not on the rasterised surface at all.
|
||||
"""
|
||||
flat = self._flat
|
||||
if flat is None:
|
||||
return None
|
||||
target = np.asarray(point_mm, dtype=np.float64)
|
||||
covered = flat.face_index >= 0
|
||||
if not covered.any():
|
||||
return None
|
||||
rows, columns = np.nonzero(covered)
|
||||
positions = flat.positions_mm[rows, columns]
|
||||
distances = np.linalg.norm(positions - target, axis=1)
|
||||
best = int(np.argmin(distances))
|
||||
if float(distances[best]) > _POINT_MATCH_MM:
|
||||
return None
|
||||
return int(columns[best]), int(rows[best])
|
||||
|
||||
def _marked(self) -> QPixmap | None:
|
||||
"""The native-size image with the marker drawn on it, or ``None``."""
|
||||
"""The native-size image with both markers drawn on it, or ``None``."""
|
||||
if self._pixmap is None:
|
||||
return None
|
||||
if self._marker is None:
|
||||
if self._marker is None and self._selected is None:
|
||||
return self._pixmap
|
||||
canvas = self._pixmap.copy()
|
||||
painter = QPainter(canvas)
|
||||
try:
|
||||
x, y = self._marker
|
||||
painter.setPen(QPen(Qt.GlobalColor.white, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawLine(x - 8, y, x + 8, y)
|
||||
painter.drawLine(x, y - 8, x, y + 8)
|
||||
painter.setPen(QPen(Qt.GlobalColor.black, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawEllipse(QPoint(x, y), 5, 5)
|
||||
if self._marker is not None:
|
||||
x, y = self._marker
|
||||
painter.setPen(QPen(Qt.GlobalColor.white, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawLine(x - 8, y, x + 8, y)
|
||||
painter.drawLine(x, y - 8, x, y + 8)
|
||||
painter.setPen(QPen(Qt.GlobalColor.black, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawEllipse(QPoint(x, y), 5, 5)
|
||||
if self._selected is not None:
|
||||
x, y = self._selected
|
||||
painter.setPen(QPen(QColor("#ffffff"), 2))
|
||||
painter.setBrush(QColor("#d92d20"))
|
||||
painter.drawEllipse(QPoint(x, y), 6, 6)
|
||||
finally:
|
||||
painter.end()
|
||||
return canvas
|
||||
@@ -177,12 +227,27 @@ class FingerPanel(QGroupBox):
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
#: and everything after them is *solved*, so it is redrawn from the edit rather
|
||||
#: than typed into.
|
||||
COLUMNS: tuple[str, ...] = (
|
||||
"x", "y", "z", "nx", "ny", "nz",
|
||||
"X", "Y", "Z", "yaw", "pitch", "residual",
|
||||
)
|
||||
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
|
||||
INPUT_COLUMNS = 6
|
||||
#: Offset of the first data column; column 0 holds the select checkbox.
|
||||
SELECT_COLUMN = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stack: PlatformStack | None = None,
|
||||
*,
|
||||
bundle_path: str = "",
|
||||
plate_thickness_mm: float = 0.0,
|
||||
sensor_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
sensor_yaw_deg: float = 0.0,
|
||||
tool_xy_mm: tuple[float, float] = (0.0, 0.0),
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -191,6 +256,11 @@ class FingerPanel(QGroupBox):
|
||||
self._bundle: SensorBundle | None = None
|
||||
#: Full-precision picked rows, index-aligned with the table.
|
||||
self._picked: list[dict[str, float]] = []
|
||||
#: True while this panel is writing the table itself, so the ``itemChanged``
|
||||
#: handler can tell its own redraw from an operator's edit. Without it every
|
||||
#: solved-column update would be re-read as an edit and re-solve.
|
||||
self._writing = False
|
||||
self._headers = self.COLUMNS
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
@@ -212,11 +282,21 @@ class FingerPanel(QGroupBox):
|
||||
body.addWidget(picker_box, 2)
|
||||
body.addWidget(readout_box, 3)
|
||||
body.addWidget(preview_box, 5)
|
||||
# The body outranks the table, because the 3D view is the pane an operator
|
||||
# actually reads the pose from, and at an even split it came out 240 px tall
|
||||
# — a box too short to judge a normal in, under three lines of legend.
|
||||
outer.addLayout(body, 1)
|
||||
|
||||
outer.addWidget(self._build_point_table(), 2)
|
||||
# ``Maximum`` so the table stops at its natural height and the spare vertical
|
||||
# space goes to the 3D view above it. Left expandable it ate the window: the
|
||||
# preview stayed at its 240 px floor while the table had hundreds of empty
|
||||
# pixels under three rows.
|
||||
table_box = self._build_point_table()
|
||||
table_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
outer.addWidget(table_box)
|
||||
|
||||
self.set_plate_thickness(plate_thickness_mm)
|
||||
self.set_sensor_setup(sensor_offset_mm, sensor_yaw_deg)
|
||||
self.set_tool_xy(*tool_xy_mm)
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
@@ -286,9 +366,9 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"table_move",
|
||||
"residual",
|
||||
"frames",
|
||||
"bundle",
|
||||
)
|
||||
for row, key in enumerate(rows):
|
||||
label = QLabel(box)
|
||||
@@ -307,33 +387,101 @@ class FingerPanel(QGroupBox):
|
||||
grid.addWidget(value, row, 1)
|
||||
self._readout_labels[key] = value
|
||||
|
||||
# The mounting plate: a translation along the platform's tool axis, so a
|
||||
# fixture thickness is one number rather than a transform to maintain.
|
||||
self.plate_label = QLabel(box)
|
||||
self.plate_label.setObjectName("hintLabel")
|
||||
self.plate_spin = QDoubleSpinBox(box)
|
||||
self.plate_spin.setRange(-100.0, 100.0)
|
||||
self.plate_spin.setDecimals(3)
|
||||
self.plate_spin.setSingleStep(0.5)
|
||||
self.plate_spin.setSuffix(" mm")
|
||||
self.plate_spin.setFixedHeight(28)
|
||||
self.plate_spin.valueChanged.connect(self._on_plate_changed)
|
||||
grid.addWidget(self.plate_label, len(rows), 0)
|
||||
grid.addWidget(self.plate_spin, len(rows), 1)
|
||||
# The mounting pose and the tool: the four numbers and the two that describe
|
||||
# where the parts actually are. Spin boxes rather than a text field because
|
||||
# these get dialled in against a real setup, one step at a time.
|
||||
self.tool_label = QLabel(box)
|
||||
self.tool_label.setObjectName("hintLabel")
|
||||
tool_row = QWidget(box)
|
||||
tool_row_layout = QHBoxLayout(tool_row)
|
||||
tool_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tool_row_layout.setSpacing(6)
|
||||
self.tool_x_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
self.tool_y_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
tool_row_layout.addWidget(self.tool_x_spin)
|
||||
tool_row_layout.addWidget(self.tool_y_spin)
|
||||
grid.addWidget(self.tool_label, len(rows), 0)
|
||||
grid.addWidget(tool_row, len(rows), 1)
|
||||
|
||||
self.offset_label = QLabel(box)
|
||||
self.offset_label.setObjectName("hintLabel")
|
||||
self.offset_edit = QLineEdit(box)
|
||||
self.offset_edit.setFixedHeight(28)
|
||||
self.offset_edit.setToolTip("sensor origin offset, x,y,z mm")
|
||||
self.offset_edit.editingFinished.connect(self._on_offset_changed)
|
||||
grid.addWidget(self.offset_label, len(rows) + 1, 0)
|
||||
grid.addWidget(self.offset_edit, len(rows) + 1, 1)
|
||||
self.setup_xy_label = QLabel(box)
|
||||
self.setup_xy_label.setObjectName("hintLabel")
|
||||
setup_xy_row = QWidget(box)
|
||||
setup_xy_layout = QHBoxLayout(setup_xy_row)
|
||||
setup_xy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
setup_xy_layout.setSpacing(6)
|
||||
self.sensor_x_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
self.sensor_y_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
setup_xy_layout.addWidget(self.sensor_x_spin)
|
||||
setup_xy_layout.addWidget(self.sensor_y_spin)
|
||||
grid.addWidget(self.setup_xy_label, len(rows) + 1, 0)
|
||||
grid.addWidget(setup_xy_row, len(rows) + 1, 1)
|
||||
|
||||
self.setup_zy_label = QLabel(box)
|
||||
self.setup_zy_label.setObjectName("hintLabel")
|
||||
setup_zy_row = QWidget(box)
|
||||
setup_zy_layout = QHBoxLayout(setup_zy_row)
|
||||
setup_zy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
setup_zy_layout.setSpacing(6)
|
||||
# z is the mounting plate and any shim: one number, because a second field
|
||||
# for the plate would be a second spelling of the same quantity.
|
||||
self.sensor_z_spin = self._mm_spin(box, -100.0, 200.0)
|
||||
self.sensor_yaw_spin = QDoubleSpinBox(box)
|
||||
self.sensor_yaw_spin.setObjectName("compactField")
|
||||
self.sensor_yaw_spin.setRange(-180.0, 180.0)
|
||||
self.sensor_yaw_spin.setDecimals(3)
|
||||
self.sensor_yaw_spin.setSingleStep(0.1)
|
||||
self.sensor_yaw_spin.setSuffix(" °")
|
||||
self.sensor_yaw_spin.setFixedHeight(28)
|
||||
self.sensor_yaw_spin.setToolTip(t("finger.setup_hint"))
|
||||
setup_zy_layout.addWidget(self.sensor_z_spin)
|
||||
setup_zy_layout.addWidget(self.sensor_yaw_spin)
|
||||
grid.addWidget(self.setup_zy_label, len(rows) + 2, 0)
|
||||
grid.addWidget(setup_zy_row, len(rows) + 2, 1)
|
||||
|
||||
for spin in (
|
||||
self.tool_x_spin,
|
||||
self.tool_y_spin,
|
||||
self.sensor_x_spin,
|
||||
self.sensor_y_spin,
|
||||
self.sensor_z_spin,
|
||||
self.sensor_yaw_spin,
|
||||
):
|
||||
spin.valueChanged.connect(self._on_frame_inputs_changed)
|
||||
|
||||
# The two summary lines live here rather than under the 3D view, where they
|
||||
# were competing with it for height: both are numbers about the pick, so they
|
||||
# belong beside the numbers. Three lines of legend under the picture left the
|
||||
# picture 240 px tall on a 1080 screen — the one pane that has to be read.
|
||||
self.reach_label = QLabel(box)
|
||||
self.reach_label.setObjectName("hintLabel")
|
||||
self.reach_label.setWordWrap(True)
|
||||
grid.addWidget(self.reach_label, len(rows) + 3, 0, 1, 2)
|
||||
self.path_label = QLabel(box)
|
||||
self.path_label.setObjectName("hintLabel")
|
||||
self.path_label.setWordWrap(True)
|
||||
grid.addWidget(self.path_label, len(rows) + 4, 0, 1, 2)
|
||||
grid.setRowStretch(len(rows) + 5, 1)
|
||||
|
||||
grid.setColumnStretch(1, 1)
|
||||
self._readout_box = box
|
||||
return box
|
||||
|
||||
def _mm_spin(self, box: QWidget, low: float, high: float) -> QDoubleSpinBox:
|
||||
"""A millimetre field with the shared range, step and suffix.
|
||||
|
||||
``compactField`` because these sit two to a row: the theme's normal input
|
||||
padding makes a field 40 px tall, which is right in a form and overflows a
|
||||
cell in a paired column.
|
||||
"""
|
||||
spin = QDoubleSpinBox(box)
|
||||
spin.setObjectName("compactField")
|
||||
spin.setRange(low, high)
|
||||
spin.setDecimals(3)
|
||||
spin.setSingleStep(0.1)
|
||||
spin.setSuffix(" mm")
|
||||
spin.setFixedHeight(28)
|
||||
return spin
|
||||
|
||||
def _build_preview(self) -> QWidget:
|
||||
from .pose_preview import PosePreview
|
||||
|
||||
@@ -343,11 +491,32 @@ class FingerPanel(QGroupBox):
|
||||
column.setContentsMargins(8, 6, 8, 8)
|
||||
column.setSpacing(6)
|
||||
self.preview = PosePreview(box)
|
||||
# The full colour key, on hover. The one-line label below carries the short
|
||||
# version: a three-sentence legend under the picture took the height the
|
||||
# picture needed, and a key nobody can see is no better than no key.
|
||||
self.preview.setToolTip(t("finger.preview_legend"))
|
||||
column.addWidget(self.preview, 1)
|
||||
checks = QHBoxLayout()
|
||||
checks.setContentsMargins(0, 0, 0, 0)
|
||||
checks.setSpacing(10)
|
||||
self.axes_check = QCheckBox(box)
|
||||
self.axes_check.setChecked(True)
|
||||
self.axes_check.toggled.connect(self.preview.set_show_axes)
|
||||
column.addWidget(self.axes_check)
|
||||
checks.addWidget(self.axes_check)
|
||||
# The table's own frame: origin plane, the two rotation axes through it, the
|
||||
# table marker, and the sensor base lifted by its z setup offset. Kept
|
||||
# separate from the sensor axes above, because this is the machine's frame.
|
||||
self.table_check = QCheckBox(box)
|
||||
self.table_check.setChecked(True)
|
||||
self.table_check.toggled.connect(self.preview.set_show_table)
|
||||
checks.addWidget(self.table_check)
|
||||
# The planned path: the arc the stages sweep, then the straight table move.
|
||||
self.trajectory_check = QCheckBox(box)
|
||||
self.trajectory_check.setChecked(True)
|
||||
self.trajectory_check.toggled.connect(self.preview.set_show_trajectory)
|
||||
checks.addWidget(self.trajectory_check)
|
||||
checks.addStretch(1)
|
||||
column.addLayout(checks)
|
||||
controls = QHBoxLayout()
|
||||
controls.setContentsMargins(0, 0, 0, 0)
|
||||
controls.setSpacing(6)
|
||||
@@ -358,6 +527,10 @@ class FingerPanel(QGroupBox):
|
||||
self.sim_button.setEnabled(False)
|
||||
self.sim_button.clicked.connect(self.preview.start_simulation)
|
||||
self.preview.simulationChanged.connect(self._on_simulation_changed)
|
||||
# A failed paint has to reach a label. Left to the widget alone it showed as
|
||||
# a scene that was merely missing some of its parts, which is how a broken
|
||||
# colour call hid the base plane and the whole trajectory without a word.
|
||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||
controls.addWidget(self.sim_button)
|
||||
controls.addStretch(1)
|
||||
column.addLayout(controls)
|
||||
@@ -368,10 +541,6 @@ class FingerPanel(QGroupBox):
|
||||
self.legend_label.setObjectName("hintLabel")
|
||||
self.legend_label.setWordWrap(True)
|
||||
column.addWidget(self.legend_label)
|
||||
self.reach_label = QLabel(box)
|
||||
self.reach_label.setObjectName("hintLabel")
|
||||
self.reach_label.setWordWrap(True)
|
||||
column.addWidget(self.reach_label)
|
||||
self._preview_box = box
|
||||
return box
|
||||
|
||||
@@ -382,35 +551,49 @@ class FingerPanel(QGroupBox):
|
||||
column.setContentsMargins(8, 6, 8, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
headers = (
|
||||
"x", "y", "z", "nx", "ny", "nz",
|
||||
"X", "Y", "Z", "yaw", "pitch", "residual",
|
||||
)
|
||||
self.table = QTableWidget(0, len(headers), box)
|
||||
self.table = QTableWidget(0, len(self._headers) + 1, box)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||
# Editable, but only on a deliberate double-click: a single click selects
|
||||
# the row (and drives the markers), which is what an operator does most.
|
||||
self.table.setEditTriggers(
|
||||
QTableWidget.EditTrigger.DoubleClicked
|
||||
| QTableWidget.EditTrigger.EditKeyPressed
|
||||
)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for index in range(len(headers)):
|
||||
self.table.setColumnWidth(0, 34)
|
||||
for index in range(1, len(self._headers) + 1):
|
||||
self.table.setColumnWidth(index, 82)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
len(headers) - 1, QHeaderView.ResizeMode.Stretch
|
||||
len(self._headers), QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.table.setMinimumHeight(90)
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
column.addWidget(self.table, 1)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.clear_button = QPushButton(box)
|
||||
self.clear_button.clicked.connect(self.clear_points)
|
||||
self.remove_button = QPushButton(box)
|
||||
self.remove_button.clicked.connect(self.remove_selected)
|
||||
self.up_button = QPushButton(box)
|
||||
self.up_button.clicked.connect(lambda: self.move_selected(-1))
|
||||
self.down_button = QPushButton(box)
|
||||
self.down_button.clicked.connect(lambda: self.move_selected(1))
|
||||
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
|
||||
row.addWidget(button)
|
||||
row.addStretch(1)
|
||||
self._selected_label = QLabel(box)
|
||||
self._selected_label.setObjectName("hintLabel")
|
||||
row.addWidget(self._selected_label)
|
||||
self.count_label = QLabel(box)
|
||||
self.count_label.setObjectName("hintLabel")
|
||||
row.addWidget(self.clear_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.count_label)
|
||||
column.addLayout(row)
|
||||
|
||||
self._table_box = box
|
||||
self._headers = headers
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
@@ -424,13 +607,68 @@ class FingerPanel(QGroupBox):
|
||||
return self._stack
|
||||
|
||||
@property
|
||||
def plate_thickness_mm(self) -> float:
|
||||
return float(self.plate_spin.value())
|
||||
def sensor_offset_mm(self) -> tuple[float, float, float]:
|
||||
"""How the sensor is mounted, in the table frame: x, y and z (the plate)."""
|
||||
return (
|
||||
float(self.sensor_x_spin.value()),
|
||||
float(self.sensor_y_spin.value()),
|
||||
float(self.sensor_z_spin.value()),
|
||||
)
|
||||
|
||||
def set_plate_thickness(self, value: float) -> None:
|
||||
self.plate_spin.blockSignals(True)
|
||||
self.plate_spin.setValue(float(value))
|
||||
self.plate_spin.blockSignals(False)
|
||||
@property
|
||||
def sensor_yaw_deg(self) -> float:
|
||||
return float(self.sensor_yaw_spin.value())
|
||||
|
||||
@property
|
||||
def tool_xy_mm(self) -> tuple[float, float]:
|
||||
return (float(self.tool_x_spin.value()), float(self.tool_y_spin.value()))
|
||||
|
||||
def set_tool_xy(self, x_mm: float, y_mm: float) -> None:
|
||||
for spin, value in ((self.tool_x_spin, x_mm), (self.tool_y_spin, y_mm)):
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(float(value))
|
||||
spin.blockSignals(False)
|
||||
self._apply_frame_inputs()
|
||||
|
||||
def set_sensor_setup(self, offset_mm, yaw_deg: float) -> None:
|
||||
"""Put the mounting pose into the fields, then push it to the stack."""
|
||||
values = tuple(float(value) for value in offset_mm) + (0.0, 0.0, 0.0)
|
||||
for spin, value in zip(
|
||||
(self.sensor_x_spin, self.sensor_y_spin, self.sensor_z_spin), values
|
||||
):
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(value)
|
||||
spin.blockSignals(False)
|
||||
self.sensor_yaw_spin.blockSignals(True)
|
||||
self.sensor_yaw_spin.setValue(float(yaw_deg))
|
||||
self.sensor_yaw_spin.blockSignals(False)
|
||||
self._apply_frame_inputs()
|
||||
|
||||
def _on_frame_inputs_changed(self) -> None:
|
||||
"""The setup or the tool moved: the geometry has changed, so re-solve."""
|
||||
self._apply_frame_inputs()
|
||||
self._resolve_everything()
|
||||
|
||||
def _apply_frame_inputs(self) -> None:
|
||||
self._stack.sensor_offset_mm = self.sensor_offset_mm
|
||||
self._stack.sensor_yaw_deg = self.sensor_yaw_deg
|
||||
self._stack.tool_xy_mm = self.tool_xy_mm
|
||||
self.preview.set_setup(self.sensor_offset_mm, self.sensor_yaw_deg)
|
||||
self.preview.set_tool_xy(*self.tool_xy_mm)
|
||||
|
||||
def _resolve_everything(self) -> None:
|
||||
"""Re-solve every stored row, then redraw what the current one shows.
|
||||
|
||||
Every row, not only the current one: the setup and the tool move the whole
|
||||
machine, so a table still holding the old X/Y/Z would be a table of numbers
|
||||
from a machine that no longer exists.
|
||||
"""
|
||||
for row in range(len(self._picked)):
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
self._refresh_path()
|
||||
self._refresh_reach()
|
||||
self.stackChanged.emit()
|
||||
|
||||
def set_bundle_path(self, path: str) -> None:
|
||||
self.path_edit.setText(path)
|
||||
@@ -441,6 +679,7 @@ class FingerPanel(QGroupBox):
|
||||
self.preview.set_bundle(bundle)
|
||||
self.refresh_readouts()
|
||||
self._refresh_reach()
|
||||
self.path_label.setText("")
|
||||
|
||||
def _on_simulation_changed(self, running: bool) -> None:
|
||||
"""Keep the button honest about what it will do next."""
|
||||
@@ -470,12 +709,19 @@ class FingerPanel(QGroupBox):
|
||||
|
||||
def clear_points(self) -> None:
|
||||
self._picked.clear()
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
finally:
|
||||
self._writing = False
|
||||
self.preview.set_pose(None)
|
||||
self.preview.set_point(None, None)
|
||||
self._sync_sim_button()
|
||||
self.table.setRowCount(0)
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_marker(None)
|
||||
self.count_label.setText(t("finger.point_count", count=0))
|
||||
self.view.set_selected(None)
|
||||
self._sync_sim_button()
|
||||
self._refresh_count()
|
||||
self.path_label.setText("")
|
||||
|
||||
def point_count(self) -> int:
|
||||
return self.table.rowCount()
|
||||
@@ -501,9 +747,9 @@ class FingerPanel(QGroupBox):
|
||||
return None
|
||||
|
||||
point, normal, face = sample
|
||||
pose = self._stack.solve(
|
||||
point, normal, plate_thickness_mm=self.plate_thickness_mm
|
||||
)
|
||||
pose = self._stack.solve(point, normal)
|
||||
# The click leaves a cross-hair where it landed *and* promotes the new row
|
||||
# to the selection, so the red dot marks the same point in both views.
|
||||
self.view.set_marker((x, y))
|
||||
self.preview.set_stack(self._stack)
|
||||
self.preview.set_point(point, normal)
|
||||
@@ -522,8 +768,16 @@ class FingerPanel(QGroupBox):
|
||||
# Hand the solved pose to the preview so the play button demonstrates
|
||||
# exactly the numbers on screen, rather than a second solve of its own.
|
||||
self.preview.set_pose(pose)
|
||||
self.preview.set_highlight(point)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose, face)
|
||||
self._refresh_path()
|
||||
# Select the row just added, so the table agrees with the dot.
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(self.table.rowCount() - 1, 1)
|
||||
finally:
|
||||
self._writing = False
|
||||
return row
|
||||
|
||||
def _append_row(self, row: dict[str, float]) -> None:
|
||||
@@ -532,17 +786,249 @@ class FingerPanel(QGroupBox):
|
||||
# the displayed text would quietly hand a later export coordinates the
|
||||
# solver never produced.
|
||||
self._picked.append(dict(row))
|
||||
index = self.table.rowCount()
|
||||
self.table.insertRow(index)
|
||||
for column, key in enumerate(self._headers):
|
||||
item = QTableWidgetItem(f"{row[key]:.3f}")
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.table.setItem(index, column, item)
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
self._write_row(self.table.rowCount() - 1)
|
||||
while self.table.rowCount() > MAX_POINTS:
|
||||
self.table.removeRow(0)
|
||||
while len(self._picked) > MAX_POINTS:
|
||||
self._picked.pop(0)
|
||||
self.count_label.setText(t("finger.point_count", count=self.table.rowCount()))
|
||||
self._refresh_count()
|
||||
|
||||
# ------------------------------------------------------- table row plumbing
|
||||
|
||||
def _write_row(self, index: int) -> None:
|
||||
"""Render one ``_picked`` row into the table, without re-triggering edits."""
|
||||
if not (0 <= index < len(self._picked)):
|
||||
return
|
||||
row = self._picked[index]
|
||||
self._writing = True
|
||||
try:
|
||||
for offset, key in enumerate(self._headers):
|
||||
column = offset + 1
|
||||
item = self.table.item(index, column)
|
||||
if item is None:
|
||||
item = QTableWidgetItem()
|
||||
item.setTextAlignment(
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
if offset >= self.INPUT_COLUMNS:
|
||||
# Solved values are shown, not typed: editing the normal and
|
||||
# reading the pose is the workflow, and the other way round
|
||||
# would need an inverse the table cannot express.
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
item.setForeground(QColor("#5c6678"))
|
||||
self.table.setItem(index, column, item)
|
||||
item.setText(f"{row[key]:.3f}")
|
||||
self._ensure_selector(index)
|
||||
finally:
|
||||
self._writing = False
|
||||
|
||||
def _ensure_selector(self, index: int, checked: bool = False) -> None:
|
||||
"""Give a row its select checkbox, created once."""
|
||||
if self.table.cellWidget(index, self.SELECT_COLUMN) is not None:
|
||||
return
|
||||
holder = QWidget(self.table)
|
||||
layout = QHBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
check = QCheckBox(holder)
|
||||
check.setChecked(checked)
|
||||
check.toggled.connect(self._refresh_count)
|
||||
layout.addWidget(check)
|
||||
self.table.setCellWidget(index, self.SELECT_COLUMN, holder)
|
||||
|
||||
def _selector(self, index: int) -> QCheckBox | None:
|
||||
holder = self.table.cellWidget(index, self.SELECT_COLUMN)
|
||||
if holder is None:
|
||||
return None
|
||||
return holder.findChild(QCheckBox)
|
||||
|
||||
def selected_rows(self) -> list[int]:
|
||||
"""Rows whose checkbox is ticked, ascending."""
|
||||
return [
|
||||
index
|
||||
for index in range(self.table.rowCount())
|
||||
if (check := self._selector(index)) is not None and check.isChecked()
|
||||
]
|
||||
|
||||
def remove_selected(self) -> int:
|
||||
"""Drop every ticked row, and the last-picked point if none is ticked."""
|
||||
rows = self.selected_rows()
|
||||
if not rows:
|
||||
row = self.table.currentRow()
|
||||
rows = [row] if row >= 0 else []
|
||||
for index in sorted(rows, reverse=True):
|
||||
self.table.removeRow(index)
|
||||
if 0 <= index < len(self._picked):
|
||||
self._picked.pop(index)
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return len(rows)
|
||||
|
||||
def move_selected(self, offset: int) -> bool:
|
||||
"""Shift the ticked rows (or the current one) up or down one place.
|
||||
|
||||
Moves the topmost row first when going down and the bottom-most first when
|
||||
going up, so a contiguous block keeps its internal order and no row is
|
||||
swapped twice. The current row follows the move, so repeating the action
|
||||
keeps pushing the same point rather than stranding the selection.
|
||||
"""
|
||||
rows = self.selected_rows() or (
|
||||
[self.table.currentRow()] if self.table.currentRow() >= 0 else []
|
||||
)
|
||||
if not rows:
|
||||
return False
|
||||
ordered = sorted(rows, reverse=offset > 0)
|
||||
moved = False
|
||||
landed = self.table.currentRow()
|
||||
for index in ordered:
|
||||
target = index + offset
|
||||
if not (0 <= target < self.table.rowCount()):
|
||||
continue
|
||||
if target in rows: # a selected neighbour will move too; do not swap twice
|
||||
continue
|
||||
self._swap_rows(index, target)
|
||||
if index == self.table.currentRow():
|
||||
landed = target
|
||||
moved = True
|
||||
if moved:
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(landed, max(1, self.table.currentColumn()))
|
||||
finally:
|
||||
self._writing = False
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return moved
|
||||
|
||||
def _swap_rows(self, first: int, second: int) -> None:
|
||||
"""Exchange two table rows and their stored points, selection included."""
|
||||
self._picked[first], self._picked[second] = self._picked[second], self._picked[first]
|
||||
checked = [
|
||||
bool((check := self._selector(row)) is not None and check.isChecked())
|
||||
for row in (first, second)
|
||||
]
|
||||
self._writing = True
|
||||
try:
|
||||
# Rebuild both rows from the swapped data: simpler than moving items, and
|
||||
# it cannot leave a stale value behind. ``takeCellWidget`` does not exist,
|
||||
# so the old checkboxes are detached by replacing them with None.
|
||||
for row in (first, second):
|
||||
for column in range(1, len(self._headers) + 1):
|
||||
self.table.takeItem(row, column)
|
||||
self.table.removeCellWidget(row, self.SELECT_COLUMN)
|
||||
self._write_row(first)
|
||||
self._write_row(second)
|
||||
finally:
|
||||
self._writing = False
|
||||
for row, was_checked in zip((first, second), checked):
|
||||
check = self._selector(row)
|
||||
if check is not None:
|
||||
check.setChecked(was_checked)
|
||||
|
||||
def _refresh_count(self) -> None:
|
||||
total = self.table.rowCount()
|
||||
chosen = len(self.selected_rows())
|
||||
self.count_label.setText(t("finger.point_count", count=total))
|
||||
self.remove_button.setEnabled(total > 0)
|
||||
self.up_button.setEnabled(total > 0)
|
||||
self.down_button.setEnabled(total > 0)
|
||||
self._selected_label.setText(
|
||||
t("finger.selected_count", count=chosen) if chosen else ""
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- editing
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
"""An operator edited a cell: re-solve the row and refresh everything.
|
||||
|
||||
Only the six input columns are editable, so an edit always means "this
|
||||
sensor-frame point or normal changed". The pose columns are redrawn from the
|
||||
new solve rather than trusted, which is what keeps the table and the readouts
|
||||
from disagreeing.
|
||||
"""
|
||||
if self._writing:
|
||||
return
|
||||
column = item.column() - 1
|
||||
row = item.row()
|
||||
if column < 0 or column >= self.INPUT_COLUMNS:
|
||||
return
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
try:
|
||||
value = float(item.text())
|
||||
except ValueError:
|
||||
# Put the old number back rather than storing a non-number.
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
if not math.isfinite(value):
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
|
||||
record = self._picked[row]
|
||||
record[self._headers[column]] = value
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
|
||||
def resolve_row(self, row: int) -> None:
|
||||
"""Re-solve a stored row from its sensor-frame point and normal."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
length = float(np.linalg.norm(normal))
|
||||
if length < 1e-12:
|
||||
return
|
||||
normal = normal / length
|
||||
pose = self._stack.solve(point, normal)
|
||||
record.update(
|
||||
{
|
||||
"nx": float(normal[0]), "ny": float(normal[1]), "nz": float(normal[2]),
|
||||
"X": float(pose.target_xy_mm[0]), "Y": float(pose.target_xy_mm[1]),
|
||||
"Z": float(pose.contact_z_mm),
|
||||
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
|
||||
"residual": float(pose.residual_deg),
|
||||
}
|
||||
)
|
||||
self._write_row(row)
|
||||
|
||||
def _on_selection_changed(self) -> None:
|
||||
"""Follow the current row in both pictures, so the marker means something."""
|
||||
row = self.table.currentRow()
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
self.show_point(row)
|
||||
|
||||
def show_point(self, row: int) -> None:
|
||||
"""Point the 2D and 3D markers at one row: this is the point to be tested."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
self.preview.set_point(point, normal)
|
||||
self.preview.set_highlight(point)
|
||||
if self.view.flat is not None:
|
||||
pixel = self.view.pixel_for_point(point)
|
||||
self.view.set_selected(pixel)
|
||||
pose = self._stack.solve(point, normal)
|
||||
self._last_pose = pose
|
||||
self.preview.set_pose(pose)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose)
|
||||
self._refresh_path()
|
||||
|
||||
def _after_points_changed(self) -> None:
|
||||
"""Keep the picture honest after a delete, reorder or edit."""
|
||||
row = self.table.currentRow()
|
||||
if 0 <= row < len(self._picked):
|
||||
self.show_point(row)
|
||||
else:
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_selected(None)
|
||||
|
||||
# ----------------------------------------------------------------- readouts
|
||||
|
||||
@@ -550,29 +1036,25 @@ class FingerPanel(QGroupBox):
|
||||
_last_face = -1
|
||||
|
||||
def refresh_readouts(self, point=None, normal=None, pose=None, face: int = -1) -> None:
|
||||
bundle = self._bundle
|
||||
self._readout_labels["frames"].setText(describe_frames(self._stack.axis_map))
|
||||
if bundle is None:
|
||||
self._readout_labels["bundle"].setText("—")
|
||||
for key in ("sensor_point", "sensor_normal", "platform_point", "pose", "residual"):
|
||||
if self._bundle is None:
|
||||
for key in (
|
||||
"sensor_point",
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"table_move",
|
||||
"residual",
|
||||
):
|
||||
self._readout_labels[key].setText("—")
|
||||
return
|
||||
|
||||
info = bundle.describe()
|
||||
self._readout_labels["bundle"].setText(
|
||||
t(
|
||||
"finger.bundle_summary",
|
||||
vertices=info["vertices"],
|
||||
triangles=info["triangles"],
|
||||
extent=" × ".join(f"{value:.2f}" for value in info["extent_mm"]),
|
||||
scale=f"{info['mm_per_unit']:.6f}",
|
||||
)
|
||||
)
|
||||
if point is None:
|
||||
self._readout_labels["table_move"].setText("—")
|
||||
return
|
||||
platform = self._stack.point_to_platform(
|
||||
point, plate_thickness_mm=self.plate_thickness_mm
|
||||
)
|
||||
# The contact where the mounted sensor actually puts it, so the platform
|
||||
# coordinates on screen are the machine's, not the drawing's.
|
||||
platform = self._stack.setup_point(point)
|
||||
self._readout_labels["sensor_point"].setText(
|
||||
" ".join(f"{value:+.3f}" for value in point) + " mm"
|
||||
)
|
||||
@@ -582,6 +1064,11 @@ class FingerPanel(QGroupBox):
|
||||
self._readout_labels["platform_point"].setText(
|
||||
" ".join(f"{value:+.3f}" for value in platform) + " mm"
|
||||
)
|
||||
self._readout_labels["table_move"].setText(
|
||||
"—"
|
||||
if pose is None
|
||||
else f"{pose.table_xy_mm[0]:+.3f} {pose.table_xy_mm[1]:+.3f} mm"
|
||||
)
|
||||
if pose is not None:
|
||||
self._readout_labels["pose"].setText(pose.describe())
|
||||
self._readout_labels["residual"].setText(
|
||||
@@ -597,6 +1084,39 @@ class FingerPanel(QGroupBox):
|
||||
)
|
||||
del face
|
||||
|
||||
def _refresh_path(self) -> None:
|
||||
"""Report what the planned motion does, and where it actually arrives.
|
||||
|
||||
The last line is the x-y check the bench asks about. It is printed as two
|
||||
pairs — where the drawing lands the contact, and where the tool is — because
|
||||
they are computed two different ways (the drawing's own transform, and the
|
||||
tool's setup number) and must agree. When they do not, the line says so
|
||||
instead of leaving it to be judged off a picture.
|
||||
"""
|
||||
summary = self.preview.trajectory_summary()
|
||||
if not summary:
|
||||
self.path_label.setText("")
|
||||
return
|
||||
text = t(
|
||||
"finger.path_summary",
|
||||
reach=f"{summary['reach_mm']:.1f}",
|
||||
radius=f"{summary['arc_radius_mm']:.1f}",
|
||||
)
|
||||
landing = self.preview.landing_xy_mm()
|
||||
if landing is not None:
|
||||
tool = self._stack.tool_xy_mm
|
||||
gap = float(np.hypot(landing[0] - tool[0], landing[1] - tool[1]))
|
||||
verdict = (
|
||||
t("finger.landing_on_tool")
|
||||
if gap <= 1e-6
|
||||
else t("finger.landing_off_tool", gap=f"{gap:.3f}")
|
||||
)
|
||||
text = (
|
||||
f"{text} {t('finger.landing', x=f'{landing[0]:+.3f}', y=f'{landing[1]:+.3f}')}"
|
||||
f" / {t('finger.tool_at', x=f'{tool[0]:+.3f}', y=f'{tool[1]:+.3f}')} {verdict}"
|
||||
)
|
||||
self.path_label.setText(text)
|
||||
|
||||
def _refresh_reach(self) -> None:
|
||||
"""The reach summary, or why there is no picture instead of silently nothing.
|
||||
|
||||
@@ -620,22 +1140,6 @@ class FingerPanel(QGroupBox):
|
||||
|
||||
# ------------------------------------------------------------------ handlers
|
||||
|
||||
def _on_plate_changed(self, _value: float) -> None:
|
||||
self.stackChanged.emit()
|
||||
|
||||
def _on_offset_changed(self) -> None:
|
||||
parts = self.offset_edit.text().replace(",", " ").split()
|
||||
try:
|
||||
triple = tuple(float(part) for part in parts[:3])
|
||||
except ValueError:
|
||||
return
|
||||
if len(triple) == 3:
|
||||
self._stack.tool_offset_mm = triple # type: ignore[assignment]
|
||||
self.stackChanged.emit()
|
||||
|
||||
def set_offset(self, triple) -> None:
|
||||
self.offset_edit.setText(" ".join(f"{value:g}" for value in triple))
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
@@ -653,22 +1157,38 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal": "finger.row_sensor_normal",
|
||||
"platform_point": "finger.row_platform_point",
|
||||
"pose": "finger.row_pose",
|
||||
"table_move": "finger.row_table_move",
|
||||
"residual": "finger.row_residual",
|
||||
"frames": "finger.row_frames",
|
||||
"bundle": "finger.row_bundle",
|
||||
}
|
||||
grid = self._readout_box.layout()
|
||||
for row, key in enumerate(labels):
|
||||
widget = grid.itemAtPosition(row, 0)
|
||||
if widget is not None and widget.widget() is not None:
|
||||
widget.widget().setText(t(labels[key]))
|
||||
self.plate_label.setText(t("finger.plate_thickness"))
|
||||
self.offset_label.setText(t("finger.tool_offset"))
|
||||
self.legend_label.setText(t("finger.preview_legend"))
|
||||
self.tool_label.setText(t("finger.tool_xy"))
|
||||
self.setup_xy_label.setText(t("finger.sensor_xy"))
|
||||
self.setup_zy_label.setText(t("finger.sensor_zy"))
|
||||
for spin in (self.tool_x_spin, self.tool_y_spin):
|
||||
spin.setToolTip(t("finger.tool_hint"))
|
||||
for spin in (self.sensor_x_spin, self.sensor_y_spin, self.sensor_z_spin):
|
||||
spin.setToolTip(t("finger.setup_hint"))
|
||||
self.sensor_yaw_spin.setToolTip(t("finger.setup_hint"))
|
||||
self.legend_label.setText(t("finger.preview_legend_short"))
|
||||
self.preview.setToolTip(t("finger.preview_legend"))
|
||||
self.axes_check.setText(t("finger.show_axes"))
|
||||
self.table_check.setText(t("finger.show_table"))
|
||||
self.trajectory_check.setText(t("finger.show_trajectory"))
|
||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||
self._sync_sim_button()
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
self.clear_button.setText(t("finger.clear_points"))
|
||||
self.count_label.setText(t("finger.point_count", count=self.table.rowCount()))
|
||||
self.table.setHorizontalHeaderLabels([t(f"finger.col_{key}") for key in self._headers])
|
||||
self.remove_button.setText(t("finger.remove_selected"))
|
||||
self.up_button.setText(t("finger.move_up"))
|
||||
self.down_button.setText(t("finger.move_down"))
|
||||
self.table.setToolTip(t("finger.table_hint"))
|
||||
self._refresh_count()
|
||||
|
||||
self.status_label.setText(t("finger.select_hint"))
|
||||
|
||||
@@ -74,6 +74,25 @@ logger = logging.getLogger(__name__)
|
||||
#: window width.
|
||||
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||
|
||||
|
||||
def _triple(value: object, length: int) -> tuple[float, ...]:
|
||||
"""A numeric preference as a fixed-length tuple, tolerant of a hand-edited file.
|
||||
|
||||
``None``, a wrong length or a string in the JSON must not stop the window from
|
||||
opening: the finger screen's setup numbers are all zero by default, and a
|
||||
malformed one is better replaced than raised on.
|
||||
"""
|
||||
values = value if isinstance(value, (list, tuple)) else ()
|
||||
numbers = []
|
||||
for item in list(values)[:length]:
|
||||
try:
|
||||
numbers.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
numbers.append(0.0)
|
||||
while len(numbers) < length:
|
||||
numbers.append(0.0)
|
||||
return tuple(numbers)
|
||||
|
||||
#: Width below which the rail would clip its own controls.
|
||||
MAIN_MINIMUM_WIDTH = 1440
|
||||
|
||||
@@ -230,17 +249,14 @@ class AutoCalMainWindow(QMainWindow):
|
||||
yaw_limit_deg=float(self.prefs.get("finger_yaw_limit_deg") or 90.0),
|
||||
pitch_limit_deg=float(self.prefs.get("finger_pitch_limit_deg") or 360.0),
|
||||
)
|
||||
offset = self.prefs.get("finger_tool_offset_mm") or [0.0, 0.0, 0.0]
|
||||
if isinstance(offset, list) and len(offset) == 3:
|
||||
self.finger_stack.tool_offset_mm = tuple(float(v) for v in offset)
|
||||
self.finger_panel = FingerPanel(
|
||||
self.finger_stack,
|
||||
bundle_path=str(self.prefs.get("finger_bundle_path") or ""),
|
||||
plate_thickness_mm=float(self.prefs.get("finger_plate_thickness_mm") or 0.0),
|
||||
sensor_offset_mm=_triple(self.prefs.get("finger_sensor_offset_mm"), 3),
|
||||
sensor_yaw_deg=float(self.prefs.get("finger_sensor_yaw_deg") or 0.0),
|
||||
tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
|
||||
parent=body,
|
||||
)
|
||||
self.finger_panel.set_offset(self.finger_stack.tool_offset_mm)
|
||||
|
||||
self.workspace = WorkspaceTabs(
|
||||
self.axis_panel, self.sequence_panel, self.finger_panel, body
|
||||
)
|
||||
@@ -286,6 +302,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||
# The readout rail's own batch buttons, connected once here. They were
|
||||
# connected from inside the finger screen's stack-changed handler, which
|
||||
# meant every edit on that screen added another copy.
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -321,7 +343,9 @@ class AutoCalMainWindow(QMainWindow):
|
||||
t(
|
||||
"finger.loaded",
|
||||
vertices=info["vertices"],
|
||||
triangles=info["triangles"],
|
||||
extent=" × ".join(f"{value:.2f}" for value in info["extent_mm"]),
|
||||
scale=f"{info['mm_per_unit']:.6f}",
|
||||
),
|
||||
ok=True,
|
||||
)
|
||||
@@ -396,13 +420,18 @@ class AutoCalMainWindow(QMainWindow):
|
||||
)
|
||||
|
||||
def _on_finger_stack_changed(self) -> None:
|
||||
"""The mounting pose, the tool position or the limits moved.
|
||||
|
||||
Only the state that depends on them is refreshed here. This handler used to
|
||||
connect three axis-readout signals as well, on every call — harmless while
|
||||
the stack changed once at startup, and a multiplying pile of duplicate
|
||||
connections the moment a spin box started changing it.
|
||||
"""
|
||||
self.finger_panel.preview.set_stack(self.finger_panel.stack)
|
||||
self.finger_panel._refresh_reach()
|
||||
self.prefs["finger_plate_thickness_mm"] = self.finger_panel.plate_thickness_mm
|
||||
self.prefs["finger_tool_offset_mm"] = list(self.finger_panel.stack.tool_offset_mm)
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.finger_panel._refresh_reach() # noqa: SLF001 - the panel owns the label
|
||||
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
|
||||
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
|
||||
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
|
||||
|
||||
# ---------------------------------------------------------------- run bar
|
||||
|
||||
|
||||
Reference in New Issue
Block a user