Add offline LightBurn MOPA project import seam with explicit units

This commit is contained in:
Codex 2026-10-24 12:51:12 +02:00
parent 7add1754cc
commit 583a0d573a
5 changed files with 212 additions and 0 deletions

View file

@ -76,6 +76,13 @@ binaries stay out of the public repository.
## Suggested first task ## Suggested first task
An offline project MOPA seam 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.
If a Windows artifact is available, keep the machine disconnected and run, in If a Windows artifact is available, keep the machine disconnected and run, in
order: order:

View file

@ -0,0 +1,32 @@
# Offline LightBurn project MOPA import seam
The addon's `project_import.py` reads observed `CutSetting` and
`CutSetting_Img` elements without loading Rayforge, geometry or any transport.
It preserves raw `frequency`, `QPulseWidth`, layer order and image dither mode.
The synthetic unit tests contain no private geometry or complete recipes.
Normalization requires the caller to declare the serialized-unit contract
`frequency=Hz;QPulseWidth=ns`. This is an explicit input, not an inference from
numeric magnitude or an authorization claim. With that contract, frequency is
represented in Hz and kHz (division by 1000), and pulse width in ns. Without
it, only raw values are returned. The seam does not establish the contract on
behalf of the caller or read machine limits from these values.
Absent fields remain absent, never zero or inherited defaults. Malformed,
non-finite, non-positive, duplicate and excessively large values are rejected.
The numeric ceiling is a parser bound, not a machine operating limit. Image
settings remain blocked pending image-processing equivalence, including
`3dslice`. XML declarations and unsupported project formats are rejected.
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.
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.

View file

