diff --git a/docs/research/rayforge-lightburn-device-import-spike.md b/docs/research/rayforge-lightburn-device-import-spike.md new file mode 100644 index 0000000..b258401 --- /dev/null +++ b/docs/research/rayforge-lightburn-device-import-spike.md @@ -0,0 +1,53 @@ +# Rayforge synthetic LightBurn JCZ device-import spike + +- Date: 2026-10-19 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Fixture: synthetic public `.lbdev`; no machine identifiers +- Scope: device-profile importer only, with no discovery or transport + +## Result + +Pinned Rayforge parses the synthetic `JCZFiber` profile and preserves its +display name, 110 x 110 mm work area and generic home-on-start value. It does +not select a driver because its LightBurn driver map has no `JCZFiber` entry. + +The imported `DeviceProfile` also has no representation of the observed +`RequireFrameBeforeStart` setting or the synthetic cut-list `frequency` and +`QPulseWidth` fields. The import is therefore explicitly classified as unsafe +for JCZ use rather than receiving guessed defaults. + +The checked result is +`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the +same SHA-256 digest, +`3c26f43be6b06e9b17a5decbe4ba7bb633eb1b8ac7bb0361449d7498a3b9279c`. + +## Evidence boundary + +The fixture uses field names already recorded by the sanitized LightBurn +device-export analysis. Its values are synthetic examples inside observed +ranges, not a copy of a private export and not certified machine limits or +material settings. It deliberately omits private GUIDs, source paths and +unpublished connection serialization. + +This spike characterizes the current importer; it does not implement a driver +mapping. A future `JCZFiber` mapping must target the separately tested +no-transport addon driver and require an exact compatible profile. It must not +silently map `JCZFiber` to a G-code or Ruida driver. + +## Reproduce + +Run against the pinned Rayforge checkout: + +```powershell +python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge +``` + +## Next seam + +The smallest evidence-backed extension is to recognize `JCZFiber` as a +distinct, fail-closed profile kind, preserve its raw settings with explicit +units/provenance, and associate it only with the external no-device JCZ driver. +Cut-list recipes and project-layer `QPulseWidth` mapping remain gated until a +known-working `.lbrn2` fixture establishes project serialization. + +No USB enumeration, controller command, motion, framing or emission occurred. diff --git a/docs/roadmap.md b/docs/roadmap.md index e4771e3..c1b94af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -276,6 +276,12 @@ The next work package should be small enough to review in a few commits: Hardware work cannot proceed until item 6 is reviewed and the physical setup is ready. +Item 2 is now covered by the +[synthetic LightBurn JCZ device-import spike](research/rayforge-lightburn-device-import-spike.md): +Rayforge preserves the common name and work-area fields, but maps no +`JCZFiber` driver and drops the JCZ-specific safety and MOPA fields from its +profile model. The fixture and regression test remain transport-free. + ## Decision rules - A simulator test precedes every equivalent hardware behavior. diff --git a/scripts/spike_rayforge_lightburn_device_import.py b/scripts/spike_rayforge_lightburn_device_import.py new file mode 100644 index 0000000..5348606 --- /dev/null +++ b/scripts/spike_rayforge_lightburn_device_import.py @@ -0,0 +1,111 @@ +"""Characterize a synthetic JCZ LightBurn device import in pinned Rayforge. + +The fixture contains only public, synthetic values. This spike performs no +device discovery or transport and deliberately does not add a JCZ driver. +""" + +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.machine.device.lightburn_importer import ( + convert_to_profile, + parse_lbdev, + ) + + raw = parse_lbdev(fixture) + profile, summary = convert_to_profile(fixture) + settings = raw["Settings"] + cut_entry = raw["CutList"][0] + + if raw["Type"] != "JCZFiber": + raise AssertionError("synthetic fixture is not a JCZFiber profile") + if profile.machine_config.axis_extents != (110.0, 110.0): + raise AssertionError("work area did not survive import") + if profile.machine_config.driver is not None or summary.driver is not None: + raise AssertionError("pinned Rayforge unexpectedly mapped a JCZ driver") + + return { + "rayforge_commit": commit, + "transport": None, + "fixture": fixture.name, + "fixture_provenance": "synthetic public data", + "raw_profile": { + "name": raw["DisplayName"], + "type": raw["Type"], + "work_area_mm": [raw["Width"], raw["Height"]], + "require_frame_before_start": settings[ + "RequireFrameBeforeStart" + ], + "cut_entry": { + "frequency": cut_entry["frequency"], + "QPulseWidth": cut_entry["QPulseWidth"], + }, + }, + "mapped_profile": { + "name": profile.meta.name, + "driver": profile.machine_config.driver, + "work_area_mm": list(profile.machine_config.axis_extents), + "home_on_start": profile.machine_config.home_on_start, + }, + "gap_matrix": { + "jczfiber_driver_mapped": profile.machine_config.driver is not None, + "require_frame_before_start_mapped": False, + "cut_list_mapped": False, + "frequency_mapped": False, + "q_pulse_width_mapped": False, + "safe_for_jcz_use": False, + }, + "conclusion": ( + "name and work area survive, but JCZFiber has no driver mapping; " + "RequireFrameBeforeStart, CutList, frequency and QPulseWidth are " + "not represented in the imported Rayforge profile" + ), + } + + +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-jcz-device.lbdev", + ) + 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-device-import.json b/tests/fixtures/rayforge-lightburn-device-import.json new file mode 100644 index 0000000..e78087f --- /dev/null +++ b/tests/fixtures/rayforge-lightburn-device-import.json @@ -0,0 +1,37 @@ +{ + "conclusion": "name and work area survive, but JCZFiber has no driver mapping; RequireFrameBeforeStart, CutList, frequency and QPulseWidth are not represented in the imported Rayforge profile", + "fixture": "synthetic-jcz-device.lbdev", + "fixture_provenance": "synthetic public data", + "gap_matrix": { + "cut_list_mapped": false, + "frequency_mapped": false, + "jczfiber_driver_mapped": false, + "q_pulse_width_mapped": false, + "require_frame_before_start_mapped": false, + "safe_for_jcz_use": false + }, + "mapped_profile": { + "driver": null, + "home_on_start": false, + "name": "Synthetic JCZ MOPA", + "work_area_mm": [ + 110.0, + 110.0 + ] + }, + "raw_profile": { + "cut_entry": { + "QPulseWidth": 100, + "frequency": 40000 + }, + "name": "Synthetic JCZ MOPA", + "require_frame_before_start": true, + "type": "JCZFiber", + "work_area_mm": [ + 110, + 110 + ] + }, + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "transport": null +} diff --git a/tests/fixtures/synthetic-jcz-device.lbdev b/tests/fixtures/synthetic-jcz-device.lbdev new file mode 100644 index 0000000..8439dd3 --- /dev/null +++ b/tests/fixtures/synthetic-jcz-device.lbdev @@ -0,0 +1,23 @@ +{ + "FixtureProvenance": "synthetic public data; not a machine export", + "DeviceList": [ + { + "DisplayName": "Synthetic JCZ MOPA", + "Name": "JCZ", + "Type": "JCZFiber", + "Width": 110, + "Height": 110, + "HomeOnStartup": false, + "Settings": { + "RequireFrameBeforeStart": true + }, + "CutList": [ + { + "Name": "Synthetic MOPA entry", + "frequency": 40000, + "QPulseWidth": 100 + } + ] + } + ] +} diff --git a/tests/test_rayforge_lightburn_device_import_spike.py b/tests/test_rayforge_lightburn_device_import_spike.py new file mode 100644 index 0000000..b1497c7 --- /dev/null +++ b/tests/test_rayforge_lightburn_device_import_spike.py @@ -0,0 +1,44 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-device-import.json" +SOURCE = ROOT / "tests" / "fixtures" / "synthetic-jcz-device.lbdev" + + +class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase): + def test_public_fixture_is_synthetic_and_contains_no_private_identity(self): + source = json.loads(SOURCE.read_text(encoding="utf-8")) + self.assertEqual( + "synthetic public data; not a machine export", + source["FixtureProvenance"], + ) + rendered = SOURCE.read_text(encoding="utf-8").lower() + self.assertNotIn("serial", rendered) + self.assertNotIn("guid", rendered) + + def test_common_profile_fields_survive(self): + result = json.loads(RESULT.read_text(encoding="utf-8")) + self.assertIsNone(result["transport"]) + self.assertEqual("JCZFiber", result["raw_profile"]["type"]) + self.assertEqual([110, 110], result["mapped_profile"]["work_area_mm"]) + self.assertEqual( + "Synthetic JCZ MOPA", result["mapped_profile"]["name"] + ) + + def test_jcz_specific_fields_fail_closed(self): + result = json.loads(RESULT.read_text(encoding="utf-8")) + gaps = result["gap_matrix"] + self.assertIsNone(result["mapped_profile"]["driver"]) + self.assertFalse(gaps["jczfiber_driver_mapped"]) + self.assertFalse(gaps["require_frame_before_start_mapped"]) + self.assertFalse(gaps["cut_list_mapped"]) + self.assertFalse(gaps["frequency_mapped"]) + self.assertFalse(gaps["q_pulse_width_mapped"]) + self.assertFalse(gaps["safe_for_jcz_use"]) + + +if __name__ == "__main__": + unittest.main()