Stage imported MOPA settings in disabled Rayforge workflows

This commit is contained in:
Codex 2026-10-24 12:55:46 +02:00
parent 583a0d573a
commit be3fb933c1
9 changed files with 327 additions and 20 deletions

View file

@ -68,20 +68,23 @@ application until both gates below have real evidence:
reviewed build runner, verified without execution, and pass the manual
no-device desktop checklist.
2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
Import mapping still requires explicit unit conversion and fail-closed
handling of absent pulse width, with synthetic offline tests.
Offline mapping and disabled-output workflow staging now have synthetic
tests. Promotion still requires an independently established unit contract,
explicit resolution of absent pulse width and machine-context validation.
Raw projects, screenshots containing identifiers, machine data and build
binaries stay out of the public repository.
## Suggested first task
An offline project MOPA seam and synthetic tests now exist; see
An offline project MOPA seam, disabled-output Rayforge workflow bridge and
synthetic tests now exist; see
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
element types and requires an explicit unit contract for normalization. It is
not wired into Rayforge's workflow and never authorizes encoding. The next
import task is host integration with layer identity and independently
established units; absent pulse width and image equivalence remain blockers.
element types and requires an explicit unit contract for normalization. The
bridge binds source layer IDs, uses the host MOPA setters, serializes review
metadata and disables every staged step. It never authorizes encoding. The
next import task is UI/preflight presentation plus independently established
units; absent pulse width and image equivalence remain blockers.
If a Windows artifact is available, keep the machine disconnected and run, in
order:

View file

@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
`transport=None`, even with complete normalized values. This is a callable
offline metadata seam; it is not yet wired to Rayforge's project importer or
workflow. Host integration must bind settings to layer indices, establish
serialized units from independent evidence, resolve absent pulse width and
validate exact machine, lens, correction and parameter limits. No existing
golden lifecycle or upstream importer run was repeated; the package inventory
was extended to include the new module.
offline metadata seam. The companion workflow bridge matches the normalized
LightBurn layer index to Rayforge's source-segment layer ID and stages complete
vector MOPA values through the host's setters. Every staged step is disabled
through `Step.set_visible(False)`, and its raw values, normalized values,
review status and blockers are saved in the serializable `Step.extra` data.
Image settings and incomplete settings remain disabled without applying MOPA
values. Multi-step staging is preflighted and rolled back on setter failure.
The bridge deliberately has no function that enables output or removes a
blocker. Host UI and preflight integration must still establish serialized
units from independent evidence, resolve absent pulse width and validate exact
machine, lens, correction and parameter limits before a separate reviewed
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
included in the pinned upstream contract manifest.
Validation covers unit conversion, absent fields, case sensitivity, duplicate
values, invalid numbers, image modes, mixed layers and malformed XML. Existing
packaging, no-device and public evidence checks remain part of the test suite.
values, invalid numbers, image modes, mixed layers, exact layer binding,
transactional rollback and malformed XML. Existing packaging, no-device and
public evidence checks remain part of the test suite.

View file

@ -59,6 +59,7 @@ its manifest and sidecar; installer-only publication is not accepted.
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
| `Step.extra`, visibility serialization, MOPA setters | Imported host workflow interfaces | Used by the disabled-output import bridge; exact source files and symbols are pinned before host integration |
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |

View file

