From 583a0d573a5bb37c922f44da04291ae8694c2e03 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 24 Oct 2026 12:51:12 +0200 Subject: [PATCH] Add offline LightBurn MOPA project import seam with explicit units --- docs/CODEX_HANDOFF.md | 7 ++ .../research/lightburn-project-mopa-import.md | 32 +++++++ .../pirate_ray_jcz/project_import.py | 89 +++++++++++++++++++ tests/fixtures/rayforge-addon-spike.json | 1 + tests/test_lightburn_project_mopa.py | 83 +++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 docs/research/lightburn-project-mopa-import.md create mode 100644 spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py create mode 100644 tests/test_lightburn_project_mopa.py diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 59b88ef..8de200e 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -76,6 +76,13 @@ binaries stay out of the public repository. ## 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 order: diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md new file mode 100644 index 0000000..5e7828b --- /dev/null +++ b/docs/research/lightburn-project-mopa-import.md @@ -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. diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py new file mode 100644 index 0000000..8b6a611 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py @@ -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" 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"]} diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index e0ea8f1..95b36dc 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -19,6 +19,7 @@ "pirate_ray_jcz/__init__.py", "pirate_ray_jcz/offline_session.py", "pirate_ray_jcz/profile_import.py", + "pirate_ray_jcz/project_import.py", "pirate_ray_jcz/rayforge_compat.py", "pirate_ray_jcz/recipe_metadata.py", "pirate_ray_jcz/worker.py", diff --git a/tests/test_lightburn_project_mopa.py b/tests/test_lightburn_project_mopa.py new file mode 100644 index 0000000..2984be3 --- /dev/null +++ b/tests/test_lightburn_project_mopa.py @@ -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'<{tag} type="{kind}">' + f'{fields}').encode() + + +FIELDS = '' + + +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 ('', '', ''): + 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 + '' + 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'', b'', + b'', + 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 = ('' + ''+FIELDS+'' + '' + '').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()