Add offline LightBurn MOPA project import seam with explicit units
This commit is contained in:
parent
7add1754cc
commit
583a0d573a
5 changed files with 212 additions and 0 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
32
docs/research/lightburn-project-mopa-import.md
Normal file
32
docs/research/lightburn-project-mopa-import.md
Normal 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.
|
||||
|
|
@ -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"]}
|
||||
1
tests/fixtures/rayforge-addon-spike.json
vendored
1
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
83
tests/test_lightburn_project_mopa.py
Normal file
83
tests/test_lightburn_project_mopa.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue