diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 1de30b9..d9d2fb8 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -100,6 +100,9 @@ Subsequent offline evidence: - [JCZ 12-byte record spike](../research/rayforge-jcz-packet-spike.md): the external encoder deterministically packs controller-shaped command records matching the pinned BalorMK mock values, while retaining no transport. +- [Synthetic LightBurn MOPA import spike](../research/rayforge-lightburn-mopa-import-spike.md): + geometry and common settings survive, but frequency is not mapped to the + workflow and pulse-width representation still lacks fixture evidence. These results support continuing toward complete list buffering and production integration, but they do not yet satisfy every proof obligation or change this diff --git a/docs/research/rayforge-lightburn-mopa-import-spike.md b/docs/research/rayforge-lightburn-mopa-import-spike.md new file mode 100644 index 0000000..da24fa5 --- /dev/null +++ b/docs/research/rayforge-lightburn-mopa-import-spike.md @@ -0,0 +1,46 @@ +# Rayforge synthetic LightBurn MOPA import spike + +- Date: 2026-10-18 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Fixture: synthetic public `.lbrn2`; no machine identifiers +- Scope: importer boundary only, with no discovery or transport + +## Result + +The pinned Rayforge importer successfully parsed a synthetic two-millimetre +square and preserved its layer name, geometry, 50 percent power, 250 mm/s +speed and one pass. LightBurn's observed `frequency` and +`overrideFrequency` elements also survive in the source asset's raw metadata. + +However, the importer does not map frequency into the workflow layer settings. +It maps only power, speed and passes from this fixture. The inspected Rayforge +LightBurn fixtures and importer contain no evidence-backed mapping for a MOPA +pulse-width field. Consequently, the imported job is explicitly classified as +unsafe for JCZ encoding rather than being completed with a guessed value. + +Two runs produced identical output with SHA-256 +`9ebce7b5b3388abfeed024e25f8f46001260bf814da19808bfc83fb6554fc26e`. +The golden result is `tests/fixtures/rayforge-lightburn-mopa-import.json`. + +## Evidence classification + +- **Observed:** Rayforge's own LightBurn test fixtures contain `frequency` and + `overrideFrequency` elements. +- **Observed:** generic parsing retains these elements in source metadata. +- **Observed:** `_build_step_config` does not map frequency or pulse width. +- **Unknown:** the exact LightBurn field and unit for JPT MOPA pulse width in a + known-working galvo project. +- **Not inferred:** no pulse-width tag, unit, default or machine limit was + invented for the public fixture. + +## Decision impact + +Rayforge's existing LightBurn importer is useful for ordinary vector geometry +and common process settings, but it does not yet satisfy M1's MOPA parameter +survival requirement. The narrowest likely extension is an importer mapping +for frequency plus a separately evidenced pulse-width field, followed by an +explicit validation gate. A private known-working LightBurn project is still +needed to establish the pulse-width representation before implementing that +mapping. + +No USB enumeration, controller command, motion or emission was attempted. diff --git a/docs/roadmap.md b/docs/roadmap.md index e73fc0d..900e44b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -139,6 +139,14 @@ records whose opcodes, values, coordinates and distances match the pinned BalorMK mock evidence. It deliberately omits list buffering and all transport; controller-safe state ordering remains an explicit evidence gap. +[The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md) +confirms that Rayforge preserves geometry, power, speed and passes and retains +an observed LightBurn frequency value in raw source metadata. Frequency is not +mapped into workflow settings, and no evidence-backed pulse-width field was +found, so the job deliberately fails the JCZ encoding gate. A private +known-working galvo fixture is required before implementing the missing MOPA +mapping. + The architecture decision remains proposed until these spikes produce evidence. An independent app requires a documented gap analysis showing why neither existing platform can be extended responsibly. diff --git a/scripts/spike_rayforge_lightburn_import.py b/scripts/spike_rayforge_lightburn_import.py new file mode 100644 index 0000000..7b595cb --- /dev/null +++ b/scripts/spike_rayforge_lightburn_import.py @@ -0,0 +1,137 @@ +"""Inspect a synthetic LightBurn MOPA layer through pinned Rayforge. + +This script performs no discovery or device I/O. It records which observed +LightBurn values survive Rayforge's importer boundary without inventing a +pulse-width field that is absent from the inspected fixture evidence. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def run(source: Path, fixture: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != RAYFORGE_COMMIT: + raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}") + sys.path.insert(0, str(source)) + + from rayforge.core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, + ) + from rayforge.image.lightburn.importer import LightBurnImporter + + data = fixture.read_bytes() + importer = LightBurnImporter(data, source_file=fixture) + manifest = importer.scan() + parsed = importer.parse() + if parsed is None: + raise AssertionError("synthetic LightBurn fixture did not parse") + vectorized = importer.vectorize( + parsed, + PassthroughSpec(layer_import_mode=LayerImportMode.NEW_LAYERS), + ) + source_asset = importer.create_source_asset(parsed) + + raw = source_asset.metadata["lightburn_cut_settings"][ + "Synthetic MOPA layer" + ] + mapped = vectorized.layer_settings["0"] + geometry = vectorized.geometries_by_layer["0"] + bounds = [round(value, 6) for value in geometry.rect()] + + expected_raw = { + "maxPower": 50, + "speed": 250, + "frequency": 40000, + "overrideFrequency": 1, + } + if any(raw.get(key) != value for key, value in expected_raw.items()): + raise AssertionError("raw LightBurn values changed during import") + if mapped.get("power") != 0.5 or mapped.get("cut_speed") != 15000: + raise AssertionError("power or speed mapping changed") + if "frequency" in mapped or "pulse_width" in mapped: + raise AssertionError("MOPA mapping changed; re-evaluate this spike") + + return { + "rayforge_commit": commit, + "transport": None, + "fixture": fixture.name, + "fixture_provenance": "synthetic public data", + "manifest": { + "title": manifest.title, + "layers": [ + { + "id": layer.id, + "name": layer.name, + "feature_count": layer.feature_count, + } + for layer in manifest.layers + ], + "errors": [str(error) for error in manifest.errors], + }, + "geometry_bounds_mm": bounds, + "raw_cut_setting": { + key: raw[key] + for key in ( + "maxPower", + "speed", + "frequency", + "overrideFrequency", + "numPasses", + ) + }, + "mapped_layer_settings": mapped, + "mopa_survival": { + "frequency_preserved_in_source_metadata": raw["frequency"] == 40000, + "frequency_mapped_to_workflow": "frequency" in mapped, + "pulse_width_source_field_observed": False, + "pulse_width_mapped_to_workflow": "pulse_width" in mapped, + "safe_for_jcz_encoding": False, + }, + "conclusion": ( + "geometry, power, speed and passes survive; frequency remains only " + "in source metadata and no evidence-backed pulse-width field is mapped" + ), + } + + +def main() -> None: + root = Path(__file__).parents[1] + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned Rayforge checkout") + parser.add_argument( + "--fixture", + type=Path, + default=root / "tests" / "fixtures" / "synthetic-mopa-import.lbrn2", + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve(), args.fixture.resolve()) + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8", newline="\n") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/rayforge-lightburn-mopa-import.json b/tests/fixtures/rayforge-lightburn-mopa-import.json new file mode 100644 index 0000000..70a78a3 --- /dev/null +++ b/tests/fixtures/rayforge-lightburn-mopa-import.json @@ -0,0 +1,43 @@ +{ + "conclusion": "geometry, power, speed and passes survive; frequency remains only in source metadata and no evidence-backed pulse-width field is mapped", + "fixture": "synthetic-mopa-import.lbrn2", + "fixture_provenance": "synthetic public data", + "geometry_bounds_mm": [ + 54.0, + 54.0, + 56.0, + 56.0 + ], + "manifest": { + "errors": [], + "layers": [ + { + "feature_count": 0, + "id": "0", + "name": "Synthetic MOPA layer" + } + ], + "title": "synthetic-public-fixture" + }, + "mapped_layer_settings": { + "cut_speed": 15000, + "passes": 1, + "power": 0.5 + }, + "mopa_survival": { + "frequency_mapped_to_workflow": false, + "frequency_preserved_in_source_metadata": true, + "pulse_width_mapped_to_workflow": false, + "pulse_width_source_field_observed": false, + "safe_for_jcz_encoding": false + }, + "raw_cut_setting": { + "frequency": 40000, + "maxPower": 50, + "numPasses": 1, + "overrideFrequency": 1, + "speed": 250 + }, + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "transport": null +} diff --git a/tests/fixtures/synthetic-mopa-import.lbrn2 b/tests/fixtures/synthetic-mopa-import.lbrn2 new file mode 100644 index 0000000..720fda8 --- /dev/null +++ b/tests/fixtures/synthetic-mopa-import.lbrn2 @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + 1 0 0 1 55 55 + + + diff --git a/tests/test_rayforge_lightburn_import_spike.py b/tests/test_rayforge_lightburn_import_spike.py new file mode 100644 index 0000000..9b0414c --- /dev/null +++ b/tests/test_rayforge_lightburn_import_spike.py @@ -0,0 +1,38 @@ +import json +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET + + +ROOT = Path(__file__).parents[1] +RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-mopa-import.json" +SOURCE = ROOT / "tests" / "fixtures" / "synthetic-mopa-import.lbrn2" + + +class RayforgeLightBurnImportSpikeTests(unittest.TestCase): + def test_public_fixture_is_small_and_synthetic(self): + root = ET.fromstring(SOURCE.read_bytes()) + self.assertEqual("synthetic-public-fixture", root.get("AppVersion")) + self.assertEqual(1, len(root.findall("Shape"))) + self.assertNotIn("serial", SOURCE.read_text(encoding="utf-8").lower()) + + def test_observed_values_survive_raw_import(self): + result = json.loads(RESULT.read_text(encoding="utf-8")) + self.assertIsNone(result["transport"]) + self.assertEqual(40000, result["raw_cut_setting"]["frequency"]) + self.assertEqual(0.5, result["mapped_layer_settings"]["power"]) + self.assertEqual(15000, result["mapped_layer_settings"]["cut_speed"]) + self.assertEqual([54.0, 54.0, 56.0, 56.0], result["geometry_bounds_mm"]) + + def test_incomplete_mopa_import_fails_the_encoding_gate(self): + result = json.loads(RESULT.read_text(encoding="utf-8")) + survival = result["mopa_survival"] + self.assertTrue(survival["frequency_preserved_in_source_metadata"]) + self.assertFalse(survival["frequency_mapped_to_workflow"]) + self.assertFalse(survival["pulse_width_source_field_observed"]) + self.assertFalse(survival["pulse_width_mapped_to_workflow"]) + self.assertFalse(survival["safe_for_jcz_encoding"]) + + +if __name__ == "__main__": + unittest.main()