@ -0,0 +1,89 @@
"""Transport-free LightBurn MOPA import seam; never authorizes encoding."""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
import xml.etree.ElementTree as ET
UNIT_CONTRACT = "frequency=Hz;QPulseWidth=ns"
class ProjectImportError(ValueError):
"""Ambiguous or malformed process metadata."""
def _number(element: ET.Element, field: str) -> str | None:
children = element.findall(field)
if not children:
return None
if len(children) != 1 or set(children[0].attrib) != {"Value"}:
raise ProjectImportError(f"ambiguous {field}")
raw = children[0].get("Value", "")
try:
value = Decimal(raw)
except InvalidOperation as exc:
raise ProjectImportError(f"invalid {field}") from exc
if not value.is_finite() or value <= 0 or value > Decimal("1e12"):
raise ProjectImportError(f"invalid {field}")
return raw
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units.
The caller must supply an independently established serialized-unit contract.
No unit is inferred from magnitude. Missing pulse width is never defaulted.
Results are offline metadata, not executable Rayforge workflow settings.
"""
if unit_contract not in (None, UNIT_CONTRACT):
raise ProjectImportError("unsupported serialized-unit contract")
if len(data) > 64 * 1024 * 1024:
raise ProjectImportError("project exceeds offline import size limit")
# Reject declarations, including UTF-16/32 encodings, before XML parsing.
probe = data.replace(b"\x00", b"").upper()
if b"<!DOCTYPE" in probe or b"<!ENTITY" in probe:
raise ProjectImportError("XML declarations are unsupported")
try:
root = ET.fromstring(data)
except ET.ParseError as exc:
raise ProjectImportError("invalid project XML") from exc
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format")
layers = []
for setting in root:
if setting.tag not in ("CutSetting", "CutSetting_Img"):
continue
kind = setting.get("type")
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
if kind not in expected:
raise ProjectImportError("unsupported layer type")
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
blockers = []
if unit_contract is None:
blockers.append("serialized units unresolved")
for key, value in raw.items():
if value is None:
blockers.append(f"{key} absent; explicit resolution required")
if kind == "Image":
blockers.append("image processing equivalence unverified")
modes = setting.findall("ditherMode")
if len(modes) > 1 or (modes and set(modes[0].attrib) != {"Value"}):
raise ProjectImportError("ambiguous image mode")
image_mode = modes[0].get("Value") if modes else None
normalized = None
if unit_contract and all(value is not None for value in raw.values()):
normalized = {
"frequency_hz": float(Decimal(raw["frequency"])),
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
}
layers.append({"element": setting.tag, "type": kind, "raw": raw,
"image_mode": image_mode,
"normalized": normalized, "blockers": blockers})
if not layers:
raise ProjectImportError("project has no process settings")
return {"layers": layers, "unit_contract": unit_contract, "transport": None,
"encoding_allowed": False, "hardware_use_allowed": False,
"blockers": ["machine identity, correction and limits unverified",
"offline import seam is not wired to host workflow"]}

View file

@ -19,6 +19,7 @@
"pirate_ray_jcz/__init__.py", "pirate_ray_jcz/__init__.py",
"pirate_ray_jcz/offline_session.py", "pirate_ray_jcz/offline_session.py",
"pirate_ray_jcz/profile_import.py", "pirate_ray_jcz/profile_import.py",
"pirate_ray_jcz/project_import.py",
"pirate_ray_jcz/rayforge_compat.py", "pirate_ray_jcz/rayforge_compat.py",
"pirate_ray_jcz/recipe_metadata.py", "pirate_ray_jcz/recipe_metadata.py",
"pirate_ray_jcz/worker.py", "pirate_ray_jcz/worker.py",

View file

@ -0,0 +1,83 @@
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 (
ProjectImportError, UNIT_CONTRACT, import_project_mopa,
)
def project(fields, tag="CutSetting", kind="Scan"):
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
f'{fields}</{tag}></LightBurnProject>').encode()
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
class ProjectMopaTests(unittest.TestCase):
def test_explicit_contract_converts_without_authorizing_execution(self):
result = import_project_mopa(project(FIELDS), unit_contract=UNIT_CONTRACT)
self.assertEqual(result["layers"][0]["normalized"], {
"frequency_hz": 125000, "frequency_khz": 125, "pulse_width_ns": 25,
})
self.assertFalse(result["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"])
def test_units_are_never_inferred_from_magnitude(self):
result = import_project_mopa(project(FIELDS))
layer = result["layers"][0]
self.assertIsNone(layer["normalized"])
self.assertEqual(layer["raw"]["frequency"], "125000")
self.assertIn("serialized units unresolved", layer["blockers"])
with self.assertRaises(ProjectImportError):
import_project_mopa(project(FIELDS), unit_contract="kHz/us")
def test_missing_fields_remain_absent(self):
for fields in ('<frequency Value="125000"/>', '<QPulseWidth Value="25"/>', ''):
with self.subTest(fields=fields):
layer = import_project_mopa(project(fields), unit_contract=UNIT_CONTRACT)["layers"][0]
self.assertIsNone(layer["normalized"])
self.assertTrue(layer["blockers"])
def test_image_settings_remain_blocked_even_with_complete_parameters(self):
fields = FIELDS + '<ditherMode Value="3dslice"/>'
layer = import_project_mopa(project(fields, "CutSetting_Img", "Image"), unit_contract=UNIT_CONTRACT)["layers"][0]
self.assertIn("image processing equivalence unverified", layer["blockers"])
self.assertEqual(layer["image_mode"], "3dslice")
def test_invalid_and_ambiguous_values_fail_closed(self):
for field in ("frequency", "QPulseWidth"):
for value in ("0", "-1", "NaN", "Infinity", "1e999", "", "abc"):
with self.subTest(field=field, value=value):
with self.assertRaises(ProjectImportError):
import_project_mopa(project(f'<{field} Value="{value}"/>'))
with self.assertRaises(ProjectImportError):
import_project_mopa(project(FIELDS + f'<{field} Value="2"/>'))
def test_wrong_case_is_absent_not_guessed(self):
layer = import_project_mopa(project(FIELDS.replace("QPulseWidth", "qpulsewidth")))["layers"][0]
self.assertIsNone(layer["raw"]["QPulseWidth"])
def test_bad_xml_formats_and_declarations_rejected(self):
for data in (b'<broken', b'<Other/>', b'<LightBurnProject FormatVersion="2"/>',
b'<!DOCTYPE x><LightBurnProject FormatVersion="1"/>',
project(FIELDS, kind="Unknown")):
with self.subTest(data=data):
with self.assertRaises(ProjectImportError):
import_project_mopa(data)
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_Img></LightBurnProject>').encode()
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
self.assertIsNone(layers[1]["normalized"])
if __name__ == "__main__":
unittest.main()