From ceb70be30ef3480f439361eeb142f72f2c384f33 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 18:16:39 +0200 Subject: [PATCH] Validate MeerK40t hatch planning on mock spooler --- docs/adr/0002-reuse-rayforge-and-balormk.md | 3 + docs/research/meerk40t-hatch-cutplan-spike.md | 36 +++++ docs/roadmap.md | 11 +- scripts/spike_meerk40t_hatch_cutplan.py | 128 ++++++++++++++++++ tests/fixtures/meerk40t-hatch-cutplan.json | 40 ++++++ tests/test_meerk40t_mock_spike.py | 11 ++ 6 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 docs/research/meerk40t-hatch-cutplan-spike.md create mode 100644 scripts/spike_meerk40t_hatch_cutplan.py create mode 100644 tests/fixtures/meerk40t-hatch-cutplan.json diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index d9d2fb8..2295603 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -89,6 +89,9 @@ Subsequent offline evidence: - [MeerK40t mock MOPA spike](../research/meerk40t-mock-mopa-spike.md): deterministic controller-level frequency, pulse-width and square encoding; +- [MeerK40t hatch cut-plan and mock-spooler spike](../research/meerk40t-hatch-cutplan-spike.md): + stock planning and spooling preserve a synthetic hatch and MOPA settings on + forced mock transport; - [Rayforge addon spike](../research/rayforge-addon-spike.md): external binary driver load and lifecycle are viable, with a documented registry API gap. - [Rayforge-to-JCZ equivalence spike](../research/rayforge-jcz-equivalence-spike.md): diff --git a/docs/research/meerk40t-hatch-cutplan-spike.md b/docs/research/meerk40t-hatch-cutplan-spike.md new file mode 100644 index 0000000..16e80bb --- /dev/null +++ b/docs/research/meerk40t-hatch-cutplan-spike.md @@ -0,0 +1,36 @@ +# MeerK40t hatch cut-plan and mock-spooler spike + +- Date: 2026-10-18 +- MeerK40t commit: `d511944e08788e88077727f682957759884ff5ca` +- Scope: synthetic hatch through stock planning and mock spooling + +## Result + +A synthetic 2 mm square was assigned MeerK40t's default hatch effect and run +through the stock `copy`, `preprocess`, `validate`, `blob`, `preopt` and +`optimize` stages. The resulting plan contained one hatch mark line for this +small fixture and preserved 50 percent power, 250 mm/s speed, 40 kHz frequency +and 200 ns pulse width in the Balor command list. + +The same plan was submitted to the stock spooler. It completed using +`MockConnection`, and mock packets were observed. A PyUSB trap was installed +before MeerK40t imports; any USB discovery or disposal attempt would fail the +run. Two runs produced identical golden output with SHA-256 +`52c20ea7ef7221ff0321386e26e200dbec3b13d0223488b9d2e83236fd713f47`. + +## What this proves + +- Stock MeerK40t can turn a hatch effect into Balor mark geometry. +- MOPA frequency and pulse width survive the cut-plan and LaserJob boundary. +- The stock spooler can complete the synthetic job on forced mock transport. + +## Remaining limits + +- The fixture uses MeerK40t's default synthetic hatch configuration; it does + not establish a production hatch recipe or machine-safe parameter range. +- It does not test the graphical workflow, project import, correction files, + framing, cancellation, status or recovery. +- It establishes no compatibility with physical hardware. + +No USB enumeration, controller command, galvo motion or laser emission was +attempted. diff --git a/docs/roadmap.md b/docs/roadmap.md index 900e44b..c13fa6b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -107,9 +107,14 @@ The first work is entirely offline: The first controller-level part of the MeerK40t mock evaluation is recorded in [the mock MOPA spike](research/meerk40t-mock-mopa-spike.md). It confirms deterministic frequency, pulse-width and square-geometry command-list encoding -through BalorMK's forced mock transport. Full cut-plan/spooler and hatch -coverage remains open, so this does not yet complete the stock-application -evaluation or settle ADR 0002. +through BalorMK's forced mock transport. That first spike left cut-plan, +spooler and hatch coverage open and did not settle ADR 0002. + +[The hatch cut-plan and mock-spooler follow-up](research/meerk40t-hatch-cutplan-spike.md) +now confirms that stock planning turns a synthetic hatch into Balor geometry, +preserves frequency and pulse width, and completes through `MockConnection` +with USB access trapped. Graphical workflow, import, correction, cancellation +and recovery coverage remain open. The first Rayforge addon proof is recorded in [the external binary-driver addon spike](research/rayforge-addon-spike.md). diff --git a/scripts/spike_meerk40t_hatch_cutplan.py b/scripts/spike_meerk40t_hatch_cutplan.py new file mode 100644 index 0000000..f559191 --- /dev/null +++ b/scripts/spike_meerk40t_hatch_cutplan.py @@ -0,0 +1,128 @@ +"""Run a synthetic MOPA hatch through MeerK40t planning without hardware.""" + +from __future__ import annotations + +import argparse +import json +import struct +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from spike_meerk40t_mock_mopa import PINNED_COMMIT, _install_usb_trap + + +def _commit_of(source: Path) -> str: + return subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + + +def run(source: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != PINNED_COMMIT: + raise RuntimeError(f"expected MeerK40t {PINNED_COMMIT}, found {commit}") + _install_usb_trap() + sys.path.insert(0, str(source)) + from test import bootstrap + from meerk40t.balormk.mock_connection import MockConnection + + with tempfile.TemporaryDirectory() as temporary: + job_path = Path(temporary) / "synthetic-hatch.lmc" + kernel = bootstrap.bootstrap(profile="ThePirateRay_HATCH") + try: + kernel.console("service device start -i balor 0\n") + device = kernel.device + device.mock = True + device.pulse_width_enabled = True + device.default_pulse_width = 200 + kernel.console("rect 54mm 54mm 2mm 2mm\n") + kernel.console("operation* delete\n") + kernel.console("hatch\n") + operation = list(kernel.elements.ops())[0] + hatch_effect = operation.children[0] + if hatch_effect.type != "effect hatch": + raise AssertionError( + f"hatch command created unexpected node: {hatch_effect.type}" + ) + element = list(kernel.elements.elems())[0] + hatch_effect.add_reference(element) + operation.speed = 250.0 + operation.power = 500 + operation.frequency = 40.0 + operation.pulse_width_enabled = True + operation.pulse_width = 200 + kernel.console( + f'plan clear copy preprocess validate blob preopt optimize save_job "{job_path}"\n' + ) + saved = job_path.read_text(encoding="utf-8") + + controller = device.driver.connection + controller.connect_if_needed() + if not isinstance(controller.connection, MockConnection): + raise AssertionError("spooler did not select MockConnection") + packets: list[str] = [] + controller.connection.send = packets.append + controller.connection.set_implied_response( + struct.pack("<4H", 0, 0, 0, 0x20) + ) + kernel.console("plan spool\n") + deadline = time.monotonic() + 10.0 + while device.spooler.queue and time.monotonic() < deadline: + time.sleep(0.02) + if device.spooler.queue: + raise TimeoutError("mock spooler did not become idle") + finally: + kernel() + + lines = saved.splitlines() + mark_lines = [line for line in lines if line.startswith("listMarkTo")] + required = ("listQSwitchPeriod", "listFiberYLPMPulseWidth", "listEndOfList") + if any(not any(line.startswith(item) for line in lines) for item in required): + raise AssertionError("planned hatch lost required MOPA/list commands") + if not mark_lines: + raise AssertionError(f"planned output contains no mark geometry: {lines}") + return { + "upstream": "meerk40t/meerk40t", + "commit": commit, + "transport": "MockConnection", + "usb_access": "trapped; none attempted", + "input": { + "geometry": "synthetic 2 mm square with default hatch effect", + "power_per_mille": 500, + "speed_mm_per_s": 250.0, + "frequency_khz": 40.0, + "pulse_width_ns": 200, + }, + "cutplan": { + "command_count": len(lines), + "mark_count": len(mark_lines), + "contains_frequency": any("listQSwitchPeriod" in line for line in lines), + "contains_pulse_width": any("listFiberYLPMPulseWidth" in line for line in lines), + }, + "spooler": { + "completed": True, + "mock_packets_observed": bool(packets), + }, + "trace": lines, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve()) + rendered = json.dumps(result, indent=2) + "\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/meerk40t-hatch-cutplan.json b/tests/fixtures/meerk40t-hatch-cutplan.json new file mode 100644 index 0000000..bbf1fd3 --- /dev/null +++ b/tests/fixtures/meerk40t-hatch-cutplan.json @@ -0,0 +1,40 @@ +{ + "upstream": "meerk40t/meerk40t", + "commit": "d511944e08788e88077727f682957759884ff5ca", + "transport": "MockConnection", + "usb_access": "trapped; none attempted", + "input": { + "geometry": "synthetic 2 mm square with default hatch effect", + "power_per_mille": 500, + "speed_mm_per_s": 250.0, + "frequency_khz": 40.0, + "pulse_width_ns": 200 + }, + "cutplan": { + "command_count": 15, + "mark_count": 1, + "contains_frequency": true, + "contains_pulse_width": true + }, + "spooler": { + "completed": true, + "mock_packets_observed": true + }, + "trace": [ + "listReadyMark 0000 0000 0000 0000 0000", + "listDelayTime 0320 0000 0000 0000 0000", + "listWritePort 0001 0000 0000 0000 0000", + "listJumpSpeed 04a7 0000 0000 0000 0000", + "listFiberYLPMPulseWidth 00c8 0000 0000 0000 0000", + "listQSwitchPeriod 01f4 0000 0000 0000 0000", + "listMarkCurrent 0800 0000 0000 0000 0000", + "listMarkSpeed 0094 0000 0000 0000 0000", + "listLaserOnDelay 0064 0000 0000 0000 0000", + "listLaserOffDelay 0064 0000 0000 0000 0000", + "listPolygonDelay 000a 0000 0000 0000 0000", + "listJumpTo 7fff 8253 0000 0253 0000", + "listMarkTo 7fff 7dac 0000 04a7 0000", + "listDelayTime 001e 0000 0000 0000 0000", + "listEndOfList 0000 0000 0000 0000 0000" + ] +} diff --git a/tests/test_meerk40t_mock_spike.py b/tests/test_meerk40t_mock_spike.py index 7bd258b..f489271 100644 --- a/tests/test_meerk40t_mock_spike.py +++ b/tests/test_meerk40t_mock_spike.py @@ -9,6 +9,7 @@ from unittest.mock import patch SCRIPT = Path(__file__).parents[1] / "scripts" / "spike_meerk40t_mock_mopa.py" FIXTURE = Path(__file__).parent / "fixtures" / "meerk40t-mock-mopa-trace.json" +HATCH_FIXTURE = Path(__file__).parent / "fixtures" / "meerk40t-hatch-cutplan.json" SPEC = importlib.util.spec_from_file_location("spike_meerk40t_mock_mopa", SCRIPT) MODULE = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None @@ -16,6 +17,16 @@ SPEC.loader.exec_module(MODULE) class MockSpikeSafetyTests(unittest.TestCase): + def test_hatch_cutplan_and_spooler_stay_on_mock_transport(self): + result = json.loads(HATCH_FIXTURE.read_text(encoding="utf-8")) + self.assertEqual("MockConnection", result["transport"]) + self.assertEqual("trapped; none attempted", result["usb_access"]) + self.assertTrue(result["spooler"]["completed"]) + self.assertTrue(result["spooler"]["mock_packets_observed"]) + self.assertEqual(1, result["cutplan"]["mark_count"]) + self.assertTrue(result["cutplan"]["contains_frequency"]) + self.assertTrue(result["cutplan"]["contains_pulse_width"]) + def test_golden_trace_records_pin_mock_and_mopa_values(self): result = json.loads(FIXTURE.read_text(encoding="utf-8")) trace = "\n".join(result["trace"])