Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
100fcee
feat(laser): add Interval Test and Focus Test parameters
switch87 Oct 9, 2026
331f89b
feat(laser): generate Interval and Focus Tests as project content
switch87 Oct 9, 2026
72bf7f6
feat(laser): add Interval Test and Focus Test to the Tools menu
switch87 Oct 9, 2026
6a7554c
docs: document the Interval and Focus Tests
switch87 Oct 9, 2026
94e2b16
feat(laser): default the Focus Test to Z axis steps when available
switch87 Oct 12, 2026
e3798ce
docs: mention the Focus Test's Z axis default in the changelog
switch87 Oct 13, 2026
6a2e18f
test(laser): let the editor settle after the calibration tests
switch87 Oct 13, 2026
4570da1
refactor(laser): check the driver through the machine package
switch87 Oct 15, 2026
9d694f2
refactor(laser): build the calibration test dialogs from VarSets
switch87 Oct 15, 2026
2e8f3b2
docs: apply site-format to the Interval and Focus Tests pages
switch87 Oct 15, 2026
61e2443
docs: translate the Focus Test's default method note
switch87 Oct 15, 2026
2e5dc9e
test(laser): type the Focus Test's mode row as a ComboRow
switch87 Oct 17, 2026
6b01844
fix(laser): link generated steps through workpiece_uids
switch87 Oct 17, 2026
8716e84
Merge remote-tracking branch 'origin/main' into feat/test-generators
switch87 Oct 21, 2026
0f44fb3
refactor(laser): build all calibration tests with one generator
switch87 Oct 21, 2026
9dee9ee
feat(screenshot): capture the calibration tests with their Test Settings
switch87 Oct 21, 2026
8a1fd06
chore: leave the changelog to the release update
switch87 Oct 21, 2026
a1206c5
docs: describe the shared Test Settings of the calibration tests
switch87 Oct 21, 2026
b81bdb3
docs: rewrap the French Test Settings paragraph
switch87 Oct 21, 2026
03bae07
fix(core): wire step signals when steps are added in bulk
switch87 Oct 21, 2026
80f7724
perf(laser): rebuild a calibration test in one bulk change
switch87 Oct 21, 2026
95107a0
fix(laser): keep the grid's step, follow recipes, leave with the layer
switch87 Oct 21, 2026
5667e86
fix(laser): keep a Focus Test's method and scroll without timers
switch87 Oct 21, 2026
9a61fed
fix(laser): keep assets a moved cell uses when the test layer goes
switch87 Oct 21, 2026
36f1971
fix(laser): stop following the Test Settings card when it is hidden
switch87 Oct 21, 2026
9a578e3
refactor(laser): register the Test Settings from the init hook
switch87 Oct 21, 2026
71eeeee
fix(laser): keep a rejected setting for the next change
switch87 Oct 21, 2026
604e079
fix(screenshot): open the Material Test Grid's own step on its layer
switch87 Oct 21, 2026
a274314
docs: screenshots of the calibration tests with their Test Settings
switch87 Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Calibration tests: the Material Test Grid, the Interval Test and the
Focus Test share one generator architecture.

Each test is a :class:`CalibrationGenerator` that turns its parameters
into a :class:`CalibrationLayout`. ``CalibrationCmd`` builds the layout
into its own layer and rebuilds it when the parameters change; the
parameters are kept in a hidden :class:`CalibrationRecord` asset.
"""

from .focus_test import FocusMode, FocusTestGenerator
from .generator import CalibrationGenerator
from .interval_test import IntervalTestGenerator
from .layout import (
CalibrationLayout,
CellEntry,
CommandEntry,
FilledRect,
ProceduralShape,
VectorShape,
)
from .material_test_grid import MaterialTestGridGenerator
from .record import CalibrationRecord, CalibrationState

GENERATORS: dict[str, CalibrationGenerator] = {
generator.id: generator
for generator in (
MaterialTestGridGenerator(),
IntervalTestGenerator(),
FocusTestGenerator(),
)
}

__all__ = [
"GENERATORS",
"CalibrationGenerator",
"CalibrationLayout",
"CalibrationRecord",
"CalibrationState",
"CellEntry",
"CommandEntry",
"FilledRect",
"FocusMode",
"FocusTestGenerator",
"IntervalTestGenerator",
"MaterialTestGridGenerator",
"ProceduralShape",
"VectorShape",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""
Turns a :class:`CalibrationLayout` into workpieces, steps and assets.

The builder creates the items; ``CalibrationCmd`` adds them to the
document in one undoable transaction. Their source assets are hidden,
since they belong to the test and are replaced when it is rebuilt.
Items are created at their test-local position and then moved
together by :meth:`BuiltTest.move_to`.

Vector cells go through the SVG importer: core has no other way to
turn geometry into a workpiece with a source asset that the renderer
and the Engrave step can rasterize.
"""

import logging
from dataclasses import dataclass, field
from gettext import gettext as _
from typing import TYPE_CHECKING, Any

from rayforge.core.source_asset import SourceAsset
from rayforge.core.step import Step
from rayforge.core.step_registry import step_registry
from rayforge.core.vectorization_spec import PassthroughSpec, ProceduralSpec
from rayforge.core.workpiece import WorkPiece
from rayforge.image.procedural import ProceduralImporter
from rayforge.image.svg.exporter import GeometrySvgExporter
from rayforge.image.svg.importer import SvgImporter

from .layout import (
CalibrationLayout,
CellEntry,
CommandEntry,
FilledRect,
ProceduralShape,
Shape,
)

if TYPE_CHECKING:
from rayforge.context import RayforgeContext

logger = logging.getLogger(__name__)

Rect = tuple[float, float, float, float]


@dataclass
class BuiltTest:
"""The items of one test, before they are added to the document."""

workpieces: list[WorkPiece] = field(default_factory=list)
steps: list[Step] = field(default_factory=list)
assets: list[SourceAsset] = field(default_factory=list)

def bounds(self) -> Rect:
"""The world bounding box (min x, min y, max x, max y)."""
return bounds_of(self.workpieces)

def move_to(self, x: float, y: float) -> None:
"""Moves the test so its bottom-left corner is at (x, y)."""
min_x, min_y, _max_x, _max_y = self.bounds()
dx, dy = x - min_x, y - min_y
for wp in self.workpieces:
wp.pos = (wp.pos[0] + dx, wp.pos[1] + dy)

def keep_inside(self, width: float, height: float) -> None:
"""
Moves the test back onto an area of *width* × *height* mm where
it sticks out, as far as it fits.
"""
min_x, min_y, max_x, max_y = self.bounds()
x = max(0.0, min(min_x, width - (max_x - min_x)))
y = max(0.0, min(min_y, height - (max_y - min_y)))
self.move_to(x, y)

def center_on(self, width: float, height: float) -> None:
"""Centers the test on an area of *width* × *height* mm."""
min_x, min_y, max_x, max_y = self.bounds()
self.move_to(
(width - (max_x - min_x)) / 2, (height - (max_y - min_y)) / 2
)


def bounds_of(workpieces: list[WorkPiece]) -> Rect:
rects = []
for wp in workpieces:
world = wp.get_world_geometry()
if world is not None and not world.is_empty():
rects.append(world.rect())
else:
x, y, width, height = wp.bbox
rects.append((x, y, x + width, y + height))
return (
min(r[0] for r in rects),
min(r[1] for r in rects),
max(r[2] for r in rects),
max(r[3] for r in rects),
)


def filled_rect_svg(width: float, height: float) -> bytes:
"""An SVG of a black rectangle of *width* × *height* mm."""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.3f}mm" '
f'height="{height:.3f}mm" viewBox="0 0 {width:.3f} {height:.3f}">'
f'<rect width="{width:.3f}" height="{height:.3f}" fill="black"/>'
f"</svg>"
).encode()


class CalibrationBuilder:
"""Creates document items for a calibration layout."""

def __init__(self, context: "RayforgeContext"):
self._context = context

def build(self, layout: CalibrationLayout) -> BuiltTest:
built = BuiltTest()
for entry in layout.entries:
if isinstance(entry, CommandEntry):
built.steps.append(self._command_step(entry))
continue
assert isinstance(entry, CellEntry)
source, wp = self._workpiece(entry.name, entry.shape)
entry.step.workpiece_uids = [wp.uid]
built.workpieces.append(wp)
built.steps.append(entry.step)
built.assets.append(source)
return built

def _command_step(self, entry: CommandEntry) -> Step:
step_cls: Any = step_registry.get("CommandStep")
if step_cls is None:
raise ValueError(_("Machine commands need the Automation addon."))
step = step_cls.create(self._context, name=entry.name)
step.set_command_text("\n".join(entry.lines))
return step

