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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ 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. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not use \ sequences for UTF chars; all modern versions of Python support UTF8 natively

"so that the job is cut on the printed marks."
),
)

def close(self):
self._disable_focus()
if self._machine:
Expand Down
16 changes: 16 additions & 0 deletions rayforge/core/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = {}
Expand Down Expand Up @@ -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

Expand All @@ -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
):
Expand Down
123 changes: 123 additions & 0 deletions rayforge/core/job_origin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raygeo has a Rect type for this



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)


# 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 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 | AnchorFractions, 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)
12 changes: 10 additions & 2 deletions rayforge/image/svg/svg_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 7 additions & 15 deletions rayforge/image/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading