From 4bb9a62ef5b8f8bb659498b9b1f5d03f0d5f2f8e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:43:39 +0200 Subject: [PATCH 01/33] feat: project serial counter for text templates Text templates can use {serial}, formatted like any other value ({serial:04d}, SN-{serial:05d}). The counter (current, start, step, optional end with wrap, auto-advance) is stored in the project. Each workpiece has a variable offset: a copy with offset n shows the value n steps after the current one, so array copies can carry consecutive numbers. When the counter changes, only workpieces whose templates read a document variable regenerate, keeping their scale. Refs #527 --- .../sketcher/core/sketch.py | 8 + .../tests/core/test_sketch_variable_text.py | 79 ++++++++ rayforge/core/doc.py | 55 ++++++ rayforge/core/variable_text.py | 172 ++++++++++++++++++ rayforge/core/workpiece.py | 126 ++++++++++++- tests/core/template_provider.py | 84 +++++++++ tests/core/test_variable_text.py | 135 ++++++++++++++ tests/core/test_workpiece_variable_text.py | 165 +++++++++++++++++ 8 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py create mode 100644 rayforge/core/variable_text.py create mode 100644 tests/core/template_provider.py create mode 100644 tests/core/test_variable_text.py create mode 100644 tests/core/test_workpiece_variable_text.py diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 67bf84609..41cb08d9e 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -346,6 +346,14 @@ def get_geometry( resolved_text_cache.update(clone._resolved_text_cache) return geo, fills + def get_template_sources(self) -> list[str]: + """The raw contents of all text boxes, including templates.""" + return [ + e.content + for e in self.registry.entities + if isinstance(e, TextBoxEntity) and e.content + ] + def _clone_for_geometry(self) -> "Sketch": """ Lightweight clone for geometry generation. diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py new file mode 100644 index 000000000..855e19a44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py @@ -0,0 +1,79 @@ +from typing import cast + +from raygeo.geo.shape.text import FontConfig +from sketcher.core import Sketch +from sketcher.core.entities.text_box import TextBoxEntity + +from rayforge.core.doc import Doc +from rayforge.core.variable_text import SerialSettings +from rayforge.core.workpiece import WorkPiece + + +def _sketch_with_text(content: str) -> tuple[Sketch, TextBoxEntity]: + sketch = Sketch(name="plate") + origin = sketch.add_point(0, 0) + width_pt = sketch.add_point(10, 0) + height_pt = sketch.add_point(0, 10) + box_id = sketch.registry.add_text_box( + origin, width_pt, height_pt, content, FontConfig() + ) + return sketch, cast(TextBoxEntity, sketch.registry.get_entity(box_id)) + + +def _resolved(wp: WorkPiece, box: TextBoxEntity) -> str: + entry = wp._resolved_text_cache[box.id] + return entry[1] + + +def _place(doc: Doc, sketch: Sketch, offset: int = 0) -> WorkPiece: + doc.add_asset(sketch) + wp = WorkPiece.from_geometry_provider(sketch) + wp.variable_offset = offset + doc.active_layer.add_child(wp) + return wp + + +def test_template_sources_lists_text_box_contents(): + sketch, _box = _sketch_with_text("No. {serial:03d}") + assert sketch.get_template_sources() == ["No. {serial:03d}"] + + +def test_serial_is_resolved_in_document(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=7) + sketch, box = _sketch_with_text("No. {serial:03d}") + wp = _place(doc, sketch) + assert _resolved(wp, box) == "No. 007" + + +def test_copies_show_consecutive_numbers(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=41) + sketch, box = _sketch_with_text("{serial}") + first = _place(doc, sketch) + second = first.duplicate() + assert isinstance(second, WorkPiece) + second.variable_offset = 1 + doc.active_layer.add_child(second) + assert _resolved(first, box) == "41" + assert _resolved(second, box) == "42" + + +def test_counter_change_updates_text(): + doc = Doc() + sketch, box = _sketch_with_text("{serial:04d}") + wp = _place(doc, sketch) + doc.variable_text.serial = SerialSettings(current=12) + assert _resolved(wp, box) == "0012" + + +def test_saved_text_is_kept_on_load(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=5) + sketch, box = _sketch_with_text("{serial}-{uuid8()}") + wp = _place(doc, sketch) + before = _resolved(wp, box) + restored = Doc.from_dict(doc.to_dict()) + restored_wp = restored.all_workpieces[0] + assert restored_wp.boundaries is not None + assert _resolved(restored_wp, box) == before diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index c6a45d17e..d5c08213d 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -17,6 +17,7 @@ from .item import DocItem from .layer import Layer from .source_asset import SourceAsset +from .variable_text import VariableText from .workpiece import WorkPiece if TYPE_CHECKING: @@ -45,6 +46,11 @@ def __init__(self): self.assets: dict[str, IAsset] = {} self.asset_order: list[str] = [] + # Variables for text templates (serial number, ...) + self._variable_text = VariableText() + self._variable_text.changed.connect(self._on_variable_text_changed) + self.descendant_added.connect(self._on_item_added_for_templates) + # A new document starts with three empty workpiece layers for i in range(3): workpiece_layer = Layer(_("Layer {}").format(i + 1)) @@ -90,6 +96,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc = cls() doc.uid = data.get("uid", doc.uid) + doc.variable_text = VariableText.from_dict(data.get("variable_text")) # Clear the default layer created by __init__ doc.set_children([]) @@ -173,8 +180,56 @@ 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()], + "variable_text": self._variable_text.to_dict(), } + @property + def variable_text(self) -> VariableText: + """The variables offered to text templates.""" + return self._variable_text + + @variable_text.setter + def variable_text(self, value: VariableText) -> None: + self._variable_text.changed.disconnect(self._on_variable_text_changed) + self._variable_text = value + value.changed.connect(self._on_variable_text_changed) + self._on_variable_text_changed(value) + + def _template_workpieces(self) -> list[WorkPiece]: + """Workpieces whose templates read a document variable.""" + names = self._variable_text.names + return [ + wp + for wp in self.all_workpieces + if wp.references_template_names(names) + ] + + def variable_text_copies(self) -> int: + """ + How many consecutive variable values one job uses: the highest + variable offset of a template workpiece plus one, or 0 if no + workpiece uses a document variable. + """ + offsets = [wp.variable_offset for wp in self._template_workpieces()] + if not offsets: + return 0 + return max(offsets) + 1 + + def _on_variable_text_changed(self, sender: VariableText) -> None: + for wp in self._template_workpieces(): + wp.refresh_template_values() + + def _on_item_added_for_templates( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ) -> None: + if isinstance(origin, WorkPiece): + added = [origin] + else: + added = origin.get_descendants(of_type=WorkPiece) + for wp in added: + if wp.references_template_names(self._variable_text.names): + wp.sync_template_values() + def add_asset( self, asset: IAsset, index: int | None = None, silent: bool = False ): diff --git a/rayforge/core/variable_text.py b/rayforge/core/variable_text.py new file mode 100644 index 000000000..2c99f3212 --- /dev/null +++ b/rayforge/core/variable_text.py @@ -0,0 +1,172 @@ +""" +Document-level variables for text templates. + +A document carries a :class:`VariableText` instance whose values are +injected into the ``{...}`` templates of every geometry provider (for +example the text boxes of a sketch). Each workpiece adds its own +*variable offset*, so copies of the same design can show consecutive +serial numbers. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Iterable +from dataclasses import dataclass, replace +from typing import Any + +from blinker import Signal + +SERIAL_NAME = "serial" + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +@dataclass(frozen=True) +class SerialSettings: + """ + An immutable serial counter. + + Attributes: + current: The value the next job starts with. + start: The value :meth:`reset` returns to, and where the + counter wraps to after ``end``. + step: How much the value grows per copy and per job. + end: Optional last value. Values past it wrap to ``start``. + auto_advance: Whether a finished job moves ``current`` on. + advance_on_export: Whether exporting G-code moves ``current`` + on as well, for jobs that run from a memory card. + """ + + current: int = 1 + start: int = 1 + step: int = 1 + end: int | None = None + auto_advance: bool = True + advance_on_export: bool = False + + def _range_length(self) -> int | None: + """Number of values in ``start..end``, or None if unbounded.""" + if self.end is None or self.step <= 0 or self.end < self.start: + return None + return (self.end - self.start) // self.step + 1 + + def value_at(self, offset: int) -> int: + """The value shown by a copy that is *offset* copies further.""" + value = self.current + offset * self.step + length = self._range_length() + if length is None: + return value + index = (value - self.start) // self.step + return self.start + (index % length) * self.step + + def values_for(self, copies: int) -> list[int]: + """The values of *copies* consecutive copies.""" + return [self.value_at(i) for i in range(max(copies, 0))] + + def advanced(self, copies: int = 1) -> SerialSettings: + """A counter moved on (or back, if negative) by *copies*.""" + return replace(self, current=self.value_at(copies)) + + def reset(self) -> SerialSettings: + """A counter whose current value is back at ``start``.""" + return replace(self, current=self.start) + + def to_dict(self) -> dict[str, Any]: + return { + "current": self.current, + "start": self.start, + "step": self.step, + "end": self.end, + "auto_advance": self.auto_advance, + "advance_on_export": self.advance_on_export, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SerialSettings: + default = cls() + start = int(data.get("start", default.start)) + end = data.get("end") + if end is not None: + end = int(end) + if end < start: + end = None + return cls( + current=int(data.get("current", default.current)), + start=start, + step=int(data.get("step", default.step)), + end=end, + auto_advance=bool(data.get("auto_advance", default.auto_advance)), + advance_on_export=bool( + data.get("advance_on_export", default.advance_on_export) + ), + ) + + +class VariableText: + """ + The variables a document offers to text templates. + + Emits :attr:`changed` whenever a value changes, so workpieces whose + templates reference a variable can regenerate. + """ + + def __init__(self): + self.changed = Signal() + self._serial = SerialSettings() + + @property + def serial(self) -> SerialSettings: + return self._serial + + @serial.setter + def serial(self, value: SerialSettings) -> None: + if value == self._serial: + return + self._serial = value + self.changed.send(self) + + @property + def names(self) -> set[str]: + """The template names this object provides.""" + return {SERIAL_NAME} + + def template_values(self, offset: int = 0) -> dict[str, Any]: + """The template values for a copy with the given offset.""" + return {SERIAL_NAME: self._serial.value_at(offset)} + + def to_dict(self) -> dict[str, Any]: + return {"serial": self._serial.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> VariableText: + instance = cls() + if data: + serial = data.get("serial") + if isinstance(serial, dict): + instance._serial = SerialSettings.from_dict(serial) + return instance + + +def _names_in_expression(expression: str) -> set[str]: + """Names an expression reads, ignoring string literals.""" + try: + tree = ast.parse(expression.strip(), mode="eval") + except SyntaxError: + return set(_IDENTIFIER_RE.findall(expression)) + return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + + +def referenced_template_names(templates: Iterable[str]) -> set[str]: + """ + Collects the names used inside the ``{...}`` placeholders of + *templates*. A format spec after the first colon is ignored. + """ + names: set[str] = set() + for template in templates: + for match in _PLACEHOLDER_RE.finditer(template or ""): + expression, _sep, _spec = match.group(1).partition(":") + names |= _names_in_expression(expression) + return names diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 9fc954308..275e14dd0 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -34,6 +34,7 @@ from .item import DocItem from .source_asset_segment import SourceAssetSegment from .tab import Tab +from .variable_text import referenced_template_names from .vectorization_spec import TraceSpec if TYPE_CHECKING: @@ -130,6 +131,12 @@ def __init__( self._resolved_text_cache: dict = {} + # Shifts the document's template variables for this copy, so + # e.g. array copies show consecutive serial numbers. + self._variable_offset: int = 0 + # The template variables the cached geometry was generated with. + self._template_values_used: dict[str, Any] = {} + self.source_asset_uid: str | None = None self._tabs: list[Tab] = [] @@ -423,8 +430,9 @@ def boundaries(self) -> Geometry | None: f"WP {self.uid[:8]}: Getting geometry with " f"params: {self._geometry_provider_params}" ) + self._template_values_used = self.template_values() unnormalized_geo, unnormalized_fills = provider.get_geometry( - self._geometry_provider_params, + self._provider_params(), resolved_text_cache=self._resolved_text_cache, ) @@ -620,9 +628,11 @@ def in_world(self) -> WorkPiece: world_wp.tabs = deepcopy(self.tabs) world_wp.tabs_enabled = self.tabs_enabled world_wp.geometry_provider_uid = self.geometry_provider_uid - world_wp.geometry_provider_params = deepcopy( - self._geometry_provider_params + world_wp._geometry_provider_params = deepcopy( + self._world_provider_params() ) + world_wp._variable_offset = self._variable_offset + world_wp._template_values_used = deepcopy(self._template_values_used) world_wp.source_asset_uid = self.source_asset_uid world_wp._resolved_text_cache = dict(self._resolved_text_cache) @@ -975,6 +985,10 @@ def to_dict(self) -> dict[str, Any]: } if cache: state["resolved_text_cache"] = cache + if self._variable_offset: + state["variable_offset"] = self._variable_offset + if self._template_values_used: + state["template_values"] = self._template_values_used if self._data is not None: state["data"] = self._data if self._original_data is not None: @@ -1012,6 +1026,8 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "geometry_provider_params", "source_asset_uid", "resolved_text_cache", + "variable_offset", + "template_values", "data", "original_data", "source_px_dims", @@ -1059,6 +1075,8 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "geometry_provider_params", {} ) or data.get("sketch_params", {}) wp.source_asset_uid = data.get("source_asset_uid") + wp._variable_offset = int(data.get("variable_offset", 0)) + wp._template_values_used = dict(data.get("template_values") or {}) raw_cache = data.get("resolved_text_cache") if raw_cache: wp._resolved_text_cache = { @@ -1114,6 +1132,105 @@ def geometry_provider_params(self, new_params: dict[str, Any]): # Regenerate the internal geometry and natural size self.regenerate_from_geometry_provider() + @property + def variable_offset(self) -> int: + """ + The copy offset applied to the document's template variables. + + A copy with offset ``n`` shows the values ``n`` copies after + the current ones, e.g. serial number ``current + n * step``. + """ + return self._variable_offset + + @variable_offset.setter + def variable_offset(self, value: int) -> None: + value = int(value) + if value == self._variable_offset: + return + self._variable_offset = value + self.refresh_template_values() + + def template_values(self) -> dict[str, Any]: + """The document's template variables as seen by this copy.""" + doc = self.doc + if doc is None: + return {} + return doc.variable_text.template_values(self._variable_offset) + + def _provider_params(self) -> dict[str, Any]: + """ + Parameters for the geometry provider: the document's template + variables, overridden by this instance's own parameters. + """ + params = self.template_values() + params.update(self._geometry_provider_params or {}) + return params + + def _world_provider_params(self) -> dict[str, Any]: + """ + Parameters for a detached copy: the template variables are baked + in only when this workpiece's templates read them. + """ + doc = self.doc + if doc is not None and self.references_template_names( + doc.variable_text.names + ): + return self._provider_params() + return dict(self._geometry_provider_params or {}) + + def template_sources(self) -> list[str]: + """The raw template strings the geometry provider renders.""" + provider = self.get_geometry_provider() + get_sources = getattr(provider, "get_template_sources", None) + if callable(get_sources): + return [str(source) for source in cast(list, get_sources())] + return [ + entry[0] + for entry in self._resolved_text_cache.values() + if isinstance(entry, (tuple, list)) and entry + ] + + def references_template_names(self, names: set[str]) -> bool: + """Whether any template of this workpiece reads one of *names*.""" + if not self.geometry_provider_uid: + return False + used = referenced_template_names(self.template_sources()) + return bool(used & names) + + def refresh_template_values(self) -> None: + """ + Regenerates the geometry after a template variable changed. + + Only workpieces whose templates read one of the document's + variables are affected. The current scale relative to the + natural size is kept, so a longer text grows instead of being + squeezed into the old box. + """ + doc = self.doc + if doc is None or not self.references_template_names( + doc.variable_text.names + ): + return + old_w, old_h = self.natural_width_mm, self.natural_height_mm + size_w, size_h = self.size + self._resolved_text_cache = {} + self.clear_render_cache() + if self.boundaries is not None and old_w > 1e-9 and old_h > 1e-9: + self.set_size( + self.natural_width_mm * size_w / old_w, + self.natural_height_mm * size_h / old_h, + ) + self.updated.send(self) + + def sync_template_values(self) -> None: + """ + Refreshes the geometry if it was generated with other template + values than the document offers now, e.g. after the workpiece + was created outside the document or copied with a new offset. + """ + if self._template_values_used != self.template_values(): + self.refresh_template_values() + def get_natural_aspect_ratio(self) -> float | None: size = self.natural_size if size: @@ -1622,7 +1739,8 @@ def regenerate_from_geometry_provider(self) -> None: ) # Get geometry with current parameter overrides. - variable_overrides = self._geometry_provider_params or {} + variable_overrides = self._provider_params() + self._template_values_used = self.template_values() logger.debug( f"WP {self.uid[:8]}: Getting geometry with params: " f"{variable_overrides}" diff --git a/tests/core/template_provider.py b/tests/core/template_provider.py new file mode 100644 index 000000000..11231b5cc --- /dev/null +++ b/tests/core/template_provider.py @@ -0,0 +1,84 @@ +"""A fake geometry provider that renders one text template.""" + +import uuid +from typing import Any, ClassVar + +from blinker import Signal +from raygeo.geo import Geometry + +from rayforge.core.doc import Doc +from rayforge.core.expression import ExpressionMap +from rayforge.core.workpiece import WorkPiece + + +class TemplateProvider: + """A geometry provider rendering one template as a rectangle whose + width grows with the length of the resolved text.""" + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "template-test" + display_icon_name: ClassVar[str] = "" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = False + type_display_name: ClassVar[str] = "Template" + 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, template: str): + self.template = template + self._uid = str(uuid.uuid4()) + self.name = "template" + self.updated = Signal() + self.hidden = False + self.calls: list[dict[str, Any]] = [] + self.rendered: list[str] = [] + + @property + def uid(self) -> str: + return self._uid + + @property + def provider_type_name(self) -> str: + return "template-test" + + @property + def renderer(self): + return None + + def get_template_sources(self) -> list[str]: + return [self.template] + + def get_geometry(self, params=None, *, resolved_text_cache=None): + params = dict(params or {}) + self.calls.append(params) + text = ExpressionMap(params).format(self.template) + self.rendered.append(text) + width = 2.0 * len(text) + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(width, 0.0) + geo.line_to(width, 5.0) + geo.line_to(0.0, 5.0) + geo.close_path() + return geo, [] + + def to_dict(self) -> dict[str, Any]: + return {"type": "template-test", "uid": self._uid} + + @classmethod + def from_dict(cls, data): + return cls("") + + def get_thumbnail(self, size: int): + return None + + +def place(doc: Doc, template: str, offset: int = 0) -> WorkPiece: + provider = TemplateProvider(template) + doc.add_asset(provider) + wp = WorkPiece.from_geometry_provider(provider) + wp.variable_offset = offset + doc.active_layer.add_child(wp) + return wp diff --git a/tests/core/test_variable_text.py b/tests/core/test_variable_text.py new file mode 100644 index 000000000..115327e17 --- /dev/null +++ b/tests/core/test_variable_text.py @@ -0,0 +1,135 @@ +from rayforge.core.variable_text import ( + SerialSettings, + VariableText, + referenced_template_names, +) + + +class TestSerialSettings: + def test_defaults(self): + s = SerialSettings() + assert s.current == 1 + assert s.start == 1 + assert s.step == 1 + assert s.end is None + assert s.auto_advance is True + assert s.advance_on_export is False + + def test_value_at_offset_uses_step(self): + s = SerialSettings(current=10, start=1, step=5) + assert s.value_at(0) == 10 + assert s.value_at(1) == 15 + assert s.value_at(3) == 25 + + def test_value_at_wraps_after_end(self): + s = SerialSettings(current=4, start=1, step=1, end=5) + assert [s.value_at(i) for i in range(4)] == [4, 5, 1, 2] + + def test_value_at_wraps_with_step(self): + s = SerialSettings(current=90, start=10, step=40, end=95) + # Range is 10, 50, 90; 130 is past the end and wraps to 10. + assert [s.value_at(i) for i in range(3)] == [90, 10, 50] + + def test_value_at_negative_offset_wraps_backwards(self): + s = SerialSettings(current=1, start=1, step=1, end=3) + assert s.value_at(-1) == 3 + + def test_zero_step_keeps_value(self): + s = SerialSettings(current=7, step=0) + assert s.value_at(5) == 7 + + def test_advanced_moves_current(self): + s = SerialSettings(current=3, step=2) + assert s.advanced(4).current == 11 + assert s.advanced().current == 5 + assert s.current == 3 + + def test_previous_moves_back_one_step(self): + s = SerialSettings(current=3, step=2) + assert s.advanced(-1).current == 1 + + def test_reset_returns_to_start(self): + s = SerialSettings(current=42, start=7) + assert s.reset().current == 7 + + def test_values_for_copies(self): + s = SerialSettings(current=8, step=1, end=9, start=1) + assert s.values_for(3) == [8, 9, 1] + + def test_round_trip(self): + s = SerialSettings( + current=12, + start=10, + step=2, + end=30, + auto_advance=False, + advance_on_export=True, + ) + assert SerialSettings.from_dict(s.to_dict()) == s + + def test_from_dict_ignores_missing_keys(self): + assert SerialSettings.from_dict({}) == SerialSettings() + + def test_from_dict_rejects_end_below_start(self): + s = SerialSettings.from_dict({"start": 10, "end": 5}) + assert s.end is None + + +class TestVariableText: + def test_template_values_with_offset(self): + vt = VariableText() + vt.serial = SerialSettings(current=100, step=10) + assert vt.template_values(0) == {"serial": 100} + assert vt.template_values(2) == {"serial": 120} + + def test_setting_serial_emits_changed(self): + vt = VariableText() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.serial = SerialSettings(current=5) + assert received == [vt] + + def test_setting_equal_serial_is_silent(self): + vt = VariableText() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.serial = SerialSettings() + assert received == [] + + def test_names(self): + assert "serial" in VariableText().names + + def test_round_trip(self): + vt = VariableText() + vt.serial = SerialSettings(current=3, start=2, step=4) + restored = VariableText.from_dict(vt.to_dict()) + assert restored.serial == vt.serial + + def test_from_dict_none(self): + assert VariableText.from_dict(None).serial == SerialSettings() + + +class TestReferencedTemplateNames: + def test_finds_names_in_placeholders(self): + names = referenced_template_names(["No. {serial:04d}", "{width * 2}"]) + assert {"serial", "width"} <= names + + def test_ignores_text_outside_placeholders(self): + assert "serial" not in referenced_template_names(["serial"]) + + def test_handles_function_calls_and_subscripts(self): + names = referenced_template_names(['{csv["Name"]} {today()}']) + assert {"csv", "today"} <= names + assert "Name" not in names + + def test_invalid_expression_falls_back_to_identifiers(self): + names = referenced_template_names(["{serial +}"]) + assert "serial" in names diff --git a/tests/core/test_workpiece_variable_text.py b/tests/core/test_workpiece_variable_text.py new file mode 100644 index 000000000..2ff9dc677 --- /dev/null +++ b/tests/core/test_workpiece_variable_text.py @@ -0,0 +1,165 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.variable_text import SerialSettings +from rayforge.core.workpiece import WorkPiece +from tests.core.template_provider import TemplateProvider, place + + +@pytest.fixture +def doc(): + return Doc() + + +class TestVariableOffset: + def test_default_offset_is_zero(self): + assert WorkPiece("wp").variable_offset == 0 + + def test_offset_round_trip(self): + wp = WorkPiece("wp") + wp.variable_offset = 4 + data = wp.to_dict() + assert data["variable_offset"] == 4 + assert WorkPiece.from_dict(data).variable_offset == 4 + + def test_zero_offset_is_not_serialized(self): + assert "variable_offset" not in WorkPiece("wp").to_dict() + + def test_offset_is_not_an_extra_attribute(self): + wp = WorkPiece("wp") + wp.variable_offset = 2 + assert WorkPiece.from_dict(wp.to_dict()).extra == {} + + def test_duplicate_keeps_offset(self): + wp = WorkPiece("wp") + wp.variable_offset = 3 + copy = wp.duplicate() + assert isinstance(copy, WorkPiece) + assert copy.variable_offset == 3 + + +class TestTemplateValues: + def test_unparented_workpiece_has_no_values(self): + assert WorkPiece("wp").template_values() == {} + + def test_values_follow_doc_counter_and_offset(self, doc): + doc.variable_text.serial = SerialSettings(current=10, step=5) + wp = place(doc, "{serial}", offset=2) + assert wp.template_values() == {"serial": 20} + + def test_provider_receives_serial(self, doc): + doc.variable_text.serial = SerialSettings(current=7) + wp = place(doc, "No. {serial:03d}") + wp.clear_render_cache() + assert wp.boundaries is not None + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "No. 007" + + def test_instance_params_win_over_doc_values(self, doc): + doc.variable_text.serial = SerialSettings(current=7) + wp = place(doc, "{serial}") + wp._geometry_provider_params = {"serial": 99} + wp.clear_render_cache() + assert wp.boundaries is not None + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "99" + + def test_in_world_bakes_values(self, doc): + doc.variable_text.serial = SerialSettings(current=3) + wp = place(doc, "{serial}", offset=1) + world = wp.in_world() + assert world.geometry_provider_params["serial"] == 4 + + def test_in_world_leaves_plain_templates_alone(self, doc): + doc.variable_text.serial = SerialSettings(current=3) + wp = place(doc, "Hello") + assert "serial" not in wp.in_world().geometry_provider_params + + def test_references_variables(self, doc): + wp = place(doc, "{serial:04d}") + plain = place(doc, "Hello") + assert wp.references_template_names({"serial"}) + assert not plain.references_template_names({"serial"}) + + +class TestRefreshOnChange: + def test_counter_change_regenerates_text(self, doc): + wp = place(doc, "{serial}") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + doc.variable_text.serial = SerialSettings(current=1234) + assert provider.rendered[-1] == "1234" + + def test_counter_change_keeps_scale(self, doc): + doc.variable_text.serial = SerialSettings(current=9) + wp = place(doc, "{serial}") + wp.set_size(4.0, 10.0) + doc.variable_text.serial = SerialSettings(current=10) + width, height = wp.size + # The text doubled in length; the 2x scale of the + # workpiece is preserved. + assert width == pytest.approx(8.0) + assert height == pytest.approx(10.0) + + def test_counter_change_emits_updated(self, doc): + wp = place(doc, "{serial}") + received = [] + + def on_updated(sender, **kwargs): + received.append(sender) + + wp.updated.connect(on_updated) + doc.variable_text.serial = SerialSettings(current=5) + assert wp in received + + def test_unrelated_workpiece_is_not_regenerated(self, doc): + wp = place(doc, "Hello") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + calls = len(provider.calls) + doc.variable_text.serial = SerialSettings(current=5) + assert len(provider.calls) == calls + + def test_offset_change_regenerates(self, doc): + doc.variable_text.serial = SerialSettings(current=1) + wp = place(doc, "{serial}") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + wp.variable_offset = 9 + assert provider.rendered[-1] == "10" + + +class TestDocSerialization: + def test_doc_round_trip_keeps_counter(self, doc): + doc.variable_text.serial = SerialSettings(current=12, step=3) + restored = Doc.from_dict(doc.to_dict()) + assert restored.variable_text.serial == doc.variable_text.serial + + def test_old_documents_get_default_counter(self, doc): + data = doc.to_dict() + data.pop("variable_text", None) + restored = Doc.from_dict(data) + assert restored.variable_text.serial == SerialSettings() + + def test_restored_doc_refreshes_on_change(self, doc): + restored = Doc.from_dict(doc.to_dict()) + received = [] + + def on_changed(sender): + received.append(sender) + + restored.variable_text.changed.connect(on_changed) + restored.variable_text.serial = SerialSettings(current=2) + assert received == [restored.variable_text] + + def test_copies_count_uses_highest_offset(self, doc): + place(doc, "{serial}", offset=0) + place(doc, "{serial}", offset=5) + place(doc, "Hello", offset=9) + assert doc.variable_text_copies() == 6 + + def test_copies_count_without_template_users(self, doc): + place(doc, "Hello") + assert doc.variable_text_copies() == 0 From 5c13ddb9118d711ca87f523638b6af21b97859ed Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:44:42 +0200 Subject: [PATCH 02/33] feat: undoable serial counter commands and numbered array copies DocEditor.variable_text changes the counter (settings, Next, Previous, Reset) through the undo history, so the document is marked modified and an accidental change can be undone. Number Copies gives selected items the offsets 0, 1, 2, ... in reading order, and grid and circular arrays give their copies increasing offsets. Refs #527 --- rayforge/doceditor/array_cmd.py | 14 +- rayforge/doceditor/editor.py | 2 + rayforge/doceditor/variable_text_cmd.py | 163 ++++++++++++++++++++++ tests/doceditor/test_array_cmd.py | 53 +++++++ tests/doceditor/test_variable_text_cmd.py | 120 ++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 rayforge/doceditor/variable_text_cmd.py create mode 100644 tests/doceditor/test_variable_text_cmd.py diff --git a/rayforge/doceditor/array_cmd.py b/rayforge/doceditor/array_cmd.py index 5c3cbeb18..a1f5a81ae 100644 --- a/rayforge/doceditor/array_cmd.py +++ b/rayforge/doceditor/array_cmd.py @@ -135,7 +135,7 @@ def create_array( created: list[DocItem] = [] with history.transaction(_("Create Array")) as t: - for delta in deltas: + for index, delta in enumerate(deltas): if _is_identity(delta): # The original selection occupies this cell. continue @@ -147,6 +147,7 @@ def create_array( ) continue copy = self._make_copy_at_delta(item, delta) + _shift_variable_offsets(copy, index) cmd = ListItemCommand( owner_obj=layer, item=copy, @@ -173,6 +174,17 @@ def _make_copy_at_delta(item: DocItem, delta_world: Matrix) -> DocItem: return copy +def _shift_variable_offsets(item: DocItem, shift: int) -> None: + """Numbers an array copy: its workpieces show the template values + ``shift`` copies after the original's.""" + if isinstance(item, WorkPiece): + workpieces = [item] + else: + workpieces = item.get_descendants(of_type=WorkPiece) + for wp in workpieces: + wp.variable_offset += shift + + def _is_identity(matrix: Matrix) -> bool: """True if the matrix has no practical effect.""" return matrix.is_identity() diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 41fc8405a..3f2a16b90 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -33,6 +33,7 @@ from .stock_cmd import StockCmd from .tab_cmd import TabCmd from .transform_cmd import TransformCmd +from .variable_text_cmd import VariableTextCmd if TYPE_CHECKING: from ..context import RayforgeContext @@ -160,6 +161,7 @@ def __init__( self.step = StepCmd(self) self.tab = TabCmd(self) self.transform = TransformCmd(self) + self.variable_text = VariableTextCmd(self) # Instantiate addon-registered commands. for name, cmd_class in command_registry.all_commands().items(): diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py new file mode 100644 index 000000000..ff67bf990 --- /dev/null +++ b/rayforge/doceditor/variable_text_cmd.py @@ -0,0 +1,163 @@ +""" +VariableTextCmd: undoable edits of the document's template variables +(serial counter) and of the per-workpiece variable offsets. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..core.item import DocItem +from ..core.undo import ChangePropertyCommand +from ..core.variable_text import SerialSettings +from ..core.workpiece import WorkPiece +from .layout.base import LayoutStrategy + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class VariableTextCmd: + """Handles changes to the document's variable text settings.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + @property + def serial(self) -> SerialSettings: + return self._editor.doc.variable_text.serial + + def set_serial( + self, settings: SerialSettings, name: str | None = None + ) -> None: + """Replaces the serial counter settings in one undoable step.""" + variable_text = self._editor.doc.variable_text + if settings == variable_text.serial: + return + cmd = ChangePropertyCommand( + target=variable_text, + property_name="serial", + new_value=settings, + name=name or _("Change serial number"), + ) + self._editor.history_manager.execute(cmd) + + def next_serial(self) -> None: + """Moves the counter forward by one job.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + self.set_serial(self.serial.advanced(copies), _("Next serial number")) + + def previous_serial(self) -> None: + """Moves the counter back by one job.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + self.set_serial( + self.serial.advanced(-copies), _("Previous serial number") + ) + + def reset_serial(self) -> None: + """Puts the counter back at its start value.""" + self.set_serial(self.serial.reset(), _("Reset serial number")) + + def preview_serial_values(self) -> list[int]: + """The serial numbers the next job uses, one per copy.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + return self.serial.values_for(copies) + + def advance_after_job(self, exported: bool = False) -> bool: + """ + Moves the counter on after a finished job (or a G-code export, + if *exported*), by one value per copy in the job. + + Returns: + True if the counter was advanced. + """ + serial = self.serial + enabled = serial.advance_on_export if exported else serial.auto_advance + copies = self._editor.doc.variable_text_copies() + if not enabled or copies == 0: + return False + self.set_serial( + serial.advanced(copies), _("Advance serial number after job") + ) + logger.info( + f"Serial number advanced by {copies} to " + f"{self._editor.doc.variable_text.serial.current}" + ) + return True + + def set_variable_offset( + self, workpieces: Sequence[WorkPiece], offset: int + ) -> None: + """Sets the same variable offset on all *workpieces*.""" + self._apply_offsets( + [(wp, offset) for wp in workpieces], _("Set variable offset") + ) + + def number_copies(self, items: Sequence[DocItem]) -> None: + """ + Gives *items* the offsets 0, 1, 2, ... in reading order: rows + from top to bottom, each row from left to right. All workpieces + inside a group share the group's offset. + """ + changes = [] + for index, item in enumerate(_reading_order(items)): + changes.extend((wp, index) for wp in _workpieces_of(item)) + self._apply_offsets(changes, _("Number copies")) + + def _apply_offsets( + self, changes: list[tuple[WorkPiece, int]], name: str + ) -> None: + history = self._editor.history_manager + with history.transaction(name) as t: + for wp, offset in changes: + if wp.variable_offset == offset: + continue + t.execute( + ChangePropertyCommand( + target=wp, + property_name="variable_offset", + new_value=offset, + name=name, + ) + ) + + +def _workpieces_of(item: DocItem) -> list[WorkPiece]: + if isinstance(item, WorkPiece): + return [item] + return item.get_descendants(of_type=WorkPiece) + + +def _reading_order(items: Sequence[DocItem]) -> list[DocItem]: + """Sorts items into rows (top first), then left to right.""" + boxes = [] + for item in items: + bbox = LayoutStrategy._get_item_world_bbox(item) + if bbox is None: + x, y = item.pos + bbox = (x, y, x, y) + x_min, y_min, x_max, y_max = bbox + boxes.append( + (item, (x_min + x_max) / 2, (y_min + y_max) / 2, y_max - y_min) + ) + boxes.sort(key=lambda b: -b[2]) + + rows: list[list[tuple[DocItem, float, float, float]]] = [] + for box in boxes: + if rows: + first = rows[-1][0] + tolerance = max(min(first[3], box[3]) / 2, 1e-6) + if abs(first[2] - box[2]) <= tolerance: + rows[-1].append(box) + continue + rows.append([box]) + + ordered: list[DocItem] = [] + for row in rows: + ordered.extend(b[0] for b in sorted(row, key=lambda b: b[1])) + return ordered diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 2a1cb3a4f..6fc6ca3dc 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -8,7 +8,9 @@ import pytest +from rayforge.core.group import Group from rayforge.core.layer import Layer +from rayforge.core.variable_text import SerialSettings from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( ArrayMode, @@ -19,6 +21,7 @@ SpacingMode, ) from rayforge.doceditor.array_cmd import ArrayCmd +from tests.core.template_provider import TemplateProvider, place @pytest.fixture @@ -224,3 +227,53 @@ def test_compute_plan_is_pure(array_cmd, multilayer_selection): assert deltas[0].is_identity() # No mutation happened. assert len(sel["layer_a"].get_content_items()) == before_count + + +@pytest.mark.asyncio +async def test_grid_array_numbers_copies(doc_editor, array_cmd): + """Copies get increasing variable offsets in array order, so + serial numbers count up across the sheet.""" + doc = doc_editor.doc + doc.variable_text.serial = SerialSettings(current=11) + wp = place(doc, "{serial}", offset=2) + params = ArrayParams( + mode=ArrayMode.GRID, + grid=GridArrayParams( + rows=2, + cols=2, + spacing_mode=SpacingMode.DISPLACEMENT, + col_spacing_mm=20.0, + row_spacing_mm=15.0, + ), + ) + created = array_cmd.create_array([wp], params) + assert [c.variable_offset for c in created] == [3, 4, 5] + last = created[-1] + provider = last.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert last.template_values() == {"serial": 16} + assert provider.rendered[-1] == "16" + + +@pytest.mark.asyncio +async def test_array_of_group_numbers_member_workpieces(doc_editor, array_cmd): + doc = doc_editor.doc + wp = place(doc, "{serial}") + group = Group(name="plate") + layer = doc.active_layer + layer.remove_child(wp) + group.add_child(wp) + layer.add_child(group) + params = ArrayParams( + mode=ArrayMode.GRID, + grid=GridArrayParams( + rows=1, + cols=3, + spacing_mode=SpacingMode.DISPLACEMENT, + col_spacing_mm=20.0, + row_spacing_mm=15.0, + ), + ) + created = array_cmd.create_array([group], params) + offsets = [g.all_workpieces[0].variable_offset for g in created] + assert offsets == [1, 2] diff --git a/tests/doceditor/test_variable_text_cmd.py b/tests/doceditor/test_variable_text_cmd.py new file mode 100644 index 000000000..3c05a23fb --- /dev/null +++ b/tests/doceditor/test_variable_text_cmd.py @@ -0,0 +1,120 @@ +import pytest + +from rayforge.core.variable_text import SerialSettings +from rayforge.doceditor.variable_text_cmd import VariableTextCmd +from tests.core.template_provider import place + + +@pytest.fixture +def cmd(doc_editor): + return VariableTextCmd(doc_editor) + + +def _serial(doc_editor) -> SerialSettings: + return doc_editor.doc.variable_text.serial + + +@pytest.mark.asyncio +async def test_editor_exposes_command(doc_editor): + assert isinstance(doc_editor.variable_text, VariableTextCmd) + + +@pytest.mark.asyncio +async def test_set_serial_is_undoable(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=5, step=2)) + assert _serial(doc_editor).current == 5 + assert not doc_editor.is_saved + doc_editor.history_manager.undo() + assert _serial(doc_editor) == SerialSettings() + + +@pytest.mark.asyncio +async def test_next_previous_reset(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=10, start=3, step=5)) + cmd.next_serial() + assert _serial(doc_editor).current == 15 + cmd.previous_serial() + cmd.previous_serial() + assert _serial(doc_editor).current == 5 + cmd.reset_serial() + assert _serial(doc_editor).current == 3 + + +@pytest.mark.asyncio +async def test_advance_after_job_uses_copies(doc_editor, cmd): + doc = doc_editor.doc + place(doc, "{serial}", offset=0) + place(doc, "{serial}", offset=3) + cmd.set_serial(SerialSettings(current=1, step=2)) + assert cmd.advance_after_job() is True + assert _serial(doc_editor).current == 9 + + +@pytest.mark.asyncio +async def test_advance_skipped_without_serial_users(doc_editor, cmd): + place(doc_editor.doc, "Hello") + assert cmd.advance_after_job() is False + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_skipped_when_disabled(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + cmd.set_serial(SerialSettings(auto_advance=False)) + assert cmd.advance_after_job() is False + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_after_export_needs_its_own_switch(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + assert cmd.advance_after_job(exported=True) is False + cmd.set_serial(SerialSettings(advance_on_export=True)) + assert cmd.advance_after_job(exported=True) is True + assert _serial(doc_editor).current == 2 + + +@pytest.mark.asyncio +async def test_advance_is_undoable(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + cmd.advance_after_job() + doc_editor.history_manager.undo() + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_set_variable_offset_is_undoable(doc_editor, cmd): + wp = place(doc_editor.doc, "{serial}") + cmd.set_variable_offset([wp], 4) + assert wp.variable_offset == 4 + doc_editor.history_manager.undo() + assert wp.variable_offset == 0 + + +@pytest.mark.asyncio +async def test_number_copies_in_reading_order(doc_editor, cmd): + doc = doc_editor.doc + wps = [] + # Two rows of two; machine Y grows upwards, so the top row has + # the larger Y and comes first. + for x, y in [(30, 0), (0, 0), (30, 50), (0, 50)]: + wp = place(doc, "{serial}") + wp.pos = (x, y) + wps.append(wp) + cmd.number_copies(wps) + assert [wp.variable_offset for wp in wps] == [3, 2, 1, 0] + doc_editor.history_manager.undo() + assert all(wp.variable_offset == 0 for wp in wps) + + +@pytest.mark.asyncio +async def test_preview_values(doc_editor, cmd): + place(doc_editor.doc, "{serial}", offset=2) + cmd.set_serial(SerialSettings(current=7)) + assert cmd.preview_serial_values() == [7, 8, 9] + + +@pytest.mark.asyncio +async def test_preview_without_copies_shows_current(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=7)) + assert cmd.preview_serial_values() == [7] From ef799dd2ac5186aad08dac0d2115a828c7428f74 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:46:18 +0200 Subject: [PATCH 03/33] feat: advance the serial counter after a finished job MachineCmd now knows whether a send job ran to its end: cancelling marks the running job, and the monitored run reports completion. Only then, and not for a pointer dry run, the editor advances the counter by one step per numbered copy. Framing and failed jobs never advance it. An optional setting also advances it after a G-code export, for jobs that run from a memory card. Refs #527 --- rayforge/doceditor/file_cmd.py | 4 + rayforge/machine/cmd.py | 21 ++- tests/doceditor/test_variable_text_job.py | 162 ++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 tests/doceditor/test_variable_text_job.py diff --git a/rayforge/doceditor/file_cmd.py b/rayforge/doceditor/file_cmd.py index ce8cde6c4..e0c56c721 100644 --- a/rayforge/doceditor/file_cmd.py +++ b/rayforge/doceditor/file_cmd.py @@ -1062,6 +1062,10 @@ def _on_export_assembly_done( name=file_path.name ) self._editor.notification_requested.send(self, message=msg) + self._editor.task_manager.schedule_on_main_thread( + self._editor.variable_text.advance_after_job, + exported=True, + ) except Exception as e: logger.error( diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 84bc523fc..fe2354f82 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -53,6 +53,11 @@ def __init__(self, editor: DocEditor): self.job_started = Signal() self._current_monitor: JobMonitor | None = None self._on_progress_callback: Callable[[dict], None] | None = None + # Set when the running job is cancelled, and whether the last + # send job ran to its end; a finished send advances the + # document's serial counter. + self._cancel_requested = False + self._send_completed = False @property def is_job_running(self) -> bool: @@ -94,11 +99,14 @@ async def _execute_monitored_job( Coroutine, ] | None = None, - ): + ) -> bool: """ Internal helper to execute a job on a driver while managing a JobMonitor for progress reporting. + Returns True if the job ran to its end, False if it had no + operations or was cancelled. + The job runs the encoded pipeline output, unless *execute* is given: then it calls ``execute(on_command_done)`` instead, which runs a driver-defined action (framing) under the same monitoring. @@ -116,7 +124,7 @@ async def _execute_monitored_job( logger.warning("Job has no operations. Skipping execution.") if machine.driver: machine.driver.job_finished.send(machine.driver) - return + return False # Store the callback self._on_progress_callback = on_progress @@ -159,6 +167,7 @@ def cleanup_monitor(): ) # Signal that the job has started. + self._cancel_requested = False self._scheduler(self.job_started.send, self) if execute is not None: @@ -202,6 +211,7 @@ def cleanup_monitor(): f"Job completed. Estimated time: {estimated_hours:.3f}h " f"added to machine hours." ) + return not self._cancel_requested finally: cleanup_monitor() @@ -328,7 +338,7 @@ async def _run_send_action( if not isinstance(artifact, JobArtifact): raise TypeError("_run_send_action received a non-JobArtifact") - await self._execute_monitored_job( + self._send_completed = await self._execute_monitored_job( artifact.ops, machine, on_progress=on_progress, @@ -452,12 +462,15 @@ async def send_job( trace does not burn the material. """ try: + self._send_completed = False await self._start_job( machine, final_job_action=self._run_send_action, on_progress=on_progress, pointer_dry_run=pointer_dry_run, ) + if self._send_completed and not pointer_dry_run: + self._scheduler(self._editor.variable_text.advance_after_job) except JobAlreadyRunningError as e: # An expected refusal, not a failure: the guard's message # already tells the user what to do. @@ -492,6 +505,8 @@ 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.""" + if self._current_monitor is not None: + self._cancel_requested = True driver = machine.driver self._editor.task_manager.add_coroutine( lambda ctx: driver.cancel(), key="cancel-job" diff --git a/tests/doceditor/test_variable_text_job.py b/tests/doceditor/test_variable_text_job.py new file mode 100644 index 000000000..f06c09edf --- /dev/null +++ b/tests/doceditor/test_variable_text_job.py @@ -0,0 +1,162 @@ +""" +The serial counter advances after a finished job (and, if enabled, +after a G-code export), but not after framing, pointer dry runs or a +cancelled job. +""" + +import asyncio + +import pytest +from raygeo.geo import Geometry + +from rayforge.context import get_context +from rayforge.core.variable_text import SerialSettings +from rayforge.machine.cmd import MachineCmd +from tests.core.template_provider import place + + +def _rect() -> Geometry: + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(10.0, 0.0) + geo.line_to(10.0, 10.0) + geo.line_to(0.0, 10.0) + geo.close_path() + return geo + + +def _setup(doc_editor, contour_step_class, context, copies: int = 1): + step = contour_step_class.create(context, name="cut") + workflow = doc_editor.doc.active_layer.workflow + assert workflow is not None + workflow.add_child(step) + for offset in range(copies): + wp = place(doc_editor.doc, "{serial}", offset=offset) + wp._edited_boundaries = _rect() + wp.set_size(10.0, 10.0) + wp.pos = (offset * 20.0, 0.0) + doc_editor.doc.variable_text.serial = SerialSettings(current=5) + + +def _current(doc_editor) -> int: + return doc_editor.doc.variable_text.serial.current + + +async def _settle(doc_editor): + await doc_editor.wait_until_settled() + # Let callbacks scheduled on the main loop run. + await asyncio.sleep(0) + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_send_job_advances_by_copies( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer, copies=3) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).send_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 8 + + +@pytest.mark.asyncio +async def test_frame_job_does_not_advance( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).frame_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + +@pytest.mark.asyncio +async def test_pointer_dry_run_does_not_advance( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).send_job(machine, pointer_dry_run=True) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + +@pytest.mark.asyncio +async def test_cancelled_job_does_not_advance( + context_initializer, doc_editor, contour_step_class, mocker +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + machine_cmd = MachineCmd(doc_editor) + + async def run_and_cancel(*args, **kwargs): + machine_cmd.cancel_job(machine) + + mocker.patch.object(machine.driver, "run", side_effect=run_and_cancel) + await machine_cmd.send_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + # The next, uncancelled job advances again. + mocker.patch.object(machine.driver, "run", return_value=None) + await machine_cmd.send_job(machine) + await _settle(doc_editor) + assert _current(doc_editor) == 6 + + +@pytest.mark.asyncio +async def test_gcode_export_advances_when_enabled( + context_initializer, doc_editor, contour_step_class, tmp_path +): + _setup(doc_editor, contour_step_class, context_initializer) + doc_editor.doc.variable_text.serial = SerialSettings( + current=5, advance_on_export=True + ) + await doc_editor.wait_until_settled() + + out = tmp_path / "job.gcode" + doc_editor.file.export_gcode_to_path(out) + for _i in range(200): + await _settle(doc_editor) + if out.exists() and _current(doc_editor) != 5: + break + await asyncio.sleep(0.01) + + assert out.exists() + assert _current(doc_editor) == 6 + + +@pytest.mark.asyncio +async def test_gcode_export_keeps_counter_by_default( + context_initializer, doc_editor, contour_step_class, tmp_path +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + + out = tmp_path / "job.gcode" + doc_editor.file.export_gcode_to_path(out) + for _i in range(200): + await _settle(doc_editor) + if out.exists(): + break + await asyncio.sleep(0.01) + await _settle(doc_editor) + + assert out.exists() + assert _current(doc_editor) == 5 From db23b14cc6c9ca5e9fdde0e8cd53a09d119d823c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:47:21 +0200 Subject: [PATCH 04/33] feat: Variable Text dialog for the serial counter Tools > Variable Text opens a dialog with the counter settings, Previous / Next / Reset and a preview of the numbers the next job uses. Arrange > Array > Number Copies numbers a selection in reading order, and the workpiece properties show the variable offset of a template workpiece. Refs #527 --- rayforge/ui_gtk/actions.py | 20 ++ .../doceditor/property_providers/__init__.py | 3 + .../property_providers/variable_text.py | 94 ++++++++ .../ui_gtk/doceditor/variable_text_dialog.py | 228 ++++++++++++++++++ rayforge/ui_gtk/main_menu.py | 4 + .../doceditor/test_variable_text_dialog.py | 133 ++++++++++ 6 files changed, 482 insertions(+) create mode 100644 rayforge/ui_gtk/doceditor/property_providers/variable_text.py create mode 100644 rayforge/ui_gtk/doceditor/variable_text_dialog.py create mode 100644 tests/ui_gtk/doceditor/test_variable_text_dialog.py diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index a3561cf4f..8b9a84a22 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -24,6 +24,7 @@ from .doceditor.add_tabs_popover import AddTabsPopover from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog +from .doceditor.variable_text_dialog import VariableTextDialog from .shared.keyboard import PRIMARY_ACCEL if TYPE_CHECKING: @@ -477,6 +478,10 @@ def register_actions(self): self._add_action("array-point-rotation", self.on_array_point_rotation) self._add_action("array-circular", self.on_array_circular) + # Variable text (serial numbers) + self._add_action("variable-text", self.on_variable_text) + self._add_action("number-copies", self.on_number_copies) + # Macro Actions self._add_action( "execute-macro", @@ -576,6 +581,9 @@ def update_action_states(self, *args, **kwargs): "delete-duplicates", ): self.actions[name].set_enabled(bool(selected_wps)) + self.actions["number-copies"].set_enabled( + len(self.win.surface.get_selected_items()) > 1 + ) self.actions["export-object"].set_enabled(len(selected_wps) == 1) sources = self.editor.reload.sources_of(list(selected_wps)) @@ -1042,6 +1050,18 @@ def on_array_circular(self, action, param): dialog.""" self._open_array_dialog(ArrayMode.CIRCULAR) + def on_variable_text(self, action, param): + """Handler for 'variable-text': opens the serial number dialog.""" + dialog = VariableTextDialog(self.win, self.editor) + dialog.present() + + def on_number_copies(self, action, param): + """Handler for 'number-copies': numbers the selected items in + reading order, so their serial numbers count up.""" + items = list(self.win.surface.get_selected_items()) + if items: + self.editor.variable_text.number_copies(items) + def _open_array_dialog(self, mode: ArrayMode) -> None: items = list(self.win.surface.get_selected_items()) if not items: diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py index 6d5113873..ddb1827b0 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/__init__.py +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -1,5 +1,6 @@ from .base import PropertyProvider, property_provider_registry from .transform import TransformPropertyProvider +from .variable_text import VariableTextPropertyProvider from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider @@ -8,12 +9,14 @@ def register_builtin_providers(): property_provider_registry.register(TransformPropertyProvider, "") property_provider_registry.register(WorkpieceInfoProvider, "") property_provider_registry.register(TabsPropertyProvider, "") + property_provider_registry.register(VariableTextPropertyProvider, "") __all__ = [ "PropertyProvider", "TabsPropertyProvider", "TransformPropertyProvider", + "VariableTextPropertyProvider", "WorkpieceInfoProvider", "property_provider_registry", "register_builtin_providers", diff --git a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py new file mode 100644 index 000000000..3c3f73138 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py @@ -0,0 +1,94 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +from ....core.item import DocItem +from ....core.variable_text import SERIAL_NAME +from ....core.workpiece import WorkPiece +from ...icons import get_icon +from ...shared.pref_rows import SpinRow +from .base import PropertyProvider + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +def _uses_variables(item: DocItem) -> bool: + doc = item.doc + return ( + isinstance(item, WorkPiece) + and doc is not None + and item.references_template_names(doc.variable_text.names) + ) + + +class VariableTextPropertyProvider(PropertyProvider): + """ + Shows the variable offset of workpieces whose text templates use a + document variable such as ``{serial}``. + """ + + priority = 30 + separate_group = True + group_title = _("Variable Text") + + def can_handle(self, items: list[DocItem]) -> bool: + return bool(items) and all(_uses_variables(i) for i in items) + + def create_widgets(self) -> list[Gtk.Widget]: + self.offset_row = SpinRow( + _("Variable Offset"), + lower=0, + upper=100_000, + numeric=True, + ) + self.offset_row.set_tooltip_text( + _( + "This copy shows the values this many copies after the " + "current ones" + ) + ) + self.offset_row.value_changed.connect(self._on_offset_changed) + + self.settings_button = Gtk.Button( + child=get_icon("settings-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Variable Text Settings"), + action_name="win.variable-text", + ) + self.offset_row.add_suffix(self.settings_button) + self._rows = [self.offset_row] + return self._rows + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + self.editor = editor + self.items = items + workpieces = [i for i in items if isinstance(i, WorkPiece)] + if not workpieces: + return + first = workpieces[0] + self._in_update = True + try: + self.offset_row.set_value(first.variable_offset) + finally: + self._in_update = False + + if len(workpieces) > 1: + self.offset_row.set_subtitle(_("Applies to all selected items")) + return + serial = first.template_values().get(SERIAL_NAME) + self.offset_row.set_subtitle( + _("Serial number: {value}").format(value=serial) + ) + + def _on_offset_changed(self, row: SpinRow) -> None: + if self._in_update: + return + workpieces = [i for i in self.items if isinstance(i, WorkPiece)] + self.editor.variable_text.set_variable_offset( + workpieces, row.get_int_value() + ) diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/variable_text_dialog.py new file mode 100644 index 000000000..222eb9eb0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -0,0 +1,228 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ...core.variable_text import SerialSettings, VariableText +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows import SpinRow + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +_PREVIEW_LIST_MAX = 5 +_INT_LIMIT = 999_999_999 + + +def format_preview(values: list[int]) -> str: + """A short text for the serial numbers of the next job.""" + if len(values) <= _PREVIEW_LIST_MAX: + return ", ".join(str(v) for v in values) + return _("{first} … {last} ({count} copies)").format( + first=values[0], last=values[-1], count=len(values) + ) + + +class VariableTextDialog(PatchedDialogWindow): + """ + A non-modal window for the document's serial number: its settings, + Previous/Next/Reset buttons and a preview of the next job. + """ + + def __init__(self, parent: Gtk.Window, editor: "DocEditor"): + super().__init__(transient_for=parent) + self.editor = editor + self._doc: Doc = editor.doc + self._in_update = False + + self.set_title(_("Variable Text")) + self.set_default_size(520, -1) + self.set_modal(False) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_box.append(Adw.HeaderBar()) + self.set_content(main_box) + + page = Adw.PreferencesPage() + page.set_vexpand(True) + main_box.append(page) + + page.add(self._build_serial_group()) + page.add(self._build_job_group()) + + self._doc.variable_text.changed.connect(self._on_model_changed) + self._doc.descendant_updated.connect(self._on_doc_items_changed) + self._doc.descendant_added.connect(self._on_doc_items_changed) + self._doc.descendant_removed.connect(self._on_doc_items_changed) + self.connect("destroy", self._on_destroy) + self._update_from_model() + + def _build_serial_group(self) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title=_("Serial Number"), + description=_( + "Use {serial} in a text box, e.g. No. {serial:04d} " + "for four digits with leading zeros." + ), + ) + self.current_row = SpinRow( + _("Current Value"), + _("The number the next job starts with"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.start_row = SpinRow( + _("Start Value"), + _("Reset returns here; the range wraps back to it"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.step_row = SpinRow( + _("Advance By"), + _("Increment per copy and per job"), + lower=0, + upper=_INT_LIMIT, + numeric=True, + ) + self.limit_row = Adw.SwitchRow( + title=_("Wrap at End Value"), + subtitle=_("Continue at the start value after the end value"), + ) + self.end_row = SpinRow( + _("End Value"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.auto_advance_row = Adw.SwitchRow( + title=_("Advance After Each Job"), + subtitle=_("When a job sent to the machine has finished"), + ) + self.export_row = Adw.SwitchRow( + title=_("Advance After G-code Export"), + subtitle=_("For jobs that run from a memory card"), + ) + + for row in (self.current_row, self.start_row, self.step_row): + row.value_changed.connect(self._on_value_row_changed) + self.end_row.value_changed.connect(self._on_value_row_changed) + for switch in (self.limit_row, self.auto_advance_row, self.export_row): + switch.connect("notify::active", self._on_switch_changed) + + for row in ( + self.current_row, + self.start_row, + self.step_row, + self.limit_row, + self.end_row, + self.auto_advance_row, + self.export_row, + ): + group.add(row) + return group + + def _build_job_group(self) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title=_("Next Job")) + self.preview_row = Adw.ActionRow(title=_("Serial Numbers")) + self.preview_row.add_css_class("property") + group.add(self.preview_row) + + button_box = Gtk.Box(spacing=6, halign=Gtk.Align.END) + button_box.set_margin_top(12) + self.previous_button = Gtk.Button(label=_("Previous")) + self.previous_button.set_tooltip_text( + _("Go back by the numbers of one job") + ) + self.next_button = Gtk.Button(label=_("Next")) + self.next_button.set_tooltip_text( + _("Skip ahead by the numbers of one job") + ) + self.reset_button = Gtk.Button(label=_("Reset")) + self.reset_button.set_tooltip_text(_("Go back to the start value")) + self.previous_button.connect("clicked", self._on_previous_clicked) + self.next_button.connect("clicked", self._on_next_clicked) + self.reset_button.connect("clicked", self._on_reset_clicked) + for button in ( + self.reset_button, + self.previous_button, + self.next_button, + ): + button_box.append(button) + group.add(button_box) + return group + + def _settings_from_widgets(self) -> SerialSettings: + start = self.start_row.get_int_value() + end = self.end_row.get_int_value() + limited = self.limit_row.get_active() and end >= start + return SerialSettings( + current=self.current_row.get_int_value(), + start=start, + step=self.step_row.get_int_value(), + end=end if limited else None, + auto_advance=self.auto_advance_row.get_active(), + advance_on_export=self.export_row.get_active(), + ) + + def _commit(self) -> None: + if self._in_update: + return + self.editor.variable_text.set_serial(self._settings_from_widgets()) + + def _on_value_row_changed(self, row: SpinRow) -> None: + self._commit() + + def _on_switch_changed(self, row: Adw.SwitchRow, _pspec) -> None: + self.end_row.set_sensitive(self.limit_row.get_active()) + self._commit() + + def _on_previous_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.previous_serial() + + def _on_next_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.next_serial() + + def _on_reset_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.reset_serial() + + def _on_model_changed(self, sender: VariableText) -> None: + self._update_from_model() + + def _on_doc_items_changed(self, sender, **kwargs) -> None: + self._update_preview() + + def _update_from_model(self) -> None: + serial = self._doc.variable_text.serial + self._in_update = True + try: + self.current_row.set_value(serial.current) + self.start_row.set_value(serial.start) + self.step_row.set_value(serial.step) + self.limit_row.set_active(serial.end is not None) + if serial.end is not None: + self.end_row.set_value(serial.end) + elif self.end_row.get_int_value() < serial.start: + self.end_row.set_value(serial.start) + self.end_row.set_sensitive(serial.end is not None) + self.auto_advance_row.set_active(serial.auto_advance) + self.export_row.set_active(serial.advance_on_export) + finally: + self._in_update = False + self._update_preview() + + def _update_preview(self) -> None: + values = self.editor.variable_text.preview_serial_values() + self.preview_row.set_subtitle(format_preview(values)) + + def _on_destroy(self, widget) -> None: + variable_text = self._doc.variable_text + variable_text.changed.disconnect(self._on_model_changed) + self._doc.descendant_updated.disconnect(self._on_doc_items_changed) + self._doc.descendant_added.disconnect(self._on_doc_items_changed) + self._doc.descendant_removed.disconnect(self._on_doc_items_changed) diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index bf0425778..5d501d2d7 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -173,6 +173,7 @@ def __init__(self): array_submenu.append(_("Grid"), "win.array-grid") array_submenu.append(_("Point Rotation"), "win.array-point-rotation") array_submenu.append(_("Circular"), "win.array-circular") + array_submenu.append(_("Number Copies"), "win.number-copies") arrange_menu.append_submenu(_("Array"), array_submenu) self._layout_group = Gio.Menu() @@ -182,6 +183,9 @@ def __init__(self): # Tools Menu tools_menu = Gio.Menu() + variable_text_group = Gio.Menu() + variable_text_group.append(_("Variable Text…"), "win.variable-text") + tools_menu.append_section(None, variable_text_group) # Addon section for Tools menu self._addon_sections["tools"] = Gio.Menu() diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py new file mode 100644 index 000000000..6b40f845c --- /dev/null +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -0,0 +1,133 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from gi.repository import Gtk + +from rayforge.core.variable_text import SerialSettings +from rayforge.doceditor.editor import DocEditor +from rayforge.ui_gtk.doceditor.property_providers.variable_text import ( + VariableTextPropertyProvider, +) +from rayforge.ui_gtk.doceditor.variable_text_dialog import ( + VariableTextDialog, + format_preview, +) +from tests.core.template_provider import place + + +@pytest.fixture +def editor(ui_context_initializer, ui_task_mgr): + editor = DocEditor( + task_manager=ui_task_mgr, context=ui_context_initializer + ) + yield editor + editor.cleanup() + + +@pytest.fixture +def dialog(editor): + parent = Gtk.Window() + dlg = VariableTextDialog(parent, editor) + yield dlg + dlg.destroy() + + +def _serial(editor) -> SerialSettings: + return editor.doc.variable_text.serial + + +@pytest.mark.ui +def test_format_preview(): + assert format_preview([7]) == "7" + assert format_preview([7, 8, 9]) == "7, 8, 9" + assert format_preview(list(range(1, 21))) == "1 … 20 (20 copies)" + + +@pytest.mark.ui +def test_dialog_shows_current_settings(editor): + editor.doc.variable_text.serial = SerialSettings( + current=42, start=10, step=2, auto_advance=False + ) + dlg = VariableTextDialog(Gtk.Window(), editor) + assert dlg.current_row.get_int_value() == 42 + assert dlg.start_row.get_int_value() == 10 + assert dlg.step_row.get_int_value() == 2 + assert dlg.auto_advance_row.get_active() is False + dlg.destroy() + + +@pytest.mark.ui +def test_editing_current_value_is_undoable(editor, dialog): + dialog.current_row.set_value(77) + dialog.current_row.value_changed.send(dialog.current_row) + assert _serial(editor).current == 77 + editor.history_manager.undo() + assert _serial(editor).current == 1 + + +@pytest.mark.ui +def test_end_value_only_applies_when_enabled(editor, dialog): + dialog.end_row.set_value(9) + dialog.end_row.value_changed.send(dialog.end_row) + assert _serial(editor).end is None + dialog.limit_row.set_active(True) + assert _serial(editor).end == 9 + dialog.limit_row.set_active(False) + assert _serial(editor).end is None + + +@pytest.mark.ui +def test_next_previous_reset_buttons(editor, dialog): + place(editor.doc, "{serial}", offset=1) + dialog.next_button.emit("clicked") + assert _serial(editor).current == 3 + dialog.previous_button.emit("clicked") + assert _serial(editor).current == 1 + editor.doc.variable_text.serial = SerialSettings(current=50, start=4) + dialog.reset_button.emit("clicked") + assert _serial(editor).current == 4 + + +@pytest.mark.ui +def test_preview_follows_model(editor, dialog): + place(editor.doc, "{serial}", offset=2) + editor.doc.variable_text.serial = SerialSettings(current=5) + assert dialog.preview_row.get_subtitle() == "5, 6, 7" + assert dialog.current_row.get_int_value() == 5 + + +@pytest.mark.ui +def test_switches_update_model(editor, dialog): + dialog.auto_advance_row.set_active(False) + dialog.export_row.set_active(True) + assert _serial(editor).auto_advance is False + assert _serial(editor).advance_on_export is True + + +@pytest.mark.ui +def test_property_provider_handles_template_workpieces(editor): + wp = place(editor.doc, "{serial:03d}") + plain = place(editor.doc, "Hello") + provider = VariableTextPropertyProvider() + assert provider.can_handle([wp]) + assert not provider.can_handle([plain]) + assert not provider.can_handle([]) + + +@pytest.mark.ui +def test_property_provider_sets_offset(editor): + editor.doc.variable_text.serial = SerialSettings(current=10) + wp = place(editor.doc, "{serial}") + provider = VariableTextPropertyProvider() + provider.create_widgets() + provider.update_widgets(editor, [wp]) + assert "10" in provider.offset_row.get_subtitle() + provider.offset_row.set_value(3) + provider.offset_row.value_changed.send(provider.offset_row) + assert wp.variable_offset == 3 + provider.update_widgets(editor, [wp]) + assert "13" in provider.offset_row.get_subtitle() From 6332035cb280b81276f8782fce328fc481ca184b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:47:35 +0200 Subject: [PATCH 05/33] docs: document Variable Text and serial numbers Refs #527 --- CHANGELOG.md | 3 ++ website/docs/features/sketcher/expressions.md | 3 ++ website/docs/features/variable-text.md | 51 +++++++++++++++++++ website/sidebars.js | 1 + 4 files changed, 58 insertions(+) create mode 100644 website/docs/features/variable-text.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..5a5359c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden +- Variable Text: a `{serial}` variable for text templates, with a + project counter that advances after every finished job and consecutive + numbers for array copies (Tools > Variable Text) (#527) ### Changed diff --git a/website/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index 008d5ea38..a17feca4b 100644 --- a/website/docs/features/sketcher/expressions.md +++ b/website/docs/features/sketcher/expressions.md @@ -117,6 +117,9 @@ registered for them (see [below](#custom-template-functions)). | `{uuid8()}` | `str` | Alias for `uuid4()` | | `{uuid()}` | `str` | Full UUID v4 string (36 chars) | +For serial numbers that advance after every job, use the project variable `{serial}` (see +[Variable Text](../variable-text.md)). + Typical uses include unique serial numbers per solve (`Part # {uuid4()}`), live dimension labels (`W={width:.1f} H={height:.1f}`), date stamps (`Date: {today()}`), production counters (`{name} - {count:.0f}pcs`), or Unix timestamps for production logging (`{timestamp():.0f}`). diff --git a/website/docs/features/variable-text.md b/website/docs/features/variable-text.md new file mode 100644 index 000000000..f995eb3ce --- /dev/null +++ b/website/docs/features/variable-text.md @@ -0,0 +1,51 @@ +--- +description: + "Serial numbers in text templates: a project-level counter that advances after every job, with + consecutive numbers for array copies." +--- + +# Variable Text + +Text boxes resolve `{...}` templates (see +[Parameters and Expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). On top of +the sketch parameters and the built-in functions, the project offers variables that change from job +to job, so you can engrave numbered plates, tags and labels without editing the text each time. + +## Serial numbers + +Use `{serial}` in a text box. It is a whole number, so format specs control its appearance: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, four digits with leading zeros +- `No. {serial:03d}` — `No. 007` + +Open **Tools → Variable Text…** to set up the counter: + +- **Current Value** — the number the next job starts with. +- **Start Value** — where **Reset** returns to. +- **Advance By** — the increment per copy and per job. +- **Wrap at End Value** / **End Value** — after the end value the counter continues at the start + value. +- **Advance After Each Job** — when a job sent to the machine has finished, the counter moves on. + Framing, pointer dry runs and cancelled jobs leave it alone. +- **Advance After G-code Export** — also move on when you export G-code, for jobs that run from a + memory card. + +The **Next Job** group previews the numbers the next job will use. **Previous** and **Next** move +the counter by one job, **Reset** returns to the start value. Every change, including the automatic +advance, is part of the undo history and is saved in the project. + +## Numbering copies + +Each workpiece has a **Variable Offset** (in the properties panel, under **Variable Text**). A copy +with offset _n_ shows the number _n_ copies after the current one. A +[grid or circular array](arrays.md) gives its copies increasing offsets automatically, so one array +of 20 plates engraves 20 consecutive numbers, and the next job continues with the 21st. + +Arrays made inside a sketch are different: their copies belong to one workpiece and all show the +same number. Make the array on the canvas instead to number the copies. + +For copies you placed yourself, select them all and choose **Arrange → Array → Number Copies**: the +offsets are assigned in reading order, rows from top to bottom and each row from left to right. + +The counter advances by the number of copies in the job: the highest variable offset plus one. diff --git a/website/sidebars.js b/website/sidebars.js index 865677101..f8f1e70aa 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -106,6 +106,7 @@ module.exports = { }, 'features/arrays', 'features/path-cleanup', + 'features/variable-text', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job', From 16319d7acf1e20e683ff60a3f1df9a90ad33250d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:56:07 +0200 Subject: [PATCH 06/33] test: guard optional subtitles in the variable text dialog tests Refs #527 --- tests/ui_gtk/doceditor/test_variable_text_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py index 6b40f845c..be94795aa 100644 --- a/tests/ui_gtk/doceditor/test_variable_text_dialog.py +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -125,9 +125,9 @@ def test_property_provider_sets_offset(editor): provider = VariableTextPropertyProvider() provider.create_widgets() provider.update_widgets(editor, [wp]) - assert "10" in provider.offset_row.get_subtitle() + assert "10" in (provider.offset_row.get_subtitle() or "") provider.offset_row.set_value(3) provider.offset_row.value_changed.send(provider.offset_row) assert wp.variable_offset == 3 provider.update_widgets(editor, [wp]) - assert "13" in provider.offset_row.get_subtitle() + assert "13" in (provider.offset_row.get_subtitle() or "") From e8b31679dba00eac8155e3e11d9cc89f86074040 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:47:57 +0200 Subject: [PATCH 07/33] refactor: advance the serial counter through job and export signals MachineCmd and FileCmd no longer call the serial counter directly. MachineCmd emits job_completed after a send that ran to its end (not for framing or pointer dry runs) and FileCmd emits gcode_exported. VariableTextCmd and the main window subscribe to them, so other features (e.g. CSV merge, #528) can hook into finished jobs too. Refs #527 --- rayforge/doceditor/file_cmd.py | 5 +-- rayforge/doceditor/variable_text_cmd.py | 8 +++++ rayforge/machine/cmd.py | 6 ++-- rayforge/ui_gtk/mainwindow.py | 3 ++ tests/doceditor/test_variable_text_job.py | 41 ++++++++++++++++++++--- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/rayforge/doceditor/file_cmd.py b/rayforge/doceditor/file_cmd.py index e0c56c721..bf09a3e35 100644 --- a/rayforge/doceditor/file_cmd.py +++ b/rayforge/doceditor/file_cmd.py @@ -19,6 +19,7 @@ warnings.simplefilter("ignore", DeprecationWarning) import pyvips +from blinker import Signal from raygeo.geo import Geometry, Matrix from raygeo.geo.types import Point, Rect from raygeo.ops.state import CoolantMode @@ -118,6 +119,7 @@ def __init__( ): self._editor = editor self._task_manager = task_manager + self.gcode_exported = Signal() def get_importer_info( self, file_path: Path, mime_type: str | None @@ -1063,8 +1065,7 @@ def _on_export_assembly_done( ) self._editor.notification_requested.send(self, message=msg) self._editor.task_manager.schedule_on_main_thread( - self._editor.variable_text.advance_after_job, - exported=True, + self.gcode_exported.send, self ) except Exception as e: diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py index ff67bf990..345348a63 100644 --- a/rayforge/doceditor/variable_text_cmd.py +++ b/rayforge/doceditor/variable_text_cmd.py @@ -27,6 +27,7 @@ class VariableTextCmd: def __init__(self, editor: DocEditor): self._editor = editor + editor.file.gcode_exported.connect(self._on_gcode_exported) @property def serial(self) -> SerialSettings: @@ -90,6 +91,13 @@ def advance_after_job(self, exported: bool = False) -> bool: ) return True + def on_job_completed(self, sender) -> None: + """Advances the counter after a job sent to the machine.""" + self.advance_after_job() + + def _on_gcode_exported(self, sender) -> None: + self.advance_after_job(exported=True) + def set_variable_offset( self, workpieces: Sequence[WorkPiece], offset: int ) -> None: diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index fe2354f82..ce9c87083 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -51,11 +51,9 @@ def __init__(self, editor: DocEditor): self._editor = editor self._scheduler = editor.task_manager.schedule_on_main_thread self.job_started = Signal() + self.job_completed = Signal() self._current_monitor: JobMonitor | None = None self._on_progress_callback: Callable[[dict], None] | None = None - # Set when the running job is cancelled, and whether the last - # send job ran to its end; a finished send advances the - # document's serial counter. self._cancel_requested = False self._send_completed = False @@ -470,7 +468,7 @@ async def send_job( pointer_dry_run=pointer_dry_run, ) if self._send_completed and not pointer_dry_run: - self._scheduler(self._editor.variable_text.advance_after_job) + self._scheduler(self.job_completed.send, self) except JobAlreadyRunningError as e: # An expected refusal, not a failure: the guard's message # already tells the user what to do. diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index c4f6f0972..376b15642 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -181,6 +181,9 @@ def __init__(self, **kwargs): ) self.machine_cmd = MachineCmd(self.doc_editor) self.machine_cmd.job_started.connect(self._on_job_started) + self.machine_cmd.job_completed.connect( + self.doc_editor.variable_text.on_job_completed + ) # Instantiate and connect the UpdateCommand's notification signal self.update_cmd = UpdateCommand(task_mgr, context) diff --git a/tests/doceditor/test_variable_text_job.py b/tests/doceditor/test_variable_text_job.py index f06c09edf..61aefbdff 100644 --- a/tests/doceditor/test_variable_text_job.py +++ b/tests/doceditor/test_variable_text_job.py @@ -42,6 +42,14 @@ def _current(doc_editor) -> int: return doc_editor.doc.variable_text.serial.current +def _machine_cmd(doc_editor) -> MachineCmd: + machine_cmd = MachineCmd(doc_editor) + machine_cmd.job_completed.connect( + doc_editor.variable_text.on_job_completed + ) + return machine_cmd + + async def _settle(doc_editor): await doc_editor.wait_until_settled() # Let callbacks scheduled on the main loop run. @@ -58,7 +66,7 @@ async def test_send_job_advances_by_copies( machine = get_context().machine assert machine is not None - await MachineCmd(doc_editor).send_job(machine) + await _machine_cmd(doc_editor).send_job(machine) await _settle(doc_editor) assert _current(doc_editor) == 8 @@ -73,7 +81,7 @@ async def test_frame_job_does_not_advance( machine = get_context().machine assert machine is not None - await MachineCmd(doc_editor).frame_job(machine) + await _machine_cmd(doc_editor).frame_job(machine) await _settle(doc_editor) assert _current(doc_editor) == 5 @@ -88,7 +96,7 @@ async def test_pointer_dry_run_does_not_advance( machine = get_context().machine assert machine is not None - await MachineCmd(doc_editor).send_job(machine, pointer_dry_run=True) + await _machine_cmd(doc_editor).send_job(machine, pointer_dry_run=True) await _settle(doc_editor) assert _current(doc_editor) == 5 @@ -102,7 +110,7 @@ async def test_cancelled_job_does_not_advance( await doc_editor.wait_until_settled() machine = get_context().machine assert machine is not None - machine_cmd = MachineCmd(doc_editor) + machine_cmd = _machine_cmd(doc_editor) async def run_and_cancel(*args, **kwargs): machine_cmd.cancel_job(machine) @@ -160,3 +168,28 @@ async def test_gcode_export_keeps_counter_by_default( assert out.exists() assert _current(doc_editor) == 5 + + +@pytest.mark.asyncio +async def test_job_completed_only_after_real_send( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + machine_cmd = MachineCmd(doc_editor) + completed = [] + + def on_completed(sender): + completed.append(sender) + + machine_cmd.job_completed.connect(on_completed) + await machine_cmd.frame_job(machine) + await machine_cmd.send_job(machine, pointer_dry_run=True) + await _settle(doc_editor) + assert completed == [] + + await machine_cmd.send_job(machine) + await _settle(doc_editor) + assert completed == [machine_cmd] From 9a912ed9a31b3ca632563e0b9adabae84a65dc0b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:48:31 +0200 Subject: [PATCH 08/33] fix: lay out the Variable Text dialog like the settings dialogs Use Adw.ToolbarView with a scrolled preferences page and the step settings dialog's default width, and move Previous / Next / Reset into the preview row instead of a loose button box under the group. Refs #527 --- .../ui_gtk/doceditor/variable_text_dialog.py | 30 ++++++++++++------- .../doceditor/test_variable_text_dialog.py | 10 ++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/variable_text_dialog.py index 222eb9eb0..29f36c80b 100644 --- a/rayforge/ui_gtk/doceditor/variable_text_dialog.py +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -17,6 +17,9 @@ _PREVIEW_LIST_MAX = 5 _INT_LIMIT = 999_999_999 +#: The dialog width, matching the step settings dialog. +DEFAULT_WIDTH = 600 + def format_preview(values: list[int]) -> str: """A short text for the serial numbers of the next job.""" @@ -40,19 +43,25 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): self._in_update = False self.set_title(_("Variable Text")) - self.set_default_size(520, -1) + self.set_default_size(DEFAULT_WIDTH, -1) self.set_modal(False) - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - main_box.append(Adw.HeaderBar()) - self.set_content(main_box) - + main_view = Adw.ToolbarView() + main_view.add_top_bar(Adw.HeaderBar()) + scrolled = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) page = Adw.PreferencesPage() - page.set_vexpand(True) - main_box.append(page) - page.add(self._build_serial_group()) page.add(self._build_job_group()) + scrolled.set_child(page) + natural_height = page.measure(Gtk.Orientation.VERTICAL, DEFAULT_WIDTH)[ + 1 + ] + scrolled.set_min_content_height(natural_height) + main_view.set_content(scrolled) + self.set_content(main_view) self._doc.variable_text.changed.connect(self._on_model_changed) self._doc.descendant_updated.connect(self._on_doc_items_changed) @@ -133,8 +142,7 @@ def _build_job_group(self) -> Adw.PreferencesGroup: self.preview_row.add_css_class("property") group.add(self.preview_row) - button_box = Gtk.Box(spacing=6, halign=Gtk.Align.END) - button_box.set_margin_top(12) + button_box = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) self.previous_button = Gtk.Button(label=_("Previous")) self.previous_button.set_tooltip_text( _("Go back by the numbers of one job") @@ -154,7 +162,7 @@ def _build_job_group(self) -> Adw.PreferencesGroup: self.next_button, ): button_box.append(button) - group.add(button_box) + self.preview_row.add_suffix(button_box) return group def _settings_from_widgets(self) -> SerialSettings: diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py index be94795aa..d60a51f91 100644 --- a/tests/ui_gtk/doceditor/test_variable_text_dialog.py +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -5,7 +5,7 @@ gi.require_version("Adw", "1") import pytest -from gi.repository import Gtk +from gi.repository import Adw, Gtk from rayforge.core.variable_text import SerialSettings from rayforge.doceditor.editor import DocEditor @@ -13,6 +13,7 @@ VariableTextPropertyProvider, ) from rayforge.ui_gtk.doceditor.variable_text_dialog import ( + DEFAULT_WIDTH, VariableTextDialog, format_preview, ) @@ -131,3 +132,10 @@ def test_property_provider_sets_offset(editor): assert wp.variable_offset == 3 provider.update_widgets(editor, [wp]) assert "13" in (provider.offset_row.get_subtitle() or "") + + +@pytest.mark.ui +def test_dialog_layout_matches_settings_dialogs(dialog): + assert dialog.get_modal() is False + assert isinstance(dialog.get_content(), Adw.ToolbarView) + assert dialog.get_default_size()[0] == DEFAULT_WIDTH From 89243b41e925ff55f25492c2d39f8ecf0fbd1804 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:11 +0200 Subject: [PATCH 09/33] refactor: share one helper for the workpieces of an item Doc, ArrayCmd and VariableTextCmd each had their own copy of the "item itself or its workpiece descendants" lookup. Refs #527 --- rayforge/core/doc.py | 8 ++------ rayforge/core/workpiece.py | 7 +++++++ rayforge/doceditor/array_cmd.py | 8 ++------ rayforge/doceditor/variable_text_cmd.py | 10 ++-------- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index d5c08213d..999818253 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -18,7 +18,7 @@ from .layer import Layer from .source_asset import SourceAsset from .variable_text import VariableText -from .workpiece import WorkPiece +from .workpiece import WorkPiece, workpieces_in if TYPE_CHECKING: from .stock import StockItem @@ -222,11 +222,7 @@ def _on_variable_text_changed(self, sender: VariableText) -> None: def _on_item_added_for_templates( self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem ) -> None: - if isinstance(origin, WorkPiece): - added = [origin] - else: - added = origin.get_descendants(of_type=WorkPiece) - for wp in added: + for wp in workpieces_in(origin): if wp.references_template_names(self._variable_text.names): wp.sync_template_values() diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 275e14dd0..f95940a6d 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -1777,3 +1777,10 @@ def regenerate_from_geometry_provider(self) -> None: # Send updated signal to trigger UI updates self.updated.send(self) + + +def workpieces_in(item: DocItem) -> list[WorkPiece]: + """The item itself if it is a workpiece, else its workpieces.""" + if isinstance(item, WorkPiece): + return [item] + return item.get_descendants(of_type=WorkPiece) diff --git a/rayforge/doceditor/array_cmd.py b/rayforge/doceditor/array_cmd.py index a1f5a81ae..904923cef 100644 --- a/rayforge/doceditor/array_cmd.py +++ b/rayforge/doceditor/array_cmd.py @@ -20,7 +20,7 @@ from ..core.group import Group from ..core.item import DocItem from ..core.undo import ListItemCommand -from ..core.workpiece import WorkPiece +from ..core.workpiece import WorkPiece, workpieces_in from .array import ArrayParams, make_array_strategy from .layout.base import LayoutStrategy from .transform_cmd import TransformCmd @@ -177,11 +177,7 @@ def _make_copy_at_delta(item: DocItem, delta_world: Matrix) -> DocItem: def _shift_variable_offsets(item: DocItem, shift: int) -> None: """Numbers an array copy: its workpieces show the template values ``shift`` copies after the original's.""" - if isinstance(item, WorkPiece): - workpieces = [item] - else: - workpieces = item.get_descendants(of_type=WorkPiece) - for wp in workpieces: + for wp in workpieces_in(item): wp.variable_offset += shift diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py index 345348a63..d0a194eaf 100644 --- a/rayforge/doceditor/variable_text_cmd.py +++ b/rayforge/doceditor/variable_text_cmd.py @@ -13,7 +13,7 @@ from ..core.item import DocItem from ..core.undo import ChangePropertyCommand from ..core.variable_text import SerialSettings -from ..core.workpiece import WorkPiece +from ..core.workpiece import WorkPiece, workpieces_in from .layout.base import LayoutStrategy if TYPE_CHECKING: @@ -114,7 +114,7 @@ def number_copies(self, items: Sequence[DocItem]) -> None: """ changes = [] for index, item in enumerate(_reading_order(items)): - changes.extend((wp, index) for wp in _workpieces_of(item)) + changes.extend((wp, index) for wp in workpieces_in(item)) self._apply_offsets(changes, _("Number copies")) def _apply_offsets( @@ -135,12 +135,6 @@ def _apply_offsets( ) -def _workpieces_of(item: DocItem) -> list[WorkPiece]: - if isinstance(item, WorkPiece): - return [item] - return item.get_descendants(of_type=WorkPiece) - - def _reading_order(items: Sequence[DocItem]) -> list[DocItem]: """Sorts items into rows (top first), then left to right.""" boxes = [] From 572d7c5078424d9617472549097acc3735b6c2ab Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:43 +0200 Subject: [PATCH 10/33] refactor: read template sources only from the geometry provider The fallback that parsed the resolved text cache was never reached: template values need a document, and then the provider is available. Refs #527 --- rayforge/core/workpiece.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index f95940a6d..2d6247f5f 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -1182,13 +1182,9 @@ def template_sources(self) -> list[str]: """The raw template strings the geometry provider renders.""" provider = self.get_geometry_provider() get_sources = getattr(provider, "get_template_sources", None) - if callable(get_sources): - return [str(source) for source in cast(list, get_sources())] - return [ - entry[0] - for entry in self._resolved_text_cache.values() - if isinstance(entry, (tuple, list)) and entry - ] + if get_sources is None: + return [] + return list(get_sources()) def references_template_names(self, names: set[str]) -> bool: """Whether any template of this workpiece reads one of *names*.""" From 81b67b76684298c25a23e444b5d08fbd8bd4beee Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:51:49 +0200 Subject: [PATCH 11/33] docs: translate the Variable Text page Add the Variable Text page and the {serial} hint on the expressions page to every translated docs directory. Refs #527 --- .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 56 ++++++++++++++++++ .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 58 +++++++++++++++++++ .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 57 ++++++++++++++++++ .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 51 ++++++++++++++++ .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 55 ++++++++++++++++++ .../current/features/sketcher/expressions.md | 3 + .../current/features/variable-text.md | 53 +++++++++++++++++ .../current/features/sketcher/expressions.md | 2 + .../current/features/variable-text.md | 39 +++++++++++++ 14 files changed, 389 insertions(+) create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 7a0fa2223..c3ac3f6b1 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -122,6 +122,9 @@ Funktionen bereit, und es können benutzerdefinierte Funktionen für sie registr | `{uuid8()}` | `str` | Alias für `uuid4()` | | `{uuid()}` | `str` | Vollständiger UUID v4-String (36 Zeichen) | +Für Seriennummern, die nach jedem Auftrag weiterzählen, verwende die Projektvariable `{serial}` +(siehe [Variabler Text](../variable-text.md)). + Typische Anwendungen sind eindeutige Seriennummern pro Lösung (`Part # {uuid4()}`), live Maßbeschriftungen (`W={width:.1f} H={height:.1f}`), Datumsstempel (`Date: {today()}`), Produktionszähler (`{name} - {count:.0f}pcs`) oder Unix-Zeitstempel für die diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..ddfc328c7 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,56 @@ +--- +description: + "Seriennummern in Textvorlagen: ein Projektzähler, der nach jedem Auftrag weiterzählt, mit + fortlaufenden Nummern für Array-Kopien." +--- + +# Variabler Text + +Textfelder lösen `{...}`-Vorlagen auf (siehe +[Parameter und Ausdrücke](sketcher/expressions.md#template-expressions-in-text-boxes)). Zusätzlich +zu den Skizzenparametern und den eingebauten Funktionen bietet das Projekt Variablen, die sich von +Auftrag zu Auftrag ändern. So gravierst du nummerierte Schilder, Anhänger und Etiketten, ohne den +Text jedes Mal zu bearbeiten. + +## Seriennummern + +Verwende `{serial}` in einem Textfeld. Es ist eine ganze Zahl, daher bestimmen Formatangaben ihr +Aussehen: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, vier Stellen mit führenden Nullen +- `No. {serial:03d}` — `No. 007` + +Öffne **Werkzeuge → Variabler Text…**, um den Zähler einzurichten: + +- **Aktueller Wert** — die Nummer, mit der der nächste Auftrag beginnt. +- **Startwert** — wohin **Zurücksetzen** zurückkehrt. +- **Erhöhen um** — die Schrittweite pro Kopie und pro Auftrag. +- **Am Endwert umbrechen** / **Endwert** — nach dem Endwert macht der Zähler beim Startwert weiter. +- **Nach jedem Auftrag erhöhen** — wenn ein an die Maschine gesendeter Auftrag fertig ist, zählt der + Zähler weiter. Rahmen, Zeiger-Probeläufe und abgebrochene Aufträge lassen ihn unverändert. +- **Nach G-Code-Export erhöhen** — auch beim Exportieren von G-Code weiterzählen, für Aufträge, die + von einer Speicherkarte laufen. + +Die Gruppe **Nächster Auftrag** zeigt eine Vorschau der Nummern, die der nächste Auftrag verwendet. +**Zurück** und **Weiter** verschieben den Zähler um einen Auftrag, **Zurücksetzen** kehrt zum +Startwert zurück. Jede Änderung, auch das automatische Weiterzählen, ist Teil des +Rückgängig-Verlaufs und wird im Projekt gespeichert. + +## Kopien nummerieren + +Jedes Werkstück hat einen **Variablen-Versatz** (im Eigenschaftenbereich unter **Variabler Text**). +Eine Kopie mit Versatz _n_ zeigt die Nummer _n_ Kopien nach der aktuellen. Ein +[Gitter- oder Kreis-Array](arrays.md) gibt seinen Kopien automatisch steigende Versätze, sodass ein +Array aus 20 Schildern 20 fortlaufende Nummern graviert und der nächste Auftrag mit der 21. +weitermacht. + +Arrays innerhalb einer Skizze sind anders: Ihre Kopien gehören zu einem Werkstück und zeigen alle +dieselbe Nummer. Erstelle das Array stattdessen auf der Leinwand, um die Kopien zu nummerieren. + +Für Kopien, die du selbst platziert hast, wähle sie alle aus und wähle **Anordnen → Array → Kopien +nummerieren**: Die Versätze werden in Leserichtung vergeben, Zeilen von oben nach unten und jede +Zeile von links nach rechts. + +Der Zähler erhöht sich um die Anzahl der Kopien im Auftrag: den höchsten Variablen-Versatz plus +eins. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 49f9d9160..29da385df 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -124,6 +124,9 @@ funciones integradas de abajo, y se pueden registrar funciones personalizadas pa | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | Cadena UUID v4 completa (36 caracteres) | +Para números de serie que avanzan tras cada trabajo, usa la variable del proyecto `{serial}` +(consulta [Texto variable](../variable-text.md)). + Entre los usos típicos se incluyen números de serie únicos en cada resolución (`Pieza #{uuid4()}`), etiquetas de dimensiones en vivo (`W={width:.1f} H={height:.1f}`), fechas en cada pieza (`Fecha: {today()}`), contadores de producción (`{name} - {count:.0f}uds`) o marcas de tiempo Unix diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..3c8d9f2f1 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,58 @@ +--- +description: + "Números de serie en plantillas de texto: un contador del proyecto que avanza tras cada trabajo, + con números consecutivos para las copias de una matriz." +--- + +# Texto variable + +Los cuadros de texto resuelven plantillas `{...}` (consulta +[Parámetros y expresiones](sketcher/expressions.md#template-expressions-in-text-boxes)). Además de +los parámetros del boceto y las funciones integradas, el proyecto ofrece variables que cambian de un +trabajo a otro, para que puedas grabar placas, etiquetas y rótulos numerados sin editar el texto +cada vez. + +## Números de serie + +Usa `{serial}` en un cuadro de texto. Es un número entero, así que los especificadores de formato +controlan su aspecto: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, cuatro dígitos con ceros a la izquierda +- `No. {serial:03d}` — `No. 007` + +Abre **Herramientas → Texto variable…** para configurar el contador: + +- **Valor actual** — el número con el que empieza el próximo trabajo. +- **Valor inicial** — adonde vuelve **Restablecer**. +- **Avanzar en** — el incremento por copia y por trabajo. +- **Volver al llegar al valor final** / **Valor final** — después del valor final, el contador + continúa en el valor inicial. +- **Avanzar tras cada trabajo** — cuando termina un trabajo enviado a la máquina, el contador + avanza. El encuadre, las pasadas de prueba con el puntero y los trabajos cancelados no lo + modifican. +- **Avanzar tras exportar G-code** — avanzar también al exportar G-code, para trabajos que se + ejecutan desde una tarjeta de memoria. + +El grupo **Próximo trabajo** muestra una vista previa de los números que usará el próximo trabajo. +**Anterior** y **Siguiente** mueven el contador un trabajo, **Restablecer** vuelve al valor inicial. +Cada cambio, incluido el avance automático, forma parte del historial de deshacer y se guarda en el +proyecto. + +## Numerar copias + +Cada pieza tiene un **Desplazamiento de variable** (en el panel de propiedades, en **Texto +variable**). Una copia con desplazamiento _n_ muestra el número _n_ copias después del actual. Una +[matriz en cuadrícula o circular](arrays.md) da a sus copias desplazamientos crecientes de forma +automática, así que una matriz de 20 placas graba 20 números consecutivos y el siguiente trabajo +continúa con el 21. + +Las matrices creadas dentro de un boceto son distintas: sus copias pertenecen a una sola pieza y +todas muestran el mismo número. Crea la matriz en el lienzo para numerar las copias. + +Para copias que hayas colocado tú, selecciónalas todas y elige **Organizar → Matriz → Numerar +copias**: los desplazamientos se asignan en orden de lectura, filas de arriba abajo y cada fila de +izquierda a derecha. + +El contador avanza según el número de copias del trabajo: el mayor desplazamiento de variable más +uno. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 0b9ac3db0..b86736616 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -124,6 +124,9 @@ fonctions intégrées ci-dessous, et des fonctions personnalisées peuvent leur | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | Chaîne UUID v4 complète (36 caractères) | +Pour des numéros de série qui avancent après chaque travail, utilisez la variable de projet +`{serial}` (voir [Texte variable](../variable-text.md)). + Les utilisations typiques comprennent les numéros de série uniques à chaque résolution (`Pièce #{uuid4()}`), les étiquettes de dimensions dynamiques (`W={width:.1f} H={height:.1f}`), les dates (`Date : {today()}`), les compteurs de production (`{name} - {count:.0f}pcs`) ou les diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..7d3a841ec --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,57 @@ +--- +description: + "Numéros de série dans les modèles de texte : un compteur de projet qui avance après chaque + travail, avec des numéros consécutifs pour les copies d'un réseau." +--- + +# Texte variable + +Les zones de texte résolvent les modèles `{...}` (voir +[Paramètres et expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). En plus +des paramètres de l'esquisse et des fonctions intégrées, le projet propose des variables qui +changent d'un travail à l'autre, pour graver des plaques, étiquettes et badges numérotés sans +modifier le texte à chaque fois. + +## Numéros de série + +Utilisez `{serial}` dans une zone de texte. C'est un nombre entier, donc les spécifications de +format contrôlent son apparence : + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, quatre chiffres avec des zéros en tête +- `No. {serial:03d}` — `No. 007` + +Ouvrez **Outils → Texte variable…** pour configurer le compteur : + +- **Valeur actuelle** — le numéro par lequel commence le prochain travail. +- **Valeur de départ** — là où **Réinitialiser** revient. +- **Avancer de** — l'incrément par copie et par travail. +- **Reboucler à la valeur de fin** / **Valeur de fin** — après la valeur de fin, le compteur reprend + à la valeur de départ. +- **Avancer après chaque travail** — quand un travail envoyé à la machine est terminé, le compteur + avance. Le cadrage, les essais à blanc avec le pointeur et les travaux annulés ne le modifient + pas. +- **Avancer après l'export G-code** — avancer aussi lors de l'export du G-code, pour les travaux + lancés depuis une carte mémoire. + +Le groupe **Prochain travail** affiche un aperçu des numéros que le prochain travail utilisera. +**Précédent** et **Suivant** déplacent le compteur d'un travail, **Réinitialiser** revient à la +valeur de départ. Chaque modification, y compris l'avance automatique, fait partie de l'historique +d'annulation et est enregistrée dans le projet. + +## Numéroter des copies + +Chaque pièce a un **Décalage de variable** (dans le panneau des propriétés, sous **Texte +variable**). Une copie avec le décalage _n_ affiche le numéro situé _n_ copies après le numéro +actuel. Un [réseau en grille ou circulaire](arrays.md) donne automatiquement à ses copies des +décalages croissants : un réseau de 20 plaques grave ainsi 20 numéros consécutifs, et le travail +suivant continue avec le 21e. + +Les réseaux créés dans une esquisse sont différents : leurs copies appartiennent à une seule pièce +et affichent toutes le même numéro. Créez plutôt le réseau sur le canevas pour numéroter les copies. + +Pour des copies placées vous-même, sélectionnez-les toutes et choisissez **Organiser → Réseau → +Numéroter les copies** : les décalages sont attribués dans l'ordre de lecture, les lignes de haut en +bas et chaque ligne de gauche à droite. + +Le compteur avance du nombre de copies du travail : le plus grand décalage de variable plus un. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 6c0fc47ae..0e93e13fc 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -116,6 +116,9 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` का उपनाम | | `{uuid()}` | `str` | पूर्ण UUID v4 स्ट्रिंग (36 वर्ण) | +हर जॉब के बाद आगे बढ़ने वाले सीरियल नंबर के लिए प्रोजेक्ट वेरिएबल `{serial}` का उपयोग करें +([वेरिएबल टेक्स्ट](../variable-text.md) देखें)। + सामान्य उपयोग में प्रति हल अद्वितीय सीरियल नंबर (`Part # {uuid4()}`), लाइव विमा लेबल (`W={width:.1f} H={height:.1f}`), तिथि चिह्न (`Date: {today()}`), उत्पादन काउंटर (`{name} - {count:.0f}pcs`), या उत्पादन लॉगिंग के लिए Unix टाइमस्टैम्प (`{timestamp():.0f}`) शामिल diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..e3a9ea308 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,51 @@ +--- +description: + "टेक्स्ट टेम्पलेट में सीरियल नंबर: एक प्रोजेक्ट काउंटर जो हर जॉब के बाद आगे बढ़ता है, और ऐरे + कॉपियों के लिए क्रमागत नंबर।" +--- + +# वेरिएबल टेक्स्ट + +टेक्स्ट बॉक्स `{...}` टेम्पलेट को हल करते हैं +([पैरामीटर और एक्सप्रेशन](sketcher/expressions.md#template-expressions-in-text-boxes) देखें)। स्केच +पैरामीटर और बिल्ट-इन फ़ंक्शन के अलावा, प्रोजेक्ट ऐसे वेरिएबल देता है जो हर जॉब के साथ बदलते हैं, +ताकि आप हर बार टेक्स्ट बदले बिना नंबर वाली प्लेट, टैग और लेबल उकेर सकें। + +## सीरियल नंबर + +टेक्स्ट बॉक्स में `{serial}` का उपयोग करें। यह एक पूर्णांक है, इसलिए फ़ॉर्मेट स्पेक इसकी दिखावट तय +करते हैं: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, आगे शून्य के साथ चार अंक +- `No. {serial:03d}` — `No. 007` + +काउंटर सेट करने के लिए **टूल्स → वेरिएबल टेक्स्ट…** खोलें: + +- **वर्तमान मान** — वह नंबर जिससे अगला जॉब शुरू होता है। +- **प्रारंभ मान** — जहाँ **रीसेट** वापस ले जाता है। +- **इतना बढ़ाएँ** — प्रति कॉपी और प्रति जॉब वृद्धि। +- **अंतिम मान पर लौटें** / **अंतिम मान** — अंतिम मान के बाद काउंटर प्रारंभ मान से जारी रहता है। +- **हर जॉब के बाद बढ़ाएँ** — मशीन को भेजा गया जॉब पूरा होने पर काउंटर आगे बढ़ता है। फ़्रेमिंग, + पॉइंटर ड्राई रन और रद्द किए गए जॉब इसे नहीं बदलते। +- **G-code एक्सपोर्ट के बाद बढ़ाएँ** — G-code एक्सपोर्ट करने पर भी आगे बढ़ें, उन जॉब के लिए जो + मेमोरी कार्ड से चलते हैं। + +**अगला जॉब** समूह उन नंबरों का पूर्वावलोकन दिखाता है जो अगला जॉब उपयोग करेगा। **पिछला** और **अगला** +काउंटर को एक जॉब आगे-पीछे करते हैं, **रीसेट** प्रारंभ मान पर लौटाता है। हर बदलाव, स्वचालित वृद्धि +सहित, पूर्ववत इतिहास का हिस्सा है और प्रोजेक्ट में सहेजा जाता है। + +## कॉपियों को नंबर देना + +हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है (प्रॉपर्टी पैनल में, **वेरिएबल टेक्स्ट** के अंतर्गत)। +ऑफ़सेट _n_ वाली कॉपी वर्तमान नंबर से _n_ कॉपी बाद का नंबर दिखाती है। एक +[ग्रिड या वृत्ताकार ऐरे](arrays.md) अपनी कॉपियों को अपने-आप बढ़ते ऑफ़सेट देता है, इसलिए 20 प्लेट का +एक ऐरे 20 क्रमागत नंबर उकेरता है, और अगला जॉब 21वें से जारी रहता है। + +स्केच के अंदर बने ऐरे अलग हैं: उनकी कॉपियाँ एक ही वर्कपीस की होती हैं और सभी एक ही नंबर दिखाती हैं। +कॉपियों को नंबर देने के लिए ऐरे कैनवास पर बनाएँ। + +जो कॉपियाँ आपने खुद रखी हैं, उन सबको चुनें और **व्यवस्थित करें → ऐरे → कॉपियों को नंबर दें** चुनें: +ऑफ़सेट पढ़ने के क्रम में दिए जाते हैं, पंक्तियाँ ऊपर से नीचे और हर पंक्ति बाएँ से दाएँ। + +काउंटर जॉब में कॉपियों की संख्या जितना बढ़ता है: सबसे बड़ा वेरिएबल ऑफ़सेट जमा एक। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 5bee6cbcc..c94327b46 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -121,6 +121,9 @@ integradas abaixo, e funções personalizadas podem ser registradas para eles (v | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | String UUID v4 completa (36 caracteres) | +Para números de série que avançam após cada trabalho, use a variável do projeto `{serial}` (veja +[Texto variável](../variable-text.md)). + Usos típicos incluem números de série únicos a cada resolução (`Peça #{uuid4()}`), rótulos de dimensões em tempo real (`L={width:.1f} A={height:.1f}`), datar cada peça (`Data: {today()}`), contadores de produção (`{name} - {count:.0f}un`) ou marcas temporais Unix para registro de produção diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..883c8d7d1 --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,55 @@ +--- +description: + "Números de série em modelos de texto: um contador do projeto que avança após cada trabalho, com + números consecutivos para as cópias de uma matriz." +--- + +# Texto variável + +As caixas de texto resolvem modelos `{...}` (veja +[Parâmetros e expressões](sketcher/expressions.md#template-expressions-in-text-boxes)). Além dos +parâmetros do esboço e das funções integradas, o projeto oferece variáveis que mudam de um trabalho +para outro, para você gravar placas, etiquetas e rótulos numerados sem editar o texto a cada vez. + +## Números de série + +Use `{serial}` em uma caixa de texto. É um número inteiro, então os especificadores de formato +controlam sua aparência: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, quatro dígitos com zeros à esquerda +- `No. {serial:03d}` — `No. 007` + +Abra **Ferramentas → Texto variável…** para configurar o contador: + +- **Valor atual** — o número com que o próximo trabalho começa. +- **Valor inicial** — para onde **Redefinir** volta. +- **Avançar em** — o incremento por cópia e por trabalho. +- **Recomeçar no valor final** / **Valor final** — depois do valor final, o contador continua no + valor inicial. +- **Avançar após cada trabalho** — quando um trabalho enviado à máquina termina, o contador avança. + Enquadramento, testes a seco com o ponteiro e trabalhos cancelados não o alteram. +- **Avançar após exportar G-code** — avançar também ao exportar G-code, para trabalhos executados a + partir de um cartão de memória. + +O grupo **Próximo trabalho** mostra uma prévia dos números que o próximo trabalho usará. +**Anterior** e **Próximo** movem o contador em um trabalho, **Redefinir** volta ao valor inicial. +Cada alteração, incluindo o avanço automático, faz parte do histórico de desfazer e é salva no +projeto. + +## Numerar cópias + +Cada peça tem um **Deslocamento de variável** (no painel de propriedades, em **Texto variável**). +Uma cópia com deslocamento _n_ mostra o número _n_ cópias depois do atual. Uma +[matriz em grade ou circular](arrays.md) dá às suas cópias deslocamentos crescentes automaticamente, +então uma matriz de 20 placas grava 20 números consecutivos, e o próximo trabalho continua com o +21º. + +Matrizes criadas dentro de um esboço são diferentes: suas cópias pertencem a uma única peça e todas +mostram o mesmo número. Crie a matriz na tela para numerar as cópias. + +Para cópias que você mesmo posicionou, selecione todas e escolha **Organizar → Matriz → Numerar +cópias**: os deslocamentos são atribuídos em ordem de leitura, linhas de cima para baixo e cada +linha da esquerda para a direita. + +O contador avança pelo número de cópias do trabalho: o maior deslocamento de variável mais um. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 2405eebd5..48db174a0 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -118,6 +118,9 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | Псевдонім для `uuid4()` | | `{uuid()}` | `str` | Повний рядок UUID v4 (36 символів) | +Для серійних номерів, що збільшуються після кожного завдання, використовуйте змінну проєкту +`{serial}` (див. [Змінний текст](../variable-text.md)). + Типові застосування включають унікальні серійні номери при кожному розв'язанні (`Деталь #{uuid4()}`), живі мітки розмірів (`Ш={width:.1f} В={height:.1f}`), датування кожної деталі (`Дата: {today()}`), лічильники виробництва (`{name} - {count:.0f}шт`) або мітки часу Unix для diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..5265353fc --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,53 @@ +--- +description: + "Серійні номери в текстових шаблонах: лічильник проєкту, що збільшується після кожного завдання, з + послідовними номерами для копій масиву." +--- + +# Змінний текст + +Текстові поля обробляють шаблони `{...}` (див. +[Параметри та вирази](sketcher/expressions.md#template-expressions-in-text-boxes)). Окрім параметрів +ескізу та вбудованих функцій, проєкт пропонує змінні, що змінюються від завдання до завдання, тож ви +можете гравіювати пронумеровані таблички, бирки й етикетки, не редагуючи текст щоразу. + +## Серійні номери + +Використовуйте `{serial}` у текстовому полі. Це ціле число, тож специфікатори формату визначають +його вигляд: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, чотири цифри з провідними нулями +- `No. {serial:03d}` — `No. 007` + +Відкрийте **Інструменти → Змінний текст…**, щоб налаштувати лічильник: + +- **Поточне значення** — номер, з якого починається наступне завдання. +- **Початкове значення** — куди повертає **Скинути**. +- **Збільшувати на** — крок для кожної копії та кожного завдання. +- **Повертатися після кінцевого значення** / **Кінцеве значення** — після кінцевого значення + лічильник продовжує з початкового. +- **Збільшувати після кожного завдання** — коли надіслане на верстат завдання завершено, лічильник + збільшується. Обрамлення, пробні проходи з указівником і скасовані завдання його не змінюють. +- **Збільшувати після експорту G-code** — збільшувати також під час експорту G-code, для завдань, що + запускаються з карти пам'яті. + +Група **Наступне завдання** показує попередній перегляд номерів, які використає наступне завдання. +**Попередній** і **Наступний** зсувають лічильник на одне завдання, **Скинути** повертає до +початкового значення. Кожна зміна, зокрема автоматичне збільшення, входить до історії скасування та +зберігається в проєкті. + +## Нумерація копій + +Кожна заготовка має **Зсув змінної** (на панелі властивостей, у розділі **Змінний текст**). Копія зі +зсувом _n_ показує номер на _n_ копій після поточного. [Сітковий або круговий масив](arrays.md) +автоматично дає своїм копіям зростаючі зсуви, тож масив із 20 табличок гравіює 20 послідовних +номерів, а наступне завдання продовжує з 21-го. + +Масиви всередині ескізу інші: їхні копії належать одній заготовці й усі показують той самий номер. +Щоб пронумерувати копії, створіть масив на полотні. + +Для копій, які ви розмістили самі, виділіть їх усі й виберіть **Упорядкувати → Масив → Пронумерувати +копії**: зсуви призначаються в порядку читання, рядки згори донизу, а кожен рядок зліва направо. + +Лічильник збільшується на кількість копій у завданні: найбільший зсув змінної плюс один. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 999811ef5..7bc97fc6b 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -97,6 +97,8 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` 的别名 | | `{uuid()}` | `str` | 完整 UUID v4 字符串(36 字符) | +如需每次任务后自动递增的序列号,请使用项目变量 `{serial}`(参见[可变文本](../variable-text.md))。 + 典型用途包括每次求解生成唯一序列号(`零件 #{uuid4()}`)、实时尺寸标签(`宽={width:.1f} 高={height:.1f}`)、日期戳(`日期:{today()}`)、生产计数器(`{name} - {count:.0f}个`),或用于生产日志的 Unix 时间戳(`{timestamp():.0f}`)。 ## 自定义模板函数 {#custom-template-functions} diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md new file mode 100644 index 000000000..83f660b88 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -0,0 +1,39 @@ +--- +description: "文本模板中的序列号:每次任务后自动递增的项目计数器,并为阵列副本生成连续编号。" +--- + +# 可变文本 + +文本框会解析 `{...}` +模板(参见[参数与表达式](sketcher/expressions.md#template-expressions-in-text-boxes))。除了草图参数和内置函数之外,项目还提供随每次任务变化的变量,因此无需每次修改文本即可雕刻带编号的铭牌、吊牌和标签。 + +## 序列号 + +在文本框中使用 `{serial}`。它是一个整数,因此可以用格式说明符控制其外观: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`,四位数并补前导零 +- `No. {serial:03d}` — `No. 007` + +打开**工具 → 可变文本…**来设置计数器: + +- **当前值** — 下一次任务开始使用的编号。 +- **起始值** — **重置**时返回的值。 +- **递增量** — 每个副本和每次任务的增量。 +- **到达结束值后循环** / **结束值** — 超过结束值后,计数器从起始值继续。 +- **每次任务后递增** + — 发送到机器的任务完成后,计数器递增。取景、指示光试运行和已取消的任务不会改变它。 +- **导出 G-code 后递增** — 导出 G-code 时也递增,适用于从存储卡运行的任务。 + +**下一次任务**分组会预览下一次任务将使用的编号。**上一个**和**下一个**将计数器移动一次任务,**重置**返回起始值。每次更改(包括自动递增)都会记入撤销历史并保存在项目中。 + +## 为副本编号 + +每个工件都有一个**变量偏移**(在属性面板的**可变文本**下)。偏移为 _n_ 的副本显示当前编号之后第 _n_ +个副本的编号。[网格或环形阵列](arrays.md)会自动为其副本分配递增的偏移,因此一个包含 20 块铭牌的阵列会雕刻 20 个连续编号,下一次任务从第 21 个继续。 + +在草图内部创建的阵列则不同:它们的副本属于同一个工件,全部显示相同的编号。如需为副本编号,请改为在画布上创建阵列。 + +对于手动放置的副本,全部选中后选择**排列 → 阵列 → 为副本编号**:偏移按阅读顺序分配,行从上到下,每行从左到右。 + +计数器按任务中的副本数递增:即最大变量偏移加一。 From 174cd2c9c5f4116e883ce0ed4bb18f45d057f249 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 18 Oct 2026 16:02:39 +0200 Subject: [PATCH 12/33] refactor: make the counter a plain job counter, not a serial number The document carries one counter that the templates consume; how a user labels it (say "SN {counter}") is the user's business, and the app makes no uniqueness promises. That keeps the document format clear of serial semantics and leaves the field open for a future serial-number or data-source management addon, as discussed in #554. Renames {serial} to {counter}, SerialSettings to CounterSettings, the undo property and dialog strings, plus the docs and their translations. The mechanism is unchanged: the counter lives in the file, advances when a job finishes (optionally on export), and workpiece offsets number array copies. --- CHANGELOG.md | 2 +- .../tests/core/test_sketch_variable_text.py | 24 +++---- rayforge/core/doc.py | 2 +- rayforge/core/variable_text.py | 40 +++++------ rayforge/core/workpiece.py | 4 +- rayforge/doceditor/variable_text_cmd.py | 52 +++++++------- rayforge/ui_gtk/actions.py | 6 +- .../property_providers/variable_text.py | 8 +-- .../ui_gtk/doceditor/variable_text_dialog.py | 50 ++++++------- tests/core/test_variable_text.py | 64 ++++++++--------- tests/core/test_workpiece_variable_text.py | 68 +++++++++--------- tests/doceditor/test_array_cmd.py | 12 ++-- tests/doceditor/test_variable_text_cmd.py | 72 +++++++++---------- tests/doceditor/test_variable_text_job.py | 12 ++-- .../doceditor/test_variable_text_dialog.py | 42 +++++------ website/docs/features/sketcher/expressions.md | 2 +- website/docs/features/variable-text.md | 12 ++-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 4 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 4 +- .../current/features/variable-text.md | 10 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 10 +-- 31 files changed, 280 insertions(+), 280 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5359c88..3f20873f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,7 +99,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden -- Variable Text: a `{serial}` variable for text templates, with a +- Variable Text: a `{counter}` variable for text templates, with a project counter that advances after every finished job and consecutive numbers for array copies (Tools > Variable Text) (#527) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py index 855e19a44..3af7872a4 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py @@ -5,7 +5,7 @@ from sketcher.core.entities.text_box import TextBoxEntity from rayforge.core.doc import Doc -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.core.workpiece import WorkPiece @@ -34,22 +34,22 @@ def _place(doc: Doc, sketch: Sketch, offset: int = 0) -> WorkPiece: def test_template_sources_lists_text_box_contents(): - sketch, _box = _sketch_with_text("No. {serial:03d}") - assert sketch.get_template_sources() == ["No. {serial:03d}"] + sketch, _box = _sketch_with_text("No. {counter:03d}") + assert sketch.get_template_sources() == ["No. {counter:03d}"] -def test_serial_is_resolved_in_document(): +def test_counter_is_resolved_in_document(): doc = Doc() - doc.variable_text.serial = SerialSettings(current=7) - sketch, box = _sketch_with_text("No. {serial:03d}") + doc.variable_text.counter = CounterSettings(current=7) + sketch, box = _sketch_with_text("No. {counter:03d}") wp = _place(doc, sketch) assert _resolved(wp, box) == "No. 007" def test_copies_show_consecutive_numbers(): doc = Doc() - doc.variable_text.serial = SerialSettings(current=41) - sketch, box = _sketch_with_text("{serial}") + doc.variable_text.counter = CounterSettings(current=41) + sketch, box = _sketch_with_text("{counter}") first = _place(doc, sketch) second = first.duplicate() assert isinstance(second, WorkPiece) @@ -61,16 +61,16 @@ def test_copies_show_consecutive_numbers(): def test_counter_change_updates_text(): doc = Doc() - sketch, box = _sketch_with_text("{serial:04d}") + sketch, box = _sketch_with_text("{counter:04d}") wp = _place(doc, sketch) - doc.variable_text.serial = SerialSettings(current=12) + doc.variable_text.counter = CounterSettings(current=12) assert _resolved(wp, box) == "0012" def test_saved_text_is_kept_on_load(): doc = Doc() - doc.variable_text.serial = SerialSettings(current=5) - sketch, box = _sketch_with_text("{serial}-{uuid8()}") + doc.variable_text.counter = CounterSettings(current=5) + sketch, box = _sketch_with_text("{counter}-{uuid8()}") wp = _place(doc, sketch) before = _resolved(wp, box) restored = Doc.from_dict(doc.to_dict()) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 999818253..2758812f3 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -46,7 +46,7 @@ def __init__(self): self.assets: dict[str, IAsset] = {} self.asset_order: list[str] = [] - # Variables for text templates (serial number, ...) + # Variables for text templates (counter value, ...) self._variable_text = VariableText() self._variable_text.changed.connect(self._on_variable_text_changed) self.descendant_added.connect(self._on_item_added_for_templates) diff --git a/rayforge/core/variable_text.py b/rayforge/core/variable_text.py index 2c99f3212..89685f671 100644 --- a/rayforge/core/variable_text.py +++ b/rayforge/core/variable_text.py @@ -5,7 +5,7 @@ injected into the ``{...}`` templates of every geometry provider (for example the text boxes of a sketch). Each workpiece adds its own *variable offset*, so copies of the same design can show consecutive -serial numbers. +counter values. """ from __future__ import annotations @@ -18,16 +18,16 @@ from blinker import Signal -SERIAL_NAME = "serial" +COUNTER_NAME = "counter" _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @dataclass(frozen=True) -class SerialSettings: +class CounterSettings: """ - An immutable serial counter. + An immutable job counter. Attributes: current: The value the next job starts with. @@ -66,11 +66,11 @@ def values_for(self, copies: int) -> list[int]: """The values of *copies* consecutive copies.""" return [self.value_at(i) for i in range(max(copies, 0))] - def advanced(self, copies: int = 1) -> SerialSettings: + def advanced(self, copies: int = 1) -> CounterSettings: """A counter moved on (or back, if negative) by *copies*.""" return replace(self, current=self.value_at(copies)) - def reset(self) -> SerialSettings: + def reset(self) -> CounterSettings: """A counter whose current value is back at ``start``.""" return replace(self, current=self.start) @@ -85,7 +85,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: dict[str, Any]) -> SerialSettings: + def from_dict(cls, data: dict[str, Any]) -> CounterSettings: default = cls() start = int(data.get("start", default.start)) end = data.get("end") @@ -115,38 +115,38 @@ class VariableText: def __init__(self): self.changed = Signal() - self._serial = SerialSettings() + self._counter = CounterSettings() @property - def serial(self) -> SerialSettings: - return self._serial + def counter(self) -> CounterSettings: + return self._counter - @serial.setter - def serial(self, value: SerialSettings) -> None: - if value == self._serial: + @counter.setter + def counter(self, value: CounterSettings) -> None: + if value == self._counter: return - self._serial = value + self._counter = value self.changed.send(self) @property def names(self) -> set[str]: """The template names this object provides.""" - return {SERIAL_NAME} + return {COUNTER_NAME} def template_values(self, offset: int = 0) -> dict[str, Any]: """The template values for a copy with the given offset.""" - return {SERIAL_NAME: self._serial.value_at(offset)} + return {COUNTER_NAME: self._counter.value_at(offset)} def to_dict(self) -> dict[str, Any]: - return {"serial": self._serial.to_dict()} + return {"counter": self._counter.to_dict()} @classmethod def from_dict(cls, data: dict[str, Any] | None) -> VariableText: instance = cls() if data: - serial = data.get("serial") - if isinstance(serial, dict): - instance._serial = SerialSettings.from_dict(serial) + counter = data.get("counter") + if isinstance(counter, dict): + instance._counter = CounterSettings.from_dict(counter) return instance diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 2d6247f5f..bd8638234 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -132,7 +132,7 @@ def __init__( self._resolved_text_cache: dict = {} # Shifts the document's template variables for this copy, so - # e.g. array copies show consecutive serial numbers. + # e.g. array copies show consecutive counter values. self._variable_offset: int = 0 # The template variables the cached geometry was generated with. self._template_values_used: dict[str, Any] = {} @@ -1138,7 +1138,7 @@ def variable_offset(self) -> int: The copy offset applied to the document's template variables. A copy with offset ``n`` shows the values ``n`` copies after - the current ones, e.g. serial number ``current + n * step``. + the current ones, e.g. counter value ``current + n * step``. """ return self._variable_offset diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py index d0a194eaf..9bd5e7163 100644 --- a/rayforge/doceditor/variable_text_cmd.py +++ b/rayforge/doceditor/variable_text_cmd.py @@ -1,6 +1,6 @@ """ VariableTextCmd: undoable edits of the document's template variables -(serial counter) and of the per-workpiece variable offsets. +(job counter) and of the per-workpiece variable offsets. """ from __future__ import annotations @@ -12,7 +12,7 @@ from ..core.item import DocItem from ..core.undo import ChangePropertyCommand -from ..core.variable_text import SerialSettings +from ..core.variable_text import CounterSettings from ..core.workpiece import WorkPiece, workpieces_in from .layout.base import LayoutStrategy @@ -30,44 +30,44 @@ def __init__(self, editor: DocEditor): editor.file.gcode_exported.connect(self._on_gcode_exported) @property - def serial(self) -> SerialSettings: - return self._editor.doc.variable_text.serial + def counter(self) -> CounterSettings: + return self._editor.doc.variable_text.counter - def set_serial( - self, settings: SerialSettings, name: str | None = None + def set_counter( + self, settings: CounterSettings, name: str | None = None ) -> None: - """Replaces the serial counter settings in one undoable step.""" + """Replaces the job counter settings in one undoable step.""" variable_text = self._editor.doc.variable_text - if settings == variable_text.serial: + if settings == variable_text.counter: return cmd = ChangePropertyCommand( target=variable_text, - property_name="serial", + property_name="counter", new_value=settings, - name=name or _("Change serial number"), + name=name or _("Change counter value"), ) self._editor.history_manager.execute(cmd) - def next_serial(self) -> None: + def next_counter(self) -> None: """Moves the counter forward by one job.""" copies = max(self._editor.doc.variable_text_copies(), 1) - self.set_serial(self.serial.advanced(copies), _("Next serial number")) + self.set_counter(self.counter.advanced(copies), _("Next counter value")) - def previous_serial(self) -> None: + def previous_counter(self) -> None: """Moves the counter back by one job.""" copies = max(self._editor.doc.variable_text_copies(), 1) - self.set_serial( - self.serial.advanced(-copies), _("Previous serial number") + self.set_counter( + self.counter.advanced(-copies), _("Previous counter value") ) - def reset_serial(self) -> None: + def reset_counter(self) -> None: """Puts the counter back at its start value.""" - self.set_serial(self.serial.reset(), _("Reset serial number")) + self.set_counter(self.counter.reset(), _("Reset counter value")) - def preview_serial_values(self) -> list[int]: - """The serial numbers the next job uses, one per copy.""" + def preview_counter_values(self) -> list[int]: + """The counter values the next job uses, one per copy.""" copies = max(self._editor.doc.variable_text_copies(), 1) - return self.serial.values_for(copies) + return self.counter.values_for(copies) def advance_after_job(self, exported: bool = False) -> bool: """ @@ -77,17 +77,17 @@ def advance_after_job(self, exported: bool = False) -> bool: Returns: True if the counter was advanced. """ - serial = self.serial - enabled = serial.advance_on_export if exported else serial.auto_advance + counter = self.counter + enabled = counter.advance_on_export if exported else counter.auto_advance copies = self._editor.doc.variable_text_copies() if not enabled or copies == 0: return False - self.set_serial( - serial.advanced(copies), _("Advance serial number after job") + self.set_counter( + counter.advanced(copies), _("Advance counter value after job") ) logger.info( - f"Serial number advanced by {copies} to " - f"{self._editor.doc.variable_text.serial.current}" + f"Job counter advanced by {copies} to " + f"{self._editor.doc.variable_text.counter.current}" ) return True diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 8b9a84a22..537c594f1 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -478,7 +478,7 @@ def register_actions(self): self._add_action("array-point-rotation", self.on_array_point_rotation) self._add_action("array-circular", self.on_array_circular) - # Variable text (serial numbers) + # Variable text (counter values) self._add_action("variable-text", self.on_variable_text) self._add_action("number-copies", self.on_number_copies) @@ -1051,13 +1051,13 @@ def on_array_circular(self, action, param): self._open_array_dialog(ArrayMode.CIRCULAR) def on_variable_text(self, action, param): - """Handler for 'variable-text': opens the serial number dialog.""" + """Handler for 'variable-text': opens the job counter dialog.""" dialog = VariableTextDialog(self.win, self.editor) dialog.present() def on_number_copies(self, action, param): """Handler for 'number-copies': numbers the selected items in - reading order, so their serial numbers count up.""" + reading order, so their counter values count up.""" items = list(self.win.surface.get_selected_items()) if items: self.editor.variable_text.number_copies(items) diff --git a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py index 3c3f73138..6c0f54e86 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py +++ b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py @@ -5,7 +5,7 @@ from gi.repository import Gtk from ....core.item import DocItem -from ....core.variable_text import SERIAL_NAME +from ....core.variable_text import COUNTER_NAME from ....core.workpiece import WorkPiece from ...icons import get_icon from ...shared.pref_rows import SpinRow @@ -29,7 +29,7 @@ def _uses_variables(item: DocItem) -> bool: class VariableTextPropertyProvider(PropertyProvider): """ Shows the variable offset of workpieces whose text templates use a - document variable such as ``{serial}``. + document variable such as ``{counter}``. """ priority = 30 @@ -80,9 +80,9 @@ def update_widgets(self, editor: "DocEditor", items: list[DocItem]): if len(workpieces) > 1: self.offset_row.set_subtitle(_("Applies to all selected items")) return - serial = first.template_values().get(SERIAL_NAME) + counter = first.template_values().get(COUNTER_NAME) self.offset_row.set_subtitle( - _("Serial number: {value}").format(value=serial) + _("Job counter: {value}").format(value=counter) ) def _on_offset_changed(self, row: SpinRow) -> None: diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/variable_text_dialog.py index 29f36c80b..c7242d155 100644 --- a/rayforge/ui_gtk/doceditor/variable_text_dialog.py +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -4,7 +4,7 @@ from gi.repository import Adw, Gtk -from ...core.variable_text import SerialSettings, VariableText +from ...core.variable_text import CounterSettings, VariableText from ..shared.patched_dialog_window import PatchedDialogWindow from ..shared.pref_rows import SpinRow @@ -22,7 +22,7 @@ def format_preview(values: list[int]) -> str: - """A short text for the serial numbers of the next job.""" + """A short text for the counter values of the next job.""" if len(values) <= _PREVIEW_LIST_MAX: return ", ".join(str(v) for v in values) return _("{first} … {last} ({count} copies)").format( @@ -32,7 +32,7 @@ def format_preview(values: list[int]) -> str: class VariableTextDialog(PatchedDialogWindow): """ - A non-modal window for the document's serial number: its settings, + A non-modal window for the document's counter value: its settings, Previous/Next/Reset buttons and a preview of the next job. """ @@ -53,7 +53,7 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, ) page = Adw.PreferencesPage() - page.add(self._build_serial_group()) + page.add(self._build_counter_group()) page.add(self._build_job_group()) scrolled.set_child(page) natural_height = page.measure(Gtk.Orientation.VERTICAL, DEFAULT_WIDTH)[ @@ -70,11 +70,11 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): self.connect("destroy", self._on_destroy) self._update_from_model() - def _build_serial_group(self) -> Adw.PreferencesGroup: + def _build_counter_group(self) -> Adw.PreferencesGroup: group = Adw.PreferencesGroup( title=_("Serial Number"), description=_( - "Use {serial} in a text box, e.g. No. {serial:04d} " + "Use {counter} in a text box, e.g. No. {counter:04d} " "for four digits with leading zeros." ), ) @@ -165,11 +165,11 @@ def _build_job_group(self) -> Adw.PreferencesGroup: self.preview_row.add_suffix(button_box) return group - def _settings_from_widgets(self) -> SerialSettings: + def _settings_from_widgets(self) -> CounterSettings: start = self.start_row.get_int_value() end = self.end_row.get_int_value() limited = self.limit_row.get_active() and end >= start - return SerialSettings( + return CounterSettings( current=self.current_row.get_int_value(), start=start, step=self.step_row.get_int_value(), @@ -181,7 +181,7 @@ def _settings_from_widgets(self) -> SerialSettings: def _commit(self) -> None: if self._in_update: return - self.editor.variable_text.set_serial(self._settings_from_widgets()) + self.editor.variable_text.set_counter(self._settings_from_widgets()) def _on_value_row_changed(self, row: SpinRow) -> None: self._commit() @@ -191,13 +191,13 @@ def _on_switch_changed(self, row: Adw.SwitchRow, _pspec) -> None: self._commit() def _on_previous_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.previous_serial() + self.editor.variable_text.previous_counter() def _on_next_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.next_serial() + self.editor.variable_text.next_counter() def _on_reset_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.reset_serial() + self.editor.variable_text.reset_counter() def _on_model_changed(self, sender: VariableText) -> None: self._update_from_model() @@ -206,26 +206,26 @@ def _on_doc_items_changed(self, sender, **kwargs) -> None: self._update_preview() def _update_from_model(self) -> None: - serial = self._doc.variable_text.serial + counter = self._doc.variable_text.counter self._in_update = True try: - self.current_row.set_value(serial.current) - self.start_row.set_value(serial.start) - self.step_row.set_value(serial.step) - self.limit_row.set_active(serial.end is not None) - if serial.end is not None: - self.end_row.set_value(serial.end) - elif self.end_row.get_int_value() < serial.start: - self.end_row.set_value(serial.start) - self.end_row.set_sensitive(serial.end is not None) - self.auto_advance_row.set_active(serial.auto_advance) - self.export_row.set_active(serial.advance_on_export) + self.current_row.set_value(counter.current) + self.start_row.set_value(counter.start) + self.step_row.set_value(counter.step) + self.limit_row.set_active(counter.end is not None) + if counter.end is not None: + self.end_row.set_value(counter.end) + elif self.end_row.get_int_value() < counter.start: + self.end_row.set_value(counter.start) + self.end_row.set_sensitive(counter.end is not None) + self.auto_advance_row.set_active(counter.auto_advance) + self.export_row.set_active(counter.advance_on_export) finally: self._in_update = False self._update_preview() def _update_preview(self) -> None: - values = self.editor.variable_text.preview_serial_values() + values = self.editor.variable_text.preview_counter_values() self.preview_row.set_subtitle(format_preview(values)) def _on_destroy(self, widget) -> None: diff --git a/tests/core/test_variable_text.py b/tests/core/test_variable_text.py index 115327e17..168009bf2 100644 --- a/tests/core/test_variable_text.py +++ b/tests/core/test_variable_text.py @@ -1,13 +1,13 @@ from rayforge.core.variable_text import ( - SerialSettings, + CounterSettings, VariableText, referenced_template_names, ) -class TestSerialSettings: +class TestCounterSettings: def test_defaults(self): - s = SerialSettings() + s = CounterSettings() assert s.current == 1 assert s.start == 1 assert s.step == 1 @@ -16,48 +16,48 @@ def test_defaults(self): assert s.advance_on_export is False def test_value_at_offset_uses_step(self): - s = SerialSettings(current=10, start=1, step=5) + s = CounterSettings(current=10, start=1, step=5) assert s.value_at(0) == 10 assert s.value_at(1) == 15 assert s.value_at(3) == 25 def test_value_at_wraps_after_end(self): - s = SerialSettings(current=4, start=1, step=1, end=5) + s = CounterSettings(current=4, start=1, step=1, end=5) assert [s.value_at(i) for i in range(4)] == [4, 5, 1, 2] def test_value_at_wraps_with_step(self): - s = SerialSettings(current=90, start=10, step=40, end=95) + s = CounterSettings(current=90, start=10, step=40, end=95) # Range is 10, 50, 90; 130 is past the end and wraps to 10. assert [s.value_at(i) for i in range(3)] == [90, 10, 50] def test_value_at_negative_offset_wraps_backwards(self): - s = SerialSettings(current=1, start=1, step=1, end=3) + s = CounterSettings(current=1, start=1, step=1, end=3) assert s.value_at(-1) == 3 def test_zero_step_keeps_value(self): - s = SerialSettings(current=7, step=0) + s = CounterSettings(current=7, step=0) assert s.value_at(5) == 7 def test_advanced_moves_current(self): - s = SerialSettings(current=3, step=2) + s = CounterSettings(current=3, step=2) assert s.advanced(4).current == 11 assert s.advanced().current == 5 assert s.current == 3 def test_previous_moves_back_one_step(self): - s = SerialSettings(current=3, step=2) + s = CounterSettings(current=3, step=2) assert s.advanced(-1).current == 1 def test_reset_returns_to_start(self): - s = SerialSettings(current=42, start=7) + s = CounterSettings(current=42, start=7) assert s.reset().current == 7 def test_values_for_copies(self): - s = SerialSettings(current=8, step=1, end=9, start=1) + s = CounterSettings(current=8, step=1, end=9, start=1) assert s.values_for(3) == [8, 9, 1] def test_round_trip(self): - s = SerialSettings( + s = CounterSettings( current=12, start=10, step=2, @@ -65,24 +65,24 @@ def test_round_trip(self): auto_advance=False, advance_on_export=True, ) - assert SerialSettings.from_dict(s.to_dict()) == s + assert CounterSettings.from_dict(s.to_dict()) == s def test_from_dict_ignores_missing_keys(self): - assert SerialSettings.from_dict({}) == SerialSettings() + assert CounterSettings.from_dict({}) == CounterSettings() def test_from_dict_rejects_end_below_start(self): - s = SerialSettings.from_dict({"start": 10, "end": 5}) + s = CounterSettings.from_dict({"start": 10, "end": 5}) assert s.end is None class TestVariableText: def test_template_values_with_offset(self): vt = VariableText() - vt.serial = SerialSettings(current=100, step=10) - assert vt.template_values(0) == {"serial": 100} - assert vt.template_values(2) == {"serial": 120} + vt.counter = CounterSettings(current=100, step=10) + assert vt.template_values(0) == {"counter": 100} + assert vt.template_values(2) == {"counter": 120} - def test_setting_serial_emits_changed(self): + def test_setting_counter_emits_changed(self): vt = VariableText() received = [] @@ -90,10 +90,10 @@ def on_changed(sender): received.append(sender) vt.changed.connect(on_changed) - vt.serial = SerialSettings(current=5) + vt.counter = CounterSettings(current=5) assert received == [vt] - def test_setting_equal_serial_is_silent(self): + def test_setting_equal_counter_is_silent(self): vt = VariableText() received = [] @@ -101,29 +101,29 @@ def on_changed(sender): received.append(sender) vt.changed.connect(on_changed) - vt.serial = SerialSettings() + vt.counter = CounterSettings() assert received == [] def test_names(self): - assert "serial" in VariableText().names + assert "counter" in VariableText().names def test_round_trip(self): vt = VariableText() - vt.serial = SerialSettings(current=3, start=2, step=4) + vt.counter = CounterSettings(current=3, start=2, step=4) restored = VariableText.from_dict(vt.to_dict()) - assert restored.serial == vt.serial + assert restored.counter == vt.counter def test_from_dict_none(self): - assert VariableText.from_dict(None).serial == SerialSettings() + assert VariableText.from_dict(None).counter == CounterSettings() class TestReferencedTemplateNames: def test_finds_names_in_placeholders(self): - names = referenced_template_names(["No. {serial:04d}", "{width * 2}"]) - assert {"serial", "width"} <= names + names = referenced_template_names(["No. {counter:04d}", "{width * 2}"]) + assert {"counter", "width"} <= names def test_ignores_text_outside_placeholders(self): - assert "serial" not in referenced_template_names(["serial"]) + assert "counter" not in referenced_template_names(["counter"]) def test_handles_function_calls_and_subscripts(self): names = referenced_template_names(['{csv["Name"]} {today()}']) @@ -131,5 +131,5 @@ def test_handles_function_calls_and_subscripts(self): assert "Name" not in names def test_invalid_expression_falls_back_to_identifiers(self): - names = referenced_template_names(["{serial +}"]) - assert "serial" in names + names = referenced_template_names(["{counter +}"]) + assert "counter" in names diff --git a/tests/core/test_workpiece_variable_text.py b/tests/core/test_workpiece_variable_text.py index 2ff9dc677..1bde26f71 100644 --- a/tests/core/test_workpiece_variable_text.py +++ b/tests/core/test_workpiece_variable_text.py @@ -1,7 +1,7 @@ import pytest from rayforge.core.doc import Doc -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.core.workpiece import WorkPiece from tests.core.template_provider import TemplateProvider, place @@ -43,13 +43,13 @@ def test_unparented_workpiece_has_no_values(self): assert WorkPiece("wp").template_values() == {} def test_values_follow_doc_counter_and_offset(self, doc): - doc.variable_text.serial = SerialSettings(current=10, step=5) - wp = place(doc, "{serial}", offset=2) - assert wp.template_values() == {"serial": 20} + doc.variable_text.counter = CounterSettings(current=10, step=5) + wp = place(doc, "{counter}", offset=2) + assert wp.template_values() == {"counter": 20} - def test_provider_receives_serial(self, doc): - doc.variable_text.serial = SerialSettings(current=7) - wp = place(doc, "No. {serial:03d}") + def test_provider_receives_counter(self, doc): + doc.variable_text.counter = CounterSettings(current=7) + wp = place(doc, "No. {counter:03d}") wp.clear_render_cache() assert wp.boundaries is not None provider = wp.get_geometry_provider() @@ -57,9 +57,9 @@ def test_provider_receives_serial(self, doc): assert provider.rendered[-1] == "No. 007" def test_instance_params_win_over_doc_values(self, doc): - doc.variable_text.serial = SerialSettings(current=7) - wp = place(doc, "{serial}") - wp._geometry_provider_params = {"serial": 99} + doc.variable_text.counter = CounterSettings(current=7) + wp = place(doc, "{counter}") + wp._geometry_provider_params = {"counter": 99} wp.clear_render_cache() assert wp.boundaries is not None provider = wp.get_geometry_provider() @@ -67,36 +67,36 @@ def test_instance_params_win_over_doc_values(self, doc): assert provider.rendered[-1] == "99" def test_in_world_bakes_values(self, doc): - doc.variable_text.serial = SerialSettings(current=3) - wp = place(doc, "{serial}", offset=1) + doc.variable_text.counter = CounterSettings(current=3) + wp = place(doc, "{counter}", offset=1) world = wp.in_world() - assert world.geometry_provider_params["serial"] == 4 + assert world.geometry_provider_params["counter"] == 4 def test_in_world_leaves_plain_templates_alone(self, doc): - doc.variable_text.serial = SerialSettings(current=3) + doc.variable_text.counter = CounterSettings(current=3) wp = place(doc, "Hello") - assert "serial" not in wp.in_world().geometry_provider_params + assert "counter" not in wp.in_world().geometry_provider_params def test_references_variables(self, doc): - wp = place(doc, "{serial:04d}") + wp = place(doc, "{counter:04d}") plain = place(doc, "Hello") - assert wp.references_template_names({"serial"}) - assert not plain.references_template_names({"serial"}) + assert wp.references_template_names({"counter"}) + assert not plain.references_template_names({"counter"}) class TestRefreshOnChange: def test_counter_change_regenerates_text(self, doc): - wp = place(doc, "{serial}") + wp = place(doc, "{counter}") provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) - doc.variable_text.serial = SerialSettings(current=1234) + doc.variable_text.counter = CounterSettings(current=1234) assert provider.rendered[-1] == "1234" def test_counter_change_keeps_scale(self, doc): - doc.variable_text.serial = SerialSettings(current=9) - wp = place(doc, "{serial}") + doc.variable_text.counter = CounterSettings(current=9) + wp = place(doc, "{counter}") wp.set_size(4.0, 10.0) - doc.variable_text.serial = SerialSettings(current=10) + doc.variable_text.counter = CounterSettings(current=10) width, height = wp.size # The text doubled in length; the 2x scale of the # workpiece is preserved. @@ -104,14 +104,14 @@ def test_counter_change_keeps_scale(self, doc): assert height == pytest.approx(10.0) def test_counter_change_emits_updated(self, doc): - wp = place(doc, "{serial}") + wp = place(doc, "{counter}") received = [] def on_updated(sender, **kwargs): received.append(sender) wp.updated.connect(on_updated) - doc.variable_text.serial = SerialSettings(current=5) + doc.variable_text.counter = CounterSettings(current=5) assert wp in received def test_unrelated_workpiece_is_not_regenerated(self, doc): @@ -119,12 +119,12 @@ def test_unrelated_workpiece_is_not_regenerated(self, doc): provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) calls = len(provider.calls) - doc.variable_text.serial = SerialSettings(current=5) + doc.variable_text.counter = CounterSettings(current=5) assert len(provider.calls) == calls def test_offset_change_regenerates(self, doc): - doc.variable_text.serial = SerialSettings(current=1) - wp = place(doc, "{serial}") + doc.variable_text.counter = CounterSettings(current=1) + wp = place(doc, "{counter}") provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) wp.variable_offset = 9 @@ -133,15 +133,15 @@ def test_offset_change_regenerates(self, doc): class TestDocSerialization: def test_doc_round_trip_keeps_counter(self, doc): - doc.variable_text.serial = SerialSettings(current=12, step=3) + doc.variable_text.counter = CounterSettings(current=12, step=3) restored = Doc.from_dict(doc.to_dict()) - assert restored.variable_text.serial == doc.variable_text.serial + assert restored.variable_text.counter == doc.variable_text.counter def test_old_documents_get_default_counter(self, doc): data = doc.to_dict() data.pop("variable_text", None) restored = Doc.from_dict(data) - assert restored.variable_text.serial == SerialSettings() + assert restored.variable_text.counter == CounterSettings() def test_restored_doc_refreshes_on_change(self, doc): restored = Doc.from_dict(doc.to_dict()) @@ -151,12 +151,12 @@ def on_changed(sender): received.append(sender) restored.variable_text.changed.connect(on_changed) - restored.variable_text.serial = SerialSettings(current=2) + restored.variable_text.counter = CounterSettings(current=2) assert received == [restored.variable_text] def test_copies_count_uses_highest_offset(self, doc): - place(doc, "{serial}", offset=0) - place(doc, "{serial}", offset=5) + place(doc, "{counter}", offset=0) + place(doc, "{counter}", offset=5) place(doc, "Hello", offset=9) assert doc.variable_text_copies() == 6 diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 6fc6ca3dc..49678a4a0 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -10,7 +10,7 @@ from rayforge.core.group import Group from rayforge.core.layer import Layer -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( ArrayMode, @@ -232,10 +232,10 @@ def test_compute_plan_is_pure(array_cmd, multilayer_selection): @pytest.mark.asyncio async def test_grid_array_numbers_copies(doc_editor, array_cmd): """Copies get increasing variable offsets in array order, so - serial numbers count up across the sheet.""" + counter values count up across the sheet.""" doc = doc_editor.doc - doc.variable_text.serial = SerialSettings(current=11) - wp = place(doc, "{serial}", offset=2) + doc.variable_text.counter = CounterSettings(current=11) + wp = place(doc, "{counter}", offset=2) params = ArrayParams( mode=ArrayMode.GRID, grid=GridArrayParams( @@ -251,14 +251,14 @@ async def test_grid_array_numbers_copies(doc_editor, array_cmd): last = created[-1] provider = last.get_geometry_provider() assert isinstance(provider, TemplateProvider) - assert last.template_values() == {"serial": 16} + assert last.template_values() == {"counter": 16} assert provider.rendered[-1] == "16" @pytest.mark.asyncio async def test_array_of_group_numbers_member_workpieces(doc_editor, array_cmd): doc = doc_editor.doc - wp = place(doc, "{serial}") + wp = place(doc, "{counter}") group = Group(name="plate") layer = doc.active_layer layer.remove_child(wp) diff --git a/tests/doceditor/test_variable_text_cmd.py b/tests/doceditor/test_variable_text_cmd.py index 3c05a23fb..eee423b42 100644 --- a/tests/doceditor/test_variable_text_cmd.py +++ b/tests/doceditor/test_variable_text_cmd.py @@ -1,6 +1,6 @@ import pytest -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.doceditor.variable_text_cmd import VariableTextCmd from tests.core.template_provider import place @@ -10,8 +10,8 @@ def cmd(doc_editor): return VariableTextCmd(doc_editor) -def _serial(doc_editor) -> SerialSettings: - return doc_editor.doc.variable_text.serial +def _counter(doc_editor) -> CounterSettings: + return doc_editor.doc.variable_text.counter @pytest.mark.asyncio @@ -20,71 +20,71 @@ async def test_editor_exposes_command(doc_editor): @pytest.mark.asyncio -async def test_set_serial_is_undoable(doc_editor, cmd): - cmd.set_serial(SerialSettings(current=5, step=2)) - assert _serial(doc_editor).current == 5 +async def test_set_counter_is_undoable(doc_editor, cmd): + cmd.set_counter(CounterSettings(current=5, step=2)) + assert _counter(doc_editor).current == 5 assert not doc_editor.is_saved doc_editor.history_manager.undo() - assert _serial(doc_editor) == SerialSettings() + assert _counter(doc_editor) == CounterSettings() @pytest.mark.asyncio async def test_next_previous_reset(doc_editor, cmd): - cmd.set_serial(SerialSettings(current=10, start=3, step=5)) - cmd.next_serial() - assert _serial(doc_editor).current == 15 - cmd.previous_serial() - cmd.previous_serial() - assert _serial(doc_editor).current == 5 - cmd.reset_serial() - assert _serial(doc_editor).current == 3 + cmd.set_counter(CounterSettings(current=10, start=3, step=5)) + cmd.next_counter() + assert _counter(doc_editor).current == 15 + cmd.previous_counter() + cmd.previous_counter() + assert _counter(doc_editor).current == 5 + cmd.reset_counter() + assert _counter(doc_editor).current == 3 @pytest.mark.asyncio async def test_advance_after_job_uses_copies(doc_editor, cmd): doc = doc_editor.doc - place(doc, "{serial}", offset=0) - place(doc, "{serial}", offset=3) - cmd.set_serial(SerialSettings(current=1, step=2)) + place(doc, "{counter}", offset=0) + place(doc, "{counter}", offset=3) + cmd.set_counter(CounterSettings(current=1, step=2)) assert cmd.advance_after_job() is True - assert _serial(doc_editor).current == 9 + assert _counter(doc_editor).current == 9 @pytest.mark.asyncio -async def test_advance_skipped_without_serial_users(doc_editor, cmd): +async def test_advance_skipped_without_counter_users(doc_editor, cmd): place(doc_editor.doc, "Hello") assert cmd.advance_after_job() is False - assert _serial(doc_editor).current == 1 + assert _counter(doc_editor).current == 1 @pytest.mark.asyncio async def test_advance_skipped_when_disabled(doc_editor, cmd): - place(doc_editor.doc, "{serial}") - cmd.set_serial(SerialSettings(auto_advance=False)) + place(doc_editor.doc, "{counter}") + cmd.set_counter(CounterSettings(auto_advance=False)) assert cmd.advance_after_job() is False - assert _serial(doc_editor).current == 1 + assert _counter(doc_editor).current == 1 @pytest.mark.asyncio async def test_advance_after_export_needs_its_own_switch(doc_editor, cmd): - place(doc_editor.doc, "{serial}") + place(doc_editor.doc, "{counter}") assert cmd.advance_after_job(exported=True) is False - cmd.set_serial(SerialSettings(advance_on_export=True)) + cmd.set_counter(CounterSettings(advance_on_export=True)) assert cmd.advance_after_job(exported=True) is True - assert _serial(doc_editor).current == 2 + assert _counter(doc_editor).current == 2 @pytest.mark.asyncio async def test_advance_is_undoable(doc_editor, cmd): - place(doc_editor.doc, "{serial}") + place(doc_editor.doc, "{counter}") cmd.advance_after_job() doc_editor.history_manager.undo() - assert _serial(doc_editor).current == 1 + assert _counter(doc_editor).current == 1 @pytest.mark.asyncio async def test_set_variable_offset_is_undoable(doc_editor, cmd): - wp = place(doc_editor.doc, "{serial}") + wp = place(doc_editor.doc, "{counter}") cmd.set_variable_offset([wp], 4) assert wp.variable_offset == 4 doc_editor.history_manager.undo() @@ -98,7 +98,7 @@ async def test_number_copies_in_reading_order(doc_editor, cmd): # Two rows of two; machine Y grows upwards, so the top row has # the larger Y and comes first. for x, y in [(30, 0), (0, 0), (30, 50), (0, 50)]: - wp = place(doc, "{serial}") + wp = place(doc, "{counter}") wp.pos = (x, y) wps.append(wp) cmd.number_copies(wps) @@ -109,12 +109,12 @@ async def test_number_copies_in_reading_order(doc_editor, cmd): @pytest.mark.asyncio async def test_preview_values(doc_editor, cmd): - place(doc_editor.doc, "{serial}", offset=2) - cmd.set_serial(SerialSettings(current=7)) - assert cmd.preview_serial_values() == [7, 8, 9] + place(doc_editor.doc, "{counter}", offset=2) + cmd.set_counter(CounterSettings(current=7)) + assert cmd.preview_counter_values() == [7, 8, 9] @pytest.mark.asyncio async def test_preview_without_copies_shows_current(doc_editor, cmd): - cmd.set_serial(SerialSettings(current=7)) - assert cmd.preview_serial_values() == [7] + cmd.set_counter(CounterSettings(current=7)) + assert cmd.preview_counter_values() == [7] diff --git a/tests/doceditor/test_variable_text_job.py b/tests/doceditor/test_variable_text_job.py index 61aefbdff..556cfd2db 100644 --- a/tests/doceditor/test_variable_text_job.py +++ b/tests/doceditor/test_variable_text_job.py @@ -1,5 +1,5 @@ """ -The serial counter advances after a finished job (and, if enabled, +The job counter advances after a finished job (and, if enabled, after a G-code export), but not after framing, pointer dry runs or a cancelled job. """ @@ -10,7 +10,7 @@ from raygeo.geo import Geometry from rayforge.context import get_context -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.machine.cmd import MachineCmd from tests.core.template_provider import place @@ -31,15 +31,15 @@ def _setup(doc_editor, contour_step_class, context, copies: int = 1): assert workflow is not None workflow.add_child(step) for offset in range(copies): - wp = place(doc_editor.doc, "{serial}", offset=offset) + wp = place(doc_editor.doc, "{counter}", offset=offset) wp._edited_boundaries = _rect() wp.set_size(10.0, 10.0) wp.pos = (offset * 20.0, 0.0) - doc_editor.doc.variable_text.serial = SerialSettings(current=5) + doc_editor.doc.variable_text.counter = CounterSettings(current=5) def _current(doc_editor) -> int: - return doc_editor.doc.variable_text.serial.current + return doc_editor.doc.variable_text.counter.current def _machine_cmd(doc_editor) -> MachineCmd: @@ -133,7 +133,7 @@ async def test_gcode_export_advances_when_enabled( context_initializer, doc_editor, contour_step_class, tmp_path ): _setup(doc_editor, contour_step_class, context_initializer) - doc_editor.doc.variable_text.serial = SerialSettings( + doc_editor.doc.variable_text.counter = CounterSettings( current=5, advance_on_export=True ) await doc_editor.wait_until_settled() diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py index d60a51f91..5b12f6e04 100644 --- a/tests/ui_gtk/doceditor/test_variable_text_dialog.py +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -7,7 +7,7 @@ import pytest from gi.repository import Adw, Gtk -from rayforge.core.variable_text import SerialSettings +from rayforge.core.variable_text import CounterSettings from rayforge.doceditor.editor import DocEditor from rayforge.ui_gtk.doceditor.property_providers.variable_text import ( VariableTextPropertyProvider, @@ -37,8 +37,8 @@ def dialog(editor): dlg.destroy() -def _serial(editor) -> SerialSettings: - return editor.doc.variable_text.serial +def _counter(editor) -> CounterSettings: + return editor.doc.variable_text.counter @pytest.mark.ui @@ -50,7 +50,7 @@ def test_format_preview(): @pytest.mark.ui def test_dialog_shows_current_settings(editor): - editor.doc.variable_text.serial = SerialSettings( + editor.doc.variable_text.counter = CounterSettings( current=42, start=10, step=2, auto_advance=False ) dlg = VariableTextDialog(Gtk.Window(), editor) @@ -65,38 +65,38 @@ def test_dialog_shows_current_settings(editor): def test_editing_current_value_is_undoable(editor, dialog): dialog.current_row.set_value(77) dialog.current_row.value_changed.send(dialog.current_row) - assert _serial(editor).current == 77 + assert _counter(editor).current == 77 editor.history_manager.undo() - assert _serial(editor).current == 1 + assert _counter(editor).current == 1 @pytest.mark.ui def test_end_value_only_applies_when_enabled(editor, dialog): dialog.end_row.set_value(9) dialog.end_row.value_changed.send(dialog.end_row) - assert _serial(editor).end is None + assert _counter(editor).end is None dialog.limit_row.set_active(True) - assert _serial(editor).end == 9 + assert _counter(editor).end == 9 dialog.limit_row.set_active(False) - assert _serial(editor).end is None + assert _counter(editor).end is None @pytest.mark.ui def test_next_previous_reset_buttons(editor, dialog): - place(editor.doc, "{serial}", offset=1) + place(editor.doc, "{counter}", offset=1) dialog.next_button.emit("clicked") - assert _serial(editor).current == 3 + assert _counter(editor).current == 3 dialog.previous_button.emit("clicked") - assert _serial(editor).current == 1 - editor.doc.variable_text.serial = SerialSettings(current=50, start=4) + assert _counter(editor).current == 1 + editor.doc.variable_text.counter = CounterSettings(current=50, start=4) dialog.reset_button.emit("clicked") - assert _serial(editor).current == 4 + assert _counter(editor).current == 4 @pytest.mark.ui def test_preview_follows_model(editor, dialog): - place(editor.doc, "{serial}", offset=2) - editor.doc.variable_text.serial = SerialSettings(current=5) + place(editor.doc, "{counter}", offset=2) + editor.doc.variable_text.counter = CounterSettings(current=5) assert dialog.preview_row.get_subtitle() == "5, 6, 7" assert dialog.current_row.get_int_value() == 5 @@ -105,13 +105,13 @@ def test_preview_follows_model(editor, dialog): def test_switches_update_model(editor, dialog): dialog.auto_advance_row.set_active(False) dialog.export_row.set_active(True) - assert _serial(editor).auto_advance is False - assert _serial(editor).advance_on_export is True + assert _counter(editor).auto_advance is False + assert _counter(editor).advance_on_export is True @pytest.mark.ui def test_property_provider_handles_template_workpieces(editor): - wp = place(editor.doc, "{serial:03d}") + wp = place(editor.doc, "{counter:03d}") plain = place(editor.doc, "Hello") provider = VariableTextPropertyProvider() assert provider.can_handle([wp]) @@ -121,8 +121,8 @@ def test_property_provider_handles_template_workpieces(editor): @pytest.mark.ui def test_property_provider_sets_offset(editor): - editor.doc.variable_text.serial = SerialSettings(current=10) - wp = place(editor.doc, "{serial}") + editor.doc.variable_text.counter = CounterSettings(current=10) + wp = place(editor.doc, "{counter}") provider = VariableTextPropertyProvider() provider.create_widgets() provider.update_widgets(editor, [wp]) diff --git a/website/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index a17feca4b..409dde9e7 100644 --- a/website/docs/features/sketcher/expressions.md +++ b/website/docs/features/sketcher/expressions.md @@ -117,7 +117,7 @@ registered for them (see [below](#custom-template-functions)). | `{uuid8()}` | `str` | Alias for `uuid4()` | | `{uuid()}` | `str` | Full UUID v4 string (36 chars) | -For serial numbers that advance after every job, use the project variable `{serial}` (see +For a job counter that advances after every job, use the project variable `{counter}` (see [Variable Text](../variable-text.md)). Typical uses include unique serial numbers per solve (`Part # {uuid4()}`), live dimension labels diff --git a/website/docs/features/variable-text.md b/website/docs/features/variable-text.md index f995eb3ce..5845b35b3 100644 --- a/website/docs/features/variable-text.md +++ b/website/docs/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Serial numbers in text templates: a project-level counter that advances after every job, with + "A job counter in text templates: one project-level number that advances after every job, with consecutive numbers for array copies." --- @@ -11,13 +11,13 @@ Text boxes resolve `{...}` templates (see the sketch parameters and the built-in functions, the project offers variables that change from job to job, so you can engrave numbered plates, tags and labels without editing the text each time. -## Serial numbers +## The job counter -Use `{serial}` in a text box. It is a whole number, so format specs control its appearance: +Use `{counter}` in a text box. It is a whole number, so format specs control its appearance: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, four digits with leading zeros -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, four digits with leading zeros +- `No. {counter:03d}` — `No. 007` Open **Tools → Variable Text…** to set up the counter: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index c3ac3f6b1..7306ec6b4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -122,7 +122,7 @@ Funktionen bereit, und es können benutzerdefinierte Funktionen für sie registr | `{uuid8()}` | `str` | Alias für `uuid4()` | | `{uuid()}` | `str` | Vollständiger UUID v4-String (36 Zeichen) | -Für Seriennummern, die nach jedem Auftrag weiterzählen, verwende die Projektvariable `{serial}` +Für einen Auftragszähler, der nach jedem Auftrag weiterzählt, verwende die Projektvariable `{counter}` (siehe [Variabler Text](../variable-text.md)). Typische Anwendungen sind eindeutige Seriennummern pro Lösung (`Part # {uuid4()}`), live diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md index ddfc328c7..c3c564056 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Seriennummern in Textvorlagen: ein Projektzähler, der nach jedem Auftrag weiterzählt, mit + "Ein Auftragszähler in Textvorlagen: eine Zahl, der nach jedem Auftrag weiterzählt, mit fortlaufenden Nummern für Array-Kopien." --- @@ -14,12 +14,12 @@ Text jedes Mal zu bearbeiten. ## Seriennummern -Verwende `{serial}` in einem Textfeld. Es ist eine ganze Zahl, daher bestimmen Formatangaben ihr +Verwende `{counter}` in einem Textfeld. Es ist eine ganze Zahl, daher bestimmen Formatangaben ihr Aussehen: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, vier Stellen mit führenden Nullen -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, vier Stellen mit führenden Nullen +- `No. {counter:03d}` — `No. 007` Öffne **Werkzeuge → Variabler Text…**, um den Zähler einzurichten: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 29da385df..646dfb16e 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -124,7 +124,7 @@ funciones integradas de abajo, y se pueden registrar funciones personalizadas pa | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | Cadena UUID v4 completa (36 caracteres) | -Para números de serie que avanzan tras cada trabajo, usa la variable del proyecto `{serial}` +Para un contador de trabajos que avanza tras cada trabajo, usa la variable del proyecto `{counter}` (consulta [Texto variable](../variable-text.md)). Entre los usos típicos se incluyen números de serie únicos en cada resolución (`Pieza #{uuid4()}`), diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md index 3c8d9f2f1..dbdf05d44 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Números de serie en plantillas de texto: un contador del proyecto que avanza tras cada trabajo, + "Un contador de trabajos en plantillas de texto: un número del proyecto que avanza tras cada trabajo, con números consecutivos para las copias de una matriz." --- @@ -14,12 +14,12 @@ cada vez. ## Números de serie -Usa `{serial}` en un cuadro de texto. Es un número entero, así que los especificadores de formato +Usa `{counter}` en un cuadro de texto. Es un número entero, así que los especificadores de formato controlan su aspecto: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, cuatro dígitos con ceros a la izquierda -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, cuatro dígitos con ceros a la izquierda +- `No. {counter:03d}` — `No. 007` Abre **Herramientas → Texto variable…** para configurar el contador: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index b86736616..a26c57cd5 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -124,8 +124,8 @@ fonctions intégrées ci-dessous, et des fonctions personnalisées peuvent leur | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | Chaîne UUID v4 complète (36 caractères) | -Pour des numéros de série qui avancent après chaque travail, utilisez la variable de projet -`{serial}` (voir [Texte variable](../variable-text.md)). +Pour un compteur de tâches qui avance après chaque travail, utilisez la variable de projet +`{counter}` (voir [Texte variable](../variable-text.md)). Les utilisations typiques comprennent les numéros de série uniques à chaque résolution (`Pièce #{uuid4()}`), les étiquettes de dimensions dynamiques (`W={width:.1f} H={height:.1f}`), les diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md index 7d3a841ec..bb8fcbbf4 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Numéros de série dans les modèles de texte : un compteur de projet qui avance après chaque + "Un compteur de tâches dans les modèles de texte : un nombre de projet qui avance après chaque travail, avec des numéros consécutifs pour les copies d'un réseau." --- @@ -14,12 +14,12 @@ modifier le texte à chaque fois. ## Numéros de série -Utilisez `{serial}` dans une zone de texte. C'est un nombre entier, donc les spécifications de +Utilisez `{counter}` dans une zone de texte. C'est un nombre entier, donc les spécifications de format contrôlent son apparence : -- `{serial}` — `7` -- `{serial:04d}` — `0007`, quatre chiffres avec des zéros en tête -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, quatre chiffres avec des zéros en tête +- `No. {counter:03d}` — `No. 007` Ouvrez **Outils → Texte variable…** pour configurer le compteur : diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 0e93e13fc..34d459e95 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -116,7 +116,7 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` का उपनाम | | `{uuid()}` | `str` | पूर्ण UUID v4 स्ट्रिंग (36 वर्ण) | -हर जॉब के बाद आगे बढ़ने वाले सीरियल नंबर के लिए प्रोजेक्ट वेरिएबल `{serial}` का उपयोग करें +हर जॉब के बाद आगे बढ़ने वाले जॉब काउंटर के लिए प्रोजेक्ट वेरिएबल `{counter}` का उपयोग करें ([वेरिएबल टेक्स्ट](../variable-text.md) देखें)। सामान्य उपयोग में प्रति हल अद्वितीय सीरियल नंबर (`Part # {uuid4()}`), लाइव विमा लेबल diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md index e3a9ea308..4870cde3d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "टेक्स्ट टेम्पलेट में सीरियल नंबर: एक प्रोजेक्ट काउंटर जो हर जॉब के बाद आगे बढ़ता है, और ऐरे + "टेक्स्ट टेम्पलेट में जॉब काउंटर: एक प्रोजेक्ट संख्या जो हर जॉब के बाद आगे बढ़ती है, और ऐरे कॉपियों के लिए क्रमागत नंबर।" --- @@ -13,12 +13,12 @@ description: ## सीरियल नंबर -टेक्स्ट बॉक्स में `{serial}` का उपयोग करें। यह एक पूर्णांक है, इसलिए फ़ॉर्मेट स्पेक इसकी दिखावट तय +टेक्स्ट बॉक्स में `{counter}` का उपयोग करें। यह एक पूर्णांक है, इसलिए फ़ॉर्मेट स्पेक इसकी दिखावट तय करते हैं: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, आगे शून्य के साथ चार अंक -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, आगे शून्य के साथ चार अंक +- `No. {counter:03d}` — `No. 007` काउंटर सेट करने के लिए **टूल्स → वेरिएबल टेक्स्ट…** खोलें: diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index c94327b46..20d296364 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -121,7 +121,7 @@ integradas abaixo, e funções personalizadas podem ser registradas para eles (v | `{uuid8()}` | `str` | Alias de `uuid4()` | | `{uuid()}` | `str` | String UUID v4 completa (36 caracteres) | -Para números de série que avançam após cada trabalho, use a variável do projeto `{serial}` (veja +Para um contador de trabalhos que avança após cada trabalho, use a variável do projeto `{counter}` (veja [Texto variável](../variable-text.md)). Usos típicos incluem números de série únicos a cada resolução (`Peça #{uuid4()}`), rótulos de diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md index 883c8d7d1..864f262f1 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Números de série em modelos de texto: um contador do projeto que avança após cada trabalho, com + "Um contador de trabalhos em modelos de texto: um número do projeto que avança após cada trabalho, com números consecutivos para as cópias de uma matriz." --- @@ -13,12 +13,12 @@ para outro, para você gravar placas, etiquetas e rótulos numerados sem editar ## Números de série -Use `{serial}` em uma caixa de texto. É um número inteiro, então os especificadores de formato +Use `{counter}` em uma caixa de texto. É um número inteiro, então os especificadores de formato controlam sua aparência: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, quatro dígitos com zeros à esquerda -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, quatro dígitos com zeros à esquerda +- `No. {counter:03d}` — `No. 007` Abra **Ferramentas → Texto variável…** para configurar o contador: diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 48db174a0..9615f6a79 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -118,8 +118,8 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | Псевдонім для `uuid4()` | | `{uuid()}` | `str` | Повний рядок UUID v4 (36 символів) | -Для серійних номерів, що збільшуються після кожного завдання, використовуйте змінну проєкту -`{serial}` (див. [Змінний текст](../variable-text.md)). +Для лічильника завдань, що збільшується після кожного завдання, використовуйте змінну проєкту +`{counter}` (див. [Змінний текст](../variable-text.md)). Типові застосування включають унікальні серійні номери при кожному розв'язанні (`Деталь #{uuid4()}`), живі мітки розмірів (`Ш={width:.1f} В={height:.1f}`), датування кожної деталі diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md index 5265353fc..8f1a1e7c1 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,6 +1,6 @@ --- description: - "Серійні номери в текстових шаблонах: лічильник проєкту, що збільшується після кожного завдання, з + "Лічильник завдань у текстових шаблонах: число проєкту, що збільшується після кожного завдання, з послідовними номерами для копій масиву." --- @@ -13,12 +13,12 @@ description: ## Серійні номери -Використовуйте `{serial}` у текстовому полі. Це ціле число, тож специфікатори формату визначають +Використовуйте `{counter}` у текстовому полі. Це ціле число, тож специфікатори формату визначають його вигляд: -- `{serial}` — `7` -- `{serial:04d}` — `0007`, чотири цифри з провідними нулями -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`, чотири цифри з провідними нулями +- `No. {counter:03d}` — `No. 007` Відкрийте **Інструменти → Змінний текст…**, щоб налаштувати лічильник: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 7bc97fc6b..b7a480554 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -97,7 +97,7 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` 的别名 | | `{uuid()}` | `str` | 完整 UUID v4 字符串(36 字符) | -如需每次任务后自动递增的序列号,请使用项目变量 `{serial}`(参见[可变文本](../variable-text.md))。 +如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[可变文本](../variable-text.md))。 典型用途包括每次求解生成唯一序列号(`零件 #{uuid4()}`)、实时尺寸标签(`宽={width:.1f} 高={height:.1f}`)、日期戳(`日期:{today()}`)、生产计数器(`{name} - {count:.0f}个`),或用于生产日志的 Unix 时间戳(`{timestamp():.0f}`)。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md index 83f660b88..63664981e 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -1,5 +1,5 @@ --- -description: "文本模板中的序列号:每次任务后自动递增的项目计数器,并为阵列副本生成连续编号。" +description: "文本模板中的任务计数器:每次任务后自动递增的项目数值,并为阵列副本生成连续编号。" --- # 可变文本 @@ -9,11 +9,11 @@ description: "文本模板中的序列号:每次任务后自动递增的项目 ## 序列号 -在文本框中使用 `{serial}`。它是一个整数,因此可以用格式说明符控制其外观: +在文本框中使用 `{counter}`。它是一个整数,因此可以用格式说明符控制其外观: -- `{serial}` — `7` -- `{serial:04d}` — `0007`,四位数并补前导零 -- `No. {serial:03d}` — `No. 007` +- `{counter}` — `7` +- `{counter:04d}` — `0007`,四位数并补前导零 +- `No. {counter:03d}` — `No. 007` 打开**工具 → 可变文本…**来设置计数器: From 2a9d9009f985c54545deba929dd4fcea47536215 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 18 Oct 2026 19:22:42 +0200 Subject: [PATCH 13/33] refactor: name the concept job counter everywhere, down to Doc Doc now carries _job_counter (class JobCounter in core/job_counter.py) instead of the surviving _variable_text, with the serialized key, the commands, the dialog, the property provider and the win.job-counter action renamed to match. Docs headings, the Tools menu entry, the property-panel group and the seven translations follow. The docs URL slug stays features/variable-text for link stability. Reported in #554: some of the previous naming had survived the first counter rework. --- CHANGELOG.md | 4 +- ...ble_text.py => test_sketch_job_counter.py} | 10 ++--- rayforge/core/doc.py | 34 +++++++-------- .../core/{variable_text.py => job_counter.py} | 6 +-- rayforge/core/workpiece.py | 8 ++-- rayforge/doceditor/editor.py | 4 +- ...ariable_text_cmd.py => job_counter_cmd.py} | 24 +++++------ rayforge/ui_gtk/actions.py | 12 +++--- ...e_text_dialog.py => job_counter_dialog.py} | 26 ++++++------ .../doceditor/property_providers/__init__.py | 6 +-- .../{variable_text.py => job_counter.py} | 14 +++---- rayforge/ui_gtk/main_menu.py | 6 +-- rayforge/ui_gtk/mainwindow.py | 2 +- ...t_variable_text.py => test_job_counter.py} | 20 ++++----- ..._text.py => test_workpiece_job_counter.py} | 42 +++++++++---------- tests/doceditor/test_array_cmd.py | 4 +- ...le_text_cmd.py => test_job_counter_cmd.py} | 10 ++--- ...le_text_job.py => test_job_counter_job.py} | 10 ++--- ...t_dialog.py => test_job_counter_dialog.py} | 28 ++++++------- website/docs/features/sketcher/expressions.md | 2 +- website/docs/features/variable-text.md | 6 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 6 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 7 ++-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 7 ++-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 6 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 6 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 6 +-- .../current/features/sketcher/expressions.md | 2 +- .../current/features/variable-text.md | 6 +-- 35 files changed, 167 insertions(+), 169 deletions(-) rename rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/{test_sketch_variable_text.py => test_sketch_job_counter.py} (88%) rename rayforge/core/{variable_text.py => job_counter.py} (97%) rename rayforge/doceditor/{variable_text_cmd.py => job_counter_cmd.py} (88%) rename rayforge/ui_gtk/doceditor/{variable_text_dialog.py => job_counter_dialog.py} (91%) rename rayforge/ui_gtk/doceditor/property_providers/{variable_text.py => job_counter.py} (87%) rename tests/core/{test_variable_text.py => test_job_counter.py} (91%) rename tests/core/{test_workpiece_variable_text.py => test_workpiece_job_counter.py} (78%) rename tests/doceditor/{test_variable_text_cmd.py => test_job_counter_cmd.py} (92%) rename tests/doceditor/{test_variable_text_job.py => test_job_counter_job.py} (94%) rename tests/ui_gtk/doceditor/{test_variable_text_dialog.py => test_job_counter_dialog.py} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f20873f2..23b670270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,9 +99,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden -- Variable Text: a `{counter}` variable for text templates, with a +^- Job Counter: a `{counter}` variable for text templates, with a project counter that advances after every finished job and consecutive - numbers for array copies (Tools > Variable Text) (#527) + numbers for array copies (Tools > Job Counter) (#527) ### Changed diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py similarity index 88% rename from rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py rename to rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index 3af7872a4..d07b1a3ea 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -5,7 +5,7 @@ from sketcher.core.entities.text_box import TextBoxEntity from rayforge.core.doc import Doc -from rayforge.core.variable_text import CounterSettings +from rayforge.core.job_counter import CounterSettings from rayforge.core.workpiece import WorkPiece @@ -40,7 +40,7 @@ def test_template_sources_lists_text_box_contents(): def test_counter_is_resolved_in_document(): doc = Doc() - doc.variable_text.counter = CounterSettings(current=7) + doc.job_counter.counter = CounterSettings(current=7) sketch, box = _sketch_with_text("No. {counter:03d}") wp = _place(doc, sketch) assert _resolved(wp, box) == "No. 007" @@ -48,7 +48,7 @@ def test_counter_is_resolved_in_document(): def test_copies_show_consecutive_numbers(): doc = Doc() - doc.variable_text.counter = CounterSettings(current=41) + doc.job_counter.counter = CounterSettings(current=41) sketch, box = _sketch_with_text("{counter}") first = _place(doc, sketch) second = first.duplicate() @@ -63,13 +63,13 @@ def test_counter_change_updates_text(): doc = Doc() sketch, box = _sketch_with_text("{counter:04d}") wp = _place(doc, sketch) - doc.variable_text.counter = CounterSettings(current=12) + doc.job_counter.counter = CounterSettings(current=12) assert _resolved(wp, box) == "0012" def test_saved_text_is_kept_on_load(): doc = Doc() - doc.variable_text.counter = CounterSettings(current=5) + doc.job_counter.counter = CounterSettings(current=5) sketch, box = _sketch_with_text("{counter}-{uuid8()}") wp = _place(doc, sketch) before = _resolved(wp, box) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 2758812f3..bb8e6f47c 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -17,7 +17,7 @@ from .item import DocItem from .layer import Layer from .source_asset import SourceAsset -from .variable_text import VariableText +from .job_counter import JobCounter from .workpiece import WorkPiece, workpieces_in if TYPE_CHECKING: @@ -47,8 +47,8 @@ def __init__(self): self.asset_order: list[str] = [] # Variables for text templates (counter value, ...) - self._variable_text = VariableText() - self._variable_text.changed.connect(self._on_variable_text_changed) + self._job_counter = JobCounter() + self._job_counter.changed.connect(self._on_job_counter_changed) self.descendant_added.connect(self._on_item_added_for_templates) # A new document starts with three empty workpiece layers @@ -96,7 +96,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc = cls() doc.uid = data.get("uid", doc.uid) - doc.variable_text = VariableText.from_dict(data.get("variable_text")) + doc.job_counter = JobCounter.from_dict(data.get("job_counter")) # Clear the default layer created by __init__ doc.set_children([]) @@ -180,31 +180,31 @@ 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()], - "variable_text": self._variable_text.to_dict(), + "job_counter": self._job_counter.to_dict(), } @property - def variable_text(self) -> VariableText: + def job_counter(self) -> JobCounter: """The variables offered to text templates.""" - return self._variable_text + return self._job_counter - @variable_text.setter - def variable_text(self, value: VariableText) -> None: - self._variable_text.changed.disconnect(self._on_variable_text_changed) - self._variable_text = value - value.changed.connect(self._on_variable_text_changed) - self._on_variable_text_changed(value) + @job_counter.setter + def job_counter(self, value: JobCounter) -> None: + self._job_counter.changed.disconnect(self._on_job_counter_changed) + self._job_counter = value + value.changed.connect(self._on_job_counter_changed) + self._on_job_counter_changed(value) def _template_workpieces(self) -> list[WorkPiece]: """Workpieces whose templates read a document variable.""" - names = self._variable_text.names + names = self._job_counter.names return [ wp for wp in self.all_workpieces if wp.references_template_names(names) ] - def variable_text_copies(self) -> int: + def job_counter_copies(self) -> int: """ How many consecutive variable values one job uses: the highest variable offset of a template workpiece plus one, or 0 if no @@ -215,7 +215,7 @@ def variable_text_copies(self) -> int: return 0 return max(offsets) + 1 - def _on_variable_text_changed(self, sender: VariableText) -> None: + def _on_job_counter_changed(self, sender: JobCounter) -> None: for wp in self._template_workpieces(): wp.refresh_template_values() @@ -223,7 +223,7 @@ def _on_item_added_for_templates( self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem ) -> None: for wp in workpieces_in(origin): - if wp.references_template_names(self._variable_text.names): + if wp.references_template_names(self._job_counter.names): wp.sync_template_values() def add_asset( diff --git a/rayforge/core/variable_text.py b/rayforge/core/job_counter.py similarity index 97% rename from rayforge/core/variable_text.py rename to rayforge/core/job_counter.py index 89685f671..53b8886f5 100644 --- a/rayforge/core/variable_text.py +++ b/rayforge/core/job_counter.py @@ -1,7 +1,7 @@ """ Document-level variables for text templates. -A document carries a :class:`VariableText` instance whose values are +A document carries a :class:`JobCounter` instance whose values are injected into the ``{...}`` templates of every geometry provider (for example the text boxes of a sketch). Each workpiece adds its own *variable offset*, so copies of the same design can show consecutive @@ -105,7 +105,7 @@ def from_dict(cls, data: dict[str, Any]) -> CounterSettings: ) -class VariableText: +class JobCounter: """ The variables a document offers to text templates. @@ -141,7 +141,7 @@ def to_dict(self) -> dict[str, Any]: return {"counter": self._counter.to_dict()} @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> VariableText: + def from_dict(cls, data: dict[str, Any] | None) -> JobCounter: instance = cls() if data: counter = data.get("counter") diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index bd8638234..4aa433e57 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -34,7 +34,7 @@ from .item import DocItem from .source_asset_segment import SourceAssetSegment from .tab import Tab -from .variable_text import referenced_template_names +from .job_counter import referenced_template_names from .vectorization_spec import TraceSpec if TYPE_CHECKING: @@ -1155,7 +1155,7 @@ def template_values(self) -> dict[str, Any]: doc = self.doc if doc is None: return {} - return doc.variable_text.template_values(self._variable_offset) + return doc.job_counter.template_values(self._variable_offset) def _provider_params(self) -> dict[str, Any]: """ @@ -1173,7 +1173,7 @@ def _world_provider_params(self) -> dict[str, Any]: """ doc = self.doc if doc is not None and self.references_template_names( - doc.variable_text.names + doc.job_counter.names ): return self._provider_params() return dict(self._geometry_provider_params or {}) @@ -1204,7 +1204,7 @@ def refresh_template_values(self) -> None: """ doc = self.doc if doc is None or not self.references_template_names( - doc.variable_text.names + doc.job_counter.names ): return old_w, old_h = self.natural_width_mm, self.natural_height_mm diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 3f2a16b90..0ee41649e 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -33,7 +33,7 @@ from .stock_cmd import StockCmd from .tab_cmd import TabCmd from .transform_cmd import TransformCmd -from .variable_text_cmd import VariableTextCmd +from .job_counter_cmd import JobCounterCmd if TYPE_CHECKING: from ..context import RayforgeContext @@ -161,7 +161,7 @@ def __init__( self.step = StepCmd(self) self.tab = TabCmd(self) self.transform = TransformCmd(self) - self.variable_text = VariableTextCmd(self) + self.job_counter = JobCounterCmd(self) # Instantiate addon-registered commands. for name, cmd_class in command_registry.all_commands().items(): diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/job_counter_cmd.py similarity index 88% rename from rayforge/doceditor/variable_text_cmd.py rename to rayforge/doceditor/job_counter_cmd.py index 9bd5e7163..46d246181 100644 --- a/rayforge/doceditor/variable_text_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -1,5 +1,5 @@ """ -VariableTextCmd: undoable edits of the document's template variables +JobCounterCmd: undoable edits of the document's template variables (job counter) and of the per-workpiece variable offsets. """ @@ -12,7 +12,7 @@ from ..core.item import DocItem from ..core.undo import ChangePropertyCommand -from ..core.variable_text import CounterSettings +from ..core.job_counter import CounterSettings from ..core.workpiece import WorkPiece, workpieces_in from .layout.base import LayoutStrategy @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -class VariableTextCmd: +class JobCounterCmd: """Handles changes to the document's variable text settings.""" def __init__(self, editor: DocEditor): @@ -31,17 +31,17 @@ def __init__(self, editor: DocEditor): @property def counter(self) -> CounterSettings: - return self._editor.doc.variable_text.counter + return self._editor.doc.job_counter.counter def set_counter( self, settings: CounterSettings, name: str | None = None ) -> None: """Replaces the job counter settings in one undoable step.""" - variable_text = self._editor.doc.variable_text - if settings == variable_text.counter: + job_counter = self._editor.doc.job_counter + if settings == job_counter.counter: return cmd = ChangePropertyCommand( - target=variable_text, + target=job_counter, property_name="counter", new_value=settings, name=name or _("Change counter value"), @@ -50,12 +50,12 @@ def set_counter( def next_counter(self) -> None: """Moves the counter forward by one job.""" - copies = max(self._editor.doc.variable_text_copies(), 1) + copies = max(self._editor.doc.job_counter_copies(), 1) self.set_counter(self.counter.advanced(copies), _("Next counter value")) def previous_counter(self) -> None: """Moves the counter back by one job.""" - copies = max(self._editor.doc.variable_text_copies(), 1) + copies = max(self._editor.doc.job_counter_copies(), 1) self.set_counter( self.counter.advanced(-copies), _("Previous counter value") ) @@ -66,7 +66,7 @@ def reset_counter(self) -> None: def preview_counter_values(self) -> list[int]: """The counter values the next job uses, one per copy.""" - copies = max(self._editor.doc.variable_text_copies(), 1) + copies = max(self._editor.doc.job_counter_copies(), 1) return self.counter.values_for(copies) def advance_after_job(self, exported: bool = False) -> bool: @@ -79,7 +79,7 @@ def advance_after_job(self, exported: bool = False) -> bool: """ counter = self.counter enabled = counter.advance_on_export if exported else counter.auto_advance - copies = self._editor.doc.variable_text_copies() + copies = self._editor.doc.job_counter_copies() if not enabled or copies == 0: return False self.set_counter( @@ -87,7 +87,7 @@ def advance_after_job(self, exported: bool = False) -> bool: ) logger.info( f"Job counter advanced by {copies} to " - f"{self._editor.doc.variable_text.counter.current}" + f"{self._editor.doc.job_counter.counter.current}" ) return True diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 537c594f1..3c131af0a 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -24,7 +24,7 @@ from .doceditor.add_tabs_popover import AddTabsPopover from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog -from .doceditor.variable_text_dialog import VariableTextDialog +from .doceditor.job_counter_dialog import JobCounterDialog from .shared.keyboard import PRIMARY_ACCEL if TYPE_CHECKING: @@ -479,7 +479,7 @@ def register_actions(self): self._add_action("array-circular", self.on_array_circular) # Variable text (counter values) - self._add_action("variable-text", self.on_variable_text) + self._add_action("job-counter", self.on_job_counter) self._add_action("number-copies", self.on_number_copies) # Macro Actions @@ -1050,9 +1050,9 @@ def on_array_circular(self, action, param): dialog.""" self._open_array_dialog(ArrayMode.CIRCULAR) - def on_variable_text(self, action, param): - """Handler for 'variable-text': opens the job counter dialog.""" - dialog = VariableTextDialog(self.win, self.editor) + def on_job_counter(self, action, param): + """Handler for 'job-counter': opens the job counter dialog.""" + dialog = JobCounterDialog(self.win, self.editor) dialog.present() def on_number_copies(self, action, param): @@ -1060,7 +1060,7 @@ def on_number_copies(self, action, param): reading order, so their counter values count up.""" items = list(self.win.surface.get_selected_items()) if items: - self.editor.variable_text.number_copies(items) + self.editor.job_counter.number_copies(items) def _open_array_dialog(self, mode: ArrayMode) -> None: items = list(self.win.surface.get_selected_items()) diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/job_counter_dialog.py similarity index 91% rename from rayforge/ui_gtk/doceditor/variable_text_dialog.py rename to rayforge/ui_gtk/doceditor/job_counter_dialog.py index c7242d155..fe76f5a14 100644 --- a/rayforge/ui_gtk/doceditor/variable_text_dialog.py +++ b/rayforge/ui_gtk/doceditor/job_counter_dialog.py @@ -4,7 +4,7 @@ from gi.repository import Adw, Gtk -from ...core.variable_text import CounterSettings, VariableText +from ...core.job_counter import CounterSettings, JobCounter from ..shared.patched_dialog_window import PatchedDialogWindow from ..shared.pref_rows import SpinRow @@ -30,7 +30,7 @@ def format_preview(values: list[int]) -> str: ) -class VariableTextDialog(PatchedDialogWindow): +class JobCounterDialog(PatchedDialogWindow): """ A non-modal window for the document's counter value: its settings, Previous/Next/Reset buttons and a preview of the next job. @@ -42,7 +42,7 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): self._doc: Doc = editor.doc self._in_update = False - self.set_title(_("Variable Text")) + self.set_title(_("Job Counter")) self.set_default_size(DEFAULT_WIDTH, -1) self.set_modal(False) @@ -63,7 +63,7 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): main_view.set_content(scrolled) self.set_content(main_view) - self._doc.variable_text.changed.connect(self._on_model_changed) + self._doc.job_counter.changed.connect(self._on_model_changed) self._doc.descendant_updated.connect(self._on_doc_items_changed) self._doc.descendant_added.connect(self._on_doc_items_changed) self._doc.descendant_removed.connect(self._on_doc_items_changed) @@ -181,7 +181,7 @@ def _settings_from_widgets(self) -> CounterSettings: def _commit(self) -> None: if self._in_update: return - self.editor.variable_text.set_counter(self._settings_from_widgets()) + self.editor.job_counter.set_counter(self._settings_from_widgets()) def _on_value_row_changed(self, row: SpinRow) -> None: self._commit() @@ -191,22 +191,22 @@ def _on_switch_changed(self, row: Adw.SwitchRow, _pspec) -> None: self._commit() def _on_previous_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.previous_counter() + self.editor.job_counter.previous_counter() def _on_next_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.next_counter() + self.editor.job_counter.next_counter() def _on_reset_clicked(self, button: Gtk.Button) -> None: - self.editor.variable_text.reset_counter() + self.editor.job_counter.reset_counter() - def _on_model_changed(self, sender: VariableText) -> None: + def _on_model_changed(self, sender: JobCounter) -> None: self._update_from_model() def _on_doc_items_changed(self, sender, **kwargs) -> None: self._update_preview() def _update_from_model(self) -> None: - counter = self._doc.variable_text.counter + counter = self._doc.job_counter.counter self._in_update = True try: self.current_row.set_value(counter.current) @@ -225,12 +225,12 @@ def _update_from_model(self) -> None: self._update_preview() def _update_preview(self) -> None: - values = self.editor.variable_text.preview_counter_values() + values = self.editor.job_counter.preview_counter_values() self.preview_row.set_subtitle(format_preview(values)) def _on_destroy(self, widget) -> None: - variable_text = self._doc.variable_text - variable_text.changed.disconnect(self._on_model_changed) + job_counter = self._doc.job_counter + job_counter.changed.disconnect(self._on_model_changed) self._doc.descendant_updated.disconnect(self._on_doc_items_changed) self._doc.descendant_added.disconnect(self._on_doc_items_changed) self._doc.descendant_removed.disconnect(self._on_doc_items_changed) diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py index ddb1827b0..ea2683212 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/__init__.py +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -1,6 +1,6 @@ from .base import PropertyProvider, property_provider_registry from .transform import TransformPropertyProvider -from .variable_text import VariableTextPropertyProvider +from .job_counter import JobCounterPropertyProvider from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider @@ -9,14 +9,14 @@ def register_builtin_providers(): property_provider_registry.register(TransformPropertyProvider, "") property_provider_registry.register(WorkpieceInfoProvider, "") property_provider_registry.register(TabsPropertyProvider, "") - property_provider_registry.register(VariableTextPropertyProvider, "") + property_provider_registry.register(JobCounterPropertyProvider, "") __all__ = [ "PropertyProvider", "TabsPropertyProvider", "TransformPropertyProvider", - "VariableTextPropertyProvider", + "JobCounterPropertyProvider", "WorkpieceInfoProvider", "property_provider_registry", "register_builtin_providers", diff --git a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py similarity index 87% rename from rayforge/ui_gtk/doceditor/property_providers/variable_text.py rename to rayforge/ui_gtk/doceditor/property_providers/job_counter.py index 6c0f54e86..22b0ab445 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py +++ b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py @@ -5,7 +5,7 @@ from gi.repository import Gtk from ....core.item import DocItem -from ....core.variable_text import COUNTER_NAME +from ....core.job_counter import COUNTER_NAME from ....core.workpiece import WorkPiece from ...icons import get_icon from ...shared.pref_rows import SpinRow @@ -22,11 +22,11 @@ def _uses_variables(item: DocItem) -> bool: return ( isinstance(item, WorkPiece) and doc is not None - and item.references_template_names(doc.variable_text.names) + and item.references_template_names(doc.job_counter.names) ) -class VariableTextPropertyProvider(PropertyProvider): +class JobCounterPropertyProvider(PropertyProvider): """ Shows the variable offset of workpieces whose text templates use a document variable such as ``{counter}``. @@ -34,7 +34,7 @@ class VariableTextPropertyProvider(PropertyProvider): priority = 30 separate_group = True - group_title = _("Variable Text") + group_title = _("Job Counter") def can_handle(self, items: list[DocItem]) -> bool: return bool(items) and all(_uses_variables(i) for i in items) @@ -57,8 +57,8 @@ def create_widgets(self) -> list[Gtk.Widget]: self.settings_button = Gtk.Button( child=get_icon("settings-symbolic"), valign=Gtk.Align.CENTER, - tooltip_text=_("Variable Text Settings"), - action_name="win.variable-text", + tooltip_text=_("Job Counter Settings"), + action_name="win.job-counter", ) self.offset_row.add_suffix(self.settings_button) self._rows = [self.offset_row] @@ -89,6 +89,6 @@ def _on_offset_changed(self, row: SpinRow) -> None: if self._in_update: return workpieces = [i for i in self.items if isinstance(i, WorkPiece)] - self.editor.variable_text.set_variable_offset( + self.editor.job_counter.set_variable_offset( workpieces, row.get_int_value() ) diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index 5d501d2d7..8ee8f406f 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -183,9 +183,9 @@ def __init__(self): # Tools Menu tools_menu = Gio.Menu() - variable_text_group = Gio.Menu() - variable_text_group.append(_("Variable Text…"), "win.variable-text") - tools_menu.append_section(None, variable_text_group) + job_counter_group = Gio.Menu() + job_counter_group.append(_("Job Counter…"), "win.job-counter") + tools_menu.append_section(None, job_counter_group) # Addon section for Tools menu self._addon_sections["tools"] = Gio.Menu() diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index 376b15642..46af6d063 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -182,7 +182,7 @@ def __init__(self, **kwargs): self.machine_cmd = MachineCmd(self.doc_editor) self.machine_cmd.job_started.connect(self._on_job_started) self.machine_cmd.job_completed.connect( - self.doc_editor.variable_text.on_job_completed + self.doc_editor.job_counter.on_job_completed ) # Instantiate and connect the UpdateCommand's notification signal diff --git a/tests/core/test_variable_text.py b/tests/core/test_job_counter.py similarity index 91% rename from tests/core/test_variable_text.py rename to tests/core/test_job_counter.py index 168009bf2..a141f2a9b 100644 --- a/tests/core/test_variable_text.py +++ b/tests/core/test_job_counter.py @@ -1,6 +1,6 @@ -from rayforge.core.variable_text import ( +from rayforge.core.job_counter import ( CounterSettings, - VariableText, + JobCounter, referenced_template_names, ) @@ -75,15 +75,15 @@ def test_from_dict_rejects_end_below_start(self): assert s.end is None -class TestVariableText: +class TestJobCounter: def test_template_values_with_offset(self): - vt = VariableText() + vt = JobCounter() vt.counter = CounterSettings(current=100, step=10) assert vt.template_values(0) == {"counter": 100} assert vt.template_values(2) == {"counter": 120} def test_setting_counter_emits_changed(self): - vt = VariableText() + vt = JobCounter() received = [] def on_changed(sender): @@ -94,7 +94,7 @@ def on_changed(sender): assert received == [vt] def test_setting_equal_counter_is_silent(self): - vt = VariableText() + vt = JobCounter() received = [] def on_changed(sender): @@ -105,16 +105,16 @@ def on_changed(sender): assert received == [] def test_names(self): - assert "counter" in VariableText().names + assert "counter" in JobCounter().names def test_round_trip(self): - vt = VariableText() + vt = JobCounter() vt.counter = CounterSettings(current=3, start=2, step=4) - restored = VariableText.from_dict(vt.to_dict()) + restored = JobCounter.from_dict(vt.to_dict()) assert restored.counter == vt.counter def test_from_dict_none(self): - assert VariableText.from_dict(None).counter == CounterSettings() + assert JobCounter.from_dict(None).counter == CounterSettings() class TestReferencedTemplateNames: diff --git a/tests/core/test_workpiece_variable_text.py b/tests/core/test_workpiece_job_counter.py similarity index 78% rename from tests/core/test_workpiece_variable_text.py rename to tests/core/test_workpiece_job_counter.py index 1bde26f71..c59de42fd 100644 --- a/tests/core/test_workpiece_variable_text.py +++ b/tests/core/test_workpiece_job_counter.py @@ -1,7 +1,7 @@ import pytest from rayforge.core.doc import Doc -from rayforge.core.variable_text import CounterSettings +from rayforge.core.job_counter import CounterSettings from rayforge.core.workpiece import WorkPiece from tests.core.template_provider import TemplateProvider, place @@ -43,12 +43,12 @@ def test_unparented_workpiece_has_no_values(self): assert WorkPiece("wp").template_values() == {} def test_values_follow_doc_counter_and_offset(self, doc): - doc.variable_text.counter = CounterSettings(current=10, step=5) + doc.job_counter.counter = CounterSettings(current=10, step=5) wp = place(doc, "{counter}", offset=2) assert wp.template_values() == {"counter": 20} def test_provider_receives_counter(self, doc): - doc.variable_text.counter = CounterSettings(current=7) + doc.job_counter.counter = CounterSettings(current=7) wp = place(doc, "No. {counter:03d}") wp.clear_render_cache() assert wp.boundaries is not None @@ -57,7 +57,7 @@ def test_provider_receives_counter(self, doc): assert provider.rendered[-1] == "No. 007" def test_instance_params_win_over_doc_values(self, doc): - doc.variable_text.counter = CounterSettings(current=7) + doc.job_counter.counter = CounterSettings(current=7) wp = place(doc, "{counter}") wp._geometry_provider_params = {"counter": 99} wp.clear_render_cache() @@ -67,13 +67,13 @@ def test_instance_params_win_over_doc_values(self, doc): assert provider.rendered[-1] == "99" def test_in_world_bakes_values(self, doc): - doc.variable_text.counter = CounterSettings(current=3) + doc.job_counter.counter = CounterSettings(current=3) wp = place(doc, "{counter}", offset=1) world = wp.in_world() assert world.geometry_provider_params["counter"] == 4 def test_in_world_leaves_plain_templates_alone(self, doc): - doc.variable_text.counter = CounterSettings(current=3) + doc.job_counter.counter = CounterSettings(current=3) wp = place(doc, "Hello") assert "counter" not in wp.in_world().geometry_provider_params @@ -89,14 +89,14 @@ def test_counter_change_regenerates_text(self, doc): wp = place(doc, "{counter}") provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) - doc.variable_text.counter = CounterSettings(current=1234) + doc.job_counter.counter = CounterSettings(current=1234) assert provider.rendered[-1] == "1234" def test_counter_change_keeps_scale(self, doc): - doc.variable_text.counter = CounterSettings(current=9) + doc.job_counter.counter = CounterSettings(current=9) wp = place(doc, "{counter}") wp.set_size(4.0, 10.0) - doc.variable_text.counter = CounterSettings(current=10) + doc.job_counter.counter = CounterSettings(current=10) width, height = wp.size # The text doubled in length; the 2x scale of the # workpiece is preserved. @@ -111,7 +111,7 @@ def on_updated(sender, **kwargs): received.append(sender) wp.updated.connect(on_updated) - doc.variable_text.counter = CounterSettings(current=5) + doc.job_counter.counter = CounterSettings(current=5) assert wp in received def test_unrelated_workpiece_is_not_regenerated(self, doc): @@ -119,11 +119,11 @@ def test_unrelated_workpiece_is_not_regenerated(self, doc): provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) calls = len(provider.calls) - doc.variable_text.counter = CounterSettings(current=5) + doc.job_counter.counter = CounterSettings(current=5) assert len(provider.calls) == calls def test_offset_change_regenerates(self, doc): - doc.variable_text.counter = CounterSettings(current=1) + doc.job_counter.counter = CounterSettings(current=1) wp = place(doc, "{counter}") provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) @@ -133,15 +133,15 @@ def test_offset_change_regenerates(self, doc): class TestDocSerialization: def test_doc_round_trip_keeps_counter(self, doc): - doc.variable_text.counter = CounterSettings(current=12, step=3) + doc.job_counter.counter = CounterSettings(current=12, step=3) restored = Doc.from_dict(doc.to_dict()) - assert restored.variable_text.counter == doc.variable_text.counter + assert restored.job_counter.counter == doc.job_counter.counter def test_old_documents_get_default_counter(self, doc): data = doc.to_dict() - data.pop("variable_text", None) + data.pop("job_counter", None) restored = Doc.from_dict(data) - assert restored.variable_text.counter == CounterSettings() + assert restored.job_counter.counter == CounterSettings() def test_restored_doc_refreshes_on_change(self, doc): restored = Doc.from_dict(doc.to_dict()) @@ -150,16 +150,16 @@ def test_restored_doc_refreshes_on_change(self, doc): def on_changed(sender): received.append(sender) - restored.variable_text.changed.connect(on_changed) - restored.variable_text.counter = CounterSettings(current=2) - assert received == [restored.variable_text] + restored.job_counter.changed.connect(on_changed) + restored.job_counter.counter = CounterSettings(current=2) + assert received == [restored.job_counter] def test_copies_count_uses_highest_offset(self, doc): place(doc, "{counter}", offset=0) place(doc, "{counter}", offset=5) place(doc, "Hello", offset=9) - assert doc.variable_text_copies() == 6 + assert doc.job_counter_copies() == 6 def test_copies_count_without_template_users(self, doc): place(doc, "Hello") - assert doc.variable_text_copies() == 0 + assert doc.job_counter_copies() == 0 diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 49678a4a0..b911e1a05 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -10,7 +10,7 @@ from rayforge.core.group import Group from rayforge.core.layer import Layer -from rayforge.core.variable_text import CounterSettings +from rayforge.core.job_counter import CounterSettings from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( ArrayMode, @@ -234,7 +234,7 @@ async def test_grid_array_numbers_copies(doc_editor, array_cmd): """Copies get increasing variable offsets in array order, so counter values count up across the sheet.""" doc = doc_editor.doc - doc.variable_text.counter = CounterSettings(current=11) + doc.job_counter.counter = CounterSettings(current=11) wp = place(doc, "{counter}", offset=2) params = ArrayParams( mode=ArrayMode.GRID, diff --git a/tests/doceditor/test_variable_text_cmd.py b/tests/doceditor/test_job_counter_cmd.py similarity index 92% rename from tests/doceditor/test_variable_text_cmd.py rename to tests/doceditor/test_job_counter_cmd.py index eee423b42..ad6679890 100644 --- a/tests/doceditor/test_variable_text_cmd.py +++ b/tests/doceditor/test_job_counter_cmd.py @@ -1,22 +1,22 @@ import pytest -from rayforge.core.variable_text import CounterSettings -from rayforge.doceditor.variable_text_cmd import VariableTextCmd +from rayforge.core.job_counter import CounterSettings +from rayforge.doceditor.job_counter_cmd import JobCounterCmd from tests.core.template_provider import place @pytest.fixture def cmd(doc_editor): - return VariableTextCmd(doc_editor) + return JobCounterCmd(doc_editor) def _counter(doc_editor) -> CounterSettings: - return doc_editor.doc.variable_text.counter + return doc_editor.doc.job_counter.counter @pytest.mark.asyncio async def test_editor_exposes_command(doc_editor): - assert isinstance(doc_editor.variable_text, VariableTextCmd) + assert isinstance(doc_editor.job_counter, JobCounterCmd) @pytest.mark.asyncio diff --git a/tests/doceditor/test_variable_text_job.py b/tests/doceditor/test_job_counter_job.py similarity index 94% rename from tests/doceditor/test_variable_text_job.py rename to tests/doceditor/test_job_counter_job.py index 556cfd2db..8b15c6946 100644 --- a/tests/doceditor/test_variable_text_job.py +++ b/tests/doceditor/test_job_counter_job.py @@ -10,7 +10,7 @@ from raygeo.geo import Geometry from rayforge.context import get_context -from rayforge.core.variable_text import CounterSettings +from rayforge.core.job_counter import CounterSettings from rayforge.machine.cmd import MachineCmd from tests.core.template_provider import place @@ -35,17 +35,17 @@ def _setup(doc_editor, contour_step_class, context, copies: int = 1): wp._edited_boundaries = _rect() wp.set_size(10.0, 10.0) wp.pos = (offset * 20.0, 0.0) - doc_editor.doc.variable_text.counter = CounterSettings(current=5) + doc_editor.doc.job_counter.counter = CounterSettings(current=5) def _current(doc_editor) -> int: - return doc_editor.doc.variable_text.counter.current + return doc_editor.doc.job_counter.counter.current def _machine_cmd(doc_editor) -> MachineCmd: machine_cmd = MachineCmd(doc_editor) machine_cmd.job_completed.connect( - doc_editor.variable_text.on_job_completed + doc_editor.job_counter.on_job_completed ) return machine_cmd @@ -133,7 +133,7 @@ async def test_gcode_export_advances_when_enabled( context_initializer, doc_editor, contour_step_class, tmp_path ): _setup(doc_editor, contour_step_class, context_initializer) - doc_editor.doc.variable_text.counter = CounterSettings( + doc_editor.doc.job_counter.counter = CounterSettings( current=5, advance_on_export=True ) await doc_editor.wait_until_settled() diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_job_counter_dialog.py similarity index 83% rename from tests/ui_gtk/doceditor/test_variable_text_dialog.py rename to tests/ui_gtk/doceditor/test_job_counter_dialog.py index 5b12f6e04..e7d4ab57f 100644 --- a/tests/ui_gtk/doceditor/test_variable_text_dialog.py +++ b/tests/ui_gtk/doceditor/test_job_counter_dialog.py @@ -7,14 +7,14 @@ import pytest from gi.repository import Adw, Gtk -from rayforge.core.variable_text import CounterSettings +from rayforge.core.job_counter import CounterSettings from rayforge.doceditor.editor import DocEditor -from rayforge.ui_gtk.doceditor.property_providers.variable_text import ( - VariableTextPropertyProvider, +from rayforge.ui_gtk.doceditor.property_providers.job_counter import ( + JobCounterPropertyProvider, ) -from rayforge.ui_gtk.doceditor.variable_text_dialog import ( +from rayforge.ui_gtk.doceditor.job_counter_dialog import ( DEFAULT_WIDTH, - VariableTextDialog, + JobCounterDialog, format_preview, ) from tests.core.template_provider import place @@ -32,13 +32,13 @@ def editor(ui_context_initializer, ui_task_mgr): @pytest.fixture def dialog(editor): parent = Gtk.Window() - dlg = VariableTextDialog(parent, editor) + dlg = JobCounterDialog(parent, editor) yield dlg dlg.destroy() def _counter(editor) -> CounterSettings: - return editor.doc.variable_text.counter + return editor.doc.job_counter.counter @pytest.mark.ui @@ -50,10 +50,10 @@ def test_format_preview(): @pytest.mark.ui def test_dialog_shows_current_settings(editor): - editor.doc.variable_text.counter = CounterSettings( + editor.doc.job_counter.counter = CounterSettings( current=42, start=10, step=2, auto_advance=False ) - dlg = VariableTextDialog(Gtk.Window(), editor) + dlg = JobCounterDialog(Gtk.Window(), editor) assert dlg.current_row.get_int_value() == 42 assert dlg.start_row.get_int_value() == 10 assert dlg.step_row.get_int_value() == 2 @@ -88,7 +88,7 @@ def test_next_previous_reset_buttons(editor, dialog): assert _counter(editor).current == 3 dialog.previous_button.emit("clicked") assert _counter(editor).current == 1 - editor.doc.variable_text.counter = CounterSettings(current=50, start=4) + editor.doc.job_counter.counter = CounterSettings(current=50, start=4) dialog.reset_button.emit("clicked") assert _counter(editor).current == 4 @@ -96,7 +96,7 @@ def test_next_previous_reset_buttons(editor, dialog): @pytest.mark.ui def test_preview_follows_model(editor, dialog): place(editor.doc, "{counter}", offset=2) - editor.doc.variable_text.counter = CounterSettings(current=5) + editor.doc.job_counter.counter = CounterSettings(current=5) assert dialog.preview_row.get_subtitle() == "5, 6, 7" assert dialog.current_row.get_int_value() == 5 @@ -113,7 +113,7 @@ def test_switches_update_model(editor, dialog): def test_property_provider_handles_template_workpieces(editor): wp = place(editor.doc, "{counter:03d}") plain = place(editor.doc, "Hello") - provider = VariableTextPropertyProvider() + provider = JobCounterPropertyProvider() assert provider.can_handle([wp]) assert not provider.can_handle([plain]) assert not provider.can_handle([]) @@ -121,9 +121,9 @@ def test_property_provider_handles_template_workpieces(editor): @pytest.mark.ui def test_property_provider_sets_offset(editor): - editor.doc.variable_text.counter = CounterSettings(current=10) + editor.doc.job_counter.counter = CounterSettings(current=10) wp = place(editor.doc, "{counter}") - provider = VariableTextPropertyProvider() + provider = JobCounterPropertyProvider() provider.create_widgets() provider.update_widgets(editor, [wp]) assert "10" in (provider.offset_row.get_subtitle() or "") diff --git a/website/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index 409dde9e7..96adaf8d5 100644 --- a/website/docs/features/sketcher/expressions.md +++ b/website/docs/features/sketcher/expressions.md @@ -118,7 +118,7 @@ registered for them (see [below](#custom-template-functions)). | `{uuid()}` | `str` | Full UUID v4 string (36 chars) | For a job counter that advances after every job, use the project variable `{counter}` (see -[Variable Text](../variable-text.md)). +[Job Counter](../variable-text.md)). Typical uses include unique serial numbers per solve (`Part # {uuid4()}`), live dimension labels (`W={width:.1f} H={height:.1f}`), date stamps (`Date: {today()}`), production counters diff --git a/website/docs/features/variable-text.md b/website/docs/features/variable-text.md index 5845b35b3..540379672 100644 --- a/website/docs/features/variable-text.md +++ b/website/docs/features/variable-text.md @@ -4,7 +4,7 @@ description: consecutive numbers for array copies." --- -# Variable Text +# Job Counter Text boxes resolve `{...}` templates (see [Parameters and Expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). On top of @@ -19,7 +19,7 @@ Use `{counter}` in a text box. It is a whole number, so format specs control its - `{counter:04d}` — `0007`, four digits with leading zeros - `No. {counter:03d}` — `No. 007` -Open **Tools → Variable Text…** to set up the counter: +Open **Tools → Job Counter…** to set up the counter: - **Current Value** — the number the next job starts with. - **Start Value** — where **Reset** returns to. @@ -37,7 +37,7 @@ advance, is part of the undo history and is saved in the project. ## Numbering copies -Each workpiece has a **Variable Offset** (in the properties panel, under **Variable Text**). A copy +Each workpiece has a **Variable Offset** (in the properties panel, under **Job Counter**). A copy with offset _n_ shows the number _n_ copies after the current one. A [grid or circular array](arrays.md) gives its copies increasing offsets automatically, so one array of 20 plates engraves 20 consecutive numbers, and the next job continues with the 21st. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 7306ec6b4..c7c3c6d24 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -123,7 +123,7 @@ Funktionen bereit, und es können benutzerdefinierte Funktionen für sie registr | `{uuid()}` | `str` | Vollständiger UUID v4-String (36 Zeichen) | Für einen Auftragszähler, der nach jedem Auftrag weiterzählt, verwende die Projektvariable `{counter}` -(siehe [Variabler Text](../variable-text.md)). +(siehe [Auftragszähler](../variable-text.md)). Typische Anwendungen sind eindeutige Seriennummern pro Lösung (`Part # {uuid4()}`), live Maßbeschriftungen (`W={width:.1f} H={height:.1f}`), Datumsstempel (`Date: {today()}`), diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md index c3c564056..6f12b1ffc 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: fortlaufenden Nummern für Array-Kopien." --- -# Variabler Text +# Auftragszähler Textfelder lösen `{...}`-Vorlagen auf (siehe [Parameter und Ausdrücke](sketcher/expressions.md#template-expressions-in-text-boxes)). Zusätzlich @@ -21,7 +21,7 @@ Aussehen: - `{counter:04d}` — `0007`, vier Stellen mit führenden Nullen - `No. {counter:03d}` — `No. 007` -Öffne **Werkzeuge → Variabler Text…**, um den Zähler einzurichten: +Öffne **Werkzeuge → Auftragszähler…**, um den Zähler einzurichten: - **Aktueller Wert** — die Nummer, mit der der nächste Auftrag beginnt. - **Startwert** — wohin **Zurücksetzen** zurückkehrt. @@ -39,7 +39,7 @@ Rückgängig-Verlaufs und wird im Projekt gespeichert. ## Kopien nummerieren -Jedes Werkstück hat einen **Variablen-Versatz** (im Eigenschaftenbereich unter **Variabler Text**). +Jedes Werkstück hat einen **Variablen-Versatz** (im Eigenschaftenbereich unter **Auftragszähler**). Eine Kopie mit Versatz _n_ zeigt die Nummer _n_ Kopien nach der aktuellen. Ein [Gitter- oder Kreis-Array](arrays.md) gibt seinen Kopien automatisch steigende Versätze, sodass ein Array aus 20 Schildern 20 fortlaufende Nummern graviert und der nächste Auftrag mit der 21. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 646dfb16e..0d4430537 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -125,7 +125,7 @@ funciones integradas de abajo, y se pueden registrar funciones personalizadas pa | `{uuid()}` | `str` | Cadena UUID v4 completa (36 caracteres) | Para un contador de trabajos que avanza tras cada trabajo, usa la variable del proyecto `{counter}` -(consulta [Texto variable](../variable-text.md)). +(consulta [Contador de trabajos](../variable-text.md)). Entre los usos típicos se incluyen números de serie únicos en cada resolución (`Pieza #{uuid4()}`), etiquetas de dimensiones en vivo (`W={width:.1f} H={height:.1f}`), fechas en cada pieza diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md index dbdf05d44..d21b9b4a2 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: con números consecutivos para las copias de una matriz." --- -# Texto variable +# Contador de trabajos Los cuadros de texto resuelven plantillas `{...}` (consulta [Parámetros y expresiones](sketcher/expressions.md#template-expressions-in-text-boxes)). Además de @@ -21,7 +21,7 @@ controlan su aspecto: - `{counter:04d}` — `0007`, cuatro dígitos con ceros a la izquierda - `No. {counter:03d}` — `No. 007` -Abre **Herramientas → Texto variable…** para configurar el contador: +Abre **Herramientas → Contador de trabajos…** para configurar el contador: - **Valor actual** — el número con el que empieza el próximo trabajo. - **Valor inicial** — adonde vuelve **Restablecer**. @@ -41,8 +41,7 @@ proyecto. ## Numerar copias -Cada pieza tiene un **Desplazamiento de variable** (en el panel de propiedades, en **Texto -variable**). Una copia con desplazamiento _n_ muestra el número _n_ copias después del actual. Una +Cada pieza tiene un **Desplazamiento de variable** (en el panel de propiedades, en **Contador de trabajos**). Una copia con desplazamiento _n_ muestra el número _n_ copias después del actual. Una [matriz en cuadrícula o circular](arrays.md) da a sus copias desplazamientos crecientes de forma automática, así que una matriz de 20 placas graba 20 números consecutivos y el siguiente trabajo continúa con el 21. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index a26c57cd5..225d087ef 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -125,7 +125,7 @@ fonctions intégrées ci-dessous, et des fonctions personnalisées peuvent leur | `{uuid()}` | `str` | Chaîne UUID v4 complète (36 caractères) | Pour un compteur de tâches qui avance après chaque travail, utilisez la variable de projet -`{counter}` (voir [Texte variable](../variable-text.md)). +`{counter}` (voir [Compteur de tâches](../variable-text.md)). Les utilisations typiques comprennent les numéros de série uniques à chaque résolution (`Pièce #{uuid4()}`), les étiquettes de dimensions dynamiques (`W={width:.1f} H={height:.1f}`), les diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md index bb8fcbbf4..3b5ce64c2 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: travail, avec des numéros consécutifs pour les copies d'un réseau." --- -# Texte variable +# Compteur de tâches Les zones de texte résolvent les modèles `{...}` (voir [Paramètres et expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). En plus @@ -21,7 +21,7 @@ format contrôlent son apparence : - `{counter:04d}` — `0007`, quatre chiffres avec des zéros en tête - `No. {counter:03d}` — `No. 007` -Ouvrez **Outils → Texte variable…** pour configurer le compteur : +Ouvrez **Outils → Compteur de tâches…** pour configurer le compteur : - **Valeur actuelle** — le numéro par lequel commence le prochain travail. - **Valeur de départ** — là où **Réinitialiser** revient. @@ -41,8 +41,7 @@ d'annulation et est enregistrée dans le projet. ## Numéroter des copies -Chaque pièce a un **Décalage de variable** (dans le panneau des propriétés, sous **Texte -variable**). Une copie avec le décalage _n_ affiche le numéro situé _n_ copies après le numéro +Chaque pièce a un **Décalage de variable** (dans le panneau des propriétés, sous **Compteur de tâches**). Une copie avec le décalage _n_ affiche le numéro situé _n_ copies après le numéro actuel. Un [réseau en grille ou circulaire](arrays.md) donne automatiquement à ses copies des décalages croissants : un réseau de 20 plaques grave ainsi 20 numéros consécutifs, et le travail suivant continue avec le 21e. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 34d459e95..4b55fed62 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -117,7 +117,7 @@ W = {width}, H = {height} | `{uuid()}` | `str` | पूर्ण UUID v4 स्ट्रिंग (36 वर्ण) | हर जॉब के बाद आगे बढ़ने वाले जॉब काउंटर के लिए प्रोजेक्ट वेरिएबल `{counter}` का उपयोग करें -([वेरिएबल टेक्स्ट](../variable-text.md) देखें)। +([जॉब काउंटर](../variable-text.md) देखें)। सामान्य उपयोग में प्रति हल अद्वितीय सीरियल नंबर (`Part # {uuid4()}`), लाइव विमा लेबल (`W={width:.1f} H={height:.1f}`), तिथि चिह्न (`Date: {today()}`), उत्पादन काउंटर diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md index 4870cde3d..af51fce7d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: कॉपियों के लिए क्रमागत नंबर।" --- -# वेरिएबल टेक्स्ट +# जॉब काउंटर टेक्स्ट बॉक्स `{...}` टेम्पलेट को हल करते हैं ([पैरामीटर और एक्सप्रेशन](sketcher/expressions.md#template-expressions-in-text-boxes) देखें)। स्केच @@ -20,7 +20,7 @@ description: - `{counter:04d}` — `0007`, आगे शून्य के साथ चार अंक - `No. {counter:03d}` — `No. 007` -काउंटर सेट करने के लिए **टूल्स → वेरिएबल टेक्स्ट…** खोलें: +काउंटर सेट करने के लिए **टूल्स → जॉब काउंटर…** खोलें: - **वर्तमान मान** — वह नंबर जिससे अगला जॉब शुरू होता है। - **प्रारंभ मान** — जहाँ **रीसेट** वापस ले जाता है। @@ -37,7 +37,7 @@ description: ## कॉपियों को नंबर देना -हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है (प्रॉपर्टी पैनल में, **वेरिएबल टेक्स्ट** के अंतर्गत)। +हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है (प्रॉपर्टी पैनल में, **जॉब काउंटर** के अंतर्गत)। ऑफ़सेट _n_ वाली कॉपी वर्तमान नंबर से _n_ कॉपी बाद का नंबर दिखाती है। एक [ग्रिड या वृत्ताकार ऐरे](arrays.md) अपनी कॉपियों को अपने-आप बढ़ते ऑफ़सेट देता है, इसलिए 20 प्लेट का एक ऐरे 20 क्रमागत नंबर उकेरता है, और अगला जॉब 21वें से जारी रहता है। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 20d296364..2435a893c 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -122,7 +122,7 @@ integradas abaixo, e funções personalizadas podem ser registradas para eles (v | `{uuid()}` | `str` | String UUID v4 completa (36 caracteres) | Para um contador de trabalhos que avança após cada trabalho, use a variável do projeto `{counter}` (veja -[Texto variável](../variable-text.md)). +[Contador de trabalhos](../variable-text.md)). Usos típicos incluem números de série únicos a cada resolução (`Peça #{uuid4()}`), rótulos de dimensões em tempo real (`L={width:.1f} A={height:.1f}`), datar cada peça (`Data: {today()}`), diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md index 864f262f1..782806255 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: números consecutivos para as cópias de uma matriz." --- -# Texto variável +# Contador de trabalhos As caixas de texto resolvem modelos `{...}` (veja [Parâmetros e expressões](sketcher/expressions.md#template-expressions-in-text-boxes)). Além dos @@ -20,7 +20,7 @@ controlam sua aparência: - `{counter:04d}` — `0007`, quatro dígitos com zeros à esquerda - `No. {counter:03d}` — `No. 007` -Abra **Ferramentas → Texto variável…** para configurar o contador: +Abra **Ferramentas → Contador de trabalhos…** para configurar o contador: - **Valor atual** — o número com que o próximo trabalho começa. - **Valor inicial** — para onde **Redefinir** volta. @@ -39,7 +39,7 @@ projeto. ## Numerar cópias -Cada peça tem um **Deslocamento de variável** (no painel de propriedades, em **Texto variável**). +Cada peça tem um **Deslocamento de variável** (no painel de propriedades, em **Contador de trabalhos**). Uma cópia com deslocamento _n_ mostra o número _n_ cópias depois do atual. Uma [matriz em grade ou circular](arrays.md) dá às suas cópias deslocamentos crescentes automaticamente, então uma matriz de 20 placas grava 20 números consecutivos, e o próximo trabalho continua com o diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 9615f6a79..70b611d6b 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -119,7 +119,7 @@ W = {width}, H = {height} | `{uuid()}` | `str` | Повний рядок UUID v4 (36 символів) | Для лічильника завдань, що збільшується після кожного завдання, використовуйте змінну проєкту -`{counter}` (див. [Змінний текст](../variable-text.md)). +`{counter}` (див. [Лічильник завдань](../variable-text.md)). Типові застосування включають унікальні серійні номери при кожному розв'язанні (`Деталь #{uuid4()}`), живі мітки розмірів (`Ш={width:.1f} В={height:.1f}`), датування кожної деталі diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md index 8f1a1e7c1..3b84425c6 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -4,7 +4,7 @@ description: послідовними номерами для копій масиву." --- -# Змінний текст +# Лічильник завдань Текстові поля обробляють шаблони `{...}` (див. [Параметри та вирази](sketcher/expressions.md#template-expressions-in-text-boxes)). Окрім параметрів @@ -20,7 +20,7 @@ description: - `{counter:04d}` — `0007`, чотири цифри з провідними нулями - `No. {counter:03d}` — `No. 007` -Відкрийте **Інструменти → Змінний текст…**, щоб налаштувати лічильник: +Відкрийте **Інструменти → Лічильник завдань…**, щоб налаштувати лічильник: - **Поточне значення** — номер, з якого починається наступне завдання. - **Початкове значення** — куди повертає **Скинути**. @@ -39,7 +39,7 @@ description: ## Нумерація копій -Кожна заготовка має **Зсув змінної** (на панелі властивостей, у розділі **Змінний текст**). Копія зі +Кожна заготовка має **Зсув змінної** (на панелі властивостей, у розділі **Лічильник завдань**). Копія зі зсувом _n_ показує номер на _n_ копій після поточного. [Сітковий або круговий масив](arrays.md) автоматично дає своїм копіям зростаючі зсуви, тож масив із 20 табличок гравіює 20 послідовних номерів, а наступне завдання продовжує з 21-го. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index b7a480554..3f5b76650 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -97,7 +97,7 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` 的别名 | | `{uuid()}` | `str` | 完整 UUID v4 字符串(36 字符) | -如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[可变文本](../variable-text.md))。 +如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[任务计数器](../variable-text.md))。 典型用途包括每次求解生成唯一序列号(`零件 #{uuid4()}`)、实时尺寸标签(`宽={width:.1f} 高={height:.1f}`)、日期戳(`日期:{today()}`)、生产计数器(`{name} - {count:.0f}个`),或用于生产日志的 Unix 时间戳(`{timestamp():.0f}`)。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md index 63664981e..638354b60 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md @@ -2,7 +2,7 @@ description: "文本模板中的任务计数器:每次任务后自动递增的项目数值,并为阵列副本生成连续编号。" --- -# 可变文本 +# 任务计数器 文本框会解析 `{...}` 模板(参见[参数与表达式](sketcher/expressions.md#template-expressions-in-text-boxes))。除了草图参数和内置函数之外,项目还提供随每次任务变化的变量,因此无需每次修改文本即可雕刻带编号的铭牌、吊牌和标签。 @@ -15,7 +15,7 @@ description: "文本模板中的任务计数器:每次任务后自动递增的 - `{counter:04d}` — `0007`,四位数并补前导零 - `No. {counter:03d}` — `No. 007` -打开**工具 → 可变文本…**来设置计数器: +打开**工具 → 任务计数器…**来设置计数器: - **当前值** — 下一次任务开始使用的编号。 - **起始值** — **重置**时返回的值。 @@ -29,7 +29,7 @@ description: "文本模板中的任务计数器:每次任务后自动递增的 ## 为副本编号 -每个工件都有一个**变量偏移**(在属性面板的**可变文本**下)。偏移为 _n_ 的副本显示当前编号之后第 _n_ +每个工件都有一个**变量偏移**(在属性面板的**任务计数器**下)。偏移为 _n_ 的副本显示当前编号之后第 _n_ 个副本的编号。[网格或环形阵列](arrays.md)会自动为其副本分配递增的偏移,因此一个包含 20 块铭牌的阵列会雕刻 20 个连续编号,下一次任务从第 21 个继续。 在草图内部创建的阵列则不同:它们的副本属于同一个工件,全部显示相同的编号。如需为副本编号,请改为在画布上创建阵列。 From bea013ee6a91608791dd2000dc64bfa40b4ba03f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 18 Oct 2026 19:39:30 +0200 Subject: [PATCH 14/33] fix: sort the renamed imports and the property-provider __all__ CI's Lint job failed on 2a9d9009f: __all__ kept VariableTextPropertyProvider's old position after the rename, and the job_counter modules shifted seven import blocks out of order. --- rayforge/core/doc.py | 2 +- rayforge/core/workpiece.py | 2 +- rayforge/doceditor/editor.py | 2 +- rayforge/doceditor/job_counter_cmd.py | 2 +- rayforge/ui_gtk/actions.py | 2 +- rayforge/ui_gtk/doceditor/property_providers/__init__.py | 4 ++-- tests/doceditor/test_array_cmd.py | 2 +- tests/ui_gtk/doceditor/test_job_counter_dialog.py | 6 +++--- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index bb8e6f47c..4604ef8ab 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -15,9 +15,9 @@ from .color import COLOR_PALETTE from .image_edit import ImageEditStep, missing_image_edit_ops from .item import DocItem +from .job_counter import JobCounter from .layer import Layer from .source_asset import SourceAsset -from .job_counter import JobCounter from .workpiece import WorkPiece, workpieces_in if TYPE_CHECKING: diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 4aa433e57..9816c3ad3 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -32,9 +32,9 @@ from .image_cache import new_image_cache from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem +from .job_counter import referenced_template_names from .source_asset_segment import SourceAssetSegment from .tab import Tab -from .job_counter import referenced_template_names from .vectorization_spec import TraceSpec if TYPE_CHECKING: diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 0ee41649e..fa5f0ae84 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -24,6 +24,7 @@ from .edit_cmd import EditCmd from .file_cmd import FileCmd from .group_cmd import GroupCmd +from .job_counter_cmd import JobCounterCmd from .layer_cmd import LayerCmd from .layout_cmd import LayoutCmd from .path_cleanup_cmd import PathCleanupCmd @@ -33,7 +34,6 @@ from .stock_cmd import StockCmd from .tab_cmd import TabCmd from .transform_cmd import TransformCmd -from .job_counter_cmd import JobCounterCmd if TYPE_CHECKING: from ..context import RayforgeContext diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index 46d246181..f66c002ed 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -11,8 +11,8 @@ from typing import TYPE_CHECKING from ..core.item import DocItem -from ..core.undo import ChangePropertyCommand from ..core.job_counter import CounterSettings +from ..core.undo import ChangePropertyCommand from ..core.workpiece import WorkPiece, workpieces_in from .layout.base import LayoutStrategy diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 3c131af0a..25f2d13e0 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -22,9 +22,9 @@ PointRotationArrayDialog, ) from .doceditor.add_tabs_popover import AddTabsPopover +from .doceditor.job_counter_dialog import JobCounterDialog from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog -from .doceditor.job_counter_dialog import JobCounterDialog from .shared.keyboard import PRIMARY_ACCEL if TYPE_CHECKING: diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py index ea2683212..cfa43cd19 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/__init__.py +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -1,6 +1,6 @@ from .base import PropertyProvider, property_provider_registry -from .transform import TransformPropertyProvider from .job_counter import JobCounterPropertyProvider +from .transform import TransformPropertyProvider from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider @@ -13,10 +13,10 @@ def register_builtin_providers(): __all__ = [ + "JobCounterPropertyProvider", "PropertyProvider", "TabsPropertyProvider", "TransformPropertyProvider", - "JobCounterPropertyProvider", "WorkpieceInfoProvider", "property_provider_registry", "register_builtin_providers", diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index b911e1a05..13f467034 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -9,8 +9,8 @@ import pytest from rayforge.core.group import Group -from rayforge.core.layer import Layer from rayforge.core.job_counter import CounterSettings +from rayforge.core.layer import Layer from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( ArrayMode, diff --git a/tests/ui_gtk/doceditor/test_job_counter_dialog.py b/tests/ui_gtk/doceditor/test_job_counter_dialog.py index e7d4ab57f..5a874e4c1 100644 --- a/tests/ui_gtk/doceditor/test_job_counter_dialog.py +++ b/tests/ui_gtk/doceditor/test_job_counter_dialog.py @@ -9,14 +9,14 @@ from rayforge.core.job_counter import CounterSettings from rayforge.doceditor.editor import DocEditor -from rayforge.ui_gtk.doceditor.property_providers.job_counter import ( - JobCounterPropertyProvider, -) from rayforge.ui_gtk.doceditor.job_counter_dialog import ( DEFAULT_WIDTH, JobCounterDialog, format_preview, ) +from rayforge.ui_gtk.doceditor.property_providers.job_counter import ( + JobCounterPropertyProvider, +) from tests.core.template_provider import place From 45a86cfc77fc7de47abf3a30796196be6f9dad6b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 18 Oct 2026 20:50:53 +0200 Subject: [PATCH 15/33] refactor: keep template knowledge out of the counter and the workpiece Following the review: the job counter is a plain counter again - the expression and template-name helpers move to rayforge.core.expression.placeholders. The geometry provider protocol now carries get_template_sources() and variables_used(), so the workpiece stops probing providers with getattr and stops inspecting templates; doc and workpiece pass the counter values unconditionally, and in_world bakes them in for every detached copy. The sketch answers the recalculation question itself: get_geometry reuses its previous result while the effective parameter values are unchanged, and drops that cache whenever the sketch changes, so a counter step does not re-solve sketches that do not read it. The property provider no longer inspects templates either, and the docs page lives at its final name, features/job-counter. --- .../sketcher/core/sketch.py | 60 ++++++++++++++++++- .../tests/core/test_sketch_job_counter.py | 27 +++++++++ rayforge/core/doc.py | 27 ++++----- rayforge/core/expression/__init__.py | 2 + rayforge/core/expression/placeholders.py | 39 ++++++++++++ rayforge/core/geometry_provider.py | 15 +++++ rayforge/core/job_counter.py | 34 +---------- rayforge/core/workpiece.py | 45 ++------------ .../property_providers/job_counter.py | 17 ++---- tests/core/template_provider.py | 8 ++- tests/core/test_expression_placeholders.py | 19 ++++++ tests/core/test_job_counter.py | 24 +------- tests/core/test_workpiece_job_counter.py | 19 +++--- .../doceditor/test_job_counter_dialog.py | 4 +- .../{variable-text.md => job-counter.md} | 0 website/docs/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- .../{variable-text.md => job-counter.md} | 0 .../current/features/sketcher/expressions.md | 2 +- website/sidebars.js | 2 +- 31 files changed, 214 insertions(+), 144 deletions(-) create mode 100644 rayforge/core/expression/placeholders.py create mode 100644 tests/core/test_expression_placeholders.py rename website/docs/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/de/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/es/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/fr/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/hi/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/uk/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) rename website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/{variable-text.md => job-counter.md} (100%) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 41cb08d9e..772ca1716 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -19,7 +19,7 @@ from rayforge.core.asset import IAsset from rayforge.core.color import ColorRGBA -from rayforge.core.expression import ExpressionMap +from rayforge.core.expression import ExpressionMap, referenced_template_names from rayforge.core.geometry_provider import IGeometryProvider from rayforge.core.varset import VarSet from rayforge.image.geo_renderer import render_geometry_to_png @@ -58,6 +58,8 @@ from .template_functions import get_template_functions from .types import EntityID +_GEOMETRY_CACHE_SIZE = 8 + DEFAULT_FILL_COLOR: ColorRGBA = (0.85, 0.85, 0.85, 0.7) FORMAT_VERSION = 1 _DEFAULT_VARSET_TITLE = _("Sketch Parameters") @@ -185,6 +187,14 @@ def __init__(self, name: str = "New Sketch") -> None: self._resolved_text_cache: dict[EntityID, tuple[str, str | None]] = {} self._solved_ctx: dict[str, Any] | None = None + # Geometry of the last calls, keyed by effective parameter + # values; cleared whenever the sketch itself changes. + self._geometry_cache: dict[ + tuple[tuple[str, str], ...], + tuple[Geometry, list[FillRenderData], dict], + ] = {} + self._updated.connect(self._invalidate_geometry_cache) + # Cache for coincident-point groups. Keys are point IDs, # values are frozensets of all points in the same group. self._coincident_cache: dict[EntityID, frozenset[EntityID]] = {} @@ -316,7 +326,10 @@ def get_geometry( Generate geometry with optional parameter overrides. Creates a clone, solves it with the given parameters, and returns - the stroke and fill geometries. + the stroke and fill geometries. An unchanged sketch reuses the + result of an earlier call when the effective parameter values + are the same, so a change to a document-wide variable does not + re-solve sketches that do not read it. When *resolved_text_cache* is supplied the clone is seeded with those values so that volatile template expressions (e.g. @@ -332,6 +345,13 @@ def get_geometry( Returns: A tuple of (stroke_geometry, fill_render_data). """ + key = self._geometry_cache_key(params) + cached = self._geometry_cache.get(key) + if cached is not None: + geo, fills, resolved = cached + if resolved_text_cache is not None: + resolved_text_cache.update(resolved) + return geo, fills clone = self._clone_for_geometry() if resolved_text_cache is not None: clone._resolved_text_cache = dict(resolved_text_cache) @@ -344,6 +364,7 @@ def get_geometry( fills = clone.get_fill_render_data() if resolved_text_cache is not None: resolved_text_cache.update(clone._resolved_text_cache) + self._remember_geometry(key, geo, fills, clone._resolved_text_cache) return geo, fills def get_template_sources(self) -> list[str]: @@ -354,6 +375,41 @@ def get_template_sources(self) -> list[str]: if isinstance(e, TextBoxEntity) and e.content ] + def variables_used(self) -> set[str]: + """The template variable names this sketch's text boxes read.""" + return referenced_template_names(self.get_template_sources()) + + def _geometry_cache_key( + self, params: dict[str, Any] | None + ) -> tuple[tuple[str, str], ...]: + """ + The effective parameter values of a geometry request. Two + requests with the same values produce the same geometry as + long as the sketch itself does not change. + """ + values = dict(self.input_parameters.get_values()) + if params: + values.update(params) + return tuple( + sorted((name, repr(value)) for name, value in values.items()) + ) + + def _remember_geometry( + self, + key: tuple[tuple[str, str], ...], + geo: Geometry, + fills: list[FillRenderData], + resolved: dict, + ) -> None: + """Caches a result, evicting the oldest when it grows too big.""" + self._geometry_cache[key] = (geo, fills, dict(resolved)) + while len(self._geometry_cache) > _GEOMETRY_CACHE_SIZE: + self._geometry_cache.pop(next(iter(self._geometry_cache))) + + def _invalidate_geometry_cache(self, sender) -> None: + """Drops cached geometry when the sketch itself changes.""" + self._geometry_cache.clear() + def _clone_for_geometry(self) -> "Sketch": """ Lightweight clone for geometry generation. diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index d07b1a3ea..b71aadfb0 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -38,6 +38,33 @@ def test_template_sources_lists_text_box_contents(): assert sketch.get_template_sources() == ["No. {counter:03d}"] +def test_variables_used_reports_read_names(): + sketch, _box = _sketch_with_text("No. {counter:03d}") + assert sketch.variables_used() == {"counter"} + + +def test_plain_sketch_uses_no_variables(): + sketch, _box = _sketch_with_text("Hello") + assert sketch.variables_used() == set() + + +def test_geometry_is_reused_for_same_values(): + sketch, _box = _sketch_with_text("{counter}") + first, _fills = sketch.get_geometry({"counter": 1}) + again, _fills = sketch.get_geometry({"counter": 1}) + assert again is first + other, _fills = sketch.get_geometry({"counter": 2}) + assert other is not first + + +def test_sketch_change_drops_cached_geometry(): + sketch, _box = _sketch_with_text("{counter}") + first, _fills = sketch.get_geometry({"counter": 1}) + sketch.notify_update() + again, _fills = sketch.get_geometry({"counter": 1}) + assert again is not first + + def test_counter_is_resolved_in_document(): doc = Doc() doc.job_counter.counter = CounterSettings(current=7) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 4604ef8ab..492686847 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -195,36 +195,31 @@ def job_counter(self, value: JobCounter) -> None: value.changed.connect(self._on_job_counter_changed) self._on_job_counter_changed(value) - def _template_workpieces(self) -> list[WorkPiece]: - """Workpieces whose templates read a document variable.""" - names = self._job_counter.names - return [ - wp - for wp in self.all_workpieces - if wp.references_template_names(names) - ] - def job_counter_copies(self) -> int: """ - How many consecutive variable values one job uses: the highest - variable offset of a template workpiece plus one, or 0 if no - workpiece uses a document variable. + How many consecutive counter values one job uses: the highest + variable offset of a workpiece that reads the counter plus + one, or 0 if no workpiece reads it. """ - offsets = [wp.variable_offset for wp in self._template_workpieces()] + names = self._job_counter.names + offsets = [] + for wp in self.all_workpieces: + provider = wp.get_geometry_provider() + if provider is not None and names & provider.variables_used(): + offsets.append(wp.variable_offset) if not offsets: return 0 return max(offsets) + 1 def _on_job_counter_changed(self, sender: JobCounter) -> None: - for wp in self._template_workpieces(): + for wp in self.all_workpieces: wp.refresh_template_values() def _on_item_added_for_templates( self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem ) -> None: for wp in workpieces_in(origin): - if wp.references_template_names(self._job_counter.names): - wp.sync_template_values() + wp.sync_template_values() def add_asset( self, asset: IAsset, index: int | None = None, silent: bool = False diff --git a/rayforge/core/expression/__init__.py b/rayforge/core/expression/__init__.py index 5c2c014f7..679a60636 100644 --- a/rayforge/core/expression/__init__.py +++ b/rayforge/core/expression/__init__.py @@ -3,6 +3,7 @@ from .evaluator import safe_evaluate from .expression_map import ExpressionMap from .parser import ExpressionParser +from .placeholders import referenced_template_names from .tokenizer import ExpressionTokenizer, Token, TokenType from .validator import ExpressionValidator @@ -16,5 +17,6 @@ "TokenType", "ValidationResult", "ValidationStatus", + "referenced_template_names", "safe_evaluate", ] diff --git a/rayforge/core/expression/placeholders.py b/rayforge/core/expression/placeholders.py new file mode 100644 index 000000000..581566b1e --- /dev/null +++ b/rayforge/core/expression/placeholders.py @@ -0,0 +1,39 @@ +""" +Names used by ``{...}`` placeholders in text templates. + +Templates embed expressions inside braces; a format spec may follow +the first colon, e.g. ``{counter:04d}``. These helpers collect the +variable names a set of template strings reads, so callers can ask +which templates depend on which variables without evaluating them. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Iterable + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def names_in_expression(expression: str) -> set[str]: + """Names an expression reads, ignoring string literals.""" + try: + tree = ast.parse(expression.strip(), mode="eval") + except SyntaxError: + return set(_IDENTIFIER_RE.findall(expression)) + return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + + +def referenced_template_names(templates: Iterable[str]) -> set[str]: + """ + Collects the names used inside the ``{...}`` placeholders of + *templates*. A format spec after the first colon is ignored. + """ + names: set[str] = set() + for template in templates: + for match in _PLACEHOLDER_RE.finditer(template or ""): + expression, _sep, _spec = match.group(1).partition(":") + names |= names_in_expression(expression) + return names diff --git a/rayforge/core/geometry_provider.py b/rayforge/core/geometry_provider.py index 77cd9e9cf..ac55a4a5f 100644 --- a/rayforge/core/geometry_provider.py +++ b/rayforge/core/geometry_provider.py @@ -65,6 +65,21 @@ def get_geometry( """ ... + def get_template_sources(self) -> list[str]: + """ + The raw contents of all template strings this provider renders, + e.g. the text of the sketch's text boxes. + """ + ... + + def variables_used(self) -> set[str]: + """ + The template variable names these templates read. Callers use + this to know which variables this provider depends on; the + provider itself decides what its templates reference. + """ + ... + def to_dict(self) -> dict[str, Any]: """Serialize the provider to a dictionary.""" ... diff --git a/rayforge/core/job_counter.py b/rayforge/core/job_counter.py index 53b8886f5..44baa4739 100644 --- a/rayforge/core/job_counter.py +++ b/rayforge/core/job_counter.py @@ -10,9 +10,6 @@ from __future__ import annotations -import ast -import re -from collections.abc import Iterable from dataclasses import dataclass, replace from typing import Any @@ -20,9 +17,6 @@ COUNTER_NAME = "counter" -_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") -_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") - @dataclass(frozen=True) class CounterSettings: @@ -107,10 +101,10 @@ def from_dict(cls, data: dict[str, Any]) -> CounterSettings: class JobCounter: """ - The variables a document offers to text templates. + The document's job counter. - Emits :attr:`changed` whenever a value changes, so workpieces whose - templates reference a variable can regenerate. + Emits :attr:`changed` whenever a value changes; the document then + regenerates the geometry that shows counter values. """ def __init__(self): @@ -148,25 +142,3 @@ def from_dict(cls, data: dict[str, Any] | None) -> JobCounter: if isinstance(counter, dict): instance._counter = CounterSettings.from_dict(counter) return instance - - -def _names_in_expression(expression: str) -> set[str]: - """Names an expression reads, ignoring string literals.""" - try: - tree = ast.parse(expression.strip(), mode="eval") - except SyntaxError: - return set(_IDENTIFIER_RE.findall(expression)) - return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} - - -def referenced_template_names(templates: Iterable[str]) -> set[str]: - """ - Collects the names used inside the ``{...}`` placeholders of - *templates*. A format spec after the first colon is ignored. - """ - names: set[str] = set() - for template in templates: - for match in _PLACEHOLDER_RE.finditer(template or ""): - expression, _sep, _spec = match.group(1).partition(":") - names |= _names_in_expression(expression) - return names diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 9816c3ad3..5bd551b12 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -32,7 +32,6 @@ from .image_cache import new_image_cache from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem -from .job_counter import referenced_template_names from .source_asset_segment import SourceAssetSegment from .tab import Tab from .vectorization_spec import TraceSpec @@ -628,9 +627,7 @@ def in_world(self) -> WorkPiece: world_wp.tabs = deepcopy(self.tabs) world_wp.tabs_enabled = self.tabs_enabled world_wp.geometry_provider_uid = self.geometry_provider_uid - world_wp._geometry_provider_params = deepcopy( - self._world_provider_params() - ) + world_wp._geometry_provider_params = deepcopy(self._provider_params()) world_wp._variable_offset = self._variable_offset world_wp._template_values_used = deepcopy(self._template_values_used) world_wp.source_asset_uid = self.source_asset_uid @@ -1166,47 +1163,13 @@ def _provider_params(self) -> dict[str, Any]: params.update(self._geometry_provider_params or {}) return params - def _world_provider_params(self) -> dict[str, Any]: - """ - Parameters for a detached copy: the template variables are baked - in only when this workpiece's templates read them. - """ - doc = self.doc - if doc is not None and self.references_template_names( - doc.job_counter.names - ): - return self._provider_params() - return dict(self._geometry_provider_params or {}) - - def template_sources(self) -> list[str]: - """The raw template strings the geometry provider renders.""" - provider = self.get_geometry_provider() - get_sources = getattr(provider, "get_template_sources", None) - if get_sources is None: - return [] - return list(get_sources()) - - def references_template_names(self, names: set[str]) -> bool: - """Whether any template of this workpiece reads one of *names*.""" - if not self.geometry_provider_uid: - return False - used = referenced_template_names(self.template_sources()) - return bool(used & names) - def refresh_template_values(self) -> None: """ Regenerates the geometry after a template variable changed. - Only workpieces whose templates read one of the document's - variables are affected. The current scale relative to the - natural size is kept, so a longer text grows instead of being - squeezed into the old box. + The current scale relative to the natural size is kept, so a + longer text grows instead of being squeezed into the old box. """ - doc = self.doc - if doc is None or not self.references_template_names( - doc.job_counter.names - ): - return old_w, old_h = self.natural_width_mm, self.natural_height_mm size_w, size_h = self.size self._resolved_text_cache = {} @@ -1224,6 +1187,8 @@ def sync_template_values(self) -> None: values than the document offers now, e.g. after the workpiece was created outside the document or copied with a new offset. """ + if not self.geometry_provider_uid: + return if self._template_values_used != self.template_values(): self.refresh_template_values() diff --git a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py index 22b0ab445..d414ded4d 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py +++ b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py @@ -17,19 +17,10 @@ logger = logging.getLogger(__name__) -def _uses_variables(item: DocItem) -> bool: - doc = item.doc - return ( - isinstance(item, WorkPiece) - and doc is not None - and item.references_template_names(doc.job_counter.names) - ) - - class JobCounterPropertyProvider(PropertyProvider): """ - Shows the variable offset of workpieces whose text templates use a - document variable such as ``{counter}``. + Shows the variable offset of the selected workpieces, with a + shortcut to the counter settings. """ priority = 30 @@ -37,7 +28,9 @@ class JobCounterPropertyProvider(PropertyProvider): group_title = _("Job Counter") def can_handle(self, items: list[DocItem]) -> bool: - return bool(items) and all(_uses_variables(i) for i in items) + return bool(items) and all( + isinstance(i, WorkPiece) for i in items + ) def create_widgets(self) -> list[Gtk.Widget]: self.offset_row = SpinRow( diff --git a/tests/core/template_provider.py b/tests/core/template_provider.py index 11231b5cc..cb5f4eb93 100644 --- a/tests/core/template_provider.py +++ b/tests/core/template_provider.py @@ -7,7 +7,10 @@ from raygeo.geo import Geometry from rayforge.core.doc import Doc -from rayforge.core.expression import ExpressionMap +from rayforge.core.expression import ( + ExpressionMap, + referenced_template_names, +) from rayforge.core.workpiece import WorkPiece @@ -50,6 +53,9 @@ def renderer(self): def get_template_sources(self) -> list[str]: return [self.template] + def variables_used(self) -> set[str]: + return referenced_template_names(self.get_template_sources()) + def get_geometry(self, params=None, *, resolved_text_cache=None): params = dict(params or {}) self.calls.append(params) diff --git a/tests/core/test_expression_placeholders.py b/tests/core/test_expression_placeholders.py new file mode 100644 index 000000000..79a5e2349 --- /dev/null +++ b/tests/core/test_expression_placeholders.py @@ -0,0 +1,19 @@ +from rayforge.core.expression import referenced_template_names + + +class TestReferencedTemplateNames: + def test_finds_names_in_placeholders(self): + names = referenced_template_names(["No. {counter:04d}", "{width * 2}"]) + assert {"counter", "width"} <= names + + def test_ignores_text_outside_placeholders(self): + assert "counter" not in referenced_template_names(["counter"]) + + def test_handles_function_calls_and_subscripts(self): + names = referenced_template_names(['{csv["Name"]} {today()}']) + assert {"csv", "today"} <= names + assert "Name" not in names + + def test_invalid_expression_falls_back_to_identifiers(self): + names = referenced_template_names(["{counter +}"]) + assert "counter" in names diff --git a/tests/core/test_job_counter.py b/tests/core/test_job_counter.py index a141f2a9b..07e758856 100644 --- a/tests/core/test_job_counter.py +++ b/tests/core/test_job_counter.py @@ -1,8 +1,4 @@ -from rayforge.core.job_counter import ( - CounterSettings, - JobCounter, - referenced_template_names, -) +from rayforge.core.job_counter import CounterSettings, JobCounter class TestCounterSettings: @@ -115,21 +111,3 @@ def test_round_trip(self): def test_from_dict_none(self): assert JobCounter.from_dict(None).counter == CounterSettings() - - -class TestReferencedTemplateNames: - def test_finds_names_in_placeholders(self): - names = referenced_template_names(["No. {counter:04d}", "{width * 2}"]) - assert {"counter", "width"} <= names - - def test_ignores_text_outside_placeholders(self): - assert "counter" not in referenced_template_names(["counter"]) - - def test_handles_function_calls_and_subscripts(self): - names = referenced_template_names(['{csv["Name"]} {today()}']) - assert {"csv", "today"} <= names - assert "Name" not in names - - def test_invalid_expression_falls_back_to_identifiers(self): - names = referenced_template_names(["{counter +}"]) - assert "counter" in names diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index c59de42fd..fa4527fce 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -72,16 +72,20 @@ def test_in_world_bakes_values(self, doc): world = wp.in_world() assert world.geometry_provider_params["counter"] == 4 - def test_in_world_leaves_plain_templates_alone(self, doc): + def test_in_world_bakes_values_for_plain_templates(self, doc): doc.job_counter.counter = CounterSettings(current=3) wp = place(doc, "Hello") - assert "counter" not in wp.in_world().geometry_provider_params + assert wp.in_world().geometry_provider_params["counter"] == 3 - def test_references_variables(self, doc): + def test_provider_reports_used_variables(self, doc): wp = place(doc, "{counter:04d}") plain = place(doc, "Hello") - assert wp.references_template_names({"counter"}) - assert not plain.references_template_names({"counter"}) + counter_provider = wp.get_geometry_provider() + plain_provider = plain.get_geometry_provider() + assert isinstance(counter_provider, TemplateProvider) + assert isinstance(plain_provider, TemplateProvider) + assert counter_provider.variables_used() == {"counter"} + assert plain_provider.variables_used() == set() class TestRefreshOnChange: @@ -114,13 +118,12 @@ def on_updated(sender, **kwargs): doc.job_counter.counter = CounterSettings(current=5) assert wp in received - def test_unrelated_workpiece_is_not_regenerated(self, doc): + def test_unrelated_workpiece_keeps_its_text(self, doc): wp = place(doc, "Hello") provider = wp.get_geometry_provider() assert isinstance(provider, TemplateProvider) - calls = len(provider.calls) doc.job_counter.counter = CounterSettings(current=5) - assert len(provider.calls) == calls + assert provider.rendered[-1] == "Hello" def test_offset_change_regenerates(self, doc): doc.job_counter.counter = CounterSettings(current=1) diff --git a/tests/ui_gtk/doceditor/test_job_counter_dialog.py b/tests/ui_gtk/doceditor/test_job_counter_dialog.py index 5a874e4c1..23b53bf33 100644 --- a/tests/ui_gtk/doceditor/test_job_counter_dialog.py +++ b/tests/ui_gtk/doceditor/test_job_counter_dialog.py @@ -110,12 +110,12 @@ def test_switches_update_model(editor, dialog): @pytest.mark.ui -def test_property_provider_handles_template_workpieces(editor): +def test_property_provider_handles_workpieces(editor): wp = place(editor.doc, "{counter:03d}") plain = place(editor.doc, "Hello") provider = JobCounterPropertyProvider() assert provider.can_handle([wp]) - assert not provider.can_handle([plain]) + assert provider.can_handle([plain]) assert not provider.can_handle([]) diff --git a/website/docs/features/variable-text.md b/website/docs/features/job-counter.md similarity index 100% rename from website/docs/features/variable-text.md rename to website/docs/features/job-counter.md diff --git a/website/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index 96adaf8d5..aa52dd05a 100644 --- a/website/docs/features/sketcher/expressions.md +++ b/website/docs/features/sketcher/expressions.md @@ -118,7 +118,7 @@ registered for them (see [below](#custom-template-functions)). | `{uuid()}` | `str` | Full UUID v4 string (36 chars) | For a job counter that advances after every job, use the project variable `{counter}` (see -[Job Counter](../variable-text.md)). +[Job Counter](../job-counter.md)). Typical uses include unique serial numbers per solve (`Part # {uuid4()}`), live dimension labels (`W={width:.1f} H={height:.1f}`), date stamps (`Date: {today()}`), production counters diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/de/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index c7c3c6d24..8fe48bc89 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -123,7 +123,7 @@ Funktionen bereit, und es können benutzerdefinierte Funktionen für sie registr | `{uuid()}` | `str` | Vollständiger UUID v4-String (36 Zeichen) | Für einen Auftragszähler, der nach jedem Auftrag weiterzählt, verwende die Projektvariable `{counter}` -(siehe [Auftragszähler](../variable-text.md)). +(siehe [Auftragszähler](../job-counter.md)). Typische Anwendungen sind eindeutige Seriennummern pro Lösung (`Part # {uuid4()}`), live Maßbeschriftungen (`W={width:.1f} H={height:.1f}`), Datumsstempel (`Date: {today()}`), diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/es/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 0d4430537..cc3ab105b 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -125,7 +125,7 @@ funciones integradas de abajo, y se pueden registrar funciones personalizadas pa | `{uuid()}` | `str` | Cadena UUID v4 completa (36 caracteres) | Para un contador de trabajos que avanza tras cada trabajo, usa la variable del proyecto `{counter}` -(consulta [Contador de trabajos](../variable-text.md)). +(consulta [Contador de trabajos](../job-counter.md)). Entre los usos típicos se incluyen números de serie únicos en cada resolución (`Pieza #{uuid4()}`), etiquetas de dimensiones en vivo (`W={width:.1f} H={height:.1f}`), fechas en cada pieza diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/fr/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 225d087ef..a17e4f20e 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -125,7 +125,7 @@ fonctions intégrées ci-dessous, et des fonctions personnalisées peuvent leur | `{uuid()}` | `str` | Chaîne UUID v4 complète (36 caractères) | Pour un compteur de tâches qui avance après chaque travail, utilisez la variable de projet -`{counter}` (voir [Compteur de tâches](../variable-text.md)). +`{counter}` (voir [Compteur de tâches](../job-counter.md)). Les utilisations typiques comprennent les numéros de série uniques à chaque résolution (`Pièce #{uuid4()}`), les étiquettes de dimensions dynamiques (`W={width:.1f} H={height:.1f}`), les diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/hi/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 4b55fed62..d56e2b724 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -117,7 +117,7 @@ W = {width}, H = {height} | `{uuid()}` | `str` | पूर्ण UUID v4 स्ट्रिंग (36 वर्ण) | हर जॉब के बाद आगे बढ़ने वाले जॉब काउंटर के लिए प्रोजेक्ट वेरिएबल `{counter}` का उपयोग करें -([जॉब काउंटर](../variable-text.md) देखें)। +([जॉब काउंटर](../job-counter.md) देखें)। सामान्य उपयोग में प्रति हल अद्वितीय सीरियल नंबर (`Part # {uuid4()}`), लाइव विमा लेबल (`W={width:.1f} H={height:.1f}`), तिथि चिह्न (`Date: {today()}`), उत्पादन काउंटर diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 2435a893c..592970dd9 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -122,7 +122,7 @@ integradas abaixo, e funções personalizadas podem ser registradas para eles (v | `{uuid()}` | `str` | String UUID v4 completa (36 caracteres) | Para um contador de trabalhos que avança após cada trabalho, use a variável do projeto `{counter}` (veja -[Contador de trabalhos](../variable-text.md)). +[Contador de trabalhos](../job-counter.md)). Usos típicos incluem números de série únicos a cada resolução (`Peça #{uuid4()}`), rótulos de dimensões em tempo real (`L={width:.1f} A={height:.1f}`), datar cada peça (`Data: {today()}`), diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/uk/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 70b611d6b..4f9160398 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -119,7 +119,7 @@ W = {width}, H = {height} | `{uuid()}` | `str` | Повний рядок UUID v4 (36 символів) | Для лічильника завдань, що збільшується після кожного завдання, використовуйте змінну проєкту -`{counter}` (див. [Лічильник завдань](../variable-text.md)). +`{counter}` (див. [Лічильник завдань](../job-counter.md)). Типові застосування включають унікальні серійні номери при кожному розв'язанні (`Деталь #{uuid4()}`), живі мітки розмірів (`Ш={width:.1f} В={height:.1f}`), датування кожної деталі diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md similarity index 100% rename from website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/variable-text.md rename to website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md index 3f5b76650..d8ab1e5bd 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/expressions.md @@ -97,7 +97,7 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` 的别名 | | `{uuid()}` | `str` | 完整 UUID v4 字符串(36 字符) | -如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[任务计数器](../variable-text.md))。 +如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[任务计数器](../job-counter.md))。 典型用途包括每次求解生成唯一序列号(`零件 #{uuid4()}`)、实时尺寸标签(`宽={width:.1f} 高={height:.1f}`)、日期戳(`日期:{today()}`)、生产计数器(`{name} - {count:.0f}个`),或用于生产日志的 Unix 时间戳(`{timestamp():.0f}`)。 diff --git a/website/sidebars.js b/website/sidebars.js index f8f1e70aa..e9201f2f2 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -106,7 +106,7 @@ module.exports = { }, 'features/arrays', 'features/path-cleanup', - 'features/variable-text', + 'features/job-counter', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job', From a67922cb4787180ebc386ff774504d49669e65bd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 18 Oct 2026 21:04:13 +0200 Subject: [PATCH 16/33] test: give the fake providers the new protocol methods pyright checks structural conformance to IGeometryProvider; the fake providers in the workpiece, wavefront and offset-fill tests now implement get_template_sources() and variables_used(). --- .../tests/steps/test_offset_fill_step.py | 6 ++++++ .../rayforge-addon-laser/tests/steps/test_wavefront_step.py | 6 ++++++ tests/core/test_workpiece.py | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_offset_fill_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_offset_fill_step.py index d1be3e6d2..4ce2833df 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_offset_fill_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_offset_fill_step.py @@ -50,6 +50,12 @@ def provider_type_name(self) -> str: def renderer(self): return None + def get_template_sources(self) -> list[str]: + return [] + + def variables_used(self) -> set[str]: + return set() + def get_geometry(self, params=None, *, resolved_text_cache=None): return self._geometry.copy(), [] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py index e09317cb5..bc2d46b9f 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_wavefront_step.py @@ -43,6 +43,12 @@ def provider_type_name(self) -> str: def renderer(self): return None + def get_template_sources(self) -> list[str]: + return [] + + def variables_used(self) -> set[str]: + return set() + def get_geometry(self, params=None, *, resolved_text_cache=None): return self._geometry.copy(), [] diff --git a/tests/core/test_workpiece.py b/tests/core/test_workpiece.py index 7810faae2..25d0a3cf4 100644 --- a/tests/core/test_workpiece.py +++ b/tests/core/test_workpiece.py @@ -112,6 +112,12 @@ def provider_type_name(self) -> str: def renderer(self): return None + def get_template_sources(self) -> list[str]: + return [] + + def variables_used(self) -> set[str]: + return set() + def get_geometry(self, params=None, *, resolved_text_cache=None): return self._geometry.copy(), [] From 859729cb5f3ce26ce8c351ef15a5c2b9c16a0310 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 19 Oct 2026 16:26:53 +0200 Subject: [PATCH 17/33] fix: key the sketch geometry cache on the caller's text and state 45a86cfc7 made Sketch.get_geometry reuse a result for the same parameter values, but the key ignored the resolved text the caller seeds and the cache survived edits that only re-solve the sketch: - two workpieces of one sketch shared one {uuid4()} value, because the second caller got the first caller's geometry and text back; - after a text box was edited and solved, the old text kept rendering until something sent the updated signal. The key now includes the seeded text. A result is also stored under the request's own key only when it is reproducible, i.e. no text was newly resolved from a template function, so every new caller still gets its own uuid. solve() and apply_undo_state() drop the cache like the updated signal does. in_world no longer writes the document's values into the copy's geometry_provider_params, which stay the instance's own overrides as on main. The copy carries them as the values its geometry uses, and a workpiece outside a document renders with those values. --- .../sketcher/core/sketch.py | 57 ++++++++++++++----- .../tests/core/test_sketch_job_counter.py | 27 +++++++++ rayforge/core/workpiece.py | 14 +++-- tests/core/test_workpiece_job_counter.py | 26 ++++++++- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 772ca1716..566df6f9e 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -59,6 +59,7 @@ from .types import EntityID _GEOMETRY_CACHE_SIZE = 8 +_GeometryKey = tuple[tuple[tuple[str, str], ...], ...] DEFAULT_FILL_COLOR: ColorRGBA = (0.85, 0.85, 0.85, 0.7) FORMAT_VERSION = 1 @@ -190,8 +191,7 @@ def __init__(self, name: str = "New Sketch") -> None: # Geometry of the last calls, keyed by effective parameter # values; cleared whenever the sketch itself changes. self._geometry_cache: dict[ - tuple[tuple[str, str], ...], - tuple[Geometry, list[FillRenderData], dict], + _GeometryKey, tuple[Geometry, list[FillRenderData], dict] ] = {} self._updated.connect(self._invalidate_geometry_cache) @@ -255,6 +255,7 @@ def apply_undo_state(self, state: dict[str, Any]) -> None: a = by_uid.get(uid) if a is not None: a.restore(astate) + self._invalidate_geometry_cache() def _validate_and_cleanup_fills(self): """ @@ -345,7 +346,8 @@ def get_geometry( Returns: A tuple of (stroke_geometry, fill_render_data). """ - key = self._geometry_cache_key(params) + seed = dict(resolved_text_cache or {}) + key = self._geometry_cache_key(params, seed) cached = self._geometry_cache.get(key) if cached is not None: geo, fills, resolved = cached @@ -364,7 +366,12 @@ def get_geometry( fills = clone.get_fill_render_data() if resolved_text_cache is not None: resolved_text_cache.update(clone._resolved_text_cache) - self._remember_geometry(key, geo, fills, clone._resolved_text_cache) + resolved = clone._resolved_text_cache + self._remember_geometry( + self._geometry_cache_key(params, resolved), geo, fills, resolved + ) + if self._is_reproducible(seed, resolved): + self._remember_geometry(key, geo, fills, resolved) return geo, fills def get_template_sources(self) -> list[str]: @@ -380,23 +387,45 @@ def variables_used(self) -> set[str]: return referenced_template_names(self.get_template_sources()) def _geometry_cache_key( - self, params: dict[str, Any] | None - ) -> tuple[tuple[str, str], ...]: + self, params: dict[str, Any] | None, texts: dict + ) -> _GeometryKey: """ - The effective parameter values of a geometry request. Two - requests with the same values produce the same geometry as - long as the sketch itself does not change. + The effective parameter values and the seeded text of a + geometry request. Two requests with the same key produce the + same geometry as long as the sketch itself does not change. """ values = dict(self.input_parameters.get_values()) if params: values.update(params) - return tuple( - sorted((name, repr(value)) for name, value in values.items()) + return ( + tuple( + sorted((name, repr(value)) for name, value in values.items()) + ), + tuple( + sorted( + (str(eid), repr(tuple(entry))) + for eid, entry in texts.items() + ) + ), + ) + + def _is_reproducible(self, seed: dict, resolved: dict) -> bool: + """ + Whether a geometry request seeded with *seed* always resolves + to *resolved*: true unless a text was newly resolved from a + template that calls a template function, as those (uuids, + timestamps) give every caller a fresh value. + """ + functions = set(get_template_functions()) + return not any( + referenced_template_names([entry[0]]) & functions + for eid, entry in resolved.items() + if tuple(seed.get(eid, ())) != tuple(entry) ) def _remember_geometry( self, - key: tuple[tuple[str, str], ...], + key: _GeometryKey, geo: Geometry, fills: list[FillRenderData], resolved: dict, @@ -406,7 +435,7 @@ def _remember_geometry( while len(self._geometry_cache) > _GEOMETRY_CACHE_SIZE: self._geometry_cache.pop(next(iter(self._geometry_cache))) - def _invalidate_geometry_cache(self, sender) -> None: + def _invalidate_geometry_cache(self, sender=None) -> None: """Drops cached geometry when the sketch itself changes.""" self._geometry_cache.clear() @@ -464,6 +493,7 @@ def _clone_for_geometry(self) -> "Sketch": clone._last_solve_values = {} clone._resolved_text_cache = {} clone._solved_ctx = None + clone._geometry_cache = {} # Build coincident cache so geometry generation is fast. clone._coincident_cache = {} clone._coincident_dirty = False @@ -1478,6 +1508,7 @@ def solve( if self._solving: return True self._solving = True + self._invalidate_geometry_cache() success = False solver: Solver | None = None all_constraints: list[Constraint] = [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index b71aadfb0..4eb707484 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -65,6 +65,33 @@ def test_sketch_change_drops_cached_geometry(): assert again is not first +def test_solve_drops_cached_geometry(): + sketch, box = _sketch_with_text("{counter}") + first, _fills = sketch.get_geometry({"counter": 1}) + box.content = "{counter}{counter}" + sketch.solve() + again, _fills = sketch.get_geometry({"counter": 1}) + assert again is not first + + +def test_volatile_text_is_not_shared_between_callers(): + sketch, box = _sketch_with_text("{uuid8()}") + first: dict = {} + second: dict = {} + geo_a, _fills = sketch.get_geometry(resolved_text_cache=first) + geo_b, _fills = sketch.get_geometry(resolved_text_cache=second) + assert first[box.id] != second[box.id] + assert geo_b is not geo_a + + +def test_volatile_text_reuses_geometry_for_its_caller(): + sketch, _box = _sketch_with_text("{uuid8()}") + cache: dict = {} + first, _fills = sketch.get_geometry(resolved_text_cache=cache) + again, _fills = sketch.get_geometry(resolved_text_cache=cache) + assert again is first + + def test_counter_is_resolved_in_document(): doc = Doc() doc.job_counter.counter = CounterSettings(current=7) diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 5bd551b12..1aec2dde2 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -627,9 +627,11 @@ def in_world(self) -> WorkPiece: world_wp.tabs = deepcopy(self.tabs) world_wp.tabs_enabled = self.tabs_enabled world_wp.geometry_provider_uid = self.geometry_provider_uid - world_wp._geometry_provider_params = deepcopy(self._provider_params()) + world_wp.geometry_provider_params = deepcopy( + self._geometry_provider_params + ) world_wp._variable_offset = self._variable_offset - world_wp._template_values_used = deepcopy(self._template_values_used) + world_wp._template_values_used = self.template_values() world_wp.source_asset_uid = self.source_asset_uid world_wp._resolved_text_cache = dict(self._resolved_text_cache) @@ -1148,10 +1150,14 @@ def variable_offset(self, value: int) -> None: self.refresh_template_values() def template_values(self) -> dict[str, Any]: - """The document's template variables as seen by this copy.""" + """ + The document's template variables as seen by this copy. A + workpiece outside a document keeps the values its geometry was + generated with, so a detached copy renders the same text. + """ doc = self.doc if doc is None: - return {} + return dict(self._template_values_used) return doc.job_counter.template_values(self._variable_offset) def _provider_params(self) -> dict[str, Any]: diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index fa4527fce..b7fd70841 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -70,12 +70,34 @@ def test_in_world_bakes_values(self, doc): doc.job_counter.counter = CounterSettings(current=3) wp = place(doc, "{counter}", offset=1) world = wp.in_world() - assert world.geometry_provider_params["counter"] == 4 + assert world.template_values() == {"counter": 4} def test_in_world_bakes_values_for_plain_templates(self, doc): doc.job_counter.counter = CounterSettings(current=3) wp = place(doc, "Hello") - assert wp.in_world().geometry_provider_params["counter"] == 3 + assert wp.in_world().template_values() == {"counter": 3} + + def test_in_world_keeps_instance_params(self, doc): + wp = place(doc, "{counter}") + wp._geometry_provider_params = {"width": 5.0} + assert wp.in_world().geometry_provider_params == {"width": 5.0} + + def test_in_world_renders_baked_values(self, doc): + doc.job_counter.counter = CounterSettings(current=3) + wp = place(doc, "No. {counter}", offset=1) + world = wp.in_world() + world.clear_render_cache() + provider = wp.get_geometry_provider() + world._transient_geometry_provider = provider + assert world.boundaries is not None + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "No. 4" + + def test_in_world_survives_serialization(self, doc): + doc.job_counter.counter = CounterSettings(current=8) + wp = place(doc, "{counter}") + world = WorkPiece.from_dict(wp.in_world().to_dict()) + assert world.template_values() == {"counter": 8} def test_provider_reports_used_variables(self, doc): wp = place(doc, "{counter:04d}") From f1340d54752691eaedf005e19d0c23219cbd4046 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 19 Oct 2026 16:27:37 +0200 Subject: [PATCH 18/33] fix: let the sketch ignore variables it does not read The geometry cache key held every value a caller passed, so a job counter step still re-solved every sketch, including the ones whose templates and expressions never mention the counter. The key now only holds the names the sketch reads: its input parameters, its parameter and constraint expressions, and its text templates. The sketch thus answers on its own whether new variables change its geometry. --- .../sketcher/core/sketch.py | 35 ++++++++++++++++--- .../tests/core/test_sketch_job_counter.py | 25 +++++++++++++ rayforge/core/expression/__init__.py | 3 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 566df6f9e..ac638d69d 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -19,7 +19,11 @@ from rayforge.core.asset import IAsset from rayforge.core.color import ColorRGBA -from rayforge.core.expression import ExpressionMap, referenced_template_names +from rayforge.core.expression import ( + ExpressionMap, + names_in_expression, + referenced_template_names, +) from rayforge.core.geometry_provider import IGeometryProvider from rayforge.core.varset import VarSet from rayforge.image.geo_renderer import render_geometry_to_png @@ -390,16 +394,23 @@ def _geometry_cache_key( self, params: dict[str, Any] | None, texts: dict ) -> _GeometryKey: """ - The effective parameter values and the seeded text of a - geometry request. Two requests with the same key produce the - same geometry as long as the sketch itself does not change. + The effective values of the names this sketch reads and the + seeded text of a geometry request. Two requests with the same + key produce the same geometry as long as the sketch itself does + not change, so a variable the sketch does not read never causes + a re-solve. """ values = dict(self.input_parameters.get_values()) if params: values.update(params) + read = self._names_read() return ( tuple( - sorted((name, repr(value)) for name, value in values.items()) + sorted( + (name, repr(value)) + for name, value in values.items() + if name in read + ) ), tuple( sorted( @@ -409,6 +420,20 @@ def _geometry_cache_key( ), ) + def _names_read(self) -> set[str]: + """ + The variable names that can influence this sketch's geometry: + its input parameters, its parameter expressions, its constraint + expressions and its text templates. + """ + expressions = self.params.to_dict()["expressions"] + sources = list(expressions.values()) + sources.extend(c.expression for c in self.constraints if c.expression) + names = set(self.input_parameters.keys()) | set(expressions) + for source in sources: + names |= names_in_expression(source) + return names | self.variables_used() + def _is_reproducible(self, seed: dict, resolved: dict) -> bool: """ Whether a geometry request seeded with *seed* always resolves diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index 4eb707484..1c08241d8 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -57,6 +57,31 @@ def test_geometry_is_reused_for_same_values(): assert other is not first +def test_unread_variable_does_not_regenerate(): + sketch, _box = _sketch_with_text("Hello") + first, _fills = sketch.get_geometry({"counter": 1}) + again, _fills = sketch.get_geometry({"counter": 2}) + assert again is first + + +def test_variable_read_by_a_parameter_regenerates(): + sketch, _box = _sketch_with_text("Hello") + sketch.params.set("gap", "counter * 2") + first, _fills = sketch.get_geometry({"counter": 1}) + again, _fills = sketch.get_geometry({"counter": 2}) + assert again is not first + + +def test_variable_read_by_a_constraint_regenerates(): + sketch, _box = _sketch_with_text("Hello") + start = sketch.add_point(0, 20) + end = sketch.add_point(10, 20) + sketch.constrain_distance(start, end, "counter * 10") + first, _fills = sketch.get_geometry({"counter": 1}) + again, _fills = sketch.get_geometry({"counter": 2}) + assert again is not first + + def test_sketch_change_drops_cached_geometry(): sketch, _box = _sketch_with_text("{counter}") first, _fills = sketch.get_geometry({"counter": 1}) diff --git a/rayforge/core/expression/__init__.py b/rayforge/core/expression/__init__.py index 679a60636..106b39ade 100644 --- a/rayforge/core/expression/__init__.py +++ b/rayforge/core/expression/__init__.py @@ -3,7 +3,7 @@ from .evaluator import safe_evaluate from .expression_map import ExpressionMap from .parser import ExpressionParser -from .placeholders import referenced_template_names +from .placeholders import names_in_expression, referenced_template_names from .tokenizer import ExpressionTokenizer, Token, TokenType from .validator import ExpressionValidator @@ -17,6 +17,7 @@ "TokenType", "ValidationResult", "ValidationStatus", + "names_in_expression", "referenced_template_names", "safe_evaluate", ] From eef4f3e9a4343e579d78c72438f94ab37d6a5f48 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 19 Oct 2026 16:30:14 +0200 Subject: [PATCH 19/33] fix: hand out copies of cached sketch geometry A workpiece normalizes the geometry and fills it gets from its provider in place, so the geometry cache held the normalized unit square after the first caller and gave it to the next one: a second workpiece of an unchanged sketch came out 1 x 1 mm. The cache now stores and returns copies, and the tests count solves instead of comparing object identity. --- .../sketcher/core/sketch.py | 15 ++- .../tests/core/test_sketch_job_counter.py | 97 +++++++++++++------ 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index ac638d69d..8b837c755 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -4,6 +4,7 @@ import math import uuid from collections import defaultdict +from dataclasses import replace from gettext import gettext as _ from pathlib import Path from typing import Any, ClassVar @@ -65,6 +66,12 @@ _GEOMETRY_CACHE_SIZE = 8 _GeometryKey = tuple[tuple[tuple[str, str], ...], ...] + +def _copy_fills(fills: list[FillRenderData]) -> list[FillRenderData]: + """Copies fills deep enough that callers may transform them.""" + return [replace(fill, geometry=fill.geometry.copy()) for fill in fills] + + DEFAULT_FILL_COLOR: ColorRGBA = (0.85, 0.85, 0.85, 0.7) FORMAT_VERSION = 1 _DEFAULT_VARSET_TITLE = _("Sketch Parameters") @@ -357,7 +364,7 @@ def get_geometry( geo, fills, resolved = cached if resolved_text_cache is not None: resolved_text_cache.update(resolved) - return geo, fills + return geo.copy(), _copy_fills(fills) clone = self._clone_for_geometry() if resolved_text_cache is not None: clone._resolved_text_cache = dict(resolved_text_cache) @@ -456,7 +463,11 @@ def _remember_geometry( resolved: dict, ) -> None: """Caches a result, evicting the oldest when it grows too big.""" - self._geometry_cache[key] = (geo, fills, dict(resolved)) + self._geometry_cache[key] = ( + geo.copy(), + _copy_fills(fills), + dict(resolved), + ) while len(self._geometry_cache) > _GEOMETRY_CACHE_SIZE: self._geometry_cache.pop(next(iter(self._geometry_cache))) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index 1c08241d8..6ca827e19 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -1,5 +1,6 @@ from typing import cast +import numpy as np from raygeo.geo.shape.text import FontConfig from sketcher.core import Sketch from sketcher.core.entities.text_box import TextBoxEntity @@ -48,73 +49,105 @@ def test_plain_sketch_uses_no_variables(): assert sketch.variables_used() == set() -def test_geometry_is_reused_for_same_values(): +def _count_solves(monkeypatch, sketch: Sketch) -> list[Sketch]: + """Records every clone the sketch solves for a geometry request.""" + clones: list[Sketch] = [] + clone_for_geometry = sketch._clone_for_geometry + + def counting_clone() -> Sketch: + clone = clone_for_geometry() + clones.append(clone) + return clone + + monkeypatch.setattr(sketch, "_clone_for_geometry", counting_clone) + return clones + + +def test_geometry_is_reused_for_same_values(monkeypatch): + sketch, _box = _sketch_with_text("{counter}") + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + sketch.get_geometry({"counter": 1}) + assert len(solves) == 1 + sketch.get_geometry({"counter": 2}) + assert len(solves) == 2 + + +def test_reused_geometry_is_a_copy(): sketch, _box = _sketch_with_text("{counter}") first, _fills = sketch.get_geometry({"counter": 1}) + rect = first.rect() + first.transform(np.diag([0.5, 0.5, 1.0, 1.0])) again, _fills = sketch.get_geometry({"counter": 1}) - assert again is first - other, _fills = sketch.get_geometry({"counter": 2}) - assert other is not first + assert again is not first + assert again.rect() == rect -def test_unread_variable_does_not_regenerate(): +def test_unread_variable_does_not_regenerate(monkeypatch): sketch, _box = _sketch_with_text("Hello") - first, _fills = sketch.get_geometry({"counter": 1}) - again, _fills = sketch.get_geometry({"counter": 2}) - assert again is first + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + sketch.get_geometry({"counter": 2}) + assert len(solves) == 1 -def test_variable_read_by_a_parameter_regenerates(): +def test_variable_read_by_a_parameter_regenerates(monkeypatch): sketch, _box = _sketch_with_text("Hello") sketch.params.set("gap", "counter * 2") - first, _fills = sketch.get_geometry({"counter": 1}) - again, _fills = sketch.get_geometry({"counter": 2}) - assert again is not first + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + sketch.get_geometry({"counter": 2}) + assert len(solves) == 2 -def test_variable_read_by_a_constraint_regenerates(): +def test_variable_read_by_a_constraint_regenerates(monkeypatch): sketch, _box = _sketch_with_text("Hello") start = sketch.add_point(0, 20) end = sketch.add_point(10, 20) sketch.constrain_distance(start, end, "counter * 10") - first, _fills = sketch.get_geometry({"counter": 1}) - again, _fills = sketch.get_geometry({"counter": 2}) - assert again is not first + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + sketch.get_geometry({"counter": 2}) + assert len(solves) == 2 -def test_sketch_change_drops_cached_geometry(): +def test_sketch_change_drops_cached_geometry(monkeypatch): sketch, _box = _sketch_with_text("{counter}") - first, _fills = sketch.get_geometry({"counter": 1}) + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) sketch.notify_update() - again, _fills = sketch.get_geometry({"counter": 1}) - assert again is not first + sketch.get_geometry({"counter": 1}) + assert len(solves) == 2 -def test_solve_drops_cached_geometry(): +def test_solve_drops_cached_geometry(monkeypatch): sketch, box = _sketch_with_text("{counter}") - first, _fills = sketch.get_geometry({"counter": 1}) + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) box.content = "{counter}{counter}" sketch.solve() - again, _fills = sketch.get_geometry({"counter": 1}) - assert again is not first + sketch.get_geometry({"counter": 1}) + assert len(solves) == 2 -def test_volatile_text_is_not_shared_between_callers(): +def test_volatile_text_is_not_shared_between_callers(monkeypatch): sketch, box = _sketch_with_text("{uuid8()}") + solves = _count_solves(monkeypatch, sketch) first: dict = {} second: dict = {} - geo_a, _fills = sketch.get_geometry(resolved_text_cache=first) - geo_b, _fills = sketch.get_geometry(resolved_text_cache=second) + sketch.get_geometry(resolved_text_cache=first) + sketch.get_geometry(resolved_text_cache=second) assert first[box.id] != second[box.id] - assert geo_b is not geo_a + assert len(solves) == 2 -def test_volatile_text_reuses_geometry_for_its_caller(): +def test_volatile_text_reuses_geometry_for_its_caller(monkeypatch): sketch, _box = _sketch_with_text("{uuid8()}") + solves = _count_solves(monkeypatch, sketch) cache: dict = {} - first, _fills = sketch.get_geometry(resolved_text_cache=cache) - again, _fills = sketch.get_geometry(resolved_text_cache=cache) - assert again is first + sketch.get_geometry(resolved_text_cache=cache) + sketch.get_geometry(resolved_text_cache=cache) + assert len(solves) == 1 def test_counter_is_resolved_in_document(): From fd9235fe512132aa39f1a5097be2f81ba1ae55db Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:21:25 +0200 Subject: [PATCH 20/33] test: give the template test provider a reference point Workpieces now ask their provider for a reference point (main's anchor modes), so the fake provider needs the method too. Refs #527 --- tests/core/template_provider.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/core/template_provider.py b/tests/core/template_provider.py index cb5f4eb93..9f5307391 100644 --- a/tests/core/template_provider.py +++ b/tests/core/template_provider.py @@ -70,6 +70,9 @@ def get_geometry(self, params=None, *, resolved_text_cache=None): geo.close_path() return geo, [] + def get_reference_point(self): + return None + def to_dict(self) -> dict[str, Any]: return {"type": "template-test", "uid": self._uid} From 4b59ddd9fa5832b02786561c35de0b8e34cc4de1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:21:25 +0200 Subject: [PATCH 21/33] fix: keep the anchor in place when the counter changes the text A counter step regenerates the text and kept the scale, but resized around the center. It now resizes around the workpiece's anchor point, like a sketch edit does since main's anchor modes. Refs #527 --- rayforge/core/workpiece.py | 17 ++++++++++++++--- tests/core/test_workpiece_job_counter.py | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 5ecf40e3c..15c4c39f9 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -1275,17 +1275,21 @@ def refresh_template_values(self) -> None: Regenerates the geometry after a template variable changed. The current scale relative to the natural size is kept, so a - longer text grows instead of being squeezed into the old box. + longer text grows instead of being squeezed into the old box, + and the anchor point (see `anchor_mode`) stays where it was. """ old_w, old_h = self.natural_width_mm, self.natural_height_mm size_w, size_h = self.size + anchor_world = self.get_anchor_world() self._resolved_text_cache = {} self.clear_render_cache() if self.boundaries is not None and old_w > 1e-9 and old_h > 1e-9: self.set_size( self.natural_width_mm * size_w / old_w, self.natural_height_mm * size_h / old_h, + anchor=self.anchor_point, ) + self._move_anchor_to(anchor_world) self.updated.send(self) def sync_template_values(self) -> None: @@ -1804,10 +1808,17 @@ def apply_provider_geometry( f"WP {self.uid[:8]}: New natural size: {width:.2f}x{height:.2f}mm" ) self.set_size(width, height, anchor=self.anchor_point) + self._move_anchor_to(anchor_world_before) + def _move_anchor_to(self, anchor_world: Point) -> None: + """ + Moves the workpiece so its anchor point lies at *anchor_world*. + Needed after new geometry, because the anchor's normalized + position shifts when the bounding box moves relative to it. + """ anchor_world_after = self.get_anchor_world() - dx = anchor_world_before[0] - anchor_world_after[0] - dy = anchor_world_before[1] - anchor_world_after[1] + dx = anchor_world[0] - anchor_world_after[0] + dy = anchor_world[1] - anchor_world_after[1] if abs(dx) > 1e-9 or abs(dy) > 1e-9: pos = self.pos self.pos = (pos[0] + dx, pos[1] + dy) diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index b7fd70841..a86fd0e8a 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -2,7 +2,7 @@ from rayforge.core.doc import Doc from rayforge.core.job_counter import CounterSettings -from rayforge.core.workpiece import WorkPiece +from rayforge.core.workpiece import AnchorMode, WorkPiece from tests.core.template_provider import TemplateProvider, place @@ -129,6 +129,24 @@ def test_counter_change_keeps_scale(self, doc): assert width == pytest.approx(8.0) assert height == pytest.approx(10.0) + def test_counter_change_keeps_corner_in_place(self, doc): + doc.job_counter.counter = CounterSettings(current=9) + wp = place(doc, "{counter}") + wp.pos = (10.0, 20.0) + doc.job_counter.counter = CounterSettings(current=10) + assert wp.pos == pytest.approx((10.0, 20.0)) + assert wp.size[0] == pytest.approx(4.0) + + def test_counter_change_keeps_center_in_place(self, doc): + doc.job_counter.counter = CounterSettings(current=9) + wp = place(doc, "{counter}") + wp.anchor_mode = AnchorMode.CENTER + wp.pos = (10.0, 20.0) + center = wp.get_anchor_world() + doc.job_counter.counter = CounterSettings(current=10) + assert wp.get_anchor_world() == pytest.approx(center) + assert wp.pos == pytest.approx((9.0, 20.0)) + def test_counter_change_emits_updated(self, doc): wp = place(doc, "{counter}") received = [] From d901c649caee6082aa8e39ae76644183bf9de656 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:29:12 +0200 Subject: [PATCH 22/33] docs: move the job counter changelog entry to UNRELEASED After the merge with main it sat in the released 1.13.0-beta1 section, with a stray caret in front. Refs #527 --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7721c168e..0e744f6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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 + +- Job Counter: a `{counter}` variable for text templates, with a + project counter that advances after every finished job and consecutive + numbers for array copies (Tools > Job Counter) (#527) + ## 1.13.0-beta4 ### Changed @@ -203,9 +211,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden -^- Job Counter: a `{counter}` variable for text templates, with a - project counter that advances after every finished job and consecutive - numbers for array copies (Tools > Job Counter) (#527) ### Changed From 1b7e176f28ae47b42349aa8895b3abf9e3109ed3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:40:26 +0200 Subject: [PATCH 23/33] fix: drop the serial number wording from the job counter dialog The group and the preview row still said "Serial Number(s)" after the rename to a plain job counter. Refs #527 --- rayforge/ui_gtk/doceditor/job_counter_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rayforge/ui_gtk/doceditor/job_counter_dialog.py b/rayforge/ui_gtk/doceditor/job_counter_dialog.py index fe76f5a14..b1d7ba154 100644 --- a/rayforge/ui_gtk/doceditor/job_counter_dialog.py +++ b/rayforge/ui_gtk/doceditor/job_counter_dialog.py @@ -72,7 +72,7 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): def _build_counter_group(self) -> Adw.PreferencesGroup: group = Adw.PreferencesGroup( - title=_("Serial Number"), + title=_("Counter"), description=_( "Use {counter} in a text box, e.g. No. {counter:04d} " "for four digits with leading zeros." @@ -138,7 +138,7 @@ def _build_counter_group(self) -> Adw.PreferencesGroup: def _build_job_group(self) -> Adw.PreferencesGroup: group = Adw.PreferencesGroup(title=_("Next Job")) - self.preview_row = Adw.ActionRow(title=_("Serial Numbers")) + self.preview_row = Adw.ActionRow(title=_("Counter Values")) self.preview_row.add_css_class("property") group.add(self.preview_row) From a5aea909c5932822aa61f154a41a07ace3502848 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:40:39 +0200 Subject: [PATCH 24/33] refactor: name the job counter in the last two variable text leftovers Refs #527 --- rayforge/doceditor/job_counter_cmd.py | 2 +- rayforge/ui_gtk/actions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index f66c002ed..7c9ecb7dd 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -23,7 +23,7 @@ class JobCounterCmd: - """Handles changes to the document's variable text settings.""" + """Handles changes to the document's job counter.""" def __init__(self, editor: DocEditor): self._editor = editor diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 25f2d13e0..45d81c4d0 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -478,7 +478,7 @@ def register_actions(self): self._add_action("array-point-rotation", self.on_array_point_rotation) self._add_action("array-circular", self.on_array_circular) - # Variable text (counter values) + # Job counter self._add_action("job-counter", self.on_job_counter) self._add_action("number-copies", self.on_number_copies) From a880612007fd6af8d0d78d37c8c2713f3d9f6e41 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:45:07 +0200 Subject: [PATCH 25/33] test: give main's parametric fake provider the template methods Pyright flagged it after the merge: the provider protocol now has get_template_sources() and variables_used(). Refs #527 --- tests/core/test_workpiece.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/core/test_workpiece.py b/tests/core/test_workpiece.py index 61acf7a09..00540c4e3 100644 --- a/tests/core/test_workpiece.py +++ b/tests/core/test_workpiece.py @@ -151,6 +151,12 @@ def provider_type_name(self) -> str: def renderer(self): return None + def get_template_sources(self) -> list[str]: + return [] + + def variables_used(self) -> set[str]: + return set() + def get_geometry(self, params=None, *, resolved_text_cache=None): values = { "width": self.width, From 30ebd05f920c94ad586cef52bcae39dae85ba6ed Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:45:07 +0200 Subject: [PATCH 26/33] style: apply ruff format to the job counter files Refs #527 --- rayforge/doceditor/job_counter_cmd.py | 8 ++++++-- .../ui_gtk/doceditor/property_providers/job_counter.py | 4 +--- tests/doceditor/test_job_counter_job.py | 4 +--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index 7c9ecb7dd..6d64290bb 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -51,7 +51,9 @@ def set_counter( def next_counter(self) -> None: """Moves the counter forward by one job.""" copies = max(self._editor.doc.job_counter_copies(), 1) - self.set_counter(self.counter.advanced(copies), _("Next counter value")) + self.set_counter( + self.counter.advanced(copies), _("Next counter value") + ) def previous_counter(self) -> None: """Moves the counter back by one job.""" @@ -78,7 +80,9 @@ def advance_after_job(self, exported: bool = False) -> bool: True if the counter was advanced. """ counter = self.counter - enabled = counter.advance_on_export if exported else counter.auto_advance + enabled = ( + counter.advance_on_export if exported else counter.auto_advance + ) copies = self._editor.doc.job_counter_copies() if not enabled or copies == 0: return False diff --git a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py index d414ded4d..c69cb1f9c 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py +++ b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py @@ -28,9 +28,7 @@ class JobCounterPropertyProvider(PropertyProvider): group_title = _("Job Counter") def can_handle(self, items: list[DocItem]) -> bool: - return bool(items) and all( - isinstance(i, WorkPiece) for i in items - ) + return bool(items) and all(isinstance(i, WorkPiece) for i in items) def create_widgets(self) -> list[Gtk.Widget]: self.offset_row = SpinRow( diff --git a/tests/doceditor/test_job_counter_job.py b/tests/doceditor/test_job_counter_job.py index 8b15c6946..3588613df 100644 --- a/tests/doceditor/test_job_counter_job.py +++ b/tests/doceditor/test_job_counter_job.py @@ -44,9 +44,7 @@ def _current(doc_editor) -> int: def _machine_cmd(doc_editor) -> MachineCmd: machine_cmd = MachineCmd(doc_editor) - machine_cmd.job_completed.connect( - doc_editor.job_counter.on_job_completed - ) + machine_cmd.job_completed.connect(doc_editor.job_counter.on_job_completed) return machine_cmd From cf3c4a8aa4e593482af33ec616e08268f33fa14f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:24:00 +0200 Subject: [PATCH 27/33] fix: count no copies for providers that give no variables A provider that inherits the protocol without implementing variables_used() gets None back, and job_counter_copies() raised a TypeError on it. Barcodes from #529 are such a provider until they read template names themselves. Refs #527 --- rayforge/core/doc.py | 7 +++++-- tests/core/test_workpiece_job_counter.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 492686847..0061fc75d 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -199,13 +199,16 @@ def job_counter_copies(self) -> int: """ How many consecutive counter values one job uses: the highest variable offset of a workpiece that reads the counter plus - one, or 0 if no workpiece reads it. + one, or 0 if no workpiece reads it. A provider whose + variables_used() gives None reads no variables. """ names = self._job_counter.names offsets = [] for wp in self.all_workpieces: provider = wp.get_geometry_provider() - if provider is not None and names & provider.variables_used(): + if provider is None: + continue + if names & (provider.variables_used() or set()): offsets.append(wp.variable_offset) if not offsets: return 0 diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index a86fd0e8a..401bf6647 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -1,11 +1,21 @@ import pytest from rayforge.core.doc import Doc +from rayforge.core.geometry_provider import IGeometryProvider from rayforge.core.job_counter import CounterSettings from rayforge.core.workpiece import AnchorMode, WorkPiece from tests.core.template_provider import TemplateProvider, place +class _UntypedProvider(TemplateProvider): + """ + A provider that inherits variables_used() from the protocol without + implementing it, so the call gives None. + """ + + variables_used = IGeometryProvider.variables_used + + @pytest.fixture def doc(): return Doc() @@ -206,3 +216,12 @@ def test_copies_count_uses_highest_offset(self, doc): def test_copies_count_without_template_users(self, doc): place(doc, "Hello") assert doc.job_counter_copies() == 0 + + def test_copies_count_ignores_providers_without_variables(self, doc): + place(doc, "{counter}", offset=2) + provider = _UntypedProvider("{counter}") + doc.add_asset(provider) + wp = WorkPiece.from_geometry_provider(provider) + wp.variable_offset = 7 + doc.active_layer.add_child(wp) + assert doc.job_counter_copies() == 3 From ff56d1a6c1834837f1cccf994acb6fbe52d989da Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:25:45 +0200 Subject: [PATCH 28/33] Revert "fix: count no copies for providers that give no variables" This reverts commit cf3c4a8aa4e593482af33ec616e08268f33fa14f. --- rayforge/core/doc.py | 7 ++----- tests/core/test_workpiece_job_counter.py | 19 ------------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 0061fc75d..492686847 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -199,16 +199,13 @@ def job_counter_copies(self) -> int: """ How many consecutive counter values one job uses: the highest variable offset of a workpiece that reads the counter plus - one, or 0 if no workpiece reads it. A provider whose - variables_used() gives None reads no variables. + one, or 0 if no workpiece reads it. """ names = self._job_counter.names offsets = [] for wp in self.all_workpieces: provider = wp.get_geometry_provider() - if provider is None: - continue - if names & (provider.variables_used() or set()): + if provider is not None and names & provider.variables_used(): offsets.append(wp.variable_offset) if not offsets: return 0 diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index 401bf6647..a86fd0e8a 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -1,21 +1,11 @@ import pytest from rayforge.core.doc import Doc -from rayforge.core.geometry_provider import IGeometryProvider from rayforge.core.job_counter import CounterSettings from rayforge.core.workpiece import AnchorMode, WorkPiece from tests.core.template_provider import TemplateProvider, place -class _UntypedProvider(TemplateProvider): - """ - A provider that inherits variables_used() from the protocol without - implementing it, so the call gives None. - """ - - variables_used = IGeometryProvider.variables_used - - @pytest.fixture def doc(): return Doc() @@ -216,12 +206,3 @@ def test_copies_count_uses_highest_offset(self, doc): def test_copies_count_without_template_users(self, doc): place(doc, "Hello") assert doc.job_counter_copies() == 0 - - def test_copies_count_ignores_providers_without_variables(self, doc): - place(doc, "{counter}", offset=2) - provider = _UntypedProvider("{counter}") - doc.add_asset(provider) - wp = WorkPiece.from_geometry_provider(provider) - wp.variable_offset = 7 - doc.active_layer.add_child(wp) - assert doc.job_counter_copies() == 3 From 0b77f5c954d1611d2b246929ab20578f19eaec1f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:35:33 +0200 Subject: [PATCH 29/33] fix: keep uuid and date text when the job counter changes A counter change cleared the resolved text of every workpiece, so a {uuid4()} or {today()} got a new value, also in sketches that do not read the counter, and undo did not bring the old one back. The sketch now records with each resolved text the values of the variables it read, and re-resolves a text only when one of them changed. The workpiece keeps its resolved text, and the document only refreshes workpieces whose provider reads the counter. With that, the workpiece no longer bakes template values into detached copies: it just passes the counter for its offset to the provider. Refs #527 --- .../sketcher/core/sketch.py | 97 +++++++++++++------ .../tests/core/test_sketch_job_counter.py | 64 ++++++++++++ .../tests/test_sketcher_workpiece.py | 2 +- rayforge/core/doc.py | 28 ++++-- rayforge/core/workpiece.py | 57 +++-------- tests/core/test_workpiece_job_counter.py | 46 +++------ tests/doceditor/test_array_cmd.py | 1 - 7 files changed, 178 insertions(+), 117 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index a64087e4e..bb070bc71 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -68,6 +68,39 @@ _GeometryKey = tuple[tuple[tuple[str, str], ...], ...] +# A text box's resolved text: its source, the result and the values of +# the variables it was resolved with (absent in older entries). +_ResolvedText = ( + tuple[str, str | None] | tuple[str, str | None, list[list[str]]] +) + + +def _value_stamp(content: str, ctx: dict[str, Any]) -> list[list[str]]: + """ + The values of the variables a template reads, as far as *ctx* + has them. Template functions are left out: their result is meant + to stay the same once resolved. + """ + functions = get_template_functions() + names = referenced_template_names([content]) - set(functions) + return [[name, repr(ctx[name])] for name in sorted(names) if name in ctx] + + +def _is_current(cached, content: str, stamp: list[list[str]]) -> bool: + """ + Whether a cached text was resolved from *content* with the values + of *stamp*. A variable missing when the text was resolved, or one + with another value now, makes it stale; one missing now does not, + so a detached copy keeps the text it was given. + """ + if cached[0] != content: + return False + if len(cached) < 3: + return True + resolved_with = {tuple(pair) for pair in cached[2]} + return all(tuple(pair) in resolved_with for pair in stamp) + + def _copy_fills(fills: list[FillRenderData]) -> list[FillRenderData]: """Copies fills deep enough that callers may transform them.""" return [replace(fill, geometry=fill.geometry.copy()) for fill in fills] @@ -197,7 +230,7 @@ def __init__(self, name: str = "New Sketch") -> None: self._updated = Signal() self._hidden: bool = False self._last_solve_values: dict[str, Any] = {} - self._resolved_text_cache: dict[EntityID, tuple[str, str | None]] = {} + self._resolved_text_cache: dict[EntityID, _ResolvedText] = {} self._solved_ctx: dict[str, Any] | None = None # Geometry of the last calls, keyed by effective parameter @@ -1774,32 +1807,19 @@ def _resolve_text_content(self, entity: TextBoxEntity) -> str | None: longer matches the entity's current content is re-resolved, otherwise reverting an edit would keep rendering stale text. """ - cached = self._resolved_text_cache.get(entity.id) - if isinstance(cached, (tuple, list)): - source, resolved = cached - if source == entity.content: - return resolved - if not entity.content: self._resolved_text_cache[entity.id] = ("", None) return None - try: - # Use the cached context from solve() when available, - # avoiding a fresh ParameterContext per text box. - if self._solved_ctx is not None: - ctx = self._solved_ctx - else: - solve_params = ParameterContext.from_dict( - self.params.to_dict() - ) - initial_values = dict(self._last_solve_values) - if not initial_values and self.input_parameters: - initial_values.update(self.input_parameters.get_values()) - solve_params.evaluate_all(initial_values=initial_values) - ctx = solve_params.get_all_values() - ctx.update(get_template_functions()) + ctx = self._text_context() + stamp = _value_stamp(entity.content, ctx) + cached = self._resolved_text_cache.get(entity.id) + if isinstance(cached, (tuple, list)) and _is_current( + cached, entity.content, stamp + ): + return cached[1] + try: expr_map = ExpressionMap(ctx) resolved = expr_map.format(entity.content) logger.debug( @@ -1807,20 +1827,33 @@ def _resolve_text_content(self, entity: TextBoxEntity) -> str | None: f"{entity.content!r} -> {resolved!r} " f"values={list(ctx.keys())[:5]}" ) - self._resolved_text_cache[entity.id] = ( - entity.content, - resolved, - ) - return resolved except (KeyError, IndexError, ValueError) as e: logger.debug( f"Template resolution failed for '{entity.content}': {e}" ) - self._resolved_text_cache[entity.id] = ( - entity.content, - None, - ) - return None + resolved = None + self._resolved_text_cache[entity.id] = ( + entity.content, + resolved, + stamp, + ) + return resolved + + def _text_context(self) -> dict[str, Any]: + """ + The values text templates are resolved with: the context of + the last solve, or a freshly evaluated one. + """ + if self._solved_ctx is not None: + return self._solved_ctx + solve_params = ParameterContext.from_dict(self.params.to_dict()) + initial_values = dict(self._last_solve_values) + if not initial_values and self.input_parameters: + initial_values.update(self.input_parameters.get_values()) + solve_params.evaluate_all(initial_values=initial_values) + ctx = solve_params.get_all_values() + ctx.update(get_template_functions()) + return ctx def to_geometry(self) -> Geometry: """ diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py index 6ca827e19..9635edb61 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -7,6 +7,7 @@ from rayforge.core.doc import Doc from rayforge.core.job_counter import CounterSettings +from rayforge.core.undo import ChangePropertyCommand from rayforge.core.workpiece import WorkPiece @@ -189,3 +190,66 @@ def test_saved_text_is_kept_on_load(): restored_wp = restored.all_workpieces[0] assert restored_wp.boundaries is not None assert _resolved(restored_wp, box) == before + + +def _sketch_with_boxes(*contents: str) -> tuple[Sketch, list[TextBoxEntity]]: + sketch = Sketch(name="plate") + boxes = [] + for i, content in enumerate(contents): + origin = sketch.add_point(0, i * 20) + width_pt = sketch.add_point(10, i * 20) + height_pt = sketch.add_point(0, i * 20 + 10) + box_id = sketch.registry.add_text_box( + origin, width_pt, height_pt, content, FontConfig() + ) + boxes.append(cast(TextBoxEntity, sketch.registry.get_entity(box_id))) + return sketch, boxes + + +def test_counter_change_keeps_uuid_of_other_sketch(): + doc = Doc() + counter_sketch, _counter_box = _sketch_with_text("{counter}") + _place(doc, counter_sketch) + sketch, box = _sketch_with_text("ID {uuid8()}") + wp = _place(doc, sketch) + before = _resolved(wp, box) + doc.job_counter.counter = CounterSettings(current=2) + assert _resolved(wp, box) == before + doc.job_counter.counter = CounterSettings(current=1) + assert _resolved(wp, box) == before + + +def test_counter_change_keeps_uuid_next_to_counter(): + doc = Doc() + sketch, (counter_box, uuid_box) = _sketch_with_boxes( + "{counter}", "ID {uuid8()}" + ) + wp = _place(doc, sketch) + before = _resolved(wp, uuid_box) + doc.job_counter.counter = CounterSettings(current=5) + assert _resolved(wp, counter_box) == "5" + assert _resolved(wp, uuid_box) == before + doc.job_counter.counter = CounterSettings(current=1) + assert _resolved(wp, counter_box) == "1" + assert _resolved(wp, uuid_box) == before + + +def test_uuid_survives_next_and_undo(): + doc = Doc() + counter_sketch, counter_box = _sketch_with_text("{counter}") + counter_wp = _place(doc, counter_sketch) + sketch, box = _sketch_with_text("ID {uuid8()}") + wp = _place(doc, sketch) + before = _resolved(wp, box) + doc.history_manager.execute( + ChangePropertyCommand( + target=doc.job_counter, + property_name="counter", + new_value=doc.job_counter.counter.advanced(1), + ) + ) + assert _resolved(counter_wp, counter_box) == "2" + assert _resolved(wp, box) == before + doc.history_manager.undo() + assert _resolved(counter_wp, counter_box) == "1" + assert _resolved(wp, box) == before diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py index 39a6cb5c1..a827c8a2d 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/test_sketcher_workpiece.py @@ -434,7 +434,7 @@ def test_undo_reverts_rendered_text_not_just_size(doc): # Regeneration must use the REVERTED text: the stale cache entry # (whose source is "abcdef") must not be trusted. _ = wp.boundaries - source, resolved = wp._resolved_text_cache[box_id] + source, resolved = wp._resolved_text_cache[box_id][:2] assert source == "ab" assert resolved == "ab" diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 492686847..16d1cf3a2 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -201,25 +201,35 @@ def job_counter_copies(self) -> int: variable offset of a workpiece that reads the counter plus one, or 0 if no workpiece reads it. """ - names = self._job_counter.names - offsets = [] - for wp in self.all_workpieces: - provider = wp.get_geometry_provider() - if provider is not None and names & provider.variables_used(): - offsets.append(wp.variable_offset) + offsets = [ + wp.variable_offset + for wp in self._counter_readers(self.all_workpieces) + ] if not offsets: return 0 return max(offsets) + 1 + def _counter_readers( + self, workpieces: Iterable[WorkPiece] + ) -> list[WorkPiece]: + """The workpieces whose geometry provider reads the counter.""" + names = self._job_counter.names + readers = [] + for wp in workpieces: + provider = wp.get_geometry_provider() + if provider is not None and names & provider.variables_used(): + readers.append(wp) + return readers + def _on_job_counter_changed(self, sender: JobCounter) -> None: - for wp in self.all_workpieces: + for wp in self._counter_readers(self.all_workpieces): wp.refresh_template_values() def _on_item_added_for_templates( self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem ) -> None: - for wp in workpieces_in(origin): - wp.sync_template_values() + for wp in self._counter_readers(workpieces_in(origin)): + wp.refresh_template_values() def add_asset( self, asset: IAsset, index: int | None = None, silent: bool = False diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 15c4c39f9..51e8eede1 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -163,11 +163,9 @@ def __init__( self._resolved_text_cache: dict = {} - # Shifts the document's template variables for this copy, so - # e.g. array copies show consecutive counter values. + # Shifts the document's job counter for this copy, so array + # copies show consecutive counter values. self._variable_offset: int = 0 - # The template variables the cached geometry was generated with. - self._template_values_used: dict[str, Any] = {} self.source_asset_uid: str | None = None @@ -518,7 +516,6 @@ def boundaries(self) -> Geometry | None: f"WP {self.uid[:8]}: Getting geometry with " f"params: {self._geometry_provider_params}" ) - self._template_values_used = self.template_values() unnormalized_geo, unnormalized_fills = provider.get_geometry( self._provider_params(), resolved_text_cache=self._resolved_text_cache, @@ -723,8 +720,6 @@ def in_world(self) -> WorkPiece: world_wp.geometry_provider_params = deepcopy( self._geometry_provider_params ) - world_wp._variable_offset = self._variable_offset - world_wp._template_values_used = self.template_values() world_wp.source_asset_uid = self.source_asset_uid world_wp._anchor_mode = self._anchor_mode world_wp._reference_anchor = self._reference_anchor @@ -1082,8 +1077,6 @@ def to_dict(self) -> dict[str, Any]: state["resolved_text_cache"] = cache if self._variable_offset: state["variable_offset"] = self._variable_offset - if self._template_values_used: - state["template_values"] = self._template_values_used if self._data is not None: state["data"] = self._data if self._original_data is not None: @@ -1123,7 +1116,6 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "source_asset_uid", "resolved_text_cache", "variable_offset", - "template_values", "data", "original_data", "source_px_dims", @@ -1176,7 +1168,6 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: ) or data.get("sketch_params", {}) wp.source_asset_uid = data.get("source_asset_uid") wp._variable_offset = int(data.get("variable_offset", 0)) - wp._template_values_used = dict(data.get("template_values") or {}) raw_cache = data.get("resolved_text_cache") if raw_cache: wp._resolved_text_cache = { @@ -1235,10 +1226,10 @@ def geometry_provider_params(self, new_params: dict[str, Any]): @property def variable_offset(self) -> int: """ - The copy offset applied to the document's template variables. + The copy offset applied to the document's job counter. - A copy with offset ``n`` shows the values ``n`` copies after - the current ones, e.g. counter value ``current + n * step``. + A copy with offset ``n`` shows the counter value ``n`` copies + after the current one, i.e. ``current + n * step``. """ return self._variable_offset @@ -1250,29 +1241,24 @@ def variable_offset(self, value: int) -> None: self._variable_offset = value self.refresh_template_values() - def template_values(self) -> dict[str, Any]: - """ - The document's template variables as seen by this copy. A - workpiece outside a document keeps the values its geometry was - generated with, so a detached copy renders the same text. - """ - doc = self.doc - if doc is None: - return dict(self._template_values_used) - return doc.job_counter.template_values(self._variable_offset) - def _provider_params(self) -> dict[str, Any]: """ - Parameters for the geometry provider: the document's template - variables, overridden by this instance's own parameters. + Parameters for the geometry provider: the document's job counter + as seen by this copy, overridden by this instance's own + parameters. """ - params = self.template_values() + doc = self.doc + params = ( + doc.job_counter.template_values(self._variable_offset) + if doc is not None + else {} + ) params.update(self._geometry_provider_params or {}) return params def refresh_template_values(self) -> None: """ - Regenerates the geometry after a template variable changed. + Regenerates the geometry after the job counter changed. The current scale relative to the natural size is kept, so a longer text grows instead of being squeezed into the old box, @@ -1281,7 +1267,6 @@ def refresh_template_values(self) -> None: old_w, old_h = self.natural_width_mm, self.natural_height_mm size_w, size_h = self.size anchor_world = self.get_anchor_world() - self._resolved_text_cache = {} self.clear_render_cache() if self.boundaries is not None and old_w > 1e-9 and old_h > 1e-9: self.set_size( @@ -1292,17 +1277,6 @@ def refresh_template_values(self) -> None: self._move_anchor_to(anchor_world) self.updated.send(self) - def sync_template_values(self) -> None: - """ - Refreshes the geometry if it was generated with other template - values than the document offers now, e.g. after the workpiece - was created outside the document or copied with a new offset. - """ - if not self.geometry_provider_uid: - return - if self._template_values_used != self.template_values(): - self.refresh_template_values() - def get_natural_aspect_ratio(self) -> float | None: size = self.natural_size if size: @@ -1856,7 +1830,6 @@ def regenerate_from_geometry_provider(self) -> None: # Get geometry with current parameter overrides. variable_overrides = self._provider_params() - self._template_values_used = self.template_values() logger.debug( f"WP {self.uid[:8]}: Getting geometry with params: " f"{variable_overrides}" diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py index a86fd0e8a..af369f90a 100644 --- a/tests/core/test_workpiece_job_counter.py +++ b/tests/core/test_workpiece_job_counter.py @@ -39,13 +39,23 @@ def test_duplicate_keeps_offset(self): class TestTemplateValues: - def test_unparented_workpiece_has_no_values(self): - assert WorkPiece("wp").template_values() == {} - def test_values_follow_doc_counter_and_offset(self, doc): doc.job_counter.counter = CounterSettings(current=10, step=5) wp = place(doc, "{counter}", offset=2) - assert wp.template_values() == {"counter": 20} + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "20" + + def test_copy_added_with_new_offset_shows_its_value(self, doc): + doc.job_counter.counter = CounterSettings(current=7) + wp = place(doc, "{counter}") + copy = wp.duplicate() + assert isinstance(copy, WorkPiece) + copy._variable_offset = 1 + doc.active_layer.add_child(copy) + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "8" def test_provider_receives_counter(self, doc): doc.job_counter.counter = CounterSettings(current=7) @@ -66,39 +76,11 @@ def test_instance_params_win_over_doc_values(self, doc): assert isinstance(provider, TemplateProvider) assert provider.rendered[-1] == "99" - def test_in_world_bakes_values(self, doc): - doc.job_counter.counter = CounterSettings(current=3) - wp = place(doc, "{counter}", offset=1) - world = wp.in_world() - assert world.template_values() == {"counter": 4} - - def test_in_world_bakes_values_for_plain_templates(self, doc): - doc.job_counter.counter = CounterSettings(current=3) - wp = place(doc, "Hello") - assert wp.in_world().template_values() == {"counter": 3} - def test_in_world_keeps_instance_params(self, doc): wp = place(doc, "{counter}") wp._geometry_provider_params = {"width": 5.0} assert wp.in_world().geometry_provider_params == {"width": 5.0} - def test_in_world_renders_baked_values(self, doc): - doc.job_counter.counter = CounterSettings(current=3) - wp = place(doc, "No. {counter}", offset=1) - world = wp.in_world() - world.clear_render_cache() - provider = wp.get_geometry_provider() - world._transient_geometry_provider = provider - assert world.boundaries is not None - assert isinstance(provider, TemplateProvider) - assert provider.rendered[-1] == "No. 4" - - def test_in_world_survives_serialization(self, doc): - doc.job_counter.counter = CounterSettings(current=8) - wp = place(doc, "{counter}") - world = WorkPiece.from_dict(wp.in_world().to_dict()) - assert world.template_values() == {"counter": 8} - def test_provider_reports_used_variables(self, doc): wp = place(doc, "{counter:04d}") plain = place(doc, "Hello") diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 13f467034..c5964356f 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -251,7 +251,6 @@ async def test_grid_array_numbers_copies(doc_editor, array_cmd): last = created[-1] provider = last.get_geometry_provider() assert isinstance(provider, TemplateProvider) - assert last.template_values() == {"counter": 16} assert provider.rendered[-1] == "16" From 31f645c1e79f41d51c6efeeafac4cbb638c5b8dd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:35:40 +0200 Subject: [PATCH 30/33] docs: describe one job counter, not template variables The module, Doc and the docs page still spoke of variables the project offers; the translations kept a "serial numbers" heading. The docs also no longer point to a Variable Offset row in the properties panel. Refs #527 --- rayforge/core/doc.py | 4 ++-- rayforge/core/job_counter.py | 6 +++--- rayforge/doceditor/job_counter_cmd.py | 4 ++-- website/docs/features/job-counter.md | 12 ++++++------ .../current/features/job-counter.md | 17 ++++++++--------- .../current/features/job-counter.md | 16 ++++++++-------- .../current/features/job-counter.md | 16 ++++++++-------- .../current/features/job-counter.md | 6 +++--- .../current/features/job-counter.md | 16 ++++++++-------- .../current/features/job-counter.md | 14 +++++++------- .../current/features/job-counter.md | 6 +++--- 11 files changed, 58 insertions(+), 59 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 16d1cf3a2..762918152 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -46,7 +46,7 @@ def __init__(self): self.assets: dict[str, IAsset] = {} self.asset_order: list[str] = [] - # Variables for text templates (counter value, ...) + # The job counter, passed to text templates as {counter} self._job_counter = JobCounter() self._job_counter.changed.connect(self._on_job_counter_changed) self.descendant_added.connect(self._on_item_added_for_templates) @@ -185,7 +185,7 @@ def to_dict(self) -> dict: @property def job_counter(self) -> JobCounter: - """The variables offered to text templates.""" + """The document's job counter.""" return self._job_counter @job_counter.setter diff --git a/rayforge/core/job_counter.py b/rayforge/core/job_counter.py index 44baa4739..97931c5a7 100644 --- a/rayforge/core/job_counter.py +++ b/rayforge/core/job_counter.py @@ -1,8 +1,8 @@ """ -Document-level variables for text templates. +The document's job counter. -A document carries a :class:`JobCounter` instance whose values are -injected into the ``{...}`` templates of every geometry provider (for +A document carries one :class:`JobCounter`. Its value is passed as +``counter`` to the ``{...}`` templates of every geometry provider (for example the text boxes of a sketch). Each workpiece adds its own *variable offset*, so copies of the same design can show consecutive counter values. diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index 6d64290bb..869106a9a 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -1,6 +1,6 @@ """ -JobCounterCmd: undoable edits of the document's template variables -(job counter) and of the per-workpiece variable offsets. +JobCounterCmd: undoable edits of the document's job counter and of +the per-workpiece variable offsets. """ from __future__ import annotations diff --git a/website/docs/features/job-counter.md b/website/docs/features/job-counter.md index 540379672..fe4fb69c0 100644 --- a/website/docs/features/job-counter.md +++ b/website/docs/features/job-counter.md @@ -8,8 +8,8 @@ description: Text boxes resolve `{...}` templates (see [Parameters and Expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). On top of -the sketch parameters and the built-in functions, the project offers variables that change from job -to job, so you can engrave numbered plates, tags and labels without editing the text each time. +the sketch parameters and the built-in functions, the project has one job counter that changes from +job to job, so you can engrave numbered plates, tags and labels without editing the text each time. ## The job counter @@ -37,10 +37,10 @@ advance, is part of the undo history and is saved in the project. ## Numbering copies -Each workpiece has a **Variable Offset** (in the properties panel, under **Job Counter**). A copy -with offset _n_ shows the number _n_ copies after the current one. A -[grid or circular array](arrays.md) gives its copies increasing offsets automatically, so one array -of 20 plates engraves 20 consecutive numbers, and the next job continues with the 21st. +Each workpiece has a **Variable Offset**. A copy with offset _n_ shows the number _n_ copies after +the current one. A [grid or circular array](arrays.md) gives its copies increasing offsets +automatically, so one array of 20 plates engraves 20 consecutive numbers, and the next job continues +with the 21st. Arrays made inside a sketch are different: their copies belong to one workpiece and all show the same number. Make the array on the canvas instead to number the copies. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md index 6f12b1ffc..a58af0c70 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,11 +8,11 @@ description: Textfelder lösen `{...}`-Vorlagen auf (siehe [Parameter und Ausdrücke](sketcher/expressions.md#template-expressions-in-text-boxes)). Zusätzlich -zu den Skizzenparametern und den eingebauten Funktionen bietet das Projekt Variablen, die sich von -Auftrag zu Auftrag ändern. So gravierst du nummerierte Schilder, Anhänger und Etiketten, ohne den -Text jedes Mal zu bearbeiten. +zu den Skizzenparametern und den eingebauten Funktionen hat das Projekt einen Auftragszähler, der +sich von Auftrag zu Auftrag ändert. So gravierst du nummerierte Schilder, Anhänger und Etiketten, +ohne den Text jedes Mal zu bearbeiten. -## Seriennummern +## Der Auftragszähler Verwende `{counter}` in einem Textfeld. Es ist eine ganze Zahl, daher bestimmen Formatangaben ihr Aussehen: @@ -39,11 +39,10 @@ Rückgängig-Verlaufs und wird im Projekt gespeichert. ## Kopien nummerieren -Jedes Werkstück hat einen **Variablen-Versatz** (im Eigenschaftenbereich unter **Auftragszähler**). -Eine Kopie mit Versatz _n_ zeigt die Nummer _n_ Kopien nach der aktuellen. Ein -[Gitter- oder Kreis-Array](arrays.md) gibt seinen Kopien automatisch steigende Versätze, sodass ein -Array aus 20 Schildern 20 fortlaufende Nummern graviert und der nächste Auftrag mit der 21. -weitermacht. +Jedes Werkstück hat einen **Variablen-Versatz**. Eine Kopie mit Versatz _n_ zeigt die Nummer _n_ +Kopien nach der aktuellen. Ein [Gitter- oder Kreis-Array](arrays.md) gibt seinen Kopien automatisch +steigende Versätze, sodass ein Array aus 20 Schildern 20 fortlaufende Nummern graviert und der +nächste Auftrag mit der 21. weitermacht. Arrays innerhalb einer Skizze sind anders: Ihre Kopien gehören zu einem Werkstück und zeigen alle dieselbe Nummer. Erstelle das Array stattdessen auf der Leinwand, um die Kopien zu nummerieren. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md index d21b9b4a2..95c91e3fb 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,11 +8,11 @@ description: Los cuadros de texto resuelven plantillas `{...}` (consulta [Parámetros y expresiones](sketcher/expressions.md#template-expressions-in-text-boxes)). Además de -los parámetros del boceto y las funciones integradas, el proyecto ofrece variables que cambian de un -trabajo a otro, para que puedas grabar placas, etiquetas y rótulos numerados sin editar el texto -cada vez. +los parámetros del boceto y las funciones integradas, el proyecto tiene un contador de trabajos que +cambia de un trabajo a otro, para que puedas grabar placas, etiquetas y rótulos numerados sin editar +el texto cada vez. -## Números de serie +## El contador de trabajos Usa `{counter}` en un cuadro de texto. Es un número entero, así que los especificadores de formato controlan su aspecto: @@ -41,10 +41,10 @@ proyecto. ## Numerar copias -Cada pieza tiene un **Desplazamiento de variable** (en el panel de propiedades, en **Contador de trabajos**). Una copia con desplazamiento _n_ muestra el número _n_ copias después del actual. Una -[matriz en cuadrícula o circular](arrays.md) da a sus copias desplazamientos crecientes de forma -automática, así que una matriz de 20 placas graba 20 números consecutivos y el siguiente trabajo -continúa con el 21. +Cada pieza tiene un **Desplazamiento de variable**. Una copia con desplazamiento _n_ muestra el +número _n_ copias después del actual. Una [matriz en cuadrícula o circular](arrays.md) da a sus +copias desplazamientos crecientes de forma automática, así que una matriz de 20 placas graba 20 +números consecutivos y el siguiente trabajo continúa con el 21. Las matrices creadas dentro de un boceto son distintas: sus copias pertenecen a una sola pieza y todas muestran el mismo número. Crea la matriz en el lienzo para numerar las copias. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md index 3b5ce64c2..3be9a91ed 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,11 +8,11 @@ description: Les zones de texte résolvent les modèles `{...}` (voir [Paramètres et expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). En plus -des paramètres de l'esquisse et des fonctions intégrées, le projet propose des variables qui -changent d'un travail à l'autre, pour graver des plaques, étiquettes et badges numérotés sans -modifier le texte à chaque fois. +des paramètres de l'esquisse et des fonctions intégrées, le projet a un compteur de tâches qui +change d'un travail à l'autre, pour graver des plaques, étiquettes et badges numérotés sans modifier +le texte à chaque fois. -## Numéros de série +## Le compteur de tâches Utilisez `{counter}` dans une zone de texte. C'est un nombre entier, donc les spécifications de format contrôlent son apparence : @@ -41,10 +41,10 @@ d'annulation et est enregistrée dans le projet. ## Numéroter des copies -Chaque pièce a un **Décalage de variable** (dans le panneau des propriétés, sous **Compteur de tâches**). Une copie avec le décalage _n_ affiche le numéro situé _n_ copies après le numéro -actuel. Un [réseau en grille ou circulaire](arrays.md) donne automatiquement à ses copies des -décalages croissants : un réseau de 20 plaques grave ainsi 20 numéros consécutifs, et le travail -suivant continue avec le 21e. +Chaque pièce a un **Décalage de variable**. Une copie avec le décalage _n_ affiche le numéro situé +_n_ copies après le numéro actuel. Un [réseau en grille ou circulaire](arrays.md) donne +automatiquement à ses copies des décalages croissants : un réseau de 20 plaques grave ainsi 20 +numéros consécutifs, et le travail suivant continue avec le 21e. Les réseaux créés dans une esquisse sont différents : leurs copies appartiennent à une seule pièce et affichent toutes le même numéro. Créez plutôt le réseau sur le canevas pour numéroter les copies. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md index af51fce7d..d418dc860 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,10 +8,10 @@ description: टेक्स्ट बॉक्स `{...}` टेम्पलेट को हल करते हैं ([पैरामीटर और एक्सप्रेशन](sketcher/expressions.md#template-expressions-in-text-boxes) देखें)। स्केच -पैरामीटर और बिल्ट-इन फ़ंक्शन के अलावा, प्रोजेक्ट ऐसे वेरिएबल देता है जो हर जॉब के साथ बदलते हैं, +पैरामीटर और बिल्ट-इन फ़ंक्शन के अलावा, प्रोजेक्ट में एक जॉब काउंटर होता है जो हर जॉब के साथ बदलता है, ताकि आप हर बार टेक्स्ट बदले बिना नंबर वाली प्लेट, टैग और लेबल उकेर सकें। -## सीरियल नंबर +## जॉब काउंटर टेक्स्ट बॉक्स में `{counter}` का उपयोग करें। यह एक पूर्णांक है, इसलिए फ़ॉर्मेट स्पेक इसकी दिखावट तय करते हैं: @@ -37,7 +37,7 @@ description: ## कॉपियों को नंबर देना -हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है (प्रॉपर्टी पैनल में, **जॉब काउंटर** के अंतर्गत)। +हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है। ऑफ़सेट _n_ वाली कॉपी वर्तमान नंबर से _n_ कॉपी बाद का नंबर दिखाती है। एक [ग्रिड या वृत्ताकार ऐरे](arrays.md) अपनी कॉपियों को अपने-आप बढ़ते ऑफ़सेट देता है, इसलिए 20 प्लेट का एक ऐरे 20 क्रमागत नंबर उकेरता है, और अगला जॉब 21वें से जारी रहता है। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md index 782806255..91e81964a 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,10 +8,11 @@ description: As caixas de texto resolvem modelos `{...}` (veja [Parâmetros e expressões](sketcher/expressions.md#template-expressions-in-text-boxes)). Além dos -parâmetros do esboço e das funções integradas, o projeto oferece variáveis que mudam de um trabalho -para outro, para você gravar placas, etiquetas e rótulos numerados sem editar o texto a cada vez. +parâmetros do esboço e das funções integradas, o projeto tem um contador de trabalhos que muda de um +trabalho para outro, para você gravar placas, etiquetas e rótulos numerados sem editar o texto a +cada vez. -## Números de série +## O contador de trabalhos Use `{counter}` em uma caixa de texto. É um número inteiro, então os especificadores de formato controlam sua aparência: @@ -39,11 +40,10 @@ projeto. ## Numerar cópias -Cada peça tem um **Deslocamento de variável** (no painel de propriedades, em **Contador de trabalhos**). -Uma cópia com deslocamento _n_ mostra o número _n_ cópias depois do atual. Uma -[matriz em grade ou circular](arrays.md) dá às suas cópias deslocamentos crescentes automaticamente, -então uma matriz de 20 placas grava 20 números consecutivos, e o próximo trabalho continua com o -21º. +Cada peça tem um **Deslocamento de variável**. Uma cópia com deslocamento _n_ mostra o número _n_ +cópias depois do atual. Uma [matriz em grade ou circular](arrays.md) dá às suas cópias deslocamentos +crescentes automaticamente, então uma matriz de 20 placas grava 20 números consecutivos, e o próximo +trabalho continua com o 21º. Matrizes criadas dentro de um esboço são diferentes: suas cópias pertencem a uma única peça e todas mostram o mesmo número. Crie a matriz na tela para numerar as cópias. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md index 3b84425c6..139fb61e9 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -8,10 +8,11 @@ description: Текстові поля обробляють шаблони `{...}` (див. [Параметри та вирази](sketcher/expressions.md#template-expressions-in-text-boxes)). Окрім параметрів -ескізу та вбудованих функцій, проєкт пропонує змінні, що змінюються від завдання до завдання, тож ви -можете гравіювати пронумеровані таблички, бирки й етикетки, не редагуючи текст щоразу. +ескізу та вбудованих функцій, проєкт має один лічильник завдань, що змінюється від завдання до +завдання, тож ви можете гравіювати пронумеровані таблички, бирки й етикетки, не редагуючи текст +щоразу. -## Серійні номери +## Лічильник завдань Використовуйте `{counter}` у текстовому полі. Це ціле число, тож специфікатори формату визначають його вигляд: @@ -39,10 +40,9 @@ description: ## Нумерація копій -Кожна заготовка має **Зсув змінної** (на панелі властивостей, у розділі **Лічильник завдань**). Копія зі -зсувом _n_ показує номер на _n_ копій після поточного. [Сітковий або круговий масив](arrays.md) -автоматично дає своїм копіям зростаючі зсуви, тож масив із 20 табличок гравіює 20 послідовних -номерів, а наступне завдання продовжує з 21-го. +Кожна заготовка має **Зсув змінної**. Копія зі зсувом _n_ показує номер на _n_ копій після +поточного. [Сітковий або круговий масив](arrays.md) автоматично дає своїм копіям зростаючі зсуви, +тож масив із 20 табличок гравіює 20 послідовних номерів, а наступне завдання продовжує з 21-го. Масиви всередині ескізу інші: їхні копії належать одній заготовці й усі показують той самий номер. Щоб пронумерувати копії, створіть масив на полотні. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md index 638354b60..e7f041d1e 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -5,9 +5,9 @@ description: "文本模板中的任务计数器:每次任务后自动递增的 # 任务计数器 文本框会解析 `{...}` -模板(参见[参数与表达式](sketcher/expressions.md#template-expressions-in-text-boxes))。除了草图参数和内置函数之外,项目还提供随每次任务变化的变量,因此无需每次修改文本即可雕刻带编号的铭牌、吊牌和标签。 +模板(参见[参数与表达式](sketcher/expressions.md#template-expressions-in-text-boxes))。除了草图参数和内置函数之外,项目还有一个随每次任务变化的任务计数器,因此无需每次修改文本即可雕刻带编号的铭牌、吊牌和标签。 -## 序列号 +## 任务计数器 在文本框中使用 `{counter}`。它是一个整数,因此可以用格式说明符控制其外观: @@ -29,7 +29,7 @@ description: "文本模板中的任务计数器:每次任务后自动递增的 ## 为副本编号 -每个工件都有一个**变量偏移**(在属性面板的**任务计数器**下)。偏移为 _n_ 的副本显示当前编号之后第 _n_ +每个工件都有一个**变量偏移**。偏移为 _n_ 的副本显示当前编号之后第 _n_ 个副本的编号。[网格或环形阵列](arrays.md)会自动为其副本分配递增的偏移,因此一个包含 20 块铭牌的阵列会雕刻 20 个连续编号,下一次任务从第 21 个继续。 在草图内部创建的阵列则不同:它们的副本属于同一个工件,全部显示相同的编号。如需为副本编号,请改为在画布上创建阵列。 From ee7a8c6546e8df34e6709adf25449891d069887f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:35:58 +0200 Subject: [PATCH 31/33] refactor: drop the Job Counter group from the properties panel It showed on every workpiece and repeated what the canvas shows. Arrays and Arrange > Array > Number Copies set the variable offsets; the counter itself lives in Tools > Job Counter. The command that only the panel used goes too. Refs #527 --- rayforge/doceditor/job_counter_cmd.py | 8 -- .../doceditor/property_providers/__init__.py | 3 - .../property_providers/job_counter.py | 85 ------------------- tests/doceditor/test_job_counter_cmd.py | 10 +-- .../doceditor/test_job_counter_dialog.py | 28 ------ 5 files changed, 1 insertion(+), 133 deletions(-) delete mode 100644 rayforge/ui_gtk/doceditor/property_providers/job_counter.py diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index 869106a9a..2c64c068c 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -102,14 +102,6 @@ def on_job_completed(self, sender) -> None: def _on_gcode_exported(self, sender) -> None: self.advance_after_job(exported=True) - def set_variable_offset( - self, workpieces: Sequence[WorkPiece], offset: int - ) -> None: - """Sets the same variable offset on all *workpieces*.""" - self._apply_offsets( - [(wp, offset) for wp in workpieces], _("Set variable offset") - ) - def number_copies(self, items: Sequence[DocItem]) -> None: """ Gives *items* the offsets 0, 1, 2, ... in reading order: rows diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py index cfa43cd19..6d5113873 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/__init__.py +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -1,5 +1,4 @@ from .base import PropertyProvider, property_provider_registry -from .job_counter import JobCounterPropertyProvider from .transform import TransformPropertyProvider from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider @@ -9,11 +8,9 @@ def register_builtin_providers(): property_provider_registry.register(TransformPropertyProvider, "") property_provider_registry.register(WorkpieceInfoProvider, "") property_provider_registry.register(TabsPropertyProvider, "") - property_provider_registry.register(JobCounterPropertyProvider, "") __all__ = [ - "JobCounterPropertyProvider", "PropertyProvider", "TabsPropertyProvider", "TransformPropertyProvider", diff --git a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py b/rayforge/ui_gtk/doceditor/property_providers/job_counter.py deleted file mode 100644 index c69cb1f9c..000000000 --- a/rayforge/ui_gtk/doceditor/property_providers/job_counter.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -from gettext import gettext as _ -from typing import TYPE_CHECKING - -from gi.repository import Gtk - -from ....core.item import DocItem -from ....core.job_counter import COUNTER_NAME -from ....core.workpiece import WorkPiece -from ...icons import get_icon -from ...shared.pref_rows import SpinRow -from .base import PropertyProvider - -if TYPE_CHECKING: - from ....doceditor.editor import DocEditor - -logger = logging.getLogger(__name__) - - -class JobCounterPropertyProvider(PropertyProvider): - """ - Shows the variable offset of the selected workpieces, with a - shortcut to the counter settings. - """ - - priority = 30 - separate_group = True - group_title = _("Job Counter") - - def can_handle(self, items: list[DocItem]) -> bool: - return bool(items) and all(isinstance(i, WorkPiece) for i in items) - - def create_widgets(self) -> list[Gtk.Widget]: - self.offset_row = SpinRow( - _("Variable Offset"), - lower=0, - upper=100_000, - numeric=True, - ) - self.offset_row.set_tooltip_text( - _( - "This copy shows the values this many copies after the " - "current ones" - ) - ) - self.offset_row.value_changed.connect(self._on_offset_changed) - - self.settings_button = Gtk.Button( - child=get_icon("settings-symbolic"), - valign=Gtk.Align.CENTER, - tooltip_text=_("Job Counter Settings"), - action_name="win.job-counter", - ) - self.offset_row.add_suffix(self.settings_button) - self._rows = [self.offset_row] - return self._rows - - def update_widgets(self, editor: "DocEditor", items: list[DocItem]): - self.editor = editor - self.items = items - workpieces = [i for i in items if isinstance(i, WorkPiece)] - if not workpieces: - return - first = workpieces[0] - self._in_update = True - try: - self.offset_row.set_value(first.variable_offset) - finally: - self._in_update = False - - if len(workpieces) > 1: - self.offset_row.set_subtitle(_("Applies to all selected items")) - return - counter = first.template_values().get(COUNTER_NAME) - self.offset_row.set_subtitle( - _("Job counter: {value}").format(value=counter) - ) - - def _on_offset_changed(self, row: SpinRow) -> None: - if self._in_update: - return - workpieces = [i for i in self.items if isinstance(i, WorkPiece)] - self.editor.job_counter.set_variable_offset( - workpieces, row.get_int_value() - ) diff --git a/tests/doceditor/test_job_counter_cmd.py b/tests/doceditor/test_job_counter_cmd.py index ad6679890..cf85a11e0 100644 --- a/tests/doceditor/test_job_counter_cmd.py +++ b/tests/doceditor/test_job_counter_cmd.py @@ -82,15 +82,6 @@ async def test_advance_is_undoable(doc_editor, cmd): assert _counter(doc_editor).current == 1 -@pytest.mark.asyncio -async def test_set_variable_offset_is_undoable(doc_editor, cmd): - wp = place(doc_editor.doc, "{counter}") - cmd.set_variable_offset([wp], 4) - assert wp.variable_offset == 4 - doc_editor.history_manager.undo() - assert wp.variable_offset == 0 - - @pytest.mark.asyncio async def test_number_copies_in_reading_order(doc_editor, cmd): doc = doc_editor.doc @@ -118,3 +109,4 @@ async def test_preview_values(doc_editor, cmd): async def test_preview_without_copies_shows_current(doc_editor, cmd): cmd.set_counter(CounterSettings(current=7)) assert cmd.preview_counter_values() == [7] + diff --git a/tests/ui_gtk/doceditor/test_job_counter_dialog.py b/tests/ui_gtk/doceditor/test_job_counter_dialog.py index 23b53bf33..dfb2f7a19 100644 --- a/tests/ui_gtk/doceditor/test_job_counter_dialog.py +++ b/tests/ui_gtk/doceditor/test_job_counter_dialog.py @@ -14,9 +14,6 @@ JobCounterDialog, format_preview, ) -from rayforge.ui_gtk.doceditor.property_providers.job_counter import ( - JobCounterPropertyProvider, -) from tests.core.template_provider import place @@ -109,31 +106,6 @@ def test_switches_update_model(editor, dialog): assert _counter(editor).advance_on_export is True -@pytest.mark.ui -def test_property_provider_handles_workpieces(editor): - wp = place(editor.doc, "{counter:03d}") - plain = place(editor.doc, "Hello") - provider = JobCounterPropertyProvider() - assert provider.can_handle([wp]) - assert provider.can_handle([plain]) - assert not provider.can_handle([]) - - -@pytest.mark.ui -def test_property_provider_sets_offset(editor): - editor.doc.job_counter.counter = CounterSettings(current=10) - wp = place(editor.doc, "{counter}") - provider = JobCounterPropertyProvider() - provider.create_widgets() - provider.update_widgets(editor, [wp]) - assert "10" in (provider.offset_row.get_subtitle() or "") - provider.offset_row.set_value(3) - provider.offset_row.value_changed.send(provider.offset_row) - assert wp.variable_offset == 3 - provider.update_widgets(editor, [wp]) - assert "13" in (provider.offset_row.get_subtitle() or "") - - @pytest.mark.ui def test_dialog_layout_matches_settings_dialogs(dialog): assert dialog.get_modal() is False From bcec4693fddfc5d3fffe8ae4cfa589c8c31d4d2f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:36:07 +0200 Subject: [PATCH 32/33] refactor: sort copies by the public item bbox Number Copies used the private LayoutStrategy._get_item_world_bbox; DocItem.bbox gives the same world box for workpieces and groups. Refs #527 --- rayforge/doceditor/job_counter_cmd.py | 11 ++--------- tests/doceditor/test_job_counter_cmd.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py index 2c64c068c..8e1def996 100644 --- a/rayforge/doceditor/job_counter_cmd.py +++ b/rayforge/doceditor/job_counter_cmd.py @@ -14,7 +14,6 @@ from ..core.job_counter import CounterSettings from ..core.undo import ChangePropertyCommand from ..core.workpiece import WorkPiece, workpieces_in -from .layout.base import LayoutStrategy if TYPE_CHECKING: from .editor import DocEditor @@ -135,14 +134,8 @@ def _reading_order(items: Sequence[DocItem]) -> list[DocItem]: """Sorts items into rows (top first), then left to right.""" boxes = [] for item in items: - bbox = LayoutStrategy._get_item_world_bbox(item) - if bbox is None: - x, y = item.pos - bbox = (x, y, x, y) - x_min, y_min, x_max, y_max = bbox - boxes.append( - (item, (x_min + x_max) / 2, (y_min + y_max) / 2, y_max - y_min) - ) + x, y, width, height = item.bbox + boxes.append((item, x + width / 2, y + height / 2, height)) boxes.sort(key=lambda b: -b[2]) rows: list[list[tuple[DocItem, float, float, float]]] = [] diff --git a/tests/doceditor/test_job_counter_cmd.py b/tests/doceditor/test_job_counter_cmd.py index cf85a11e0..7d20167c5 100644 --- a/tests/doceditor/test_job_counter_cmd.py +++ b/tests/doceditor/test_job_counter_cmd.py @@ -1,5 +1,6 @@ import pytest +from rayforge.core.group import Group from rayforge.core.job_counter import CounterSettings from rayforge.doceditor.job_counter_cmd import JobCounterCmd from tests.core.template_provider import place @@ -110,3 +111,21 @@ async def test_preview_without_copies_shows_current(doc_editor, cmd): cmd.set_counter(CounterSettings(current=7)) assert cmd.preview_counter_values() == [7] + +@pytest.mark.asyncio +async def test_number_copies_orders_groups_by_their_bounds(doc_editor, cmd): + doc = doc_editor.doc + layer = doc.active_layer + plain = place(doc, "{counter}") + plain.pos = (0.0, 0.0) + member = place(doc, "{counter}") + member.pos = (40.0, 0.0) + result = Group.create_from_items([member], layer) + assert result is not None + group = result.new_group + layer.add_child(group) + member.matrix = result.child_matrices[member.uid] + group.add_child(member) + cmd.number_copies([group, plain]) + assert plain.variable_offset == 0 + assert member.variable_offset == 1 From b5c3193882d1b63f827ce3711ee07181eab7de28 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:53:30 +0200 Subject: [PATCH 33/33] docs: the copy offset is set by arrays and Number Copies, not a panel field --- website/docs/features/job-counter.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/features/job-counter.md b/website/docs/features/job-counter.md index fe4fb69c0..5efe177e7 100644 --- a/website/docs/features/job-counter.md +++ b/website/docs/features/job-counter.md @@ -37,10 +37,10 @@ advance, is part of the undo history and is saved in the project. ## Numbering copies -Each workpiece has a **Variable Offset**. A copy with offset _n_ shows the number _n_ copies after -the current one. A [grid or circular array](arrays.md) gives its copies increasing offsets -automatically, so one array of 20 plates engraves 20 consecutive numbers, and the next job continues -with the 21st. +Each workpiece has an offset. A copy with offset _n_ shows the number _n_ copies after the current +one. A [grid or circular array](arrays.md) and **Arrange → Array → Number Copies** give the copies +increasing offsets, so one array of 20 plates engraves 20 consecutive numbers, and the next job +continues with the 21st. Arrays made inside a sketch are different: their copies belong to one workpiece and all show the same number. Make the array on the canvas instead to number the copies.