def _workpiece(
self, name: str, shape: Shape
) -> tuple[SourceAsset, WorkPiece]:
if isinstance(shape, ProceduralShape):
source, wp, target = self._procedural(name, shape)
elif isinstance(shape, FilledRect):
data = filled_rect_svg(shape.width, shape.height)
source, wp = self._import_svg(name, data)
target = (shape.x, shape.y)
else:
data = GeometrySvgExporter(shape.geometry).export()
source, wp = self._import_svg(name, data)
target = shape.geometry.rect()[:2]
source.name = name
source.hidden = True
wp.name = name
min_x, min_y, _max_x, _max_y = bounds_of([wp])
wp.pos = (
wp.pos[0] + target[0] - min_x,
wp.pos[1] + target[1] - min_y,
)
return source, wp

@staticmethod
def _import_svg(name: str, data: bytes) -> tuple[SourceAsset, WorkPiece]:
result = SvgImporter(data).get_doc_items(PassthroughSpec())
if not result or not result.payload or not result.payload.items:
raise RuntimeError(f"Could not create the {name} workpiece.")
wp = result.payload.items[0]
assert isinstance(wp, WorkPiece)
return result.payload.source, wp

@staticmethod
def _procedural(
name: str, shape: ProceduralShape
) -> tuple[SourceAsset, WorkPiece, tuple[float, float]]:
draw = shape.draw_function
size = shape.size_function
importer = ProceduralImporter(
drawing_function_path=f"{draw.__module__}.{draw.__name__}",
size_function_path=f"{size.__module__}.{size.__name__}",
params=shape.params,
name=name,
)
result = importer.get_doc_items(ProceduralSpec())
if not result or not result.payload or not result.payload.items:
raise RuntimeError(f"Could not create the {name} workpiece.")
wp = result.payload.items[0]
assert isinstance(wp, WorkPiece)
return result.payload.source, wp, shape.origin
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Settings and building blocks shared by the Interval and Focus Tests."""

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

from raygeo.geo import Geometry

from rayforge.core.varset import BoolVar, SliderFloatVar, SpeedVar, Var

from .generator import contour_step
from .layout import CellEntry, VectorShape

if TYPE_CHECKING:
from rayforge.context import RayforgeContext

#: Gap between a cell or line and its label, in label heights.
LABEL_GAP = 0.6
LABEL_HEIGHT = 2.5


def labels_active(values: dict[str, Any]) -> bool:
return bool(values.get("include_labels"))


def _speed_limit(context: "RayforgeContext") -> int | None:
machine = context.machine
return int(machine.max_cut_speed) if machine is not None else None


def _capped(speed: int, limit: int | None) -> int:
return speed if limit is None else min(speed, limit)


def laser_vars(
context: "RayforgeContext", power_percent: float, speed: int
) -> list[Var]:
"""Power and speed of the test, capped at the machine's speed."""
limit = _speed_limit(context)
return [
SliderFloatVar(
key="power_percent",
label=_("Power"),
default=power_percent,
min_val=0.0,
max_val=100.0,
show_value=True,
format_suffix="%",
),
SpeedVar(
key="speed",
label=_("Speed"),
default=_capped(speed, limit),
min_val=1,
max_val=limit,
),
]


def label_vars(context: "RayforgeContext") -> list[Var]:
limit = _speed_limit(context)
return [
BoolVar(
key="include_labels",
label=_("Include Labels"),
default=True,
),
SliderFloatVar(
key="label_power_percent",
label=_("Label Engrave Power (%)"),
default=10.0,
min_val=0.0,
max_val=100.0,
show_value=True,
format_suffix="%",
sensitive_when=labels_active,
),
SpeedVar(
key="label_speed",
label=_("Label Engrave Speed"),
default=_capped(1000, limit),
min_val=1,
max_val=limit,
sensitive_when=labels_active,
),
]


def label_block_height(params: dict[str, Any], lines: int) -> float:
"""Height reserved below the cells for *lines* lines of labels."""
if not params["include_labels"]:
return 0.0
return LABEL_HEIGHT * (LABEL_GAP + lines + (lines - 1) * 0.5)


def labels_cell(
context: "RayforgeContext", params: dict[str, Any], geometry: Geometry
) -> CellEntry:
"""All labels of a test as one workpiece with a low-power step."""
step = contour_step(
context, params["label_power_percent"], params["label_speed"]
)
step.name = _("Labels")
return CellEntry(_("Labels"), VectorShape(geometry), step)
Loading
Loading