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
|
## 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:
|
||||||
|
|
||||||
|
|
|
||||||
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/__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",
|
||||||
|
|
|
||||||
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