Document Rayforge LightBurn MOPA import gap
This commit is contained in:
parent
4742099be8
commit
a63a4ee4a6
7 changed files with 292 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
46
docs/research/rayforge-lightburn-mopa-import-spike.md
Normal file
46
docs/research/rayforge-lightburn-mopa-import-spike.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
137
scripts/spike_rayforge_lightburn_import.py
Normal file
137
scripts/spike_rayforge_lightburn_import.py
Normal file
|
|
@ -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()
|
||||
43
tests/fixtures/rayforge-lightburn-mopa-import.json
vendored
Normal file
43
tests/fixtures/rayforge-lightburn-mopa-import.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
17
tests/fixtures/synthetic-mopa-import.lbrn2
vendored
Normal file
17
tests/fixtures/synthetic-mopa-import.lbrn2
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LightBurnProject AppVersion="synthetic-public-fixture" FormatVersion="1">
|
||||
<CutSetting type="Cut">
|
||||
<index Value="0"/>
|
||||
<name Value="Synthetic MOPA layer"/>
|
||||
<maxPower Value="50"/>
|
||||
<speed Value="250"/>
|
||||
<frequency Value="40000"/>
|
||||
<overrideFrequency Value="1"/>
|
||||
<numPasses Value="1"/>
|
||||
<doOutput Value="1"/>
|
||||
</CutSetting>
|
||||
<Shape Type="Rect" CutIndex="0" W="2" H="2" Cr="0">
|
||||
<XForm>1 0 0 1 55 55</XForm>
|
||||
</Shape>
|
||||
<Notes ShowOnLoad="0" Notes="Synthetic public fixture; no machine identifiers or settings."/>
|
||||
</LightBurnProject>
|
||||
38
tests/test_rayforge_lightburn_import_spike.py
Normal file
38
tests/test_rayforge_lightburn_import_spike.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue