Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0088fac
feat(barcodes): QR, Code 128 and EAN-13 encoders
switch87 Oct 12, 2026
0840df9
feat(barcodes): symbol outlines with holes, text and invert
switch87 Oct 12, 2026
b226635
fix(sketcher): only treat sketch workpieces as sketches
switch87 Oct 12, 2026
3367267
feat(barcodes): barcode asset as a geometry provider
switch87 Oct 12, 2026
2efe514
feat(barcodes): Add Barcode action and Barcode properties
switch87 Oct 12, 2026
cdad5d0
docs: document the Barcodes addon
switch87 Oct 12, 2026
1611c84
build: install segno in the Windows build
switch87 Oct 12, 2026
f50dfaa
test(barcodes): give the addon tests unique module names
switch87 Oct 13, 2026
68449be
refactor(barcodes): register the action directly (addon API 22)
switch87 Oct 13, 2026
68bfc1a
fix(barcodes): place new barcodes at the panel's work area center
switch87 Oct 15, 2026
d4bcd97
refactor(barcodes): orient contours with raygeo
switch87 Oct 15, 2026
ad11ef4
style(barcodes): format the geometry test
switch87 Oct 15, 2026
ad5ddea
refactor(barcodes): edit settings through UpdateAssetCommand
switch87 Oct 15, 2026
96f8baf
refactor(barcodes): unit-aware rows for module size and bar height
switch87 Oct 15, 2026
24cbcc8
refactor(barcodes): ship the barcode icon with the addon
switch87 Oct 15, 2026
91853f6
refactor(sketcher): narrow is_sketch_workpiece with TypeGuard
switch87 Oct 15, 2026
afea1bc
docs: match the Barcodes page to the unit-aware size rows
switch87 Oct 15, 2026
bcd3dc7
docs: translate the Barcodes addon page
switch87 Oct 15, 2026
c513d54
Merge branch 'main' into feat/barcodes
switch87 Oct 21, 2026
795f232
refactor(barcodes): use {counter} from the job counter, not {serial}
switch87 Oct 21, 2026
e2b6990
docs: move the barcodes changelog entry to UNRELEASED
switch87 Oct 21, 2026
1cfe497
test: give the sketcher's other-provider fake a reference point
switch87 Oct 21, 2026
21f61c9
fix(barcodes): register the property provider before the main window
switch87 Oct 21, 2026
1241639
test(barcodes): use the frontend module in the registration test
switch87 Oct 21, 2026
c403976
test: give the other-provider fake the template methods
switch87 Oct 21, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to Rayforge will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## UNRELEASED

### Added

- Barcodes addon: QR codes, Code 128 and EAN-13 as vector workpieces
whose content can be a template (Object > Add Barcode) (#529)

## 1.13.0-beta4

### Changed
Expand Down
2 changes: 1 addition & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, ${python3:Depends},
python3-pypdf, python3-yaml, python3-aiohttp, python3-websockets,
python3-blinker, python3-platformdirs, python3-opengl, desktop-file-utils,
python3-svgelements, python3-semver, python3-fitz, python3-serial,
python3-rpyc, python3-zeroconf
python3-rpyc, python3-zeroconf, python3-segno
Description: Desktop application for laser cutting and engraving
Rayforge is a powerful, open-source, and cross-platform software for
controlling your laser cutter and engraver.
12 changes: 12 additions & 0 deletions pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pymupdf = "==1.28.2"
pyserial = "==3.5"
pyvips = "==3.2.0"
PyYAML = "==6.0.3"
segno = "==1.6.6"
semver = "==3.1.0"
trimesh = "==5.1.1"
vtracer = "==0.6.15"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Barcode and QR code workpieces generated from templates."""
211 changes: 211 additions & 0 deletions rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""
The barcode asset: a document asset that provides the geometry of a QR
code or barcode generated from a template string.
"""

import logging
import uuid
from gettext import gettext as _
from typing import Any, ClassVar

from blinker import Signal
from raygeo.geo import Geometry

from rayforge.core.asset import IAsset
from rayforge.core.expression.expression_map import ExpressionMap
from rayforge.core.geometry_provider import IGeometryProvider
from rayforge.image.base_renderer import Renderer
from rayforge.image.geo_renderer import render_geometry_to_png
from rayforge.image.registry import renderer_registry
from rayforge.image.structures import FillRenderData, FillStyle

from .encoders import (
MIN_QUIET_ZONE,
QrErrorCorrection,
Symbology,
encode,
)
from .geometry import (
BarcodeStyle,
barcode_geometry,
loops_to_geometry,
)

logger = logging.getLogger(__name__)

RENDERER_NAME = "SketchRenderer"
CACHE_KEY = 0
PLACEHOLDER_MM = 10.0
_BLACK = (0.0, 0.0, 0.0, 1.0)


class BarcodeAsset(IAsset, IGeometryProvider):
"""
A QR code or barcode whose content is a template. The template is
resolved with the parameters the workpiece passes in (for example
the document's job counter), then encoded and drawn as outlines
plus a solid fill.
"""

is_addable: ClassVar[bool] = False
asset_type_name: ClassVar[str] = "barcode"
display_icon_name: ClassVar[str] = "barcode-symbolic"
is_reorderable: ClassVar[bool] = True
is_draggable_to_canvas: ClassVar[bool] = True
type_display_name: ClassVar[str] = _("Barcode")
can_edit: ClassVar[bool] = False
add_action: ClassVar[str | None] = None
activate_action: ClassVar[str | None] = None
edit_item_action: ClassVar[str | None] = None

def __init__(
self,
name: str | None = None,
content: str = "",
symbology: Symbology = Symbology.QR,
error_correction: QrErrorCorrection = QrErrorCorrection.M,
module_mm: float = 0.5,
bar_height_mm: float = 15.0,
quiet_zone: int | None = None,
show_text: bool = False,
invert: bool = False,
):
self._uid = str(uuid.uuid4())
self._name = name or _("Barcode")
self._updated = Signal()
self.content = content
self.symbology = symbology
self.error_correction = error_correction
self.module_mm = module_mm
self.bar_height_mm = bar_height_mm
self.quiet_zone = quiet_zone
self.show_text = show_text
self.invert = invert
self._error: str | None = None

@property
def uid(self) -> str:
return self._uid

@property
def name(self) -> str:
return self._name

@name.setter
def name(self, value: str) -> None:
if value != self._name:
self._name = value
self._updated.send(self)

@property
def updated(self) -> Signal:
return self._updated

@property
def hidden(self) -> bool:
return False

@property
def provider_type_name(self) -> str:
return self.asset_type_name

@property
def renderer(self) -> Renderer | None:
return renderer_registry.get_by_name(RENDERER_NAME)

@property
def error(self) -> str | None:
"""Why the last content could not be encoded, if it could not."""
return self._error

@property
def effective_quiet_zone(self) -> int:
if self.quiet_zone is not None:
return self.quiet_zone
return MIN_QUIET_ZONE[self.symbology]

def get_template_sources(self) -> list[str]:
return [self.content]

def resolve(self, params: dict[str, Any] | None = None) -> str:
"""The content with its ``{...}`` placeholders resolved."""
return ExpressionMap(dict(params or {})).format(self.content)

def _resolved(
self, params: dict[str, Any] | None, cache: dict | None
) -> str:
cached = cache.get(CACHE_KEY) if cache is not None else None
if cached and cached[0] == self.content:
return cached[1]
text = self.resolve(params)
if cache is not None:
cache[CACHE_KEY] = (self.content, text)
return text

def _style(self) -> BarcodeStyle:
return BarcodeStyle(
module_mm=self.module_mm,
bar_height_mm=self.bar_height_mm,
quiet_zone=self.effective_quiet_zone,
show_text=self.show_text,
invert=self.invert,
)

def get_geometry(
self,
params: dict[str, Any] | None = None,
*,
resolved_text_cache: dict | None = None,
) -> tuple[Geometry, list[FillRenderData]]:
text = self._resolved(params, resolved_text_cache)
try:
encoded = encode(self.symbology, text, self.error_correction)
except ValueError as e:
self._error = str(e)
logger.info(f"Barcode {self.uid[:8]}: {e}")
size = PLACEHOLDER_MM
outline = [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)]
return loops_to_geometry([outline]), []
self._error = None
geometry = barcode_geometry(encoded, self.symbology, self._style())
fill = FillRenderData(
geometry=geometry.copy(), style=FillStyle.SOLID, color=_BLACK
)
return geometry, [fill]

def get_thumbnail(self, size: int) -> bytes | None:
geometry, _fills = self.get_geometry()
return render_geometry_to_png(geometry, size)

def to_dict(self) -> dict[str, Any]:
return {
"uid": self._uid,
"type": self.asset_type_name,
"name": self._name,
"content": self.content,
"symbology": self.symbology.value,
"error_correction": self.error_correction.value,
"module_mm": self.module_mm,
"bar_height_mm": self.bar_height_mm,
"quiet_zone": self.quiet_zone,
"show_text": self.show_text,
"invert": self.invert,
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "BarcodeAsset":
asset = cls(
name=data.get("name"),
content=data.get("content", ""),
symbology=Symbology(data.get("symbology", "qr")),
error_correction=QrErrorCorrection(
data.get("error_correction", "M")
),
module_mm=float(data.get("module_mm", 0.5)),
bar_height_mm=float(data.get("bar_height_mm", 15.0)),
quiet_zone=data.get("quiet_zone"),
show_text=bool(data.get("show_text", False)),
invert=bool(data.get("invert", False)),
)
asset._uid = data.get("uid", asset._uid)
return asset
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Undoable barcode edits: adding a barcode and changing its settings.
"""

from gettext import gettext as _
from typing import Any, Protocol

from rayforge.core.doc import Doc
from rayforge.core.item import DocItem
from rayforge.core.undo import HistoryManager, ListItemCommand
from rayforge.core.workpiece import WorkPiece
from rayforge.doceditor.asset_cmd import UpdateAssetCommand

from .asset import BarcodeAsset

DEFAULT_CONTENT = "Rayforge"
DEFAULT_POSITION_MM = (50.0, 50.0)


class _Editor(Protocol):
doc: Doc
history_manager: HistoryManager


def barcode_of(item: DocItem | None) -> BarcodeAsset | None:
"""The barcode asset behind a workpiece, if it is a barcode."""
if not isinstance(item, WorkPiece):
return None
provider = item.get_geometry_provider()
return provider if isinstance(provider, BarcodeAsset) else None


def default_position(editor: Any) -> tuple[float, float]:
"""The centre of the machine's work area in world coordinates."""
machine = editor.context.machine
if machine is None:
return DEFAULT_POSITION_MM
return machine.panel.work_area_center()


def add_barcode(
editor: _Editor, position_mm: tuple[float, float]
) -> WorkPiece:
"""Adds a QR code centred on *position_mm*, as one undo step."""
doc = editor.doc
asset = BarcodeAsset(content=DEFAULT_CONTENT)
with editor.history_manager.transaction(_("Add Barcode")) as t:
t.execute(
ListItemCommand(
owner_obj=doc,
item=asset,
undo_command="remove_asset",
redo_command="add_asset",
)
)
workpiece = WorkPiece.from_geometry_provider(asset)
width, height = workpiece.natural_size
workpiece.pos = (
position_mm[0] - width / 2.0,
position_mm[1] - height / 2.0,
)
t.execute(
ListItemCommand(
owner_obj=doc.active_layer,
item=workpiece,
undo_command="remove_child",
redo_command="add_child",
)
)
return workpiece


def set_barcode_setting(
editor: _Editor, asset: BarcodeAsset, setting: str, value: Any
) -> None:
"""
Changes one barcode setting as an undo step. The asset is replaced
and its workpieces are resized by the generic asset update.
"""
if getattr(asset, setting) == value:
return
changed = BarcodeAsset.from_dict(asset.to_dict())
setattr(changed, setting, value)
editor.history_manager.execute(
UpdateAssetCommand(
doc=editor.doc,
asset_uid=asset.uid,
new_data=changed.to_dict(),
name=_("Change Barcode"),
)
)
Loading
Loading