From e07e00dfd43cc65bf5ce12e77346b60ffe8791eb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:04:27 +0200 Subject: [PATCH 01/11] feat: add the Start From job origin model A document now carries a job origin: the Start From mode (Absolute Coordinates, Current Position or User Origin) and one of nine anchor points on the job's bounding box. It is saved with the project and older projects load as Absolute Coordinates. job_placement resolves it against a machine: User Origin is the zero point of the active WCS (or the work area origin), Current Position is the head position the controller reports (the pointer dot while pointer alignment is on) and needs a connected, idle machine. Refs #518 --- rayforge/core/doc.py | 16 ++++ rayforge/core/job_origin.py | 116 +++++++++++++++++++++++++++ rayforge/machine/job_placement.py | 117 +++++++++++++++++++++++++++ tests/core/test_job_origin.py | 104 ++++++++++++++++++++++++ tests/machine/test_job_placement.py | 120 ++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 rayforge/core/job_origin.py create mode 100644 rayforge/machine/job_placement.py create mode 100644 tests/core/test_job_origin.py create mode 100644 tests/machine/test_job_placement.py diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index c6a45d17e..73f3e483e 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -15,6 +15,7 @@ from .color import COLOR_PALETTE from .image_edit import ImageEditStep, missing_image_edit_ops from .item import DocItem +from .job_origin import JobOrigin from .layer import Layer from .source_asset import SourceAsset from .workpiece import WorkPiece @@ -40,6 +41,8 @@ def __init__(self): self.history_manager = HistoryManager() self.active_layer_changed = Signal() self.job_assembly_invalidated = Signal() + self.job_origin_changed = Signal() + self.job_origin: JobOrigin = JobOrigin() # Asset Management self.assets: dict[str, IAsset] = {} @@ -147,6 +150,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc.set_children(children) doc._active_layer_index = data.get("active_layer_index", 0) doc.adopt_legacy_image_edits() + doc.job_origin = JobOrigin.from_dict(data.get("job_origin")) return doc @@ -173,8 +177,20 @@ def to_dict(self) -> dict: "active_layer_index": self._active_layer_index, "children": [child.to_dict() for child in self.children], "assets": [asset.to_dict() for asset in self.get_all_assets()], + "job_origin": self.job_origin.to_dict(), } + def set_job_origin(self, job_origin: JobOrigin): + """ + Sets where jobs of this document start (see JobOrigin) and + invalidates the assembled job, which depends on it. + """ + if job_origin == self.job_origin: + return + self.job_origin = job_origin + self.job_origin_changed.send(self) + self.job_assembly_invalidated.send(self) + def add_asset( self, asset: IAsset, index: int | None = None, silent: bool = False ): diff --git a/rayforge/core/job_origin.py b/rayforge/core/job_origin.py new file mode 100644 index 000000000..7e7e1c576 --- /dev/null +++ b/rayforge/core/job_origin.py @@ -0,0 +1,116 @@ +""" +Job origin ("Start From") settings of a document. + +The job origin decides where a job runs on the bed. With ``ABSOLUTE`` +the job runs exactly where it is placed on the canvas. With +``USER_ORIGIN`` and ``CURRENT_POSITION`` the whole job is translated so +that one of nine points of its bounding box (the anchor) lands on the +zero point of the active work coordinate system or on the reported +head position. +""" + +from dataclasses import dataclass +from enum import Enum +from gettext import gettext as _ +from typing import Any + +from raygeo.geo.types import Point + +# A world-space bounding box as (min_x, min_y, max_x, max_y). +BBox = tuple[float, float, float, float] + + +class StartFrom(Enum): + """Where a job is placed on the machine.""" + + ABSOLUTE = "absolute" + CURRENT_POSITION = "current_position" + USER_ORIGIN = "user_origin" + + @property + def label(self) -> str: + labels = { + StartFrom.ABSOLUTE: _("Absolute Coordinates"), + StartFrom.CURRENT_POSITION: _("Current Position"), + StartFrom.USER_ORIGIN: _("User Origin"), + } + return labels[self] + + +class JobAnchor(Enum): + """ + One of nine points on the job's bounding box. + + The value holds the fractions along X (left to right) and Y + (bottom to top) in world space, which is Y-up. + """ + + TOP_LEFT = (0.0, 1.0) + TOP = (0.5, 1.0) + TOP_RIGHT = (1.0, 1.0) + LEFT = (0.0, 0.5) + CENTER = (0.5, 0.5) + RIGHT = (1.0, 0.5) + BOTTOM_LEFT = (0.0, 0.0) + BOTTOM = (0.5, 0.0) + BOTTOM_RIGHT = (1.0, 0.0) + + @property + def label(self) -> str: + labels = { + JobAnchor.TOP_LEFT: _("Top left"), + JobAnchor.TOP: _("Top center"), + JobAnchor.TOP_RIGHT: _("Top right"), + JobAnchor.LEFT: _("Middle left"), + JobAnchor.CENTER: _("Center"), + JobAnchor.RIGHT: _("Middle right"), + JobAnchor.BOTTOM_LEFT: _("Bottom left"), + JobAnchor.BOTTOM: _("Bottom center"), + JobAnchor.BOTTOM_RIGHT: _("Bottom right"), + } + return labels[self] + + +@dataclass(frozen=True) +class JobOrigin: + """The "Start From" mode and job anchor of a document.""" + + start_from: StartFrom = StartFrom.ABSOLUTE + anchor: JobAnchor = JobAnchor.BOTTOM_LEFT + + @property + def is_absolute(self) -> bool: + return self.start_from == StartFrom.ABSOLUTE + + def to_dict(self) -> dict[str, str]: + return { + "start_from": self.start_from.value, + "anchor": self.anchor.name.lower(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "JobOrigin": + if not data: + return cls() + try: + start_from = StartFrom(data.get("start_from")) + except ValueError: + start_from = StartFrom.ABSOLUTE + anchor_name = str(data.get("anchor", "")).upper() + anchor = JobAnchor.__members__.get(anchor_name, JobAnchor.BOTTOM_LEFT) + return cls(start_from=start_from, anchor=anchor) + + +def anchor_point(rect: BBox, anchor: JobAnchor) -> Point: + """The world-space position of *anchor* on the bounding box.""" + min_x, min_y, max_x, max_y = rect + fx, fy = anchor.value + return (min_x + fx * (max_x - min_x), min_y + fy * (max_y - min_y)) + + +def placement_offset( + rect: BBox, anchor: JobAnchor, start_point: Point +) -> Point: + """The translation that moves *anchor* of *rect* onto *start_point*.""" + ax, ay = anchor_point(rect, anchor) + return (start_point[0] - ax, start_point[1] - ay) diff --git a/rayforge/machine/job_placement.py b/rayforge/machine/job_placement.py new file mode 100644 index 000000000..cae40e894 --- /dev/null +++ b/rayforge/machine/job_placement.py @@ -0,0 +1,117 @@ +""" +Resolve a document's job origin ("Start From") against a machine. + +The job origin is applied as one rigid XY translation of the whole job +in world space. This module turns the abstract setting into that +translation: it finds the start point (the zero point of the active +WCS, or the reported head position) and the offset that moves the +job's anchor onto it. + +The G-code stays absolute in the active WCS; nothing is written to the +controller (no G92 or G10), so an aborted job leaves no hidden offset +behind. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point + +from ..core.job_origin import BBox, JobOrigin, StartFrom, placement_offset +from .driver.driver import DeviceStatus +from .models.coordspace import MachineSpace + +if TYPE_CHECKING: + from .models.machine import Machine + + +class JobPlacementError(ValueError): + """ + Raised when a job cannot be placed as requested, e.g. because the + head position is unknown. The message is user-facing. + """ + + +def resolve_start_point( + machine: "Machine", start_from: StartFrom +) -> Point | None: + """ + The world-space point the job anchor is placed on, or None for + absolute coordinates. + + Raises: + JobPlacementError: If the point cannot be determined. + """ + if start_from == StartFrom.USER_ORIGIN: + return _user_origin(machine) + if start_from == StartFrom.CURRENT_POSITION: + return _current_position(machine) + return None + + +def compute_job_shift( + machine: "Machine", job_origin: JobOrigin, design_rect: BBox | None +) -> Point | None: + """ + The world-space (dx, dy) translation that places a job whose + bounding box is *design_rect* as requested by *job_origin*. + + Returns None when no translation applies (absolute coordinates or + an empty job). + + Raises: + JobPlacementError: If the start point cannot be determined. + """ + if job_origin.is_absolute or design_rect is None: + return None + start_point = resolve_start_point(machine, job_origin.start_from) + if start_point is None: + return None + return placement_offset(design_rect, job_origin.anchor, start_point) + + +def _user_origin(machine: "Machine") -> Point: + """The zero point of the active WCS in world space.""" + space = MachineSpace.from_machine(machine) + zero_x, zero_y, _z = space.get_command_offset( + wcs_offset=machine.get_active_wcs_offset(), + wcs_is_workarea_origin=machine.wcs_origin_is_workarea_origin, + ) + return space.machine_point_to_world(zero_x, zero_y) + + +def _current_position(machine: "Machine") -> Point: + """ + The reported head position in world space. While pointer alignment + is on, this is the position of the pointer dot. + """ + if not machine.is_connected(): + raise JobPlacementError( + _( + "Start From “Current Position” needs a connected " + "machine. Connect it, or use “User Origin” for " + "files you run elsewhere." + ) + ) + state = machine.device_state + if state.status != DeviceStatus.IDLE: + raise JobPlacementError( + _( + "Start From “Current Position” needs an idle " + "machine; it is {status}." + ).format(status=state.status.name.lower()) + ) + pos_x, pos_y = state.machine_pos[0], state.machine_pos[1] + if pos_x is None or pos_y is None: + raise JobPlacementError( + _( + "Start From “Current Position” needs the head " + "position, but the machine has not reported it yet." + ) + ) + if machine.pointer_alignment_enabled: + dx, dy = machine.get_pointer_offset() + pos_x += dx + pos_y += dy + space = MachineSpace.from_machine(machine) + return space.machine_point_to_world(pos_x, pos_y) diff --git a/tests/core/test_job_origin.py b/tests/core/test_job_origin.py new file mode 100644 index 000000000..ab331a8c3 --- /dev/null +++ b/tests/core/test_job_origin.py @@ -0,0 +1,104 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.job_origin import ( + JobAnchor, + JobOrigin, + StartFrom, + anchor_point, + placement_offset, +) + +RECT = (10.0, 20.0, 50.0, 40.0) + + +class TestJobOriginModel: + def test_default_is_absolute_bottom_left(self): + origin = JobOrigin() + assert origin.start_from == StartFrom.ABSOLUTE + assert origin.anchor == JobAnchor.BOTTOM_LEFT + assert origin.is_absolute + + def test_relative_modes_are_not_absolute(self): + assert not JobOrigin(StartFrom.USER_ORIGIN).is_absolute + assert not JobOrigin(StartFrom.CURRENT_POSITION).is_absolute + + def test_round_trip(self): + origin = JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + assert JobOrigin.from_dict(origin.to_dict()) == origin + + def test_from_dict_tolerates_missing_and_unknown_values(self): + assert JobOrigin.from_dict(None) == JobOrigin() + assert JobOrigin.from_dict({}) == JobOrigin() + data = {"start_from": "bogus", "anchor": "nowhere"} + assert JobOrigin.from_dict(data) == JobOrigin() + + def test_nine_anchors(self): + assert len(JobAnchor) == 9 + + +class TestAnchorPoint: + @pytest.mark.parametrize( + "anchor,expected", + [ + (JobAnchor.BOTTOM_LEFT, (10.0, 20.0)), + (JobAnchor.BOTTOM, (30.0, 20.0)), + (JobAnchor.BOTTOM_RIGHT, (50.0, 20.0)), + (JobAnchor.LEFT, (10.0, 30.0)), + (JobAnchor.CENTER, (30.0, 30.0)), + (JobAnchor.RIGHT, (50.0, 30.0)), + (JobAnchor.TOP_LEFT, (10.0, 40.0)), + (JobAnchor.TOP, (30.0, 40.0)), + (JobAnchor.TOP_RIGHT, (50.0, 40.0)), + ], + ) + def test_anchor_point_in_world_space(self, anchor, expected): + assert anchor_point(RECT, anchor) == pytest.approx(expected) + + def test_placement_offset_moves_anchor_onto_start_point(self): + dx, dy = placement_offset(RECT, JobAnchor.CENTER, (100.0, 200.0)) + assert (dx, dy) == pytest.approx((70.0, 170.0)) + + def test_placement_offset_can_be_negative(self): + dx, dy = placement_offset(RECT, JobAnchor.TOP_RIGHT, (0.0, 0.0)) + assert (dx, dy) == pytest.approx((-50.0, -40.0)) + + +class TestDocJobOrigin: + def test_new_doc_uses_absolute(self): + assert Doc().job_origin == JobOrigin() + + def test_doc_serializes_job_origin(self): + doc = Doc() + origin = JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.TOP_LEFT) + doc.set_job_origin(origin) + restored = Doc.from_dict(doc.to_dict()) + assert restored.job_origin == origin + + def test_legacy_doc_without_job_origin_is_absolute(self): + data = Doc().to_dict() + data.pop("job_origin", None) + assert Doc.from_dict(data).job_origin == JobOrigin() + + def test_set_job_origin_signals_and_invalidates_job(self): + doc = Doc() + changed = [] + invalidated = [] + doc.job_origin_changed.connect( + lambda sender: changed.append(sender), weak=False + ) + doc.job_assembly_invalidated.connect( + lambda sender: invalidated.append(sender), weak=False + ) + doc.set_job_origin(JobOrigin(StartFrom.CURRENT_POSITION)) + assert changed == [doc] + assert invalidated == [doc] + + def test_setting_same_origin_is_silent(self): + doc = Doc() + changed = [] + doc.job_origin_changed.connect( + lambda sender: changed.append(sender), weak=False + ) + doc.set_job_origin(JobOrigin()) + assert changed == [] diff --git a/tests/machine/test_job_placement.py b/tests/machine/test_job_placement.py new file mode 100644 index 000000000..eea08f805 --- /dev/null +++ b/tests/machine/test_job_placement.py @@ -0,0 +1,120 @@ +import pytest + +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom +from rayforge.machine.driver.driver import DeviceState, DeviceStatus +from rayforge.machine.job_placement import ( + JobPlacementError, + compute_job_shift, + resolve_start_point, +) +from rayforge.machine.models.machine import Origin +from rayforge.machine.transport import TransportStatus + +DESIGN_RECT = (10.0, 20.0, 30.0, 40.0) + + +@pytest.fixture +def machine(sync_machine): + m = sync_machine + m.set_axis_extents(400, 400) + m.set_origin(Origin.BOTTOM_LEFT) + m.set_active_wcs("G54") + m.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + return m + + +def _connect_at(machine, x, y, status=DeviceStatus.IDLE): + machine.set_connection_status(TransportStatus.CONNECTED) + machine.set_device_state( + DeviceState(status=status, machine_pos=(x, y, 0.0)) + ) + + +class TestResolveStartPoint: + def test_absolute_has_no_start_point(self, machine): + assert resolve_start_point(machine, StartFrom.ABSOLUTE) is None + + def test_user_origin_is_active_wcs_zero(self, machine): + point = resolve_start_point(machine, StartFrom.USER_ORIGIN) + assert point == pytest.approx((100.0, 50.0)) + + def test_user_origin_with_other_wcs(self, machine): + machine.update_wcs_offset("G55", (5.0, 6.0, 0.0)) + machine.set_active_wcs("G55") + point = resolve_start_point(machine, StartFrom.USER_ORIGIN) + assert point == pytest.approx((5.0, 6.0)) + + def test_user_origin_on_top_left_machine(self, machine): + machine.set_origin(Origin.TOP_LEFT) + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + point = resolve_start_point(machine, StartFrom.USER_ORIGIN) + # 50 mm down from the top edge of a 400 mm bed. + assert point == pytest.approx((100.0, 350.0)) + + def test_user_origin_as_workarea_origin(self, machine): + machine.set_work_margins(7.0, 0.0, 0.0, 3.0) + machine.set_wcs_origin_is_workarea_origin(True) + point = resolve_start_point(machine, StartFrom.USER_ORIGIN) + assert point == pytest.approx((7.0, 3.0)) + + def test_current_position_uses_reported_head(self, machine): + _connect_at(machine, 120.0, 80.0) + point = resolve_start_point(machine, StartFrom.CURRENT_POSITION) + assert point == pytest.approx((120.0, 80.0)) + + def test_current_position_on_top_right_machine(self, machine): + machine.set_origin(Origin.TOP_RIGHT) + _connect_at(machine, 120.0, 80.0) + point = resolve_start_point(machine, StartFrom.CURRENT_POSITION) + assert point == pytest.approx((280.0, 320.0)) + + def test_current_position_follows_pointer_when_aligning(self, machine): + head = machine.get_default_laser_head() + head.set_pointer_offset_enabled(True) + head.set_pointer_offset(-12.0, 4.0) + machine.set_pointer_alignment(True) + _connect_at(machine, 120.0, 80.0) + point = resolve_start_point(machine, StartFrom.CURRENT_POSITION) + assert point == pytest.approx((108.0, 84.0)) + + def test_current_position_needs_connection(self, machine): + machine.set_device_state( + DeviceState(status=DeviceStatus.IDLE, machine_pos=(1, 2, 0)) + ) + with pytest.raises(JobPlacementError): + resolve_start_point(machine, StartFrom.CURRENT_POSITION) + + def test_current_position_needs_known_position(self, machine): + machine.set_connection_status(TransportStatus.CONNECTED) + machine.set_device_state(DeviceState(status=DeviceStatus.IDLE)) + with pytest.raises(JobPlacementError): + resolve_start_point(machine, StartFrom.CURRENT_POSITION) + + @pytest.mark.parametrize( + "status", + [DeviceStatus.RUN, DeviceStatus.JOG, DeviceStatus.ALARM], + ) + def test_current_position_needs_idle_machine(self, machine, status): + _connect_at(machine, 120.0, 80.0, status=status) + with pytest.raises(JobPlacementError): + resolve_start_point(machine, StartFrom.CURRENT_POSITION) + + +class TestComputeJobShift: + def test_absolute_has_no_shift(self, machine): + assert compute_job_shift(machine, JobOrigin(), DESIGN_RECT) is None + + def test_user_origin_bottom_left(self, machine): + origin = JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.BOTTOM_LEFT) + shift = compute_job_shift(machine, origin, DESIGN_RECT) + assert shift == pytest.approx((90.0, 30.0)) + + def test_current_position_center(self, machine): + _connect_at(machine, 200.0, 150.0) + origin = JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + shift = compute_job_shift(machine, origin, DESIGN_RECT) + assert shift == pytest.approx((180.0, 120.0)) + + def test_empty_design_has_no_shift(self, machine): + origin = JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + assert compute_job_shift(machine, origin, None) is None From 569d3f1003de9c5038cc731ed53753c19420ca4e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:04:27 +0200 Subject: [PATCH 02/11] feat(pipeline): place jobs via Start From The job origin is applied as one rigid XY translation of the whole job in world space, folded into the workpiece placement of the step aggregates. Everything downstream (machine transform, encoder, 3D preview, framing, sanity checks) sees the placed job, while the G-code stays absolute in the active WCS: no G92 or G10 is emitted and nothing is written to the controller. - The anchor refers to the bounding box of the designs on the canvas (job_design_rect), not the toolpath with overscan or lead-ins. - Current Position re-reads the head position for every job request, so send, frame and export never reuse a stale placement. - Rotary layers only support Absolute Coordinates. - MachineCmd refuses a placed job that leaves the machine extents or enters a no-go zone, and in Current Position mode moves the head back to the start position after framing or a finished job, because the dialect postscript usually returns to X0 Y0. - workpiece_world_aabb used the size on top of the world transform, which already scales the unit square, so workpieces without vector geometry (images) got a far too large box. Golden G-code tests cover every mode on GRBL. Refs #518 --- rayforge/machine/cmd.py | 101 ++++- rayforge/pipeline/intent_builder.py | 164 ++++++-- rayforge/pipeline/intent_controller.py | 7 + rayforge/pipeline/pipeline.py | 16 +- tests/pipeline/data/job_origin/absolute.gcode | 20 + .../current_position_bottom_left.gcode | 20 + .../data/job_origin/user_origin_center.gcode | 20 + .../job_origin/user_origin_top_right.gcode | 20 + tests/pipeline/test_job_design_rect.py | 35 ++ tests/pipeline/test_job_origin_gcode.py | 358 ++++++++++++++++++ 10 files changed, 733 insertions(+), 28 deletions(-) create mode 100644 tests/pipeline/data/job_origin/absolute.gcode create mode 100644 tests/pipeline/data/job_origin/current_position_bottom_left.gcode create mode 100644 tests/pipeline/data/job_origin/user_origin_center.gcode create mode 100644 tests/pipeline/data/job_origin/user_origin_top_right.gcode create mode 100644 tests/pipeline/test_job_design_rect.py create mode 100644 tests/pipeline/test_job_origin_gcode.py diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 420a19056..5f2f535ec 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from collections.abc import Awaitable, Callable, Coroutine from gettext import gettext as _ @@ -12,15 +13,18 @@ from raygeo.ops.types import CommandType from ..context import get_context +from ..core.job_origin import StartFrom from ..pipeline.artifact import JobArtifact from ..pipeline.artifact.handle import BaseArtifactHandle from ..pipeline.encoder.base import EncodedOutput from ..pipeline.encoder.context import GcodeContext, JobInfo from ..shared.util.template import TemplateFormatter -from .driver.driver import FrameCorner +from .driver.driver import DeviceStatus, FrameCorner from .job_monitor import JobMonitor +from .job_placement import JobPlacementError from .kinematic_mapping import KinematicMapping from .models.coordspace import MachineSpace +from .sanity import CheckMode, IssueSeverity, SanityChecker if TYPE_CHECKING: from ..core.layer import Layer @@ -32,6 +36,11 @@ logger = logging.getLogger(__name__) +# How long to wait for the machine to become idle after a framing or a +# job before the head is moved back to the start position. +RETURN_TO_START_TIMEOUT_S = 60.0 +_IDLE_POLL_INTERVAL_S = 0.1 + class JobAlreadyRunningError(RuntimeError): """ @@ -52,6 +61,7 @@ def __init__(self, editor: DocEditor): self.job_started = Signal() self._current_monitor: JobMonitor | None = None self._on_progress_callback: Callable[[dict], None] | None = None + self._cancel_requested = False @property def is_job_running(self) -> bool: @@ -342,6 +352,8 @@ async def _start_job( artifact_store = self._editor.pipeline.artifact_store pipeline = self._editor.pipeline shift_used = False + self._cancel_requested = False + return_point = self._start_position_in_command_space(machine) try: if pointer_dry_run and machine.has_pointer_offset(): @@ -378,8 +390,13 @@ async def _start_job( # checkout keeps the artifact alive. pipeline.invalidate_job_output() + if isinstance(artifact, JobArtifact): + self._check_job_placement(artifact, machine) await final_job_action(artifact, machine, on_progress) + if return_point is not None and not self._cancel_requested: + await self._return_to_start(machine, return_point) + except JobAlreadyRunningError: # Already logged as a warning by the guard; not an error. raise @@ -390,6 +407,87 @@ async def _start_job( artifact_store.release(handle) raise + def _start_position_in_command_space( + self, machine: Machine + ) -> tuple[float, float] | None: + """ + The head position before a Start From "Current Position" job, + in command coordinates of the active WCS, or None in any other + mode or when the position is unknown. + """ + job_origin = self._editor.doc.job_origin + if job_origin.start_from != StartFrom.CURRENT_POSITION: + return None + pos_x, pos_y = machine.device_state.machine_pos[:2] + if pos_x is None or pos_y is None: + return None + off_x, off_y, _z = machine.panel.get_command_offset( + wcs_offset=machine.get_active_wcs_offset(), + wcs_is_workarea_origin=machine.wcs_origin_is_workarea_origin, + ) + return (pos_x - off_x, pos_y - off_y) + + def _check_job_placement(self, artifact: JobArtifact, machine: Machine): + """ + Refuse a job placed via Start From that leaves the machine + travel or enters a no-go zone. + + The UI runs the same checks before sending and lets the user + override them; this is the backend guard for jobs whose + position depends on where the head or the WCS zero is, which + the user cannot see on the canvas. + + Raises: + JobPlacementError: With the issues found. + """ + if self._editor.doc.job_origin.is_absolute: + return + report = SanityChecker(machine).check( + artifact.ops, mode=CheckMode.FAST + ) + errors = [ + issue.message + for issue in report.issues + if issue.severity == IssueSeverity.ERROR + ] + if errors: + raise JobPlacementError( + _( + "The job does not fit at the chosen start position: " + "{issues}" + ).format(issues="; ".join(errors)) + ) + + async def _return_to_start( + self, machine: Machine, point: tuple[float, float] + ): + """ + Move the head back to where a "Current Position" job or frame + started, once the machine is idle again. + + The dialect postscript usually returns to X0 Y0; without this, + Frame followed by Start would place the job at the WCS zero. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + RETURN_TO_START_TIMEOUT_S + while machine.device_state.status != DeviceStatus.IDLE: + if loop.time() >= deadline or self._cancel_requested: + logger.warning( + "Machine did not become idle; head not returned to " + "the start position." + ) + self._editor.notification_requested.send( + self, + message=_( + "The head was not moved back to the start " + "position. Check its position before the next " + "Frame or Start." + ), + ) + return + await asyncio.sleep(_IDLE_POLL_INTERVAL_S) + await machine.driver.move_to(point[0], point[1]) + async def frame_job( self, machine: Machine, @@ -476,6 +574,7 @@ def set_hold(self, machine: Machine, is_requesting_hold: bool): def cancel_job(self, machine: Machine): """Adds a task to cancel the currently running job on the machine.""" + self._cancel_requested = True driver = machine.driver self._editor.task_manager.add_coroutine( lambda ctx: driver.cancel(), key="cancel-job" diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index 63c6ff644..981548b45 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -41,6 +41,7 @@ import logging import math from collections.abc import Callable, Mapping, Sequence +from gettext import gettext as _ from typing import ( TYPE_CHECKING, Any, @@ -77,6 +78,7 @@ from ..machine.driver import get_driver_cls from ..machine.driver.dummy import NoDeviceDriver +from ..machine.job_placement import JobPlacementError, compute_job_shift from ..machine.kinematic_math import KinematicMath from ..machine.models.coordspace import MachineSpace from ..machine.models.dialect import GRBL_DIALECT @@ -110,6 +112,92 @@ STOCK_KEY_FMT = "stock:{stock_uid}" +def workpiece_world_aabb( + wp: WorkPiece, +) -> tuple[float, float, float, float] | None: + """The workpiece's world-space AABB (fold wiring, job origin). + + Uses the resolved world geometry when available (vector + workpieces). Image/raster workpieces carry no vector geometry + (``get_world_geometry`` returns ``None``), so their AABB is + derived from the workpiece ``size`` transformed into world + space via its world transform. Returns ``None`` when neither + yields a usable rect (no geometry and no size). + """ + geo = wp.get_world_geometry() + if geo is not None and not geo.is_empty(): + return geo.rect() + if not wp.size: + return None + w, h = wp.size + if w <= 0 or h <= 0: + return None + # The world transform maps the unit square onto the workpiece; its + # scale already carries the size. + world = wp.get_world_transform() + corners = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] + pts = [world.transform_point(x, y) for x, y in corners] + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return (min(xs), min(ys), max(xs), max(ys)) + + +def job_design_rect(doc: Doc) -> tuple[float, float, float, float] | None: + """ + The world-space bounding box of the design that makes up the job: + the union of the AABBs of all workpieces processed by a visible + step. The job origin anchor refers to this box, so it matches what + is seen on the canvas regardless of overscan, lead-ins or kerf. + Returns None when no workpiece contributes. + """ + rects = [] + for layer in doc.layers: + if not layer.workflow: + continue + workpieces = list(layer.all_workpieces) + for step in layer.workflow.steps: + if not step.visible or not step.needs_workpieces: + continue + for wp in IntentBuilder._workpieces_for_step(step, workpieces): + rect = workpiece_world_aabb(wp) + if rect is not None: + rects.append(rect) + if not rects: + return None + return ( + min(r[0] for r in rects), + min(r[1] for r in rects), + max(r[2] for r in rects), + max(r[3] for r in rects), + ) + + +def validate_job_placement(machine: Machine | None, doc: Doc) -> None: + """ + Check that the document's job origin can be applied. + + Start From "User Origin" and "Current Position" translate the + whole job in world space, which has no meaning for rotary layers + (their Y is mapped onto the rotary axis), and "Current Position" + needs a connected, idle machine that reports the head position. + + Raises: + JobPlacementError: With a user-facing message. + """ + job_origin = doc.job_origin + if job_origin.is_absolute or machine is None: + return + if doc.has_rotary_layer: + raise JobPlacementError( + _( + "Start From \u201c{mode}\u201d is not available while a " + "layer uses a rotary module. Use \u201cAbsolute " + "Coordinates\u201d." + ).format(mode=job_origin.start_from.label) + ) + compute_job_shift(machine, job_origin, (0.0, 0.0, 0.0, 0.0)) + + class UnsupportedRotaryPanelOrientationError(ValueError): """Raised when rotary output is requested from a rotated panel. @@ -209,6 +297,8 @@ def __init__( self._generation_id = generation_id self._loop = loop self._doc: Doc | None = None + self._job_shift: tuple[float, float] | None = None + self.job_placement_error: JobPlacementError | None = None @property def generation_id(self) -> int: @@ -265,6 +355,13 @@ def build(self, doc: Doc) -> list[NodeRequest]: self._build_stock_fold_nodes(doc, wp_compute_keys, nodes) self._build_rotary_fold_nodes(doc, wp_compute_keys, nodes) + self._job_shift = None + placement_error: JobPlacementError | None = None + try: + self._job_shift = self._resolve_job_shift(doc) + except JobPlacementError as e: + placement_error = e + for layer in doc.layers: if not layer.workflow: continue @@ -279,12 +376,44 @@ def build(self, doc: Doc) -> list[NodeRequest]: step, layer, upstream ) + self.job_placement_error = placement_error + if placement_error is not None: + logger.info("Job is not assembled: %s", placement_error) + return nodes if step_tokens: self._build_job_node(doc, nodes, step_tokens) self._build_machine_transform_node(doc, nodes, step_tokens) self._build_encoder_node(doc, nodes, step_tokens) return nodes + def _resolve_job_shift(self, doc: Doc) -> tuple[float, float] | None: + """ + The world-space translation that applies the document's job + origin ("Start From") to the whole job, or None. + """ + if doc.job_origin.is_absolute: + return None + validate_job_placement(self._machine, doc) + return compute_job_shift( + self._machine, doc.job_origin, job_design_rect(doc) + ) + + def _job_placement_matrix(self, wp: WorkPiece) -> list[list[float]]: + """ + The workpiece's placement matrix in the assembled job: its + world placement, followed by the job origin translation. + """ + placement = _workpiece_placement_matrix_obj(wp) + if self._job_shift is not None: + dx, dy = self._job_shift + placement = Matrix.translation(dx, dy) @ placement + return placement.to_4x4_list() + + @property + def job_shift(self) -> tuple[float, float] | None: + """The job origin translation applied by the last build.""" + return self._job_shift + # ------------------------------------------------------------------ # Workpiece compute nodes # ------------------------------------------------------------------ @@ -480,29 +609,8 @@ def _build_encoder_node( def _workpiece_world_aabb( self, wp: WorkPiece ) -> tuple[float, float, float, float] | None: - """The workpiece's world-space AABB for fold dependency wiring. - - Uses the resolved world geometry when available (vector - workpieces). Image/raster workpieces carry no vector geometry - (``get_world_geometry`` returns ``None``), so their AABB is - derived from the workpiece ``size`` transformed into world - space via its world transform. Returns ``None`` when neither - yields a usable rect (no geometry and no size). - """ - geo = wp.get_world_geometry() - if geo is not None and not geo.is_empty(): - return geo.rect() - if not wp.size: - return None - w, h = wp.size - if w <= 0 or h <= 0: - return None - world = wp.get_world_transform() - corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] - pts = [world.transform_point(x, y) for x, y in corners] - xs = [p[0] for p in pts] - ys = [p[1] for p in pts] - return (min(xs), min(ys), max(xs), max(ys)) + """The workpiece's world-space AABB for fold dependency wiring.""" + return workpiece_world_aabb(wp) def _active_laser_physics(self) -> tuple[float, float]: """The active laser's ``(wavelength_nm, max_power_watts)``. @@ -810,7 +918,7 @@ def _aggregate_token( else: placements.append( { - "matrix": _workpiece_placement_matrix(wp), + "matrix": self._job_placement_matrix(wp), "size": list(wp.size) if wp.size else [0, 0], } ) @@ -855,7 +963,11 @@ def _job_token(self, doc: Doc, step_tokens: dict[str, int]) -> int: "spxf": _canonical(step.per_step_transformers_dicts), } ) - payload: dict[str, Any] = {"kind": "job", "steps": payloads} + payload: dict[str, Any] = { + "kind": "job", + "steps": payloads, + "job_shift": _canonical(self._job_shift), + } return _hash_int(payload) # ------------------------------------------------------------------ @@ -1135,7 +1247,7 @@ def _step_stage( ) ) continue - placement = _workpiece_placement_matrix(wp) + placement = self._job_placement_matrix(wp) target = wp.size inp = AggregateInput( source_key=wp_key, diff --git a/rayforge/pipeline/intent_controller.py b/rayforge/pipeline/intent_controller.py index 27867fb5e..a95ec8ea0 100644 --- a/rayforge/pipeline/intent_controller.py +++ b/rayforge/pipeline/intent_controller.py @@ -408,6 +408,13 @@ def _worker() -> None: self._emit_job_generation_failed ) return + if builder.job_placement_error is not None: + # The job nodes were left out because the job origin + # cannot be resolved; complete anyone waiting for the + # job instead of leaving them blocked. + self._task_manager.schedule_on_main_thread( + self._emit_job_generation_failed + ) try: self._refresh_key_to_item_map(nodes) new_intent = create_intent_from_nodes(nodes) diff --git a/rayforge/pipeline/pipeline.py b/rayforge/pipeline/pipeline.py index 0551433af..2509a6af1 100644 --- a/rayforge/pipeline/pipeline.py +++ b/rayforge/pipeline/pipeline.py @@ -15,7 +15,9 @@ from ..core.capability import MachineCapability from ..core.doc import Doc +from ..core.job_origin import StartFrom from ..core.workpiece import WorkPiece +from ..machine.job_placement import JobPlacementError from ..machine.kinematic_mapping import KinematicMapping from .artifact import ( BaseArtifactHandle, @@ -27,6 +29,7 @@ from .encoder.base import EncodedOutput, MachineCodeOpMap from .intent_builder import ( UnsupportedRotaryPanelOrientationError, + validate_job_placement, validate_panel_configuration, ) from .intent_controller import IntentController @@ -538,11 +541,22 @@ def generate_job_artifact( try: validate_panel_configuration(self._machine, self._doc) - except UnsupportedRotaryPanelOrientationError as exc: + validate_job_placement(self._machine, self._doc) + except ( + UnsupportedRotaryPanelOrientationError, + JobPlacementError, + ) as exc: self.invalidate_job_output() when_done(None, exc) return + if self._doc.job_origin.start_from == StartFrom.CURRENT_POSITION: + # The placement depends on the live head position, which + # does not trigger a rebuild when it changes. Every request + # (send, frame, export, sanity check) therefore assembles + # the job afresh for the position the head has right now. + self.invalidate_job_output() + if self._last_job_handle is not None: when_done(self._last_job_handle, None) return diff --git a/tests/pipeline/data/job_origin/absolute.gcode b/tests/pipeline/data/job_origin/absolute.gcode new file mode 100644 index 000000000..05a68963e --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute.gcode @@ -0,0 +1,20 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X0.055 Y9.945 +M4 S500 +G1 X9.945 F3000 +G1 Y0.055 F3000 +G1 X0.055 F3000 +G1 Y9.945 F3000 +M5 +G0 X0 Y10 +M4 S500 +G1 X10 F3000 +G1 Y0 F3000 +G1 X0 F3000 +G1 Y10 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/current_position_bottom_left.gcode b/tests/pipeline/data/job_origin/current_position_bottom_left.gcode new file mode 100644 index 000000000..757b52be8 --- /dev/null +++ b/tests/pipeline/data/job_origin/current_position_bottom_left.gcode @@ -0,0 +1,20 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X100.055 Y109.945 +M4 S500 +G1 X109.945 F3000 +G1 Y100.055 F3000 +G1 X100.055 F3000 +G1 Y109.945 F3000 +M5 +G0 X100 Y110 +M4 S500 +G1 X110 F3000 +G1 Y100 F3000 +G1 X100 F3000 +G1 Y110 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/user_origin_center.gcode b/tests/pipeline/data/job_origin/user_origin_center.gcode new file mode 100644 index 000000000..336f74a95 --- /dev/null +++ b/tests/pipeline/data/job_origin/user_origin_center.gcode @@ -0,0 +1,20 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-4.945 Y4.945 +M4 S500 +G1 X4.945 F3000 +G1 Y-4.945 F3000 +G1 X-4.945 F3000 +G1 Y4.945 F3000 +M5 +G0 X-5 Y5 +M4 S500 +G1 X5 F3000 +G1 Y-5 F3000 +G1 X-5 F3000 +G1 Y5 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/user_origin_top_right.gcode b/tests/pipeline/data/job_origin/user_origin_top_right.gcode new file mode 100644 index 000000000..8e77f3c01 --- /dev/null +++ b/tests/pipeline/data/job_origin/user_origin_top_right.gcode @@ -0,0 +1,20 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-9.945 Y-0.055 +M4 S500 +G1 X-0.055 F3000 +G1 Y-9.945 F3000 +G1 X-9.945 F3000 +G1 Y-0.055 F3000 +M5 +G0 X-10 Y0 +M4 S500 +G1 X0 F3000 +G1 Y-10 F3000 +G1 X-10 F3000 +G1 Y0 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/test_job_design_rect.py b/tests/pipeline/test_job_design_rect.py new file mode 100644 index 000000000..111357e6f --- /dev/null +++ b/tests/pipeline/test_job_design_rect.py @@ -0,0 +1,35 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.intent_builder import ( + job_design_rect, + workpiece_world_aabb, +) + + +def _image_workpiece(pos, size) -> WorkPiece: + """A workpiece without vector geometry, like an engraved image.""" + wp = WorkPiece(name="image") + wp.set_size(*size) + wp.pos = pos + return wp + + +def test_aabb_of_workpiece_without_geometry(): + wp = _image_workpiece((27.2, 43.0), (85.6, 54.0)) + assert wp.get_world_geometry() is None + assert workpiece_world_aabb(wp) == pytest.approx((27.2, 43.0, 112.8, 97.0)) + + +def test_aabb_of_rotated_workpiece_without_geometry(): + wp = _image_workpiece((0.0, 0.0), (20.0, 10.0)) + wp.angle = 90.0 + min_x, min_y, max_x, max_y = workpiece_world_aabb(wp) + assert (max_x - min_x, max_y - min_y) == pytest.approx((10.0, 20.0)) + + +def test_design_rect_ignores_layers_without_visible_steps(): + doc = Doc() + doc.active_layer.add_child(_image_workpiece((10, 10), (5, 5))) + assert job_design_rect(doc) is None diff --git a/tests/pipeline/test_job_origin_gcode.py b/tests/pipeline/test_job_origin_gcode.py new file mode 100644 index 000000000..4e4674b18 --- /dev/null +++ b/tests/pipeline/test_job_origin_gcode.py @@ -0,0 +1,358 @@ +""" +Golden G-code tests for the job origin ("Start From") setting. + +A 10 x 10 mm square is imported at world (0, 0) on a bottom-left GRBL +machine whose G54 zero sits at machine (100, 50). The same document is +exported in every Start From mode; the expected files show exactly +which coordinates reach the controller. +""" + +import re +from pathlib import Path + +import pytest +import pytest_asyncio + +from rayforge.context import get_context +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom +from rayforge.core.vectorization_spec import TraceSpec +from rayforge.machine.cmd import MachineCmd +from rayforge.machine.driver.driver import DeviceState, DeviceStatus +from rayforge.machine.job_placement import JobPlacementError +from rayforge.machine.models.machine import Origin +from rayforge.machine.models.zone import Zone +from rayforge.machine.transport import TransportStatus +from rayforge.pipeline.artifact import JobArtifact + +ASSETS = Path(__file__).parent.parent / "doceditor" / "assets" +DATA = Path(__file__).parent / "data" / "job_origin" +G54_OFFSET = (100.0, 50.0, 0.0) +HEAD_POS = (200.0, 150.0, 0.0) + + +def _motion_extents(gcode: str) -> tuple[float, float, float, float]: + xs: list[float] = [] + ys: list[float] = [] + for line in gcode.splitlines(): + code = line.split(";")[0] + if not re.match(r"\s*G[01]\b", code): + continue + for axis, value in re.findall(r"([XY])([-+]?\d*\.?\d+)", code): + (xs if axis == "X" else ys).append(float(value)) + return min(xs), min(ys), max(xs), max(ys) + + +def _job_motion(gcode: str) -> str: + """The G-code without the dialect postscript's return move.""" + return "\n".join( + line for line in gcode.splitlines() if "Return to origin" not in line + ) + + +@pytest_asyncio.fixture +async def square_doc(doc_editor, context_initializer, contour_step_class): + machine = get_context().machine + assert machine is not None + machine.set_dialect_uid("grbl") + machine.set_origin(Origin.BOTTOM_LEFT) + machine.set_axis_extents(400, 400) + machine.set_active_wcs("G54") + machine.update_wcs_offset("G54", G54_OFFSET) + + step = contour_step_class.create( + context_initializer, name="Contour", optimize=False + ) + step.set_power(0.5) + step.set_cut_speed(3000) + workflow = doc_editor.doc.active_layer.workflow + assert workflow is not None + workflow.add_step(step) + + await doc_editor.import_file_from_path( + ASSETS / "10x10_square.svg", + mime_type="image/svg+xml", + vectorization_spec=TraceSpec(), + ) + await doc_editor.wait_until_settled() + return doc_editor, machine + + +def _set_head(machine, x, y): + machine.set_connection_status(TransportStatus.CONNECTED) + machine.set_device_state( + DeviceState(status=DeviceStatus.IDLE, machine_pos=(x, y, 0.0)) + ) + + +async def _generate(editor) -> tuple[str, tuple]: + await editor.wait_until_settled() + pipeline = editor.pipeline + handle = await pipeline.generate_job_artifact_async() + assert handle is not None + with pipeline.artifact_store.checkout_handle(handle) as artifact: + assert isinstance(artifact, JobArtifact) + assert artifact.machine_code is not None + result = artifact.machine_code, artifact.ops.rect() + await editor.wait_until_settled() + return result + + +def _assert_golden(gcode: str, name: str): + expected = (DATA / name).read_text(encoding="utf-8") + assert gcode.strip().splitlines() == expected.strip().splitlines() + + +@pytest.mark.asyncio +async def test_absolute_is_unchanged(square_doc): + editor, _machine = square_doc + gcode, rect = await _generate(editor) + # Imported items land on the G54 zero; absolute output is relative + # to G54 as before. + assert _motion_extents(_job_motion(gcode)) == pytest.approx( + (0.0, 0.0, 10.0, 10.0) + ) + assert rect == pytest.approx((100.0, 50.0, 110.0, 60.0), abs=0.06) + _assert_golden(gcode, "absolute.gcode") + + +@pytest.mark.asyncio +async def test_user_origin_center(square_doc): + editor, _machine = square_doc + editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + gcode, rect = await _generate(editor) + # Centred on the G54 zero: the coordinates are WCS-relative and do + # not depend on the stored offset value. + assert _motion_extents(_job_motion(gcode)) == pytest.approx( + (-5.0, -5.0, 5.0, 5.0) + ) + assert rect == pytest.approx((95.0, 45.0, 105.0, 55.0), abs=0.06) + _assert_golden(gcode, "user_origin_center.gcode") + + +@pytest.mark.asyncio +async def test_user_origin_output_is_independent_of_offset(square_doc): + editor, machine = square_doc + editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.TOP_RIGHT) + ) + first, _rect = await _generate(editor) + machine.update_wcs_offset("G54", (250.0, 300.0, 0.0)) + machine.changed.send(machine) + editor.pipeline.invalidate_job_output() + second, rect = await _generate(editor) + assert first == second + assert rect == pytest.approx((240.0, 290.0, 250.0, 300.0), abs=0.06) + _assert_golden(first, "user_origin_top_right.gcode") + + +@pytest.mark.asyncio +async def test_current_position_bottom_left(square_doc): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + gcode, rect = await _generate(editor) + assert _motion_extents(_job_motion(gcode)) == pytest.approx( + (100.0, 100.0, 110.0, 110.0) + ) + assert rect == pytest.approx((200.0, 150.0, 210.0, 160.0), abs=0.06) + _assert_golden(gcode, "current_position_bottom_left.gcode") + + +@pytest.mark.asyncio +async def test_current_position_reads_head_at_generation(square_doc): + editor, machine = square_doc + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + _set_head(machine, 150.0, 150.0) + _first, rect_a = await _generate(editor) + _set_head(machine, 300.0, 200.0) + _second, rect_b = await _generate(editor) + assert rect_a == pytest.approx((145.0, 145.0, 155.0, 155.0), abs=0.06) + assert rect_b == pytest.approx((295.0, 195.0, 305.0, 205.0), abs=0.06) + + +@pytest.mark.asyncio +async def test_current_position_without_machine_is_refused(square_doc): + editor, _machine = square_doc + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + await editor.wait_until_settled() + with pytest.raises(JobPlacementError): + await editor.pipeline.generate_job_artifact_async() + + +@pytest.mark.asyncio +async def test_back_to_absolute_restores_output(square_doc): + editor, _machine = square_doc + editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + await _generate(editor) + editor.doc.set_job_origin(JobOrigin()) + gcode, _rect = await _generate(editor) + _assert_golden(gcode, "absolute.gcode") + + +@pytest.mark.asyncio +async def test_rotary_layer_refuses_relative_start(square_doc): + editor, _machine = square_doc + editor.doc.active_layer.set_rotary_enabled(True) + editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + await editor.wait_until_settled() + with pytest.raises(JobPlacementError): + await editor.pipeline.generate_job_artifact_async() + + +# ---------------------------------------------------------------------- +# Framing and sending +# ---------------------------------------------------------------------- + + +def _frame_extents(corners) -> tuple[float, float, float, float]: + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + return min(xs), min(ys), max(xs), max(ys) + + +@pytest.mark.asyncio +async def test_frame_current_position_and_return_to_start(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + await editor.wait_until_settled() + frame_spy = mocker.spy(machine.driver, "frame") + move_spy = mocker.spy(machine.driver, "move_to") + + await MachineCmd(editor).frame_job(machine) + await editor.wait_until_settled() + + frame_spy.assert_called_once() + corners = frame_spy.call_args.args[0] + # Centred on the head (200, 150), in G54 command coordinates. + assert _frame_extents(corners) == pytest.approx( + (95.0, 95.0, 105.0, 105.0), abs=0.06 + ) + # The postscript returns to X0 Y0; the head is sent back to where + # it started, so a following Start lands in the same place. + move_spy.assert_called_once() + assert move_spy.call_args.args[:2] == pytest.approx((100.0, 100.0)) + + +@pytest.mark.asyncio +async def test_frame_user_origin_does_not_move_head_back(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.BOTTOM_LEFT) + ) + await editor.wait_until_settled() + frame_spy = mocker.spy(machine.driver, "frame") + move_spy = mocker.spy(machine.driver, "move_to") + + await MachineCmd(editor).frame_job(machine) + await editor.wait_until_settled() + + corners = frame_spy.call_args.args[0] + assert _frame_extents(corners) == pytest.approx( + (0.0, 0.0, 10.0, 10.0), abs=0.06 + ) + move_spy.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_current_position_runs_placed_job(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + await editor.wait_until_settled() + run_spy = mocker.spy(machine.driver, "run") + move_spy = mocker.spy(machine.driver, "move_to") + + await MachineCmd(editor).send_job(machine) + await editor.wait_until_settled() + + run_spy.assert_called_once() + _assert_golden( + run_spy.call_args.args[0].text, "current_position_bottom_left.gcode" + ) + move_spy.assert_called_once() + assert move_spy.call_args.args[:2] == pytest.approx((100.0, 100.0)) + + +@pytest.mark.asyncio +async def test_cancelled_job_does_not_return_to_start(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + await editor.wait_until_settled() + cmd = MachineCmd(editor) + original_run = machine.driver.run + + async def run_and_cancel(*args, **kwargs): + cmd.cancel_job(machine) + await original_run(*args, **kwargs) + + mocker.patch.object(machine.driver, "run", side_effect=run_and_cancel) + move_spy = mocker.spy(machine.driver, "move_to") + + await cmd.send_job(machine) + await editor.wait_until_settled() + + move_spy.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_refuses_job_beyond_machine_extents(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, 395.0, 395.0) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + await editor.wait_until_settled() + run_spy = mocker.spy(machine.driver, "run") + messages: list[str] = [] + editor.notification_requested.connect( + lambda sender, message, **kw: messages.append(message), weak=False + ) + + with pytest.raises(JobPlacementError): + await MachineCmd(editor).send_job(machine) + await editor.wait_until_settled() + + run_spy.assert_not_called() + assert messages and "X=404.9 > 400.0" in messages[-1] + + +@pytest.mark.asyncio +async def test_frame_refuses_job_in_nogo_zone(square_doc, mocker): + editor, machine = square_doc + zone = Zone() + zone.set_name("Clamp") + zone.params.update({"x": 195.0, "y": 145.0, "w": 10.0, "h": 10.0}) + machine.add_nogo_zone(zone) + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + await editor.wait_until_settled() + frame_spy = mocker.spy(machine.driver, "frame") + + with pytest.raises(JobPlacementError): + await MachineCmd(editor).frame_job(machine) + await editor.wait_until_settled() + + frame_spy.assert_not_called() From 7afe4b3e7ad2bd1a177f82692832cc1affc14611 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:04:31 +0200 Subject: [PATCH 03/11] feat(ui): add Start From controls and a placement outline The Control Panel gets a Start From row and a 3x3 Job Origin selector under the WCS selector; changes are undoable and the subtitle shows where the job will start. When the mode is not Absolute, the 2D canvas draws a dashed outline of the placed job with a marker on its origin point, following the head in Current Position mode. Refs #518 --- .../ui_gtk/canvas2d/elements/job_placement.py | 74 +++++++ rayforge/ui_gtk/canvas2d/surface.py | 74 +++++++ rayforge/ui_gtk/doceditor/bottom_panel.py | 9 + rayforge/ui_gtk/machine/job_origin_rows.py | 187 ++++++++++++++++++ .../canvas2d/test_job_placement_overlay.py | 71 +++++++ tests/ui_gtk/canvas2d/test_surface.py | 1 + ...test_bottom_panel_dro_pointer_alignment.py | 1 + ...t_bottom_panel_pointer_alignment_toggle.py | 1 + tests/ui_gtk/machine/test_job_origin_rows.py | 67 +++++++ 9 files changed, 485 insertions(+) create mode 100644 rayforge/ui_gtk/canvas2d/elements/job_placement.py create mode 100644 rayforge/ui_gtk/machine/job_origin_rows.py create mode 100644 tests/ui_gtk/canvas2d/test_job_placement_overlay.py create mode 100644 tests/ui_gtk/machine/test_job_origin_rows.py diff --git a/rayforge/ui_gtk/canvas2d/elements/job_placement.py b/rayforge/ui_gtk/canvas2d/elements/job_placement.py new file mode 100644 index 000000000..44e24b16f --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/elements/job_placement.py @@ -0,0 +1,74 @@ +import math + +import cairo + +from ...canvas import CanvasElement + +_STROKE_COLOR = (0.1, 0.55, 0.95, 1.0) +_DASH_PX = 6.0 +_LINE_PX = 2.0 + + +class JobPlacementElement(CanvasElement): + """ + A non-interactive CanvasElement that shows where a job placed via + Start From ("User Origin" or "Current Position") will run: a dashed + outline of the placed job and a marker on its job origin point. + + The element covers the placed job's bounding box; the anchor is + given in the element's local coordinates (mm). + """ + + def __init__(self, **kwargs): + super().__init__( + x=0, + y=0, + width=10.0, + height=10.0, + selectable=False, + draggable=False, + clip=False, + **kwargs, + ) + self._anchor = (0.0, 0.0) + + def set_placement( + self, + rect: tuple[float, float, float, float], + anchor: tuple[float, float], + ): + """ + Places the outline on *rect* (min_x, min_y, max_x, max_y) and + the marker on *anchor*, both in canvas coordinates. + """ + min_x, min_y, max_x, max_y = rect + self.set_size(max(max_x - min_x, 1e-6), max(max_y - min_y, 1e-6)) + self.set_pos(min_x, min_y) + self._anchor = (anchor[0] - min_x, anchor[1] - min_y) + + @property + def anchor(self) -> tuple[float, float]: + """The anchor in the element's local coordinates.""" + return self._anchor + + def draw(self, ctx: cairo.Context): + ctx.save() + ctx.set_source_rgba(*_STROKE_COLOR) + line, _unused = ctx.device_to_user_distance(_LINE_PX, 0) + ctx.set_line_width(abs(line) or 0.1) + dash, _unused = ctx.device_to_user_distance(_DASH_PX, 0) + ctx.set_dash([abs(dash) or 1.0]) + ctx.rectangle(0, 0, self.width, self.height) + ctx.stroke() + + ctx.set_dash([]) + ax, ay = self._anchor + radius = max(min(self.width, self.height) * 0.08, 1.0) + ctx.arc(ax, ay, radius, 0, 2 * math.pi) + ctx.stroke() + ctx.move_to(ax - 2 * radius, ay) + ctx.line_to(ax + 2 * radius, ay) + ctx.move_to(ax, ay - 2 * radius) + ctx.line_to(ax, ay + 2 * radius) + ctx.stroke() + ctx.restore() diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py index 94a6ac8d5..fde6f9f97 100644 --- a/rayforge/ui_gtk/canvas2d/surface.py +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -11,13 +11,16 @@ from ...core.color import ColorRGBA, hex_to_rgba from ...core.group import Group from ...core.item import DocItem +from ...core.job_origin import anchor_point from ...core.layer import Layer from ...core.stock import StockItem from ...core.stock_asset import StockAsset from ...core.workpiece import WorkPiece +from ...machine.job_placement import JobPlacementError, compute_job_shift from ...machine.models.machine import Machine from ...machine.models.machine_panel import MachinePanel from ...pipeline.artifact import RenderContext +from ...pipeline.intent_builder import job_design_rect from ...shared.units.formatter import get_preferred_unit_factor from ..canvas import Canvas, CanvasElement, WorldSurface from ..shared.keyboard import is_primary_modifier @@ -29,6 +32,7 @@ from .elements.camera_image import CameraImageElement from .elements.dot import DotElement from .elements.group import GroupElement +from .elements.job_placement import JobPlacementElement from .elements.layer import LayerElement from .elements.nogo_zone import NogoZoneElement from .elements.stock import StockElement @@ -165,6 +169,15 @@ def __init__( self._extent_frame_element.set_visible(False) self.root.add(self._extent_frame_element) + # Outline of a job placed via Start From (hidden for absolute) + self._job_placement_element = JobPlacementElement() + self._job_placement_element.set_visible(False) + self.root.add(self._job_placement_element) + self._job_design_rect_cache: ( + tuple[float, float, float, float] | None + ) = None + self._job_design_rect_valid = False + # Signals for clipboard and duplication operations self.cut_requested = Signal() self.copy_requested = Signal() @@ -489,6 +502,7 @@ def _on_history_changed(self, sender, **kwargs): f"History changed, synchronizing selection state. Sender: {sender}" ) self._sync_selection_state() + self._invalidate_job_placement() self.queue_draw() def _on_pipeline_data_stale(self, sender, **kwargs): @@ -511,6 +525,7 @@ def _on_doc_structure_changed(self, sender, **kwargs): """Refreshes render context when layers are added/removed.""" logger.debug(f"_on_doc_structure_changed fired: sender={sender}") self._update_pipeline_view_context() + self._invalidate_job_placement() def _on_document_changed(self, sender, **kwargs): """Reconnect all doc signals when a new doc is loaded.""" @@ -527,8 +542,10 @@ def _connect_doc_signals(self): doc.active_layer_changed.connect(self._on_active_layer_changed) doc.descendant_added.connect(self._on_doc_structure_changed) doc.descendant_removed.connect(self._on_doc_structure_changed) + doc.job_origin_changed.connect(self._on_job_origin_changed) self._connect_active_layer_wcs() self._connected_doc = doc + self._invalidate_job_placement() def _disconnect_doc_signals(self): self._disconnect_active_layer_wcs() @@ -538,6 +555,7 @@ def _disconnect_doc_signals(self): doc.active_layer_changed.disconnect(self._on_active_layer_changed) doc.descendant_added.disconnect(self._on_doc_structure_changed) doc.descendant_removed.disconnect(self._on_doc_structure_changed) + doc.job_origin_changed.disconnect(self._on_job_origin_changed) self._connected_doc = None def _on_any_transform_begin( @@ -819,6 +837,7 @@ def _on_wcs_updated(self, machine: Machine): self._work_origin_element.set_pos(canvas_x, canvas_y) self._work_origin_element.set_visible(True) self._update_extent_frame() + self._update_job_placement() self.queue_draw() def _is_rotary_active(self): @@ -879,6 +898,58 @@ def _on_machine_state_changed(self, machine: Machine, state): if m_pos and all(p is not None for p in m_pos): m_x, m_y = m_pos[0], m_pos[1] self.set_laser_dot_position(m_x, m_y) + if self.doc is not None and not self.doc.job_origin.is_absolute: + self._update_job_placement() + + def _on_job_origin_changed(self, sender): + self._invalidate_job_placement() + + def _invalidate_job_placement(self): + """Recomputes the job placement outline after a doc change.""" + self._job_design_rect_valid = False + self._update_job_placement() + + def _update_job_placement(self): + """ + Shows where a job placed via Start From will run, or hides the + outline for absolute coordinates or when the start point is + unknown (e.g. "Current Position" without a connected machine). + """ + element = self._job_placement_element + doc = self.doc + machine = self.machine + if doc is None or machine is None or doc.job_origin.is_absolute: + if element.visible: + element.set_visible(False) + self.queue_draw() + return + if not self._job_design_rect_valid: + self._job_design_rect_cache = job_design_rect(doc) + self._job_design_rect_valid = True + rect = self._job_design_rect_cache + try: + shift = compute_job_shift(machine, doc.job_origin, rect) + except JobPlacementError: + shift = None + if rect is None or shift is None: + element.set_visible(False) + self.queue_draw() + return + dx, dy = shift + placed = (rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy) + ax, ay = anchor_point(placed, doc.job_origin.anchor) + panel = machine.panel + anchor_rect = panel.world_bbox_to_panel((ax, ay, ax, ay)) + element.set_placement( + panel.world_bbox_to_panel(placed), anchor_rect[:2] + ) + element.set_visible(True) + self.queue_draw() + + @property + def job_placement_element(self) -> JobPlacementElement: + """The outline of a job placed via Start From.""" + return self._job_placement_element def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: self._update_theme_colors() @@ -1153,6 +1224,8 @@ def sort_key(element: CanvasElement): """ if isinstance(element, DotElement): return float("inf") - 1 + if isinstance(element, JobPlacementElement): + return float("inf") - 2 if isinstance(element, LayerElement): # LayerElements are ordered according to the doc.layers list. # Add a large offset to ensure all layers are above stock @@ -1192,6 +1265,7 @@ def remove_all(self): NogoZoneElement, WorkOriginElement, AxisExtentFrameElement, + JobPlacementElement, ), ) ] diff --git a/rayforge/ui_gtk/doceditor/bottom_panel.py b/rayforge/ui_gtk/doceditor/bottom_panel.py index a2036674a..48f5389c9 100644 --- a/rayforge/ui_gtk/doceditor/bottom_panel.py +++ b/rayforge/ui_gtk/doceditor/bottom_panel.py @@ -16,6 +16,7 @@ from ..doceditor.layers_tab import LayersTab from ..icons import get_icon from ..machine.console import Console +from ..machine.job_origin_rows import JobOriginRows from ..machine.jog_widget import JogWidget from ..machine.laser_control_widget import LaserControlWidget from ..machine.move_to_popover import MoveToPopover @@ -129,6 +130,7 @@ def __init__( self._controls_widget.set_margin_top(9) self._controls_widget.set_margin_bottom(9) + self.job_origin_rows = JobOriginRows() if machine: self._setup_wcs_controls() self._connect_machine_signals() @@ -244,6 +246,7 @@ def set_doc(self, doc): self.doc = doc self.asset_browser.set_doc(doc) self.layers_tab.set_doc(doc) + self.job_origin_rows.set_doc(doc) if doc: doc.active_layer_changed.connect(self._on_active_layer_changed) self._connect_layer_signals() @@ -342,6 +345,10 @@ def _setup_wcs_controls(self): ) self.wcs_group.add(self.wcs_row) + self.job_origin_rows.set_machine(self.machine) + self.wcs_group.add(self.job_origin_rows.start_from_row) + self.wcs_group.add(self.job_origin_rows.anchor_row) + self.offsets_row = Adw.ActionRow(title=_("Current Offsets")) self.edit_offsets_btn = Gtk.Button(child=get_icon("edit-symbolic")) @@ -523,6 +530,7 @@ def set_machine( self.machine_cmd = machine_cmd self.console.set_machine(machine) + self.job_origin_rows.set_machine(machine) if self.machine: self._connect_machine_signals() @@ -688,6 +696,7 @@ def _update_wcs_ui(self): if not self.machine: return + self.job_origin_rows.update() self._sync_pointer_alignment_btn() self._sync_wcs_model() diff --git a/rayforge/ui_gtk/machine/job_origin_rows.py b/rayforge/ui_gtk/machine/job_origin_rows.py new file mode 100644 index 000000000..055680804 --- /dev/null +++ b/rayforge/ui_gtk/machine/job_origin_rows.py @@ -0,0 +1,187 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ...core.job_origin import JobAnchor, JobOrigin, StartFrom +from ...core.undo import ChangePropertyCommand +from ...machine.job_placement import JobPlacementError, resolve_start_point +from ..shared.gtk import apply_css + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...machine.models.machine import Machine + +_MODES = [ + StartFrom.ABSOLUTE, + StartFrom.CURRENT_POSITION, + StartFrom.USER_ORIGIN, +] + +# Rows of the selector as seen on screen, top to bottom. +_GRID = [ + [JobAnchor.TOP_LEFT, JobAnchor.TOP, JobAnchor.TOP_RIGHT], + [JobAnchor.LEFT, JobAnchor.CENTER, JobAnchor.RIGHT], + [JobAnchor.BOTTOM_LEFT, JobAnchor.BOTTOM, JobAnchor.BOTTOM_RIGHT], +] + +apply_css( + """ +button.job-origin-cell { + min-width: 14px; + min-height: 14px; + padding: 0; + border-radius: 3px; +} +button.job-origin-cell:checked { + background-color: @accent_bg_color; +} +""" +) + + +class JobOriginRows: + """ + The "Start From" mode and the 9-point job origin selector. + + Provides two preference rows: a combo row for the mode and an + action row holding a 3x3 grid of toggle buttons for the anchor. + Changes go through the document's undo history. + """ + + def __init__(self): + self.doc: Doc | None = None + self.machine: Machine | None = None + self._updating = False + + self.start_from_row = Adw.ComboRow( + title=_("Start From"), + model=Gtk.StringList.new([mode.label for mode in _MODES]), + ) + self.start_from_row.set_tooltip_text( + _( + "Where the job runs: as placed on the canvas, at the " + "laser head, or on the zero point of the active WCS" + ) + ) + self.start_from_row.connect("notify::selected", self._on_mode_changed) + + self.anchor_row = Adw.ActionRow(title=_("Job Origin")) + self.anchor_row.set_tooltip_text( + _("The point of the job that is placed on the start position") + ) + grid = Gtk.Grid(row_spacing=2, column_spacing=2) + grid.set_valign(Gtk.Align.CENTER) + self.anchor_buttons: dict[JobAnchor, Gtk.ToggleButton] = {} + group: Gtk.ToggleButton | None = None + for row, anchors in enumerate(_GRID): + for col, anchor in enumerate(anchors): + button = Gtk.ToggleButton() + button.add_css_class("job-origin-cell") + button.set_tooltip_text(anchor.label) + if group is None: + group = button + else: + button.set_group(group) + button.connect("toggled", self._on_anchor_toggled, anchor) + grid.attach(button, col, row, 1, 1) + self.anchor_buttons[anchor] = button + self.anchor_row.add_suffix(grid) + + self.update() + + def set_doc(self, doc: "Doc | None"): + if self.doc is not None: + self.doc.job_origin_changed.disconnect(self._on_job_origin_changed) + self.doc = doc + if doc is not None: + doc.job_origin_changed.connect(self._on_job_origin_changed) + self.update() + + def set_machine(self, machine: "Machine | None"): + self.machine = machine + self.update() + + def _on_job_origin_changed(self, sender): + self.update() + + def update(self): + """Mirrors the document's job origin into the rows.""" + job_origin = self.doc.job_origin if self.doc else JobOrigin() + self._updating = True + try: + self.start_from_row.set_selected( + _MODES.index(job_origin.start_from) + ) + self.anchor_buttons[job_origin.anchor].set_active(True) + finally: + self._updating = False + self.start_from_row.set_sensitive(self.doc is not None) + self.start_from_row.set_subtitle(self._describe_start(job_origin)) + self.anchor_row.set_subtitle(job_origin.anchor.label) + self.anchor_row.set_sensitive( + self.doc is not None and not job_origin.is_absolute + ) + + def _describe_start(self, job_origin: JobOrigin) -> str: + if job_origin.is_absolute: + return _("As placed on the canvas") + if self.machine is None: + return "" + if job_origin.start_from == StartFrom.USER_ORIGIN: + if self.machine.wcs_origin_is_workarea_origin: + return _("On the work area origin") + return _("On the zero point of {wcs}").format( + wcs=self.machine.active_wcs + ) + try: + point = resolve_start_point(self.machine, job_origin.start_from) + except JobPlacementError: + return _("Needs a connected, idle machine") + if point is None: + return "" + x, y = self._world_to_wcs(point) + if self.machine.pointer_alignment_enabled: + text = _("At the pointer dot: X {x:.2f} Y {y:.2f}") + else: + text = _("At the laser head: X {x:.2f} Y {y:.2f}") + return text.format(x=x, y=y) + + def _world_to_wcs(self, point: tuple[float, float]) -> tuple[float, float]: + """A world point in the coordinates of the active WCS.""" + assert self.machine is not None + panel = self.machine.panel + m_x, m_y = panel.world_point_to_machine(*point) + off_x, off_y, _z = panel.get_command_offset( + wcs_offset=self.machine.get_active_wcs_offset(), + wcs_is_workarea_origin=self.machine.wcs_origin_is_workarea_origin, + ) + return (m_x - off_x, m_y - off_y) + + def _set_job_origin(self, job_origin: JobOrigin): + doc = self.doc + if doc is None or job_origin == doc.job_origin: + return + command = ChangePropertyCommand( + target=doc, + property_name="job_origin", + new_value=job_origin, + setter_method_name="set_job_origin", + name=_("Change Start From"), + ) + doc.history_manager.execute(command) + + def _on_mode_changed(self, row, _pspec): + if self._updating or self.doc is None: + return + index = row.get_selected() + if not 0 <= index < len(_MODES): + return + current = self.doc.job_origin + self._set_job_origin(JobOrigin(_MODES[index], current.anchor)) + + def _on_anchor_toggled(self, button, anchor: JobAnchor): + if self._updating or self.doc is None or not button.get_active(): + return + current = self.doc.job_origin + self._set_job_origin(JobOrigin(current.start_from, anchor)) diff --git a/tests/ui_gtk/canvas2d/test_job_placement_overlay.py b/tests/ui_gtk/canvas2d/test_job_placement_overlay.py new file mode 100644 index 000000000..dae22f83f --- /dev/null +++ b/tests/ui_gtk/canvas2d/test_job_placement_overlay.py @@ -0,0 +1,71 @@ +from unittest.mock import MagicMock + +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom +from rayforge.machine.driver.driver import DeviceState, DeviceStatus +from rayforge.machine.models.machine import Machine, Origin +from rayforge.machine.transport import TransportStatus +from rayforge.ui_gtk.canvas2d import surface as surface_module +from rayforge.ui_gtk.canvas2d.surface import WorkSurface + +DESIGN_RECT = (10.0, 20.0, 30.0, 40.0) + + +@pytest.fixture +def surface(ui_context_initializer, monkeypatch): + monkeypatch.setattr( + surface_module, "job_design_rect", lambda doc: DESIGN_RECT + ) + machine = Machine(ui_context_initializer) + machine.set_axis_extents(400, 400) + machine.set_origin(Origin.BOTTOM_LEFT) + machine.set_active_wcs("G54") + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + editor = MagicMock() + editor.doc = Doc() + return WorkSurface(editor, MagicMock(), machine) + + +def _placed(surface): + element = surface.job_placement_element + tx, ty = element.transform.get_translation() + return (tx, ty, tx + element.width, ty + element.height), element.anchor + + +@pytest.mark.ui +def test_absolute_shows_no_outline(surface): + assert not surface.job_placement_element.visible + + +@pytest.mark.ui +def test_user_origin_outline_on_wcs_zero(surface): + surface.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + assert surface.job_placement_element.visible + rect, anchor = _placed(surface) + assert rect == pytest.approx((90.0, 40.0, 110.0, 60.0)) + assert anchor == pytest.approx((10.0, 10.0)) + + +@pytest.mark.ui +def test_current_position_outline_follows_head(surface): + machine = surface.machine + surface.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + assert not surface.job_placement_element.visible + + machine.set_connection_status(TransportStatus.CONNECTED) + state = DeviceState(status=DeviceStatus.IDLE, machine_pos=(200, 150, 0)) + machine.set_device_state(state) + surface._on_machine_state_changed(machine, state) + assert surface.job_placement_element.visible + rect, anchor = _placed(surface) + assert rect == pytest.approx((200.0, 150.0, 220.0, 170.0)) + assert anchor == pytest.approx((0.0, 0.0)) + + surface.doc.set_job_origin(JobOrigin()) + assert not surface.job_placement_element.visible diff --git a/tests/ui_gtk/canvas2d/test_surface.py b/tests/ui_gtk/canvas2d/test_surface.py index 27bb490c9..7a73bbccb 100644 --- a/tests/ui_gtk/canvas2d/test_surface.py +++ b/tests/ui_gtk/canvas2d/test_surface.py @@ -31,6 +31,7 @@ def surface(mock_work_origin): s.width_mm = 100.0 s.height_mm = 100.0 s._update_extent_frame = MagicMock() + s._update_job_placement = MagicMock() s._connected_doc = None s._connected_layer = None s._active_layer_wcs_conn = None diff --git a/tests/ui_gtk/doceditor/test_bottom_panel_dro_pointer_alignment.py b/tests/ui_gtk/doceditor/test_bottom_panel_dro_pointer_alignment.py index 0f7ccdffe..30e9bc24d 100644 --- a/tests/ui_gtk/doceditor/test_bottom_panel_dro_pointer_alignment.py +++ b/tests/ui_gtk/doceditor/test_bottom_panel_dro_pointer_alignment.py @@ -22,6 +22,7 @@ def bottom_panel_dro(sync_machine): assert sync_machine.driver is not None bottom.doc = None bottom._sync_wcs_model = MagicMock() + bottom.job_origin_rows = MagicMock() bottom._updating_wcs_ui = False bottom.wcs_list = ["G54", "G55"] bottom.wcs_row = MagicMock() diff --git a/tests/ui_gtk/doceditor/test_bottom_panel_pointer_alignment_toggle.py b/tests/ui_gtk/doceditor/test_bottom_panel_pointer_alignment_toggle.py index d5310cc76..18dcc84fd 100644 --- a/tests/ui_gtk/doceditor/test_bottom_panel_pointer_alignment_toggle.py +++ b/tests/ui_gtk/doceditor/test_bottom_panel_pointer_alignment_toggle.py @@ -21,6 +21,7 @@ def bottom_panel_toggle(sync_machine): bottom: Any = BottomPanel.__new__(BottomPanel) bottom.machine = sync_machine bottom._sync_wcs_model = MagicMock() + bottom.job_origin_rows = MagicMock() bottom._updating_wcs_ui = False bottom.wcs_list = ["G54", "G55"] bottom.wcs_row = MagicMock() diff --git a/tests/ui_gtk/machine/test_job_origin_rows.py b/tests/ui_gtk/machine/test_job_origin_rows.py new file mode 100644 index 000000000..1d0648adf --- /dev/null +++ b/tests/ui_gtk/machine/test_job_origin_rows.py @@ -0,0 +1,67 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom +from rayforge.machine.driver.driver import DeviceState, DeviceStatus +from rayforge.machine.transport import TransportStatus + + +@pytest.fixture +def rows(sync_machine): + from rayforge.ui_gtk.machine.job_origin_rows import JobOriginRows + + sync_machine.set_active_wcs("G54") + rows = JobOriginRows() + rows.set_machine(sync_machine) + rows.set_doc(Doc()) + return rows + + +@pytest.mark.ui +def test_defaults_to_absolute(rows): + assert rows.start_from_row.get_selected() == 0 + assert rows.anchor_buttons[JobAnchor.BOTTOM_LEFT].get_active() + assert not rows.anchor_row.get_sensitive() + assert rows.start_from_row.get_subtitle() == "As placed on the canvas" + + +@pytest.mark.ui +def test_choosing_mode_is_undoable(rows): + doc = rows.doc + rows.start_from_row.set_selected(2) + assert doc.job_origin.start_from == StartFrom.USER_ORIGIN + assert rows.anchor_row.get_sensitive() + assert "G54" in rows.start_from_row.get_subtitle() + + doc.history_manager.undo() + assert doc.job_origin == JobOrigin() + assert rows.start_from_row.get_selected() == 0 + + +@pytest.mark.ui +def test_choosing_anchor(rows): + rows.start_from_row.set_selected(2) + rows.anchor_buttons[JobAnchor.CENTER].set_active(True) + assert rows.doc.job_origin == JobOrigin( + StartFrom.USER_ORIGIN, JobAnchor.CENTER + ) + assert not rows.anchor_buttons[JobAnchor.BOTTOM_LEFT].get_active() + assert rows.anchor_row.get_subtitle() == "Center" + + +@pytest.mark.ui +def test_current_position_subtitle(rows): + rows.start_from_row.set_selected(1) + assert rows.start_from_row.get_subtitle() == ( + "Needs a connected, idle machine" + ) + machine = rows.machine + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + machine.set_connection_status(TransportStatus.CONNECTED) + machine.set_device_state( + DeviceState(status=DeviceStatus.IDLE, machine_pos=(120, 80, 0)) + ) + rows.update() + assert rows.start_from_row.get_subtitle() == ( + "At the laser head: X 20.00 Y 30.00" + ) From 106d0b508348cfcfe3a8a759df5738b267aa7d09 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:04:31 +0200 Subject: [PATCH 04/11] docs: document Start From Refs #518 --- CHANGELOG.md | 8 ++++ .../docs/general-info/coordinate-systems.md | 34 +++++++++++++++++ .../general-info/coordinate-systems.md | 37 +++++++++++++++++++ .../general-info/coordinate-systems.md | 37 +++++++++++++++++++ .../general-info/coordinate-systems.md | 37 +++++++++++++++++++ .../general-info/coordinate-systems.md | 34 +++++++++++++++++ .../general-info/coordinate-systems.md | 37 +++++++++++++++++++ .../general-info/coordinate-systems.md | 36 ++++++++++++++++++ .../general-info/coordinate-systems.md | 24 ++++++++++++ 9 files changed, 284 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16ae179e..dc01b508b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Duplicates (duplicate contours inside a workpiece and stacked copies of whole workpieces), and Break Apart (one workpiece per contour, holes and open paths included). Each is a single undo step (#516) +- Start From: run a job at its canvas position (Absolute Coordinates), + on the zero point of the active WCS (User Origin) or at the laser + head (Current Position), with a 9-point Job Origin selector in the + Control Panel. The job is shifted as a whole before encoding, so the + G-code stays absolute in the active WCS (no G92/G10); framing, the + 3D preview and the pre-flight checks use the shifted job, and a + shifted job that leaves the machine travel or enters a no-go zone is + refused (#518) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/website/docs/general-info/coordinate-systems.md b/website/docs/general-info/coordinate-systems.md index 5f8788580..3e4283f70 100644 --- a/website/docs/general-info/coordinate-systems.md +++ b/website/docs/general-info/coordinate-systems.md @@ -189,6 +189,40 @@ next to the selector lets you adjust offsets without leaving the dialog. Layers without an explicit WCS assignment use the globally active WCS from the toolbar dropdown. +## Start From and Job Origin + +By default a job runs exactly where it is placed on the canvas. The **Start From** setting in the +Control Panel (saved with the project) lets you place it somewhere else instead: + +| Mode | Where the job runs | +| ------------------------ | -------------------------------------------------------------------------- | +| **Absolute Coordinates** | As placed on the canvas (default) | +| **User Origin** | The job origin point lands on the zero point of the active WCS | +| **Current Position** | The job origin point lands where the laser head is when you Frame or Start | + +The **Job Origin** selector next to it picks which of nine points of the job's bounding box (a +corner, the middle of an edge or the center) is placed on that position. The bounding box is the one +of the designs you see on the canvas, without overscan or lead-ins. + +When the mode is not Absolute, the canvas shows a dashed blue outline where the job will actually +run, with a marker on the job origin point. + +How it works: + +- The whole job is shifted as one piece before it is turned into G-code. The G-code stays ordinary + absolute G-code in the active WCS: Rayforge does not send `G92` or `G10` and does not change your + WCS offsets. +- Framing, the 3D preview and the pre-flight checks use the same shifted job. When the shifted job + would leave the machine travel or enter a no-go zone, sending and framing are refused. +- **User Origin** produces coordinates relative to the WCS zero. A G-code file exported this way + follows the WCS when you zero it somewhere else on the machine, which makes it the right choice + for files you run from an SD card or USB stick. +- **Current Position** needs a connected, idle machine that reports its position. With pointer + alignment on, the pointer dot is used instead of the beam. After framing, or after a finished job, + the head is moved back to the start position, so Frame followed by Start lands in the same place. + An exported file contains the head position at the time of the export. +- Layers that use a rotary module only support Absolute Coordinates. + ## Practical Workflows ### Workflow 1: Multiple Fixture Positions diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index e005f861f..0fdb8df89 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -190,6 +190,43 @@ Dialog zu verlassen. Ebenen ohne explizite WCS-Zuweisung verwenden das global aktive WCS aus dem Dropdown in der Symbolleiste. +## Starten ab und Job-Ursprung + +Standardmäßig läuft ein Job genau dort, wo er auf der Arbeitsfläche platziert ist. Mit der +Einstellung **Starten ab** im Steuerungsfeld (wird mit dem Projekt gespeichert) kannst du ihn +stattdessen woanders platzieren: + +| Modus | Wo der Job läuft | +| ------------------------ | ------------------------------------------------------------------------------------ | +| **Absolute Koordinaten** | Wie auf der Arbeitsfläche platziert (Standard) | +| **Benutzerursprung** | Der Job-Ursprung liegt auf dem Nullpunkt des aktiven WCS | +| **Aktuelle Position** | Der Job-Ursprung liegt dort, wo der Laserkopf beim Umranden oder Starten steht | + +Mit der Auswahl **Job-Ursprung** daneben wählst du, welcher von neun Punkten des Begrenzungsrahmens +des Jobs (eine Ecke, die Mitte einer Kante oder die Mitte) auf diese Position gelegt wird. Der +Begrenzungsrahmen ist der der Designs auf der Arbeitsfläche, ohne Overscan oder Anfahrwege. + +Wenn der Modus nicht Absolut ist, zeigt die Arbeitsfläche einen gestrichelten blauen Rahmen dort, wo +der Job tatsächlich läuft, mit einer Markierung auf dem Job-Ursprung. + +So funktioniert es: + +- Der ganze Job wird als Ganzes verschoben, bevor daraus G-Code wird. Der G-Code bleibt normaler + absoluter G-Code im aktiven WCS: Rayforge sendet kein `G92` oder `G10` und ändert deine + WCS-Offsets nicht. +- Umranden, die 3D-Vorschau und die Prüfungen vor dem Senden verwenden denselben verschobenen Job. + Würde der verschobene Job den Verfahrweg der Maschine verlassen oder eine Sperrzone berühren, + werden Senden und Umranden abgelehnt. +- **Benutzerursprung** erzeugt Koordinaten relativ zum WCS-Nullpunkt. Eine so exportierte G-Code- + Datei folgt dem WCS, wenn du es an der Maschine woanders nullst. Das ist die richtige Wahl für + Dateien, die du von einer SD-Karte oder einem USB-Stick ausführst. +- **Aktuelle Position** benötigt eine verbundene, bereite Maschine, die ihre Position meldet. Mit + eingeschalteter Pointer-Ausrichtung wird der Pointer-Punkt statt des Strahls verwendet. Nach dem + Umranden oder einem abgeschlossenen Job fährt der Kopf zurück zur Startposition, sodass Umranden + gefolgt von Starten an derselben Stelle landet. Eine exportierte Datei enthält die Kopfposition + zum Zeitpunkt des Exports. +- Ebenen mit einem Rotationsmodul unterstützen nur Absolute Koordinaten. + ## Praktische Workflows ### Workflow 1: Mehrere Vorrichtungs-Positionen diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 037ea08a9..72837b58e 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -190,6 +190,43 @@ de edición rápida junto al selector te permite ajustar los desplazamientos sin Las capas sin una asignación explícita de WCS usan el WCS activo global del menú desplegable de la barra de herramientas. +## Iniciar desde y origen del trabajo + +De forma predeterminada, un trabajo se ejecuta exactamente donde está colocado en el lienzo. El +ajuste **Iniciar desde** del panel de control (se guarda con el proyecto) te permite colocarlo en +otro lugar: + +| Modo | Dónde se ejecuta el trabajo | +| ------------------------- | ----------------------------------------------------------------------------------- | +| **Coordenadas absolutas** | Tal como está en el lienzo (predeterminado) | +| **Origen de usuario** | El origen del trabajo cae en el punto cero del WCS activo | +| **Posición actual** | El origen del trabajo cae donde está el cabezal láser al encuadrar o iniciar | + +El selector **Origen del trabajo** que está al lado elige cuál de los nueve puntos del cuadro +delimitador del trabajo (una esquina, el centro de un borde o el centro) se coloca en esa posición. +El cuadro delimitador es el de los diseños que ves en el lienzo, sin sobrebarrido ni entradas. + +Cuando el modo no es Absoluto, el lienzo muestra un contorno azul discontinuo donde se ejecutará +realmente el trabajo, con una marca en el origen del trabajo. + +Cómo funciona: + +- Todo el trabajo se desplaza como una sola pieza antes de convertirse en G-code. El G-code sigue + siendo G-code absoluto normal en el WCS activo: Rayforge no envía `G92` ni `G10` y no cambia tus + desplazamientos WCS. +- El encuadre, la vista previa 3D y las comprobaciones previas usan el mismo trabajo desplazado. Si + el trabajo desplazado saliera del recorrido de la máquina o entrara en una zona prohibida, se + rechazan el envío y el encuadre. +- **Origen de usuario** genera coordenadas relativas al cero del WCS. Un archivo G-code exportado + así sigue al WCS cuando lo pones a cero en otro lugar de la máquina, lo que lo convierte en la + opción adecuada para archivos que ejecutas desde una tarjeta SD o una memoria USB. +- **Posición actual** requiere una máquina conectada e inactiva que informe su posición. Con la + alineación por puntero activada se usa el punto del puntero en lugar del rayo. Tras encuadrar, o + tras un trabajo terminado, el cabezal vuelve a la posición de inicio, de modo que encuadrar y + luego iniciar acaba en el mismo lugar. Un archivo exportado contiene la posición del cabezal en el + momento de la exportación. +- Las capas que usan un módulo rotativo solo admiten coordenadas absolutas. + ## Flujos de Trabajo Prácticos ### Flujo de Trabajo 1: Múltiples Posiciones de Fijación diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 37471194a..ef6784e48 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -189,6 +189,43 @@ boîte de dialogue. Les calques sans attribution explicite de WCS utilisent le WCS global actif du menu déroulant de la barre d'outils. +## Démarrer depuis et origine de la tâche + +Par défaut, une tâche s'exécute exactement là où elle est placée sur le canevas. Le réglage +**Démarrer depuis** du panneau de contrôle (enregistré avec le projet) permet de la placer +ailleurs : + +| Mode | Où la tâche s'exécute | +| ------------------------- | ------------------------------------------------------------------------------------- | +| **Coordonnées absolues** | Tel que placé sur le canevas (par défaut) | +| **Origine utilisateur** | L'origine de la tâche tombe sur le point zéro du WCS actif | +| **Position actuelle** | L'origine de la tâche tombe là où se trouve la tête laser au cadrage ou au démarrage | + +Le sélecteur **Origine de la tâche** à côté choisit lequel des neuf points du cadre englobant de la +tâche (un coin, le milieu d'un bord ou le centre) est placé sur cette position. Le cadre englobant +est celui des motifs visibles sur le canevas, sans surbalayage ni entrées. + +Quand le mode n'est pas Absolu, le canevas affiche un contour bleu en pointillés là où la tâche +s'exécutera réellement, avec un repère sur l'origine de la tâche. + +Fonctionnement : + +- Toute la tâche est déplacée d'un bloc avant d'être convertie en G-code. Le G-code reste du G-code + absolu ordinaire dans le WCS actif : Rayforge n'envoie ni `G92` ni `G10` et ne modifie pas vos + décalages WCS. +- Le cadrage, l'aperçu 3D et les vérifications préalables utilisent la même tâche déplacée. Si la + tâche déplacée sortait de la course de la machine ou entrait dans une zone interdite, l'envoi et + le cadrage sont refusés. +- **Origine utilisateur** produit des coordonnées relatives au zéro du WCS. Un fichier G-code + exporté ainsi suit le WCS lorsque vous le remettez à zéro ailleurs sur la machine : c'est le bon + choix pour les fichiers exécutés depuis une carte SD ou une clé USB. +- **Position actuelle** nécessite une machine connectée et au repos qui signale sa position. Avec + l'alignement par pointeur activé, le point du pointeur est utilisé à la place du faisceau. Après + un cadrage, ou après une tâche terminée, la tête revient à la position de départ, de sorte que + cadrer puis démarrer aboutit au même endroit. Un fichier exporté contient la position de la tête + au moment de l'export. +- Les calques qui utilisent un module rotatif ne prennent en charge que les coordonnées absolues. + ## Flux de Travail Pratiques ### Flux de Travail 1 : Plusieurs Positions de Fixation diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 794c838b9..89470fac9 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -189,6 +189,40 @@ G53 आपकी मशीन की पूर्ण स्थिति दर स्पष्ट WCS निर्दिष्टि रहित लेयरें टूलबार ड्रॉपडाउन की वैश्विक रूप से सक्रिय WCS उपयोग करती हैं. +## यहाँ से शुरू और जॉब मूल बिंदु + +डिफ़ॉल्ट रूप से कोई जॉब ठीक वहीं चलता है जहाँ वह कैनवास पर रखा गया है। कंट्रोल पैनल में +**यहाँ से शुरू** सेटिंग (प्रोजेक्ट के साथ सहेजी जाती है) से आप इसे कहीं और रख सकते हैं: + +| मोड | जॉब कहाँ चलता है | +| --------------------------- | ------------------------------------------------------------------------- | +| **निरपेक्ष निर्देशांक** | कैनवास पर जैसा रखा गया है (डिफ़ॉल्ट) | +| **उपयोगकर्ता मूल बिंदु** | जॉब मूल बिंदु सक्रिय WCS के शून्य बिंदु पर आता है | +| **वर्तमान स्थिति** | जॉब मूल बिंदु वहाँ आता है जहाँ फ़्रेम या शुरू करते समय लेज़र हेड होता है | + +उसके पास वाला **जॉब मूल बिंदु** चयनकर्ता चुनता है कि जॉब के बाउंडिंग बॉक्स के नौ बिंदुओं में से कौन-सा +(एक कोना, किसी किनारे का मध्य या केंद्र) उस स्थिति पर रखा जाए। बाउंडिंग बॉक्स कैनवास पर दिखने वाले +डिज़ाइनों का होता है, ओवरस्कैन या लीड-इन के बिना। + +जब मोड निरपेक्ष नहीं होता, तो कैनवास उस जगह एक धराशायी नीली रूपरेखा दिखाता है जहाँ जॉब वास्तव में +चलेगा, और जॉब मूल बिंदु पर एक चिह्न। + +यह कैसे काम करता है: + +- पूरा जॉब G-code बनने से पहले एक टुकड़े के रूप में खिसकाया जाता है। G-code सक्रिय WCS में सामान्य + निरपेक्ष G-code ही रहता है: Rayforge `G92` या `G10` नहीं भेजता और आपके WCS ऑफ़सेट नहीं बदलता। +- फ़्रेमिंग, 3D पूर्वावलोकन और पूर्व-जाँच उसी खिसकाए गए जॉब का उपयोग करते हैं। यदि खिसकाया गया जॉब + मशीन की यात्रा सीमा से बाहर जाए या किसी नो-गो ज़ोन में प्रवेश करे, तो भेजना और फ़्रेम करना अस्वीकार + कर दिया जाता है। +- **उपयोगकर्ता मूल बिंदु** WCS शून्य के सापेक्ष निर्देशांक बनाता है। इस तरह निर्यात की गई G-code फ़ाइल + WCS का अनुसरण करती है जब आप मशीन पर उसे कहीं और शून्य करते हैं, इसलिए यह SD कार्ड या USB स्टिक से + चलाई जाने वाली फ़ाइलों के लिए सही विकल्प है। +- **वर्तमान स्थिति** के लिए कनेक्टेड, निष्क्रिय मशीन चाहिए जो अपनी स्थिति रिपोर्ट करे। पॉइंटर संरेखण + चालू होने पर बीम के बजाय पॉइंटर बिंदु का उपयोग होता है। फ़्रेम करने के बाद, या पूरा हुए जॉब के बाद, हेड + शुरुआती स्थिति पर लौट आता है, ताकि फ़्रेम के बाद शुरू करने पर जॉब उसी जगह चले। निर्यात की गई फ़ाइल में + निर्यात के समय की हेड स्थिति होती है। +- रोटरी मॉड्यूल का उपयोग करने वाली लेयर केवल निरपेक्ष निर्देशांक का समर्थन करती हैं। + ## व्यावहारिक वर्कफ़्लो ### वर्कफ़्लो 1: कई फ़िक्स्चर स्थितियाँ diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index a027cdcde..47fd961c4 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -186,6 +186,43 @@ de edição rápida ao lado do seletor permite ajustar os deslocamentos sem sair Camadas sem uma atribuição explícita de WCS usam o WCS ativo global do menu suspenso da barra de ferramentas. +## Iniciar a partir de e origem do trabalho + +Por padrão, um trabalho é executado exatamente onde está posicionado na tela. A configuração +**Iniciar a partir de** no painel de controle (salva com o projeto) permite posicioná-lo em outro +lugar: + +| Modo | Onde o trabalho é executado | +| ------------------------- | ----------------------------------------------------------------------------------- | +| **Coordenadas absolutas** | Tal como posicionado na tela (padrão) | +| **Origem do usuário** | A origem do trabalho fica no ponto zero do WCS ativo | +| **Posição atual** | A origem do trabalho fica onde a cabeça do laser está ao enquadrar ou iniciar | + +O seletor **Origem do trabalho** ao lado escolhe qual dos nove pontos da caixa delimitadora do +trabalho (um canto, o meio de uma borda ou o centro) é colocado nessa posição. A caixa delimitadora +é a dos desenhos que você vê na tela, sem overscan nem entradas. + +Quando o modo não é Absoluto, a tela mostra um contorno azul tracejado onde o trabalho será +realmente executado, com uma marca na origem do trabalho. + +Como funciona: + +- Todo o trabalho é deslocado como uma peça só antes de virar G-code. O G-code continua sendo G-code + absoluto comum no WCS ativo: o Rayforge não envia `G92` nem `G10` e não altera seus deslocamentos + WCS. +- O enquadramento, a pré-visualização 3D e as verificações prévias usam o mesmo trabalho deslocado. + Se o trabalho deslocado sair do curso da máquina ou entrar em uma zona proibida, o envio e o + enquadramento são recusados. +- **Origem do usuário** gera coordenadas relativas ao zero do WCS. Um arquivo G-code exportado assim + acompanha o WCS quando você o zera em outro lugar da máquina, o que o torna a escolha certa para + arquivos executados a partir de um cartão SD ou pen drive. +- **Posição atual** requer uma máquina conectada e ociosa que informe sua posição. Com o + alinhamento por ponteiro ativado, o ponto do ponteiro é usado em vez do feixe. Após o + enquadramento, ou após um trabalho concluído, a cabeça volta à posição inicial, de modo que + enquadrar e depois iniciar termina no mesmo lugar. Um arquivo exportado contém a posição da cabeça + no momento da exportação. +- Camadas que usam um módulo rotativo só suportam coordenadas absolutas. + ## Fluxos de Trabalho Práticos ### Fluxo de Trabalho 1: Múltiplas Posições de Fixação diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 271dadca6..3a17d66ad 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -184,6 +184,42 @@ G53 представляє абсолютну позицію вашої маши Шари без явного призначення WCS використовують глобально активний WCS із випадаючого списку панелі інструментів. +## Почати від і початок завдання + +Типово завдання виконується саме там, де його розміщено на полотні. Параметр **Почати від** на +панелі керування (зберігається разом із проєктом) дає змогу розмістити його деінде: + +| Режим | Де виконується завдання | +| ------------------------- | ------------------------------------------------------------------------------- | +| **Абсолютні координати** | Як розміщено на полотні (типово) | +| **Початок користувача** | Початок завдання потрапляє в нульову точку активної WCS | +| **Поточна позиція** | Початок завдання потрапляє туди, де стоїть лазерна голівка під час рамки чи запуску | + +Вибір **Початок завдання** поруч визначає, яка з дев'яти точок обмежувальної рамки завдання (кут, +середина краю або центр) розміщується в цій позиції. Обмежувальна рамка — це рамка дизайнів, які ви +бачите на полотні, без перебігу та заходів. + +Якщо режим не Абсолютний, полотно показує синій пунктирний контур там, де завдання фактично +виконуватиметься, з позначкою на початку завдання. + +Як це працює: + +- Усе завдання зсувається як одне ціле, перш ніж перетворитися на G-code. G-code залишається + звичайним абсолютним G-code в активній WCS: Rayforge не надсилає `G92` чи `G10` і не змінює ваші + зміщення WCS. +- Рамка, 3D-попередній перегляд і попередні перевірки використовують те саме зсунуте завдання. Якщо + зсунуте завдання виходить за межі ходу верстата або заходить у заборонену зону, надсилання та + рамку буде відхилено. +- **Початок користувача** створює координати відносно нуля WCS. Експортований так файл G-code + слідує за WCS, коли ви обнуляєте її в іншому місці верстата, тож це правильний вибір для файлів, + які ви запускаєте з SD-картки чи USB-накопичувача. +- **Поточна позиція** потребує підключеного верстата в стані очікування, який повідомляє свою + позицію. З увімкненим вирівнюванням за вказівником використовується точка вказівника замість + променя. Після рамки або завершеного завдання голівка повертається в початкову позицію, тож рамка, + а потім запуск потрапляють у те саме місце. Експортований файл містить позицію голівки на момент + експорту. +- Шари з поворотним модулем підтримують лише абсолютні координати. + ## Практичні робочі процеси ### Робочий процес 1: Кілька позицій кріплення diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 2355569f3..f206e4ce3 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -166,6 +166,30 @@ G53 代表您机器的绝对位置,零点在机器的原点位置。这由您 没有明确分配 WCS 的图层使用工具栏下拉菜单中的全局活动 WCS。 +## 起始位置与作业原点 + +默认情况下,作业会准确地在画布上的放置位置运行。控制面板中的**起始位置**设置(随项目保存)可以让你把作业放到其他位置: + +| 模式 | 作业运行位置 | +| ------------ | ------------------------------------------------ | +| **绝对坐标** | 按画布上的位置(默认) | +| **用户原点** | 作业原点落在当前工件坐标系(WCS)的零点上 | +| **当前位置** | 作业原点落在走边框或开始时激光头所在的位置 | + +旁边的**作业原点**选择器用于选择作业边界框九个点中的哪一个(某个角、某条边的中点或中心)放在该位置。边界框是画布上所见设计的边界框,不包括过扫描和引入线。 + +当模式不是绝对坐标时,画布会在作业实际运行的位置显示蓝色虚线轮廓,并在作业原点处显示标记。 + +工作原理: + +- 整个作业在转换为 G-code 之前作为一个整体平移。G-code 仍是当前 WCS 中的普通绝对坐标 G-code:Rayforge 不会发送 + `G92` 或 `G10`,也不会更改你的 WCS 偏移。 +- 走边框、3D 预览和发送前检查都使用同一个平移后的作业。如果平移后的作业超出机器行程或进入禁区,将拒绝发送和走边框。 +- **用户原点**生成相对于 WCS 零点的坐标。以这种方式导出的 G-code 文件会在你于机器上其他位置重新归零 WCS + 时随之移动,因此适合从 SD 卡或 U 盘运行的文件。 +- **当前位置**需要已连接且空闲、并报告自身位置的机器。开启光点对齐时,使用光点而不是光束的位置。走边框后或作业完成后,激光头会回到起始位置,因此先走边框再开始会落在同一位置。导出的文件包含导出时的激光头位置。 +- 使用旋转模块的图层只支持绝对坐标。 + ## 实用工作流程 ### 工作流程 1:多个夹具位置 From fdfa26ba00c09c62f8117ff36351292e5bb75220 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:17:57 +0200 Subject: [PATCH 05/11] fix(ui): check Current Position jobs at the head's present position The pre-flight check reused the cached job, which in Current Position mode was placed where the head was when it was assembled. It now assembles the job afresh in that mode. Refs #518 --- rayforge/ui_gtk/mainwindow.py | 7 +++ .../test_mainwindow_sanity_job_origin.py | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/ui_gtk/test_mainwindow_sanity_job_origin.py diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index af9215482..861601279 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -15,6 +15,7 @@ from ..core.config import RightPanelMode from ..core.group import Group from ..core.item import DocItem +from ..core.job_origin import StartFrom from ..core.registration import call_registration_hooks from ..core.undo import Command, HistoryManager from ..doceditor.editor import DocEditor @@ -2372,6 +2373,12 @@ def _handle_ops(ops): dialog.present() existing = self.doc_editor.pipeline.get_existing_job_handle() + if self.doc_editor.doc.job_origin.start_from == ( + StartFrom.CURRENT_POSITION + ): + # The cached job was placed where the head was back then; + # check the placement for the position it has now. + existing = None if existing is not None: artifact_store = self.doc_editor.pipeline.artifact_store try: diff --git a/tests/ui_gtk/test_mainwindow_sanity_job_origin.py b/tests/ui_gtk/test_mainwindow_sanity_job_origin.py new file mode 100644 index 000000000..9dd814fa5 --- /dev/null +++ b/tests/ui_gtk/test_mainwindow_sanity_job_origin.py @@ -0,0 +1,47 @@ +""" +The pre-flight sanity check must not reuse a cached job when the job +is placed at the current head position: the head may have moved since +that job was assembled. +""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom + + +@pytest.fixture +def window(monkeypatch): + from rayforge.ui_gtk import mainwindow as mainwindow_module + from rayforge.ui_gtk.mainwindow import MainWindow + + config = MagicMock() + monkeypatch.setattr( + mainwindow_module, "get_context", lambda: MagicMock(config=config) + ) + win: Any = MainWindow.__new__(MainWindow) + win.doc_editor = MagicMock() + win.doc_editor.doc = Doc() + win.doc_editor.pipeline.get_existing_job_handle.return_value = object() + return win + + +@pytest.mark.ui +@pytest.mark.parametrize( + "start_from,reuses_cache", + [ + (StartFrom.ABSOLUTE, True), + (StartFrom.USER_ORIGIN, True), + (StartFrom.CURRENT_POSITION, False), + ], +) +def test_sanity_check_cache_use(window, start_from, reuses_cache): + window.doc_editor.doc.set_job_origin( + JobOrigin(start_from, JobAnchor.CENTER) + ) + window._run_sanity_check_and_proceed(MagicMock()) + assemble = window.doc_editor.file.assemble_job_in_background + assert assemble.called is not reuses_cache From 49da45ddfe06109fd5d6688676e5a87c60acd88b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:19:41 +0200 Subject: [PATCH 06/11] test: no new Current Position job while the head returns Refs #518 --- tests/pipeline/test_job_origin_gcode.py | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/pipeline/test_job_origin_gcode.py b/tests/pipeline/test_job_origin_gcode.py index 4e4674b18..5f10c8348 100644 --- a/tests/pipeline/test_job_origin_gcode.py +++ b/tests/pipeline/test_job_origin_gcode.py @@ -7,6 +7,7 @@ which coordinates reach the controller. """ +import asyncio import re from pathlib import Path @@ -356,3 +357,41 @@ async def test_frame_refuses_job_in_nogo_zone(square_doc, mocker): await editor.wait_until_settled() frame_spy.assert_not_called() + + +@pytest.mark.asyncio +async def test_no_new_job_while_returning_to_start(square_doc, mocker): + editor, machine = square_doc + _set_head(machine, *HEAD_POS[:2]) + editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.BOTTOM_LEFT) + ) + await editor.wait_until_settled() + cmd = MachineCmd(editor) + original_run = machine.driver.run + + async def run_and_keep_busy(*args, **kwargs): + await original_run(*args, **kwargs) + # The controller still works through its buffer. + machine.device_state.status = DeviceStatus.RUN + + mocker.patch.object(machine.driver, "run", side_effect=run_and_keep_busy) + move_spy = mocker.spy(machine.driver, "move_to") + messages: list[str] = [] + editor.notification_requested.connect( + lambda sender, message, **kw: messages.append(message), weak=False + ) + + first = asyncio.create_task(cmd.send_job(machine)) + while machine.device_state.status != DeviceStatus.RUN: + await asyncio.sleep(0.01) + # While the head has not returned yet, a new Current Position job + # is refused: the machine is not idle. + with pytest.raises(JobPlacementError): + await cmd.send_job(machine) + assert messages and "idle" in messages[-1] + + machine.device_state.status = DeviceStatus.IDLE + await first + await editor.wait_until_settled() + move_spy.assert_called_once() From dc2ece32d6a2c804273ff14f200f2be4176550f4 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 19:44:28 +0200 Subject: [PATCH 07/11] test: guard the AABB unpack before destructuring --- tests/pipeline/test_job_design_rect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/pipeline/test_job_design_rect.py b/tests/pipeline/test_job_design_rect.py index 111357e6f..caaf31dc4 100644 --- a/tests/pipeline/test_job_design_rect.py +++ b/tests/pipeline/test_job_design_rect.py @@ -25,7 +25,9 @@ def test_aabb_of_workpiece_without_geometry(): def test_aabb_of_rotated_workpiece_without_geometry(): wp = _image_workpiece((0.0, 0.0), (20.0, 10.0)) wp.angle = 90.0 - min_x, min_y, max_x, max_y = workpiece_world_aabb(wp) + aabb = workpiece_world_aabb(wp) + assert aabb is not None + min_x, min_y, max_x, max_y = aabb assert (max_x - min_x, max_y - min_y) == pytest.approx((10.0, 20.0)) From 9398a4aef25805020d7479bc5e76ca28088bd871 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 16:11:41 +0200 Subject: [PATCH 08/11] fix(tracing): make the vtracer pixel limit platform-independent The SVG trace strategy rendered at sqrt(VTRACER_PIXEL_LIMIT) = 1104 px. With the 2 px border the tracer adds, the image handed to vtracer is 1108x1108 and exceeds the limit, so _handle_oversized_image downscaled it - by a different factor per platform, because the limit was platform-dependent (1_220_000 on Linux, 1_000_000 on Windows). The resulting traced geometry differs by about 0.005 mm, which made the job-origin golden G-code tests fail on the Windows build. Use the conservative 1_000_000 limit on every platform, and size the SVG render to fit under it including the border so that tracing no longer downscales at all. Regenerate the job-origin goldens: they now carry the exact 0.05 mm stroke half-width instead of a downscale artifact. Refs #538 --- rayforge/image/svg/svg_trace.py | 12 ++++++++-- rayforge/image/tracing.py | 22 ++++++------------- tests/pipeline/data/job_origin/absolute.gcode | 10 ++++----- .../current_position_bottom_left.gcode | 10 ++++----- .../data/job_origin/user_origin_center.gcode | 10 ++++----- .../job_origin/user_origin_top_right.gcode | 10 ++++----- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/rayforge/image/svg/svg_trace.py b/rayforge/image/svg/svg_trace.py index cc94a5cbf..498ae2af2 100644 --- a/rayforge/image/svg/svg_trace.py +++ b/rayforge/image/svg/svg_trace.py @@ -18,7 +18,11 @@ ) from ..engine import NormalizationEngine from ..structures import LayerGeometry, ParsingResult, VectorizationResult -from ..tracing import VTRACER_PIXEL_LIMIT, trace_surface +from ..tracing import ( + BORDER_SIZE, + VTRACER_PIXEL_LIMIT, + trace_surface, +) from .renderer import SVG_RENDERER from .svg_base import SvgImporterBase from .svgutil import trim_svg @@ -123,7 +127,11 @@ def vectorize( return VectorizationResult({}, parse_result) aspect = w_mm / h_mm if h_mm > 0 else 1.0 - TARGET_DIM = math.sqrt(VTRACER_PIXEL_LIMIT) + # Size the render so that the image with the border added by the + # tracer stays within the vtracer pixel limit. Otherwise tracing + # would silently downscale the render, and a platform-dependent + # downscale factor would leak into the traced geometry. + TARGET_DIM = math.isqrt(VTRACER_PIXEL_LIMIT) - 2 * BORDER_SIZE if aspect >= 1.0: w_px = int(TARGET_DIM) diff --git a/rayforge/image/tracing.py b/rayforge/image/tracing.py index 5d25ff794..2d6ee3547 100644 --- a/rayforge/image/tracing.py +++ b/rayforge/image/tracing.py @@ -25,11 +25,11 @@ # If the generates more paths than this, we fall back to convex hulls. MAX_VECTORS_LIMIT = 25000 # A pixel count limit to prevent integer overflows in the underlying vtracer -# native library. -VTRACER_PIXEL_LIMIT = 1_220_000 -# On Windows, we use a dedicated thread with increased stack size (8MB) -# allowing us to maintain high resolution without stack overflows. -VTRACER_WINDOWS_SAFE_LIMIT = 1_000_000 +# native library. On Windows vtracer can access-violate on slightly larger +# images, so this is the conservative limit that is safe everywhere. It must +# not depend on the platform: a smaller Windows limit would make the trace +# downscale by a different factor there and yield different geometry. +VTRACER_PIXEL_LIMIT = 1_000_000 class ColorMode(Enum): @@ -339,11 +339,7 @@ def _handle_oversized_image( returning the new image, upscaling factors, and new content height. """ h_bordered, w_bordered = image.shape - pixel_limit = ( - VTRACER_WINDOWS_SAFE_LIMIT - if sys.platform == "win32" - else VTRACER_PIXEL_LIMIT - ) + pixel_limit = VTRACER_PIXEL_LIMIT if h_bordered * w_bordered <= pixel_limit: return image, 1.0, 1.0, original_height @@ -529,11 +525,7 @@ def trace_color_image( height, width = color_image.shape[:2] - pixel_limit = ( - VTRACER_WINDOWS_SAFE_LIMIT - if sys.platform == "win32" - else VTRACER_PIXEL_LIMIT - ) + pixel_limit = VTRACER_PIXEL_LIMIT if height * width > pixel_limit: scale = (pixel_limit / (height * width)) ** 0.5 diff --git a/tests/pipeline/data/job_origin/absolute.gcode b/tests/pipeline/data/job_origin/absolute.gcode index 05a68963e..43cf6aa17 100644 --- a/tests/pipeline/data/job_origin/absolute.gcode +++ b/tests/pipeline/data/job_origin/absolute.gcode @@ -2,12 +2,12 @@ G21 ;Set units to mm G90 ;Absolute positioning G54 T0 -G0 X0.055 Y9.945 +G0 X0.05 Y9.95 M4 S500 -G1 X9.945 F3000 -G1 Y0.055 F3000 -G1 X0.055 F3000 -G1 Y9.945 F3000 +G1 X9.95 F3000 +G1 Y0.05 F3000 +G1 X0.05 F3000 +G1 Y9.95 F3000 M5 G0 X0 Y10 M4 S500 diff --git a/tests/pipeline/data/job_origin/current_position_bottom_left.gcode b/tests/pipeline/data/job_origin/current_position_bottom_left.gcode index 757b52be8..0cf9e6e68 100644 --- a/tests/pipeline/data/job_origin/current_position_bottom_left.gcode +++ b/tests/pipeline/data/job_origin/current_position_bottom_left.gcode @@ -2,12 +2,12 @@ G21 ;Set units to mm G90 ;Absolute positioning G54 T0 -G0 X100.055 Y109.945 +G0 X100.05 Y109.95 M4 S500 -G1 X109.945 F3000 -G1 Y100.055 F3000 -G1 X100.055 F3000 -G1 Y109.945 F3000 +G1 X109.95 F3000 +G1 Y100.05 F3000 +G1 X100.05 F3000 +G1 Y109.95 F3000 M5 G0 X100 Y110 M4 S500 diff --git a/tests/pipeline/data/job_origin/user_origin_center.gcode b/tests/pipeline/data/job_origin/user_origin_center.gcode index 336f74a95..f949ac644 100644 --- a/tests/pipeline/data/job_origin/user_origin_center.gcode +++ b/tests/pipeline/data/job_origin/user_origin_center.gcode @@ -2,12 +2,12 @@ G21 ;Set units to mm G90 ;Absolute positioning G54 T0 -G0 X-4.945 Y4.945 +G0 X-4.95 Y4.95 M4 S500 -G1 X4.945 F3000 -G1 Y-4.945 F3000 -G1 X-4.945 F3000 -G1 Y4.945 F3000 +G1 X4.95 F3000 +G1 Y-4.95 F3000 +G1 X-4.95 F3000 +G1 Y4.95 F3000 M5 G0 X-5 Y5 M4 S500 diff --git a/tests/pipeline/data/job_origin/user_origin_top_right.gcode b/tests/pipeline/data/job_origin/user_origin_top_right.gcode index 8e77f3c01..4cc07a57c 100644 --- a/tests/pipeline/data/job_origin/user_origin_top_right.gcode +++ b/tests/pipeline/data/job_origin/user_origin_top_right.gcode @@ -2,12 +2,12 @@ G21 ;Set units to mm G90 ;Absolute positioning G54 T0 -G0 X-9.945 Y-0.055 +G0 X-9.95 Y-0.05 M4 S500 -G1 X-0.055 F3000 -G1 Y-9.945 F3000 -G1 X-9.945 F3000 -G1 Y-0.055 F3000 +G1 X-0.05 F3000 +G1 Y-9.95 F3000 +G1 X-9.95 F3000 +G1 Y-0.05 F3000 M5 G0 X-10 Y0 M4 S500 From 0c776f316dc2aa34d75c1fa353b7ba907762d653 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:21:32 +0200 Subject: [PATCH 09/11] test: pin the default Start From output against main Absolute Coordinates is the default and must not change any existing setup. The expected G-code was generated on the merge base before Start From existed (c9ac56606) for a bottom-left bed, top-right with negative axes, top-left and bottom-right with "machine origin is work area origin" and margins, a layer with its own WCS, and a rotary layer. The default job origin must reproduce it byte for byte. Refs #518 --- .../absolute_baseline/bottom_left.gcode | 13 ++ .../bottom_right_negative_x_margins.gcode | 13 ++ .../layer_with_own_wcs.gcode | 14 ++ .../absolute_baseline/rotary_layer.gcode | 13 ++ .../top_left_workarea_origin.gcode | 13 ++ .../top_right_negative_axes.gcode | 13 ++ .../test_job_origin_absolute_baseline.py | 128 ++++++++++++++++++ 7 files changed, 207 insertions(+) create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/bottom_left.gcode create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/bottom_right_negative_x_margins.gcode create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/layer_with_own_wcs.gcode create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/rotary_layer.gcode create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/top_left_workarea_origin.gcode create mode 100644 tests/pipeline/data/job_origin/absolute_baseline/top_right_negative_axes.gcode create mode 100644 tests/pipeline/test_job_origin_absolute_baseline.py diff --git a/tests/pipeline/data/job_origin/absolute_baseline/bottom_left.gcode b/tests/pipeline/data/job_origin/absolute_baseline/bottom_left.gcode new file mode 100644 index 000000000..33713c437 --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/bottom_left.gcode @@ -0,0 +1,13 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-99.9 Y-39.9 +M4 S500 +G1 Y-49.9 F3000 +G1 X-89.9 F3000 +G1 Y-39.9 F3000 +G1 X-99.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/absolute_baseline/bottom_right_negative_x_margins.gcode b/tests/pipeline/data/job_origin/absolute_baseline/bottom_right_negative_x_margins.gcode new file mode 100644 index 000000000..335624ec9 --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/bottom_right_negative_x_margins.gcode @@ -0,0 +1,13 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-384.9 Y-14.9 +M4 S500 +G1 Y-24.9 F3000 +G1 X-374.9 F3000 +G1 Y-14.9 F3000 +G1 X-384.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/absolute_baseline/layer_with_own_wcs.gcode b/tests/pipeline/data/job_origin/absolute_baseline/layer_with_own_wcs.gcode new file mode 100644 index 000000000..b22680763 --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/layer_with_own_wcs.gcode @@ -0,0 +1,14 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +G55 +T0 +G0 X-29.9 Y-9.9 +M4 S500 +G1 Y-19.9 F3000 +G1 X-19.9 F3000 +G1 Y-9.9 F3000 +G1 X-29.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/absolute_baseline/rotary_layer.gcode b/tests/pipeline/data/job_origin/absolute_baseline/rotary_layer.gcode new file mode 100644 index 000000000..8b61602ff --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/rotary_layer.gcode @@ -0,0 +1,13 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-99.9 Y-50 A46.295 +M4 S500 +G1 X-99.9 A0.458 F3000 +G1 X-89.9 F3000 +G1 X-89.9 A46.295 F3000 +G1 X-99.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/absolute_baseline/top_left_workarea_origin.gcode b/tests/pipeline/data/job_origin/absolute_baseline/top_left_workarea_origin.gcode new file mode 100644 index 000000000..cb0cca2d7 --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/top_left_workarea_origin.gcode @@ -0,0 +1,13 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-9.9 Y369.9 +M4 S500 +G1 Y379.9 F3000 +G1 X0.1 F3000 +G1 Y369.9 F3000 +G1 X-9.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/data/job_origin/absolute_baseline/top_right_negative_axes.gcode b/tests/pipeline/data/job_origin/absolute_baseline/top_right_negative_axes.gcode new file mode 100644 index 000000000..29862deac --- /dev/null +++ b/tests/pipeline/data/job_origin/absolute_baseline/top_right_negative_axes.gcode @@ -0,0 +1,13 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X-279.9 Y-309.9 +M4 S500 +G1 Y-319.9 F3000 +G1 X-269.9 F3000 +G1 Y-309.9 F3000 +G1 X-279.9 F3000 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/pipeline/test_job_origin_absolute_baseline.py b/tests/pipeline/test_job_origin_absolute_baseline.py new file mode 100644 index 000000000..2deb97ef9 --- /dev/null +++ b/tests/pipeline/test_job_origin_absolute_baseline.py @@ -0,0 +1,128 @@ +""" +Start From "Absolute Coordinates" is the default and must not change +the output of any existing machine setup. + +The expected files were generated on main before Start From existed +(merge base c9ac56606) for the machine setups below. A document with +the default job origin must still produce byte-identical G-code. +""" + +from pathlib import Path + +import pytest +import pytest_asyncio +from raygeo.ops.axis import Axis + +from rayforge.context import get_context +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.machine.models.machine import Origin +from rayforge.machine.models.rotary_module import RotaryMode, RotaryModule +from rayforge.pipeline.artifact import JobArtifact + +ASSETS = Path(__file__).parent.parent / "doceditor" / "assets" +DATA = Path(__file__).parent / "data" / "job_origin" / "absolute_baseline" + + +def _bottom_left(machine, layer): + machine.set_origin(Origin.BOTTOM_LEFT) + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + + +def _top_right_negative_axes(machine, layer): + machine.set_origin(Origin.TOP_RIGHT) + machine.set_reverse_x_axis(True) + machine.set_reverse_y_axis(True) + machine.update_wcs_offset("G54", (-120.0, -80.0, 0.0)) + + +def _top_left_workarea_origin(machine, layer): + machine.set_origin(Origin.TOP_LEFT) + machine.set_work_margins(10.0, 20.0, 30.0, 40.0) + machine.set_wcs_origin_is_workarea_origin(True) + + +def _bottom_right_negative_x_margins(machine, layer): + machine.set_origin(Origin.BOTTOM_RIGHT) + machine.set_reverse_x_axis(True) + machine.set_work_margins(5.0, 0.0, 15.0, 25.0) + machine.set_wcs_origin_is_workarea_origin(True) + + +def _layer_with_own_wcs(machine, layer): + machine.set_origin(Origin.BOTTOM_LEFT) + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + machine.update_wcs_offset("G55", (30.0, 20.0, 0.0)) + layer.set_wcs("G55") + + +def _rotary_layer(machine, layer): + machine.set_origin(Origin.BOTTOM_LEFT) + machine.update_wcs_offset("G54", (100.0, 50.0, 0.0)) + module = RotaryModule() + module.set_mode(RotaryMode.TRUE_4TH_AXIS) + module.set_axis(Axis.A) + machine.add_rotary_module(module) + layer.set_rotary_enabled(True) + layer.set_rotary_module_uid(module.uid) + layer.set_rotary_diameter(25.0) + + +SETUPS = { + "bottom_left": _bottom_left, + "top_right_negative_axes": _top_right_negative_axes, + "top_left_workarea_origin": _top_left_workarea_origin, + "bottom_right_negative_x_margins": _bottom_right_negative_x_margins, + "layer_with_own_wcs": _layer_with_own_wcs, + "rotary_layer": _rotary_layer, +} + + +@pytest_asyncio.fixture +async def editor(doc_editor, context_initializer, contour_step_class): + machine = get_context().machine + assert machine is not None + machine.set_dialect_uid("grbl") + machine.set_axis_extents(400, 400) + machine.set_active_wcs("G54") + + step = contour_step_class.create( + context_initializer, name="Contour", optimize=False + ) + step.set_power(0.5) + step.set_cut_speed(3000) + workflow = doc_editor.doc.active_layer.workflow + assert workflow is not None + workflow.add_step(step) + + await doc_editor.import_file_from_path( + ASSETS / "10x10_square.svg", + mime_type="image/svg+xml", + vectorization_spec=PassthroughSpec(), + ) + await doc_editor.wait_until_settled() + yield doc_editor, machine + await doc_editor.wait_until_settled() + + +async def _gcode(editor) -> str: + await editor.wait_until_settled() + pipeline = editor.pipeline + pipeline.invalidate_job_output() + handle = await pipeline.generate_job_artifact_async() + assert handle is not None + with pipeline.artifact_store.checkout_handle(handle) as artifact: + assert isinstance(artifact, JobArtifact) + assert artifact.machine_code is not None + gcode = artifact.machine_code + await editor.wait_until_settled() + return gcode + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", sorted(SETUPS)) +async def test_default_output_matches_main(editor, name): + doc_editor, machine = editor + SETUPS[name](machine, doc_editor.doc.active_layer) + gcode = await _gcode(doc_editor) + expected = (DATA / f"{name}.gcode").read_text(encoding="utf-8") + assert gcode == expected From 9d08f580a12888fbabc776d574d7c5ab57744748 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:21:33 +0200 Subject: [PATCH 10/11] fix: keep Start From safe on rotated panels, layer WCS and Print & Cut - The Job Origin is picked on the canvas, but was applied in world space. On a rotated panel "top left" therefore placed another corner of the job on the start point. Map the anchor through the panel rotation, for the G-code and for the outline marker. - A layer with its own WCS is emitted relative to another zero point than the one the job is placed on. Refuse User Origin and Current Position for such layers (unless the work area origin is the zero point, where WCS offsets are not used), as for rotary layers. - Show why Start From is not available in its subtitle, and hide the placement outline instead of drawing one for a refused job. - Applying a Print & Cut alignment places the design on the printed marks; switch Start From back to Absolute Coordinates with a notice so the job is not moved away from them. Pin Start From on every origin corner, with and without negative axes and with "machine origin is work area origin" plus margins: User Origin and Current Position G-code, the preview placement, and framing with the return to the start position. Refs #518 --- CHANGELOG.md | 4 +- .../print_and_cut/wizard.py | 22 ++ rayforge/core/job_origin.py | 15 +- rayforge/machine/job_placement.py | 29 +- rayforge/pipeline/intent_builder.py | 56 ++- rayforge/ui_gtk/canvas2d/surface.py | 57 ++- rayforge/ui_gtk/machine/job_origin_rows.py | 7 +- tests/pipeline/test_job_origin_machines.py | 353 ++++++++++++++++++ .../canvas2d/test_job_placement_overlay.py | 28 ++ tests/ui_gtk/machine/test_job_origin_rows.py | 8 + tests/ui_gtk/test_print_and_cut_wizard.py | 26 ++ 11 files changed, 573 insertions(+), 32 deletions(-) create mode 100644 tests/pipeline/test_job_origin_machines.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dc01b508b..61333556c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 G-code stays absolute in the active WCS (no G92/G10); framing, the 3D preview and the pre-flight checks use the shifted job, and a shifted job that leaves the machine travel or enters a no-go zone is - refused (#518) + refused. Rotary layers and layers with their own WCS keep Absolute + Coordinates, the Job Origin follows a rotated panel, and applying a + Print & Cut alignment switches back to Absolute Coordinates (#518) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py index 3932aece6..d77d011e6 100644 --- a/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py +++ b/rayforge/builtin_addons/rayforge-addon-print-and-cut/print_and_cut/wizard.py @@ -6,6 +6,7 @@ from raygeo.geo import Matrix from rayforge.core.group import Group +from rayforge.core.job_origin import JobOrigin from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.editor import DocEditor from rayforge.machine.cmd import MachineCmd @@ -870,11 +871,32 @@ def _on_apply_clicked(self, button): self._editor.transform.create_transform_transaction( [(self._item, old_matrix, new_matrix)] ) + self._use_absolute_job_origin() toast = Adw.Toast(title=_("Alignment applied")) self.toast_overlay.add_toast(toast) self.close() + def _use_absolute_job_origin(self): + """ + Switch Start From back to Absolute Coordinates. + + The alignment places the design on the printed marks in machine + coordinates; any other Start From would move the job away from + them again. + """ + doc = self._editor.doc + if doc.job_origin.is_absolute: + return + doc.set_job_origin(JobOrigin()) + self._editor.notification_requested.send( + self, + message=_( + "Start From was set to \u201cAbsolute Coordinates\u201d " + "so that the job is cut on the printed marks." + ), + ) + def close(self): self._disable_focus() if self._machine: diff --git a/rayforge/core/job_origin.py b/rayforge/core/job_origin.py index 7e7e1c576..07a7ef709 100644 --- a/rayforge/core/job_origin.py +++ b/rayforge/core/job_origin.py @@ -101,15 +101,22 @@ def from_dict(cls, data: dict[str, Any] | None) -> "JobOrigin": return cls(start_from=start_from, anchor=anchor) -def anchor_point(rect: BBox, anchor: JobAnchor) -> Point: - """The world-space position of *anchor* on the bounding box.""" +# Fractions along X and Y of a bounding box, as in JobAnchor values. +AnchorFractions = tuple[float, float] + + +def anchor_point(rect: BBox, anchor: JobAnchor | AnchorFractions) -> Point: + """ + The world-space position of *anchor* on the bounding box. The + anchor is a JobAnchor or its (x, y) fractions in world space. + """ min_x, min_y, max_x, max_y = rect - fx, fy = anchor.value + fx, fy = anchor.value if isinstance(anchor, JobAnchor) else anchor return (min_x + fx * (max_x - min_x), min_y + fy * (max_y - min_y)) def placement_offset( - rect: BBox, anchor: JobAnchor, start_point: Point + rect: BBox, anchor: JobAnchor | AnchorFractions, start_point: Point ) -> Point: """The translation that moves *anchor* of *rect* onto *start_point*.""" ax, ay = anchor_point(rect, anchor) diff --git a/rayforge/machine/job_placement.py b/rayforge/machine/job_placement.py index cae40e894..c40acabf8 100644 --- a/rayforge/machine/job_placement.py +++ b/rayforge/machine/job_placement.py @@ -17,9 +17,17 @@ from raygeo.geo.types import Point -from ..core.job_origin import BBox, JobOrigin, StartFrom, placement_offset +from ..core.job_origin import ( + AnchorFractions, + BBox, + JobAnchor, + JobOrigin, + StartFrom, + placement_offset, +) from .driver.driver import DeviceStatus from .models.coordspace import MachineSpace +from .models.machine_panel import PanelOrientation if TYPE_CHECKING: from .models.machine import Machine @@ -67,7 +75,24 @@ def compute_job_shift( start_point = resolve_start_point(machine, job_origin.start_from) if start_point is None: return None - return placement_offset(design_rect, job_origin.anchor, start_point) + anchor = world_anchor(machine, job_origin.anchor) + return placement_offset(design_rect, anchor, start_point) + + +def world_anchor(machine: "Machine", anchor: JobAnchor) -> AnchorFractions: + """ + The world-space fractions of *anchor*, which is chosen as seen on + the canvas. A rotated panel shows the bed turned by 90 degrees, so + e.g. the top-left corner on screen is another corner in world + space. + """ + fx, fy = anchor.value + orientation = machine.panel_orientation + if orientation == PanelOrientation.ROTATED_LEFT: + return (fy, 1.0 - fx) + if orientation == PanelOrientation.ROTATED_RIGHT: + return (1.0 - fy, fx) + return (fx, fy) def _user_origin(machine: "Machine") -> Point: diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index 981548b45..c3a27e796 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -172,14 +172,15 @@ def job_design_rect(doc: Doc) -> tuple[float, float, float, float] | None: ) -def validate_job_placement(machine: Machine | None, doc: Doc) -> None: +def check_job_origin_supported(machine: Machine | None, doc: Doc) -> None: """ - Check that the document's job origin can be applied. + Check that the document's job origin fits its layers. Start From "User Origin" and "Current Position" translate the - whole job in world space, which has no meaning for rotary layers - (their Y is mapped onto the rotary axis), and "Current Position" - needs a connected, idle machine that reports the head position. + whole job in world space onto one start point. That has no meaning + for rotary layers (their Y is mapped onto the rotary axis), and a + layer with its own WCS would be emitted relative to another zero + point than the one the job is placed on. Raises: JobPlacementError: With a user-facing message. @@ -187,14 +188,57 @@ def validate_job_placement(machine: Machine | None, doc: Doc) -> None: job_origin = doc.job_origin if job_origin.is_absolute or machine is None: return + mode = job_origin.start_from.label if doc.has_rotary_layer: raise JobPlacementError( _( "Start From \u201c{mode}\u201d is not available while a " "layer uses a rotary module. Use \u201cAbsolute " "Coordinates\u201d." - ).format(mode=job_origin.start_from.label) + ).format(mode=mode) + ) + layer = _layer_with_own_wcs(machine, doc) + if layer is not None: + raise JobPlacementError( + _( + "Start From \u201c{mode}\u201d is not available while " + "layer \u201c{layer}\u201d uses its own WCS ({wcs}). " + "Use \u201cAbsolute Coordinates\u201d, or let the layer " + "use the active WCS." + ).format(mode=mode, layer=layer.name, wcs=layer.wcs) ) + + +def _layer_with_own_wcs(machine: Machine, doc: Doc) -> Layer | None: + """ + The first layer with workpieces whose WCS differs from the active + one, or None. When the work area origin is the zero point, the WCS + offsets are not used at all. + """ + if machine.wcs_origin_is_workarea_origin: + return None + for layer in doc.layers: + if not layer.all_workpieces: + continue + if layer.get_effective_wcs(machine) != machine.active_wcs: + return layer + return None + + +def validate_job_placement(machine: Machine | None, doc: Doc) -> None: + """ + Check that the document's job origin can be applied: it must fit + the layers (see check_job_origin_supported), and "Current + Position" needs a connected, idle machine that reports the head + position. + + Raises: + JobPlacementError: With a user-facing message. + """ + job_origin = doc.job_origin + if job_origin.is_absolute or machine is None: + return + check_job_origin_supported(machine, doc) compute_job_shift(machine, job_origin, (0.0, 0.0, 0.0, 0.0)) diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py index fde6f9f97..420f620df 100644 --- a/rayforge/ui_gtk/canvas2d/surface.py +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -16,11 +16,18 @@ from ...core.stock import StockItem from ...core.stock_asset import StockAsset from ...core.workpiece import WorkPiece -from ...machine.job_placement import JobPlacementError, compute_job_shift +from ...machine.job_placement import ( + JobPlacementError, + compute_job_shift, + world_anchor, +) from ...machine.models.machine import Machine from ...machine.models.machine_panel import MachinePanel from ...pipeline.artifact import RenderContext -from ...pipeline.intent_builder import job_design_rect +from ...pipeline.intent_builder import ( + check_job_origin_supported, + job_design_rect, +) from ...shared.units.formatter import get_preferred_unit_factor from ..canvas import Canvas, CanvasElement, WorldSurface from ..shared.keyboard import is_primary_modifier @@ -912,39 +919,53 @@ def _invalidate_job_placement(self): def _update_job_placement(self): """ Shows where a job placed via Start From will run, or hides the - outline for absolute coordinates or when the start point is - unknown (e.g. "Current Position" without a connected machine). + outline for absolute coordinates, when the start point is + unknown (e.g. "Current Position" without a connected machine) + or when the layers do not support the chosen Start From. """ element = self._job_placement_element - doc = self.doc machine = self.machine - if doc is None or machine is None or doc.job_origin.is_absolute: + placement = self._placed_job() + if placement is None or machine is None: if element.visible: element.set_visible(False) self.queue_draw() return + placed, (ax, ay) = placement + panel = machine.panel + anchor_rect = panel.world_bbox_to_panel((ax, ay, ax, ay)) + element.set_placement( + panel.world_bbox_to_panel(placed), anchor_rect[:2] + ) + element.set_visible(True) + self.queue_draw() + + def _placed_job( + self, + ) -> tuple[tuple[float, float, float, float], tuple[float, float]] | None: + """ + The world-space bounding box of the job placed via Start From + and its anchor point, or None when no outline applies. + """ + doc = self.doc + machine = self.machine + if doc is None or machine is None or doc.job_origin.is_absolute: + return None if not self._job_design_rect_valid: self._job_design_rect_cache = job_design_rect(doc) self._job_design_rect_valid = True rect = self._job_design_rect_cache try: + check_job_origin_supported(machine, doc) shift = compute_job_shift(machine, doc.job_origin, rect) except JobPlacementError: - shift = None + return None if rect is None or shift is None: - element.set_visible(False) - self.queue_draw() - return + return None dx, dy = shift placed = (rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy) - ax, ay = anchor_point(placed, doc.job_origin.anchor) - panel = machine.panel - anchor_rect = panel.world_bbox_to_panel((ax, ay, ax, ay)) - element.set_placement( - panel.world_bbox_to_panel(placed), anchor_rect[:2] - ) - element.set_visible(True) - self.queue_draw() + anchor = world_anchor(machine, doc.job_origin.anchor) + return placed, anchor_point(placed, anchor) @property def job_placement_element(self) -> JobPlacementElement: diff --git a/rayforge/ui_gtk/machine/job_origin_rows.py b/rayforge/ui_gtk/machine/job_origin_rows.py index 055680804..6df5cb6ae 100644 --- a/rayforge/ui_gtk/machine/job_origin_rows.py +++ b/rayforge/ui_gtk/machine/job_origin_rows.py @@ -6,6 +6,7 @@ from ...core.job_origin import JobAnchor, JobOrigin, StartFrom from ...core.undo import ChangePropertyCommand from ...machine.job_placement import JobPlacementError, resolve_start_point +from ...pipeline.intent_builder import check_job_origin_supported from ..shared.gtk import apply_css if TYPE_CHECKING: @@ -126,8 +127,12 @@ def update(self): def _describe_start(self, job_origin: JobOrigin) -> str: if job_origin.is_absolute: return _("As placed on the canvas") - if self.machine is None: + if self.machine is None or self.doc is None: return "" + try: + check_job_origin_supported(self.machine, self.doc) + except JobPlacementError as e: + return str(e) if job_origin.start_from == StartFrom.USER_ORIGIN: if self.machine.wcs_origin_is_workarea_origin: return _("On the work area origin") diff --git a/tests/pipeline/test_job_origin_machines.py b/tests/pipeline/test_job_origin_machines.py new file mode 100644 index 000000000..5870e1ad7 --- /dev/null +++ b/tests/pipeline/test_job_origin_machines.py @@ -0,0 +1,353 @@ +""" +Start From on machine setups other than a plain bottom-left GRBL bed. + +Each case pins what reaches the controller for one of the special cases +of the coordinate model: every origin corner, negative (reversed) axes, +"machine origin is work area origin" with margins, a rotated panel, +layers with their own WCS and rotary layers. + +A 10 x 10 mm square is used throughout. The anchor is chosen as it is +seen on the canvas, so "bottom left" means the bottom-left corner of +the design on screen, whatever the machine calls its axes. +""" + +import re +from pathlib import Path + +import pytest +import pytest_asyncio + +from rayforge.context import get_context +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.machine.cmd import MachineCmd +from rayforge.machine.driver.driver import DeviceState, DeviceStatus +from rayforge.machine.job_placement import JobPlacementError +from rayforge.machine.models.machine import Origin +from rayforge.machine.models.machine_panel import PanelOrientation +from rayforge.machine.transport import TransportStatus +from rayforge.pipeline.artifact import JobArtifact + +ASSETS = Path(__file__).parent.parent / "doceditor" / "assets" +MARGINS = (10.0, 20.0, 30.0, 40.0) +WCS_OFFSET = (100.0, 50.0) +NEGATIVE_WCS_OFFSET = (-120.0, -80.0) +HEAD = (200.0, 150.0) +NEGATIVE_HEAD = (-200.0, -150.0) + +# Command-space extents of the square for User Origin with the +# bottom-left anchor, per (origin, reversed axes). The anchor always +# lands on X0 Y0; the square extends in the direction in which the +# machine's coordinates grow towards the top right of the canvas. +USER_ORIGIN_BOTTOM_LEFT = { + (Origin.BOTTOM_LEFT, False): (0.0, 0.0, 10.0, 10.0), + (Origin.BOTTOM_LEFT, True): (-10.0, -10.0, 0.0, 0.0), + (Origin.TOP_LEFT, False): (0.0, -10.0, 10.0, 0.0), + (Origin.TOP_LEFT, True): (-10.0, 0.0, 0.0, 10.0), + (Origin.BOTTOM_RIGHT, False): (-10.0, 0.0, 0.0, 10.0), + (Origin.BOTTOM_RIGHT, True): (0.0, -10.0, 10.0, 0.0), + (Origin.TOP_RIGHT, False): (-10.0, -10.0, 0.0, 0.0), + (Origin.TOP_RIGHT, True): (0.0, 0.0, 10.0, 10.0), +} + +# The command offset (the zero point in machine coordinates) when the +# machine origin is the work area origin, per origin corner, for the +# margins (left, top, right, bottom) = MARGINS. Reversed axes negate it. +WORKAREA_ZERO = { + Origin.BOTTOM_LEFT: (10.0, 40.0), + Origin.TOP_LEFT: (10.0, 20.0), + Origin.BOTTOM_RIGHT: (30.0, 40.0), + Origin.TOP_RIGHT: (30.0, 20.0), +} + +ORIGINS = list(WORKAREA_ZERO) + + +def _configure(machine, origin, reversed_axes, workarea_origin): + machine.set_origin(origin) + machine.set_reverse_x_axis(reversed_axes) + machine.set_reverse_y_axis(reversed_axes) + machine.set_work_margins(*MARGINS) + machine.set_wcs_origin_is_workarea_origin(workarea_origin) + off_x, off_y = NEGATIVE_WCS_OFFSET if reversed_axes else WCS_OFFSET + machine.update_wcs_offset("G54", (off_x, off_y, 0.0)) + machine.changed.send(machine) + + +def _zero_point(origin, reversed_axes, workarea_origin): + """The active zero point in (signed) machine coordinates.""" + if workarea_origin: + x, y = WORKAREA_ZERO[origin] + return (-x, -y) if reversed_axes else (x, y) + return NEGATIVE_WCS_OFFSET if reversed_axes else WCS_OFFSET + + +def _set_head(machine, x, y): + machine.set_connection_status(TransportStatus.CONNECTED) + machine.set_device_state( + DeviceState(status=DeviceStatus.IDLE, machine_pos=(x, y, 0.0)) + ) + + +def _motion_extents(gcode: str) -> tuple[float, float, float, float]: + xs: list[float] = [] + ys: list[float] = [] + for line in gcode.splitlines(): + if "Return to origin" in line: + continue + code = line.split(";")[0] + if not re.match(r"\s*G[01]\b", code): + continue + for axis, value in re.findall(r"([XY])([-+]?\d*\.?\d+)", code): + (xs if axis == "X" else ys).append(float(value)) + return min(xs), min(ys), max(xs), max(ys) + + +def _center(rect): + """The center of a world-space (min_x, min_y, max_x, max_y) box.""" + return ((rect[0] + rect[2]) / 2, (rect[1] + rect[3]) / 2) + + +@pytest_asyncio.fixture +async def editor(doc_editor, context_initializer, contour_step_class): + machine = get_context().machine + assert machine is not None + machine.set_dialect_uid("grbl") + machine.set_axis_extents(400, 400) + machine.set_active_wcs("G54") + + step = contour_step_class.create( + context_initializer, name="Contour", optimize=False + ) + step.set_power(0.5) + step.set_cut_speed(3000) + workflow = doc_editor.doc.active_layer.workflow + assert workflow is not None + workflow.add_step(step) + + await doc_editor.import_file_from_path( + ASSETS / "10x10_square.svg", + mime_type="image/svg+xml", + vectorization_spec=PassthroughSpec(), + ) + await doc_editor.wait_until_settled() + yield doc_editor, machine + await doc_editor.wait_until_settled() + + +async def _generate(doc_editor) -> tuple[str, tuple]: + await doc_editor.wait_until_settled() + pipeline = doc_editor.pipeline + pipeline.invalidate_job_output() + handle = await pipeline.generate_job_artifact_async() + assert handle is not None + with pipeline.artifact_store.checkout_handle(handle) as artifact: + assert isinstance(artifact, JobArtifact) + assert artifact.machine_code is not None + result = artifact.machine_code, artifact.ops.rect() + await doc_editor.wait_until_settled() + return result + + +# ---------------------------------------------------------------------- +# User Origin +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workarea_origin", [False, True]) +@pytest.mark.parametrize("reversed_axes", [False, True]) +@pytest.mark.parametrize("origin", ORIGINS) +async def test_user_origin_bottom_left_on_every_setup( + editor, origin, reversed_axes, workarea_origin +): + doc_editor, machine = editor + _configure(machine, origin, reversed_axes, workarea_origin) + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.BOTTOM_LEFT) + ) + gcode, _rect = await _generate(doc_editor) + expected = USER_ORIGIN_BOTTOM_LEFT[(origin, reversed_axes)] + assert _motion_extents(gcode) == pytest.approx(expected, abs=1e-3) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workarea_origin", [False, True]) +@pytest.mark.parametrize("reversed_axes", [False, True]) +@pytest.mark.parametrize("origin", ORIGINS) +async def test_user_origin_center_lands_on_zero_point( + editor, origin, reversed_axes, workarea_origin +): + doc_editor, machine = editor + _configure(machine, origin, reversed_axes, workarea_origin) + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + gcode, rect = await _generate(doc_editor) + assert _motion_extents(gcode) == pytest.approx( + (-5.0, -5.0, 5.0, 5.0), abs=1e-3 + ) + zero = _zero_point(origin, reversed_axes, workarea_origin) + preview_center = machine.panel.world_point_to_machine(*_center(rect)) + assert preview_center == pytest.approx(zero, abs=1e-3) + + +# ---------------------------------------------------------------------- +# Current Position +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workarea_origin", [False, True]) +@pytest.mark.parametrize("reversed_axes", [False, True]) +@pytest.mark.parametrize("origin", ORIGINS) +async def test_current_position_center_lands_on_head( + editor, origin, reversed_axes, workarea_origin +): + doc_editor, machine = editor + _configure(machine, origin, reversed_axes, workarea_origin) + head = NEGATIVE_HEAD if reversed_axes else HEAD + _set_head(machine, *head) + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + gcode, rect = await _generate(doc_editor) + preview_center = machine.panel.world_point_to_machine(*_center(rect)) + assert preview_center == pytest.approx(head, abs=1e-3) + zero = _zero_point(origin, reversed_axes, workarea_origin) + cx, cy = head[0] - zero[0], head[1] - zero[1] + assert _motion_extents(gcode) == pytest.approx( + (cx - 5.0, cy - 5.0, cx + 5.0, cy + 5.0), abs=1e-3 + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workarea_origin", [False, True]) +@pytest.mark.parametrize("origin", ORIGINS) +async def test_frame_and_return_on_negative_axes( + editor, mocker, origin, workarea_origin +): + doc_editor, machine = editor + _configure(machine, origin, True, workarea_origin) + _set_head(machine, *NEGATIVE_HEAD) + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.CURRENT_POSITION, JobAnchor.CENTER) + ) + await doc_editor.wait_until_settled() + frame_spy = mocker.spy(machine.driver, "frame") + move_spy = mocker.spy(machine.driver, "move_to") + + await MachineCmd(doc_editor).frame_job(machine) + await doc_editor.wait_until_settled() + + zero = _zero_point(origin, True, workarea_origin) + cx = NEGATIVE_HEAD[0] - zero[0] + cy = NEGATIVE_HEAD[1] - zero[1] + corners = frame_spy.call_args.args[0] + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + assert (min(xs), min(ys), max(xs), max(ys)) == pytest.approx( + (cx - 5.0, cy - 5.0, cx + 5.0, cy + 5.0), abs=1e-3 + ) + move_spy.assert_called_once() + assert move_spy.call_args.args[:2] == pytest.approx((cx, cy)) + + +# ---------------------------------------------------------------------- +# Rotated panel: the anchor is the one seen on screen +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "orientation, anchor, expected", + [ + (PanelOrientation.NATIVE, JobAnchor.TOP_LEFT, (0, -10, 10, 0)), + (PanelOrientation.ROTATED_LEFT, JobAnchor.TOP_LEFT, (-10, -10, 0, 0)), + ( + PanelOrientation.ROTATED_LEFT, + JobAnchor.BOTTOM_LEFT, + (0, -10, 10, 0), + ), + (PanelOrientation.ROTATED_RIGHT, JobAnchor.TOP_LEFT, (0, 0, 10, 10)), + ( + PanelOrientation.ROTATED_RIGHT, + JobAnchor.BOTTOM_RIGHT, + (-10, -10, 0, 0), + ), + ], +) +async def test_rotated_panel_uses_anchor_as_seen_on_screen( + editor, orientation, anchor, expected +): + doc_editor, machine = editor + _configure(machine, Origin.BOTTOM_LEFT, False, False) + machine.set_panel_orientation(orientation, align_cameras=False) + doc_editor.doc.set_job_origin(JobOrigin(StartFrom.USER_ORIGIN, anchor)) + gcode, _rect = await _generate(doc_editor) + assert _motion_extents(gcode) == pytest.approx(expected, abs=1e-3) + + +# ---------------------------------------------------------------------- +# Combinations that are refused instead of guessed +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "start_from", [StartFrom.USER_ORIGIN, StartFrom.CURRENT_POSITION] +) +async def test_layer_with_own_wcs_is_refused(editor, start_from): + doc_editor, machine = editor + _configure(machine, Origin.BOTTOM_LEFT, False, False) + _set_head(machine, *HEAD) + machine.update_wcs_offset("G55", (30.0, 20.0, 0.0)) + doc_editor.doc.active_layer.set_wcs("G55") + doc_editor.doc.set_job_origin(JobOrigin(start_from, JobAnchor.CENTER)) + await doc_editor.wait_until_settled() + with pytest.raises(JobPlacementError, match="G55"): + await doc_editor.pipeline.generate_job_artifact_async() + + +@pytest.mark.asyncio +async def test_layer_naming_the_active_wcs_is_accepted(editor): + doc_editor, machine = editor + _configure(machine, Origin.BOTTOM_LEFT, False, False) + doc_editor.doc.active_layer.set_wcs("G54") + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + gcode, _rect = await _generate(doc_editor) + assert _motion_extents(gcode) == pytest.approx( + (-5.0, -5.0, 5.0, 5.0), abs=1e-3 + ) + + +@pytest.mark.asyncio +async def test_layer_wcs_is_ignored_when_workarea_is_origin(editor): + doc_editor, machine = editor + _configure(machine, Origin.BOTTOM_LEFT, False, True) + machine.update_wcs_offset("G55", (30.0, 20.0, 0.0)) + doc_editor.doc.active_layer.set_wcs("G55") + doc_editor.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + gcode, _rect = await _generate(doc_editor) + assert _motion_extents(gcode) == pytest.approx( + (-5.0, -5.0, 5.0, 5.0), abs=1e-3 + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "start_from", [StartFrom.USER_ORIGIN, StartFrom.CURRENT_POSITION] +) +async def test_rotary_layer_is_refused_in_every_mode(editor, start_from): + doc_editor, machine = editor + _configure(machine, Origin.BOTTOM_LEFT, False, False) + _set_head(machine, *HEAD) + doc_editor.doc.active_layer.set_rotary_enabled(True) + doc_editor.doc.set_job_origin(JobOrigin(start_from, JobAnchor.CENTER)) + await doc_editor.wait_until_settled() + with pytest.raises(JobPlacementError, match="rotary"): + await doc_editor.pipeline.generate_job_artifact_async() diff --git a/tests/ui_gtk/canvas2d/test_job_placement_overlay.py b/tests/ui_gtk/canvas2d/test_job_placement_overlay.py index dae22f83f..1dc708159 100644 --- a/tests/ui_gtk/canvas2d/test_job_placement_overlay.py +++ b/tests/ui_gtk/canvas2d/test_job_placement_overlay.py @@ -6,6 +6,7 @@ from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom from rayforge.machine.driver.driver import DeviceState, DeviceStatus from rayforge.machine.models.machine import Machine, Origin +from rayforge.machine.models.machine_panel import PanelOrientation from rayforge.machine.transport import TransportStatus from rayforge.ui_gtk.canvas2d import surface as surface_module from rayforge.ui_gtk.canvas2d.surface import WorkSurface @@ -69,3 +70,30 @@ def test_current_position_outline_follows_head(surface): surface.doc.set_job_origin(JobOrigin()) assert not surface.job_placement_element.visible + + +@pytest.mark.ui +@pytest.mark.parametrize( + "orientation", + [ + PanelOrientation.NATIVE, + PanelOrientation.ROTATED_LEFT, + PanelOrientation.ROTATED_RIGHT, + ], +) +def test_anchor_marker_is_where_chosen_on_screen(surface, orientation): + surface.machine.set_panel_orientation(orientation, align_cameras=False) + surface.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.TOP_LEFT) + ) + rect, anchor = _placed(surface) + assert anchor == pytest.approx((0.0, rect[3] - rect[1])) + + +@pytest.mark.ui +def test_unsupported_layer_shows_no_outline(surface): + surface.doc.active_layer.set_rotary_enabled(True) + surface.doc.set_job_origin( + JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER) + ) + assert not surface.job_placement_element.visible diff --git a/tests/ui_gtk/machine/test_job_origin_rows.py b/tests/ui_gtk/machine/test_job_origin_rows.py index 1d0648adf..c0db8e7c8 100644 --- a/tests/ui_gtk/machine/test_job_origin_rows.py +++ b/tests/ui_gtk/machine/test_job_origin_rows.py @@ -65,3 +65,11 @@ def test_current_position_subtitle(rows): assert rows.start_from_row.get_subtitle() == ( "At the laser head: X 20.00 Y 30.00" ) + + +@pytest.mark.ui +def test_subtitle_explains_unsupported_layer(rows): + doc = rows.doc + doc.active_layer.set_rotary_enabled(True) + doc.set_job_origin(JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER)) + assert "rotary" in rows.start_from_row.get_subtitle() diff --git a/tests/ui_gtk/test_print_and_cut_wizard.py b/tests/ui_gtk/test_print_and_cut_wizard.py index 58cb1f23a..73901ea85 100644 --- a/tests/ui_gtk/test_print_and_cut_wizard.py +++ b/tests/ui_gtk/test_print_and_cut_wizard.py @@ -14,6 +14,9 @@ import pytest +from rayforge.core.doc import Doc +from rayforge.core.job_origin import JobAnchor, JobOrigin, StartFrom + _REPO_ROOT = Path(__file__).parents[2] _ADDON_ROOT = ( _REPO_ROOT / "rayforge" / "builtin_addons" / "rayforge-addon-print-and-cut" @@ -223,3 +226,26 @@ def test_sync_alignment_row_state_mirrors_machine(wizard_stub): wizard_stub._alignment_row.handler_unblock.assert_called_once_with( wizard_stub._alignment_handler_id ) + + +@pytest.mark.ui +def test_apply_switches_start_from_to_absolute(wizard_stub): + doc = Doc() + doc.set_job_origin(JobOrigin(StartFrom.USER_ORIGIN, JobAnchor.CENTER)) + wizard_stub._editor.doc = doc + + PrintAndCutWizard._use_absolute_job_origin(wizard_stub) + + assert doc.job_origin == JobOrigin() + wizard_stub._editor.notification_requested.send.assert_called_once() + + +@pytest.mark.ui +def test_apply_keeps_absolute_start_from_silently(wizard_stub): + doc = Doc() + wizard_stub._editor.doc = doc + + PrintAndCutWizard._use_absolute_job_origin(wizard_stub) + + assert doc.job_origin == JobOrigin() + wizard_stub._editor.notification_requested.send.assert_not_called() From 9314c2ed4bf5d3e61bd3e961f5972ee2c13cc467 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:21:33 +0200 Subject: [PATCH 11/11] docs: document the Start From limits and Print & Cut behaviour Refs #518 --- .../docs/general-info/coordinate-systems.md | 6 ++- .../general-info/coordinate-systems.md | 17 +++++--- .../general-info/coordinate-systems.md | 17 +++++--- .../general-info/coordinate-systems.md | 22 ++++++---- .../general-info/coordinate-systems.md | 42 ++++++++++--------- .../general-info/coordinate-systems.md | 26 +++++++----- .../general-info/coordinate-systems.md | 22 ++++++---- .../general-info/coordinate-systems.md | 17 ++++---- 8 files changed, 101 insertions(+), 68 deletions(-) diff --git a/website/docs/general-info/coordinate-systems.md b/website/docs/general-info/coordinate-systems.md index 3e4283f70..b89f7ac09 100644 --- a/website/docs/general-info/coordinate-systems.md +++ b/website/docs/general-info/coordinate-systems.md @@ -221,7 +221,11 @@ How it works: alignment on, the pointer dot is used instead of the beam. After framing, or after a finished job, the head is moved back to the start position, so Frame followed by Start lands in the same place. An exported file contains the head position at the time of the export. -- Layers that use a rotary module only support Absolute Coordinates. +- Layers that use a rotary module, or a WCS other than the active one, only support Absolute + Coordinates. Start From shows why it is not available, and no outline is drawn. +- The Job Origin point is picked as you see the job on the canvas, also when the panel is rotated. +- Applying a Print & Cut alignment switches Start From back to Absolute Coordinates, because the + alignment places the design on the printed marks. ## Practical Workflows diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 0fdb8df89..eb1392960 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -196,11 +196,11 @@ Standardmäßig läuft ein Job genau dort, wo er auf der Arbeitsfläche platzier Einstellung **Starten ab** im Steuerungsfeld (wird mit dem Projekt gespeichert) kannst du ihn stattdessen woanders platzieren: -| Modus | Wo der Job läuft | -| ------------------------ | ------------------------------------------------------------------------------------ | -| **Absolute Koordinaten** | Wie auf der Arbeitsfläche platziert (Standard) | -| **Benutzerursprung** | Der Job-Ursprung liegt auf dem Nullpunkt des aktiven WCS | -| **Aktuelle Position** | Der Job-Ursprung liegt dort, wo der Laserkopf beim Umranden oder Starten steht | +| Modus | Wo der Job läuft | +| ------------------------ | ------------------------------------------------------------------------------ | +| **Absolute Koordinaten** | Wie auf der Arbeitsfläche platziert (Standard) | +| **Benutzerursprung** | Der Job-Ursprung liegt auf dem Nullpunkt des aktiven WCS | +| **Aktuelle Position** | Der Job-Ursprung liegt dort, wo der Laserkopf beim Umranden oder Starten steht | Mit der Auswahl **Job-Ursprung** daneben wählst du, welcher von neun Punkten des Begrenzungsrahmens des Jobs (eine Ecke, die Mitte einer Kante oder die Mitte) auf diese Position gelegt wird. Der @@ -225,7 +225,12 @@ So funktioniert es: Umranden oder einem abgeschlossenen Job fährt der Kopf zurück zur Startposition, sodass Umranden gefolgt von Starten an derselben Stelle landet. Eine exportierte Datei enthält die Kopfposition zum Zeitpunkt des Exports. -- Ebenen mit einem Rotationsmodul unterstützen nur Absolute Koordinaten. +- Ebenen mit einem Rotationsmodul oder mit einem anderen als dem aktiven WCS unterstützen nur + Absolute Koordinaten. Starten ab zeigt dann den Grund an, und es wird kein Umriss gezeichnet. +- Der Job-Ursprung wird so gewählt, wie der Job auf der Arbeitsfläche zu sehen ist, auch bei + gedrehter Ansicht. +- Das Anwenden einer Print-&-Cut-Ausrichtung setzt Starten ab auf Absolute Koordinaten zurück, weil + die Ausrichtung das Design auf die gedruckten Marken legt. ## Praktische Workflows diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 72837b58e..295583492 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -196,11 +196,11 @@ De forma predeterminada, un trabajo se ejecuta exactamente donde está colocado ajuste **Iniciar desde** del panel de control (se guarda con el proyecto) te permite colocarlo en otro lugar: -| Modo | Dónde se ejecuta el trabajo | -| ------------------------- | ----------------------------------------------------------------------------------- | -| **Coordenadas absolutas** | Tal como está en el lienzo (predeterminado) | -| **Origen de usuario** | El origen del trabajo cae en el punto cero del WCS activo | -| **Posición actual** | El origen del trabajo cae donde está el cabezal láser al encuadrar o iniciar | +| Modo | Dónde se ejecuta el trabajo | +| ------------------------- | ---------------------------------------------------------------------------- | +| **Coordenadas absolutas** | Tal como está en el lienzo (predeterminado) | +| **Origen de usuario** | El origen del trabajo cae en el punto cero del WCS activo | +| **Posición actual** | El origen del trabajo cae donde está el cabezal láser al encuadrar o iniciar | El selector **Origen del trabajo** que está al lado elige cuál de los nueve puntos del cuadro delimitador del trabajo (una esquina, el centro de un borde o el centro) se coloca en esa posición. @@ -225,7 +225,12 @@ Cómo funciona: tras un trabajo terminado, el cabezal vuelve a la posición de inicio, de modo que encuadrar y luego iniciar acaba en el mismo lugar. Un archivo exportado contiene la posición del cabezal en el momento de la exportación. -- Las capas que usan un módulo rotativo solo admiten coordenadas absolutas. +- Las capas que usan un módulo rotativo, o un WCS distinto del activo, solo admiten coordenadas + absolutas. Iniciar desde muestra el motivo y no se dibuja ningún contorno. +- El punto de origen del trabajo se elige tal como se ve el trabajo en el lienzo, también con el + panel girado. +- Aplicar una alineación de Print & Cut devuelve Iniciar desde a coordenadas absolutas, porque la + alineación coloca el diseño sobre las marcas impresas. ## Flujos de Trabajo Prácticos diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index ef6784e48..d812014f3 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -192,14 +192,14 @@ barre d'outils. ## Démarrer depuis et origine de la tâche Par défaut, une tâche s'exécute exactement là où elle est placée sur le canevas. Le réglage -**Démarrer depuis** du panneau de contrôle (enregistré avec le projet) permet de la placer -ailleurs : +**Démarrer depuis** du panneau de contrôle (enregistré avec le projet) permet de la placer ailleurs +: -| Mode | Où la tâche s'exécute | -| ------------------------- | ------------------------------------------------------------------------------------- | -| **Coordonnées absolues** | Tel que placé sur le canevas (par défaut) | -| **Origine utilisateur** | L'origine de la tâche tombe sur le point zéro du WCS actif | -| **Position actuelle** | L'origine de la tâche tombe là où se trouve la tête laser au cadrage ou au démarrage | +| Mode | Où la tâche s'exécute | +| ------------------------ | ------------------------------------------------------------------------------------ | +| **Coordonnées absolues** | Tel que placé sur le canevas (par défaut) | +| **Origine utilisateur** | L'origine de la tâche tombe sur le point zéro du WCS actif | +| **Position actuelle** | L'origine de la tâche tombe là où se trouve la tête laser au cadrage ou au démarrage | Le sélecteur **Origine de la tâche** à côté choisit lequel des neuf points du cadre englobant de la tâche (un coin, le milieu d'un bord ou le centre) est placé sur cette position. Le cadre englobant @@ -224,7 +224,13 @@ Fonctionnement : un cadrage, ou après une tâche terminée, la tête revient à la position de départ, de sorte que cadrer puis démarrer aboutit au même endroit. Un fichier exporté contient la position de la tête au moment de l'export. -- Les calques qui utilisent un module rotatif ne prennent en charge que les coordonnées absolues. +- Les calques qui utilisent un module rotatif, ou un WCS autre que celui actif, ne prennent en + charge que les coordonnées absolues. Démarrer depuis en indique la raison et aucun contour n'est + dessiné. +- Le point d'origine du travail se choisit tel que le travail apparaît sur le canevas, y compris + avec un panneau pivoté. +- Appliquer un alignement Print & Cut remet Démarrer depuis sur les coordonnées absolues, car + l'alignement place le dessin sur les repères imprimés. ## Flux de Travail Pratiques diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 89470fac9..af1ddea5e 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -191,18 +191,18 @@ G53 आपकी मशीन की पूर्ण स्थिति दर ## यहाँ से शुरू और जॉब मूल बिंदु -डिफ़ॉल्ट रूप से कोई जॉब ठीक वहीं चलता है जहाँ वह कैनवास पर रखा गया है। कंट्रोल पैनल में -**यहाँ से शुरू** सेटिंग (प्रोजेक्ट के साथ सहेजी जाती है) से आप इसे कहीं और रख सकते हैं: +डिफ़ॉल्ट रूप से कोई जॉब ठीक वहीं चलता है जहाँ वह कैनवास पर रखा गया है। कंट्रोल पैनल में **यहाँ से +शुरू** सेटिंग (प्रोजेक्ट के साथ सहेजी जाती है) से आप इसे कहीं और रख सकते हैं: -| मोड | जॉब कहाँ चलता है | -| --------------------------- | ------------------------------------------------------------------------- | -| **निरपेक्ष निर्देशांक** | कैनवास पर जैसा रखा गया है (डिफ़ॉल्ट) | -| **उपयोगकर्ता मूल बिंदु** | जॉब मूल बिंदु सक्रिय WCS के शून्य बिंदु पर आता है | -| **वर्तमान स्थिति** | जॉब मूल बिंदु वहाँ आता है जहाँ फ़्रेम या शुरू करते समय लेज़र हेड होता है | +| मोड | जॉब कहाँ चलता है | +| ------------------------ | ------------------------------------------------------------------------ | +| **निरपेक्ष निर्देशांक** | कैनवास पर जैसा रखा गया है (डिफ़ॉल्ट) | +| **उपयोगकर्ता मूल बिंदु** | जॉब मूल बिंदु सक्रिय WCS के शून्य बिंदु पर आता है | +| **वर्तमान स्थिति** | जॉब मूल बिंदु वहाँ आता है जहाँ फ़्रेम या शुरू करते समय लेज़र हेड होता है | -उसके पास वाला **जॉब मूल बिंदु** चयनकर्ता चुनता है कि जॉब के बाउंडिंग बॉक्स के नौ बिंदुओं में से कौन-सा -(एक कोना, किसी किनारे का मध्य या केंद्र) उस स्थिति पर रखा जाए। बाउंडिंग बॉक्स कैनवास पर दिखने वाले -डिज़ाइनों का होता है, ओवरस्कैन या लीड-इन के बिना। +उसके पास वाला **जॉब मूल बिंदु** चयनकर्ता चुनता है कि जॉब के बाउंडिंग बॉक्स के नौ बिंदुओं में से +कौन-सा (एक कोना, किसी किनारे का मध्य या केंद्र) उस स्थिति पर रखा जाए। बाउंडिंग बॉक्स कैनवास पर दिखने +वाले डिज़ाइनों का होता है, ओवरस्कैन या लीड-इन के बिना। जब मोड निरपेक्ष नहीं होता, तो कैनवास उस जगह एक धराशायी नीली रूपरेखा दिखाता है जहाँ जॉब वास्तव में चलेगा, और जॉब मूल बिंदु पर एक चिह्न। @@ -212,16 +212,20 @@ G53 आपकी मशीन की पूर्ण स्थिति दर - पूरा जॉब G-code बनने से पहले एक टुकड़े के रूप में खिसकाया जाता है। G-code सक्रिय WCS में सामान्य निरपेक्ष G-code ही रहता है: Rayforge `G92` या `G10` नहीं भेजता और आपके WCS ऑफ़सेट नहीं बदलता। - फ़्रेमिंग, 3D पूर्वावलोकन और पूर्व-जाँच उसी खिसकाए गए जॉब का उपयोग करते हैं। यदि खिसकाया गया जॉब - मशीन की यात्रा सीमा से बाहर जाए या किसी नो-गो ज़ोन में प्रवेश करे, तो भेजना और फ़्रेम करना अस्वीकार - कर दिया जाता है। -- **उपयोगकर्ता मूल बिंदु** WCS शून्य के सापेक्ष निर्देशांक बनाता है। इस तरह निर्यात की गई G-code फ़ाइल - WCS का अनुसरण करती है जब आप मशीन पर उसे कहीं और शून्य करते हैं, इसलिए यह SD कार्ड या USB स्टिक से - चलाई जाने वाली फ़ाइलों के लिए सही विकल्प है। + मशीन की यात्रा सीमा से बाहर जाए या किसी नो-गो ज़ोन में प्रवेश करे, तो भेजना और फ़्रेम करना + अस्वीकार कर दिया जाता है। +- **उपयोगकर्ता मूल बिंदु** WCS शून्य के सापेक्ष निर्देशांक बनाता है। इस तरह निर्यात की गई G-code + फ़ाइल WCS का अनुसरण करती है जब आप मशीन पर उसे कहीं और शून्य करते हैं, इसलिए यह SD कार्ड या USB + स्टिक से चलाई जाने वाली फ़ाइलों के लिए सही विकल्प है। - **वर्तमान स्थिति** के लिए कनेक्टेड, निष्क्रिय मशीन चाहिए जो अपनी स्थिति रिपोर्ट करे। पॉइंटर संरेखण - चालू होने पर बीम के बजाय पॉइंटर बिंदु का उपयोग होता है। फ़्रेम करने के बाद, या पूरा हुए जॉब के बाद, हेड - शुरुआती स्थिति पर लौट आता है, ताकि फ़्रेम के बाद शुरू करने पर जॉब उसी जगह चले। निर्यात की गई फ़ाइल में - निर्यात के समय की हेड स्थिति होती है। -- रोटरी मॉड्यूल का उपयोग करने वाली लेयर केवल निरपेक्ष निर्देशांक का समर्थन करती हैं। + चालू होने पर बीम के बजाय पॉइंटर बिंदु का उपयोग होता है। फ़्रेम करने के बाद, या पूरा हुए जॉब के + बाद, हेड शुरुआती स्थिति पर लौट आता है, ताकि फ़्रेम के बाद शुरू करने पर जॉब उसी जगह चले। निर्यात की + गई फ़ाइल में निर्यात के समय की हेड स्थिति होती है। +- रोटरी मॉड्यूल या सक्रिय WCS से अलग WCS का उपयोग करने वाली लेयर केवल निरपेक्ष निर्देशांक का समर्थन + करती हैं। **यहाँ से शुरू** इसका कारण दिखाता है और कोई रूपरेखा नहीं बनती। +- जॉब मूल बिंदु वैसे ही चुना जाता है जैसे जॉब कैनवास पर दिखता है, घुमाए गए पैनल पर भी। +- प्रिंट और कट संरेखण लागू करने पर **यहाँ से शुरू** फिर से निरपेक्ष निर्देशांक पर आ जाता है, क्योंकि + संरेखण डिज़ाइन को छपे हुए निशानों पर रखता है। ## व्यावहारिक वर्कफ़्लो diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 47fd961c4..2396c7c0c 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -192,11 +192,11 @@ Por padrão, um trabalho é executado exatamente onde está posicionado na tela. **Iniciar a partir de** no painel de controle (salva com o projeto) permite posicioná-lo em outro lugar: -| Modo | Onde o trabalho é executado | -| ------------------------- | ----------------------------------------------------------------------------------- | -| **Coordenadas absolutas** | Tal como posicionado na tela (padrão) | -| **Origem do usuário** | A origem do trabalho fica no ponto zero do WCS ativo | -| **Posição atual** | A origem do trabalho fica onde a cabeça do laser está ao enquadrar ou iniciar | +| Modo | Onde o trabalho é executado | +| ------------------------- | ----------------------------------------------------------------------------- | +| **Coordenadas absolutas** | Tal como posicionado na tela (padrão) | +| **Origem do usuário** | A origem do trabalho fica no ponto zero do WCS ativo | +| **Posição atual** | A origem do trabalho fica onde a cabeça do laser está ao enquadrar ou iniciar | O seletor **Origem do trabalho** ao lado escolhe qual dos nove pontos da caixa delimitadora do trabalho (um canto, o meio de uma borda ou o centro) é colocado nessa posição. A caixa delimitadora @@ -216,12 +216,16 @@ Como funciona: - **Origem do usuário** gera coordenadas relativas ao zero do WCS. Um arquivo G-code exportado assim acompanha o WCS quando você o zera em outro lugar da máquina, o que o torna a escolha certa para arquivos executados a partir de um cartão SD ou pen drive. -- **Posição atual** requer uma máquina conectada e ociosa que informe sua posição. Com o - alinhamento por ponteiro ativado, o ponto do ponteiro é usado em vez do feixe. Após o - enquadramento, ou após um trabalho concluído, a cabeça volta à posição inicial, de modo que - enquadrar e depois iniciar termina no mesmo lugar. Um arquivo exportado contém a posição da cabeça - no momento da exportação. -- Camadas que usam um módulo rotativo só suportam coordenadas absolutas. +- **Posição atual** requer uma máquina conectada e ociosa que informe sua posição. Com o alinhamento + por ponteiro ativado, o ponto do ponteiro é usado em vez do feixe. Após o enquadramento, ou após + um trabalho concluído, a cabeça volta à posição inicial, de modo que enquadrar e depois iniciar + termina no mesmo lugar. Um arquivo exportado contém a posição da cabeça no momento da exportação. +- Camadas que usam um módulo rotativo, ou um WCS diferente do ativo, só suportam coordenadas + absolutas. Iniciar a partir de mostra o motivo e nenhum contorno é desenhado. +- O ponto de origem do trabalho é escolhido como o trabalho aparece na tela, também com o painel + girado. +- Aplicar um alinhamento de Print & Cut volta Iniciar a partir de para coordenadas absolutas, porque + o alinhamento coloca o design sobre as marcas impressas. ## Fluxos de Trabalho Práticos diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index 3a17d66ad..5ce6b713e 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -189,11 +189,11 @@ G53 представляє абсолютну позицію вашої маши Типово завдання виконується саме там, де його розміщено на полотні. Параметр **Почати від** на панелі керування (зберігається разом із проєктом) дає змогу розмістити його деінде: -| Режим | Де виконується завдання | -| ------------------------- | ------------------------------------------------------------------------------- | -| **Абсолютні координати** | Як розміщено на полотні (типово) | -| **Початок користувача** | Початок завдання потрапляє в нульову точку активної WCS | -| **Поточна позиція** | Початок завдання потрапляє туди, де стоїть лазерна голівка під час рамки чи запуску | +| Режим | Де виконується завдання | +| ------------------------ | ----------------------------------------------------------------------------------- | +| **Абсолютні координати** | Як розміщено на полотні (типово) | +| **Початок користувача** | Початок завдання потрапляє в нульову точку активної WCS | +| **Поточна позиція** | Початок завдання потрапляє туди, де стоїть лазерна голівка під час рамки чи запуску | Вибір **Початок завдання** поруч визначає, яка з дев'яти точок обмежувальної рамки завдання (кут, середина краю або центр) розміщується в цій позиції. Обмежувальна рамка — це рамка дизайнів, які ви @@ -210,15 +210,19 @@ G53 представляє абсолютну позицію вашої маши - Рамка, 3D-попередній перегляд і попередні перевірки використовують те саме зсунуте завдання. Якщо зсунуте завдання виходить за межі ходу верстата або заходить у заборонену зону, надсилання та рамку буде відхилено. -- **Початок користувача** створює координати відносно нуля WCS. Експортований так файл G-code - слідує за WCS, коли ви обнуляєте її в іншому місці верстата, тож це правильний вибір для файлів, - які ви запускаєте з SD-картки чи USB-накопичувача. +- **Початок користувача** створює координати відносно нуля WCS. Експортований так файл G-code слідує + за WCS, коли ви обнуляєте її в іншому місці верстата, тож це правильний вибір для файлів, які ви + запускаєте з SD-картки чи USB-накопичувача. - **Поточна позиція** потребує підключеного верстата в стані очікування, який повідомляє свою позицію. З увімкненим вирівнюванням за вказівником використовується точка вказівника замість променя. Після рамки або завершеного завдання голівка повертається в початкову позицію, тож рамка, а потім запуск потрапляють у те саме місце. Експортований файл містить позицію голівки на момент експорту. -- Шари з поворотним модулем підтримують лише абсолютні координати. +- Шари з поворотним модулем або з іншою WCS, ніж активна, підтримують лише абсолютні координати. + Почати від показує причину, і контур не малюється. +- Точка початку завдання вибирається так, як завдання видно на полотні, також при поверненій панелі. +- Застосування вирівнювання Print & Cut повертає Почати від до абсолютних координат, бо вирівнювання + кладе дизайн на надруковані мітки. ## Практичні робочі процеси diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md index f206e4ce3..45be5b5a5 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/general-info/coordinate-systems.md @@ -170,11 +170,11 @@ G53 代表您机器的绝对位置,零点在机器的原点位置。这由您 默认情况下,作业会准确地在画布上的放置位置运行。控制面板中的**起始位置**设置(随项目保存)可以让你把作业放到其他位置: -| 模式 | 作业运行位置 | -| ------------ | ------------------------------------------------ | -| **绝对坐标** | 按画布上的位置(默认) | -| **用户原点** | 作业原点落在当前工件坐标系(WCS)的零点上 | -| **当前位置** | 作业原点落在走边框或开始时激光头所在的位置 | +| 模式 | 作业运行位置 | +| ------------ | ------------------------------------------ | +| **绝对坐标** | 按画布上的位置(默认) | +| **用户原点** | 作业原点落在当前工件坐标系(WCS)的零点上 | +| **当前位置** | 作业原点落在走边框或开始时激光头所在的位置 | 旁边的**作业原点**选择器用于选择作业边界框九个点中的哪一个(某个角、某条边的中点或中心)放在该位置。边界框是画布上所见设计的边界框,不包括过扫描和引入线。 @@ -185,10 +185,11 @@ G53 代表您机器的绝对位置,零点在机器的原点位置。这由您 - 整个作业在转换为 G-code 之前作为一个整体平移。G-code 仍是当前 WCS 中的普通绝对坐标 G-code:Rayforge 不会发送 `G92` 或 `G10`,也不会更改你的 WCS 偏移。 - 走边框、3D 预览和发送前检查都使用同一个平移后的作业。如果平移后的作业超出机器行程或进入禁区,将拒绝发送和走边框。 -- **用户原点**生成相对于 WCS 零点的坐标。以这种方式导出的 G-code 文件会在你于机器上其他位置重新归零 WCS - 时随之移动,因此适合从 SD 卡或 U 盘运行的文件。 +- **用户原点**生成相对于 WCS 零点的坐标。以这种方式导出的 G-code 文件会在你于机器上其他位置重新归零 WCS 时随之移动,因此适合从 SD 卡或 U 盘运行的文件。 - **当前位置**需要已连接且空闲、并报告自身位置的机器。开启光点对齐时,使用光点而不是光束的位置。走边框后或作业完成后,激光头会回到起始位置,因此先走边框再开始会落在同一位置。导出的文件包含导出时的激光头位置。 -- 使用旋转模块的图层只支持绝对坐标。 +- 使用旋转模块或使用与当前激活 WCS 不同的 WCS 的图层只支持绝对坐标。起始位置 会显示原因,且不绘制轮廓。 +- 作业原点按作业在画布上的显示方式选择,面板旋转时也是如此。 +- 应用 打印与切割 对齐后,起始位置 会切回绝对坐标,因为对齐会把设计放到打印的标记上。 ## 实用工作流程