Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions rayforge/machine/models/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,6 @@ def _clamp_jog_distance(
return target - pos


def _shift_pos(pos: float | None, offset: float) -> float | None:
"""Convert a machine position into work coordinates."""
if pos is None:
return None
return pos - offset


def _raise_error(*args, **kwargs):
raise RuntimeError("Cannot schedule from worker process")

Expand Down Expand Up @@ -1217,7 +1210,13 @@ def get_beam_position(self) -> Point | None:
return m_pos[0], m_pos[1]

def get_soft_limits(self) -> Rect:
"""Get the soft limits as (x_min, y_min, x_max, y_max)."""
"""
Get the soft limits as (x_min, y_min, x_max, y_max).

The rect is in machine coordinates: it is anchored to the
physical bed and does not move with the work origin. Configured
values are entered as distances from the origin corner.
"""
if self._soft_limits is not None:
x_min, y_min, x_max, y_max = self._soft_limits
if self.reverse_x_axis:
Expand Down Expand Up @@ -1253,15 +1252,19 @@ def would_jog_exceed_limits(self, axis: Axis, distance: float) -> bool:
def _adjust_jog_distance_for_limits(
self, axis: Axis, distance: float
) -> float:
"""Adjust jog distance to stay within soft limits."""
"""Adjust jog distance to stay within soft limits.

The limits describe the physical bed in machine coordinates and
are compared against the machine position, so they hold
regardless of where the work origin (WCS) is zeroed.
"""
if not self.soft_limits_enabled:
return distance

current_pos = self.device_state.machine_pos
off_x, off_y, _ = self.get_active_wcs_offset()
x_pos = _shift_pos(current_pos[0], off_x)
y_pos = _shift_pos(current_pos[1], off_y)
x_min, y_min, x_max, y_max = self.get_soft_limits()
x_pos = current_pos[0]
y_pos = current_pos[1]
adjusted_distance = distance

# Check X axis
Expand Down
6 changes: 4 additions & 2 deletions rayforge/ui_gtk/canvas2d/elements/work_origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ def draw(self, ctx: cairo.Context):
"""
ctx.save()

# Set drawing properties
# Set drawing properties. The stroke uses a device-space width:
# a world-unit width would be thinner than a pixel at most zoom
# levels, leaving the marker invisible after anti-aliasing.
ctx.set_source_rgba(0.2, 0.8, 0.2, 0.9) # A distinct green color
ctx.set_line_width(0.2) # Use a thin line width in world units (mm)
ctx.set_hairline(True)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.set_line_join(cairo.LINE_JOIN_ROUND)

Expand Down
75 changes: 39 additions & 36 deletions tests/machine/models/test_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2618,72 +2618,75 @@ def test_would_jog_exceed_limits_matches_clamp_outside_range(
)

@pytest.mark.parametrize(
"work_pos, axis, distance, expected",
"machine_pos, axis, distance, expected",
[
# Work position X 100, Y 170, far from every edge: all
# four directions jog freely (issue #574)
((100, 170), Axis.X, -10.0, -10.0),
((100, 170), Axis.X, 10.0, 10.0),
((100, 170), Axis.Y, -10.0, -10.0),
((100, 170), Axis.Y, 10.0, 10.0),
# Near the max edge: outward shortens, inward is untouched
((390, 170), Axis.X, 10.0, 5.0),
((390, 170), Axis.X, -10.0, -10.0),
# Near the origin: negative jogs shorten
((3, 170), Axis.X, -10.0, -3.0),
((100, 3), Axis.Y, -10.0, -3.0),
# Mid-bed: all four directions jog freely
((-200.0, -170.0), Axis.X, -10.0, -10.0),
((-200.0, -170.0), Axis.X, 10.0, 10.0),
((-200.0, -170.0), Axis.Y, -10.0, -10.0),
((-200.0, -170.0), Axis.Y, 10.0, 10.0),
# Near the far travel end: outward shortens, inward is
# untouched
((-5.0, -170.0), Axis.X, 10.0, 5.0),
((-5.0, -170.0), Axis.X, -10.0, -10.0),
# Near the switch end: outward jogs shorten
((-392.0, -170.0), Axis.X, -10.0, -3.0),
((-170.0, -392.0), Axis.Y, -10.0, -3.0),
],
)
def test_adjust_jog_distance_uses_work_coordinates(
self, isolated_machine: Machine, work_pos, axis, distance, expected
def test_adjust_jog_distance_uses_machine_coordinates(
self, isolated_machine: Machine, machine_pos, axis, distance, expected
):
"""
Soft limits are work-area bounds, so the jog check must run in
the work coordinate space the position display uses - not in
raw machine coordinates, which differ by the active WCS offset
(issue #574).
Soft limits describe the physical bed in machine coordinates,
so the jog check compares against the machine position and is
unaffected by the active WCS offset.

The machine coordinates simulate a GRBL that homes at the max
end and reports negative machine positions: the G54 offset is
(-387, -387), so work = machine + 387.
This supersedes the work-space evaluation from #574: that
setup paired a bottom-left origin with firmware that reports
negative positions and a G54 used as a UI correction - the
misconfiguration later diagnosed in #613. With the machine
configured to match the firmware (top-right origin, reversed
axes, G54 at zero), machine-space evaluation clamps at the
real bed edges in every zeroing workflow.
"""
isolated_machine.set_axis_extents(395, 395)
isolated_machine.set_reverse_x_axis(True)
isolated_machine.set_reverse_y_axis(True)
isolated_machine.set_soft_limits(0.0, 0.0, 395.0, 395.0)
isolated_machine.update_wcs_offsets_batch(
{"G54": (-387.0, -387.0, 0.0)}
)
isolated_machine.device_state.machine_pos = (
work_pos[0] - 387.0,
work_pos[1] - 387.0,
0.0,
{"G54": (-292.0, -341.0, 0.0)}
)
isolated_machine.device_state.machine_pos = machine_pos + (0.0,)

assert isolated_machine._adjust_jog_distance_for_limits(
axis, distance
) == pytest.approx(expected)

def test_would_jog_exceed_limits_uses_work_coordinates(
def test_would_jog_exceed_limits_ignores_wcs_offset(
self, isolated_machine: Machine
):
"""
The orange jog warning keys off the work position, so a machine
well inside the work area is not flagged even when its raw
machine coordinates sit outside the work-area bounds (issue
#574).
The orange jog warning keys off the machine position against
the bed bounds, so zeroing the WCS mid-bed neither flags valid
jogs nor hides jogs past the bed edge (issue #613).
"""
isolated_machine.set_axis_extents(395, 395)
isolated_machine.set_reverse_x_axis(True)
isolated_machine.set_reverse_y_axis(True)
isolated_machine.update_wcs_offsets_batch(
{"G54": (-387.0, -387.0, 0.0)}
{"G54": (-292.0, -341.0, 0.0)}
)
isolated_machine.device_state.machine_pos = (-287.0, -217.0, 0.0)

assert isolated_machine.would_jog_exceed_limits(Axis.X, -10.0) is False
assert isolated_machine.would_jog_exceed_limits(Axis.X, 10.0) is False

isolated_machine.device_state.machine_pos = (-384.0, -217.0, 0.0)
isolated_machine.device_state.machine_pos = (-394.0, -217.0, 0.0)
assert isolated_machine.would_jog_exceed_limits(Axis.X, -10.0) is True
assert isolated_machine.would_jog_exceed_limits(Axis.X, 10.0) is False

def test_disabled_soft_limits_skip_work_coordinate_check(
def test_disabled_soft_limits_skip_limit_check(
self, isolated_machine: Machine
):
"""
Expand Down
79 changes: 78 additions & 1 deletion tests/machine/models/test_machine_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
from rayforge import context as context_module
from rayforge.context import get_context
from rayforge.core.doc import Doc
from rayforge.machine.driver.driver import Driver, ZHomingMode
from rayforge.machine.driver.driver import (
DeviceState,
Driver,
ZHomingMode,
)
from rayforge.machine.models.coordinate_system import CoordinateSystem
from rayforge.machine.models.dialect_manager import DialectManager
from rayforge.machine.models.machine import Machine, Origin
from rayforge.machine.models.rotary_module import RotaryModule
Expand Down Expand Up @@ -927,3 +932,75 @@ def handler(sender):
machine.changed.connect(handler)
machine.set_driver(TestDriver3, {"port": "/dev/null"})
assert len(signals) == 0


@pytest.mark.usefixtures("lite_context")
class TestSoftLimitClampingSpace:
"""Soft limits are bed-fixed machine-space boxes and must be
clamped against the machine position, not the work position, so
zeroing the WCS mid-bed does not block valid jogs or allow jogs
past the bed edge (issue #613)."""

def _make_machine(self, lite_context):
machine = Machine(lite_context)
machine.set_axis_extents(400.0, 400.0)
machine.set_reverse_x_axis(True)
machine.set_reverse_y_axis(True)
machine.set_soft_limits_enabled(True)
return machine

def _set_machine_pos(self, machine, x, y):
machine.set_device_state(
DeviceState(machine_pos=(x, y, 0.0), work_pos=(0.0, 0.0, 0.0))
)

def _set_wcs(self, machine, x, y):
machine.coordinate_systems["G54"] = CoordinateSystem(
name="G54", offset=(x, y, 0.0)
)

def test_jog_into_positive_work_space_is_allowed(self, lite_context):
"""Zeroing at an object mid-bed (G54 -292/-341) must not block
jogging right/top away from it."""
machine = self._make_machine(lite_context)
self._set_wcs(machine, -292.0, -341.0)
self._set_machine_pos(machine, -292.0, -341.0)

assert machine._adjust_jog_distance_for_limits(Axis.X, 7.0) == 7.0
assert machine._adjust_jog_distance_for_limits(Axis.Y, 7.0) == 7.0

def test_jog_is_clamped_at_bed_edge(self, lite_context):
machine = self._make_machine(lite_context)
self._set_machine_pos(machine, -50.0, -50.0)

assert machine._adjust_jog_distance_for_limits(Axis.X, 100.0) == 50.0
assert (
machine._adjust_jog_distance_for_limits(Axis.Y, -100.0) == -100.0
)
assert (
machine._adjust_jog_distance_for_limits(Axis.Y, -400.0) == -350.0
)

def test_configured_limits_clamp_in_machine_space(self, lite_context):
"""User limits 0..397 are origin-corner distances: machine
[-397, 0] on a reversed machine."""
machine = self._make_machine(lite_context)
machine.set_soft_limits(0.0, 0.0, 397.0, 397.0)
self._set_machine_pos(machine, -100.0, -100.0)

assert machine.get_soft_limits() == (-397.0, -397.0, 0.0, 0.0)
assert machine._adjust_jog_distance_for_limits(Axis.X, 7.0) == 7.0
assert (
machine._adjust_jog_distance_for_limits(Axis.X, -500.0) == -297.0
)

def test_work_position_shift_no_longer_applied(self, lite_context):
"""Regression: with a mid-bed WCS zero the work position used
to be compared against machine-space limits, blocking every
positive-direction jog."""
machine = self._make_machine(lite_context)
self._set_wcs(machine, -292.0, -341.0)
self._set_machine_pos(machine, -292.0, -341.0)

assert machine.would_jog_exceed_limits(Axis.X, 7.0) is False
assert machine.would_jog_exceed_limits(Axis.Y, 7.0) is False
61 changes: 61 additions & 0 deletions tests/ui_gtk/canvas2d/test_work_origin_element.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Unit tests for the WorkOriginElement rendering.
"""

import cairo
import numpy as np
import pytest
from raygeo.geo import Matrix

from rayforge.ui_gtk.canvas2d.elements.work_origin import WorkOriginElement


def _render_marker(scale: float) -> np.ndarray:
"""Renders the marker into a small image and returns it as RGB.

The parent canvas normally applies the element transform to the
Cairo context before draw(); the test does the same.
"""
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 120, 120)
ctx = cairo.Context(surface)
# Dark background like the canvas theme, so diluted strokes show up
ctx.set_source_rgb(0.1, 0.1, 0.1)
ctx.paint()

element = WorkOriginElement()
matrix = Matrix.translation(20, 20) @ Matrix.scale(scale, scale)
ctx.transform(cairo.Matrix(*matrix.for_cairo()))
element.draw(ctx)
surface.flush()

buf = np.ndarray(
shape=(120, 120, 4), dtype=np.uint8, buffer=surface.get_data()
)
return buf[:, :, :3]


@pytest.mark.parametrize("scale", [1.0, 2.0, 6.0])
def test_marker_stays_visible_at_zoom_levels(scale):
"""The stroke width is device-space: the marker must deposit enough
green to survive anti-aliasing at every zoom level. The old
world-unit 0.2 mm width dilutes into sub-pixel lines whose total
green energy stays near zero regardless of zoom."""
# numpy gives BGRA order for little-endian ARGB: [1] is green
rgb = _render_marker(scale).astype(int)
g, b = rgb[:, :, 1], rgb[:, :, 0]
# The stroke must fully cover at least one pixel: a world-unit
# 0.2 mm width covers ~20% of a pixel at low zoom, which the light
# theme background washes out completely.
peak_green = int((g - b).max())
assert peak_green >= 92, (
f"marker stroke too faint at scale {scale}: peak green "
f"coverage {peak_green} (expected >= 92)"
)


def test_marker_points_into_the_bed():
"""Top-right origin: both axes point left and down on screen."""
element = WorkOriginElement()
element.set_axis_direction(x_axis_right=True, y_axis_down=True)
assert element._x_dir == (-1.0, 0.0)
assert element._y_dir == (0.0, -1.0)
7 changes: 4 additions & 3 deletions website/docs/machine/hardware.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ movement outside these limits. When disabled, jogging is not limited.
- **X/Y Min**: Minimum coordinate for each axis
- **X/Y Max**: Maximum coordinate for each axis

Soft limits are evaluated in the work coordinate system shown in the position display, so they stay
put when the work origin moves. When enabled without custom bounds, the work surface bounds are
used. Limits are automatically constrained to stay within the axis extents (0 to extent value).
Soft limits are evaluated in machine coordinates and stay anchored to the physical bed, so they do
not move when the work origin is re-zeroed. Values are entered as distances from the coordinate
origin corner into the bed. When enabled without custom bounds, the axis extents are used. Limits
are automatically constrained to stay within the axis extents (0 to extent value).

<!-- prettier-ignore-start -->
:::tip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ begrenzt.
- **X/Y Min**: Minimale Koordinate für jede Achse
- **X/Y Max**: Maximale Koordinate für jede Achse

Software-Limits werden im Koordinatensystem ausgewertet, das in der Positionsanzeige angezeigt wird,
und bleiben daher beim Verschieben des Arbeitsursprungs an ihrem Platz. Ohne benutzerdefinierte
Grenzen werden bei Aktivierung die Grenzen der Arbeitsfläche verwendet. Limits werden automatisch
eingeschränkt, um innerhalb der Achsenbereiche zu bleiben (0 bis Bereichswert).
Software-Limits werden in Maschinenkoordinaten ausgewertet und bleiben an der physischen Maschine
verankert, sie verschieben sich also nicht, wenn der Arbeitsursprung neu genullt wird. Werte werden
als Abstände von der Ecke des Koordinatenursprungs in die Maschine hinein angegeben. Ohne
benutzerdefinierte Grenzen werden bei Aktivierung die Achsenbereiche verwendet. Limits werden
automatisch eingeschränkt, um innerhalb der Achsenbereiche zu bleiben (0 bis Bereichswert).

<!-- prettier-ignore-start -->
:::tip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ deshabilitados, el desplazamiento no está limitado.
- **X/Y Mín**: Coordenada mínima para cada eje
- **X/Y Máx**: Coordenada máxima para cada eje

Los límites suaves se evalúan en el sistema de coordenadas de trabajo que muestra el indicador de
posición, por lo que se mantienen en su lugar cuando el origen de trabajo se mueve. Sin límites
personalizados, al habilitarlos se usan los límites de la superficie de trabajo. Los límites se
restringen automáticamente para permanecer dentro de la extensión de los ejes (0 al valor de
extensión).
Los límites suaves se evalúan en coordenadas de máquina y permanecen anclados a la cama física, por
lo que no se mueven cuando el origen de trabajo se restablece. Los valores se introducen como
distancias desde la esquina del origen de coordenadas hacia la cama. Sin límites personalizados, al
habilitarlos se usa la extensión de los ejes. Los límites se restringen automáticamente para
permanecer dentro de la extensión de los ejes (0 al valor de extensión).

<!-- prettier-ignore-start -->
:::tip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,11 @@ désactivées, le déplacement n'est pas limité.
- **X/Y Min** : Coordonnée minimum pour chaque axe
- **X/Y Max** : Coordonnée maximum pour chaque axe

Les limites logicielles sont évaluées dans le système de coordonnées de travail affiché dans
l'affichage de position ; elles restent donc en place lorsque l'origine de travail se déplace. Sans
limites personnalisées, l'activation utilise les limites de la surface de travail. Les limites sont
automatiquement contraintes pour rester dans l'étendue des axes (0 à la valeur d'étendue).
Les limites logicielles sont évaluées en coordonnées machine et restent ancrées à la table physique
: elles ne bougent pas lorsque l'origine de travail est remise à zéro. Les valeurs sont saisies
comme des distances depuis le coin de l'origine des coordonnées vers la table. Sans limites
personnalisées, l'activation utilise l'étendue des axes. Les limites sont automatiquement
contraintes pour rester dans l'étendue des axes (0 à la valeur d'étendue).

<!-- prettier-ignore-start -->
:::tip
Expand Down
Loading
Loading