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
|
- [JCZ 12-byte record spike](../research/rayforge-jcz-packet-spike.md): the
|
||||||
external encoder deterministically packs controller-shaped command records
|
external encoder deterministically packs controller-shaped command records
|
||||||
matching the pinned BalorMK mock values, while retaining no transport.
|
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
|
These results support continuing toward complete list buffering and production
|
||||||
integration, but they do not yet satisfy every proof obligation or change this
|
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;
|
BalorMK mock evidence. It deliberately omits list buffering and all transport;
|
||||||
controller-safe state ordering remains an explicit evidence gap.
|
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
|
The architecture decision remains proposed until these spikes produce
|
||||||
evidence. An independent app requires a documented gap analysis showing why
|
evidence. An independent app requires a documented gap analysis showing why
|
||||||
neither existing platform can be extended responsibly.
|
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