diff --git a/CHANGELOG.md b/CHANGELOG.md index 754f744ba..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 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 4fe5e74c1..1e7143b1f 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 1431a92c6..224bf99b9 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/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 0b80b70fc..bb070bc71 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -5,6 +5,7 @@ import time import uuid from collections import defaultdict +from dataclasses import replace from gettext import gettext as _ from pathlib import Path from typing import Any, ClassVar @@ -20,7 +21,11 @@ from rayforge.core.asset import IAsset from rayforge.core.color import ColorRGBA -from rayforge.core.expression import ExpressionMap +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 @@ -59,6 +64,48 @@ from .template_functions import get_template_functions from .types import EntityID +_GEOMETRY_CACHE_SIZE = 8 +_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] + + DEFAULT_FILL_COLOR: ColorRGBA = (0.85, 0.85, 0.85, 0.7) FORMAT_VERSION = 1 _DEFAULT_VARSET_TITLE = _("Sketch Parameters") @@ -183,9 +230,16 @@ 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 + # values; cleared whenever the sketch itself changes. + self._geometry_cache: dict[ + _GeometryKey, 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]] = {} @@ -246,6 +300,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): """ @@ -326,7 +381,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. @@ -342,6 +400,14 @@ def get_geometry( Returns: A tuple of (stroke_geometry, fill_render_data). """ + 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 + if resolved_text_cache is not None: + resolved_text_cache.update(resolved) + 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) @@ -354,8 +420,104 @@ def get_geometry( fills = clone.get_fill_render_data() if resolved_text_cache is not None: resolved_text_cache.update(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]: + """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 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, texts: dict + ) -> _GeometryKey: + """ + 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() + if name in read + ) + ), + tuple( + sorted( + (str(eid), repr(tuple(entry))) + for eid, entry in texts.items() + ) + ), + ) + + 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 + 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: _GeometryKey, + geo: Geometry, + fills: list[FillRenderData], + resolved: dict, + ) -> None: + """Caches a result, evicting the oldest when it grows too big.""" + 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))) + + def _invalidate_geometry_cache(self, sender=None) -> None: + """Drops cached geometry when the sketch itself changes.""" + self._geometry_cache.clear() + def _clone_for_geometry(self) -> "Sketch": """ Lightweight clone for geometry generation. @@ -410,6 +572,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 @@ -1448,6 +1611,7 @@ def solve( if self._solving: return True self._solving = True + self._invalidate_geometry_cache() solve_started = time.perf_counter() params_done = prep_done = solver_done = arrays_done = solve_started success = False @@ -1643,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( @@ -1676,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 new file mode 100644 index 000000000..9635edb61 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_job_counter.py @@ -0,0 +1,255 @@ +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 + +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 + + +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. {counter:03d}") + 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 _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 not first + assert again.rect() == rect + + +def test_unread_variable_does_not_regenerate(monkeypatch): + sketch, _box = _sketch_with_text("Hello") + 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(monkeypatch): + sketch, _box = _sketch_with_text("Hello") + sketch.params.set("gap", "counter * 2") + 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(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") + 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(monkeypatch): + sketch, _box = _sketch_with_text("{counter}") + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + sketch.notify_update() + sketch.get_geometry({"counter": 1}) + assert len(solves) == 2 + + +def test_solve_drops_cached_geometry(monkeypatch): + sketch, box = _sketch_with_text("{counter}") + solves = _count_solves(monkeypatch, sketch) + sketch.get_geometry({"counter": 1}) + box.content = "{counter}{counter}" + sketch.solve() + sketch.get_geometry({"counter": 1}) + assert len(solves) == 2 + + +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 = {} + sketch.get_geometry(resolved_text_cache=first) + sketch.get_geometry(resolved_text_cache=second) + assert first[box.id] != second[box.id] + assert len(solves) == 2 + + +def test_volatile_text_reuses_geometry_for_its_caller(monkeypatch): + sketch, _box = _sketch_with_text("{uuid8()}") + solves = _count_solves(monkeypatch, sketch) + cache: dict = {} + 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(): + doc = Doc() + 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" + + +def test_copies_show_consecutive_numbers(): + doc = Doc() + doc.job_counter.counter = CounterSettings(current=41) + sketch, box = _sketch_with_text("{counter}") + 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("{counter:04d}") + wp = _place(doc, sketch) + doc.job_counter.counter = CounterSettings(current=12) + assert _resolved(wp, box) == "0012" + + +def test_saved_text_is_kept_on_load(): + doc = Doc() + doc.job_counter.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()) + 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 c6a45d17e..762918152 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -15,9 +15,10 @@ 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 .workpiece import WorkPiece +from .workpiece import WorkPiece, workpieces_in if TYPE_CHECKING: from .stock import StockItem @@ -45,6 +46,11 @@ def __init__(self): self.assets: dict[str, IAsset] = {} self.asset_order: list[str] = [] + # 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) + # 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.job_counter = JobCounter.from_dict(data.get("job_counter")) # Clear the default layer created by __init__ doc.set_children([]) @@ -173,8 +180,57 @@ def to_dict(self) -> dict: "active_layer_index": self._active_layer_index, "children": [child.to_dict() for child in self.children], "assets": [asset.to_dict() for asset in self.get_all_assets()], + "job_counter": self._job_counter.to_dict(), } + @property + def job_counter(self) -> JobCounter: + """The document's job counter.""" + return self._job_counter + + @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 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. + """ + 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._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 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/expression/__init__.py b/rayforge/core/expression/__init__.py index 5c2c014f7..106b39ade 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 names_in_expression, referenced_template_names from .tokenizer import ExpressionTokenizer, Token, TokenType from .validator import ExpressionValidator @@ -16,5 +17,7 @@ "TokenType", "ValidationResult", "ValidationStatus", + "names_in_expression", + "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 b1ed21366..12dae70a6 100644 --- a/rayforge/core/geometry_provider.py +++ b/rayforge/core/geometry_provider.py @@ -66,6 +66,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 new file mode 100644 index 000000000..97931c5a7 --- /dev/null +++ b/rayforge/core/job_counter.py @@ -0,0 +1,144 @@ +""" +The document's job counter. + +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any + +from blinker import Signal + +COUNTER_NAME = "counter" + + +@dataclass(frozen=True) +class CounterSettings: + """ + An immutable job 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) -> CounterSettings: + """A counter moved on (or back, if negative) by *copies*.""" + return replace(self, current=self.value_at(copies)) + + def reset(self) -> CounterSettings: + """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]) -> CounterSettings: + 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 JobCounter: + """ + The document's job counter. + + Emits :attr:`changed` whenever a value changes; the document then + regenerates the geometry that shows counter values. + """ + + def __init__(self): + self.changed = Signal() + self._counter = CounterSettings() + + @property + def counter(self) -> CounterSettings: + return self._counter + + @counter.setter + def counter(self, value: CounterSettings) -> None: + if value == self._counter: + return + self._counter = value + self.changed.send(self) + + @property + def names(self) -> set[str]: + """The template names this object provides.""" + return {COUNTER_NAME} + + def template_values(self, offset: int = 0) -> dict[str, Any]: + """The template values for a copy with the given offset.""" + return {COUNTER_NAME: self._counter.value_at(offset)} + + def to_dict(self) -> dict[str, Any]: + return {"counter": self._counter.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> JobCounter: + instance = cls() + if data: + 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 006f2ceed..51e8eede1 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -163,6 +163,10 @@ def __init__( self._resolved_text_cache: dict = {} + # Shifts the document's job counter for this copy, so array + # copies show consecutive counter values. + self._variable_offset: int = 0 + self.source_asset_uid: str | None = None self._tabs: list[Tab] = [] @@ -513,7 +517,7 @@ def boundaries(self) -> Geometry | None: f"params: {self._geometry_provider_params}" ) unnormalized_geo, unnormalized_fills = provider.get_geometry( - self._geometry_provider_params, + self._provider_params(), resolved_text_cache=self._resolved_text_cache, ) @@ -1071,6 +1075,8 @@ 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._data is not None: state["data"] = self._data if self._original_data is not None: @@ -1109,6 +1115,7 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "geometry_provider_params", "source_asset_uid", "resolved_text_cache", + "variable_offset", "data", "original_data", "source_px_dims", @@ -1160,6 +1167,7 @@ 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)) raw_cache = data.get("resolved_text_cache") if raw_cache: wp._resolved_text_cache = { @@ -1215,6 +1223,60 @@ 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 job counter. + + A copy with offset ``n`` shows the counter value ``n`` copies + after the current one, i.e. ``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 _provider_params(self) -> dict[str, Any]: + """ + Parameters for the geometry provider: the document's job counter + as seen by this copy, overridden by this instance's own + parameters. + """ + 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 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, + 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.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 get_natural_aspect_ratio(self) -> float | None: size = self.natural_size if size: @@ -1720,10 +1782,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) @@ -1760,7 +1829,7 @@ 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() logger.debug( f"WP {self.uid[:8]}: Getting geometry with params: " f"{variable_overrides}" @@ -1784,3 +1853,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 5c3cbeb18..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 @@ -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,13 @@ 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.""" + for wp in workpieces_in(item): + 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 e9850bb23..b406b68fd 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 .node_edit_cmd import NodeEditCmd @@ -162,6 +163,7 @@ def __init__( self.step = StepCmd(self) self.tab = TabCmd(self) self.transform = TransformCmd(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/file_cmd.py b/rayforge/doceditor/file_cmd.py index ce8cde6c4..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 @@ -1062,6 +1064,9 @@ 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.gcode_exported.send, self + ) except Exception as e: logger.error( diff --git a/rayforge/doceditor/job_counter_cmd.py b/rayforge/doceditor/job_counter_cmd.py new file mode 100644 index 000000000..8e1def996 --- /dev/null +++ b/rayforge/doceditor/job_counter_cmd.py @@ -0,0 +1,154 @@ +""" +JobCounterCmd: undoable edits of the document's job 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.job_counter import CounterSettings +from ..core.undo import ChangePropertyCommand +from ..core.workpiece import WorkPiece, workpieces_in + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class JobCounterCmd: + """Handles changes to the document's job counter.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + editor.file.gcode_exported.connect(self._on_gcode_exported) + + @property + def counter(self) -> CounterSettings: + 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.""" + job_counter = self._editor.doc.job_counter + if settings == job_counter.counter: + return + cmd = ChangePropertyCommand( + target=job_counter, + property_name="counter", + new_value=settings, + name=name or _("Change counter value"), + ) + self._editor.history_manager.execute(cmd) + + 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") + ) + + def previous_counter(self) -> None: + """Moves the counter back by one job.""" + copies = max(self._editor.doc.job_counter_copies(), 1) + self.set_counter( + self.counter.advanced(-copies), _("Previous counter value") + ) + + def reset_counter(self) -> None: + """Puts the counter back at its start value.""" + self.set_counter(self.counter.reset(), _("Reset counter value")) + + def preview_counter_values(self) -> list[int]: + """The counter values the next job uses, one per copy.""" + copies = max(self._editor.doc.job_counter_copies(), 1) + return self.counter.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. + """ + counter = self.counter + 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 + self.set_counter( + counter.advanced(copies), _("Advance counter value after job") + ) + logger.info( + f"Job counter advanced by {copies} to " + f"{self._editor.doc.job_counter.counter.current}" + ) + 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 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_in(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 _reading_order(items: Sequence[DocItem]) -> list[DocItem]: + """Sorts items into rows (top first), then left to right.""" + boxes = [] + for item in items: + 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]]] = [] + 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/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 6cc4b1d71..b1b96eab0 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -52,8 +52,11 @@ 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 + self._cancel_requested = False + self._send_completed = False @property def is_job_running(self) -> bool: @@ -95,11 +98,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. @@ -117,7 +123,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 @@ -160,6 +166,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: @@ -203,6 +210,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() @@ -329,7 +337,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, @@ -457,12 +465,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.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. @@ -497,6 +508,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/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index a3561cf4f..45d81c4d0 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -22,6 +22,7 @@ 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 .shared.keyboard import PRIMARY_ACCEL @@ -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) + # Job counter + self._add_action("job-counter", self.on_job_counter) + 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_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): + """Handler for 'number-copies': numbers the selected items in + reading order, so their counter values count up.""" + items = list(self.win.surface.get_selected_items()) + if items: + self.editor.job_counter.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/job_counter_dialog.py b/rayforge/ui_gtk/doceditor/job_counter_dialog.py new file mode 100644 index 000000000..b1d7ba154 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/job_counter_dialog.py @@ -0,0 +1,236 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ...core.job_counter import CounterSettings, JobCounter +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 + +#: The dialog width, matching the step settings dialog. +DEFAULT_WIDTH = 600 + + +def format_preview(values: list[int]) -> str: + """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( + first=values[0], last=values[-1], count=len(values) + ) + + +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. + """ + + 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(_("Job Counter")) + self.set_default_size(DEFAULT_WIDTH, -1) + self.set_modal(False) + + 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.add(self._build_counter_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.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) + self.connect("destroy", self._on_destroy) + self._update_from_model() + + def _build_counter_group(self) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title=_("Counter"), + description=_( + "Use {counter} in a text box, e.g. No. {counter: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=_("Counter Values")) + self.preview_row.add_css_class("property") + group.add(self.preview_row) + + 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") + ) + 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) + self.preview_row.add_suffix(button_box) + return group + + 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 CounterSettings( + 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.job_counter.set_counter(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.job_counter.previous_counter() + + def _on_next_clicked(self, button: Gtk.Button) -> None: + self.editor.job_counter.next_counter() + + def _on_reset_clicked(self, button: Gtk.Button) -> None: + self.editor.job_counter.reset_counter() + + 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.job_counter.counter + self._in_update = True + try: + 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.job_counter.preview_counter_values() + self.preview_row.set_subtitle(format_preview(values)) + + def _on_destroy(self, widget) -> None: + 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/main_menu.py b/rayforge/ui_gtk/main_menu.py index 4934ba858..401e0aa72 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -185,6 +185,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() @@ -194,6 +195,9 @@ def __init__(self): # Tools Menu tools_menu = Gio.Menu() + 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 ad25fc2db..ce6bd3dea 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.job_counter.on_job_completed + ) # Instantiate and connect the UpdateCommand's notification signal self.update_cmd = UpdateCommand(task_mgr, context) diff --git a/tests/core/template_provider.py b/tests/core/template_provider.py new file mode 100644 index 000000000..9f5307391 --- /dev/null +++ b/tests/core/template_provider.py @@ -0,0 +1,93 @@ +"""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, + referenced_template_names, +) +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 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) + 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 get_reference_point(self): + return None + + 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_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 new file mode 100644 index 000000000..07e758856 --- /dev/null +++ b/tests/core/test_job_counter.py @@ -0,0 +1,113 @@ +from rayforge.core.job_counter import CounterSettings, JobCounter + + +class TestCounterSettings: + def test_defaults(self): + s = CounterSettings() + 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 = 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 = 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 = 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 = CounterSettings(current=1, start=1, step=1, end=3) + assert s.value_at(-1) == 3 + + def test_zero_step_keeps_value(self): + s = CounterSettings(current=7, step=0) + assert s.value_at(5) == 7 + + def test_advanced_moves_current(self): + 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 = CounterSettings(current=3, step=2) + assert s.advanced(-1).current == 1 + + def test_reset_returns_to_start(self): + s = CounterSettings(current=42, start=7) + assert s.reset().current == 7 + + def test_values_for_copies(self): + s = CounterSettings(current=8, step=1, end=9, start=1) + assert s.values_for(3) == [8, 9, 1] + + def test_round_trip(self): + s = CounterSettings( + current=12, + start=10, + step=2, + end=30, + auto_advance=False, + advance_on_export=True, + ) + assert CounterSettings.from_dict(s.to_dict()) == s + + def test_from_dict_ignores_missing_keys(self): + assert CounterSettings.from_dict({}) == CounterSettings() + + def test_from_dict_rejects_end_below_start(self): + s = CounterSettings.from_dict({"start": 10, "end": 5}) + assert s.end is None + + +class TestJobCounter: + def test_template_values_with_offset(self): + 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 = JobCounter() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.counter = CounterSettings(current=5) + assert received == [vt] + + def test_setting_equal_counter_is_silent(self): + vt = JobCounter() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.counter = CounterSettings() + assert received == [] + + def test_names(self): + assert "counter" in JobCounter().names + + def test_round_trip(self): + vt = JobCounter() + vt.counter = CounterSettings(current=3, start=2, step=4) + restored = JobCounter.from_dict(vt.to_dict()) + assert restored.counter == vt.counter + + def test_from_dict_none(self): + assert JobCounter.from_dict(None).counter == CounterSettings() diff --git a/tests/core/test_workpiece.py b/tests/core/test_workpiece.py index 76ca80b17..00540c4e3 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(), [] @@ -145,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, diff --git a/tests/core/test_workpiece_job_counter.py b/tests/core/test_workpiece_job_counter.py new file mode 100644 index 000000000..af369f90a --- /dev/null +++ b/tests/core/test_workpiece_job_counter.py @@ -0,0 +1,190 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.job_counter import CounterSettings +from rayforge.core.workpiece import AnchorMode, 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_values_follow_doc_counter_and_offset(self, doc): + doc.job_counter.counter = CounterSettings(current=10, step=5) + wp = place(doc, "{counter}", offset=2) + 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) + wp = place(doc, "No. {counter: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.job_counter.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() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "99" + + 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_provider_reports_used_variables(self, doc): + wp = place(doc, "{counter:04d}") + plain = place(doc, "Hello") + 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: + def test_counter_change_regenerates_text(self, doc): + wp = place(doc, "{counter}") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + doc.job_counter.counter = CounterSettings(current=1234) + assert provider.rendered[-1] == "1234" + + def test_counter_change_keeps_scale(self, doc): + doc.job_counter.counter = CounterSettings(current=9) + wp = place(doc, "{counter}") + wp.set_size(4.0, 10.0) + doc.job_counter.counter = CounterSettings(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_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 = [] + + def on_updated(sender, **kwargs): + received.append(sender) + + wp.updated.connect(on_updated) + doc.job_counter.counter = CounterSettings(current=5) + assert wp in received + + def test_unrelated_workpiece_keeps_its_text(self, doc): + wp = place(doc, "Hello") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + doc.job_counter.counter = CounterSettings(current=5) + assert provider.rendered[-1] == "Hello" + + def test_offset_change_regenerates(self, doc): + doc.job_counter.counter = CounterSettings(current=1) + wp = place(doc, "{counter}") + 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.job_counter.counter = CounterSettings(current=12, step=3) + restored = Doc.from_dict(doc.to_dict()) + assert restored.job_counter.counter == doc.job_counter.counter + + def test_old_documents_get_default_counter(self, doc): + data = doc.to_dict() + data.pop("job_counter", None) + restored = Doc.from_dict(data) + assert restored.job_counter.counter == CounterSettings() + + 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.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.job_counter_copies() == 6 + + def test_copies_count_without_template_users(self, doc): + place(doc, "Hello") + assert doc.job_counter_copies() == 0 diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 2a1cb3a4f..c5964356f 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -8,6 +8,8 @@ import pytest +from rayforge.core.group import Group +from rayforge.core.job_counter import CounterSettings from rayforge.core.layer import Layer from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( @@ -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,52 @@ 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 + counter values count up across the sheet.""" + doc = doc_editor.doc + doc.job_counter.counter = CounterSettings(current=11) + wp = place(doc, "{counter}", 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 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, "{counter}") + 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_job_counter_cmd.py b/tests/doceditor/test_job_counter_cmd.py new file mode 100644 index 000000000..7d20167c5 --- /dev/null +++ b/tests/doceditor/test_job_counter_cmd.py @@ -0,0 +1,131 @@ +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 + + +@pytest.fixture +def cmd(doc_editor): + return JobCounterCmd(doc_editor) + + +def _counter(doc_editor) -> CounterSettings: + return doc_editor.doc.job_counter.counter + + +@pytest.mark.asyncio +async def test_editor_exposes_command(doc_editor): + assert isinstance(doc_editor.job_counter, JobCounterCmd) + + +@pytest.mark.asyncio +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 _counter(doc_editor) == CounterSettings() + + +@pytest.mark.asyncio +async def test_next_previous_reset(doc_editor, cmd): + 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, "{counter}", offset=0) + place(doc, "{counter}", offset=3) + cmd.set_counter(CounterSettings(current=1, step=2)) + assert cmd.advance_after_job() is True + assert _counter(doc_editor).current == 9 + + +@pytest.mark.asyncio +async def test_advance_skipped_without_counter_users(doc_editor, cmd): + place(doc_editor.doc, "Hello") + assert cmd.advance_after_job() is False + assert _counter(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_skipped_when_disabled(doc_editor, cmd): + place(doc_editor.doc, "{counter}") + cmd.set_counter(CounterSettings(auto_advance=False)) + assert cmd.advance_after_job() is False + 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, "{counter}") + assert cmd.advance_after_job(exported=True) is False + cmd.set_counter(CounterSettings(advance_on_export=True)) + assert cmd.advance_after_job(exported=True) is True + assert _counter(doc_editor).current == 2 + + +@pytest.mark.asyncio +async def test_advance_is_undoable(doc_editor, cmd): + place(doc_editor.doc, "{counter}") + cmd.advance_after_job() + doc_editor.history_manager.undo() + assert _counter(doc_editor).current == 1 + + +@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, "{counter}") + 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, "{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_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 diff --git a/tests/doceditor/test_job_counter_job.py b/tests/doceditor/test_job_counter_job.py new file mode 100644 index 000000000..3588613df --- /dev/null +++ b/tests/doceditor/test_job_counter_job.py @@ -0,0 +1,193 @@ +""" +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. +""" + +import asyncio + +import pytest +from raygeo.geo import Geometry + +from rayforge.context import get_context +from rayforge.core.job_counter import CounterSettings +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, "{counter}", offset=offset) + wp._edited_boundaries = _rect() + wp.set_size(10.0, 10.0) + wp.pos = (offset * 20.0, 0.0) + doc_editor.doc.job_counter.counter = CounterSettings(current=5) + + +def _current(doc_editor) -> int: + 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.job_counter.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. + 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 _machine_cmd(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 _machine_cmd(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 _machine_cmd(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 = _machine_cmd(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.job_counter.counter = CounterSettings( + 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 + + +@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] diff --git a/tests/ui_gtk/doceditor/test_job_counter_dialog.py b/tests/ui_gtk/doceditor/test_job_counter_dialog.py new file mode 100644 index 000000000..dfb2f7a19 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_job_counter_dialog.py @@ -0,0 +1,113 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from gi.repository import Adw, Gtk + +from rayforge.core.job_counter import CounterSettings +from rayforge.doceditor.editor import DocEditor +from rayforge.ui_gtk.doceditor.job_counter_dialog import ( + DEFAULT_WIDTH, + JobCounterDialog, + 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 = JobCounterDialog(parent, editor) + yield dlg + dlg.destroy() + + +def _counter(editor) -> CounterSettings: + return editor.doc.job_counter.counter + + +@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.job_counter.counter = CounterSettings( + current=42, start=10, step=2, auto_advance=False + ) + 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 + 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 _counter(editor).current == 77 + editor.history_manager.undo() + 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 _counter(editor).end is None + dialog.limit_row.set_active(True) + assert _counter(editor).end == 9 + dialog.limit_row.set_active(False) + assert _counter(editor).end is None + + +@pytest.mark.ui +def test_next_previous_reset_buttons(editor, dialog): + place(editor.doc, "{counter}", offset=1) + dialog.next_button.emit("clicked") + assert _counter(editor).current == 3 + dialog.previous_button.emit("clicked") + assert _counter(editor).current == 1 + editor.doc.job_counter.counter = CounterSettings(current=50, start=4) + dialog.reset_button.emit("clicked") + assert _counter(editor).current == 4 + + +@pytest.mark.ui +def test_preview_follows_model(editor, dialog): + place(editor.doc, "{counter}", offset=2) + 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 + + +@pytest.mark.ui +def test_switches_update_model(editor, dialog): + dialog.auto_advance_row.set_active(False) + dialog.export_row.set_active(True) + assert _counter(editor).auto_advance is False + assert _counter(editor).advance_on_export is True + + +@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 diff --git a/website/docs/features/job-counter.md b/website/docs/features/job-counter.md new file mode 100644 index 000000000..5efe177e7 --- /dev/null +++ b/website/docs/features/job-counter.md @@ -0,0 +1,51 @@ +--- +description: + "A job counter in text templates: one project-level number that advances after every job, with + consecutive numbers for array copies." +--- + +# Job Counter + +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 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 + +Use `{counter}` in a text box. It is a whole number, so format specs control its appearance: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, four digits with leading zeros +- `No. {counter:03d}` — `No. 007` + +Open **Tools → Job Counter…** 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 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. + +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/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index 008d5ea38..aa52dd05a 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 a job counter that advances after every job, use the project variable `{counter}` (see +[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 (`{name} - {count:.0f}pcs`), or Unix timestamps for production logging (`{timestamp():.0f}`). 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 new file mode 100644 index 000000000..a58af0c70 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,55 @@ +--- +description: + "Ein Auftragszähler in Textvorlagen: eine Zahl, der nach jedem Auftrag weiterzählt, mit + fortlaufenden Nummern für Array-Kopien." +--- + +# Auftragszähler + +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 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. + +## Der Auftragszähler + +Verwende `{counter}` in einem Textfeld. Es ist eine ganze Zahl, daher bestimmen Formatangaben ihr +Aussehen: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, vier Stellen mit führenden Nullen +- `No. {counter:03d}` — `No. 007` + +Ö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. +- **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**. 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/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..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 @@ -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 einen Auftragszähler, der nach jedem Auftrag weiterzählt, verwende die Projektvariable `{counter}` +(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()}`), Produktionszähler (`{name} - {count:.0f}pcs`) oder Unix-Zeitstempel für die 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 new file mode 100644 index 000000000..95c91e3fb --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,57 @@ +--- +description: + "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." +--- + +# 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 +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. + +## 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: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, cuatro dígitos con ceros a la izquierda +- `No. {counter:03d}` — `No. 007` + +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**. +- **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**. 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/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..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 @@ -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 un contador de trabajos que avanza tras cada trabajo, usa la variable del proyecto `{counter}` +(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 (`Fecha: {today()}`), contadores de producción (`{name} - {count:.0f}uds`) o marcas de tiempo Unix 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 new file mode 100644 index 000000000..3be9a91ed --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,56 @@ +--- +description: + "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." +--- + +# 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 +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. + +## 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 : + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, quatre chiffres avec des zéros en tête +- `No. {counter:03d}` — `No. 007` + +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. +- **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**. 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/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..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 @@ -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 un compteur de tâches qui avance après chaque travail, utilisez la variable de projet +`{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 dates (`Date : {today()}`), les compteurs de production (`{name} - {count:.0f}pcs`) ou les 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 new file mode 100644 index 000000000..d418dc860 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,51 @@ +--- +description: + "टेक्स्ट टेम्पलेट में जॉब काउंटर: एक प्रोजेक्ट संख्या जो हर जॉब के बाद आगे बढ़ती है, और ऐरे + कॉपियों के लिए क्रमागत नंबर।" +--- + +# जॉब काउंटर + +टेक्स्ट बॉक्स `{...}` टेम्पलेट को हल करते हैं +([पैरामीटर और एक्सप्रेशन](sketcher/expressions.md#template-expressions-in-text-boxes) देखें)। स्केच +पैरामीटर और बिल्ट-इन फ़ंक्शन के अलावा, प्रोजेक्ट में एक जॉब काउंटर होता है जो हर जॉब के साथ बदलता है, +ताकि आप हर बार टेक्स्ट बदले बिना नंबर वाली प्लेट, टैग और लेबल उकेर सकें। + +## जॉब काउंटर + +टेक्स्ट बॉक्स में `{counter}` का उपयोग करें। यह एक पूर्णांक है, इसलिए फ़ॉर्मेट स्पेक इसकी दिखावट तय +करते हैं: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, आगे शून्य के साथ चार अंक +- `No. {counter:03d}` — `No. 007` + +काउंटर सेट करने के लिए **टूल्स → जॉब काउंटर…** खोलें: + +- **वर्तमान मान** — वह नंबर जिससे अगला जॉब शुरू होता है। +- **प्रारंभ मान** — जहाँ **रीसेट** वापस ले जाता है। +- **इतना बढ़ाएँ** — प्रति कॉपी और प्रति जॉब वृद्धि। +- **अंतिम मान पर लौटें** / **अंतिम मान** — अंतिम मान के बाद काउंटर प्रारंभ मान से जारी रहता है। +- **हर जॉब के बाद बढ़ाएँ** — मशीन को भेजा गया जॉब पूरा होने पर काउंटर आगे बढ़ता है। फ़्रेमिंग, + पॉइंटर ड्राई रन और रद्द किए गए जॉब इसे नहीं बदलते। +- **G-code एक्सपोर्ट के बाद बढ़ाएँ** — G-code एक्सपोर्ट करने पर भी आगे बढ़ें, उन जॉब के लिए जो + मेमोरी कार्ड से चलते हैं। + +**अगला जॉब** समूह उन नंबरों का पूर्वावलोकन दिखाता है जो अगला जॉब उपयोग करेगा। **पिछला** और **अगला** +काउंटर को एक जॉब आगे-पीछे करते हैं, **रीसेट** प्रारंभ मान पर लौटाता है। हर बदलाव, स्वचालित वृद्धि +सहित, पूर्ववत इतिहास का हिस्सा है और प्रोजेक्ट में सहेजा जाता है। + +## कॉपियों को नंबर देना + +हर वर्कपीस का एक **वेरिएबल ऑफ़सेट** होता है। +ऑफ़सेट _n_ वाली कॉपी वर्तमान नंबर से _n_ कॉपी बाद का नंबर दिखाती है। एक +[ग्रिड या वृत्ताकार ऐरे](arrays.md) अपनी कॉपियों को अपने-आप बढ़ते ऑफ़सेट देता है, इसलिए 20 प्लेट का +एक ऐरे 20 क्रमागत नंबर उकेरता है, और अगला जॉब 21वें से जारी रहता है। + +स्केच के अंदर बने ऐरे अलग हैं: उनकी कॉपियाँ एक ही वर्कपीस की होती हैं और सभी एक ही नंबर दिखाती हैं। +कॉपियों को नंबर देने के लिए ऐरे कैनवास पर बनाएँ। + +जो कॉपियाँ आपने खुद रखी हैं, उन सबको चुनें और **व्यवस्थित करें → ऐरे → कॉपियों को नंबर दें** चुनें: +ऑफ़सेट पढ़ने के क्रम में दिए जाते हैं, पंक्तियाँ ऊपर से नीचे और हर पंक्ति बाएँ से दाएँ। + +काउंटर जॉब में कॉपियों की संख्या जितना बढ़ता है: सबसे बड़ा वेरिएबल ऑफ़सेट जमा एक। 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..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 @@ -116,6 +116,9 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` का उपनाम | | `{uuid()}` | `str` | पूर्ण UUID v4 स्ट्रिंग (36 वर्ण) | +हर जॉब के बाद आगे बढ़ने वाले जॉब काउंटर के लिए प्रोजेक्ट वेरिएबल `{counter}` का उपयोग करें +([जॉब काउंटर](../job-counter.md) देखें)। + सामान्य उपयोग में प्रति हल अद्वितीय सीरियल नंबर (`Part # {uuid4()}`), लाइव विमा लेबल (`W={width:.1f} H={height:.1f}`), तिथि चिह्न (`Date: {today()}`), उत्पादन काउंटर (`{name} - {count:.0f}pcs`), या उत्पादन लॉगिंग के लिए Unix टाइमस्टैम्प (`{timestamp():.0f}`) शामिल 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 new file mode 100644 index 000000000..91e81964a --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,55 @@ +--- +description: + "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." +--- + +# 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 +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. + +## 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: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, quatro dígitos com zeros à esquerda +- `No. {counter:03d}` — `No. 007` + +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. +- **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**. 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/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..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 @@ -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 um contador de trabalhos que avança após cada trabalho, use a variável do projeto `{counter}` (veja +[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()}`), contadores de produção (`{name} - {count:.0f}un`) ou marcas temporais Unix para registro de produção 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 new file mode 100644 index 000000000..139fb61e9 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,53 @@ +--- +description: + "Лічильник завдань у текстових шаблонах: число проєкту, що збільшується після кожного завдання, з + послідовними номерами для копій масиву." +--- + +# Лічильник завдань + +Текстові поля обробляють шаблони `{...}` (див. +[Параметри та вирази](sketcher/expressions.md#template-expressions-in-text-boxes)). Окрім параметрів +ескізу та вбудованих функцій, проєкт має один лічильник завдань, що змінюється від завдання до +завдання, тож ви можете гравіювати пронумеровані таблички, бирки й етикетки, не редагуючи текст +щоразу. + +## Лічильник завдань + +Використовуйте `{counter}` у текстовому полі. Це ціле число, тож специфікатори формату визначають +його вигляд: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`, чотири цифри з провідними нулями +- `No. {counter:03d}` — `No. 007` + +Відкрийте **Інструменти → Лічильник завдань…**, щоб налаштувати лічильник: + +- **Поточне значення** — номер, з якого починається наступне завдання. +- **Початкове значення** — куди повертає **Скинути**. +- **Збільшувати на** — крок для кожної копії та кожного завдання. +- **Повертатися після кінцевого значення** / **Кінцеве значення** — після кінцевого значення + лічильник продовжує з початкового. +- **Збільшувати після кожного завдання** — коли надіслане на верстат завдання завершено, лічильник + збільшується. Обрамлення, пробні проходи з указівником і скасовані завдання його не змінюють. +- **Збільшувати після експорту G-code** — збільшувати також під час експорту G-code, для завдань, що + запускаються з карти пам'яті. + +Група **Наступне завдання** показує попередній перегляд номерів, які використає наступне завдання. +**Попередній** і **Наступний** зсувають лічильник на одне завдання, **Скинути** повертає до +початкового значення. Кожна зміна, зокрема автоматичне збільшення, входить до історії скасування та +зберігається в проєкті. + +## Нумерація копій + +Кожна заготовка має **Зсув змінної**. Копія зі зсувом _n_ показує номер на _n_ копій після +поточного. [Сітковий або круговий масив](arrays.md) автоматично дає своїм копіям зростаючі зсуви, +тож масив із 20 табличок гравіює 20 послідовних номерів, а наступне завдання продовжує з 21-го. + +Масиви всередині ескізу інші: їхні копії належать одній заготовці й усі показують той самий номер. +Щоб пронумерувати копії, створіть масив на полотні. + +Для копій, які ви розмістили самі, виділіть їх усі й виберіть **Упорядкувати → Масив → Пронумерувати +копії**: зсуви призначаються в порядку читання, рядки згори донизу, а кожен рядок зліва направо. + +Лічильник збільшується на кількість копій у завданні: найбільший зсув змінної плюс один. 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..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 @@ -118,6 +118,9 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | Псевдонім для `uuid4()` | | `{uuid()}` | `str` | Повний рядок UUID v4 (36 символів) | +Для лічильника завдань, що збільшується після кожного завдання, використовуйте змінну проєкту +`{counter}` (див. [Лічильник завдань](../job-counter.md)). + Типові застосування включають унікальні серійні номери при кожному розв'язанні (`Деталь #{uuid4()}`), живі мітки розмірів (`Ш={width:.1f} В={height:.1f}`), датування кожної деталі (`Дата: {today()}`), лічильники виробництва (`{name} - {count:.0f}шт`) або мітки часу Unix для 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 new file mode 100644 index 000000000..e7f041d1e --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/job-counter.md @@ -0,0 +1,39 @@ +--- +description: "文本模板中的任务计数器:每次任务后自动递增的项目数值,并为阵列副本生成连续编号。" +--- + +# 任务计数器 + +文本框会解析 `{...}` +模板(参见[参数与表达式](sketcher/expressions.md#template-expressions-in-text-boxes))。除了草图参数和内置函数之外,项目还有一个随每次任务变化的任务计数器,因此无需每次修改文本即可雕刻带编号的铭牌、吊牌和标签。 + +## 任务计数器 + +在文本框中使用 `{counter}`。它是一个整数,因此可以用格式说明符控制其外观: + +- `{counter}` — `7` +- `{counter:04d}` — `0007`,四位数并补前导零 +- `No. {counter: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..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,6 +97,8 @@ W = {width}, H = {height} | `{uuid8()}` | `str` | `uuid4()` 的别名 | | `{uuid()}` | `str` | 完整 UUID v4 字符串(36 字符) | +如需每次任务后自动递增的任务计数器,请使用项目变量 `{counter}`(参见[任务计数器](../job-counter.md))。 + 典型用途包括每次求解生成唯一序列号(`零件 #{uuid4()}`)、实时尺寸标签(`宽={width:.1f} 高={height:.1f}`)、日期戳(`日期:{today()}`)、生产计数器(`{name} - {count:.0f}个`),或用于生产日志的 Unix 时间戳(`{timestamp():.0f}`)。 ## 自定义模板函数 {#custom-template-functions} diff --git a/website/sidebars.js b/website/sidebars.js index 865677101..e9201f2f2 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -106,6 +106,7 @@ module.exports = { }, 'features/arrays', 'features/path-cleanup', + 'features/job-counter', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job',