@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None:
return raw
def _layer_id(element: ET.Element) -> str | None:
children = element.findall("index")
if not children:
return None
if len(children) != 1 or set(children[0].attrib) != {"Value"}:
raise ProjectImportError("ambiguous layer index")
raw = children[0].get("Value", "")
if not raw.isdecimal():
raise ProjectImportError("invalid layer index")
return str(int(raw))
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units.
@ -51,6 +63,7 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format")
layers = []
layer_ids: set[str] = set()
for setting in root:
if setting.tag not in ("CutSetting", "CutSetting_Img"):
continue
@ -58,6 +71,11 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
if kind not in expected:
raise ProjectImportError("unsupported layer type")
layer_id = _layer_id(setting)
if layer_id is not None and layer_id in layer_ids:
raise ProjectImportError("duplicate layer index")
if layer_id is not None:
layer_ids.add(layer_id)
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
blockers = []
if unit_contract is None:
@ -78,7 +96,8 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
}
layers.append({"element": setting.tag, "type": kind, "raw": raw,
layers.append({"element": setting.tag, "type": kind,
"source_layer_id": layer_id, "raw": raw,
"image_mode": image_mode,
"normalized": normalized, "blockers": blockers})
if not layers:

View file

@ -0,0 +1,138 @@
"""Stage MOPA metadata on Rayforge workflow steps with output disabled."""
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from typing import Any
METADATA_KEY = "pirateray_import"
class WorkflowImportError(ValueError):
"""The imported layer cannot be bound safely to a Rayforge step."""
def _host_integer(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise WorkflowImportError(f"{label} must be numeric")
result = int(value)
if result <= 0 or result != value:
raise WorkflowImportError(f"{label} must be a positive integer")
return result
def stage_mopa_workflow_steps(
imported: Mapping[str, Any],
steps_by_layer_id: Mapping[str, Any],
) -> dict[str, Any]:
"""Bind complete imported MOPA settings while keeping every step disabled.
The mapping is constructed by the host integration from the source segment's
``layer_id`` to the single workflow step created by Rayforge's assembler.
This seam never enables a step and never removes an existing blocker.
"""
if imported.get("encoding_allowed") is not False:
raise WorkflowImportError("import must explicitly prohibit encoding")
if imported.get("hardware_use_allowed") is not False:
raise WorkflowImportError("import must explicitly prohibit hardware use")
layers = imported.get("layers")
if not isinstance(layers, list):
raise WorkflowImportError("imported layers are missing")
by_id: dict[str, Mapping[str, Any]] = {}
for layer in layers:
if not isinstance(layer, Mapping):
raise WorkflowImportError("imported layer must be an object")
layer_id = layer.get("source_layer_id")
if layer_id is None:
continue
if not isinstance(layer_id, str) or layer_id in by_id:
raise WorkflowImportError("imported layer identity is ambiguous")
by_id[layer_id] = layer
if not steps_by_layer_id:
raise WorkflowImportError("no Rayforge workflow steps were supplied")
global_blockers = imported.get("blockers")
if not isinstance(global_blockers, list) or not all(
isinstance(item, str) and item for item in global_blockers
):
raise WorkflowImportError("global blockers are missing")
plans = []
step_identities: set[int] = set()
for layer_id, step in steps_by_layer_id.items():
if not isinstance(layer_id, str):
raise WorkflowImportError("Rayforge layer ID must be text")
if id(step) in step_identities:
raise WorkflowImportError("one Rayforge step cannot represent multiple layers")
step_identities.add(id(step))
layer = by_id.get(layer_id)
if layer is None:
raise WorkflowImportError(f"no imported settings for layer {layer_id}")
if not isinstance(getattr(step, "extra", None), dict):
raise WorkflowImportError("Rayforge step has no serializable extra data")
if METADATA_KEY in step.extra:
raise WorkflowImportError("Rayforge step already has PirateRay import data")
if not hasattr(step, "visible") or not callable(
getattr(step, "set_visible", None)
):
raise WorkflowImportError("Rayforge step has no output visibility gate")
normalized = layer.get("normalized")
layer_blockers = layer.get("blockers")
if not isinstance(layer_blockers, list):
raise WorkflowImportError("layer blockers are missing")
apply_values = normalized is not None and not layer_blockers
frequency = pulse_width = None
if apply_values:
if not callable(getattr(step, "set_frequency", None)) or not callable(
getattr(step, "set_pulse_width", None)
):
raise WorkflowImportError("Rayforge step has no MOPA setters")
frequency = _host_integer(normalized.get("frequency_hz"), "frequency")
pulse_width = _host_integer(
normalized.get("pulse_width_ns"), "pulse width"
)
plans.append((layer_id, step, layer, frequency, pulse_width))
snapshots = [
(step, step.visible, deepcopy(step.extra), getattr(step, "frequency", None),
getattr(step, "pulse_width", None))
for _layer_id, step, _layer, _frequency, _pulse_width in plans
]
staged = []
try:
for layer_id, step, layer, frequency, pulse_width in plans:
step.set_visible(False)
blockers = list(global_blockers) + list(layer["blockers"])
if frequency is not None and pulse_width is not None:
step.set_frequency(frequency)
step.set_pulse_width(pulse_width)
step.extra[METADATA_KEY] = {
"status": "staged-review-required",
"source_layer_id": layer_id,
"raw": deepcopy(layer.get("raw")),
"normalized": deepcopy(layer.get("normalized")),
"blockers": blockers,
"encoding_allowed": False,
"hardware_use_allowed": False,
}
staged.append({
"source_layer_id": layer_id,
"parameters_applied": frequency is not None,
"output_enabled": False,
"blockers": blockers,
})
except Exception:
for step, visible, extra, frequency, pulse_width in snapshots:
step.set_visible(visible)
step.extra.clear()
step.extra.update(extra)
if frequency is not None:
step.frequency = frequency
if pulse_width is not None:
step.pulse_width = pulse_width
raise
return {"steps": staged, "encoding_allowed": False,
"hardware_use_allowed": False, "transport": None}

View file

@ -23,6 +23,7 @@
"pirate_ray_jcz/rayforge_compat.py",
"pirate_ray_jcz/recipe_metadata.py",
"pirate_ray_jcz/worker.py",
"pirate_ray_jcz/workflow_import.py",
"rayforge-addon.yaml"
],
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"

View file

@ -8,9 +8,9 @@ from pirate_ray_jcz.project_import import (
)
def project(fields, tag="CutSetting", kind="Scan"):
def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
f'{fields}</{tag}></LightBurnProject>').encode()
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase):
self.assertFalse(result["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"])
self.assertEqual(result["layers"][0]["source_layer_id"], "0")
def test_units_are_never_inferred_from_magnitude(self):
result = import_project_mopa(project(FIELDS))
@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase):
with self.assertRaises(ProjectImportError):
import_project_mopa(data)
def test_layer_indices_are_normalized_and_must_be_unique(self):
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
self.assertEqual(layer["source_layer_id"], "7")
duplicate = ('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode()
with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"):
import_project_mopa(duplicate)
def test_multiple_layers_preserve_order_and_missing_pulse(self):
data = ('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut">'+FIELDS+'</CutSetting>'
'<CutSetting_Img type="Image"><frequency Value="125000"/>'
'<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
'</CutSetting_Img></LightBurnProject>').encode()
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])

View file

@ -0,0 +1,113 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
from pirate_ray_jcz.project_import import UNIT_CONTRACT, import_project_mopa
from pirate_ray_jcz.workflow_import import (
METADATA_KEY, WorkflowImportError, stage_mopa_workflow_steps,
)
class Step:
def __init__(self, fail=False):
self.visible = True
self.frequency = 1
self.pulse_width = 2
self.extra = {"preserved": True}
self.fail = fail
def set_frequency(self, value):
self.frequency = value
def set_visible(self, value):
self.visible = value
def set_pulse_width(self, value):
if self.fail:
raise ValueError("synthetic setter failure")
self.pulse_width = value
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
class WorkflowImportTests(unittest.TestCase):
def test_complete_vector_settings_are_staged_but_output_is_disabled(self):
step = Step()
result = stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
self.assertEqual((step.frequency, step.pulse_width), (125000, 25))
self.assertFalse(step.visible)
self.assertFalse(result["encoding_allowed"])
self.assertIsNone(result["transport"])
gate = step.extra[METADATA_KEY]
self.assertEqual(gate["status"], "staged-review-required")
self.assertFalse(gate["encoding_allowed"])
self.assertTrue(gate["blockers"])
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
for result in (
imported('<frequency Value="125000"/>'),
imported(FIELDS + '<ditherMode Value="3dslice"/>', kind="Image", tag="CutSetting_Img"),
):
with self.subTest(result=result):
step = Step()
staged = stage_mopa_workflow_steps(result, {"3": step})
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
self.assertFalse(step.visible)
self.assertFalse(staged["steps"][0]["parameters_applied"])
def test_layer_identity_must_match(self):
with self.assertRaisesRegex(WorkflowImportError, "no imported settings"):
stage_mopa_workflow_steps(imported(FIELDS), {"4": Step()})
def test_existing_metadata_and_reused_step_are_rejected(self):
step = Step()
step.extra[METADATA_KEY] = {"status": "existing"}
with self.assertRaisesRegex(WorkflowImportError, "already has"):
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
two_layers = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
reused = Step()
with self.assertRaisesRegex(WorkflowImportError, "multiple layers"):
stage_mopa_workflow_steps(two_layers, {"1": reused, "2": reused})
def test_preflight_failure_does_not_mutate_any_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
first = Step()
with self.assertRaisesRegex(WorkflowImportError, "no serializable extra"):
stage_mopa_workflow_steps(result, {"1": first, "2": object()})
self.assertTrue(first.visible)
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
def test_setter_failure_rolls_back_every_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
first, second = Step(), Step(fail=True)
with self.assertRaisesRegex(ValueError, "synthetic setter failure"):
stage_mopa_workflow_steps(result, {"1": first, "2": second})
for step in (first, second):
self.assertTrue(step.visible)
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
self.assertEqual(step.extra, {"preserved": True})
if __name__ == "__main__":
unittest.main()

View file

@ -38,6 +38,18 @@
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
"stability": "host-lifecycle-implementation",
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
},
{
"path": "rayforge/core/step.py",
"sha256": "4d25da9e4fb9f854562bfd33d238db3660c3056eeadfaa95c8669c6770d504f4",
"stability": "imported-host-workflow-interface",
"required_symbols": ["Step", "Step.set_visible", "Step.to_dict", "Step.from_dict"]
},
{
"path": "rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py",
"sha256": "59c94fec6861edc94383db856bba0c335e0e7c4d16a878e7c2a2c62b657e06c5",
"stability": "imported-host-workflow-interface",
"required_symbols": ["LaserStep", "LaserStep.set_frequency", "LaserStep.set_pulse_width", "LaserStep.to_dict", "LaserStep.from_dict"]
}
],
"purpose": "Pinned host application for offline reuse and JCZ addon validation"